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.
Files changed (213) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1493 -2
  3. package/dist/accounts/chart.d.ts +96 -0
  4. package/dist/accounts/chart.js +244 -0
  5. package/dist/accounts/index.d.ts +3 -0
  6. package/dist/accounts/index.js +3 -0
  7. package/dist/accounts/standard.d.ts +12 -0
  8. package/dist/accounts/standard.js +95 -0
  9. package/dist/accounts/types.d.ts +20 -0
  10. package/dist/accounts/types.js +40 -0
  11. package/dist/cli/args.d.ts +45 -0
  12. package/dist/cli/args.js +147 -0
  13. package/dist/cli/index.d.ts +2 -0
  14. package/dist/cli/index.js +2 -0
  15. package/dist/cli/main.d.ts +6 -0
  16. package/dist/cli/main.js +25 -0
  17. package/dist/cli/run.d.ts +34 -0
  18. package/dist/cli/run.js +1660 -0
  19. package/dist/dashboard/charts.d.ts +38 -0
  20. package/dist/dashboard/charts.js +173 -0
  21. package/dist/dashboard/index.d.ts +3 -0
  22. package/dist/dashboard/index.js +3 -0
  23. package/dist/dashboard/model.d.ts +137 -0
  24. package/dist/dashboard/model.js +190 -0
  25. package/dist/dashboard/render.d.ts +23 -0
  26. package/dist/dashboard/render.js +231 -0
  27. package/dist/dashboard/script.d.ts +28 -0
  28. package/dist/dashboard/script.js +704 -0
  29. package/dist/dashboard/styles.d.ts +14 -0
  30. package/dist/dashboard/styles.js +726 -0
  31. package/dist/demo/disposal.d.ts +80 -0
  32. package/dist/demo/disposal.js +215 -0
  33. package/dist/demo/evidence.d.ts +153 -0
  34. package/dist/demo/evidence.js +348 -0
  35. package/dist/demo/foreign.d.ts +29 -0
  36. package/dist/demo/foreign.js +113 -0
  37. package/dist/demo/foreignReport.d.ts +10 -0
  38. package/dist/demo/foreignReport.js +89 -0
  39. package/dist/demo/generator.d.ts +108 -0
  40. package/dist/demo/generator.js +445 -0
  41. package/dist/demo/group.d.ts +70 -0
  42. package/dist/demo/group.js +195 -0
  43. package/dist/demo/groupPeriods.d.ts +27 -0
  44. package/dist/demo/groupPeriods.js +125 -0
  45. package/dist/demo/groupReport.d.ts +13 -0
  46. package/dist/demo/groupReport.js +87 -0
  47. package/dist/demo/ingest.d.ts +6 -0
  48. package/dist/demo/ingest.js +61 -0
  49. package/dist/demo/main.d.ts +6 -0
  50. package/dist/demo/main.js +7 -0
  51. package/dist/demo/mainDisposal.d.ts +1 -0
  52. package/dist/demo/mainDisposal.js +2 -0
  53. package/dist/demo/mainEvidence.d.ts +8 -0
  54. package/dist/demo/mainEvidence.js +9 -0
  55. package/dist/demo/mainForeign.d.ts +6 -0
  56. package/dist/demo/mainForeign.js +7 -0
  57. package/dist/demo/mainGroup.d.ts +6 -0
  58. package/dist/demo/mainGroup.js +7 -0
  59. package/dist/demo/mainGroupPeriods.d.ts +6 -0
  60. package/dist/demo/mainGroupPeriods.js +7 -0
  61. package/dist/demo/mainIngest.d.ts +6 -0
  62. package/dist/demo/mainIngest.js +7 -0
  63. package/dist/demo/mainMidYear.d.ts +6 -0
  64. package/dist/demo/mainMidYear.js +7 -0
  65. package/dist/demo/mainReconcile.d.ts +6 -0
  66. package/dist/demo/mainReconcile.js +7 -0
  67. package/dist/demo/mainStatements.d.ts +6 -0
  68. package/dist/demo/mainStatements.js +7 -0
  69. package/dist/demo/midYear.d.ts +51 -0
  70. package/dist/demo/midYear.js +162 -0
  71. package/dist/demo/month.d.ts +13 -0
  72. package/dist/demo/month.js +142 -0
  73. package/dist/demo/random.d.ts +48 -0
  74. package/dist/demo/random.js +94 -0
  75. package/dist/demo/receivables.d.ts +36 -0
  76. package/dist/demo/receivables.js +104 -0
  77. package/dist/demo/reconcile.d.ts +20 -0
  78. package/dist/demo/reconcile.js +104 -0
  79. package/dist/demo/report.d.ts +6 -0
  80. package/dist/demo/report.js +26 -0
  81. package/dist/demo/statement.d.ts +13 -0
  82. package/dist/demo/statement.js +51 -0
  83. package/dist/demo/statements.d.ts +6 -0
  84. package/dist/demo/statements.js +29 -0
  85. package/dist/demo/supplierRun.d.ts +46 -0
  86. package/dist/demo/supplierRun.js +94 -0
  87. package/dist/demo/unrealisedEvidence.d.ts +51 -0
  88. package/dist/demo/unrealisedEvidence.js +183 -0
  89. package/dist/fx/average.d.ts +60 -0
  90. package/dist/fx/average.js +139 -0
  91. package/dist/fx/document.d.ts +60 -0
  92. package/dist/fx/document.js +205 -0
  93. package/dist/fx/exposure.d.ts +108 -0
  94. package/dist/fx/exposure.js +262 -0
  95. package/dist/fx/index.d.ts +8 -0
  96. package/dist/fx/index.js +8 -0
  97. package/dist/fx/rate.d.ts +77 -0
  98. package/dist/fx/rate.js +200 -0
  99. package/dist/fx/revaluation.d.ts +92 -0
  100. package/dist/fx/revaluation.js +197 -0
  101. package/dist/fx/settlement.d.ts +84 -0
  102. package/dist/fx/settlement.js +162 -0
  103. package/dist/fx/table.d.ts +120 -0
  104. package/dist/fx/table.js +271 -0
  105. package/dist/fx/translate.d.ts +126 -0
  106. package/dist/fx/translate.js +258 -0
  107. package/dist/group/accounts.d.ts +74 -0
  108. package/dist/group/accounts.js +179 -0
  109. package/dist/group/acquisition.d.ts +324 -0
  110. package/dist/group/acquisition.js +416 -0
  111. package/dist/group/aggregate.d.ts +143 -0
  112. package/dist/group/aggregate.js +288 -0
  113. package/dist/group/comparative.d.ts +102 -0
  114. package/dist/group/comparative.js +166 -0
  115. package/dist/group/consolidate.d.ts +196 -0
  116. package/dist/group/consolidate.js +635 -0
  117. package/dist/group/disposal.d.ts +181 -0
  118. package/dist/group/disposal.js +233 -0
  119. package/dist/group/document.d.ts +41 -0
  120. package/dist/group/document.js +326 -0
  121. package/dist/group/index.d.ts +12 -0
  122. package/dist/group/index.js +12 -0
  123. package/dist/group/intercompany.d.ts +136 -0
  124. package/dist/group/intercompany.js +238 -0
  125. package/dist/group/interest.d.ts +109 -0
  126. package/dist/group/interest.js +224 -0
  127. package/dist/group/movement.d.ts +206 -0
  128. package/dist/group/movement.js +432 -0
  129. package/dist/group/structure.d.ts +151 -0
  130. package/dist/group/structure.js +327 -0
  131. package/dist/group/timeline.d.ts +109 -0
  132. package/dist/group/timeline.js +169 -0
  133. package/dist/group/unrealised.d.ts +165 -0
  134. package/dist/group/unrealised.js +314 -0
  135. package/dist/index.d.ts +10 -0
  136. package/dist/index.js +10 -0
  137. package/dist/ledger/close.d.ts +75 -0
  138. package/dist/ledger/close.js +142 -0
  139. package/dist/ledger/date.d.ts +48 -0
  140. package/dist/ledger/date.js +141 -0
  141. package/dist/ledger/entry.d.ts +150 -0
  142. package/dist/ledger/entry.js +264 -0
  143. package/dist/ledger/index.d.ts +6 -0
  144. package/dist/ledger/index.js +6 -0
  145. package/dist/ledger/ledger.d.ts +101 -0
  146. package/dist/ledger/ledger.js +308 -0
  147. package/dist/ledger/serialise.d.ts +68 -0
  148. package/dist/ledger/serialise.js +204 -0
  149. package/dist/ledger/trialBalance.d.ts +49 -0
  150. package/dist/ledger/trialBalance.js +103 -0
  151. package/dist/money/currency.d.ts +37 -0
  152. package/dist/money/currency.js +67 -0
  153. package/dist/money/index.d.ts +3 -0
  154. package/dist/money/index.js +3 -0
  155. package/dist/money/money.d.ts +106 -0
  156. package/dist/money/money.js +300 -0
  157. package/dist/money/rounding.d.ts +41 -0
  158. package/dist/money/rounding.js +117 -0
  159. package/dist/reconcile/accuracy.d.ts +65 -0
  160. package/dist/reconcile/accuracy.js +162 -0
  161. package/dist/reconcile/assignment.d.ts +83 -0
  162. package/dist/reconcile/assignment.js +326 -0
  163. package/dist/reconcile/bankView.d.ts +65 -0
  164. package/dist/reconcile/bankView.js +86 -0
  165. package/dist/reconcile/bridge.d.ts +76 -0
  166. package/dist/reconcile/bridge.js +129 -0
  167. package/dist/reconcile/candidates.d.ts +84 -0
  168. package/dist/reconcile/candidates.js +174 -0
  169. package/dist/reconcile/decisions.d.ts +116 -0
  170. package/dist/reconcile/decisions.js +168 -0
  171. package/dist/reconcile/index.d.ts +12 -0
  172. package/dist/reconcile/index.js +12 -0
  173. package/dist/reconcile/matcher.d.ts +89 -0
  174. package/dist/reconcile/matcher.js +306 -0
  175. package/dist/reconcile/memory.d.ts +120 -0
  176. package/dist/reconcile/memory.js +281 -0
  177. package/dist/reconcile/posting.d.ts +185 -0
  178. package/dist/reconcile/posting.js +481 -0
  179. package/dist/reconcile/scoring.d.ts +98 -0
  180. package/dist/reconcile/scoring.js +319 -0
  181. package/dist/reconcile/similarity.d.ts +88 -0
  182. package/dist/reconcile/similarity.js +272 -0
  183. package/dist/reconcile/subsetSum.d.ts +61 -0
  184. package/dist/reconcile/subsetSum.js +136 -0
  185. package/dist/reports/ageing.d.ts +75 -0
  186. package/dist/reports/ageing.js +149 -0
  187. package/dist/reports/balanceSheet.d.ts +57 -0
  188. package/dist/reports/balanceSheet.js +107 -0
  189. package/dist/reports/incomeStatement.d.ts +59 -0
  190. package/dist/reports/incomeStatement.js +132 -0
  191. package/dist/reports/index.d.ts +4 -0
  192. package/dist/reports/index.js +4 -0
  193. package/dist/reports/period.d.ts +37 -0
  194. package/dist/reports/period.js +78 -0
  195. package/dist/statement/amount.d.ts +55 -0
  196. package/dist/statement/amount.js +211 -0
  197. package/dist/statement/columns.d.ts +39 -0
  198. package/dist/statement/columns.js +221 -0
  199. package/dist/statement/csv.d.ts +50 -0
  200. package/dist/statement/csv.js +185 -0
  201. package/dist/statement/dates.d.ts +52 -0
  202. package/dist/statement/dates.js +204 -0
  203. package/dist/statement/duplicates.d.ts +51 -0
  204. package/dist/statement/duplicates.js +101 -0
  205. package/dist/statement/import.d.ts +52 -0
  206. package/dist/statement/import.js +172 -0
  207. package/dist/statement/index.d.ts +20 -0
  208. package/dist/statement/index.js +32 -0
  209. package/dist/statement/line.d.ts +60 -0
  210. package/dist/statement/line.js +73 -0
  211. package/dist/statement/ofx.d.ts +47 -0
  212. package/dist/statement/ofx.js +199 -0
  213. package/package.json +72 -4
package/README.md CHANGED
@@ -1,3 +1,1494 @@
1
- # Temporary Holding Version
1
+ # tallyd
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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