Skip to content

Commit 2179f43

Browse files
committed
register: aggregation by tags or payee
1 parent 12090e3 commit 2179f43

File tree

16 files changed

+541
-259
lines changed

16 files changed

+541
-259
lines changed

.vscode/settings.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@
1414
},
1515
"files.insertFinalNewline": true,
1616
"files.trimFinalNewlines": true,
17-
"files.trimTrailingWhitespace": true,
18-
"makefile.configureOnOpen": false
17+
"files.trimTrailingWhitespace": false, // breaks test files
18+
"makefile.configureOnOpen": false,
19+
"[go]": {
20+
"editor.defaultFormatter": "golang.go"
21+
}
1922
}

TODO.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
### Features
22

3+
- register: aggregate by tags, payee, etc
34
- thousand separator in amounts
45
- check for account/cc numbers in transactions
56
- balance: last reconciled posting date
@@ -24,8 +25,9 @@
2425

2526
### coin2html
2627

27-
- update user docs in README (details, balances, screenshots ...)
2828
- allow dropping subaccounts from aggregations (in both chart and register)
29+
- show reconciliation state in balance view (last reconciled date? vs balance date?)
30+
- show reconciliation flag in non-aggregated register
2931
- filter subaccounts, payee, tag...
3032
- brush to select date range
3133
- buttons to reset To/From to Min/Max date
@@ -35,6 +37,7 @@
3537
- tooltips for columns, inputs and wherever useful
3638
- preserve UI state in history (make back/forward buttons work)
3739
- trim to time range on export (need to recalc posting balances!)
40+
- trim to account subtree (need to recalc outer posting balances!)
3841
- show commodities and prices
3942
- investment performance summary
4043

cmd/coin/register.go

Lines changed: 38 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ func (*cmdRegister) newCommand(names ...string) command {
4040
4141
Lists or aggregate postings from the specified account.`)
4242
cmd.BoolVar(&cmd.verbose, "v", false, "log debug info to stderr")
43-
cmd.BoolVar(&cmd.recurse, "r", false, "include subaccount postings in parent accounts")
43+
cmd.BoolVar(&cmd.recurse, "r", false, "include sub-account postings in parent accounts")
4444
// filtering options
4545
cmd.Var(&cmd.begin, "b", "begin register from this date")
4646
cmd.Var(&cmd.end, "e", "end register on this date")
@@ -51,7 +51,7 @@ Lists or aggregate postings from the specified account.`)
5151
cmd.BoolVar(&cmd.monthly, "m", false, "aggregate postings by month")
5252
cmd.BoolVar(&cmd.quarterly, "q", false, "aggregate postings by quarter")
5353
cmd.BoolVar(&cmd.yearly, "y", false, "aggregate postings by year")
54-
cmd.IntVar(&cmd.top, "g", 5, "include this many largest subaccounts in aggregate results")
54+
cmd.IntVar(&cmd.top, "g", 5, "include this many largest sub-accounts in aggregate results")
5555
cmd.BoolVar(&cmd.cumulative, "c", false, "aggregate cumulatively across time")
5656
// output options
5757
cmd.IntVar(&cmd.maxLabelWidth, "l", 12, "maximum width of a column label")
@@ -73,92 +73,59 @@ func (cmd *cmdRegister) execute(f io.Writer) {
7373
fmt.Fprintln(f, acc.FullName, acc.Commodity.Id)
7474
}
7575
if by := cmd.period(); by != nil {
76-
if cmd.recurse {
77-
cmd.recursiveAggregatedRegister(f, acc, by)
78-
} else {
79-
cmd.flatAggregatedRegister(f, acc, by)
80-
}
76+
cmd.aggregatedRegister(f, acc, by)
8177
} else {
82-
var opts = options{
83-
prefix: acc.FullName,
84-
maxAcct: cmd.maxLabelWidth,
85-
location: cmd.location,
86-
commodity: acc.Commodity,
87-
showNotes: cmd.showNotes,
88-
}
89-
if cmd.recurse {
90-
var ps postings
91-
acc.WithChildrenDo(func(a *coin.Account) {
92-
ps = append(ps, cmd.trim(a.Postings)...)
93-
})
94-
sort.SliceStable(ps, func(i, j int) bool {
95-
return ps[i].Transaction.Posted.Before(ps[j].Transaction.Posted)
96-
})
97-
ps.printLong(f, &opts)
98-
} else {
99-
cmd.trim(acc.Postings).print(f, &opts)
100-
}
78+
cmd.fullRegister(f, acc)
10179
}
10280
}
10381

