Skip to content

Commit

Permalink
Merge pull request #3 from jacob-ian/feat/ofx-ba
Browse files Browse the repository at this point in the history
Add OFX support
  • Loading branch information
jacob-ian authored Jun 27, 2024
2 parents 17fa188 + f99d12f commit 0799b37
Show file tree
Hide file tree
Showing 11 changed files with 246 additions and 13 deletions.
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ A CLI tool to import transaction data into a beancount ledger, written in Go.

## Financial Institution Support

| Institution | Provider |
| ----------- | -------- |
| Amex (CSV) | amexcsv |
| Institution | Provider |
| -------------- | -------- |
| Amex (CSV) | amexcsv |
| Bank Australia | ofx |

## Get Started

Expand Down
6 changes: 6 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ go 1.22
require github.com/fatih/color v1.17.0

require (
github.com/aclindsa/xml v0.0.0-20201125035057-bbd5c9ec99ac // indirect
golang.org/x/text v0.3.7 // indirect
)

require (
github.com/aclindsa/ofxgo v0.1.3
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
golang.org/x/sys v0.18.0 // indirect
Expand Down
9 changes: 9 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,14 +1,23 @@
github.com/aclindsa/ofxgo v0.1.3 h1:20Ckjpg5gG4rdGh2juGfa5I1gnWULMXGWxpseVLCVaM=
github.com/aclindsa/ofxgo v0.1.3/go.mod h1:q2mYxGiJr5X3rlyoQjQq+qqHAQ8cTLntPOtY0Dq0pzE=
github.com/aclindsa/xml v0.0.0-20201125035057-bbd5c9ec99ac h1:xCNSfPWpcx3Sdz/+aB/Re4L8oA6Y4kRRRuTh1CHCDEw=
github.com/aclindsa/xml v0.0.0-20201125035057-bbd5c9ec99ac/go.mod h1:GjqOUT8xlg5+T19lFv6yAGNrtMKkZ839Gt4e16mBXlY=
github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4=
github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
Expand Down
3 changes: 2 additions & 1 deletion internal/beanport/formatting.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ func FormatTransaction(txn *Transaction) string {
return fmt.Sprintf(
"%s * \"%s REF:%s\"\n\t%s\t%.2f %s\n\t%s\t%.2f %s\n\n",
txn.Date.Format("2006-01-02"),
txn.Description, txn.Reference,
txn.Description,
txn.Reference,
txn.Account,
txn.Amount,
txn.Commodity,
Expand Down
10 changes: 10 additions & 0 deletions internal/beanport/importer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// The shared types for beanport
package beanport

// An import provider
type Provider = string

// The interface for a data importer
type Importer interface {
Import() ([]*PendingTransaction, error)
}
8 changes: 0 additions & 8 deletions internal/beanport/transactions.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,6 @@ package beanport

import "time"

// An import provider
type Provider = string

// A transaction that hasn't been confirmed
type PendingTransaction struct {
Index int
Expand All @@ -22,8 +19,3 @@ type Transaction struct {
PendingTransaction
OppositeAccount string
}

// The interface for a data importer
type Importer interface {
Import() ([]*PendingTransaction, error)
}
7 changes: 6 additions & 1 deletion internal/providers/amexcsv/importer.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,13 +127,18 @@ func (ai *amexImporter) Import() ([]*beanport.PendingTransaction, error) {
parseErr = err
break
}

if ai.config.NegativeAmounts {
amount = -amount
}

reference := split[13]
txns = append(txns, &beanport.PendingTransaction{
Index: i,
Account: ai.config.Account,
Date: date,
Description: description,
Amount: 0 - amount,
Amount: amount,
Reference: reference,
Commodity: ai.config.Commodity,
})
Expand Down
54 changes: 54 additions & 0 deletions internal/providers/ofx/description.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package ofx

import (
"fmt"
"strings"

"github.com/aclindsa/ofxgo"
)

// Gets a description from an OFX transaction
func description(txn ofxgo.Transaction) (string, error) {
if txn.Payee != nil {
return fmt.Sprintf("%s %s", txn.Payee.Name.String(), txn.Payee.State), nil
}
if txn.Name.String() != "" {
return txn.Name.String(), nil
}
if txn.Memo.String() != "" {
return descriptionFromMemo(txn.Memo.String()), nil
}
return "", fmt.Errorf("No available description properties for FITID %v", txn.FiTID.String())
}

// Convert a bank transaction memo to a description
func descriptionFromMemo(memo string) string {
description := memo
if description[:5] == "VISA-" {
description = description[5:]
}

fields := strings.Fields(description)
if len(fields) == 0 {
return description
}

var output []string
for i, f := range fields {
if strings.Contains(f, "Ref") {
continue
}

if i > 0 && fields[i-1] == "Apple" && f == "Pay" {
continue
}

if i < len(fields)-1 && f == "Apple" && fields[i+1] == "Pay" {
continue
}

output = append(output, f)
}

return strings.Join(output, " ")
}
90 changes: 90 additions & 0 deletions internal/providers/ofx/importer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package ofx

import (
"bytes"
"errors"
"fmt"
"strconv"

"github.com/aclindsa/ofxgo"
"github.com/jacob-ian/beanport/internal/beanport"
)

const (
Provider beanport.Provider = "ofx"
)

type ImporterConfig struct {
Commodity string
Account string
}

type Importer struct {
data []byte
commodity string
account string
}

func (imp *Importer) Import() ([]*beanport.PendingTransaction, error) {
res, err := ofxgo.ParseResponse(bytes.NewReader(imp.data))
if err != nil {
return nil, errors.Join(errors.New("Couldn't parse OFX"), err)
}

if len(res.Bank) != 1 {
return nil, errors.New("OFX error: too many bank messages")
}

root, ok := res.Bank[0].(*ofxgo.StatementResponse)
if !ok {
return nil, errors.Join(errors.New("OFX error: not a statement response"), err)
}

txns := root.BankTranList.Transactions
var output []*beanport.PendingTransaction
var parseErr error

for i, txn := range txns {
description, err := description(txn)
if err != nil {
parseErr = errors.Join(fmt.Errorf("Could not generate description"), err)
break
}

amt, err := strconv.ParseFloat(txn.TrnAmt.String(), 64)
if err != nil {
parseErr = errors.Join(fmt.Errorf("Could not parse transaction amount for FITID %v: '%s'", txn.FiTID.String(), description), err)
break
}

if amt == 0.00 {
continue
}

ref := reference(txn, description, amt)

output = append(output, &beanport.PendingTransaction{
Index: i,
Account: imp.account,
Date: txn.DtPosted.Time,
Description: description,
Amount: amt,
Reference: ref,
Commodity: imp.commodity,
})
}

if parseErr != nil {
return nil, errors.Join(errors.New("Could not read transactions"), err)
}

return output, nil
}

func NewImporter(data []byte, config *ImporterConfig) *Importer {
return &Importer{
data: data,
commodity: config.Commodity,
account: config.Account,
}
}
54 changes: 54 additions & 0 deletions internal/providers/ofx/reference.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package ofx

import (
"crypto/md5"
"encoding/base64"
"fmt"
"strings"

"github.com/aclindsa/ofxgo"
)

// Gets a reference for an OFX transaction
// The reference is designed to prevent ledger duplicates
func reference(txn ofxgo.Transaction, description string, amount float64) string {
if txn.CheckNum.String() != "" {
return txn.CheckNum.String()
}
if txn.RefNum.String() != "" {
return txn.RefNum.String()
}

if ref := referenceFromMemo(txn.Memo.String()); ref != "" {
return ref
}

input := fmt.Sprintf("%v:%s:%.2f", txn.DtPosted.Time.Format("2006-01-02"), description, amount)
hashed := fmt.Sprintf("%x", md5.Sum([]byte(input)))
encoded := base64.StdEncoding.EncodeToString([]byte(hashed))
return encoded
}

// Attempts to find a ref in the transaction memo
func referenceFromMemo(memo string) string {
if memo == "" {
return ""
}

fields := strings.Fields(memo)
if len(fields) == 0 {
return ""
}

reference := ""
for _, f := range fields {
exists := strings.Contains(f, "Ref")
if !exists {
continue
}
reference = f
break
}

return reference
}
11 changes: 11 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (

"github.com/jacob-ian/beanport/internal/beanport"
"github.com/jacob-ian/beanport/internal/providers/amexcsv"
"github.com/jacob-ian/beanport/internal/providers/ofx"
"github.com/jacob-ian/beanport/internal/tui"
)

Expand Down Expand Up @@ -42,6 +43,16 @@ func main() {
Commodity: cfg.Commodity,
})
}
if cfg.Provider == ofx.Provider {
importer = ofx.NewImporter(data, &ofx.ImporterConfig{
Account: cfg.Account,
Commodity: cfg.Commodity,
})
}

if importer == nil {
panic("Invalid provider selected")
}

defaults, err := beanport.UserDefaultsFromFile(cfg.DefaultsFilePath)
if err != nil {
Expand Down

0 comments on commit 0799b37

Please sign in to comment.