Skip to content
Open
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
87 changes: 86 additions & 1 deletion ledger/cmd/import.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 <account-substring> <csv-file>",
Expand All @@ -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)
}
Expand Down
47 changes: 47 additions & 0 deletions ledger/qfx/qfx.go
Original file line number Diff line number Diff line change
@@ -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
}
96 changes: 96 additions & 0 deletions ledger/qfx/qfx_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Loading