Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
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{})
}
88 changes: 88 additions & 0 deletions server/internal/parser/axis_parser_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
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())
})
})
})
Loading