Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions server/internal/parser/axis_credit_parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package parser
import (
"expenses/internal/models"
"expenses/pkg/utils"
"strings"
"time"

. "github.com/onsi/ginkgo/v2"
Expand Down Expand Up @@ -136,6 +137,45 @@ var _ = Describe("AxisCreditParser", func() {
Expect(err).To(HaveOccurred())
_, _, err = parser.parseAmount("abc123")
Expect(err).To(HaveOccurred())
_, _, err = parser.parseAmount("₹")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("empty amount after cleaning"))
})

It("truncates long description to 40 chars in transaction name", func() {
longDesc := strings.Repeat("LONGTEXT-", 6) + "COMPANYNAME"
row := []string{"01 Nov '25", longDesc, "", "₹ 1,234.00", "Debit"}
txn, err := parser.parseTransactionRow(row, 0, 1, 3, 4)
Expect(err).NotTo(HaveOccurred())
Expect(txn).NotTo(BeNil())
Expect(len(txn.Name)).To(Equal(40))
Expect(strings.HasSuffix(txn.Name, "...")).To(BeTrue())
})

It("returns error when amount string is not found in row", func() {
row := []string{"01 Nov '25", "ShopX", "", "", ""}
txn, err := parser.parseTransactionRow(row, 0, 1, 3, 4)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("amount not found"))
Expect(txn).To(BeNil())
})

It("returns nil for non-transaction/invalid rows", func() {
row := []string{"NotADate", "desc", "", "₹ 100.00", "Debit"}
txn, err := parser.parseTransactionRow(row, 0, 1, 3, 4)
Expect(err).To(BeNil())
Expect(txn).To(BeNil())
})

It("returns error when header exists but no valid transaction rows found", func() {
data := [][]string{
{"Date", "Transaction Details", "", "Amount (INR)", "Debit/Credit"},
{"", "", "", "", ""},
{"", "", "", "", ""},
}
b := utils.CreateXLSXFile(data)
_, err := parser.Parse(b, "", "test.xlsx", "")
Expect(err).To(MatchError("transaction header row not found in Axis credit statement"))
})
})

Expand Down
177 changes: 177 additions & 0 deletions server/internal/parser/axis_parser.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
package parser

import (
"bytes"
"encoding/csv"
"errors"
"fmt"
"regexp"
"strings"

"expenses/internal/models"
"expenses/pkg/logger"
"expenses/pkg/utils"
)

// AxisParser parses Axis bank account CSV statements
type AxisParser struct{}

// Precompiled regex patterns for Axis transaction description parsing.
// Compiling once improves performance when parsing many rows.
var axisPatterns = []struct {
regex *regexp.Regexp
creditName string
debitName string
}{
{regexp.MustCompile(`(?i)UPI/P2[AM]/\d+/([^/]+)/`), "UPI from $1", "UPI to $1"},
{regexp.MustCompile(`(?i)IMPS/P2A/\d+/([^/]+)/`), "IMPS from $1", "IMPS to $1"},
{regexp.MustCompile(`(?i)NEFT/([^/]+)`), "NEFT from $1", "NEFT to $1"},
{regexp.MustCompile(`(?i)RTGS/[^/]+/([^/]+)/`), "RTGS from $1", "RTGS to $1"},
{regexp.MustCompile(`(?i)INT\.PD|Int\.Pd`), "Interest", "Interest"},
}

func (p *AxisParser) Parse(fileBytes []byte, metadata string, fileName string, password string) ([]models.CreateTransactionInput, error) {
r := csv.NewReader(bytes.NewReader(fileBytes))
r.FieldsPerRecord = -1
// Bank CSVs can contain malformed quoting; be permissive
r.LazyQuotes = true
recs, err := r.ReadAll()
if err != nil {
return nil, fmt.Errorf("failed to read csv: %w", err)
}

// find header row
headerIdx := -1
for i, row := range recs {
if len(row) < 3 {
continue
}
joined := strings.ToLower(strings.Join(row, " "))
if strings.Contains(joined, "tran date") || (strings.Contains(joined, "particulars") && (strings.Contains(joined, "dr") || strings.Contains(joined, "cr"))) {
headerIdx = i
break
}
}

if headerIdx == -1 {
return nil, errors.New("transaction header row not found")
}

var transactions []models.CreateTransactionInput
for i := headerIdx + 1; i < len(recs); i++ {
row := recs[i]
// trim fields
for j := range row {
row[j] = strings.TrimSpace(row[j])
}

if len(row) < 6 {
logger.Debugf("Skipping row %d: expected at least 6 columns, got %d", i+1, len(row))
continue
}

txn, err := p.parseTransactionRow(row)
if err != nil {
logger.Warnf("Failed to parse row %d: %v", i+1, err)
continue
}
if txn != nil {
transactions = append(transactions, *txn)
}
}

return transactions, nil
}

