tallyd 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +1493 -2
- package/dist/accounts/chart.d.ts +96 -0
- package/dist/accounts/chart.js +244 -0
- package/dist/accounts/index.d.ts +3 -0
- package/dist/accounts/index.js +3 -0
- package/dist/accounts/standard.d.ts +12 -0
- package/dist/accounts/standard.js +95 -0
- package/dist/accounts/types.d.ts +20 -0
- package/dist/accounts/types.js +40 -0
- package/dist/cli/args.d.ts +45 -0
- package/dist/cli/args.js +147 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +2 -0
- package/dist/cli/main.d.ts +6 -0
- package/dist/cli/main.js +25 -0
- package/dist/cli/run.d.ts +34 -0
- package/dist/cli/run.js +1660 -0
- package/dist/dashboard/charts.d.ts +38 -0
- package/dist/dashboard/charts.js +173 -0
- package/dist/dashboard/index.d.ts +3 -0
- package/dist/dashboard/index.js +3 -0
- package/dist/dashboard/model.d.ts +137 -0
- package/dist/dashboard/model.js +190 -0
- package/dist/dashboard/render.d.ts +23 -0
- package/dist/dashboard/render.js +231 -0
- package/dist/dashboard/script.d.ts +28 -0
- package/dist/dashboard/script.js +704 -0
- package/dist/dashboard/styles.d.ts +14 -0
- package/dist/dashboard/styles.js +726 -0
- package/dist/demo/disposal.d.ts +80 -0
- package/dist/demo/disposal.js +215 -0
- package/dist/demo/evidence.d.ts +153 -0
- package/dist/demo/evidence.js +348 -0
- package/dist/demo/foreign.d.ts +29 -0
- package/dist/demo/foreign.js +113 -0
- package/dist/demo/foreignReport.d.ts +10 -0
- package/dist/demo/foreignReport.js +89 -0
- package/dist/demo/generator.d.ts +108 -0
- package/dist/demo/generator.js +445 -0
- package/dist/demo/group.d.ts +70 -0
- package/dist/demo/group.js +195 -0
- package/dist/demo/groupPeriods.d.ts +27 -0
- package/dist/demo/groupPeriods.js +125 -0
- package/dist/demo/groupReport.d.ts +13 -0
- package/dist/demo/groupReport.js +87 -0
- package/dist/demo/ingest.d.ts +6 -0
- package/dist/demo/ingest.js +61 -0
- package/dist/demo/main.d.ts +6 -0
- package/dist/demo/main.js +7 -0
- package/dist/demo/mainDisposal.d.ts +1 -0
- package/dist/demo/mainDisposal.js +2 -0
- package/dist/demo/mainEvidence.d.ts +8 -0
- package/dist/demo/mainEvidence.js +9 -0
- package/dist/demo/mainForeign.d.ts +6 -0
- package/dist/demo/mainForeign.js +7 -0
- package/dist/demo/mainGroup.d.ts +6 -0
- package/dist/demo/mainGroup.js +7 -0
- package/dist/demo/mainGroupPeriods.d.ts +6 -0
- package/dist/demo/mainGroupPeriods.js +7 -0
- package/dist/demo/mainIngest.d.ts +6 -0
- package/dist/demo/mainIngest.js +7 -0
- package/dist/demo/mainMidYear.d.ts +6 -0
- package/dist/demo/mainMidYear.js +7 -0
- package/dist/demo/mainReconcile.d.ts +6 -0
- package/dist/demo/mainReconcile.js +7 -0
- package/dist/demo/mainStatements.d.ts +6 -0
- package/dist/demo/mainStatements.js +7 -0
- package/dist/demo/midYear.d.ts +51 -0
- package/dist/demo/midYear.js +162 -0
- package/dist/demo/month.d.ts +13 -0
- package/dist/demo/month.js +142 -0
- package/dist/demo/random.d.ts +48 -0
- package/dist/demo/random.js +94 -0
- package/dist/demo/receivables.d.ts +36 -0
- package/dist/demo/receivables.js +104 -0
- package/dist/demo/reconcile.d.ts +20 -0
- package/dist/demo/reconcile.js +104 -0
- package/dist/demo/report.d.ts +6 -0
- package/dist/demo/report.js +26 -0
- package/dist/demo/statement.d.ts +13 -0
- package/dist/demo/statement.js +51 -0
- package/dist/demo/statements.d.ts +6 -0
- package/dist/demo/statements.js +29 -0
- package/dist/demo/supplierRun.d.ts +46 -0
- package/dist/demo/supplierRun.js +94 -0
- package/dist/demo/unrealisedEvidence.d.ts +51 -0
- package/dist/demo/unrealisedEvidence.js +183 -0
- package/dist/fx/average.d.ts +60 -0
- package/dist/fx/average.js +139 -0
- package/dist/fx/document.d.ts +60 -0
- package/dist/fx/document.js +205 -0
- package/dist/fx/exposure.d.ts +108 -0
- package/dist/fx/exposure.js +262 -0
- package/dist/fx/index.d.ts +8 -0
- package/dist/fx/index.js +8 -0
- package/dist/fx/rate.d.ts +77 -0
- package/dist/fx/rate.js +200 -0
- package/dist/fx/revaluation.d.ts +92 -0
- package/dist/fx/revaluation.js +197 -0
- package/dist/fx/settlement.d.ts +84 -0
- package/dist/fx/settlement.js +162 -0
- package/dist/fx/table.d.ts +120 -0
- package/dist/fx/table.js +271 -0
- package/dist/fx/translate.d.ts +126 -0
- package/dist/fx/translate.js +258 -0
- package/dist/group/accounts.d.ts +74 -0
- package/dist/group/accounts.js +179 -0
- package/dist/group/acquisition.d.ts +324 -0
- package/dist/group/acquisition.js +416 -0
- package/dist/group/aggregate.d.ts +143 -0
- package/dist/group/aggregate.js +288 -0
- package/dist/group/comparative.d.ts +102 -0
- package/dist/group/comparative.js +166 -0
- package/dist/group/consolidate.d.ts +196 -0
- package/dist/group/consolidate.js +635 -0
- package/dist/group/disposal.d.ts +181 -0
- package/dist/group/disposal.js +233 -0
- package/dist/group/document.d.ts +41 -0
- package/dist/group/document.js +326 -0
- package/dist/group/index.d.ts +12 -0
- package/dist/group/index.js +12 -0
- package/dist/group/intercompany.d.ts +136 -0
- package/dist/group/intercompany.js +238 -0
- package/dist/group/interest.d.ts +109 -0
- package/dist/group/interest.js +224 -0
- package/dist/group/movement.d.ts +206 -0
- package/dist/group/movement.js +432 -0
- package/dist/group/structure.d.ts +151 -0
- package/dist/group/structure.js +327 -0
- package/dist/group/timeline.d.ts +109 -0
- package/dist/group/timeline.js +169 -0
- package/dist/group/unrealised.d.ts +165 -0
- package/dist/group/unrealised.js +314 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +10 -0
- package/dist/ledger/close.d.ts +75 -0
- package/dist/ledger/close.js +142 -0
- package/dist/ledger/date.d.ts +48 -0
- package/dist/ledger/date.js +141 -0
- package/dist/ledger/entry.d.ts +150 -0
- package/dist/ledger/entry.js +264 -0
- package/dist/ledger/index.d.ts +6 -0
- package/dist/ledger/index.js +6 -0
- package/dist/ledger/ledger.d.ts +101 -0
- package/dist/ledger/ledger.js +308 -0
- package/dist/ledger/serialise.d.ts +68 -0
- package/dist/ledger/serialise.js +204 -0
- package/dist/ledger/trialBalance.d.ts +49 -0
- package/dist/ledger/trialBalance.js +103 -0
- package/dist/money/currency.d.ts +37 -0
- package/dist/money/currency.js +67 -0
- package/dist/money/index.d.ts +3 -0
- package/dist/money/index.js +3 -0
- package/dist/money/money.d.ts +106 -0
- package/dist/money/money.js +300 -0
- package/dist/money/rounding.d.ts +41 -0
- package/dist/money/rounding.js +117 -0
- package/dist/reconcile/accuracy.d.ts +65 -0
- package/dist/reconcile/accuracy.js +162 -0
- package/dist/reconcile/assignment.d.ts +83 -0
- package/dist/reconcile/assignment.js +326 -0
- package/dist/reconcile/bankView.d.ts +65 -0
- package/dist/reconcile/bankView.js +86 -0
- package/dist/reconcile/bridge.d.ts +76 -0
- package/dist/reconcile/bridge.js +129 -0
- package/dist/reconcile/candidates.d.ts +84 -0
- package/dist/reconcile/candidates.js +174 -0
- package/dist/reconcile/decisions.d.ts +116 -0
- package/dist/reconcile/decisions.js +168 -0
- package/dist/reconcile/index.d.ts +12 -0
- package/dist/reconcile/index.js +12 -0
- package/dist/reconcile/matcher.d.ts +89 -0
- package/dist/reconcile/matcher.js +306 -0
- package/dist/reconcile/memory.d.ts +120 -0
- package/dist/reconcile/memory.js +281 -0
- package/dist/reconcile/posting.d.ts +185 -0
- package/dist/reconcile/posting.js +481 -0
- package/dist/reconcile/scoring.d.ts +98 -0
- package/dist/reconcile/scoring.js +319 -0
- package/dist/reconcile/similarity.d.ts +88 -0
- package/dist/reconcile/similarity.js +272 -0
- package/dist/reconcile/subsetSum.d.ts +61 -0
- package/dist/reconcile/subsetSum.js +136 -0
- package/dist/reports/ageing.d.ts +75 -0
- package/dist/reports/ageing.js +149 -0
- package/dist/reports/balanceSheet.d.ts +57 -0
- package/dist/reports/balanceSheet.js +107 -0
- package/dist/reports/incomeStatement.d.ts +59 -0
- package/dist/reports/incomeStatement.js +132 -0
- package/dist/reports/index.d.ts +4 -0
- package/dist/reports/index.js +4 -0
- package/dist/reports/period.d.ts +37 -0
- package/dist/reports/period.js +78 -0
- package/dist/statement/amount.d.ts +55 -0
- package/dist/statement/amount.js +211 -0
- package/dist/statement/columns.d.ts +39 -0
- package/dist/statement/columns.js +221 -0
- package/dist/statement/csv.d.ts +50 -0
- package/dist/statement/csv.js +185 -0
- package/dist/statement/dates.d.ts +52 -0
- package/dist/statement/dates.js +204 -0
- package/dist/statement/duplicates.d.ts +51 -0
- package/dist/statement/duplicates.js +101 -0
- package/dist/statement/import.d.ts +52 -0
- package/dist/statement/import.js +172 -0
- package/dist/statement/index.d.ts +20 -0
- package/dist/statement/index.js +32 -0
- package/dist/statement/line.d.ts +60 -0
- package/dist/statement/line.js +73 -0
- package/dist/statement/ofx.d.ts +47 -0
- package/dist/statement/ofx.js +199 -0
- package/package.json +72 -4
package/README.md
CHANGED
|
@@ -1,3 +1,1494 @@
|
|
|
1
|
-
#
|
|
1
|
+
# tallyd
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A double-entry ledger and bank-reconciliation engine, written in TypeScript.
|
|
4
|
+
|
|
5
|
+
Bookkeeping software is mostly a solved problem right up until the moment you have
|
|
6
|
+
to match a bank statement against your books. Then the amounts are off by a payment
|
|
7
|
+
processor's fee, the dates are off by two days of settlement lag, the description is
|
|
8
|
+
`SQ *COFFEE 4471` and your ledger says `Client lunch — Acme`, and one deposit covers
|
|
9
|
+
three invoices. That matching problem is what this project is actually about.
|
|
10
|
+
|
|
11
|
+
## What is checked, and against what
|
|
12
|
+
|
|
13
|
+
A reconciliation engine is easy to describe and hard to believe. Every one of
|
|
14
|
+
them matches a bank statement against a set of books; the question is how often
|
|
15
|
+
it is right, how often it is *wrong*, and what happens to the lines it cannot
|
|
16
|
+
explain. So here is what was measured rather than what it does. Every figure is
|
|
17
|
+
produced by `npm run demo:evidence`, which runs in CI and exits non-zero if any
|
|
18
|
+
of them moves:
|
|
19
|
+
|
|
20
|
+
| Check | Result |
|
|
21
|
+
| --- | --- |
|
|
22
|
+
| The matcher against generated books with an answer key, 20 periods | 1,786 links, 0 wrong matches, precision 1.000 in every period |
|
|
23
|
+
| The worst of those periods, unattended and then with the review queue | recall 64.8%, 94.9% with suggestions |
|
|
24
|
+
| Candidate pairs the amount-and-date index lets through to scoring | 269 of 90,144 (335x), 1,172 of 1,760,979 (1503x) at 1,419 movements |
|
|
25
|
+
| A counterparty confirmed once, in the next period's unrelated books | recall 75.6% to 89.0%, queue 16 to 5 (68.8% shorter), precision still 1.000 |
|
|
26
|
+
| Trial balance over 625 generated journal entries | 640042.62 GBP against 640042.62 GBP, difference 0.00 GBP |
|
|
27
|
+
| A three-company, three-currency consolidation | 9 balanced entries, residual 0.00 GBP |
|
|
28
|
+
| The translation reserve against the two things that are in it | -27117.51 GBP against adjustments of 22164.01 GBP and -4953.50 GBP of goodwill retranslated, unaccounted for 0.00 GBP |
|
|
29
|
+
| The same quarter read as a markup on cost and as a margin on price | 20000.00 and 25000.00 on the same 100000.00 of stock, the second carrying goods that cost 80000.00 at 75000.00 |
|
|
30
|
+
| One consignment of intragroup stock, sent up to the parent and then down to the subsidiary | stock down 20000.00 either way; the outside quarter bears 5000.00 when its company sold and 0.00 when it only bought |
|
|
31
|
+
|
|
32
|
+
Three of those rows are worth a second look.
|
|
33
|
+
|
|
34
|
+
**The matcher is scored, not described.** The generator in
|
|
35
|
+
`src/demo/generator.ts` builds books of any size *with the answer key*, so
|
|
36
|
+
`measureAccuracy` can compare what the matcher decided against what was
|
|
37
|
+
actually true. Twenty unrelated periods produced 1,786 pairings to find and not
|
|
38
|
+
one wrong match — and the row quotes the *worst* of the twenty rather than the
|
|
39
|
+
mean, because a mean hides the period a reader cares about. Recall of 64.8%
|
|
40
|
+
unattended is the deliberate half of that: a match below the accept threshold
|
|
41
|
+
goes to a review queue rather than through, and counting the queue the figure
|
|
42
|
+
is 94.9%. A wrong auto-accepted match is worse than a queued one, because it
|
|
43
|
+
consumes the evidence and strands the two lines it should have paired.
|
|
44
|
+
|
|
45
|
+
**The speed figure is a count, not a duration.** Milliseconds are a statement
|
|
46
|
+
about the machine they were measured on. The number of candidate pairs the
|
|
47
|
+
amount-and-date index lets through to text comparison is a property of the
|
|
48
|
+
algorithm and is the same everywhere: at a year of a busy account — 1,419
|
|
49
|
+
ledger movements against 1,241 statement lines — 1,172 pairs are scored out of
|
|
50
|
+
the 1,760,979 a matcher comparing everything to everything would look at.
|
|
51
|
+
|
|
52
|
+
**Zero means zero.** Amounts are integer minor units, so a trial balance either
|
|
53
|
+
balances or it does not; there is no tolerance to pick. The consolidation rows
|
|
54
|
+
are there because three companies kept in three currencies have to be translated
|
|
55
|
+
at different rates and the difference has to land somewhere — and it lands in
|
|
56
|
+
the translation reserve, which comes back from its own two components rather
|
|
57
|
+
than from whatever made the page add up. Those two are the entities' own
|
|
58
|
+
translation adjustments, and the exchange difference on goodwill, which is in no
|
|
59
|
+
entity's books at all because goodwill exists only at the group level. Both are
|
|
60
|
+
substantial on this group and the reserve is neither of them, which is what
|
|
61
|
+
makes the row worth printing: an identity whose second term happened to be nil
|
|
62
|
+
would hold no matter what the engine did with it.
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
npm run demo:evidence # all six, non-zero if a figure moves
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Behind them: 1,984 tests on Node 20.19, 22 and 24, including property-based
|
|
69
|
+
sweeps over the ledger and the money type — 2,000 random entries cannot
|
|
70
|
+
unbalance the ledger, and `split(n)` conserves every minor unit across 20,000
|
|
71
|
+
random allocations.
|
|
72
|
+
|
|
73
|
+
## What is built
|
|
74
|
+
|
|
75
|
+
All phases in [`ROADMAP.md`](./ROADMAP.md) are done. The accounting core —
|
|
76
|
+
money, the chart of accounts, journal entries, the ledger, the trial balance —
|
|
77
|
+
and statement ingestion, with CSV and OFX readers, format detection and
|
|
78
|
+
duplicate flagging. The matching engine end to end, including one-to-many and
|
|
79
|
+
many-to-one matches, producing a bank reconciliation that balances to the
|
|
80
|
+
penny. On top: the financial statements, a CLI that runs the whole thing against
|
|
81
|
+
files on disk, and a dashboard that turns the review queue into something you
|
|
82
|
+
can work through.
|
|
83
|
+
|
|
84
|
+
The cycle closes at both ends. Decisions leave the review UI as a file the CLI
|
|
85
|
+
already reads, and the statement lines nothing in the books explains come back
|
|
86
|
+
as journal entries — classified, balanced, and impossible to post twice.
|
|
87
|
+
|
|
88
|
+
It holds more than one currency. Rates are exact rationals with dated lookup
|
|
89
|
+
that inverts and triangulates; balances in another currency are retranslated at
|
|
90
|
+
the close, per open item so that settling one invoice out of several cannot
|
|
91
|
+
count a rate movement twice; and the statements can be presented in a currency
|
|
92
|
+
the books are not kept in, with the translation adjustment as a line rather than
|
|
93
|
+
a plug.
|
|
94
|
+
|
|
95
|
+
And it consolidates. A group is several sets of books in several currencies,
|
|
96
|
+
holdings that run through one company to reach another, balances that eliminate
|
|
97
|
+
against each other, and shareholders outside the group with a claim on companies
|
|
98
|
+
inside it. All of it comes out as a real ledger in the presentation currency,
|
|
99
|
+
one balanced journal entry per step, so every consolidated figure traces back to
|
|
100
|
+
what put it there. The group can change shape during the period, too: a company
|
|
101
|
+
bought in April contributes nine months of results and a full balance sheet, one
|
|
102
|
+
sold in September contributes eight months and no balance sheet at all, and the
|
|
103
|
+
second is measured against what the group was carrying it at rather than against
|
|
104
|
+
what the shares cost.
|
|
105
|
+
|
|
106
|
+
## Installing it
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
npm install tallyd
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
import { Money, Ledger, reconcile } from "tallyd";
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
The command line comes with it:
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
npx tallyd --help
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Node 20.19 or newer. The package ships compiled JavaScript and type
|
|
123
|
+
declarations and has no runtime dependencies.
|
|
124
|
+
|
|
125
|
+
## Running it from source
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
npm install
|
|
129
|
+
npm test # 1,984 tests
|
|
130
|
+
npm run typecheck
|
|
131
|
+
npm run demo # a worked month, posted and reported
|
|
132
|
+
npm run demo:ingest # the same month as the bank recorded it
|
|
133
|
+
npm run demo:reconcile # the two, matched against each other
|
|
134
|
+
npm run demo:reports # income statement, balance sheet and ageing
|
|
135
|
+
npm run demo:foreign # a quarter with a euro customer and a dollar supplier
|
|
136
|
+
npm run demo:group # three companies in three currencies, consolidated
|
|
137
|
+
npm run demo:periods # that group two years running, and what moved between
|
|
138
|
+
npm run demo:midyear # a company bought in April, consolidated both ways
|
|
139
|
+
npm run demo:evidence # the front page's figures, recomputed
|
|
140
|
+
npm run bench # the matcher timed and scored over generated books
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## The command line
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
npm run build
|
|
147
|
+
node dist/src/cli/main.js --help
|
|
148
|
+
|
|
149
|
+
tallyd report -l books.json --from 2026-07-01 --to 2026-09-30 --compare 2026-04-01:2026-06-30
|
|
150
|
+
tallyd ageing -l books.json -a 1130 --as-at 2026-09-30
|
|
151
|
+
tallyd reconcile -l books.json -s statement.csv
|
|
152
|
+
tallyd import -s statement.csv # what the reader made of it, no matching
|
|
153
|
+
tallyd accounts -l books.json
|
|
154
|
+
tallyd dashboard -l books.json -s statement.csv -o reconciliation.html
|
|
155
|
+
tallyd generate -o ./fixtures --months 12 --invoices 30 --truth
|
|
156
|
+
tallyd rates -r ecb.csv -b EUR -p USD/GBP --on 2026-03-13 --amount 5000.00
|
|
157
|
+
tallyd revalue -l books.json -r ecb.csv -b EUR --as-at 2026-03-31 -o closed.json
|
|
158
|
+
tallyd report -l books.json -p USD -r usd.csv -b GBP --as-at 2026-12-31
|
|
159
|
+
tallyd bench --sizes 1:10,6:15,12:30
|
|
160
|
+
tallyd learn -m memory.json -d decisions.json
|
|
161
|
+
tallyd reconcile -l books.json -s statement.csv -m memory.json
|
|
162
|
+
tallyd post -l books.json -s statement.csv -d decisions.json -o after.json
|
|
163
|
+
tallyd consolidate -g group.json -r rates.csv --show all -o group-books.json
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Every command takes `--json`. Exit codes carry meaning: `0` success, `1` a bad
|
|
167
|
+
file or a bad flag, and `2` for work that ran to completion and came out wrong —
|
|
168
|
+
a reconciliation that does not balance, a trial balance that does not agree. A
|
|
169
|
+
CI job that treats an unbalanced reconciliation as a pass is worse than no CI
|
|
170
|
+
job at all.
|
|
171
|
+
|
|
172
|
+
```
|
|
173
|
+
$ tallyd reconcile -l month.json -s bank.csv
|
|
174
|
+
Reconciling 1110 against 12 statement lines
|
|
175
|
+
7 matched, 2 to review, 1 + 3 unmatched
|
|
176
|
+
...
|
|
177
|
+
Reconciled 0.00
|
|
178
|
+
$ echo $?
|
|
179
|
+
0
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
`npm run demo` posts a month of transactions for a small consultancy and prints the
|
|
183
|
+
trial balance, the totals by account type, and a running bank statement:
|
|
184
|
+
|
|
185
|
+
```
|
|
186
|
+
Trial balance (GBP)
|
|
187
|
+
---------------------------------------------------------
|
|
188
|
+
Account Name Debit Credit
|
|
189
|
+
1110 Bank 23143.58
|
|
190
|
+
2200 VAT Payable 1200.00
|
|
191
|
+
2300 Payroll Liabilities 2180.00
|
|
192
|
+
3100 Share Capital 25000.00
|
|
193
|
+
4100 Sales 480.00
|
|
194
|
+
4200 Consulting 6000.00
|
|
195
|
+
5200 Salaries 9400.00
|
|
196
|
+
5300 Rent 1850.00
|
|
197
|
+
5400 Software 299.00
|
|
198
|
+
5500 Payment Processing Fees 6.92
|
|
199
|
+
5700 Professional Fees 142.50
|
|
200
|
+
5800 Bank Charges 18.00
|
|
201
|
+
---------------------------------------------------------
|
|
202
|
+
Total 34860.00 34860.00
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
The month is deliberately awkward in the ways real books are. A £480 card sale is
|
|
206
|
+
captured on the 5th but settles on the 7th as £473.08, net of a 1.4% + 20p fee, so
|
|
207
|
+
the deposit that reaches the bank matches no invoice exactly — the case the
|
|
208
|
+
matcher exists for. A £299 licence is split three ways across projects with no penny
|
|
209
|
+
lost. An expense posted to the wrong account is corrected by reversal, not by
|
|
210
|
+
editing history, so `JE-008` is still there next to the `JE-009` that undoes it.
|
|
211
|
+
|
|
212
|
+
`npm run demo:ingest` reads the bank's own record of that month — a CSV with a
|
|
213
|
+
three-line preamble, day-first dates and separate paid-out/paid-in columns — and
|
|
214
|
+
prints what the importer made of it:
|
|
215
|
+
|
|
216
|
+
```
|
|
217
|
+
Bank statement — CSV
|
|
218
|
+
12 lines, 0 duplicates, 0 errors from 12 rows (DD/MM/YYYY, '.' decimal, GBP)
|
|
219
|
+
columns: 0:date 1:description 2:debit 3:credit 4:balance
|
|
220
|
+
|
|
221
|
+
Near-duplicates worth a second look
|
|
222
|
+
2026-08-14 and 2026-08-15 (1d apart): -142.50 TO BISTRO
|
|
223
|
+
|
|
224
|
+
Descriptions as the matcher will see them
|
|
225
|
+
SQ *SETTLEMENT 0805 4471 → SETTLEMENT 0805
|
|
226
|
+
FPI ACME LTD — INV1001 → ACME LTD INV1001
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
`npm run demo:reconcile` is the one that matters. It runs the matcher over both,
|
|
230
|
+
prints what it decided and why, and finishes with the reconciliation statement:
|
|
231
|
+
|
|
232
|
+
```
|
|
233
|
+
Matched
|
|
234
|
+
1:1 exact 0.970 7200.00
|
|
235
|
+
bank 2026-08-12 FPI ACME LTD INV1001
|
|
236
|
+
ledger 2026-08-12 Invoice 1001 settled
|
|
237
|
+
· amount: exact at 7200.00
|
|
238
|
+
· date: same day
|
|
239
|
+
· reference: reference 1001
|
|
240
|
+
1:N high 0.883 5160.00
|
|
241
|
+
bank 2026-09-20 FPI NORTHWIND LTD INV1042
|
|
242
|
+
ledger 2026-09-20 Northwind Ltd — INV1042
|
|
243
|
+
ledger 2026-09-20 Northwind Ltd — INV1043
|
|
244
|
+
ledger 2026-09-20 Northwind Ltd — INV1044
|
|
245
|
+
|
|
246
|
+
Review queue
|
|
247
|
+
1:1 medium 0.709 -142.50
|
|
248
|
+
bank 2026-08-15 CARD PAYMENT TO BISTRO ON 14-AUG
|
|
249
|
+
ledger 2026-08-15 Client dinner — recoded
|
|
250
|
+
|
|
251
|
+
Bank reconciliation (GBP)
|
|
252
|
+
Balance per bank statement 20764.20
|
|
253
|
+
Add: receipts not yet on the statement
|
|
254
|
+
2026-08-15 Reverse JE-008 — miscoded to Tra 142.50
|
|
255
|
+
Less: payments not yet on the statement
|
|
256
|
+
2026-08-14 Client dinner -142.50
|
|
257
|
+
2026-08-15 Client dinner — recoded -142.50
|
|
258
|
+
Adjusted bank balance 20621.70
|
|
259
|
+
...
|
|
260
|
+
Reconciled 0.00
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
## The dashboard
|
|
264
|
+
|
|
265
|
+
`tallyd dashboard` writes one HTML file — inline styles, inline script, data
|
|
266
|
+
embedded as JSON, nothing fetched. It opens from a file path on a machine with
|
|
267
|
+
no network and no toolchain, which matters because the person who most needs to
|
|
268
|
+
look at a reconciliation is often the one who cannot install anything.
|
|
269
|
+
|
|
270
|
+
It is not a rendering of the CLI output. Accepting a suggestion moves it into
|
|
271
|
+
the matched set and recomputes the bridge in front of you; rejecting one pushes
|
|
272
|
+
both lines back into the leftovers. An undecided suggestion counts as
|
|
273
|
+
outstanding on *both* sides, so the arithmetic is honest about what has not been
|
|
274
|
+
confirmed yet. The ledger explorer lists every account that has been posted to,
|
|
275
|
+
with grouping accounts rolled up, and drills into the postings behind any of
|
|
276
|
+
them.
|
|
277
|
+
|
|
278
|
+
### Getting the decisions back out
|
|
279
|
+
|
|
280
|
+
Working through a queue produces the only new information in the whole cycle: a
|
|
281
|
+
person asserting which counterparties are the same counterparty. The page hands
|
|
282
|
+
that back as a decisions file, in the format `tallyd learn` reads.
|
|
283
|
+
|
|
284
|
+
```bash
|
|
285
|
+
tallyd dashboard -l books.json -s statement.csv -a 1110 -o review.html
|
|
286
|
+
# work the queue, press Export decisions, then:
|
|
287
|
+
tallyd learn -m memory.json -d ~/Downloads/decisions-1110-2026-04-30.json
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
A decision is reversible until it is exported — the bar along the bottom counts
|
|
291
|
+
what is pending and undoes the last one, and the *Decided* section undoes any of
|
|
292
|
+
them. Nothing is written anywhere until you ask for the file.
|
|
293
|
+
|
|
294
|
+
The design decision that mattered here is where the format lives. The payload
|
|
295
|
+
for each suggestion — which two descriptions are being paired, and the amount,
|
|
296
|
+
date, kind and confidence that ride along as context — is computed in
|
|
297
|
+
TypeScript and baked into the page. The browser stamps a verdict and today's
|
|
298
|
+
date onto it and serialises the result. That is the whole of the client's
|
|
299
|
+
involvement with the format, so what can go wrong in the browser is a boolean
|
|
300
|
+
and a date rather than a schema. A format definition living in a string of
|
|
301
|
+
hand-written client JavaScript is a format definition nobody can test.
|
|
302
|
+
|
|
303
|
+
Group matches expand: accepting `BACS SUPPLIER RUN 724262` against three
|
|
304
|
+
supplier payments is one click but three facts, because next month the same
|
|
305
|
+
batch arrives with a different reference and the same three names in it. The
|
|
306
|
+
expansion is the cross product of the descriptions on each side, deduplicated,
|
|
307
|
+
and bounded at sixteen by the matcher's own four-a-side group cap.
|
|
308
|
+
|
|
309
|
+
## Generated books, and performance
|
|
310
|
+
|
|
311
|
+
Everything above was developed against a hand-written month: a dozen statement
|
|
312
|
+
lines, a couple of dozen postings. That is enough to prove the logic and nothing
|
|
313
|
+
like enough to prove it scales, so `tallyd generate` builds books of any size.
|
|
314
|
+
|
|
315
|
+
It is seeded, so the same seed produces the same books byte for byte, and it is
|
|
316
|
+
deliberately awkward in the ways real books are — settlement lag, processor fees
|
|
317
|
+
netted off the deposit, batch supplier runs that leave the bank as one debit,
|
|
318
|
+
charges nobody booked, cheques that clear next period, and descriptors written
|
|
319
|
+
the way a bank writes them rather than the way a bookkeeper does.
|
|
320
|
+
|
|
321
|
+
The part that matters most is `--truth`: because the generator built both sides,
|
|
322
|
+
it knows which statement line really came from which posting. That answer key is
|
|
323
|
+
what turns a benchmark from "how fast" into "how fast, and still right".
|
|
324
|
+
|
|
325
|
+
```bash
|
|
326
|
+
tallyd generate -o ./fixtures --months 12 --invoices 30 --truth
|
|
327
|
+
tallyd bench --sizes 1:10,6:15,12:30,24:40 --repeat 2
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
```
|
|
331
|
+
size books lines ms scored prec rec rec+
|
|
332
|
+
1m x10 22 20 3.2 4.09% 100.0% 79.0% 94.7%
|
|
333
|
+
3m x12 77 70 6.2 1.21% 100.0% 76.5% 95.6%
|
|
334
|
+
6m x15 189 171 12.3 0.50% 100.0% 73.5% 96.4%
|
|
335
|
+
12m x15 380 329 23.0 0.24% 100.0% 72.7% 95.9%
|
|
336
|
+
12m x30 715 625 189.4 0.13% 100.0% 76.9% 96.7%
|
|
337
|
+
24m x40 1916 1657 1655.4 0.05% 100.0% 77.5% 96.6%
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
Precision is 100% at every size: the matcher never auto-accepted a pairing the
|
|
341
|
+
generator says is wrong. That is the number to watch, because the two kinds of
|
|
342
|
+
error are not equally bad. A missed match costs a reviewer a minute; a *wrong*
|
|
343
|
+
match costs them an afternoon, because the evidence has been consumed and the
|
|
344
|
+
two lines that should have gone together are now stranded elsewhere in the
|
|
345
|
+
report. `rec` is what went through unattended and `rec+` counts a correct
|
|
346
|
+
suggestion as found, which is what a reviewer actually experiences.
|
|
347
|
+
|
|
348
|
+
<h3 id="performance">What made it fast</h3>
|
|
349
|
+
|
|
350
|
+
The 12m x30 row took **16.8 seconds** before this work and takes **0.2** now,
|
|
351
|
+
from two changes. Both came from taking the scorer's own structure seriously
|
|
352
|
+
rather than from micro-optimisation.
|
|
353
|
+
|
|
354
|
+
**The one-to-one pass was scoring every pair.** All of them: 715 x 625 is
|
|
355
|
+
446,875 comparisons, each running Levenshtein and Jaro-Winkler over two
|
|
356
|
+
descriptions to produce, 99.9% of the time, a rejection. But the scorer's first
|
|
357
|
+
rules are *gates*, not contributions — a pair dies outright if the currencies
|
|
358
|
+
differ, if the amounts differ by more than the tolerance, if the signs disagree,
|
|
359
|
+
or if the dates fall outside the window. Three of those four are indexable. So
|
|
360
|
+
book lines are bucketed by currency and amount and kept sorted by date, and a
|
|
361
|
+
statement line's candidates are a map lookup plus a binary search. The `scored`
|
|
362
|
+
column above is the share that survived to be scored at all — 0.13% on that row.
|
|
363
|
+
|
|
364
|
+
The index's contract is exact rather than approximate: it admits precisely the
|
|
365
|
+
pairs the scorer would not reject, never a superset and never a subset. Blocking
|
|
366
|
+
that quietly dropped a real pair would cost recall while looking like a speedup,
|
|
367
|
+
which is why the equivalence is a property test rather than a comment.
|
|
368
|
+
|
|
369
|
+
**What survives is sparse, so it decomposes.** The Hungarian solver needs a
|
|
370
|
+
complete matrix and pads it to `(rows + cols)` square, making it cubic in the
|
|
371
|
+
total number of lines whether or not the pairs are plausible. But a matching
|
|
372
|
+
cannot use an edge between two components of a graph, because there is none — so
|
|
373
|
+
each connected component is solved on its own and the answers concatenated. The
|
|
374
|
+
result is exactly as optimal as solving the whole thing at once; the cost
|
|
375
|
+
becomes cubic in the largest island rather than in the size of the books.
|
|
376
|
+
|
|
377
|
+
**Group matching was searching both directions at once.** Finding which
|
|
378
|
+
invoices add up to one batch debit is subset-sum, pruned by bounds on what the
|
|
379
|
+
remaining values could still contribute. With inflows and outflows in the same
|
|
380
|
+
pool that bound spans everything from the sum of all the debits to the sum of
|
|
381
|
+
all the credits, so no branch can ever be ruled out and the search degenerates.
|
|
382
|
+
Restricting the pool to movements going the same way as the anchor is what the
|
|
383
|
+
direction gate already implies — a supplier run is all outflows, a lump-sum
|
|
384
|
+
receipt all inflows — and it took the group pass from 8.3 seconds to 0.2. It is
|
|
385
|
+
also more correct: a "group" mixing directions is money in and money out that
|
|
386
|
+
happen to cancel, which is exactly the coincidence the matcher should refuse.
|
|
387
|
+
|
|
388
|
+
The worst case has not gone away and is not pretended away. Books where hundreds
|
|
389
|
+
of movements share one amount and one date are a single component with no
|
|
390
|
+
decomposition to exploit, and that stays a dense solve. It stays correct; it
|
|
391
|
+
does not stay fast.
|
|
392
|
+
|
|
393
|
+
## What it remembers
|
|
394
|
+
|
|
395
|
+
Every month the same supplier arrives with a new reference. The bank writes
|
|
396
|
+
`FPO ASHGROVE SUPPLIES 4471 BILL-3104` in March and `SO ASHGROVE SUPPLIES 8822
|
|
397
|
+
BILL-3391` in April; the books say `Payment — Ashgrove Supplies` both times. The
|
|
398
|
+
description rule scores the two months identically, so a pair a human confirmed
|
|
399
|
+
in March lands back in the review queue in April, and in May, and for as long as
|
|
400
|
+
anyone uses the tool. That is the cheapest training signal there is being thrown
|
|
401
|
+
away — a confirmed match is a person asserting the identity the description rule
|
|
402
|
+
is guessing at.
|
|
403
|
+
|
|
404
|
+
What gets remembered is the **counterparty**, not the transaction. Transactions
|
|
405
|
+
never repeat; counterparties do. Anything carrying a digit is a reference, a
|
|
406
|
+
terminal id or a date, so it goes, and what is left is the name — stable on both
|
|
407
|
+
sides, month after month.
|
|
408
|
+
|
|
409
|
+
```bash
|
|
410
|
+
tallyd learn -m memory.json -d decisions.json # fold in what a reviewer decided
|
|
411
|
+
tallyd learn -m memory.json --show # read back what it thinks it knows
|
|
412
|
+
tallyd reconcile -l books.json -s statement.csv -m memory.json
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
```
|
|
416
|
+
$ tallyd learn -m memory.json --show
|
|
417
|
+
2 remembered pairings
|
|
418
|
+
|
|
419
|
+
bank ledger yes/no last seen
|
|
420
|
+
BACS PAYROLL NET PAY PAYROLL 1/0 2026-04-30
|
|
421
|
+
PROPERTY RENT MONTHLY RENT 1/0 2026-04-30
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
The file is plain JSON of exactly that, which is deliberate. A memory nobody can
|
|
425
|
+
read is a memory nobody can correct, and being able to open it and delete a line
|
|
426
|
+
is the difference between a tool that learns and a tool that quietly drifts.
|
|
427
|
+
|
|
428
|
+
Three things are worth remembering, not one. A **confirmation** is evidence for.
|
|
429
|
+
A **rejection** is evidence against, and outweighs a confirmation of the same
|
|
430
|
+
pairing — proposing a pair a reviewer already refused is worse than proposing
|
|
431
|
+
nothing, because it teaches them the queue is not worth reading. And a bank name
|
|
432
|
+
seen before but only ever confirmed against a *different* ledger counterparty is
|
|
433
|
+
evidence against too, which is the case that absence-of-memory would silently
|
|
434
|
+
score as neutral.
|
|
435
|
+
|
|
436
|
+
Measured on generated books — trained on one four-month period, then run against
|
|
437
|
+
a different one with the same cast of customers and suppliers:
|
|
438
|
+
|
|
439
|
+
| | auto-accepted | review queue | precision | recall |
|
|
440
|
+
|---|---|---|---|---|
|
|
441
|
+
| cold | 62 | 16 | 100% | 75.6% |
|
|
442
|
+
| with memory | 73 | 5 | 100% | 89.0% |
|
|
443
|
+
|
|
444
|
+
Eleven more matches went through unattended, the queue shrank by 69%, and not
|
|
445
|
+
one of the extra matches was wrong. Precision is the number that makes the rest
|
|
446
|
+
of the table mean anything: auto-accepting more is only an improvement if the
|
|
447
|
+
extra ones are right.
|
|
448
|
+
|
|
449
|
+
### What memory is not allowed to do
|
|
450
|
+
|
|
451
|
+
It never overrides a gate. A remembered counterparty does not make a £40
|
|
452
|
+
discrepancy acceptable, does not reconcile a debit against a credit, and does not
|
|
453
|
+
pull a pair back inside the date window. It is weighted level with description —
|
|
454
|
+
a reviewer's assertion should not count for less than the wording guess it
|
|
455
|
+
replaces — and no higher, because it is evidence about *who*, not about *which
|
|
456
|
+
transaction*: two payments to the same supplier in one week are remembered
|
|
457
|
+
equally well. In practice it lifts a pair the numbers already agree on over the
|
|
458
|
+
auto-accept line, and it cannot carry a weak one there on its own.
|
|
459
|
+
|
|
460
|
+
It does get one veto. A pairing a reviewer has refused is denied the exact-match
|
|
461
|
+
floor that would otherwise restore its score to 0.95 whatever else was known.
|
|
462
|
+
Without that the refusal would change nothing, which is the one outcome
|
|
463
|
+
guaranteed to make somebody stop using the review queue.
|
|
464
|
+
|
|
465
|
+
With no memory supplied, scoring is exactly what it was before any of this
|
|
466
|
+
existed — the rule contributes nothing and is not averaged in, the same
|
|
467
|
+
treatment the reference rule gets when neither side carries one.
|
|
468
|
+
|
|
469
|
+
## Booking what the statement says happened
|
|
470
|
+
|
|
471
|
+
A reconciliation ends with statement lines the ledger has never heard of: the
|
|
472
|
+
bank charge nobody entered, the interest, the direct debit set up two years
|
|
473
|
+
ago, the card processor's cut. They are not matching failures — there is
|
|
474
|
+
genuinely nothing to match them against — and the reconciliation will not
|
|
475
|
+
balance until somebody books them. That somebody used to retype each one and
|
|
476
|
+
guess the account, and the guess was recorded nowhere.
|
|
477
|
+
|
|
478
|
+
```bash
|
|
479
|
+
tallyd post -l books.json -s statement.csv -a 1110 # what would be booked
|
|
480
|
+
tallyd post -l books.json -s statement.csv -a 1110 -o after.json # and book it
|
|
481
|
+
```
|
|
482
|
+
|
|
483
|
+
```
|
|
484
|
+
$ tallyd post -l books.json -s statement.csv -a 1110
|
|
485
|
+
|
|
486
|
+
5 statement lines have no counterpart in the books
|
|
487
|
+
|
|
488
|
+
date description amount account rule
|
|
489
|
+
2026-01-27 BACS SUPPLIER RUN 724262 -2053.00 — unclassified
|
|
490
|
+
2026-02-03 ACCOUNT MAINTENANCE FEE -1.64 5800 bank-charges
|
|
491
|
+
2026-02-26 BACS SUPPLIER RUN 178732 -4849.00 — unclassified
|
|
492
|
+
2026-03-11 ACCOUNT MAINTENANCE FEE -1.84 5800 bank-charges
|
|
493
|
+
2026-03-28 BACS SUPPLIER RUN 709648 -7151.00 — unclassified
|
|
494
|
+
|
|
495
|
+
2 to book, 0 skipped, 0 already in the ledger, 3 unclassified
|
|
496
|
+
Net effect on the bank account: -3.48
|
|
497
|
+
|
|
498
|
+
5800 -3.48 (2 lines)
|
|
499
|
+
```
|
|
500
|
+
|
|
501
|
+
Three refusals shape this, and they are the interesting part.
|
|
502
|
+
|
|
503
|
+
**It proposes; it does not post.** Every proposal carries the statement line it
|
|
504
|
+
came from, the rule that classified it, and a balanced entry. `--out` is a
|
|
505
|
+
separate thing to ask for.
|
|
506
|
+
|
|
507
|
+
**It never invents an account.** A line no rule matches comes back
|
|
508
|
+
unclassified — the three supplier runs above — rather than being swept into a
|
|
509
|
+
suspense account the chart does not have. That is how a chart of accounts rots:
|
|
510
|
+
the account appears, everything awkward goes in it, and nobody ever empties it.
|
|
511
|
+
`--suspense 1120` names an existing account and takes responsibility for it;
|
|
512
|
+
`--strict` turns anything left unclassified into exit code 2, which is what a
|
|
513
|
+
pipeline wants.
|
|
514
|
+
|
|
515
|
+
**Posting the same statement twice is a no-op.** Entry ids are derived from the
|
|
516
|
+
statement line's fingerprint — date, amount, normalised description — so the
|
|
517
|
+
same line always implies the same id, and an id already in the ledger is
|
|
518
|
+
reported as already booked rather than posted again. Overlapping exports are
|
|
519
|
+
the normal case, not an edge case. In practice the second run does not even get
|
|
520
|
+
that far: the entries posted on the first run now *match* those lines, so they
|
|
521
|
+
never reach the proposal stage.
|
|
522
|
+
|
|
523
|
+
Classification is an ordered rule list, and the order is load-bearing.
|
|
524
|
+
`INTEREST` outbound is a cost of borrowing and `INTEREST` inbound is income, so
|
|
525
|
+
direction is part of the test, not an afterthought. Opening balance lines are
|
|
526
|
+
skipped before anything can read them as a receipt — the bank telling you where
|
|
527
|
+
it thinks you started is not a transaction. The rules are plain JSON and
|
|
528
|
+
`--rules` replaces them wholesale, because a chart that is not this chart needs
|
|
529
|
+
its own.
|
|
530
|
+
|
|
531
|
+
### What the reviewer's decisions have to do with it
|
|
532
|
+
|
|
533
|
+
Only lines with *no* suggestion are proposed. A line sitting in the review queue
|
|
534
|
+
has a ledger counterpart the matcher believes in, and booking a second entry for
|
|
535
|
+
it would double-count the transaction. Undecided means unproposed: a missed
|
|
536
|
+
entry is a reconciliation that does not balance, and a duplicated entry is a
|
|
537
|
+
reconciliation that balances and is wrong.
|
|
538
|
+
|
|
539
|
+
A rejection changes that. `tallyd post -d decisions.json` reads the file the
|
|
540
|
+
dashboard exports, and a suggestion whose every description pair the reviewer
|
|
541
|
+
refused is treated as no suggestion at all — its statement lines join the ones
|
|
542
|
+
needing entries. A batch where only one of four suppliers was refused stays
|
|
543
|
+
where it is; part of it is still something the books know about.
|
|
544
|
+
|
|
545
|
+
The dashboard shows the same thing under *What this implies*, and recomputes it
|
|
546
|
+
as you work: rejecting a suggestion adds a row in the same gesture that pushes
|
|
547
|
+
its lines back into the leftovers. Every statement line carries a precomputed
|
|
548
|
+
proposal for exactly this reason — the browser cannot classify anything, so the
|
|
549
|
+
classification has to be there before it is needed.
|
|
550
|
+
|
|
551
|
+
## Rates
|
|
552
|
+
|
|
553
|
+
Everything above holds one currency at a time. A business that invoices in euros
|
|
554
|
+
and banks in sterling needs somewhere for a price to come from, and it has to be
|
|
555
|
+
as exact as the rest of the money path — so a rate is a pair of bigints, not a
|
|
556
|
+
float. `0.8473` as an IEEE-754 double is `0.847300000000000053290705182007513940334320068359375`,
|
|
557
|
+
and that error compounds through an inversion, a triangulation and a
|
|
558
|
+
multiplication before landing in a revaluation entry where it looks like a real
|
|
559
|
+
gain.
|
|
560
|
+
|
|
561
|
+
```bash
|
|
562
|
+
tallyd rates -r ecb.csv -b EUR # what the table holds
|
|
563
|
+
tallyd rates -r ecb.csv -b EUR -p EUR/GBP --on 2026-03-15
|
|
564
|
+
tallyd rates -r ecb.csv -b EUR -p USD/GBP --amount 5000.00
|
|
565
|
+
tallyd rates -r ecb.csv -b EUR -p EUR/GBP --average 2026-03-01:2026-03-31
|
|
566
|
+
```
|
|
567
|
+
|
|
568
|
+
Rate files are sparse in three ways at once and a lookup has to survive all
|
|
569
|
+
three. Sparse in **time**: rates are published on business days and asked for on
|
|
570
|
+
any day, so lookup is on-or-before with a bound on staleness — a payment dated
|
|
571
|
+
Sunday takes Friday's close, and a quote from four hundred days ago is not an
|
|
572
|
+
answer. Sparse in **direction**: a file quoting EUR/GBP answers GBP/EUR by
|
|
573
|
+
inversion. Sparse in **pairs**: a file quoting everything against the euro
|
|
574
|
+
answers USD/GBP by going through it, found by a breadth-first walk so the answer
|
|
575
|
+
uses the fewest legs available and ties break deterministically.
|
|
576
|
+
|
|
577
|
+
Every answer carries how it was reached:
|
|
578
|
+
|
|
579
|
+
```
|
|
580
|
+
1 USD = 0.781858 GBP on 2026-03-14
|
|
581
|
+
via USD -> EUR -> GBP, quotes 2026-03-13, 2026-03-13
|
|
582
|
+
1 day behind the date asked for
|
|
583
|
+
source: ecb.csv
|
|
584
|
+
```
|
|
585
|
+
|
|
586
|
+
A triangulated rate composes exactly and rounds once, at the end. Converting leg
|
|
587
|
+
by leg does not: 0.01 EUR through two rates of 1.5 gives 0.03 stepwise and 0.02
|
|
588
|
+
composed, and 0.0225 is the true answer.
|
|
589
|
+
|
|
590
|
+
Two readers, because rates arrive in two shapes. The JSON document is
|
|
591
|
+
canonical — versioned, explicit about direction, carrying its own staleness
|
|
592
|
+
bound. The CSV reader handles what actually comes out of a provider: a wide
|
|
593
|
+
table, one row per date, one column per currency, with blank cells for the days
|
|
594
|
+
that market was shut.
|
|
595
|
+
|
|
596
|
+
Period averages come in two kinds, because "the average rate for March" means
|
|
597
|
+
two different things. `--method quoted` averages the quotes that were published,
|
|
598
|
+
which under-weights days a market was closed. `--method daily` (the default)
|
|
599
|
+
averages one rate per calendar day, carrying the last close forward over
|
|
600
|
+
weekends — which is what a monthly translation wants. Both are exact means of
|
|
601
|
+
rationals, reduced at each step, so a year of daily quotes averages to the same
|
|
602
|
+
value whatever order they arrive in.
|
|
603
|
+
|
|
604
|
+
## Balances in another currency
|
|
605
|
+
|
|
606
|
+
An account denominated in something other than the chart's currency is what
|
|
607
|
+
makes a balance an exposure. Nothing needs a monetary/non-monetary flag: a
|
|
608
|
+
machine bought in euros and carried at historical cost never had a foreign
|
|
609
|
+
denomination — it was booked straight to a sterling asset account at the rate on
|
|
610
|
+
the day — so it never shows up and is never retranslated. That is the right
|
|
611
|
+
answer, and it falls out of the model rather than being enforced by a rule.
|
|
612
|
+
|
|
613
|
+
A posting on such an account carries two amounts:
|
|
614
|
+
|
|
615
|
+
```ts
|
|
616
|
+
{ account: "1131", amount: Money.parse("840.00", GBP), foreign: Money.parse("1000.00", EUR) }
|
|
617
|
+
```
|
|
618
|
+
|
|
619
|
+
`amount` is what the books are kept in and what the entry has to balance in.
|
|
620
|
+
The invariant does not become "balances in every currency at once", because
|
|
621
|
+
that was never true of a real transaction: a euro invoice is one economic event
|
|
622
|
+
with two numbers attached, 1,000 EUR of receivable booked at 840.00 GBP. The
|
|
623
|
+
second is what the trial balance adds up; the first is what the customer owes.
|
|
624
|
+
|
|
625
|
+
### The close, and what settlement realises
|
|
626
|
+
|
|
627
|
+
```bash
|
|
628
|
+
tallyd revalue -l books.json --show # what is exposed
|
|
629
|
+
tallyd revalue -l books.json -r ecb.json --as-at 2026-03-31
|
|
630
|
+
tallyd revalue -l books.json -r ecb.json --as-at 2026-03-31 -o closed.json
|
|
631
|
+
```
|
|
632
|
+
|
|
633
|
+
The revaluation retranslates each balance at the closing rate and books the
|
|
634
|
+
difference. It computes and prints by default and only writes when told to,
|
|
635
|
+
because the rate you use at a close is a choice and the person making it should
|
|
636
|
+
see the numbers before the ledger grows an entry.
|
|
637
|
+
|
|
638
|
+
Two properties it is built around. It is **idempotent**: the adjustment is
|
|
639
|
+
measured against the carrying amount, which already includes every revaluation
|
|
640
|
+
posted before it, so running it twice on the same date produces the second
|
|
641
|
+
entry as nothing at all. The usual reverse-it-next-month dance is therefore
|
|
642
|
+
optional rather than required — leaving March's revaluation in place and
|
|
643
|
+
running June's gives the same balance sheet either way, and there is a test that
|
|
644
|
+
says so.
|
|
645
|
+
|
|
646
|
+
And it works **per open item**. An account holding two invoices booked at
|
|
647
|
+
different rates has no single rate it was booked at:
|
|
648
|
+
|
|
649
|
+
```
|
|
650
|
+
Account Name Balance Carried Closing Move
|
|
651
|
+
1131 Accounts Receivable (EUR) / INV-014 1000.00 EUR 840.00 860.00 20.00
|
|
652
|
+
1131 Accounts Receivable (EUR) / INV-021 500.00 EUR 425.00 430.00 5.00
|
|
653
|
+
2101 Accounts Payable (USD) / BILL-221 -500.00 USD -390.00 -385.00 5.00
|
|
654
|
+
```
|
|
655
|
+
|
|
656
|
+
That is the design decision that mattered, and it was not the first attempt.
|
|
657
|
+
Adjusting each account as one balance passes every obvious test — the balance
|
|
658
|
+
sheet is right, the entry balances, the trial balance agrees — and is still
|
|
659
|
+
wrong, because the adjustment ends up attributable to the account and not to
|
|
660
|
+
either invoice. Settle one of them afterwards and it measures against what that
|
|
661
|
+
invoice was originally booked at, so the movement between the invoice and the
|
|
662
|
+
close gets counted twice: once as unrealised in the first quarter, once as
|
|
663
|
+
realised in the second. The demo caught it by adding the two halves up and
|
|
664
|
+
finding 45.00 where the rates only ever moved 40.00.
|
|
665
|
+
|
|
666
|
+
The fix is a posting that can name the open item it belongs to. A revaluation
|
|
667
|
+
adjusts several invoices in one entry, each on its own line carrying its own
|
|
668
|
+
reference, and settlement then measures against what that item is *now carried
|
|
669
|
+
at* rather than what it was booked at:
|
|
670
|
+
|
|
671
|
+
```
|
|
672
|
+
INV-021 500.00 EUR carried at 430.00 received 435.00 realised 5.00
|
|
673
|
+
```
|
|
674
|
+
|
|
675
|
+
Booked at 0.8500, revalued to 0.8600, received at 0.8700 — 5.00 unrealised in
|
|
676
|
+
the first quarter and 5.00 realised in the second, adding to the 10.00 the rate
|
|
677
|
+
actually moved. `npm run demo:foreign` runs the whole quarter and checks that
|
|
678
|
+
arithmetic against the rates directly, without reference to any of the entries
|
|
679
|
+
it just posted.
|
|
680
|
+
|
|
681
|
+
Settlement will also take what the bank actually credited rather than a rate:
|
|
682
|
+
|
|
683
|
+
```ts
|
|
684
|
+
settleForeignItem(ledger, {
|
|
685
|
+
id: "RCT-021", date: "2026-04-20",
|
|
686
|
+
account: "1131", bankAccount: "1110", reference: "INV-021",
|
|
687
|
+
settledFor: Money.parse("864.20", GBP), // what landed, spread and all
|
|
688
|
+
});
|
|
689
|
+
```
|
|
690
|
+
|
|
691
|
+
Partial settlements take their share of the carrying amount pro rata, as one
|
|
692
|
+
exact bigint division, so settling half a receivable leaves the other half
|
|
693
|
+
carried at exactly the rate it was carried at before.
|
|
694
|
+
|
|
695
|
+
## Reading the books in another currency
|
|
696
|
+
|
|
697
|
+
That is a different problem from a euro receivable and it does not have the same
|
|
698
|
+
answer. A receivable is retranslated because what it is worth genuinely changed.
|
|
699
|
+
Nothing changes here: the business did what it did, and someone — a parent
|
|
700
|
+
consolidating a subsidiary, a lender, a euro investor reading sterling
|
|
701
|
+
accounts — wants to read the result in dollars. So no balance is restated; the
|
|
702
|
+
whole statement is, and each line takes the rate its nature calls for.
|
|
703
|
+
|
|
704
|
+
```bash
|
|
705
|
+
tallyd report -l books.json --as-at 2026-12-31 --from 2026-01-01 --to 2026-12-31 \
|
|
706
|
+
--present USD --rates usd.csv --base GBP
|
|
707
|
+
```
|
|
708
|
+
|
|
709
|
+
```
|
|
710
|
+
Trial balance as at 2026-12-31, presented in USD (books kept in GBP)
|
|
711
|
+
Closing GBP/USD 1.350000, daily average 1.275479 over 2026-01-01 to 2026-12-31
|
|
712
|
+
------------------------------------------------------------------------
|
|
713
|
+
Account Name Basis Debit Credit
|
|
714
|
+
1110 Bank closing 51300.00
|
|
715
|
+
3100 Share Capital historical 12000.00
|
|
716
|
+
4200 Consulting average 51019.18
|
|
717
|
+
5300 Rent average 15305.75
|
|
718
|
+
Translation adjustment residual 3586.57
|
|
719
|
+
------------------------------------------------------------------------
|
|
720
|
+
Total 66605.75 66605.75
|
|
721
|
+
```
|
|
722
|
+
|
|
723
|
+
Assets and liabilities at the **closing rate**: what is owned and owed exists on
|
|
724
|
+
the balance sheet date, so it is worth what it is worth that day. Income and
|
|
725
|
+
expenses at the **average rate** for the period: revenue earned across a year was
|
|
726
|
+
not earned on 31 December, and translating it at the close would price a year of
|
|
727
|
+
trading at one day's rate. Equity at the rate on the day each movement
|
|
728
|
+
happened — share capital subscribed in 2025 was subscribed at the 2025 rate, and
|
|
729
|
+
nothing since has changed what was put in.
|
|
730
|
+
|
|
731
|
+
Three different rates has an arithmetic consequence: the translated columns no
|
|
732
|
+
longer agree. That difference is the point of the exercise rather than a failure
|
|
733
|
+
of it. It is the cumulative translation adjustment, it is an equity item and not
|
|
734
|
+
a profit, and it gets a line a reader can point at. Folding it silently into
|
|
735
|
+
retained earnings would be claiming the business made money it did not make.
|
|
736
|
+
|
|
737
|
+
Every row says which basis it took, so any single line can be checked without
|
|
738
|
+
recomputing the statement. Presenting into the currency the books are already
|
|
739
|
+
kept in returns them unchanged and looks up no rates at all, which means a
|
|
740
|
+
report can pass `--present` unconditionally. `--equity closing` exists for the
|
|
741
|
+
case where the rate file does not reach as far back as the share capital does;
|
|
742
|
+
it is wrong in principle and sometimes the only thing available, so it says
|
|
743
|
+
`closing` in the basis column where it was used.
|
|
744
|
+
|
|
745
|
+
## Consolidating a group
|
|
746
|
+
|
|
747
|
+
Translation restates one set of books. A group is several, and adding them up is
|
|
748
|
+
the easy part. The hard parts are that the group cannot owe itself money, that
|
|
749
|
+
the parent's investment in a subsidiary and that subsidiary's own share capital
|
|
750
|
+
are the same money counted twice, and that a company the group controls is not
|
|
751
|
+
necessarily a company the group owns all of.
|
|
752
|
+
|
|
753
|
+
```bash
|
|
754
|
+
tallyd consolidate --group group.json --rates rates.csv --show all
|
|
755
|
+
```
|
|
756
|
+
|
|
757
|
+
The group document says who holds whom, where each company's books live, which
|
|
758
|
+
balances face which company, and what was paid for each subsidiary. Everything
|
|
759
|
+
else is derived.
|
|
760
|
+
|
|
761
|
+
### Control and ownership are different questions
|
|
762
|
+
|
|
763
|
+
```
|
|
764
|
+
The Halden Group — consolidated in GBP
|
|
765
|
+
HH Halden Holdings GBP
|
|
766
|
+
HN Halden Nord GmbH EUR 80% owned, 20% outside
|
|
767
|
+
HS Halden Systems Inc USD 60% owned, 40% outside
|
|
768
|
+
```
|
|
769
|
+
|
|
770
|
+
Halden Holdings holds 80% of a German company that holds 75% of an American one.
|
|
771
|
+
It controls the American company completely — it directs the company that directs
|
|
772
|
+
it — and owns 60% of it. The other 40% is a non-controlling interest even though
|
|
773
|
+
no outside shareholder holds 40% of anything directly: 20% sits outside the first
|
|
774
|
+
company and 25% outside the second, and the two do not add.
|
|
775
|
+
|
|
776
|
+
So all of the American company's assets are consolidated, in full, and 40% of its
|
|
777
|
+
net assets are shown as belonging to somebody else.
|
|
778
|
+
|
|
779
|
+
Ownership is an exact fraction over bigints, not a decimal, because these numbers
|
|
780
|
+
get multiplied down chains and then multiplied by money. Two thirds of three
|
|
781
|
+
quarters is exactly a half; rounded to four places and multiplied through it is
|
|
782
|
+
50.0025%, which surfaces later as a non-controlling interest a few pence out in a
|
|
783
|
+
way nobody can trace. Holdings form a graph and not a tree, so a company held 20%
|
|
784
|
+
directly and 60% through an 80% subsidiary comes out at 68% — the sum over both
|
|
785
|
+
paths, which walking one chain would miss.
|
|
786
|
+
|
|
787
|
+
Control does not propagate through a company the group does not control. A 40%
|
|
788
|
+
associate holding 90% of something gives the group 36% of it and control of
|
|
789
|
+
neither, so neither consolidates. A definition can assert or deny control
|
|
790
|
+
outright, because control is about the ability to direct and a bare majority is
|
|
791
|
+
neither necessary nor sufficient for it.
|
|
792
|
+
|
|
793
|
+
### What the group owes itself
|
|
794
|
+
|
|
795
|
+
```
|
|
796
|
+
Intercompany eliminations — 2 pairs, 270227.76 GBP removed
|
|
797
|
+
HH 1190 Owed by Group Companies 210000.00 balance-sheet
|
|
798
|
+
HN 2190 Owed to Group Companies -156680.21
|
|
799
|
+
out by 53319.79 — HH's side is the larger
|
|
800
|
+
```
|
|
801
|
+
|
|
802
|
+
An account is intercompany because a declaration says so and names the company it
|
|
803
|
+
faces; nothing in a balance reveals whether the other end of it is inside the
|
|
804
|
+
group. Declarations pair on the entity, the counterparty and the relationship —
|
|
805
|
+
two companies almost always have a loan *and* a trading account running between
|
|
806
|
+
them, and pairing on the two companies alone matches the loan against the
|
|
807
|
+
purchases.
|
|
808
|
+
|
|
809
|
+
The two sides rarely agree. Here the German company repaid 60,000 euros three days
|
|
810
|
+
before the year end and the parent has not recorded it, so the money is in neither
|
|
811
|
+
company's cash and in both companies' intercompany accounts on the wrong side. The
|
|
812
|
+
residual goes to **items in transit** with the pair that produced it and which side
|
|
813
|
+
was larger. It is never plugged: a group whose intercompany accounts are out by six
|
|
814
|
+
figures has a problem, and making the number disappear would be hiding it.
|
|
815
|
+
|
|
816
|
+
A declaration with no mirror on the other side is reported and *not* eliminated.
|
|
817
|
+
Removing one side on its own would unbalance the group.
|
|
818
|
+
|
|
819
|
+
### What the group sells itself
|
|
820
|
+
|
|
821
|
+
```
|
|
822
|
+
Unrealised profit on intragroup stock — 1 consignment, 20000.00 GBP provided
|
|
823
|
+
S into P 1160 Inventory 100000.00 on hand
|
|
824
|
+
markup-on-cost, 20% of the price 20000.00 provided
|
|
825
|
+
15000.00 the group's, 5000.00 the 25% outside S
|
|
826
|
+
```
|
|
827
|
+
|
|
828
|
+
Taking the trading out leaves the margin behind. A company that sold another
|
|
829
|
+
100,000 of goods at cost plus a quarter earned 20,000 on the journey, and if the
|
|
830
|
+
buyer still has the goods then consolidated stock is carried at what the group
|
|
831
|
+
charged itself rather than at what the goods cost it. Nothing is wrong in either
|
|
832
|
+
set of books, which is why this cannot be inferred from them: the seller sold at
|
|
833
|
+
a profit, the buyer paid the price, and only somebody who knows the goods came
|
|
834
|
+
from inside the group can say the profit is not yet real.
|
|
835
|
+
|
|
836
|
+
Two parts of it are easy to get wrong.
|
|
837
|
+
|
|
838
|
+
**The rate is quoted two incompatible ways.** "Cost plus a quarter" and "a
|
|
839
|
+
quarter margin" are different trades: goods costing 80 sold at cost plus a
|
|
840
|
+
quarter fetch 100 and carry 20 of profit, which is a *fifth* of the selling
|
|
841
|
+
price. Taking a quarter of the price instead provides 25 against a margin that
|
|
842
|
+
was only ever 20, and carries the goods at 75 against a cost to the group of 80
|
|
843
|
+
— the same error again, in the other direction. Both conventions are named, and
|
|
844
|
+
so is a third that states what the goods cost and needs no conversion at all.
|
|
845
|
+
The rate is a rational rather than a decimal, so a third of a price stays a
|
|
846
|
+
third: quoted to four places it is 3.33 out on 100,000 and ten times that on a
|
|
847
|
+
million.
|
|
848
|
+
|
|
849
|
+
**The charge follows the seller, not the stock.** A provision reverses a profit
|
|
850
|
+
and the profit belongs to whoever recorded it. When a three-quarter-owned
|
|
851
|
+
subsidiary sells up to its parent, the outside quarter gives up its share of a
|
|
852
|
+
margin that was never earned. When the parent sells *down* into that same
|
|
853
|
+
subsidiary, the outside stake bears none of it — however much of the stock the
|
|
854
|
+
subsidiary is holding. The write-down against inventory is identical either way;
|
|
855
|
+
only the attribution moves, which is the part that looks wrong until you ask
|
|
856
|
+
whose profit is being taken away. A company held through another takes the
|
|
857
|
+
complement of the chained interest: 80% of 75% is 60% owned, so 40% is outside,
|
|
858
|
+
not the 20% or the 25% that either holding alone suggests.
|
|
859
|
+
|
|
860
|
+
A provision larger than the account it is written against is reported and not
|
|
861
|
+
posted. Writing stock below nothing is not a smaller error than carrying it
|
|
862
|
+
above cost, and consignments sitting in the same account are added up before
|
|
863
|
+
that is decided, since any one of them alone can look affordable.
|
|
864
|
+
|
|
865
|
+
In a group document:
|
|
866
|
+
|
|
867
|
+
```json
|
|
868
|
+
"intragroupStock": [
|
|
869
|
+
{
|
|
870
|
+
"seller": "S",
|
|
871
|
+
"holder": "P",
|
|
872
|
+
"account": "1160",
|
|
873
|
+
"onHand": { "amount": "100000.00", "currency": "GBP" },
|
|
874
|
+
"basis": "markup-on-cost",
|
|
875
|
+
"rate": "25%"
|
|
876
|
+
}
|
|
877
|
+
]
|
|
878
|
+
```
|
|
879
|
+
|
|
880
|
+
`basis` is required and has no default. `markup-on-cost`, `margin-on-price` and
|
|
881
|
+
`cost` are the three; `rate` takes a percentage or a ratio (`"1/3"`), and `cost`
|
|
882
|
+
replaces it on the third. There is no safe convention to assume, and a file that
|
|
883
|
+
left it out would be wrong by a quarter of the provision while looking complete.
|
|
884
|
+
|
|
885
|
+
### What was paid, and for what
|
|
886
|
+
|
|
887
|
+
```
|
|
888
|
+
HS — acquired 2025-01-02, 60% to the group, non-controlling interest at fair-value
|
|
889
|
+
USD GBP
|
|
890
|
+
Consideration transferred 163324.87 128700.00
|
|
891
|
+
Non-controlling interest at acquisition 72000.00 56736.00
|
|
892
|
+
Net assets acquired -160000.00 -126080.00
|
|
893
|
+
Goodwill 75324.87 59356.00
|
|
894
|
+
of which attributable to the outside stake 8000.00 6304.00
|
|
895
|
+
Measured in USD and translated at the rate on 2025-01-02: goodwill on a
|
|
896
|
+
foreign operation is an asset of that operation.
|
|
897
|
+
```
|
|
898
|
+
|
|
899
|
+
Goodwill is the consideration plus the non-controlling interest at acquisition,
|
|
900
|
+
less the net assets acquired — the part of the price that bought something the
|
|
901
|
+
subsidiary's balance sheet does not carry. How the non-controlling interest is
|
|
902
|
+
measured is a choice per acquisition and it changes the answer: proportionately,
|
|
903
|
+
as its share of the identifiable net assets, or at what the outside stake was
|
|
904
|
+
actually worth on the day, in which case the difference is goodwill belonging to
|
|
905
|
+
the outside shareholders.
|
|
906
|
+
|
|
907
|
+
**Two columns, because the subtraction happened in the left one.** Goodwill on a
|
|
908
|
+
foreign operation is an asset *of that operation*: what the extra money bought —
|
|
909
|
+
a name, a position, people who stay — is a thing in that country, priced in that
|
|
910
|
+
currency, and the group owning it does not move it. So the arithmetic runs in the
|
|
911
|
+
subsidiary's currency. The consideration comes the other way, into that currency
|
|
912
|
+
at the rate on the day; the net assets read from its own books need no
|
|
913
|
+
translating because they are already there. A single sterling column would be
|
|
914
|
+
actively misleading: a reader adding its three figures does not quite get the
|
|
915
|
+
fourth, because each is a rounding of a dollar amount and the fourth is a
|
|
916
|
+
rounding of their dollar difference.
|
|
917
|
+
|
|
918
|
+
Having the measurement in dollars is also what makes the figure retranslatable.
|
|
919
|
+
Goodwill is carried at the **closing rate** at every reporting date, along with
|
|
920
|
+
the rest of that subsidiary's assets, and the exchange difference goes to the
|
|
921
|
+
translation reserve — the group's share as a posting of its own, the outside
|
|
922
|
+
stake's share through its claim, which is measured afresh at each date and so
|
|
923
|
+
already contains it. Posting the stake's share as well double-counts it. Which
|
|
924
|
+
share is whose follows how the stake was *measured* rather than how large it is:
|
|
925
|
+
under the proportionate method the stake owns no goodwill and therefore none of
|
|
926
|
+
the difference, however much of the company it owns. The bargain purchase gain is
|
|
927
|
+
not retranslated — a gain is recognised once, at the rate on the day, and is part
|
|
928
|
+
of reserves afterwards rather than an asset sitting there to remeasure.
|
|
929
|
+
|
|
930
|
+
"The closing rate" means the date that subsidiary's own assets were read at,
|
|
931
|
+
which for a company sold during the period is the day it went. The disposal
|
|
932
|
+
derecognises goodwill at that same figure, so the gain is measured against a
|
|
933
|
+
carrying amount the balance sheet actually showed, and nothing is left in the
|
|
934
|
+
goodwill account for a company that is no longer in the group.
|
|
935
|
+
|
|
936
|
+
**Writing it down, and the basis that catches people out.** An impairment is a
|
|
937
|
+
judgement the group makes about the business it bought, so it is declared — as
|
|
938
|
+
an *event with a date*, not a running total. The date is needed because a loss
|
|
939
|
+
takes the rate on the day it was recognised, and the events are needed because
|
|
940
|
+
a reporting date has to tell what has been written off by then from what was
|
|
941
|
+
written off during the period. A cumulative total cannot tell those apart, and a
|
|
942
|
+
comparative column would charge the whole history to the later year and print a
|
|
943
|
+
plausible number.
|
|
944
|
+
|
|
945
|
+
The write-off comes off the measurement before any rate touches it. The exchange
|
|
946
|
+
difference is then taken against cost less everything already through the income
|
|
947
|
+
statement, at the rates those write-offs took — so a write-off recognised at the
|
|
948
|
+
closing rate leaves the difference exactly as it would have been with none,
|
|
949
|
+
because the asset and the figure it is measured against shrink by the same
|
|
950
|
+
amount.
|
|
951
|
+
|
|
952
|
+
Its share of the charge is the outside stake's **ownership** fraction, where its
|
|
953
|
+
share of the exchange difference was its share of the **goodwill**. The two look
|
|
954
|
+
alike and are not. A remeasurement of an asset follows whoever owns the asset;
|
|
955
|
+
a loss of the period is shared the way every other result is. On the
|
|
956
|
+
cross-border fixture the stake holds 20% of the company and 5.45% of the
|
|
957
|
+
goodwill, so the ownership basis gives it nearly four times the loss the
|
|
958
|
+
goodwill basis would. Under the proportionate method the balance sheet carries
|
|
959
|
+
only the group's own goodwill, so the only write-down recognisable against it is
|
|
960
|
+
the group's — however much of the company the stake holds.
|
|
961
|
+
|
|
962
|
+
That share comes out of the result rather than out of reserves brought forward,
|
|
963
|
+
because it is this period's loss: the balancing figure is untouched by a
|
|
964
|
+
write-off recognised in the period, and reduced by the group's share of one
|
|
965
|
+
recognised earlier, which is exactly where a prior year's loss belongs.
|
|
966
|
+
|
|
967
|
+
```
|
|
968
|
+
S — Sub GmbH, 80% to the group
|
|
969
|
+
Goodwill at cost 44000.00
|
|
970
|
+
written off to date (5100.00 this period) -8300.00
|
|
971
|
+
retranslated at the closing rate 4800.00
|
|
972
|
+
Goodwill 40500.00
|
|
973
|
+
...
|
|
974
|
+
Outside stake's share of the result 1687.45
|
|
975
|
+
less its share of the goodwill written off -1020.00
|
|
976
|
+
allocated to the outside stake 667.45
|
|
977
|
+
```
|
|
978
|
+
|
|
979
|
+
Both columns add down. A reader who cannot add a column has to take its last
|
|
980
|
+
figure on trust, which is the opposite of the point.
|
|
981
|
+
|
|
982
|
+
A price *below* the net assets acquired is a bargain purchase and shows up as a
|
|
983
|
+
gain in the income statement. Negative goodwill sitting in the balance sheet
|
|
984
|
+
would be asserting that the group owns something worth less than nothing.
|
|
985
|
+
|
|
986
|
+
The investment accounts are translated at the rate on the day the shares were
|
|
987
|
+
bought rather than at the closing rate, because an investment in a subsidiary is
|
|
988
|
+
carried at cost and cost is a fact about the day of the purchase. Retranslated at
|
|
989
|
+
closing it would fail to eliminate against the price paid, and the shortfall
|
|
990
|
+
would sit in the consolidated balance sheet looking like an investment in a
|
|
991
|
+
company nobody could name.
|
|
992
|
+
|
|
993
|
+
### It comes out as a ledger
|
|
994
|
+
|
|
995
|
+
Most consolidations are a table of numbers. This one is a `Ledger`: every step is
|
|
996
|
+
a balanced journal entry, so the result is a real set of books in the presentation
|
|
997
|
+
currency that every report already written works on, that serialises, and that
|
|
998
|
+
reads back and verifies.
|
|
999
|
+
|
|
1000
|
+
```
|
|
1001
|
+
TB-HH Halden Holdings — trial balance, restated
|
|
1002
|
+
TB-HN Halden Nord GmbH — trial balance, restated
|
|
1003
|
+
TB-HS Halden Systems Inc — trial balance, restated
|
|
1004
|
+
ELIM-001 Eliminate HH/HN intercompany — loan and trading account
|
|
1005
|
+
ELIM-002 Eliminate HH/HN intercompany
|
|
1006
|
+
CONS-HN Eliminate the investment in Halden Nord GmbH against its equity
|
|
1007
|
+
NCI-HN 20% of Halden Nord GmbH's result for the period
|
|
1008
|
+
CONS-HS Eliminate the investment in Halden Systems Inc against its equity
|
|
1009
|
+
NCI-HS 40% of Halden Systems Inc's result for the period
|
|
1010
|
+
```
|
|
1011
|
+
|
|
1012
|
+
Nothing is adjusted invisibly. If a figure in the consolidated balance sheet is
|
|
1013
|
+
wrong, there is an entry with a narration that put it there.
|
|
1014
|
+
|
|
1015
|
+
The non-controlling interest's claim is its share of the net assets *now* plus
|
|
1016
|
+
whatever goodwill was attributed to it at acquisition — a formulation that needs
|
|
1017
|
+
no roll-forward from one period to the next and so cannot drift, which a schedule
|
|
1018
|
+
of movements can. Its share of this period's profit is moved out of the result
|
|
1019
|
+
attributable to the parent's owners by a separate entry, because without it the
|
|
1020
|
+
totals would still be right and the presentation wrong: the whole profit would
|
|
1021
|
+
read as the group's and the reserves brought forward would be short by exactly
|
|
1022
|
+
the same amount.
|
|
1023
|
+
|
|
1024
|
+
The balancing figure on each consolidation entry is the group's share of the
|
|
1025
|
+
reserves earned since it took control, and it comes out to exactly
|
|
1026
|
+
`groupInterest x (equity removed + net assets at acquisition)`. The tests check
|
|
1027
|
+
that identity rather than assuming it.
|
|
1028
|
+
|
|
1029
|
+
`tallyd consolidate` exits 2 when the consolidated ledger does not balance or its
|
|
1030
|
+
accounting equation leaves a residual — a consolidation that came out wrong ran
|
|
1031
|
+
to completion, and a job that treated that as a pass would be worse than none.
|
|
1032
|
+
|
|
1033
|
+
### Two dates, and what moved between them
|
|
1034
|
+
|
|
1035
|
+
A consolidated balance sheet is published with last year beside it, and a
|
|
1036
|
+
consolidation that only knows one date cannot produce one. `--comparative` runs
|
|
1037
|
+
the same books at a second date and sets the two columns side by side:
|
|
1038
|
+
|
|
1039
|
+
```
|
|
1040
|
+
tallyd consolidate -g group.json -r rates.json --comparative 2025-12-31
|
|
1041
|
+
```
|
|
1042
|
+
|
|
1043
|
+
Nothing has to have been kept from last time. An entity's ledger is a complete
|
|
1044
|
+
history, so last year's balance sheet is the same file asked a different
|
|
1045
|
+
question — which means a group that has never prepared a consolidation before
|
|
1046
|
+
can still produce a comparative. The comparative period defaults to one of the
|
|
1047
|
+
same length ending on the comparative date, which for the ordinary annual case
|
|
1048
|
+
is exactly the year before; `--comparative-from` says otherwise.
|
|
1049
|
+
|
|
1050
|
+
The reason to want the second column is not the column. It is that two of the
|
|
1051
|
+
figures on a consolidated balance sheet mean very little on their own and quite
|
|
1052
|
+
a lot as a movement — the non-controlling interest and the translation reserve.
|
|
1053
|
+
Both are measured directly from the closing position, which is deliberate: a
|
|
1054
|
+
figure computed from the closing balance sheet cannot drift, where one rolled
|
|
1055
|
+
forward from last period's can. But that same property means nothing anywhere
|
|
1056
|
+
says *why* they moved.
|
|
1057
|
+
|
|
1058
|
+
The answer has to come from the net assets underneath, because both figures are
|
|
1059
|
+
shares of them, and for a company kept in another currency exactly three things
|
|
1060
|
+
move net assets between two dates:
|
|
1061
|
+
|
|
1062
|
+
| | |
|
|
1063
|
+
|---|---|
|
|
1064
|
+
| the currency | the same euros, a different number of pounds; nothing happened inside the company |
|
|
1065
|
+
| the result | what the entity earned, at the period's average rate — the rate the income statement uses |
|
|
1066
|
+
| everything else | dividends, capital, and the difference between the average rate and the closing one |
|
|
1067
|
+
|
|
1068
|
+
The third is defined as what is left, so the three are exhaustive and the
|
|
1069
|
+
identity `opening + currency + result + other = closing` holds exactly, with no
|
|
1070
|
+
rounding anywhere. The tests check it rather than asserting it.
|
|
1071
|
+
|
|
1072
|
+
The first is the one worth being careful about, because getting it wrong is
|
|
1073
|
+
invisible. Retranslating opening net assets means retranslating the rows that
|
|
1074
|
+
were carried at a rate and *not* the rows that were not. Nord holds its
|
|
1075
|
+
investment in Systems at what it paid: 150,000 EUR struck on the day of the
|
|
1076
|
+
purchase, and no later rate touches it. Of Nord's opening net assets of 355,500
|
|
1077
|
+
only 226,800 moves with sterling, and the currency line says so:
|
|
1078
|
+
|
|
1079
|
+
```
|
|
1080
|
+
Movement in net assets
|
|
1081
|
+
Entity Opening Currency Result Other Closing
|
|
1082
|
+
HN Halden Nord GmbH 355500.00 -5400.00 7257.44 -114.55 357242.89
|
|
1083
|
+
HS Halden Systems Inc 188650.00 -4900.00 57999.46 -999.46 240750.00
|
|
1084
|
+
```
|
|
1085
|
+
|
|
1086
|
+
Restating that investment at this year's closing rate would produce a currency
|
|
1087
|
+
movement on a balance that never moved — and the residual would silently absorb
|
|
1088
|
+
the same figure with the opposite sign, so the identity would still hold and
|
|
1089
|
+
both lines would be wrong.
|
|
1090
|
+
|
|
1091
|
+
What is left in `other` is then only what it should be. Systems earned 76,000
|
|
1092
|
+
USD; at the closing rate that is 57,000 and the income statement translates it
|
|
1093
|
+
at the average and gets 57,999.46. The 999.46 has to land somewhere, and it
|
|
1094
|
+
lands here rather than in a column labelled "currency", where it is not a
|
|
1095
|
+
currency movement on an opening balance.
|
|
1096
|
+
|
|
1097
|
+
The share schedules apply each entity's own outside fraction to each component:
|
|
1098
|
+
|
|
1099
|
+
```
|
|
1100
|
+
Non-controlling interest
|
|
1101
|
+
At the start of the period 152720.00
|
|
1102
|
+
Share of the result for the period 24651.27
|
|
1103
|
+
Share of the translation effect -3040.00
|
|
1104
|
+
Share of other movements in net assets -422.69
|
|
1105
|
+
Its own goodwill, retranslated -160.00
|
|
1106
|
+
At the reporting date 173748.58
|
|
1107
|
+
```
|
|
1108
|
+
|
|
1109
|
+
The last of those lines is not a share of anything, and that is the point of
|
|
1110
|
+
giving it its own row. Goodwill on a foreign operation is an asset of that
|
|
1111
|
+
operation and is carried at its closing rate, so the sterling amount moves
|
|
1112
|
+
every year; but the outside stake owns only whatever goodwill was *attributed*
|
|
1113
|
+
to it when the company was bought. Systems' stake was measured at fair value,
|
|
1114
|
+
so it owns a slice and therefore the exchange difference on that slice.
|
|
1115
|
+
Nord's was measured proportionately, so it owns none of the goodwill and none
|
|
1116
|
+
of the difference — which is a fact about the measurement and not about the
|
|
1117
|
+
size of the stake.
|
|
1118
|
+
|
|
1119
|
+
Goodwill gets a schedule of its own for a sharper reason. Four things move it —
|
|
1120
|
+
arising on acquisition, written off, retranslated, derecognised on a disposal —
|
|
1121
|
+
and three of them are invisible in the difference between two columns. Any two
|
|
1122
|
+
can cancel: goodwill flat across a comparative can mean nothing happened, or it
|
|
1123
|
+
can mean a write-off and a favourable rate move of the same size, and those are
|
|
1124
|
+
not the same news. The suite has a fixture where they cancel to within a penny.
|
|
1125
|
+
|
|
1126
|
+
```
|
|
1127
|
+
Goodwill
|
|
1128
|
+
At the start of the period 110945.60
|
|
1129
|
+
HN — retranslated -1260.60
|
|
1130
|
+
HS — retranslated -1506.50
|
|
1131
|
+
At the reporting date 108178.50
|
|
1132
|
+
```
|
|
1133
|
+
|
|
1134
|
+
Each line is at the figure that thing moved the account by: a write-off at the
|
|
1135
|
+
rate it was recognised at, and the retranslation as whatever the closing rate
|
|
1136
|
+
did on top of that. Take the write-off at the closing rate instead and both
|
|
1137
|
+
lines are wrong while still adding to the right total — which is the kind of
|
|
1138
|
+
error a schedule is supposed to make visible rather than contain.
|
|
1139
|
+
|
|
1140
|
+
That closing figure is what the consolidation reports, not what the lines add
|
|
1141
|
+
up to. Applying a fraction three times rounds three times where measuring
|
|
1142
|
+
directly rounds once, so the two can differ — and where they do, the difference
|
|
1143
|
+
is printed as a line of its own called *Not explained by the above*, never
|
|
1144
|
+
folded into the line above it. A schedule that quietly plugs its last line is
|
|
1145
|
+
worse than no schedule: it is the same number with the evidence removed.
|
|
1146
|
+
|
|
1147
|
+
`npm run demo:periods` runs the Halden group over both years and ends by
|
|
1148
|
+
computing each figure both ways and printing the difference.
|
|
1149
|
+
|
|
1150
|
+
### A company bought part-way through the year
|
|
1151
|
+
|
|
1152
|
+
A subsidiary acquired in April contributes a full balance sheet at the year end
|
|
1153
|
+
and nine months of results. Getting that wrong is easy and quiet: take all
|
|
1154
|
+
twelve months and the group reports revenue it did not earn, hands the outside
|
|
1155
|
+
stake a share of a profit made before the stake existed, and puts the first
|
|
1156
|
+
quarter's profit into post-acquisition reserves where it does not belong. Every
|
|
1157
|
+
total still balances. The accounting equation still closes to nil.
|
|
1158
|
+
|
|
1159
|
+
The result of a period is now what an entity's income accounts *moved* over the
|
|
1160
|
+
part of it the group controlled the company for, rather than what they *stand
|
|
1161
|
+
at* on the reporting date. The mechanism is a closing entry: the entity's books
|
|
1162
|
+
are closed at the date control was obtained, so what is left on the income
|
|
1163
|
+
accounts is the group's result and what came off them is pre-acquisition profit
|
|
1164
|
+
sitting in the equity the consolidation eliminates against the investment.
|
|
1165
|
+
|
|
1166
|
+
It is an entry rather than an adjustment, for the same reason everything else
|
|
1167
|
+
here is — if a figure changed there is a balanced journal entry with a narration
|
|
1168
|
+
that changed it. And it produces a new ledger rather than modifying one: an
|
|
1169
|
+
entity's books are not the group's to rewrite, so the group takes a view of them
|
|
1170
|
+
closed at a date and the file on disk is untouched.
|
|
1171
|
+
|
|
1172
|
+
```
|
|
1173
|
+
How much of the period each company was the group's
|
|
1174
|
+
FG Fenwick Group the whole period no acquisition date, so the whole period is taken to be the group's
|
|
1175
|
+
AL Aldermere Ltd 2026-04-02 to 2026-12-31 acquired 2026-04-01, part-way through the period
|
|
1176
|
+
```
|
|
1177
|
+
|
|
1178
|
+
That table prints whatever `--show` says as soon as any entity was not the
|
|
1179
|
+
group's for the whole period, because a consolidation covering nine months of
|
|
1180
|
+
one company and twelve of another should not be read without knowing it.
|
|
1181
|
+
|
|
1182
|
+
**The window decides the rate as well as the months.** Nine months of results
|
|
1183
|
+
are translated at the average rate over those nine months, not over the year.
|
|
1184
|
+
Both ends of a company's life in the group work this way, and the symmetry is
|
|
1185
|
+
the argument: the months after a sale are not the group's and neither are the
|
|
1186
|
+
months before an acquisition. Averaging over the whole year would mix in rates
|
|
1187
|
+
from the quarter whose result has just been taken out as pre-acquisition — the
|
|
1188
|
+
same class of quiet error as taking all twelve months of profit, and harder to
|
|
1189
|
+
see, because the figure it produces is a plausible number rather than an
|
|
1190
|
+
obviously large one. On the cross-border fixture it is 1.18% of the rate over a
|
|
1191
|
+
nine-month window and 3.54% over a three-month one, in opposite directions.
|
|
1192
|
+
|
|
1193
|
+
The `wholePeriodRegardless` option takes the whole year's average with it. That
|
|
1194
|
+
option exists so the difference can be shown rather than described, and a result
|
|
1195
|
+
which includes the pre-acquisition months belongs at an average that includes
|
|
1196
|
+
them too; half-applying the window would give a figure that is neither.
|
|
1197
|
+
|
|
1198
|
+
The boundary is the acquisition date inclusive. It is arbitrary and it has to be
|
|
1199
|
+
consistent with something, and the thing it has to be consistent with is that
|
|
1200
|
+
net assets at acquisition are measured *as at* that date. Otherwise a sale made
|
|
1201
|
+
on the day of completion would be counted twice: once in the price paid for the
|
|
1202
|
+
net assets and once in the group's result.
|
|
1203
|
+
|
|
1204
|
+
`npm run demo:midyear` prints the same books consolidated both ways. The wrong
|
|
1205
|
+
answer is worth printing rather than describing:
|
|
1206
|
+
|
|
1207
|
+
```
|
|
1208
|
+
Result the group is entitled to 120000.00
|
|
1209
|
+
Result taking the whole year 180000.00
|
|
1210
|
+
Overstated by 60000.00
|
|
1211
|
+
|
|
1212
|
+
Outside stake's share, correctly 30000.00
|
|
1213
|
+
Outside stake's share, taking the year 45000.00
|
|
1214
|
+
|
|
1215
|
+
Post-acquisition reserves, correctly 0.00
|
|
1216
|
+
Post-acquisition reserves, taking the year -45000.00
|
|
1217
|
+
```
|
|
1218
|
+
|
|
1219
|
+
The only visible symptom is that last line: reserves earned since control was
|
|
1220
|
+
obtained, and they are negative, which on those numbers would mean they had been
|
|
1221
|
+
earned backwards. Net assets at the reporting date are 420,000 either way. Only
|
|
1222
|
+
the split between what was bought and what was earned moves.
|
|
1223
|
+
|
|
1224
|
+
The same substitution helps a second case. `withResultClosed` is on the ledger,
|
|
1225
|
+
not the group, so a caller with books that have never been closed can close them
|
|
1226
|
+
at the period start and get a result for the period rather than every year since
|
|
1227
|
+
the company was formed. The consolidation does not do that on its own: without
|
|
1228
|
+
an acquisition date inside the period it has no way to know a period boundary is
|
|
1229
|
+
missing, and guessing at one would be worse than the gap.
|
|
1230
|
+
|
|
1231
|
+
### And one sold part-way through it
|
|
1232
|
+
|
|
1233
|
+
The other half of the same question, and the harder one. A company sold in
|
|
1234
|
+
September was the group's for eight months, so its results for those eight
|
|
1235
|
+
months are the group's — and on the reporting date the group does not own a
|
|
1236
|
+
penny of it, so none of its balance sheet is.
|
|
1237
|
+
|
|
1238
|
+
Closing the books cannot express that. Closing decides which period a result
|
|
1239
|
+
belongs to and leaves the balance sheet exactly where it was, and here the
|
|
1240
|
+
balance sheet has to go entirely. So a disposal is a *removal*. The company is
|
|
1241
|
+
consolidated as at the day control was lost — its trial balance read on that
|
|
1242
|
+
date, its result translated at the average over the window it was still the
|
|
1243
|
+
group's — and then one balanced entry takes the whole of that closing position
|
|
1244
|
+
back off: its assets and liabilities, its goodwill, and the outside stake's
|
|
1245
|
+
claim on it. The results stay.
|
|
1246
|
+
|
|
1247
|
+
What falls out as the balancing figure is the gain:
|
|
1248
|
+
|
|
1249
|
+
```
|
|
1250
|
+
carrying amount = net assets at disposal
|
|
1251
|
+
- the outside stake's claim on them
|
|
1252
|
+
+ the goodwill recognised when it was bought
|
|
1253
|
+
|
|
1254
|
+
gain = proceeds - carrying amount
|
|
1255
|
+
```
|
|
1256
|
+
|
|
1257
|
+
Each term earns its place. The net assets are read on the day control was lost,
|
|
1258
|
+
not at the reporting date, where they belong to somebody else. The outside
|
|
1259
|
+
stake's claim comes off because the group never owned that part and is not
|
|
1260
|
+
selling it — their claim goes *with* the company, which is a removal and not a
|
|
1261
|
+
remeasurement. And the goodwill goes because it only ever existed as a
|
|
1262
|
+
consequence of holding this company; leaving it on the balance sheet would
|
|
1263
|
+
assert the group still owns something it cannot name.
|
|
1264
|
+
|
|
1265
|
+
**This is not the gain the holding company records.** Its own books compare the
|
|
1266
|
+
proceeds with what it paid for the shares. The group compares them with the net
|
|
1267
|
+
assets that have walked out of the door, which have been moving ever since:
|
|
1268
|
+
every pound the subsidiary earned and kept raised them, and the group's share of
|
|
1269
|
+
every one of those pounds has already been reported as profit in this period or
|
|
1270
|
+
an earlier one. Reporting the holder's figure as well would report the same
|
|
1271
|
+
earnings twice. So the consolidation reverses the holder's gain and puts its own
|
|
1272
|
+
in its place. `npm run demo:disposal` prints both:
|
|
1273
|
+
|
|
1274
|
+
```
|
|
1275
|
+
Proceeds 600000.00
|
|
1276
|
+
Less what the holder paid for the shares -400000.00
|
|
1277
|
+
The holder's own gain 200000.00
|
|
1278
|
+
|
|
1279
|
+
Proceeds 600000.00
|
|
1280
|
+
Less net assets handed over -500000.00
|
|
1281
|
+
Add back the outside stake's claim on them 100000.00
|
|
1282
|
+
Less goodwill derecognised -120000.00
|
|
1283
|
+
The group's gain 80000.00
|
|
1284
|
+
```
|
|
1285
|
+
|
|
1286
|
+
The 120,000 between them is the group's 80% share of the 150,000 the company
|
|
1287
|
+
earned and retained between purchase and sale.
|
|
1288
|
+
|
|
1289
|
+
Two consequences worth knowing. The proceeds a group document declares and the
|
|
1290
|
+
proceeds the holder's own books recorded are the same event, so if they disagree
|
|
1291
|
+
the difference is left sitting in the disposal accounts and named:
|
|
1292
|
+
`disposalResidual`, printed as *"the proceeds given are not the ones the holder's
|
|
1293
|
+
books recorded"*. And which disposals belong in a set of accounts is decided by
|
|
1294
|
+
the control window, not by the list: the same document produces a comparative
|
|
1295
|
+
column for the year before the sale, and accounting for the sale in that column
|
|
1296
|
+
would take a company off a balance sheet it was still on.
|
|
1297
|
+
|
|
1298
|
+
A company sold *before* the period opened is not an error. It is not
|
|
1299
|
+
consolidated, for the same reason an associate is not, and it says so beside
|
|
1300
|
+
them — otherwise a comparative column spanning the sale could not be produced at
|
|
1301
|
+
all.
|
|
1302
|
+
|
|
1303
|
+
## Using it as a library
|
|
1304
|
+
|
|
1305
|
+
```ts
|
|
1306
|
+
import { Ledger, JournalEntry, Money, GBP, standardChart, trialBalance } from "tallyd";
|
|
1307
|
+
|
|
1308
|
+
const chart = standardChart(GBP);
|
|
1309
|
+
const ledger = Ledger.empty(chart).post(
|
|
1310
|
+
JournalEntry.simple({
|
|
1311
|
+
id: "JE-001",
|
|
1312
|
+
date: "2026-08-01",
|
|
1313
|
+
narration: "August rent",
|
|
1314
|
+
debit: "5300",
|
|
1315
|
+
credit: "1110",
|
|
1316
|
+
amount: Money.parse("1850.00", GBP),
|
|
1317
|
+
}),
|
|
1318
|
+
);
|
|
1319
|
+
|
|
1320
|
+
trialBalance(ledger).balanced; // true
|
|
1321
|
+
```
|
|
1322
|
+
|
|
1323
|
+
Reconciling a statement takes three calls:
|
|
1324
|
+
|
|
1325
|
+
```ts
|
|
1326
|
+
import { bankView, reconcile, reconciliationBridge, renderReconciliationBridge } from "tallyd";
|
|
1327
|
+
|
|
1328
|
+
const books = bankView(ledger, "1110");
|
|
1329
|
+
const result = reconcile(books, importedStatementLines);
|
|
1330
|
+
|
|
1331
|
+
result.matched; // safe to post
|
|
1332
|
+
result.suggested; // needs a human, best first
|
|
1333
|
+
result.unmatchedStatement; // charges and interest nobody booked
|
|
1334
|
+
|
|
1335
|
+
const bridge = reconciliationBridge(result, { bankClosingBalance, bookClosingBalance });
|
|
1336
|
+
bridge.reconciled; // true, or the matcher lost a line
|
|
1337
|
+
console.log(renderReconciliationBridge(bridge));
|
|
1338
|
+
```
|
|
1339
|
+
|
|
1340
|
+
And booking what is left over takes two more:
|
|
1341
|
+
|
|
1342
|
+
```ts
|
|
1343
|
+
import { linesNeedingEntries, proposeEntries, applyProposals } from "tallyd";
|
|
1344
|
+
|
|
1345
|
+
const proposals = proposeEntries(linesNeedingEntries(result, memory), {
|
|
1346
|
+
account: "1110",
|
|
1347
|
+
ledger, // so a line already booked is skipped rather than duplicated
|
|
1348
|
+
});
|
|
1349
|
+
|
|
1350
|
+
proposals.filter((p) => p.outcome === "unclassified"); // still a person's problem
|
|
1351
|
+
const booked = applyProposals(ledger, proposals);
|
|
1352
|
+
```
|
|
1353
|
+
|
|
1354
|
+
## Design notes
|
|
1355
|
+
|
|
1356
|
+
**Money is never a float.** Every amount is a `bigint` count of minor units paired
|
|
1357
|
+
with a currency. Arithmetic between mismatched currencies is an exception, not a
|
|
1358
|
+
runtime surprise, and converting from a JS `number` requires naming a rounding mode
|
|
1359
|
+
at the call site so the lossy step is visible in the code. Allocation uses
|
|
1360
|
+
largest-remainder, so splitting £100 three ways yields 33.34 / 33.33 / 33.33 and
|
|
1361
|
+
never loses a penny — verified by a property test that the parts sum back to the
|
|
1362
|
+
original for any amount, any weights, and either sign.
|
|
1363
|
+
|
|
1364
|
+
**The one that mattered: the balancing invariant lives in the constructor.** A
|
|
1365
|
+
`JournalEntry` whose postings do not sum to zero cannot be constructed. The
|
|
1366
|
+
constructor is private, the factory checks, and every field is frozen. The
|
|
1367
|
+
alternative — a `validate()` method called by whoever remembers — is the design
|
|
1368
|
+
that lets an unbalanced entry reach the database on the one path nobody tested.
|
|
1369
|
+
Two consequences fall out of putting the check at the type boundary. Postings carry
|
|
1370
|
+
a single signed amount rather than a `(side, magnitude)` pair, so the invariant is
|
|
1371
|
+
literally "the amounts sum to zero" and needs no interpretation. And the check runs
|
|
1372
|
+
per currency, so a GBP debit offset by a USD credit is rejected rather than netting
|
|
1373
|
+
to a nonsense zero. The `Ledger` never re-checks entries it is handed; it cannot
|
|
1374
|
+
hold an invalid one. What it does check, in `verify()`, is that its own incremental
|
|
1375
|
+
balance index still agrees with a full replay of the entries — because that index
|
|
1376
|
+
is an optimisation, and optimisations are what drift.
|
|
1377
|
+
|
|
1378
|
+
**Ambiguity is decided per column, never per row.** `03/04/2026` is 3 April in
|
|
1379
|
+
London and 4 March in New York, and `1.234` is either a thousand or one and a
|
|
1380
|
+
bit. Both questions are settled once for a whole column, by looking for a value
|
|
1381
|
+
that rules one reading out — a day above 12, a separator that repeats. Deciding
|
|
1382
|
+
per row is the failure mode that matters: it yields a statement where most lines
|
|
1383
|
+
are right and a few are silently six weeks or a thousandfold out. When a column
|
|
1384
|
+
genuinely cannot be settled, the importer says so in a warning and takes a
|
|
1385
|
+
documented default rather than pretending to know. The same instinct runs through
|
|
1386
|
+
duplicate detection: two £2.85 coffees at the same shop on the same day are two
|
|
1387
|
+
transactions, so repeated lines are counted and flagged for a human rather than
|
|
1388
|
+
quietly dropped.
|
|
1389
|
+
|
|
1390
|
+
**Dates are strings, not `Date`s.** A posting happens on a calendar date. Using
|
|
1391
|
+
`Date` would drag a timezone into every comparison, and a statement line dated
|
|
1392
|
+
1 March in London would sort before a ledger entry dated 1 March in New York. So
|
|
1393
|
+
dates are validated `YYYY-MM-DD` strings with integer day arithmetic, which also
|
|
1394
|
+
means lexicographic sort is chronological sort.
|
|
1395
|
+
|
|
1396
|
+
**Matching is two passes, and the order is the whole design.** Groups go first.
|
|
1397
|
+
A batch supplier payment leaves the bank as one debit and sits in the books as
|
|
1398
|
+
four invoices; if the one-to-one pass ran first it would pair that bank line
|
|
1399
|
+
with whichever single invoice looked closest, consume both, and strand the other
|
|
1400
|
+
three — and the mistake is unrecoverable, because the evidence has been spent.
|
|
1401
|
+
Only once the groups are settled do the remaining lines go through
|
|
1402
|
+
maximum-weight bipartite matching, so that two statement lines competing for the
|
|
1403
|
+
same ledger entry are decided by what is best overall rather than by which
|
|
1404
|
+
happened to be scored first. Greedy matching is kept in the codebase purely to
|
|
1405
|
+
show the gap: on one small matrix it scores 1.05 where optimal scores 1.80.
|
|
1406
|
+
|
|
1407
|
+
Group candidates come from a bounded subset-sum search, and the bound that
|
|
1408
|
+
matters most is not the node budget — it is the rule that a group is only
|
|
1409
|
+
proposed when no single line already explains the anchor on its own. Without it,
|
|
1410
|
+
a £7,200 receipt gets "explained" as an invoice plus a reversal pair that
|
|
1411
|
+
happens to cancel out. That is arithmetic, not evidence.
|
|
1412
|
+
|
|
1413
|
+
**Every match carries its reasons.** Amount and direction are gates rather than
|
|
1414
|
+
contributions: a payment that differs by £40 is not a weak match, it is a
|
|
1415
|
+
different transaction. What remains — amount, date proximity, description
|
|
1416
|
+
similarity, shared references — is weighted and averaged, and each rule reports
|
|
1417
|
+
its own sub-score and what it contributed. The reference rule drops out of the
|
|
1418
|
+
average entirely when neither side carries one, because scoring a missing signal
|
|
1419
|
+
as zero would punish every cash transaction for something it could never have
|
|
1420
|
+
had. Descriptions are compared after normalisation, which is what lets
|
|
1421
|
+
`DD RENT, AUGUST 08` and `August rent` agree, and a reference both sides share
|
|
1422
|
+
(`INV1001` against a bare `1001`) is treated as near-decisive.
|
|
1423
|
+
|
|
1424
|
+
**The reconciliation statement is the real test.** Matching produces two piles of
|
|
1425
|
+
leftovers; the bridge walks from the bank's closing balance to the ledger's, one
|
|
1426
|
+
reconciling item at a time, and the two adjusted balances must be equal. That is
|
|
1427
|
+
not a convention — every match pairs equal amounts, so the whole difference
|
|
1428
|
+
between the two closing balances has to live in the unmatched items. A matcher
|
|
1429
|
+
that pairs the wrong things still balances; a matcher that loses or double-counts
|
|
1430
|
+
a line cannot. The property test throws 400 random books-and-statement pairs at
|
|
1431
|
+
it and asserts the difference is exactly zero every time.
|
|
1432
|
+
|
|
1433
|
+
**The balance sheet folds the period result into equity itself.** This ledger is
|
|
1434
|
+
append-only and never rewrites history, so nothing has been closed out to
|
|
1435
|
+
retained earnings — income and expense accounts still hold their balances.
|
|
1436
|
+
Report equity as it stands and the statement is out by exactly the profit for
|
|
1437
|
+
the period, every time. So the result goes in as its own visible line. It is not
|
|
1438
|
+
a fudge to make the two sides agree: it is what closing entries would post if
|
|
1439
|
+
they existed, and the identity underneath falls straight out of double entry.
|
|
1440
|
+
A test removes the fold and asserts the sheet then fails to balance, so the line
|
|
1441
|
+
cannot be deleted as redundant.
|
|
1442
|
+
|
|
1443
|
+
**Ageing groups by reference, not by counterparty.** A receivables account is not
|
|
1444
|
+
a list of debts, it is a list of postings, some of which cancel others. Postings
|
|
1445
|
+
are grouped by external reference, netted, and the groups that do not reach zero
|
|
1446
|
+
are what is outstanding. Two invoices to the same customer age separately,
|
|
1447
|
+
because one may be current while the other is ninety days overdue — rolling them
|
|
1448
|
+
together hides exactly the item worth chasing. An item ages from its earliest
|
|
1449
|
+
posting, not its last: a part payment received today does not make a sixty-day
|
|
1450
|
+
debt current. An overpayment stays visible as a negative item, because filtering
|
|
1451
|
+
it out would be tidier and would quietly stop the ageing total tying to the
|
|
1452
|
+
account balance.
|
|
1453
|
+
|
|
1454
|
+
**The CLI is a pure function.** `run(argv, environment)` takes its filesystem
|
|
1455
|
+
reader and its clock as parameters and returns stdout, stderr and an exit code.
|
|
1456
|
+
Nothing in the command layer touches `process` or `console`; the executable is
|
|
1457
|
+
twenty lines that connect the two. The whole CLI is therefore tested end to end
|
|
1458
|
+
against an in-memory filesystem, running the same code paths the binary does,
|
|
1459
|
+
with no spawning and no temp directories.
|
|
1460
|
+
|
|
1461
|
+
**The dashboard does arithmetic in integer minor units, in the browser.** Every
|
|
1462
|
+
amount crosses into the page twice: once as a decimal string to display, once as
|
|
1463
|
+
a count of minor units to add up. Recomputing the bridge from formatted strings —
|
|
1464
|
+
or from floats parsed out of them — is how a reconciliation ends up out by a
|
|
1465
|
+
penny in the one place a penny matters. The test suite runs the client's bridge
|
|
1466
|
+
arithmetic over the embedded data for every possible subset of accept decisions
|
|
1467
|
+
and asserts the difference is exactly zero each time, so a page that would open
|
|
1468
|
+
showing a discrepancy fails in CI rather than in front of a user.
|
|
1469
|
+
|
|
1470
|
+
**Charts are drawn by hand, and positioned by date.** Two charts do not justify
|
|
1471
|
+
shipping a charting engine inside a bookkeeping report, and a library would break
|
|
1472
|
+
the single-file promise. The cash position line places points by calendar date
|
|
1473
|
+
rather than by index — spacing ten movements evenly across a month draws a
|
|
1474
|
+
three-day gap and a thirteen-day gap the same width, which is a lie about the one
|
|
1475
|
+
axis the chart exists to show.
|
|
1476
|
+
|
|
1477
|
+
## Releasing
|
|
1478
|
+
|
|
1479
|
+
A release is a tag. `v0.1.0` publishes `0.1.0` and nothing else publishes
|
|
1480
|
+
anything: the workflow refuses a tag that disagrees with `package.json`, runs
|
|
1481
|
+
the suite and the type check, then installs the packed tarball into a bare
|
|
1482
|
+
project and uses it from there before anything reaches the registry.
|
|
1483
|
+
|
|
1484
|
+
The publish job holds one permission, `id-token: write`. There is no npm token
|
|
1485
|
+
in this repository, in its secrets, or in any log: npm trusted publishing
|
|
1486
|
+
exchanges a short-lived OIDC token for the right to publish this package from
|
|
1487
|
+
this repository, and attaches build provenance automatically. That also means
|
|
1488
|
+
the trusted publisher has to be registered on npm for the package before the
|
|
1489
|
+
first tag is pushed — the registry does not validate the arrangement until a
|
|
1490
|
+
publish is attempted.
|
|
1491
|
+
|
|
1492
|
+
## Licence
|
|
1493
|
+
|
|
1494
|
+
MIT
|