parse ledger files
commit
12cb3b81f7
|
|
@ -0,0 +1,55 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/ioutil"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Ledger struct {
|
||||||
|
path string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLedger(path string) (Ledger, error) {
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err == nil && info.IsDir() {
|
||||||
|
return Ledger{}, errors.New("path is dir")
|
||||||
|
}
|
||||||
|
return Ledger{
|
||||||
|
path: path,
|
||||||
|
}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ledger Ledger) Transactions() ([]Transaction, error) {
|
||||||
|
f, err := ledger.open()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
transactions := make([]Transaction, 0)
|
||||||
|
for err == nil {
|
||||||
|
var transaction Transaction
|
||||||
|
transaction, err = readTransaction(f)
|
||||||
|
if err == io.EOF {
|
||||||
|
} else if err == nil {
|
||||||
|
transactions = append(transactions, transaction)
|
||||||
|
} else {
|
||||||
|
err = fmt.Errorf("error parsing transaction #%d: %v", len(transactions), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err == io.EOF {
|
||||||
|
err = nil
|
||||||
|
}
|
||||||
|
return transactions, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ledger Ledger) open() (io.ReadCloser, error) {
|
||||||
|
f, err := os.Open(ledger.path)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return ioutil.NopCloser(bytes.NewReader([]byte{})), nil
|
||||||
|
}
|
||||||
|
return f, err
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestLedgerTransactions(t *testing.T) {
|
||||||
|
ledger, err := NewLedger("./testdata/ledger.dat")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
transactions, err := ledger.Transactions()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(transactions) != 2 {
|
||||||
|
t.Fatal(transactions)
|
||||||
|
}
|
||||||
|
if want := (Transaction{
|
||||||
|
Date: "2021-07-29",
|
||||||
|
Description: "Qt needs pizza n kodiak",
|
||||||
|
Payee: "Withdrawal:Home:Grocery+Resturaunt:Target",
|
||||||
|
Payer: "Debts:Credit:ChaseAarpChaseVisa",
|
||||||
|
Amount: "$37.86",
|
||||||
|
}); transactions[0] != want {
|
||||||
|
t.Fatalf("want \n\t%+v, got \n\t%+v", want, transactions[0])
|
||||||
|
}
|
||||||
|
if want := (Transaction{
|
||||||
|
Date: "2021-07-30",
|
||||||
|
Description: "Testing detecting deposits via email alerts 5421705162",
|
||||||
|
Payer: "AssetAccount:Cash:Uccu",
|
||||||
|
Payee: "Debts:Credit:ChaseAarpChaseVisa",
|
||||||
|
Amount: "$100.00",
|
||||||
|
}); transactions[1] != want {
|
||||||
|
t.Fatalf("want \n\t%+v, got \n\t%+v", want, transactions[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,6 @@
|
||||||
|
2021-07-29 Qt needs pizza n kodiak
|
||||||
|
Debts:Credit:ChaseAarpChaseVisa $-37.86
|
||||||
|
Withdrawal:Home:Grocery+Resturaunt:Target
|
||||||
|
2021-07-30 Testing detecting deposits via email alerts 5421705162
|
||||||
|
AssetAccount:Cash:Uccu $-100.00
|
||||||
|
Debts:Credit:ChaseAarpChaseVisa
|
||||||
|
|
@ -0,0 +1,117 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Transaction struct {
|
||||||
|
Date string
|
||||||
|
Description string
|
||||||
|
Payer string
|
||||||
|
Payee string
|
||||||
|
Amount string
|
||||||
|
}
|
||||||
|
|
||||||
|
func readTransaction(r io.Reader) (Transaction, error) {
|
||||||
|
lines := make([][]byte, 3)
|
||||||
|
for i := range lines {
|
||||||
|
line, err := nextLine(r)
|
||||||
|
if err != nil {
|
||||||
|
return Transaction{}, err
|
||||||
|
}
|
||||||
|
lines[i] = line
|
||||||
|
}
|
||||||
|
for i := range lines {
|
||||||
|
if len(lines[i]) == 0 {
|
||||||
|
return Transaction{}, fmt.Errorf("stub transaction: line %d empty: %q, %q, %q", i, lines[0], lines[1], lines[2])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var transaction Transaction
|
||||||
|
for _, foo := range []func([][]byte) error{
|
||||||
|
transaction.readDate,
|
||||||
|
transaction.readDescription,
|
||||||
|
transaction.readAmount,
|
||||||
|
transaction.readPayerPayee,
|
||||||
|
} {
|
||||||
|
if err := foo(lines); err != nil {
|
||||||
|
return Transaction{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return transaction, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func nextLine(r io.Reader) ([]byte, error) {
|
||||||
|
line, err := readLine(r)
|
||||||
|
for err == nil && len(bytes.TrimSpace(line)) == 0 {
|
||||||
|
line, err = readLine(r)
|
||||||
|
}
|
||||||
|
return line, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func readLine(r io.Reader) ([]byte, error) {
|
||||||
|
w := bytes.NewBuffer(make([]byte, 0, 128))
|
||||||
|
buff := make([]byte, 1)
|
||||||
|
n, err := r.Read(buff)
|
||||||
|
for err == nil && n > 0 && buff[0] != '\n' {
|
||||||
|
w.Write(buff)
|
||||||
|
n, err = r.Read(buff)
|
||||||
|
}
|
||||||
|
return w.Bytes(), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func words(b []byte) [][]byte {
|
||||||
|
scanner := bufio.NewScanner(bytes.NewReader(b))
|
||||||
|
scanner.Split(bufio.ScanWords)
|
||||||
|
words := make([][]byte, 0)
|
||||||
|
for scanner.Scan() {
|
||||||
|
words = append(words, []byte(scanner.Text()))
|
||||||
|
}
|
||||||
|
return words
|
||||||
|
}
|
||||||
|
|
||||||
|
func (transaction *Transaction) readDate(lines [][]byte) error {
|
||||||
|
transaction.Date = string(words(lines[0])[0])
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (transaction *Transaction) readDescription(lines [][]byte) error {
|
||||||
|
transaction.Description = string(bytes.Join(words(lines[0])[1:], []byte(" ")))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (transaction *Transaction) readPayerPayee(lines [][]byte) error {
|
||||||
|
payer := string(words(lines[1])[0])
|
||||||
|
payee := string(words(lines[2])[0])
|
||||||
|
if !strings.Contains(transaction.Amount, "-") {
|
||||||
|
tmp := payer
|
||||||
|
payer = payee
|
||||||
|
payee = tmp
|
||||||
|
} else {
|
||||||
|
transaction.Amount = strings.ReplaceAll(transaction.Amount, "-", "")
|
||||||
|
}
|
||||||
|
transaction.Payer = payer
|
||||||
|
transaction.Payee = payee
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (transaction *Transaction) readAmount(lines [][]byte) error {
|
||||||
|
transaction.Amount = string(words(lines[1])[1])
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (transaction Transaction) Marshal() string {
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"%-25s%s\n%25s%-50s%s\n%25s%s",
|
||||||
|
transaction.Date,
|
||||||
|
transaction.Description,
|
||||||
|
"",
|
||||||
|
transaction.Payee,
|
||||||
|
transaction.Amount,
|
||||||
|
"",
|
||||||
|
transaction.Payer,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,94 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWords(t *testing.T) {
|
||||||
|
input := `
|
||||||
|
hello world
|
||||||
|
i have s ome things
|
||||||
|
`
|
||||||
|
got := words([]byte(input))
|
||||||
|
want := [][]byte{
|
||||||
|
[]byte("hello"),
|
||||||
|
[]byte("world"),
|
||||||
|
[]byte("i"),
|
||||||
|
[]byte("have"),
|
||||||
|
[]byte("s"),
|
||||||
|
[]byte("ome"),
|
||||||
|
[]byte("things"),
|
||||||
|
}
|
||||||
|
if fmt.Sprint(got) != fmt.Sprint(want) {
|
||||||
|
t.Fatal(want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLines(t *testing.T) {
|
||||||
|
s := `
|
||||||
|
|
||||||
|
hello world
|
||||||
|
`
|
||||||
|
scanner := strings.NewReader(s)
|
||||||
|
line, _ := nextLine(scanner)
|
||||||
|
if strings.TrimSpace(string(line)) != "hello world" {
|
||||||
|
t.Fatalf("%q", line)
|
||||||
|
}
|
||||||
|
if string(line) == strings.TrimSpace(string(line)) {
|
||||||
|
t.Fatalf("%q", line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTwoReadTransaction(t *testing.T) {
|
||||||
|
input := `
|
||||||
|
date1 desc1
|
||||||
|
payee1 $1.00
|
||||||
|
payer1
|
||||||
|
date2 desc2
|
||||||
|
payee2 $2.00
|
||||||
|
payer2
|
||||||
|
`
|
||||||
|
r := strings.NewReader(input)
|
||||||
|
got1, _ := readTransaction(r)
|
||||||
|
got2, _ := readTransaction(r)
|
||||||
|
t.Logf("%+v", got1)
|
||||||
|
t.Logf("%+v", got2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadTransaction(t *testing.T) {
|
||||||
|
cases := map[string]struct {
|
||||||
|
input string
|
||||||
|
want Transaction
|
||||||
|
}{
|
||||||
|
"ez": {
|
||||||
|
input: `
|
||||||
|
2021-07-01 description
|
||||||
|
|
||||||
|
payee $1.00
|
||||||
|
payer
|
||||||
|
`,
|
||||||
|
want: Transaction{
|
||||||
|
Date: "2021-07-01",
|
||||||
|
Description: "description",
|
||||||
|
Payee: "payee",
|
||||||
|
Amount: "$1.00",
|
||||||
|
Payer: "payer",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, d := range cases {
|
||||||
|
c := d
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
got, err := readTransaction(strings.NewReader(c.input))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got != c.want {
|
||||||
|
t.Fatalf("\n%s =!> %+v, got %+v", c.input, c.want, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue