Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add OFX support #3

Merged
merged 3 commits into from
Jun 27, 2024
Merged
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
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