104-
func (cmd *cmdRegister) flatAggregatedRegister(f io.Writer, acc *coin.Account, by *reducer) {
105-
totals := accountTotals{}
106-
acc.WithChildrenDo(func(a *coin.Account) {
107-
ts := totals.newTotals(a, by)
108-
for _, p := range cmd.trim(a.Postings) {
109-
ts.add(p.Transaction.Posted, p.Quantity)
110-
}
111-
})
112-
var accounts []*coin.Account
113-
totals, accounts = totals.top(cmd.top)
114-
top := totals[accounts[0]]
115-
for _, ts := range totals {
116-
top.mergeTime(ts)
117-
}
118-
totals.mergeTime(top)
119-
if cmd.cumulative {
120-
totals.makeCumulative()
82+
func (cmd *cmdRegister) fullRegister(f io.Writer, acc *coin.Account) {
83+
var opts = options{
84+
prefix: acc.FullName,
85+
maxAcct: cmd.maxLabelWidth,
86+
location: cmd.location,
87+
commodity: acc.Commodity,
88+
showNotes: cmd.showNotes,
12189
}
122-
label := func(a *coin.Account) string {
123-
switch a {
124-
case nil:
125-
return "Other"
126-
case acc:
127-
return acc.Name
128-
default:
129-
n := strings.TrimPrefix(a.FullName, acc.FullName)
130-
return coin.ShortenAccountName(n, cmd.maxLabelWidth)
131-
}
90+
if cmd.recurse {
91+
var ps postings
92+
acc.WithChildrenDo(func(a *coin.Account) {
93+
ps = append(ps, cmd.trim(a.Postings)...)
94+
})
95+
sort.SliceStable(ps, func(i, j int) bool {
96+
return ps[i].Transaction.Posted.Before(ps[j].Transaction.Posted)
97+
})
98+
ps.printLong(f, &opts)
99+
} else {
100+
cmd.trim(acc.Postings).print(f, &opts)
132101
}
133-
totals.output(f, accounts, label, cmd.output)
134102
}
135103

136-
func (cmd *cmdRegister) recursiveAggregatedRegister(f io.Writer, acc *coin.Account, by *reducer) {
104+
func (cmd *cmdRegister) aggregatedRegister(f io.Writer, acc *coin.Account, by *timeReducer) {
137105
totals := accountTotals{}
138106
acc.WithChildrenDo(func(a *coin.Account) {
139107
ts := totals.newTotals(a, by)
140108
for _, p := range cmd.trim(a.Postings) {
141-
ts.add(p.Transaction.Posted, p.Quantity)
109+
ts.add(p)
142110
}
143111
})
144-
if cmd.recurse {
145-
acc.FirstWithChildrenDo(func(a *coin.Account) {
146-
child := totals[a]
147-
parent := totals[a.Parent]
148-
if parent != nil {
112+
// Propagate timelines and possibly amounts up top
113+
acc.FirstWithChildrenDo(func(a *coin.Account) {
114+
child := totals[a]
115+
parent := totals[a.Parent]
116+
if parent != nil {
117+
if cmd.recurse {
149118
parent.merge(child)
119+
} else {
120+
parent.mergeTime(child)
150121
}
151-
})
152-
}
122+
}
123+
})
153124
totals.sanitize()
154-
accTotals := totals[acc]
155-
check.If(accTotals != nil, "root account totals shouldn't be empty\n")
156-
delete(totals, acc)
157125
var accounts []*coin.Account
158126
totals, accounts = totals.top(cmd.top)
159-
totals.mergeTime(accTotals)
160-
totals[acc] = accTotals
161-
accounts = append(accounts, acc)
127+
totals.mergeTime(totals[acc])
128+
162129
if cmd.cumulative {
163130
totals.makeCumulative()
164131
}
@@ -167,7 +134,7 @@ func (cmd *cmdRegister) recursiveAggregatedRegister(f io.Writer, acc *coin.Accou
167134
case nil:
168135
return "Other"
169136
case acc:
170-
return "Totals"
137+
return acc.Name
171138
default:
172139
n := strings.TrimPrefix(a.FullName, acc.FullName)
173140
return coin.ShortenAccountName(n, cmd.maxLabelWidth)
@@ -176,7 +143,7 @@ func (cmd *cmdRegister) recursiveAggregatedRegister(f io.Writer, acc *coin.Accou
176143
totals.output(f, accounts, label, cmd.output)
177144
}
178145

179-
func (cmd *cmdRegister) period() *reducer {
146+
func (cmd *cmdRegister) period() *timeReducer {
180147
switch {
181148
case cmd.weekly:
182149
return &week

cmd/coin/test.go

Lines changed: 75 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,12 @@ import (
66
"fmt"
77
"io"
88
"os"
9+
"path"
10+
"strconv"
911
"strings"
1012

1113
"github.com/mkobetic/coin"
14+
"github.com/mkobetic/coin/check"
1215
"github.com/pmezard/go-difflib/difflib"
1316
)
1417

@@ -26,10 +29,10 @@ func (*cmdTest) newCommand(names ...string) command {
2629
cmd.FlagSet = newCommand(&cmd, names...)
2730
setUsage(cmd.FlagSet, `(test|t)
2831
29-
Execute any test clauses found in the ledger (see tests/ directory).`)
32+
Execute any test clauses found in the ledger (see tests/ directory).
33+
If test result is empty, updates the test file with computed result.`)
3034
cmd.BoolVar(&cmd.verbose, "v", false, "print OK result for every test, not just for each file")
3135
return &cmd
32-
3336
}
3437

3538
func (cmd *cmdTest) init() {
@@ -42,8 +45,22 @@ func (cmd *cmdTest) execute(f io.Writer) {
4245
return
4346
}
4447
lastTestFile := file(coin.Tests[0])
48+
var toUpdate []*coin.Test
4549
success := true
4650
for _, t := range coin.Tests {
51+
// assume the tests are sorted file by file
52+
// and in the order they are in the file
53+
testFile := file(t)
54+
startingNewFile := testFile != lastTestFile
55+
var oldTestFile string // this is set only when moving to new file
56+
if startingNewFile {
57+
oldTestFile = lastTestFile
58+
}
59+
lastTestFile = testFile
60+
if startingNewFile && len(toUpdate) > 0 {
61+
updateTestFile(toUpdate)
62+
toUpdate = nil
63+
}
4764
var args []string
4865
scanner := bufio.NewScanner(strings.NewReader(t.Cmd))
4966
scanner.Split(bufio.ScanWords)
@@ -69,14 +86,19 @@ func (cmd *cmdTest) execute(f io.Writer) {
6986
}
7087
var b bytes.Buffer
7188
command.execute(&b)
89+
// If the test result is empty, update the test file
90+
// once we collect all the tests to be updated
91+
if len(t.Result) == 0 {
92+
t.Result = b.Bytes()
93+
toUpdate = append(toUpdate, t)
94+
continue
95+
}
7296
if bytes.Equal(b.Bytes(), t.Result) {
73-
testFile := file(t)
7497
if cmd.verbose {
7598
fmt.Fprintf(f, "OK %s %s\n", t.Location(), t.Cmd)
76-
} else if lastTestFile != testFile {
77-
fmt.Fprintf(f, "OK %s\n", lastTestFile)
99+
} else if oldTestFile != "" {
100+
fmt.Fprintf(f, "OK %s\n", oldTestFile)
78101
}
79-
lastTestFile = testFile
80102
continue
81103
}
82104
success = false
@@ -90,6 +112,9 @@ func (cmd *cmdTest) execute(f io.Writer) {
90112
Context: 3,
91113
})
92114
}
115+
if len(toUpdate) > 0 {
116+
updateTestFile(toUpdate)
117+
}
93118
if !cmd.verbose {
94119
result := "OK"
95120
if !success {
@@ -103,3 +128,47 @@ func file(t *coin.Test) string {
103128
file, _, _ := strings.Cut(t.Location(), ":")
104129
return file
105130
}
131+
132+
// assume ts are tests from the same file in the order in which they are in the file,
133+
// and all test have empty result in the file,
134+
// then write the results from the test commands into the file.
135+
func updateTestFile(ts []*coin.Test) {
136+
fn := file(ts[0])
137+
tf, err := os.CreateTemp(path.Dir(fn), path.Base(fn))
138+
check.NoError(err, "creating temp file")
139+
// read file fn by lines up to line
140+
// write the lines into tf
141+
of, err := os.Open(fn)
142+
check.NoError(err, "opening old file")
143+
defer of.Close()
144+
145+
scanner := bufio.NewScanner(of)
146+
writer := bufio.NewWriter(tf)
147+
line := 0
148+
for i, t := range ts {
149+
_, ln, _ := strings.Cut(t.Location(), ":")
150+
tLine, _ := strconv.Atoi(ln)
151+
152+
for ; line < tLine && scanner.Scan(); line++ {
153+
_, err := writer.Write(scanner.Bytes())
154+
check.NoError(err, "writing prefix %d", i)
155+
check.NoError(writer.WriteByte('\n'), "writing prefix %d", i)
156+
}
157+
check.NoError(scanner.Err(), "writing prefix %d", i)
158+
_, err = writer.Write(t.Result)
159+
check.NoError(err, "writing result %d", i)
160+
fmt.Fprintf(os.Stderr, "UPDATED %s %s\n", t.Location(), t.Cmd)
161+
}
162+
// finish writing rest of the old file
163+
for scanner.Scan() {
164+
_, err := writer.Write(scanner.Bytes())
165+
check.NoError(err, "writing trailer")
166+
check.NoError(writer.WriteByte('\n'), "writing trailer")
167+
}
168+
check.NoError(scanner.Err(), "writing trailer")
169+
check.NoError(of.Close(), "closing old file")
170+
check.NoError(writer.Flush(), "flushing writer")
171+
check.NoError(tf.Close(), "closing temp file")
172+
check.NoError(os.Remove(fn), "deleting old file")
173+
check.NoError(os.Rename(tf.Name(), fn), "renaming temp file %s to %s", tf.Name(), fn)
174+
}

0 commit comments

Comments
 (0)