parse ledger files

master
bel 2021-07-31 23:36:26 -06:00
commit 12cb3b81f7
6 changed files with 7522 additions and 0 deletions

55
ledger.go Normal file
View File

@ -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
}

35
ledger_test.go Normal file
View File

@ -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])
}
}

7215
testdata/2018-.dat vendored Executable file

File diff suppressed because it is too large Load Diff

6
testdata/ledger.dat vendored Normal file
View File

@ -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

117
transaction.go Normal file
View File

@ -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,
)
}

94
transaction_test.go Normal file
View File

@ -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)
}
})
}
}