diff options
author | Martin Polden <mpolden@mpolden.no> | 2020-07-13 20:01:04 +0200 |
---|---|---|
committer | Martin Polden <mpolden@mpolden.no> | 2020-07-13 20:23:35 +0200 |
commit | 6c566179b25da781478bb66154a8203cc210309b (patch) | |
tree | be43519f2033cf5dc4560878538ab00a66bca255 | |
parent | 3a9483b42354cc5486f5202e9694abe1ee835063 (diff) |
Import records from DNB
-rw-r--r-- | README.md | 2 | ||||
-rw-r--r-- | cmd/cmd.go | 2 | ||||
-rw-r--r-- | journal/journal.go | 3 | ||||
-rw-r--r-- | journal/journal_test.go | 6 | ||||
-rw-r--r-- | record/dnb/dnb.go | 95 | ||||
-rw-r--r-- | record/dnb/dnb_test.go | 51 | ||||
-rw-r--r-- | record/dnb/testdata/test.xlsx | bin | 0 -> 5903 bytes |
7 files changed, 157 insertions, 2 deletions
@@ -7,7 +7,7 @@ ## Features * Import financial records from multiple Norwegian banks, such as Eika Group -(most local banks), Storebrand, Bank Norwegian and Komplett Bank. +(most local banks), DNB, Storebrand, Bank Norwegian and Komplett Bank. * Identify spending habits using automatic grouping of records. * Define budgets for record groups. * Export record groups for further processing in other programs. @@ -25,7 +25,7 @@ type Options struct { // Import represents options for the import sub-command. type Import struct { Options - Reader string `short:"r" long:"reader" description:"Name of reader to use when importing data" choice:"csv" choice:"komplett" choice:"norwegian" choice:"auto" default:"auto"` + Reader string `short:"r" long:"reader" description:"Name of reader to use when importing data" choice:"csv" choice:"komplett" choice:"norwegian" choice:"dnb" choice:"auto" default:"auto"` Args struct { Account string `description:"Account number" positional-arg-name:"account-number"` Files []string `description:"File containing records to import" positional-arg-name:"import-file"` diff --git a/journal/journal.go b/journal/journal.go index 2592a9a..156305e 100644 --- a/journal/journal.go +++ b/journal/journal.go @@ -14,6 +14,7 @@ import ( "github.com/BurntSushi/toml" "github.com/mpolden/journal/record" + "github.com/mpolden/journal/record/dnb" "github.com/mpolden/journal/record/komplett" "github.com/mpolden/journal/record/norwegian" "github.com/mpolden/journal/sql" @@ -109,6 +110,8 @@ func readConfig(r io.Reader) (Config, error) { func readerFrom(r io.Reader, name, filename string) (record.Reader, error) { var rr record.Reader switch name { + case "dnb": + rr = dnb.NewReader(r) case "csv": rr = record.NewReader(r) case "komplett": diff --git a/journal/journal_test.go b/journal/journal_test.go index 58ea40a..8aa7294 100644 --- a/journal/journal_test.go +++ b/journal/journal_test.go @@ -7,6 +7,7 @@ import ( "time" "github.com/mpolden/journal/record" + "github.com/mpolden/journal/record/dnb" "github.com/mpolden/journal/record/komplett" "github.com/mpolden/journal/record/norwegian" ) @@ -69,6 +70,7 @@ func TestReaderFrom(t *testing.T) { filename string impl string }{ + {"dnb", "", "dnb"}, {"csv", "", "default"}, {"norwegian", "", "norwegian"}, {"komplett", "", "komplett"}, @@ -86,6 +88,10 @@ func TestReaderFrom(t *testing.T) { if _, ok := rr.(record.Reader); !ok { t.Errorf("#%d: want record.Reader, got %T", i, rr) } + case "dnb": + if _, ok := rr.(*dnb.Reader); !ok { + t.Errorf("#%d: want dnb.Reader, got %T", i, rr) + } case "norwegian": if _, ok := rr.(*norwegian.Reader); !ok { t.Errorf("#%d: want norwegian.Reader, got %T", i, rr) diff --git a/record/dnb/dnb.go b/record/dnb/dnb.go new file mode 100644 index 0000000..1f809cb --- /dev/null +++ b/record/dnb/dnb.go @@ -0,0 +1,95 @@ +package dnb + +import ( + "fmt" + "io" + "io/ioutil" + "strconv" + "strings" + + "github.com/mpolden/journal/record" + "github.com/tealeg/xlsx" +) + +const ( + firstHeaderCell = "Dato" + decimalSeparator = "." + thousandSeparator = "," +) + +// Reader implements a reader for DNB-encoded (XLSX) records. +type Reader struct { + rd io.Reader + replacer *strings.Replacer +} + +// NewReader returns a new reader for DNB-encoded records. +func NewReader(rd io.Reader) *Reader { + return &Reader{ + rd: rd, + replacer: strings.NewReplacer(decimalSeparator, "", thousandSeparator, ""), + } +} + +func (r *Reader) parseAmount(s string) (int64, error) { + if s == "" { + return 0, nil + } + if strings.LastIndex(s, decimalSeparator) == len(s)-2 { // Pad single digit decimal + s += "0" + } + hasDecimals := strings.Contains(s, decimalSeparator) + v := r.replacer.Replace(s) + n, err := strconv.ParseInt(v, 10, 64) + if err != nil { + return 0, err + } + if !hasDecimals { + return n * 100, nil + } + return n, nil +} + +func (r *Reader) Read() ([]record.Record, error) { + data, err := ioutil.ReadAll(r.rd) + if err != nil { + return nil, err + } + f, err := xlsx.OpenBinary(data) + if err != nil { + return nil, err + } + if len(f.Sheets) == 0 { + return nil, fmt.Errorf("xlsx contains 0 sheets") + } + var rs []record.Record + for _, row := range f.Sheets[0].Rows { + cells := row.Cells + if len(cells) < 6 { + continue + } + if cells[0].String() == firstHeaderCell { // Header row + continue + } + time, err := cells[0].GetTime(false) + if err != nil { + return nil, err + } + amountIn, err := r.parseAmount(cells[4].String()) + if err != nil { + return nil, err + } + amountOut, err := r.parseAmount(cells[5].String()) + if err != nil { + return nil, err + } + amount := amountIn - amountOut + r := record.Record{ + Time: time, + Text: cells[1].String(), + Amount: amount, + } + rs = append(rs, r) + } + return rs, nil +} diff --git a/record/dnb/dnb_test.go b/record/dnb/dnb_test.go new file mode 100644 index 0000000..ae2983e --- /dev/null +++ b/record/dnb/dnb_test.go @@ -0,0 +1,51 @@ +package dnb + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestRead(t *testing.T) { + wd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + testFile := filepath.Join(wd, "testdata", "test.xlsx") + f, err := os.Open(testFile) + if err != nil { + t.Fatal(err) + } + defer f.Close() + + r := NewReader(f) + rs, err := r.Read() + if err != nil { + t.Fatal(err) + } + + var tests = []struct { + t time.Time + text string + amount int64 + }{ + {time.Date(2020, 6, 25, 2, 0, 0, 209, time.UTC), "Transaction 1", -119990}, + {time.Date(2020, 6, 26, 2, 0, 0, 209, time.UTC), "Transaction 2", -59995}, + {time.Date(2020, 6, 27, 2, 0, 0, 209, time.UTC), "Transaction 3", 70000}, + } + if len(rs) != len(tests) { + t.Fatalf("want %d records, got %d", len(tests), len(rs)) + } + for i, tt := range tests { + if !rs[i].Time.Equal(tt.t) { + t.Errorf("#%d: want Time = %s, got %s", i, tt.t, rs[i].Time) + } + if rs[i].Text != tt.text { + t.Errorf("#%d: want Text = %s, got %s", i, tt.text, rs[i].Text) + } + if rs[i].Amount != tt.amount { + t.Errorf("#%d: want Amount = %d, got %d", i, tt.amount, rs[i].Amount) + } + } +} diff --git a/record/dnb/testdata/test.xlsx b/record/dnb/testdata/test.xlsx Binary files differnew file mode 100644 index 0000000..3d9430d --- /dev/null +++ b/record/dnb/testdata/test.xlsx |