diff --git a/ledger/cmd/import.go b/ledger/cmd/import.go index 52e50a6..cea3ae8 100644 --- a/ledger/cmd/import.go +++ b/ledger/cmd/import.go @@ -13,6 +13,7 @@ import ( "github.com/howeyc/ledger" "github.com/howeyc/ledger/decimal" "github.com/howeyc/ledger/ledger/camt" + "github.com/howeyc/ledger/ledger/qfx" "github.com/jbrukh/bayesian" "github.com/spf13/cobra" ) @@ -222,6 +223,11 @@ func importCamt(accountSubstring, camtFileName string) { classifier := trainClassifier(generalLedger, matchingAccount) entries, err := camt.ParseCamt(fileReader) + if err != nil { + fmt.Println("CAMT parse error:", err.Error()) + return + } + expenseAccount := ledger.Account{Name: "unknown:unknown", Balance: decimal.Zero} camtAccount := ledger.Account{Name: matchingAccount, Balance: decimal.Zero} for _, entry := range entries { @@ -280,6 +286,82 @@ func importCamt(accountSubstring, camtFileName string) { } } +func importQFX(accountSubstring, qfxFileName string) { + decScale := decimal.NewFromFloat(scaleFactor) + + fileReader, err := os.Open(qfxFileName) + if err != nil { + fmt.Println("QFX: ", err, qfxFileName) + return + } + defer fileReader.Close() + + generalLedger, parseError := ledger.ParseLedgerFile(ledgerFilePath) + if parseError != nil { + fmt.Printf("%s:%s\n", ledgerFilePath, parseError.Error()) + return + } + + matchingAccount, err := findMatchingAccount(generalLedger, accountSubstring) + if err != nil { + fmt.Println(err) + return + } + + classifier := trainClassifier(generalLedger, matchingAccount) + + entries, err := qfx.ParseQFX(fileReader) + if err != nil { + fmt.Println("QFX parse error:", err.Error()) + return + } + + expenseAccount := ledger.Account{Name: "unknown:unknown", Balance: decimal.Zero} + qfxAccount := ledger.Account{Name: matchingAccount, Balance: decimal.Zero} + for _, entry := range entries { + // QFX DTPOSTED is typically YYYYMMDDHHMMSS.XXX; we only care about the date. + // Take the first 8 characters as YYYYMMDD. + dateStr := entry.DtPosted + if len(dateStr) >= 8 { + dateStr = dateStr[:8] + } + dateTime, err := time.Parse("20060102", dateStr) + if err != nil { + fmt.Println("QFX date parse error:", err.Error()) + continue + } + + // Parse amount + amount, err := decimal.NewFromString(entry.TrnAmt) + if err != nil { + fmt.Println("QFX amount parse error:", err.Error()) + continue + } + + payee := entry.Memo + inputPayeeWords := strings.Fields(payee) + + expenseAccount.Name = predictAccount(classifier, inputPayeeWords) + expenseAccount.Balance = amount + + // Apply scale + expenseAccount.Balance = expenseAccount.Balance.Mul(decScale) + + // Account side is the opposite of expense + qfxAccount.Balance = expenseAccount.Balance.Neg() + + // Create valid transaction for print in ledger format + trans := &ledger.Transaction{Date: dateTime, Payee: payee} + trans.AccountChanges = []ledger.Account{qfxAccount, expenseAccount} + + // Comment with FITID if present + if entry.FitID != "" { + trans.Comments = []string{";" + entry.FitID} + } + WriteTransaction(os.Stdout, trans, 80) + } +} + // importCmd represents the import command var importCmd = &cobra.Command{ Use: "import ", @@ -289,8 +371,11 @@ var importCmd = &cobra.Command{ accountSubstring := args[0] fileName := args[1] - if strings.HasSuffix(strings.ToLower(fileName), ".xml") { + lower := strings.ToLower(fileName) + if strings.HasSuffix(lower, ".xml") { importCamt(accountSubstring, fileName) + } else if strings.HasSuffix(lower, ".qfx") || strings.HasSuffix(lower, ".ofx") { + importQFX(accountSubstring, fileName) } else { importCSV(accountSubstring, fileName) } diff --git a/ledger/qfx/qfx.go b/ledger/qfx/qfx.go new file mode 100644 index 0000000..f670dff --- /dev/null +++ b/ledger/qfx/qfx.go @@ -0,0 +1,47 @@ +package qfx + +import ( + "encoding/xml" + "io" +) + +// QFX/OFX XML structures (simplified for bank statement transactions) + +type OFX struct { + BankMsgsRsV1 BankMsgsRsV1 `xml:"BANKMSGSRSV1"` +} + +type BankMsgsRsV1 struct { + StmtTrnRs StmtTrnRs `xml:"STMTTRNRS"` +} + +type StmtTrnRs struct { + StmtRs StmtRs `xml:"STMTRS"` +} + +type StmtRs struct { + BankTranList BankTranList `xml:"BANKTRANLIST"` +} + +type BankTranList struct { + StmtTrn []StmtTrn `xml:"STMTTRN"` +} + +type StmtTrn struct { + TrnType string `xml:"TRNTYPE"` + DtPosted string `xml:"DTPOSTED"` + TrnAmt string `xml:"TRNAMT"` + FitID string `xml:"FITID"` + Memo string `xml:"MEMO"` +} + +// ParseQFX parses a QFX/OFX XML document and returns the list of statement +// transactions contained in the first bank statement response. +func ParseQFX(reader io.Reader) ([]StmtTrn, error) { + var ofx OFX + if err := xml.NewDecoder(reader).Decode(&ofx); err != nil { + return nil, err + } + + return ofx.BankMsgsRsV1.StmtTrnRs.StmtRs.BankTranList.StmtTrn, nil +} diff --git a/ledger/qfx/qfx_test.go b/ledger/qfx/qfx_test.go new file mode 100644 index 0000000..52ab7f9 --- /dev/null +++ b/ledger/qfx/qfx_test.go @@ -0,0 +1,96 @@ +package qfx_test + +import ( + "bytes" + _ "embed" + "testing" + + "github.com/howeyc/ledger/ledger/qfx" +) + +//go:embed sample.qfx +var qfxSample []byte + +func TestParseQFX(t *testing.T) { + entries, err := qfx.ParseQFX(bytes.NewBuffer(qfxSample)) + if err != nil { + t.Fatal(err) + } + if len(entries) != 26 { + t.Fatalf("Expected 26 entries, got %d", len(entries)) + } + + // Spot-check a few transactions to ensure fields are parsed correctly. + tests := []struct { + index int + trnType string + dtPosted string + trnAmt string + fitID string + memo string + }{ + { + index: 0, + trnType: "CREDIT", + dtPosted: "20251231000000.000", + trnAmt: "0.13", + fitID: "202512311", + memo: "IOD INTEREST PAID", + }, + { + index: 6, + trnType: "DEBIT", + dtPosted: "20250829000000.000", + trnAmt: "-30", + fitID: "202508292", + memo: "Minimum balance charge", + }, + { + index: 14, + trnType: "DEBIT", + dtPosted: "20250609000000.000", + trnAmt: "-200", + fitID: "202506091", + memo: "ACH Withdrawal CAPITAL ONE", + }, + { + index: 21, + trnType: "DEBIT", + dtPosted: "20250219000000.000", + trnAmt: "-620", + fitID: "202502192", + memo: "ACH Withdrawal", + }, + { + index: 25, + trnType: "CREDIT", + dtPosted: "20250123000000.000", + trnAmt: "11892", + fitID: "202501231", + memo: "ACH deposit INTERACTIVE BROK ACH TRANSF", + }, + } + + for _, tt := range tests { + if tt.index >= len(entries) { + t.Fatalf("test index %d out of range, len(entries)=%d", tt.index, len(entries)) + } + e := entries[tt.index] + + if e.TrnType != tt.trnType { + t.Errorf("entry %d: expected TrnType %q, got %q", tt.index, tt.trnType, e.TrnType) + } + if e.DtPosted != tt.dtPosted { + t.Errorf("entry %d: expected DtPosted %q, got %q", tt.index, tt.dtPosted, e.DtPosted) + } + if e.TrnAmt != tt.trnAmt { + t.Errorf("entry %d: expected TrnAmt %q, got %q", tt.index, tt.trnAmt, e.TrnAmt) + } + if e.FitID != tt.fitID { + t.Errorf("entry %d: expected FitID %q, got %q", tt.index, tt.fitID, e.FitID) + } + if e.Memo != tt.memo { + t.Errorf("entry %d: expected Memo %q, got %q", tt.index, tt.memo, e.Memo) + } + } +} diff --git a/ledger/qfx/sample.qfx b/ledger/qfx/sample.qfx new file mode 100644 index 0000000..acb5d7e --- /dev/null +++ b/ledger/qfx/sample.qfx @@ -0,0 +1,227 @@ + + + + + + + 0 + INFO + + 208972398723409.061 + ENG + + Capital One Bank + 1001 + + 17410 + + + + + 0 + + 0 + INFO + + + USD + + 0000010101 + 1000 + SAVINGS + + + 20260211073846.061 + 20260211073846.061 + + CREDIT + 20251231000000.000 + 0.13 + 202512311 + IOD INTEREST PAID + + + CREDIT + 20251128000000.000 + 0.13 + 202511281 + IOD INTEREST PAID + + + CREDIT + 20251031000000.000 + 0.13 + 202510311 + IOD INTEREST PAID + + + CREDIT + 20250930000000.000 + 0.13 + 202509301 + IOD INTEREST PAID + + + DEBIT + 20250908000000.000 + -12.46 + 202509081 + ACH Withdrawal + + + CREDIT + 20250902000000.000 + 243 + 202509021 + ACH deposit + + + DEBIT + 20250829000000.000 + -30 + 202508292 + Minimum balance charge + + + CREDIT + 20250829000000.000 + 0.03 + 202508291 + IOD INTEREST PAID + + + DEBIT + 20250818000000.000 + -45 + 202508181 + ACH Withdrawal CAPITAL ONE + + + DEBIT + 20250815000000.000 + -423.38 + 202508151 + TRANSFER WITHDRAWAL TO ....1023 + + + DEBIT + 20250804000000.000 + -5.14 + 202508041 + ACH Withdrawal CAPITAL ONE + + + CREDIT + 20250731000000.000 + 0.15 + 202507311 + IOD INTEREST PAID + + + CREDIT + 20250630000000.000 + 0.11 + 202506301 + IOD INTEREST PAID + + + CREDIT + 20250610000000.000 + 430 + 202506101 + ACH deposit + + + DEBIT + 20250609000000.000 + -200 + 202506091 + ACH Withdrawal CAPITAL ONE + + + CREDIT + 20250530000000.000 + 0.19 + 202505301 + IOD INTEREST PAID + + + CREDIT + 20250430000000.000 + 0.19 + 202504301 + IOD INTEREST PAID + + + CREDIT + 20250331000000.000 + 0.19 + 202503311 + IOD INTEREST PAID + + + DEBIT + 20250311000000.000 + -15 + 202503111 + ACH Withdrawal + + + CREDIT + 20250228000000.000 + 0.12 + 202502281 + IOD INTEREST PAID + + + CREDIT + 20250220000000.000 + 126 + 202502201 + ACH deposit INTERACTIVE BROK ACH TRANSF + + + DEBIT + 20250219000000.000 + -620 + 202502192 + ACH Withdrawal + + + CREDIT + 20250219000000.000 + 0.01 + 202502191 + Instant Transfer Received FROM ....1023 + + + CREDIT + 20250131000000.000 + 9 + 202501311 + IOD INTEREST PAID + + + DEBIT + 20250127000000.000 + -13648 + 202501271 + TRANSFER WITHDRAWAL TO ....1023 + + + CREDIT + 20250123000000.000 + 11892 + 202501231 + ACH deposit INTERACTIVE BROK ACH TRANSF + + + + 273.18 + 20260211073846.061 + + + + + +