Skip to content

Commit 2b5955d

Browse files
authored
feat: add parser for axis credit card (#130)
1 parent 0598a28 commit 2b5955d

File tree

6 files changed

+422
-2
lines changed

6 files changed

+422
-2
lines changed

frontend/components/custom/Modal/Accounts/AccountForm.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,9 @@ export function AccountForm({
8080
</SelectTrigger>
8181
<SelectContent>
8282
<SelectItem value="axis">Axis Bank</SelectItem>
83+
<SelectItem value="axis_credit">
84+
Axis Bank (Credit Card)
85+
</SelectItem>
8386
<SelectItem value="sbi">State Bank of India</SelectItem>
8487
<SelectItem value="hdfc">HDFC Bank</SelectItem>
8588
<SelectItem value="icici">ICICI Bank</SelectItem>

frontend/components/custom/Modal/Accounts/AddAccountModal.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,9 @@ export function AddAccountModal({
104104
</SelectTrigger>
105105
<SelectContent>
106106
<SelectItem value="axis">Axis Bank</SelectItem>
107+
<SelectItem value="axis_credit">
108+
Axis Bank (Credit Card)
109+
</SelectItem>
107110
<SelectItem value="sbi">State Bank of India</SelectItem>
108111
<SelectItem value="hdfc">HDFC Bank</SelectItem>
109112
<SelectItem value="icici">ICICI Bank</SelectItem>

frontend/lib/models/account.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export type BankType =
22
| "investment"
33
| "axis"
4+
| "axis_credit"
45
| "sbi"
56
| "hdfc"
67
| "icici"

server/internal/models/account.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ type BankType string
55
const (
66
BankTypeInvestment BankType = "investment"
77
BankTypeAxis BankType = "axis"
8+
BankTypeAxisCredit BankType = "axis_credit"
89
BankTypeSBI BankType = "sbi"
910
BankTypeHDFC BankType = "hdfc"
1011
BankTypeICICI BankType = "icici"
@@ -19,15 +20,15 @@ const (
1920

2021
type CreateAccountInput struct {
2122
Name string `json:"name" binding:"required"`
22-
BankType BankType `json:"bank_type" binding:"required,oneof=investment axis sbi hdfc icici icici_credit others"`
23+
BankType BankType `json:"bank_type" binding:"required,oneof=investment axis axis_credit sbi hdfc icici icici_credit others"`
2324
Currency string `json:"currency" binding:"required,oneof=inr usd"`
2425
Balance *float64 `json:"balance"`
2526
CreatedBy int64 `json:"created_by" binding:"required"`
2627
}
2728

2829
type UpdateAccountInput struct {
2930
Name string `json:"name,omitempty"`
30-
BankType BankType `json:"bank_type,omitempty" binding:"omitempty,oneof=investment axis sbi hdfc icici icici_credit others"`
31+
BankType BankType `json:"bank_type,omitempty" binding:"omitempty,oneof=investment axis axis_credit sbi hdfc icici icici_credit others"`
3132
Currency string `json:"currency,omitempty" binding:"omitempty,oneof=inr usd"`
3233
Balance *float64 `json:"balance,omitempty"`
3334
}
Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
package parser
2+
3+
import (
4+
"bytes"
5+
"errors"
6+
"fmt"
7+
"math"
8+
"regexp"
9+
"strconv"
10+
"strings"
11+
12+
"expenses/internal/models"
13+
"expenses/pkg/logger"
14+
"expenses/pkg/utils"
15+
16+
"github.com/xuri/excelize/v2"
17+
)
18+
19+
// AxisCreditParser parses Axis credit card statements exported as XLSX
20+
type AxisCreditParser struct{}
21+
22+
func (p *AxisCreditParser) Parse(fileBytes []byte, metadata string, fileName string, password string) ([]models.CreateTransactionInput, error) {
23+
f, err := excelize.OpenReader(bytes.NewReader(fileBytes))
24+
if err != nil {
25+
return nil, fmt.Errorf("failed to open xlsx: %w", err)
26+
}
27+
defer func() {
28+
_ = f.Close()
29+
}()
30+
31+
for _, sheet := range f.GetSheetList() {
32+
rows, err := f.GetRows(sheet)
33+
if err != nil {
34+
continue
35+
}
36+
37+
headerIndex := -1
38+
var dateIdx, descIdx, amountIdx, signIdx int
39+
for i, row := range rows {
40+
if len(row) == 0 {
41+
continue
42+
}
43+
joined := strings.ToLower(strings.Join(row, " "))
44+
if strings.Contains(joined, "date") && strings.Contains(joined, "amount") && (strings.Contains(joined, "debit") || strings.Contains(joined, "credit") || strings.Contains(joined, "debit/credit")) {
45+
headerIndex = i
46+
// determine column indexes
47+
dateIdx, descIdx, amountIdx, signIdx = -1, -1, -1, -1
48+
for idx, cell := range row {
49+
lc := strings.ToLower(strings.TrimSpace(cell))
50+
if dateIdx == -1 && strings.Contains(lc, "date") {
51+
dateIdx = idx
52+
}
53+
if descIdx == -1 && (strings.Contains(lc, "transaction") || strings.Contains(lc, "transaction details") || strings.Contains(lc, "details")) {
54+
descIdx = idx
55+
}
56+
if amountIdx == -1 && strings.Contains(lc, "amount") {
57+
amountIdx = idx
58+
}
59+
if signIdx == -1 && (strings.Contains(lc, "debit") || strings.Contains(lc, "credit")) {
60+
signIdx = idx
61+
}
62+
}
63+
break
64+
}
65+
}
66+
67+
if headerIndex == -1 {
68+
// try next sheet
69+
continue
70+
}
71+
72+
var transactions []models.CreateTransactionInput
73+
for i := headerIndex + 1; i < len(rows); i++ {
74+
row := rows[i]
75+
// Skip empty rows
76+
if len(row) == 0 {
77+
continue
78+
}
79+
80+
// Normalize cell slice to avoid index issues
81+
for len(row) <= descIdx && len(row) < 5 {
82+
row = append(row, "")
83+
}
84+
85+
txn, err := p.parseTransactionRow(row, dateIdx, descIdx, amountIdx, signIdx)
86+
if err != nil {
87+
logger.Warnf("Failed to parse row %d in sheet %s: %v", i+1, sheet, err)
88+
continue
89+
}
90+
if txn != nil {
91+
transactions = append(transactions, *txn)
92+
}
93+
}
94+
95+
if len(transactions) > 0 {
96+
return transactions, nil
97+
}
98+
}
99+
100+
return nil, errors.New("transaction header row not found in Axis credit statement")
101+
}
102+
103+
func (p *AxisCreditParser) parseTransactionRow(row []string, dateIdx int, descIdx int, amountIdx int, signIdx int) (*models.CreateTransactionInput, error) {
104+
// Validate indexes
105+
if dateIdx < 0 || descIdx < 0 {
106+
return nil, nil
107+
}
108+
109+
get := func(idx int) string {
110+
if idx >= 0 && idx < len(row) {
111+
return strings.TrimSpace(row[idx])
112+
}
113+
return ""
114+
}
115+
116+
dateStr := get(dateIdx)
117+
if dateStr == "" || strings.EqualFold(dateStr, "date") {
118+
return nil, nil
119+
}
120+
121+
// Normalize two-digit year like 14 Nov '25 -> 14 Nov 2025
122+
re := regexp.MustCompile(`'(?P<yy>\d{2})`)
123+
dateStr = re.ReplaceAllString(dateStr, "20$1")
124+
125+
txnDate, err := utils.ParseDate(dateStr)
126+
if err != nil {
127+
// Not a valid transaction row
128+
return nil, nil
129+
}
130+
131+
desc := get(descIdx)
132+
// If description is empty, try next available column
133+
if desc == "" {
134+
if len(row) > descIdx+1 {
135+
desc = strings.TrimSpace(row[descIdx+1])
136+
}
137+
}
138+
139+
amountStr := get(amountIdx)
140+
var inferredSign string
141+
var amount float64
142+
// If amount not found in configured column, try to find numeric cell after description
143+
if amountStr == "" {
144+
for j := descIdx + 1; j < len(row); j++ {
145+
cell := strings.TrimSpace(row[j])
146+
if cell == "" {
147+
continue
148+
}
149+
if v, sgn, err := p.parseAmount(cell); err == nil {
150+
amountStr = cell
151+
inferredSign = sgn
152+
amount = v
153+
break
154+
}
155+
}
156+
}
157+
158+
if amountStr == "" {
159+
return nil, fmt.Errorf("amount not found for row: %v", row)
160+
}
161+
162+
// Try parse amount from amountStr if not already parsed
163+
if amount == 0 {
164+
v, sgn, err := p.parseAmount(amountStr)
165+
if err != nil {
166+
return nil, fmt.Errorf("failed to parse amount '%s': %w", amountStr, err)
167+
}
168+
amount = v
169+
if inferredSign == "" {
170+
inferredSign = sgn
171+
}
172+
}
173+
174+
// sign handling: prefer explicit sign column; fall back to inferred sign
175+
sign := strings.ToLower(get(signIdx))
176+
if sign != "" {
177+
if strings.Contains(sign, "debit") || strings.Contains(sign, "dr") {
178+
amount = math.Abs(amount)
179+
} else if strings.Contains(sign, "credit") || strings.Contains(sign, "cr") {
180+
amount = -math.Abs(amount)
181+
}
182+
} else {
183+
switch inferredSign {
184+
case "debit":
185+
amount = math.Abs(amount)
186+
case "credit":
187+
amount = -math.Abs(amount)
188+
}
189+
}
190+
191+
name := strings.TrimSpace(desc)
192+
if len(name) > 40 {
193+
name = name[:37] + "..."
194+
}
195+
196+
txn := &models.CreateTransactionInput{
197+
CreateBaseTransactionInput: models.CreateBaseTransactionInput{
198+
Name: name,
199+
Description: desc,
200+
Amount: &amount,
201+
Date: txnDate,
202+
},
203+
CategoryIds: []int64{},
204+
}
205+
return txn, nil
206+
}
207+
208+
// parseAmount cleans the raw amount cell and returns numeric value and inferred sign ("debit" or "credit") if detectable.
209+
func (p *AxisCreditParser) parseAmount(raw string) (float64, string, error) {
210+
s := strings.TrimSpace(raw)
211+
if s == "" {
212+
return 0, "", fmt.Errorf("empty amount")
213+
}
214+
215+
// Replace non-breaking spaces
216+
s = strings.ReplaceAll(s, "\u00A0", " ")
217+
lower := strings.ToLower(s)
218+
inferred := ""
219+
220+
// Parentheses indicate negative amount (treat as credit/payment)
221+
if strings.Contains(s, "(") && strings.Contains(s, ")") {
222+
// don't make the numeric value negative here; infer sign as credit
223+
inferred = "credit"
224+
}
225+
226+
// Trailing CR/DR indicators
227+
if strings.HasSuffix(strings.TrimSpace(lower), "cr") {
228+
inferred = "credit"
229+
s = strings.TrimSpace(s[:len(s)-2])
230+
} else if strings.HasSuffix(strings.TrimSpace(lower), "dr") {
231+
inferred = "debit"
232+
s = strings.TrimSpace(s[:len(s)-2])
233+
}
234+
// Remove currency symbols and words
235+
replacements := []string{"₹", "rs.", "rs", "inr"}
236+
for _, r := range replacements {
237+
s = strings.ReplaceAll(strings.ToLower(s), r, "")
238+
}
239+
240+
// Remove commas, spaces and parentheses
241+
s = strings.ReplaceAll(s, ",", "")
242+
s = strings.ReplaceAll(s, " ", "")
243+
s = strings.ReplaceAll(s, "(", "")
244+
s = strings.ReplaceAll(s, ")", "")
245+
246+
if s == "" {
247+
return 0, inferred, fmt.Errorf("empty amount after cleaning")
248+
}
249+
250+
v, err := strconv.ParseFloat(s, 64)
251+
if err != nil {
252+
return 0, inferred, err
253+
}
254+
255+
// If parentheses were present we already set inferred to credit; keep value positive
256+
return v, inferred, nil
257+
}
258+
259+
func init() {
260+
RegisterParser(models.BankTypeAxisCredit, &AxisCreditParser{})
261+
}

0 commit comments

Comments
 (0)