func (p *AxisParser) parseTransactionRow(fields []string) (*models.CreateTransactionInput, error) {
if len(fields) < 6 {
return nil, errors.New("insufficient columns in row")
}

txnDateStr := strings.TrimSpace(fields[0])
description := strings.TrimSpace(fields[2])
debitStr := strings.TrimSpace(fields[3])
creditStr := strings.TrimSpace(fields[4])

if txnDateStr == "" {
return nil, errors.New("empty transaction date")
}

// Normalize common date separators (31-03-2025 -> 31/03/2025) so utils.ParseDate can handle it
txnDateStr = strings.ReplaceAll(txnDateStr, "-", "/")

txnDate, err := utils.ParseDate(txnDateStr)
if err != nil {
return nil, fmt.Errorf("failed to parse transaction date '%s': %w", txnDateStr, err)
}

var amount float64
var isCredit bool

if debitStr != "" {
val, err := utils.ParseFloat(debitStr)
if err != nil {
return nil, fmt.Errorf("failed to parse debit amount '%s': %w", debitStr, err)
}
amount = val
isCredit = false
} else if creditStr != "" {
val, err := utils.ParseFloat(creditStr)
if err != nil {
return nil, fmt.Errorf("failed to parse credit amount '%s': %w", creditStr, err)
}
amount = -val // credits (incoming) are represented as negative amounts
isCredit = true
} else {
return nil, errors.New("both debit and credit amounts are empty")
}

name := p.generateTransactionName(description, isCredit)

transaction := &models.CreateTransactionInput{
CreateBaseTransactionInput: models.CreateBaseTransactionInput{
Name: name,
Description: description,
Amount: &amount,
Date: txnDate,
},
CategoryIds: []int64{},
}
return transaction, nil
}

func (p *AxisParser) generateTransactionName(description string, isCredit bool) string {
desc := strings.TrimSpace(description)

// Use precompiled patterns for performance
for _, pattern := range axisPatterns {
if pattern.regex.MatchString(desc) {
if matches := pattern.regex.FindStringSubmatch(desc); len(matches) > 1 {
if isCredit {
return strings.Replace(pattern.creditName, "$1", strings.TrimSpace(matches[1]), 1)
}
return strings.Replace(pattern.debitName, "$1", strings.TrimSpace(matches[1]), 1)
}
// For patterns with no capture group (Interest), fallthrough
return pattern.creditName
}
}

prefix := ""
if isCredit {
prefix = "Credit: "
} else {
prefix = "Debit: "
}
n := prefix + desc
// Truncate safely for unicode by slicing runes
runes := []rune(n)
if len(runes) > 40 {
return strings.TrimSpace(string(runes[:37])) + "..."
}
return n
}

func init() {
RegisterParser(models.BankTypeAxis, &AxisParser{})
}
153 changes: 153 additions & 0 deletions server/internal/parser/axis_parser_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
package parser

import (
"math"
"strings"

. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)

