aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorMartin Polden <mpolden@mpolden.no>2020-07-13 20:01:04 +0200
committerMartin Polden <mpolden@mpolden.no>2020-07-13 20:23:35 +0200
commit6c566179b25da781478bb66154a8203cc210309b (patch)
treebe43519f2033cf5dc4560878538ab00a66bca255
parent3a9483b42354cc5486f5202e9694abe1ee835063 (diff)
Import records from DNB
-rw-r--r--README.md2
-rw-r--r--cmd/cmd.go2
-rw-r--r--journal/journal.go3
-rw-r--r--journal/journal_test.go6
-rw-r--r--record/dnb/dnb.go95
-rw-r--r--record/dnb/dnb_test.go51
-rw-r--r--record/dnb/testdata/test.xlsxbin0 -> 5903 bytes
7 files changed, 157 insertions, 2 deletions
diff --git a/README.md b/README.md
index a471462..3b0c02c 100644
--- a/README.md
+++ b/README.md
@@ -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.
diff --git a/cmd/cmd.go b/cmd/cmd.go
index a08416b..a428500 100644
--- a/cmd/cmd.go
+++ b/cmd/cmd.go
@@ -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
new file mode 100644
index 0000000..3d9430d
--- /dev/null
+++ b/record/dnb/testdata/test.xlsx
Binary files differ