Skip to content

Commit aa1d981

Browse files
aodhan-domhnaillAidan Macdonald
andauthored
adding sample data and copying code from camt (#70)
add QFX parsing and import support. tests passing more testing remove sign change Co-authored-by: Aidan Macdonald <aidan@apmac.us>
1 parent 369581c commit aa1d981

4 files changed

Lines changed: 456 additions & 1 deletion

File tree

ledger/cmd/import.go

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"github.com/howeyc/ledger"
1414
"github.com/howeyc/ledger/decimal"
1515
"github.com/howeyc/ledger/ledger/camt"
16+
"github.com/howeyc/ledger/ledger/qfx"
1617
"github.com/jbrukh/bayesian"
1718
"github.com/spf13/cobra"
1819
)
@@ -222,6 +223,11 @@ func importCamt(accountSubstring, camtFileName string) {
222223
classifier := trainClassifier(generalLedger, matchingAccount)
223224

224225
entries, err := camt.ParseCamt(fileReader)
226+
if err != nil {
227+
fmt.Println("CAMT parse error:", err.Error())
228+
return
229+
}
230+
225231
expenseAccount := ledger.Account{Name: "unknown:unknown", Balance: decimal.Zero}
226232
camtAccount := ledger.Account{Name: matchingAccount, Balance: decimal.Zero}
227233
for _, entry := range entries {
@@ -280,6 +286,82 @@ func importCamt(accountSubstring, camtFileName string) {
280286
}
281287
}
282288

289+
func importQFX(accountSubstring, qfxFileName string) {
290+
decScale := decimal.NewFromFloat(scaleFactor)
291+
292+
fileReader, err := os.Open(qfxFileName)
293+
if err != nil {
294+
fmt.Println("QFX: ", err, qfxFileName)
295+
return
296+
}
297+
defer fileReader.Close()
298+
299+
generalLedger, parseError := ledger.ParseLedgerFile(ledgerFilePath)
300+
if parseError != nil {
301+
fmt.Printf("%s:%s\n", ledgerFilePath, parseError.Error())
302+
return
303+
}
304+
305+
matchingAccount, err := findMatchingAccount(generalLedger, accountSubstring)
306+
if err != nil {
307+
fmt.Println(err)
308+
return
309+
}
310+
311+
classifier := trainClassifier(generalLedger, matchingAccount)
312+
313+
entries, err := qfx.ParseQFX(fileReader)
314+
if err != nil {
315+
fmt.Println("QFX parse error:", err.Error())
316+
return
317+
}
318+
319+
expenseAccount := ledger.Account{Name: "unknown:unknown", Balance: decimal.Zero}
320+
qfxAccount := ledger.Account{Name: matchingAccount, Balance: decimal.Zero}
321+
for _, entry := range entries {
322+
// QFX DTPOSTED is typically YYYYMMDDHHMMSS.XXX; we only care about the date.
323+
// Take the first 8 characters as YYYYMMDD.
324+
dateStr := entry.DtPosted
325+
if len(dateStr) >= 8 {
326+
dateStr = dateStr[:8]
327+
}
328+
dateTime, err := time.Parse("20060102", dateStr)
329+
if err != nil {
330+
fmt.Println("QFX date parse error:", err.Error())
331+
continue
332+
}
333+
334+
// Parse amount
335+
amount, err := decimal.NewFromString(entry.TrnAmt)
336+
if err != nil {
337+
fmt.Println("QFX amount parse error:", err.Error())
338+
continue
339+
}
340+
341+
payee := entry.Memo
342+
inputPayeeWords := strings.Fields(payee)
343+
344+
expenseAccount.Name = predictAccount(classifier, inputPayeeWords)
345+
expenseAccount.Balance = amount
346+
347+
// Apply scale
348+
expenseAccount.Balance = expenseAccount.Balance.Mul(decScale)
349+
350+
// Account side is the opposite of expense
351+
qfxAccount.Balance = expenseAccount.Balance.Neg()
352+
353+
// Create valid transaction for print in ledger format
354+
trans := &ledger.Transaction{Date: dateTime, Payee: payee}
355+
trans.AccountChanges = []ledger.Account{qfxAccount, expenseAccount}
356+
357+
// Comment with FITID if present
358+
if entry.FitID != "" {
359+
trans.Comments = []string{";" + entry.FitID}
360+
}
361+
WriteTransaction(os.Stdout, trans, 80)
362+
}
363+
}
364+
283365
// importCmd represents the import command
284366
var importCmd = &cobra.Command{
285367
Use: "import <account-substring> <csv-file>",
@@ -289,8 +371,11 @@ var importCmd = &cobra.Command{
289371
accountSubstring := args[0]
290372
fileName := args[1]
291373

292-
if strings.HasSuffix(strings.ToLower(fileName), ".xml") {
374+
lower := strings.ToLower(fileName)
375+
if strings.HasSuffix(lower, ".xml") {
293376
importCamt(accountSubstring, fileName)
377+
} else if strings.HasSuffix(lower, ".qfx") || strings.HasSuffix(lower, ".ofx") {
378+
importQFX(accountSubstring, fileName)
294379
} else {
295380
importCSV(accountSubstring, fileName)
296381
}

ledger/qfx/qfx.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package qfx
2+
3+
import (
4+
"encoding/xml"
5+
"io"
6+
)
7+
8+
// QFX/OFX XML structures (simplified for bank statement transactions)
9+
10+
type OFX struct {
11+
BankMsgsRsV1 BankMsgsRsV1 `xml:"BANKMSGSRSV1"`
12+
}
13+
14+
type BankMsgsRsV1 struct {
15+
StmtTrnRs StmtTrnRs `xml:"STMTTRNRS"`
16+
}
17+
18+
type StmtTrnRs struct {
19+
StmtRs StmtRs `xml:"STMTRS"`
20+
}
21+
22+
type StmtRs struct {
23+
BankTranList BankTranList `xml:"BANKTRANLIST"`
24+
}
25+
26+
type BankTranList struct {
27+
StmtTrn []StmtTrn `xml:"STMTTRN"`
28+
}
29+
30+
type StmtTrn struct {
31+
TrnType string `xml:"TRNTYPE"`
32+
DtPosted string `xml:"DTPOSTED"`
33+
TrnAmt string `xml:"TRNAMT"`
34+
FitID string `xml:"FITID"`
35+
Memo string `xml:"MEMO"`
36+
}
37+
38+
// ParseQFX parses a QFX/OFX XML document and returns the list of statement
39+
// transactions contained in the first bank statement response.
40+
func ParseQFX(reader io.Reader) ([]StmtTrn, error) {
41+
var ofx OFX
42+
if err := xml.NewDecoder(reader).Decode(&ofx); err != nil {
43+
return nil, err
44+
}
45+
46+
return ofx.BankMsgsRsV1.StmtTrnRs.StmtRs.BankTranList.StmtTrn, nil
47+
}

ledger/qfx/qfx_test.go

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package qfx_test
2+
3+
import (
4+
"bytes"
5+
_ "embed"
6+
"testing"
7+
8+
"github.com/howeyc/ledger/ledger/qfx"
9+
)
10+
11+
//go:embed sample.qfx
12+
var qfxSample []byte
13+
14+
func TestParseQFX(t *testing.T) {
15+
entries, err := qfx.ParseQFX(bytes.NewBuffer(qfxSample))
16+
if err != nil {
17+
t.Fatal(err)
18+
}
19+
if len(entries) != 26 {
20+
t.Fatalf("Expected 26 entries, got %d", len(entries))
21+
}
22+
23+
// Spot-check a few transactions to ensure fields are parsed correctly.
24+
tests := []struct {
25+
index int
26+
trnType string
27+
dtPosted string
28+
trnAmt string
29+
fitID string
30+
memo string
31+
}{
32+
{
33+
index: 0,
34+
trnType: "CREDIT",
35+
dtPosted: "20251231000000.000",
36+
trnAmt: "0.13",
37+
fitID: "202512311",
38+
memo: "IOD INTEREST PAID",
39+
},
40+
{
41+
index: 6,
42+
trnType: "DEBIT",
43+
dtPosted: "20250829000000.000",
44+
trnAmt: "-30",
45+
fitID: "202508292",
46+
memo: "Minimum balance charge",
47+
},
48+
{
49+
index: 14,
50+
trnType: "DEBIT",
51+
dtPosted: "20250609000000.000",
52+
trnAmt: "-200",
53+
fitID: "202506091",
54+
memo: "ACH Withdrawal CAPITAL ONE",
55+
},
56+
{
57+
index: 21,
58+
trnType: "DEBIT",
59+
dtPosted: "20250219000000.000",
60+
trnAmt: "-620",
61+
fitID: "202502192",
62+
memo: "ACH Withdrawal",
63+
},
64+
{
65+
index: 25,
66+
trnType: "CREDIT",
67+
dtPosted: "20250123000000.000",
68+
trnAmt: "11892",
69+
fitID: "202501231",
70+
memo: "ACH deposit INTERACTIVE BROK ACH TRANSF",
71+
},
72+
}
73+
74+
for _, tt := range tests {
75+
if tt.index >= len(entries) {
76+
t.Fatalf("test index %d out of range, len(entries)=%d", tt.index, len(entries))
77+
}
78+
e := entries[tt.index]
79+
80+
if e.TrnType != tt.trnType {
81+
t.Errorf("entry %d: expected TrnType %q, got %q", tt.index, tt.trnType, e.TrnType)
82+
}
83+
if e.DtPosted != tt.dtPosted {
84+
t.Errorf("entry %d: expected DtPosted %q, got %q", tt.index, tt.dtPosted, e.DtPosted)
85+
}
86+
if e.TrnAmt != tt.trnAmt {
87+
t.Errorf("entry %d: expected TrnAmt %q, got %q", tt.index, tt.trnAmt, e.TrnAmt)
88+
}
89+
if e.FitID != tt.fitID {
90+
t.Errorf("entry %d: expected FitID %q, got %q", tt.index, tt.fitID, e.FitID)
91+
}
92+
if e.Memo != tt.memo {
93+
t.Errorf("entry %d: expected Memo %q, got %q", tt.index, tt.memo, e.Memo)
94+
}
95+
}
96+
}

0 commit comments

Comments
 (0)