var _ = Describe("AxisParser", func() {
var parser *AxisParser

BeforeEach(func() {
parser = &AxisParser{}
})

Describe("parseTransactionRow", func() {
It("should error on insufficient columns", func() {
fields := []string{"01-12-2025", "Short", "X"}
res, err := parser.parseTransactionRow(fields)
Expect(err).To(HaveOccurred())
Expect(res).To(BeNil())
})

It("should error on invalid date", func() {
fields := []string{"invalid", "-", "IMPS/XXX", "100.00", "", "1000.00"}
res, err := parser.parseTransactionRow(fields)
Expect(err).To(HaveOccurred())
Expect(res).To(BeNil())
})

It("should parse debit row", func() {
fields := []string{"31-03-2025", "-", "UPI/P2M/509038927105/Yes Bank Partner Sell/Accoun/YesBank_Yespay", "1.00", "", "131999.00"}
res, err := parser.parseTransactionRow(fields)
Expect(err).NotTo(HaveOccurred())
Expect(res).NotTo(BeNil())
Expect(*res.Amount).To(BeNumerically("==", 1.00))
Expect(res.Name).To(ContainSubstring("UPI to"))
})

It("should parse credit row", func() {
fields := []string{"31-03-2025", "-", "IMPS/P2A/509017158423/Nedungad/Remitter/salary/9177359940927139000", "", "132000.00", "132000.00"}
res, err := parser.parseTransactionRow(fields)
Expect(err).NotTo(HaveOccurred())
Expect(res).NotTo(BeNil())
Expect(*res.Amount).To(BeNumerically("==", -132000.00))
Expect(res.Name).To(ContainSubstring("IMPS from"))
})
})

Describe("Parse", func() {
It("should parse a synthetic axis csv and return multiple transactions without PII", func() {
// Build a synthetic CSV content (no PII) that mimics Axis statement structure
csvContent := `Name :- SAMPLE USER
Statement of Account No - 000000000000 for the period (From : 01-03-2025 To : 04-01-2026)

Tran Date,CHQNO,PARTICULARS,DR,CR,BAL,SOL
31-03-2025,-,IMPS/P2A/000000000000/SENDER/Remitter/salary/0000000000, ,132000.00,132000.00,4806
31-03-2025,-,UPI/P2M/000000000000/MERCHANT1/UPI/,1.00, ,131999.00,4806
01-04-2025,-,NEFT/ICIC0000001/ACME_CORP,5000.00, ,127010.00,4806
30-04-2025,-,RTGS/REF0001/LARGE_PAYMENT_BANK, ,223771.00,248108.82,248
30-05-2025,-,UPI/P2A/000000000001/CUSTOMER/UPI/,100000.00, ,316304.42,4806
`

fileBytes := []byte(csvContent)

txns, err := parser.Parse(fileBytes, "", "test.csv", "")
Expect(err).NotTo(HaveOccurred())
Expect(txns).ToNot(BeEmpty())

// ensure at least one large amount exists and patterns were recognized
foundLarge := false
foundUPI := false
for _, t := range txns {
if t.Amount != nil && math.Abs(*t.Amount) >= 100000 {
foundLarge = true
}
if strings.Contains(strings.ToLower(t.Description), "upi") || strings.Contains(strings.ToLower(t.Name), "upi") {
foundUPI = true
}
}
Expect(foundLarge).To(BeTrue())
Expect(foundUPI).To(BeTrue())
})

It("should return error for CSV without a recognizable header", func() {
csvContent := `random,values,only
31-03-2025,1,2,3,4,5`
fileBytes := []byte(csvContent)
txns, err := parser.Parse(fileBytes, "", "bad.csv", "")
Expect(err).To(HaveOccurred())
Expect(txns).To(BeNil())
})

It("should skip rows with fewer than 6 columns", func() {
csvContent := `Tran Date,CHQNO,PARTICULARS,DR,CR,BAL
31-03-2025,-,SHORT,1.00`
fileBytes := []byte(csvContent)
txns, err := parser.Parse(fileBytes, "", "short.csv", "")
Expect(err).NotTo(HaveOccurred())
Expect(txns).To(BeEmpty())
})

It("should handle very long descriptions by truncating the generated name", func() {
longDesc := strings.Repeat("LONGTEXT-", 10) + "COMPANY/EXTRA"
fields := []string{"31-03-2025", "-", longDesc, "10.00", "", "1000.00"}
res, err := parser.parseTransactionRow(fields)
Expect(err).NotTo(HaveOccurred())
Expect(res).NotTo(BeNil())
// Name should be prefixed with Debit and truncated to 40 runes (37 + "...")
Expect(strings.HasPrefix(res.Name, "Debit: ")).To(BeTrue())
Expect(strings.HasSuffix(res.Name, "...")).To(BeTrue())
Expect(len([]rune(res.Name))).To(Equal(40))
})

It("should parse different debit patterns (NEFT, RTGS) correctly", func() {
neft := []string{"31-03-2025", "-", "NEFT/ICIC0000001/ACME_CORP", "5000.00", "", "127010.00"}
res, err := parser.parseTransactionRow(neft)
Expect(err).NotTo(HaveOccurred())
Expect(res.Name).To(Equal("NEFT to ICIC0000001"))

rtgs := []string{"30-04-2025", "-", "RTGS/REF0001/LARGE_PAYMENT_BANK/", "1000.00", "", "248108.82"}
res2, err2 := parser.parseTransactionRow(rtgs)
Expect(err2).NotTo(HaveOccurred())
Expect(res2.Name).To(Equal("RTGS to LARGE_PAYMENT_BANK"))
})

It("should return error when transaction date is empty", func() {
fields := []string{"", "-", "NEFT/ICIC0000001/ACME_CORP", "5000.00", "", "127010.00"}
res, err := parser.parseTransactionRow(fields)
Expect(err).To(HaveOccurred())
Expect(res).To(BeNil())
Expect(err.Error()).To(ContainSubstring("empty transaction date"))
})

It("should error when credit is not a float", func() {
fields := []string{"31-03-2025", "-", "IMPS/P2A/509017158423/Example", "", "notanumber", "132000.00"}
res, err := parser.parseTransactionRow(fields)
Expect(err).To(HaveOccurred())
Expect(res).To(BeNil())
Expect(err.Error()).To(ContainSubstring("failed to parse credit amount"))
})

It("should error when both credit and debit are empty", func() {
fields := []string{"31-03-2025", "-", "IMPS/P2A/509017158423/Example", "", "", "132000.00"}
res, err := parser.parseTransactionRow(fields)
Expect(err).To(HaveOccurred())
Expect(res).To(BeNil())
Expect(err.Error()).To(ContainSubstring("both debit and credit amounts are empty"))
})
})
})
Loading