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
@@ -0,0 +1,1660 @@
1
+ /**
2
+ * The commands.
3
+ *
4
+ * `run` is a pure function of its arguments and an environment: it takes the
5
+ * argv, a filesystem reader and a clock, and returns text plus an exit code.
6
+ * Nothing here touches `process` or `console`, which is what makes the whole
7
+ * CLI testable end to end without spawning anything.
8
+ *
9
+ * Exit codes carry meaning. Zero is success; 1 is a usage or input problem;
10
+ * and 2 is reserved for work that ran to completion and came out wrong — a
11
+ * reconciliation that does not balance, a trial balance that does not agree.
12
+ * A CI job that treats an unbalanced reconciliation as a pass is worse than no
13
+ * CI job at all.
14
+ */
15
+ import { Money } from "../money/money.js";
16
+ import { currency as lookupCurrency } from "../money/currency.js";
17
+ import { addDays, compareDates, date, dateRange, daysBetween } from "../ledger/date.js";
18
+ import { ledgerFromJson, ledgerToJson } from "../ledger/serialise.js";
19
+ import { renderTrialBalance, trialBalance } from "../ledger/trialBalance.js";
20
+ import { importStatement } from "../statement/index.js";
21
+ import { incomeStatement, renderIncomeStatement } from "../reports/incomeStatement.js";
22
+ import { balanceSheet, renderBalanceSheet } from "../reports/balanceSheet.js";
23
+ import { ageing, renderAgeing } from "../reports/ageing.js";
24
+ import { bankView } from "../reconcile/bankView.js";
25
+ import { reconcile, significantReasons } from "../reconcile/matcher.js";
26
+ import { reconciliationBridge, renderReconciliationBridge, statementClosingBalance, } from "../reconcile/bridge.js";
27
+ import { measureAccuracy } from "../reconcile/accuracy.js";
28
+ import { MatchMemory, MemoryDocumentError, renderMemory } from "../reconcile/memory.js";
29
+ import { DecisionDocumentError, decisionsFor } from "../reconcile/decisions.js";
30
+ import { generateBooks, statementCsv, } from "../demo/generator.js";
31
+ import { dashboardData } from "../dashboard/model.js";
32
+ import { renderDashboard } from "../dashboard/render.js";
33
+ import { applyProposals, parseRules, PostingRuleError, proposeEntries, renderProposals, linesNeedingEntries, standardRules, summariseProposals, } from "../reconcile/posting.js";
34
+ import { averageRate } from "../fx/average.js";
35
+ import { GroupDocumentError, groupFromJson } from "../group/document.js";
36
+ import { renderAggregation } from "../group/aggregate.js";
37
+ import { renderEliminations } from "../group/intercompany.js";
38
+ import { renderAcquisition } from "../group/acquisition.js";
39
+ import { renderDisposal } from "../group/disposal.js";
40
+ import { consolidate, renderConsolidation } from "../group/consolidate.js";
41
+ import { compareConsolidations, renderComparative } from "../group/comparative.js";
42
+ import { renderMovementSchedule, renderNetAssetsMovements } from "../group/movement.js";
43
+ import { renderControlWindows } from "../group/timeline.js";
44
+ import { GroupError } from "../group/structure.js";
45
+ import { exposures, renderExposures } from "../fx/exposure.js";
46
+ import { TranslationError, renderTranslation, translate, } from "../fx/translate.js";
47
+ import { RevaluationError, renderRevaluation, revalue, } from "../fx/revaluation.js";
48
+ import { RateDocumentError, ratesFromText } from "../fx/document.js";
49
+ import { RateTable } from "../fx/table.js";
50
+ import { ArgumentError, booleanFlag, parseArgs, renderFlags, requiredFlag, stringFlag, } from "./args.js";
51
+ const COMMON = {
52
+ ledger: { kind: "string", short: "l", describe: "Ledger document (JSON)", placeholder: "file" },
53
+ json: { kind: "boolean", describe: "Emit JSON instead of text" },
54
+ };
55
+ const COMMANDS = {
56
+ report: {
57
+ describe: "Trial balance, income statement and balance sheet",
58
+ flags: {
59
+ ...COMMON,
60
+ "as-at": { kind: "string", describe: "Balance sheet date (default: today)", placeholder: "date" },
61
+ from: { kind: "string", describe: "Start of the income statement period", placeholder: "date" },
62
+ to: { kind: "string", describe: "End of the income statement period", placeholder: "date" },
63
+ compare: { kind: "string", describe: "Comparative period, as from:to", placeholder: "a:b" },
64
+ present: { kind: "string", short: "p", describe: "Also present the trial balance in this currency", placeholder: "code" },
65
+ rates: { kind: "string", short: "r", describe: "Rate table for --present (JSON or CSV)", placeholder: "file" },
66
+ base: { kind: "string", short: "b", describe: "Base currency for a CSV of bare currency columns", placeholder: "code" },
67
+ equity: { kind: "string", describe: "Translate equity at historical (default) or closing rates", placeholder: "how" },
68
+ "average-method": { kind: "string", describe: "daily (default) or quoted, for the P&L rate", placeholder: "how" },
69
+ stale: { kind: "string", describe: "Days a quote may be behind the date asked for", placeholder: "days" },
70
+ },
71
+ },
72
+ ageing: {
73
+ describe: "Outstanding items on a control account, bucketed by age",
74
+ flags: {
75
+ ...COMMON,
76
+ account: { kind: "string", short: "a", describe: "Control account code", placeholder: "code" },
77
+ "as-at": { kind: "string", describe: "Ageing date (default: today)", placeholder: "date" },
78
+ buckets: { kind: "string", describe: "Bucket boundaries in days (default 30,60,90)", placeholder: "n,n,n" },
79
+ },
80
+ },
81
+ reconcile: {
82
+ describe: "Match a bank statement against the ledger and print the review queue",
83
+ flags: {
84
+ ...COMMON,
85
+ statement: { kind: "string", short: "s", describe: "Bank statement (CSV or OFX)", placeholder: "file" },
86
+ account: { kind: "string", short: "a", describe: "Bank account code", placeholder: "code" },
87
+ currency: { kind: "string", describe: "Statement currency (default: the chart's)", placeholder: "code" },
88
+ "date-window": { kind: "string", describe: "Days either side a pair may differ", placeholder: "days" },
89
+ "no-groups": { kind: "boolean", describe: "Disable one-to-many matching" },
90
+ memory: { kind: "string", short: "m", describe: "Confirmed counterparties (JSON)", placeholder: "file" },
91
+ },
92
+ },
93
+ import: {
94
+ describe: "Show what the statement reader made of a file, without matching",
95
+ flags: {
96
+ statement: { kind: "string", short: "s", describe: "Bank statement (CSV or OFX)", placeholder: "file" },
97
+ currency: { kind: "string", describe: "Statement currency (default GBP)", placeholder: "code" },
98
+ json: { kind: "boolean", describe: "Emit JSON instead of text" },
99
+ },
100
+ },
101
+ accounts: {
102
+ describe: "Render the chart of accounts",
103
+ flags: { ...COMMON },
104
+ },
105
+ generate: {
106
+ describe: "Write a synthetic ledger and the bank statement that goes with it",
107
+ flags: {
108
+ out: { kind: "string", short: "o", describe: "Directory to write into", placeholder: "dir" },
109
+ seed: { kind: "string", describe: "Same seed, same books (default 1)", placeholder: "n" },
110
+ months: { kind: "string", describe: "Months to generate (default 3)", placeholder: "n" },
111
+ invoices: { kind: "string", describe: "Invoices a month (default 12)", placeholder: "n" },
112
+ start: { kind: "string", describe: "First day (default 2026-01-01)", placeholder: "date" },
113
+ currency: { kind: "string", describe: "Currency (default GBP)", placeholder: "code" },
114
+ truth: { kind: "boolean", describe: "Also write the ground-truth links as JSON" },
115
+ json: { kind: "boolean", describe: "Emit JSON instead of text" },
116
+ },
117
+ },
118
+ bench: {
119
+ describe: "Time the matcher over generated books of increasing size",
120
+ flags: {
121
+ seed: { kind: "string", describe: "Same seed, same books (default 1)", placeholder: "n" },
122
+ sizes: {
123
+ kind: "string",
124
+ describe: "Sizes as months:invoices (default 1:10,3:12,6:15,12:15)",
125
+ placeholder: "m:i,...",
126
+ },
127
+ repeat: { kind: "string", describe: "Runs per size, fastest wins (default 1)", placeholder: "n" },
128
+ json: { kind: "boolean", describe: "Emit JSON instead of text" },
129
+ },
130
+ },
131
+ dashboard: {
132
+ describe: "Write a self-contained HTML reconciliation dashboard",
133
+ flags: {
134
+ ledger: { kind: "string", short: "l", describe: "Ledger document (JSON)", placeholder: "file" },
135
+ statement: { kind: "string", short: "s", describe: "Bank statement (CSV or OFX)", placeholder: "file" },
136
+ account: { kind: "string", short: "a", describe: "Bank account code", placeholder: "code" },
137
+ out: { kind: "string", short: "o", describe: "Where to write the HTML", placeholder: "file" },
138
+ currency: { kind: "string", describe: "Statement currency (default: the chart's)", placeholder: "code" },
139
+ "date-window": { kind: "string", describe: "Days either side a pair may differ", placeholder: "days" },
140
+ "no-groups": { kind: "boolean", describe: "Disable one-to-many matching" },
141
+ memory: { kind: "string", short: "m", describe: "Confirmed counterparties (JSON)", placeholder: "file" },
142
+ rules: { kind: "string", short: "r", describe: "Classification rules for the implied entries (JSON)", placeholder: "file" },
143
+ suspense: { kind: "string", describe: "Existing account for lines no rule matched", placeholder: "code" },
144
+ },
145
+ },
146
+ post: {
147
+ describe: "Propose the journal entries a reconciled statement implies",
148
+ flags: {
149
+ ledger: { kind: "string", short: "l", describe: "Ledger document (JSON)", placeholder: "file" },
150
+ statement: { kind: "string", short: "s", describe: "Bank statement (CSV or OFX)", placeholder: "file" },
151
+ account: { kind: "string", short: "a", describe: "Bank account code", placeholder: "code" },
152
+ decisions: { kind: "string", short: "d", describe: "Reviewed decisions from the dashboard (JSON)", placeholder: "file" },
153
+ rules: { kind: "string", short: "r", describe: "Classification rules (JSON); the built-in set is used otherwise", placeholder: "file" },
154
+ suspense: { kind: "string", describe: "Existing account for lines no rule matched", placeholder: "code" },
155
+ out: { kind: "string", short: "o", describe: "Write the ledger with the entries applied", placeholder: "file" },
156
+ currency: { kind: "string", describe: "Statement currency (default: the chart's)", placeholder: "code" },
157
+ "date-window": { kind: "string", describe: "Days either side a pair may differ", placeholder: "days" },
158
+ "no-groups": { kind: "boolean", describe: "Disable one-to-many matching" },
159
+ memory: { kind: "string", short: "m", describe: "Confirmed counterparties (JSON)", placeholder: "file" },
160
+ strict: { kind: "boolean", describe: "Exit 2 when any line could not be classified" },
161
+ json: { kind: "boolean", describe: "Emit JSON instead of text" },
162
+ },
163
+ },
164
+ rates: {
165
+ describe: "Inspect a rate table, price a pair on a date, convert an amount",
166
+ flags: {
167
+ rates: { kind: "string", short: "r", describe: "Rate table (JSON or CSV)", placeholder: "file" },
168
+ base: { kind: "string", short: "b", describe: "Base currency for a CSV of bare currency columns", placeholder: "code" },
169
+ invert: { kind: "boolean", describe: "The CSV quotes the base per unit of the column" },
170
+ pair: { kind: "string", short: "p", describe: "Pair to price, as BASE/QUOTE", placeholder: "a/b" },
171
+ on: { kind: "string", describe: "Date to price it on (default: today)", placeholder: "date" },
172
+ amount: { kind: "string", describe: "Convert this amount from the pair's base", placeholder: "n" },
173
+ average: { kind: "string", describe: "Average over a period instead, as from:to", placeholder: "a:b" },
174
+ method: { kind: "string", describe: "Average method: daily (default) or quoted", placeholder: "how" },
175
+ stale: { kind: "string", describe: "Days a quote may be behind the date asked for", placeholder: "days" },
176
+ json: { kind: "boolean", describe: "Emit JSON instead of text" },
177
+ },
178
+ },
179
+ revalue: {
180
+ describe: "Retranslate foreign-currency balances at a closing rate",
181
+ flags: {
182
+ ledger: { kind: "string", short: "l", describe: "Ledger document (JSON)", placeholder: "file" },
183
+ rates: { kind: "string", short: "r", describe: "Rate table (JSON or CSV)", placeholder: "file" },
184
+ base: { kind: "string", short: "b", describe: "Base currency for a CSV of bare currency columns", placeholder: "code" },
185
+ "as-at": { kind: "string", describe: "Closing date (default: today)", placeholder: "date" },
186
+ out: { kind: "string", short: "o", describe: "Write the ledger with the revaluation applied", placeholder: "file" },
187
+ gain: { kind: "string", describe: "Income account for a net gain (default 4400)", placeholder: "code" },
188
+ loss: { kind: "string", describe: "Expense account for a net loss (default 5950)", placeholder: "code" },
189
+ exclude: { kind: "string", describe: "Accounts to leave alone, comma-separated", placeholder: "a,b" },
190
+ stale: { kind: "string", describe: "Days a quote may be behind the closing date", placeholder: "days" },
191
+ show: { kind: "boolean", describe: "Print the exposures and stop, without revaluing" },
192
+ json: { kind: "boolean", describe: "Emit JSON instead of text" },
193
+ },
194
+ },
195
+ consolidate: {
196
+ describe: "Consolidate a group of companies into one set of accounts",
197
+ flags: {
198
+ group: { kind: "string", short: "g", describe: "Group document (JSON)", placeholder: "file" },
199
+ rates: { kind: "string", short: "r", describe: "Rate table (JSON or CSV)", placeholder: "file" },
200
+ base: { kind: "string", short: "b", describe: "Base currency for a CSV of bare currency columns", placeholder: "code" },
201
+ "as-at": { kind: "string", describe: "Reporting date (default: the group document's)", placeholder: "date" },
202
+ from: { kind: "string", describe: "Start of the reporting period", placeholder: "date" },
203
+ to: { kind: "string", describe: "End of the reporting period", placeholder: "date" },
204
+ comparative: { kind: "string", describe: "Consolidate at this date too and show both columns", placeholder: "date" },
205
+ "comparative-from": { kind: "string", describe: "Start of the comparative period (default: the same length)", placeholder: "date" },
206
+ show: { kind: "string", describe: "workings (default), combined, eliminations, windows, movement, statements or all", placeholder: "what" },
207
+ "average-method": { kind: "string", describe: "daily (default) or quoted, for the P&L rate", placeholder: "how" },
208
+ stale: { kind: "string", describe: "Days a quote may be behind the date asked for", placeholder: "days" },
209
+ out: { kind: "string", short: "o", describe: "Write the consolidated ledger as a document", placeholder: "file" },
210
+ json: { kind: "boolean", describe: "Emit JSON instead of text" },
211
+ },
212
+ },
213
+ learn: {
214
+ describe: "Fold reviewed decisions into the counterparty memory",
215
+ flags: {
216
+ memory: { kind: "string", short: "m", describe: "Memory file, created if absent", placeholder: "file" },
217
+ decisions: { kind: "string", short: "d", describe: "Reviewed decisions (JSON)", placeholder: "file" },
218
+ show: { kind: "boolean", describe: "Print what is remembered and write nothing" },
219
+ json: { kind: "boolean", describe: "Emit JSON instead of text" },
220
+ },
221
+ },
222
+ };
223
+ function usage() {
224
+ const width = Math.max(...Object.keys(COMMANDS).map((name) => name.length));
225
+ const lines = [
226
+ "tallyd — double-entry ledger and bank reconciliation",
227
+ "",
228
+ "Usage: tallyd <command> [options]",
229
+ "",
230
+ "Commands:",
231
+ ...Object.entries(COMMANDS).map(([name, command]) => ` ${name.padEnd(width + 2)}${command.describe}`),
232
+ "",
233
+ "Run 'tallyd <command> --help' for the options of a command.",
234
+ "",
235
+ "Exit codes: 0 success, 1 bad input, 2 the books do not agree.",
236
+ ];
237
+ return lines.join("\n");
238
+ }
239
+ function commandHelp(name) {
240
+ const command = COMMANDS[name];
241
+ return [`tallyd ${name} — ${command.describe}`, "", "Options:", renderFlags(command.flags)].join("\n");
242
+ }
243
+ function loadLedger(environment, path) {
244
+ return ledgerFromJson(environment.readFile(path));
245
+ }
246
+ function parseBoundaries(text) {
247
+ if (text === undefined)
248
+ return undefined;
249
+ const values = text
250
+ .split(",")
251
+ .map((part) => part.trim())
252
+ .filter((part) => part !== "")
253
+ .map((part) => {
254
+ const value = Number(part);
255
+ if (!Number.isInteger(value) || value <= 0) {
256
+ throw new ArgumentError(`--buckets wants positive whole numbers, got "${part}"`);
257
+ }
258
+ return value;
259
+ });
260
+ if (values.length === 0)
261
+ throw new ArgumentError("--buckets needs at least one boundary");
262
+ return values;
263
+ }
264
+ /**
265
+ * The memory named by `--memory`, or an empty one.
266
+ *
267
+ * A missing file is not an error the first time round: a reviewer who has not
268
+ * confirmed anything yet has an empty memory, and asking them to create a file
269
+ * full of nothing before they can reconcile would be silly.
270
+ */
271
+ function loadMemory(environment, path) {
272
+ if (path === undefined)
273
+ return MatchMemory.empty();
274
+ try {
275
+ return MatchMemory.fromJson(environment.readFile(path));
276
+ }
277
+ catch (error) {
278
+ if (error instanceof MemoryDocumentError)
279
+ throw error;
280
+ return MatchMemory.empty();
281
+ }
282
+ }
283
+ /**
284
+ * Resolve the account to reconcile against.
285
+ *
286
+ * A grouping account is refused rather than reconciled. It exists in the chart
287
+ * and nothing has ever been posted to it, so reconciling it "succeeds": no
288
+ * ledger movements, no matches, and a bridge that is out by the entire
289
+ * statement while reporting nothing wrong. That is the worst kind of wrong
290
+ * answer, because it looks like an answer.
291
+ */
292
+ function bankAccountFor(ledger, requested) {
293
+ if (requested !== undefined) {
294
+ const chart = ledger.chart;
295
+ if (chart !== undefined) {
296
+ if (!chart.has(requested)) {
297
+ throw new ArgumentError(`No account ${requested} in the chart`);
298
+ }
299
+ if (!chart.isPostable(requested)) {
300
+ const account = chart.get(requested);
301
+ const children = chart.children(requested).map((child) => child.code);
302
+ const advice = children.length > 0 ? `; reconcile one of ${children.join(", ")}` : "";
303
+ throw new ArgumentError(`Account ${requested} (${account.name}) cannot be posted to, so there is nothing to ` +
304
+ `reconcile against it${advice}`);
305
+ }
306
+ }
307
+ return requested;
308
+ }
309
+ // "1110" is the bank account in the standard chart; fall back to the first
310
+ // posted asset account so an unusual chart still works without a flag.
311
+ if (ledger.chart?.has("1110") === true)
312
+ return "1110";
313
+ const asset = ledger.activeAccounts().find((code) => ledger.chart?.find(code)?.type === "asset");
314
+ if (asset === undefined) {
315
+ throw new ArgumentError("Could not guess the bank account; pass --account");
316
+ }
317
+ return asset;
318
+ }
319
+ // ------------------------------------------------------------------ commands
320
+ function reportCommand(environment, argv) {
321
+ const parsed = parseArgs(argv, COMMANDS["report"].flags);
322
+ const ledger = loadLedger(environment, requiredFlag(parsed, "ledger"));
323
+ const asAtText = stringFlag(parsed, "as-at");
324
+ const asAt = asAtText === undefined ? environment.today() : date(asAtText);
325
+ const from = stringFlag(parsed, "from");
326
+ const to = stringFlag(parsed, "to");
327
+ const period = dateRange(from ?? "0001-01-01", to ?? asAt);
328
+ const compare = stringFlag(parsed, "compare");
329
+ let comparative;
330
+ if (compare !== undefined) {
331
+ const parts = compare.split(":");
332
+ if (parts.length !== 2)
333
+ throw new ArgumentError("--compare wants from:to");
334
+ comparative = dateRange(parts[0], parts[1]);
335
+ }
336
+ const balances = trialBalance(ledger, { asAt });
337
+ const profit = incomeStatement(ledger, period, comparative === undefined ? {} : { comparative });
338
+ const sheet = balanceSheet(ledger, asAt);
339
+ const agrees = balances.balanced && sheet.balanced;
340
+ // The books stay in the currency they are kept in; presenting is an extra
341
+ // reading of them, printed alongside rather than instead.
342
+ const presentIn = stringFlag(parsed, "present");
343
+ let translated;
344
+ if (presentIn !== undefined) {
345
+ const equityText = stringFlag(parsed, "equity") ?? "historical";
346
+ if (equityText !== "historical" && equityText !== "closing") {
347
+ throw new ArgumentError(`--equity wants historical or closing, got "${equityText}"`);
348
+ }
349
+ const methodText = stringFlag(parsed, "average-method") ?? "daily";
350
+ if (methodText !== "daily" && methodText !== "quoted") {
351
+ throw new ArgumentError(`--average-method wants daily or quoted, got "${methodText}"`);
352
+ }
353
+ const rates = stringFlag(parsed, "rates") === undefined ? RateTable.empty() : loadRates(environment, parsed);
354
+ try {
355
+ translated = translate(ledger, {
356
+ presentation: presentIn,
357
+ rates,
358
+ asAt,
359
+ period,
360
+ equityBasis: equityText,
361
+ averageMethod: methodText,
362
+ });
363
+ }
364
+ catch (error) {
365
+ if (error instanceof TranslationError)
366
+ throw new ArgumentError(error.message);
367
+ throw error;
368
+ }
369
+ }
370
+ if (booleanFlag(parsed, "json")) {
371
+ return {
372
+ stdout: JSON.stringify({
373
+ asAt,
374
+ period: { from: period.from, to: period.to },
375
+ trialBalance: {
376
+ balanced: balances.balanced,
377
+ totalDebit: balances.totalDebit.toDecimalString(),
378
+ totalCredit: balances.totalCredit.toDecimalString(),
379
+ },
380
+ incomeStatement: {
381
+ income: profit.income.total.toDecimalString(),
382
+ expenses: profit.expenses.total.toDecimalString(),
383
+ netResult: profit.netResult.toDecimalString(),
384
+ },
385
+ balanceSheet: {
386
+ assets: sheet.assets.total.toDecimalString(),
387
+ liabilities: sheet.liabilities.total.toDecimalString(),
388
+ equity: sheet.equity.total.toDecimalString(),
389
+ balanced: sheet.balanced,
390
+ },
391
+ ...(translated === undefined
392
+ ? {}
393
+ : {
394
+ presented: {
395
+ currency: translated.presentation.code,
396
+ closingRate: translated.closingRate?.toDecimalString(10) ?? null,
397
+ averageRate: translated.averageRate?.toDecimalString(10) ?? null,
398
+ averageMethod: translated.averageMethod,
399
+ translationAdjustment: translated.translationAdjustment.toDecimalString(),
400
+ totalDebit: translated.totalDebit.toDecimalString(),
401
+ totalCredit: translated.totalCredit.toDecimalString(),
402
+ rows: translated.rows.map((row) => ({
403
+ account: row.account,
404
+ name: row.name,
405
+ basis: row.basis,
406
+ functional: row.functional.toDecimalString(),
407
+ presentation: row.presentation.toDecimalString(),
408
+ rate: row.effectiveRate?.toDecimalString(10) ?? null,
409
+ })),
410
+ },
411
+ }),
412
+ }, null, 2),
413
+ stderr: "",
414
+ code: agrees ? 0 : 2,
415
+ };
416
+ }
417
+ return {
418
+ stdout: [
419
+ renderTrialBalance(balances),
420
+ "",
421
+ renderIncomeStatement(profit),
422
+ "",
423
+ renderBalanceSheet(sheet),
424
+ ...(translated === undefined ? [] : ["", renderTranslation(translated)]),
425
+ ].join("\n"),
426
+ stderr: agrees ? "" : "The books do not agree; see the differences above.\n",
427
+ code: agrees ? 0 : 2,
428
+ };
429
+ }
430
+ function ageingCommand(environment, argv) {
431
+ const parsed = parseArgs(argv, COMMANDS["ageing"].flags);
432
+ const ledger = loadLedger(environment, requiredFlag(parsed, "ledger"));
433
+ const account = requiredFlag(parsed, "account");
434
+ const asAtText = stringFlag(parsed, "as-at");
435
+ const asAt = asAtText === undefined ? environment.today() : date(asAtText);
436
+ const boundaries = parseBoundaries(stringFlag(parsed, "buckets"));
437
+ const report = ageing(ledger, account, asAt, boundaries === undefined ? {} : { boundaries });
438
+ if (booleanFlag(parsed, "json")) {
439
+ return {
440
+ stdout: JSON.stringify({
441
+ account: report.account,
442
+ asAt: report.asAt,
443
+ total: report.total.toDecimalString(),
444
+ buckets: report.buckets.map((bucket) => ({
445
+ label: bucket.label,
446
+ total: bucket.total.toDecimalString(),
447
+ count: bucket.items.length,
448
+ })),
449
+ items: report.items.map((item) => ({
450
+ reference: item.reference,
451
+ raised: item.raised,
452
+ days: item.daysOutstanding,
453
+ outstanding: item.outstanding.toDecimalString(),
454
+ })),
455
+ }, null, 2),
456
+ stderr: "",
457
+ code: 0,
458
+ };
459
+ }
460
+ return { stdout: renderAgeing(report), stderr: "", code: 0 };
461
+ }
462
+ function reconciliationFor(environment, parsed, extraDecisions = []) {
463
+ const ledger = loadLedger(environment, requiredFlag(parsed, "ledger"));
464
+ const account = bankAccountFor(ledger, stringFlag(parsed, "account"));
465
+ const raw = environment.readFile(requiredFlag(parsed, "statement"));
466
+ const currencyCode = stringFlag(parsed, "currency") ??
467
+ ledger.chart?.find(account)?.currency.code ??
468
+ ledger.currenciesUsed()[0] ??
469
+ "GBP";
470
+ const imported = importStatement(raw, { currency: lookupCurrency(currencyCode), idPrefix: "BANK" });
471
+ const options = {};
472
+ const windowText = stringFlag(parsed, "date-window");
473
+ if (windowText !== undefined) {
474
+ const days = Number(windowText);
475
+ if (!Number.isInteger(days) || days < 0) {
476
+ throw new ArgumentError(`--date-window wants a whole number of days, got "${windowText}"`);
477
+ }
478
+ options.dateWindowDays = days;
479
+ }
480
+ if (booleanFlag(parsed, "no-groups"))
481
+ options.groupMatching = false;
482
+ const memory = loadMemory(environment, stringFlag(parsed, "memory")).learnAll(extraDecisions);
483
+ if (memory.size > 0)
484
+ options.memory = memory;
485
+ const books = bankView(ledger, account, { currency: currencyCode });
486
+ const result = reconcile(books, imported.lines, options);
487
+ return {
488
+ ledger,
489
+ account,
490
+ currencyCode,
491
+ imported,
492
+ books,
493
+ result,
494
+ memory,
495
+ bankClosingBalance: statementClosingBalance(imported.lines, Money.zero(currencyCode)),
496
+ bookClosingBalance: books.reduce((total, line) => total.plus(line.amount), Money.zero(currencyCode)),
497
+ };
498
+ }
499
+ function reconcileCommand(environment, argv) {
500
+ const parsed = parseArgs(argv, COMMANDS["reconcile"].flags);
501
+ const run = reconciliationFor(environment, parsed);
502
+ const { account, imported, result, memory } = run;
503
+ const bridge = reconciliationBridge(result, {
504
+ bankClosingBalance: run.bankClosingBalance,
505
+ bookClosingBalance: run.bookClosingBalance,
506
+ });
507
+ if (booleanFlag(parsed, "json")) {
508
+ return {
509
+ stdout: JSON.stringify({
510
+ account,
511
+ format: imported.format,
512
+ statementLines: result.stats.statementLines,
513
+ bookLines: result.stats.bookLines,
514
+ matched: result.matched.map((match) => ({
515
+ kind: match.kind,
516
+ score: Number(match.scored.score.toFixed(4)),
517
+ confidence: match.scored.confidence,
518
+ book: match.book.map((line) => line.id),
519
+ statement: match.statement.map((line) => line.id),
520
+ })),
521
+ suggested: result.suggested.map((match) => ({
522
+ kind: match.kind,
523
+ score: Number(match.scored.score.toFixed(4)),
524
+ reasons: significantReasons(match),
525
+ book: match.book.map((line) => line.id),
526
+ statement: match.statement.map((line) => line.id),
527
+ })),
528
+ unmatchedBook: result.unmatchedBook.map((line) => line.id),
529
+ unmatchedStatement: result.unmatchedStatement.map((line) => line.id),
530
+ bridge: {
531
+ bankClosingBalance: bridge.bankClosingBalance.toDecimalString(),
532
+ bookClosingBalance: bridge.bookClosingBalance.toDecimalString(),
533
+ adjustedBankBalance: bridge.adjustedBankBalance.toDecimalString(),
534
+ adjustedBookBalance: bridge.adjustedBookBalance.toDecimalString(),
535
+ difference: bridge.difference.toDecimalString(),
536
+ reconciled: bridge.reconciled,
537
+ },
538
+ }, null, 2),
539
+ stderr: "",
540
+ code: bridge.reconciled ? 0 : 2,
541
+ };
542
+ }
543
+ const out = [];
544
+ out.push(`Reconciling ${account} against ${result.stats.statementLines} statement lines`);
545
+ if (memory.size > 0) {
546
+ out.push(` ${memory.size} remembered ${memory.size === 1 ? "counterparty" : "counterparties"} in play`);
547
+ }
548
+ out.push(` ${result.matched.length} matched, ${result.suggested.length} to review, ` +
549
+ `${result.unmatchedBook.length} + ${result.unmatchedStatement.length} unmatched`);
550
+ out.push("");
551
+ if (result.matched.length > 0) {
552
+ out.push("Matched");
553
+ for (const match of result.matched) {
554
+ out.push(` ${match.scored.confidence.padEnd(7)}${match.scored.score.toFixed(3)} ` +
555
+ `${match.statement.map((line) => `${line.date} ${line.description}`).join(" + ")}`);
556
+ }
557
+ out.push("");
558
+ }
559
+ if (result.suggested.length > 0) {
560
+ out.push("Review queue");
561
+ for (const match of result.suggested) {
562
+ out.push(` ${match.scored.confidence.padEnd(7)}${match.scored.score.toFixed(3)} ` +
563
+ `${match.statement.map((line) => `${line.date} ${line.description}`).join(" + ")}`);
564
+ for (const reason of significantReasons(match))
565
+ out.push(` · ${reason}`);
566
+ }
567
+ out.push("");
568
+ }
569
+ out.push(renderReconciliationBridge(bridge));
570
+ return {
571
+ stdout: out.join("\n"),
572
+ stderr: bridge.reconciled ? "" : "The reconciliation does not balance.\n",
573
+ code: bridge.reconciled ? 0 : 2,
574
+ };
575
+ }
576
+ function importCommand(environment, argv) {
577
+ const parsed = parseArgs(argv, COMMANDS["import"].flags);
578
+ const raw = environment.readFile(requiredFlag(parsed, "statement"));
579
+ const currencyCode = stringFlag(parsed, "currency") ?? "GBP";
580
+ const imported = importStatement(raw, { currency: lookupCurrency(currencyCode), idPrefix: "BANK" });
581
+ if (booleanFlag(parsed, "json")) {
582
+ return {
583
+ stdout: JSON.stringify({
584
+ format: imported.format,
585
+ warnings: imported.warnings,
586
+ errors: imported.errors.map((error) => ({ row: error.row, reason: error.reason })),
587
+ lines: imported.lines.map((line) => ({
588
+ id: line.id,
589
+ date: line.date,
590
+ description: line.description,
591
+ amount: line.amount.toDecimalString(),
592
+ })),
593
+ }, null, 2),
594
+ stderr: "",
595
+ code: 0,
596
+ };
597
+ }
598
+ const out = [];
599
+ out.push(`Bank statement — ${imported.format.toUpperCase()}`);
600
+ out.push(` ${imported.lines.length} lines, ${imported.errors.length} errors`);
601
+ for (const warning of imported.warnings)
602
+ out.push(` warning: ${warning}`);
603
+ out.push("");
604
+ for (const line of imported.lines) {
605
+ out.push(` ${line.date} ${line.amount.toDecimalString().padStart(12)} ${line.description}`);
606
+ }
607
+ return { stdout: out.join("\n"), stderr: "", code: imported.errors.length === 0 ? 0 : 2 };
608
+ }
609
+ function accountsCommand(environment, argv) {
610
+ const parsed = parseArgs(argv, COMMANDS["accounts"].flags);
611
+ const ledger = loadLedger(environment, requiredFlag(parsed, "ledger"));
612
+ if (ledger.chart === undefined) {
613
+ return { stdout: "", stderr: "This ledger has no chart of accounts.\n", code: 1 };
614
+ }
615
+ if (booleanFlag(parsed, "json")) {
616
+ return {
617
+ stdout: JSON.stringify(ledger.chart.toDefinitions(), null, 2),
618
+ stderr: "",
619
+ code: 0,
620
+ };
621
+ }
622
+ return { stdout: ledger.chart.render(), stderr: "", code: 0 };
623
+ }
624
+ function dashboardCommand(environment, argv) {
625
+ const parsed = parseArgs(argv, COMMANDS["dashboard"].flags);
626
+ const out = requiredFlag(parsed, "out");
627
+ if (environment.writeFile === undefined) {
628
+ throw new ArgumentError("This environment cannot write files");
629
+ }
630
+ const run = reconciliationFor(environment, parsed);
631
+ const { ledger, account, imported, books, result, bankClosingBalance, bookClosingBalance } = run;
632
+ // Every statement line gets a proposal, not only the ones currently
633
+ // unmatched: the page decides which are live from its own leftovers, so a
634
+ // rejection has to have something to show the moment it is made.
635
+ const implied = proposeEntries(imported.lines, {
636
+ account,
637
+ rules: rulesFor(environment, parsed),
638
+ ledger,
639
+ ...(stringFlag(parsed, "suspense") === undefined
640
+ ? {}
641
+ : { suspenseAccount: stringFlag(parsed, "suspense") }),
642
+ });
643
+ const html = renderDashboard(dashboardData({
644
+ ledger,
645
+ account,
646
+ books,
647
+ statement: imported.lines,
648
+ result,
649
+ bankClosingBalance,
650
+ bookClosingBalance,
651
+ statementFormat: imported.format,
652
+ implied,
653
+ }));
654
+ environment.writeFile(out, html);
655
+ const bridge = reconciliationBridge(result, { bankClosingBalance, bookClosingBalance });
656
+ return {
657
+ stdout: `Wrote ${out} — ${result.matched.length} matched, ${result.suggested.length} to review, ${Math.round(html.length / 1024)} KB, no network needed.`,
658
+ stderr: bridge.reconciled ? "" : "The reconciliation does not balance.\n",
659
+ code: bridge.reconciled ? 0 : 2,
660
+ };
661
+ }
662
+ // ------------------------------------------------------ generate and bench
663
+ /** A positive whole number from a flag, or the default. */
664
+ function countFlag(parsed, name, fallback) {
665
+ const text = stringFlag(parsed, name);
666
+ if (text === undefined)
667
+ return fallback;
668
+ const value = Number(text);
669
+ if (!Number.isInteger(value) || value <= 0) {
670
+ throw new ArgumentError(`--${name} wants a positive whole number, got "${text}"`);
671
+ }
672
+ return value;
673
+ }
674
+ function generatorOptionsFrom(parsed) {
675
+ const options = {
676
+ seed: countFlag(parsed, "seed", 1),
677
+ months: countFlag(parsed, "months", 3),
678
+ invoicesPerMonth: countFlag(parsed, "invoices", 12),
679
+ };
680
+ const start = stringFlag(parsed, "start");
681
+ const code = stringFlag(parsed, "currency");
682
+ return {
683
+ ...options,
684
+ ...(start === undefined ? {} : { start: date(start) }),
685
+ ...(code === undefined ? {} : { currency: lookupCurrency(code) }),
686
+ };
687
+ }
688
+ function generateCommand(environment, argv) {
689
+ const parsed = parseArgs(argv, COMMANDS["generate"].flags);
690
+ if (environment.writeFile === undefined) {
691
+ return { stdout: "", stderr: "This environment cannot write files.\n", code: 1 };
692
+ }
693
+ const directory = stringFlag(parsed, "out") ?? ".";
694
+ const generated = generateBooks(generatorOptionsFrom(parsed));
695
+ const ledgerPath = `${directory}/books.json`;
696
+ const statementPath = `${directory}/statement.csv`;
697
+ environment.writeFile(ledgerPath, ledgerToJson(generated.ledger));
698
+ environment.writeFile(statementPath, statementCsv(generated));
699
+ const written = [ledgerPath, statementPath];
700
+ if (booleanFlag(parsed, "truth")) {
701
+ const truthPath = `${directory}/truth.json`;
702
+ environment.writeFile(truthPath, `${JSON.stringify(generated.truth, null, 2)}\n`);
703
+ written.push(truthPath);
704
+ }
705
+ if (booleanFlag(parsed, "json")) {
706
+ return {
707
+ stdout: JSON.stringify({ written, summary: generated.summary }, null, 2),
708
+ stderr: "",
709
+ code: 0,
710
+ };
711
+ }
712
+ const summary = generated.summary;
713
+ return {
714
+ stdout: [
715
+ `Wrote ${written.join(", ")}`,
716
+ ` ${summary.entries} journal entries, ${summary.statementLines} statement lines`,
717
+ ` ${summary.explainable} lines a matcher could explain, of which ${summary.grouped} are batches`,
718
+ ` ${summary.bankOnly} bank-only, ${summary.ledgerOnly} still outstanding`,
719
+ ].join("\n"),
720
+ stderr: "",
721
+ code: 0,
722
+ };
723
+ }
724
+ function parseSizes(text) {
725
+ if (text === undefined) {
726
+ return [
727
+ { months: 1, invoices: 10 },
728
+ { months: 3, invoices: 12 },
729
+ { months: 6, invoices: 15 },
730
+ { months: 12, invoices: 15 },
731
+ ];
732
+ }
733
+ const sizes = text
734
+ .split(",")
735
+ .map((part) => part.trim())
736
+ .filter((part) => part !== "")
737
+ .map((part) => {
738
+ const [months, invoices] = part.split(":");
739
+ const m = Number(months);
740
+ const i = Number(invoices);
741
+ if (!Number.isInteger(m) || m <= 0 || !Number.isInteger(i) || i <= 0) {
742
+ throw new ArgumentError(`--sizes wants months:invoices pairs, got "${part}"`);
743
+ }
744
+ return { months: m, invoices: i };
745
+ });
746
+ if (sizes.length === 0)
747
+ throw new ArgumentError("--sizes needs at least one size");
748
+ return sizes;
749
+ }
750
+ /**
751
+ * Time the matcher, and check it is still right while doing so.
752
+ *
753
+ * A benchmark that only reports wall time is a benchmark that will eventually
754
+ * celebrate a matcher that stopped matching. The generator knows which lines
755
+ * really belong together, so every timed run is scored against that too, and
756
+ * the accuracy columns sit next to the milliseconds.
757
+ */
758
+ function benchCommand(_environment, argv) {
759
+ const parsed = parseArgs(argv, COMMANDS["bench"].flags);
760
+ const seed = countFlag(parsed, "seed", 1);
761
+ const repeat = countFlag(parsed, "repeat", 1);
762
+ const sizes = parseSizes(stringFlag(parsed, "sizes"));
763
+ const rows = sizes.map((size) => {
764
+ const generated = generateBooks({
765
+ seed,
766
+ months: size.months,
767
+ invoicesPerMonth: size.invoices,
768
+ });
769
+ const books = bankView(generated.ledger, generated.bankAccount);
770
+ let best = Number.POSITIVE_INFINITY;
771
+ let result = reconcile(books, generated.statement);
772
+ for (let run = 0; run < repeat; run++) {
773
+ const started = performance.now();
774
+ result = reconcile(books, generated.statement);
775
+ best = Math.min(best, performance.now() - started);
776
+ }
777
+ const accuracy = measureAccuracy(result, generated.truth);
778
+ const cross = books.length * generated.statement.length;
779
+ return {
780
+ months: size.months,
781
+ invoices: size.invoices,
782
+ bookLines: books.length,
783
+ statementLines: generated.statement.length,
784
+ milliseconds: Number(best.toFixed(1)),
785
+ pairsScored: result.stats.pairsScored,
786
+ /** Share of the full cross product that reached the scorer. */
787
+ scoredShare: cross === 0 ? 0 : Number((result.stats.pairsScored / cross).toFixed(5)),
788
+ precision: Number(accuracy.precision.toFixed(4)),
789
+ recall: Number(accuracy.recall.toFixed(4)),
790
+ recallWithSuggestions: Number(accuracy.recallWithSuggestions.toFixed(4)),
791
+ };
792
+ });
793
+ if (booleanFlag(parsed, "json")) {
794
+ return { stdout: JSON.stringify({ seed, repeat, rows }, null, 2), stderr: "", code: 0 };
795
+ }
796
+ const header = ["size", "books", "lines", "ms", "scored", "prec", "rec", "rec+"];
797
+ const body = rows.map((row) => [
798
+ `${row.months}m x${row.invoices}`,
799
+ String(row.bookLines),
800
+ String(row.statementLines),
801
+ row.milliseconds.toFixed(1),
802
+ `${(row.scoredShare * 100).toFixed(2)}%`,
803
+ `${(row.precision * 100).toFixed(1)}%`,
804
+ `${(row.recall * 100).toFixed(1)}%`,
805
+ `${(row.recallWithSuggestions * 100).toFixed(1)}%`,
806
+ ]);
807
+ const widths = header.map((cell, column) => Math.max(cell.length, ...body.map((row) => row[column].length)));
808
+ const format = (cells) => cells.map((cell, column) => cell.padStart(widths[column])).join(" ");
809
+ return {
810
+ stdout: [
811
+ `Matcher over generated books, seed ${seed}, best of ${repeat}`,
812
+ "",
813
+ format(header),
814
+ ...body.map(format),
815
+ "",
816
+ "scored is the share of all possible pairs that reached the scorer;",
817
+ "rec+ counts a correct suggestion as found, which is what a reviewer sees.",
818
+ ].join("\n"),
819
+ stderr: "",
820
+ code: 0,
821
+ };
822
+ }
823
+ /**
824
+ * Fold a reviewer's decisions into the memory.
825
+ *
826
+ * The decisions file is deliberately dumb: a list of `{ statement, book,
827
+ * accepted, on }`. That is what a review UI can emit, what a script can build
828
+ * from a spreadsheet, and what a person can write by hand when they want to
829
+ * teach the matcher one thing without clicking through a queue to do it.
830
+ */
831
+ function learnCommand(environment, argv) {
832
+ const parsed = parseArgs(argv, COMMANDS["learn"].flags);
833
+ const memoryPath = requiredFlag(parsed, "memory");
834
+ const existing = loadMemory(environment, memoryPath);
835
+ if (booleanFlag(parsed, "show")) {
836
+ if (booleanFlag(parsed, "json")) {
837
+ return { stdout: JSON.stringify(existing.toDocument(), null, 2), stderr: "", code: 0 };
838
+ }
839
+ return { stdout: renderMemory(existing), stderr: "", code: 0 };
840
+ }
841
+ if (environment.writeFile === undefined) {
842
+ return { stdout: "", stderr: "This environment cannot write files.\n", code: 1 };
843
+ }
844
+ const decisions = readDecisions(environment, requiredFlag(parsed, "decisions"));
845
+ const updated = existing.learnAll(decisions);
846
+ environment.writeFile(memoryPath, updated.toJson());
847
+ const learned = updated.size - existing.size;
848
+ if (booleanFlag(parsed, "json")) {
849
+ return {
850
+ stdout: JSON.stringify({ memory: memoryPath, decisions: decisions.length, pairings: updated.size, new: learned }, null, 2),
851
+ stderr: "",
852
+ code: 0,
853
+ };
854
+ }
855
+ return {
856
+ stdout: [
857
+ `Read ${decisions.length} ${decisions.length === 1 ? "decision" : "decisions"}, wrote ${memoryPath}`,
858
+ ` ${updated.size} ${updated.size === 1 ? "pairing" : "pairings"} remembered, ${learned} new`,
859
+ ].join("\n"),
860
+ stderr: "",
861
+ code: 0,
862
+ };
863
+ }
864
+ /**
865
+ * Book what the statement says happened and the books do not know about.
866
+ *
867
+ * Only lines the matcher left *entirely* unexplained are proposed. A line
868
+ * sitting in the review queue has a ledger counterpart the matcher believes
869
+ * in; booking an entry for it as well would double-count it. Undecided means
870
+ * unproposed, which is the conservative reading and the right one.
871
+ *
872
+ * Decisions reach this command through the memory rather than through match
873
+ * ids. The decisions file has no ids in it on purpose — an id only means
874
+ * something against the exact reconciliation that produced it — so a
875
+ * confirmation raises the pair's score until it matches outright, and a
876
+ * rejection pushes it down until the statement line falls out of the queue and
877
+ * becomes something to book. The mechanism was already there; this command
878
+ * just points it at the right question.
879
+ */
880
+ function postCommand(environment, argv) {
881
+ const parsed = parseArgs(argv, COMMANDS["post"].flags);
882
+ const decisionsPath = stringFlag(parsed, "decisions");
883
+ const decisions = decisionsPath === undefined ? [] : readDecisions(environment, decisionsPath);
884
+ const run = reconciliationFor(environment, parsed, decisions);
885
+ const { ledger, account, currencyCode, result, memory } = run;
886
+ const needing = linesNeedingEntries(result, memory);
887
+ const rules = rulesFor(environment, parsed);
888
+ const suspense = stringFlag(parsed, "suspense");
889
+ if (suspense !== undefined && ledger.chart?.isPostable(suspense) === false) {
890
+ throw new ArgumentError(`The suspense account ${suspense} is not one this chart can post to`);
891
+ }
892
+ const options = {
893
+ account,
894
+ rules,
895
+ ledger,
896
+ ...(suspense === undefined ? {} : { suspenseAccount: suspense }),
897
+ };
898
+ let proposals;
899
+ try {
900
+ proposals = proposeEntries(needing, options);
901
+ }
902
+ catch (error) {
903
+ if (error instanceof PostingRuleError)
904
+ throw new ArgumentError(error.message);
905
+ throw error;
906
+ }
907
+ const summary = summariseProposals(proposals, currencyCode);
908
+ const out = stringFlag(parsed, "out");
909
+ let written;
910
+ if (out !== undefined) {
911
+ if (environment.writeFile === undefined) {
912
+ return { stdout: "", stderr: "This environment cannot write files.\n", code: 1 };
913
+ }
914
+ environment.writeFile(out, ledgerToJson(applyProposals(ledger, proposals)));
915
+ written = out;
916
+ }
917
+ const strict = booleanFlag(parsed, "strict");
918
+ const code = strict && summary.unclassified > 0 ? 2 : 0;
919
+ if (booleanFlag(parsed, "json")) {
920
+ return {
921
+ stdout: JSON.stringify({
922
+ account,
923
+ proposals: proposals.map((proposal) => ({
924
+ statementLine: proposal.line.id,
925
+ date: proposal.line.date,
926
+ description: proposal.line.description,
927
+ amount: proposal.line.amount.toDecimalString(),
928
+ outcome: proposal.outcome,
929
+ rule: proposal.rule?.id ?? null,
930
+ account: proposal.account,
931
+ entry: proposal.entry?.id ?? null,
932
+ reason: proposal.reason,
933
+ })),
934
+ summary: {
935
+ total: summary.total,
936
+ booked: summary.booked,
937
+ skipped: summary.skipped,
938
+ unclassified: summary.unclassified,
939
+ alreadyBooked: summary.alreadyBooked,
940
+ net: summary.net.toDecimalString(),
941
+ byAccount: summary.byAccount.map((total) => ({
942
+ account: total.account,
943
+ amount: total.amount.toDecimalString(),
944
+ count: total.count,
945
+ })),
946
+ },
947
+ ...(written === undefined ? {} : { wrote: written }),
948
+ }, null, 2),
949
+ stderr: "",
950
+ code,
951
+ };
952
+ }
953
+ const lines = [
954
+ `${needing.length} statement ${needing.length === 1 ? "line has" : "lines have"} no counterpart in the books`,
955
+ "",
956
+ renderProposals(proposals, summary),
957
+ ];
958
+ if (suspense !== undefined) {
959
+ lines.push("", `Everything unmatched went to ${suspense}. A line the matcher merely failed to`, "match is not a line the books are missing, so check the queue before trusting this.");
960
+ }
961
+ if (written !== undefined) {
962
+ lines.push("", `Wrote ${written} with ${summary.booked} ${summary.booked === 1 ? "entry" : "entries"} applied.`);
963
+ }
964
+ else if (summary.booked > 0) {
965
+ lines.push("", "Nothing was written. Pass --out to apply these.");
966
+ }
967
+ return {
968
+ stdout: lines.join("\n"),
969
+ stderr: code === 2
970
+ ? `${summary.unclassified} line${summary.unclassified === 1 ? "" : "s"} could not be classified.\n`
971
+ : "",
972
+ code,
973
+ };
974
+ }
975
+ /**
976
+ * The classification rules for a run: the built-in set, or a file if one was
977
+ * named. Shared by `post` and by the dashboard's implied entries, so the two
978
+ * cannot disagree about what a line means.
979
+ */
980
+ function rulesFor(environment, parsed) {
981
+ const path = stringFlag(parsed, "rules");
982
+ if (path === undefined)
983
+ return standardRules();
984
+ try {
985
+ return parseRules(environment.readFile(path));
986
+ }
987
+ catch (error) {
988
+ if (error instanceof PostingRuleError)
989
+ throw new ArgumentError(error.message);
990
+ throw error;
991
+ }
992
+ }
993
+ /**
994
+ * Read a decisions file, in the same format the dashboard writes.
995
+ *
996
+ * The format lives in `reconcile/decisions.ts` so that "what the review UI
997
+ * emits" and "what this command accepts" cannot drift apart. Its errors are
998
+ * re-thrown as usage errors because from the command line that is what they
999
+ * are: a file the caller handed over that could not be read.
1000
+ */
1001
+ function readDecisions(environment, path) {
1002
+ try {
1003
+ return decisionsFor(environment.readFile(path));
1004
+ }
1005
+ catch (error) {
1006
+ if (error instanceof DecisionDocumentError)
1007
+ throw new ArgumentError(error.message);
1008
+ throw error;
1009
+ }
1010
+ }
1011
+ /**
1012
+ * Read the rate table a command was pointed at.
1013
+ *
1014
+ * The format is sniffed rather than declared, because a rate file arrives from
1015
+ * two places — a download from a provider, and something this engine wrote —
1016
+ * and asking which is which at the command line adds a flag that would only
1017
+ * ever be wrong.
1018
+ */
1019
+ function loadRates(environment, parsed) {
1020
+ const path = requiredFlag(parsed, "rates");
1021
+ const options = {
1022
+ ...(stringFlag(parsed, "base") === undefined
1023
+ ? {}
1024
+ : { base: stringFlag(parsed, "base") }),
1025
+ ...(booleanFlag(parsed, "invert") ? { invert: true } : {}),
1026
+ source: path,
1027
+ };
1028
+ let table;
1029
+ try {
1030
+ table = ratesFromText(environment.readFile(path), options);
1031
+ }
1032
+ catch (error) {
1033
+ if (error instanceof RateDocumentError)
1034
+ throw new ArgumentError(error.message);
1035
+ throw error;
1036
+ }
1037
+ const stale = stringFlag(parsed, "stale");
1038
+ if (stale === undefined)
1039
+ return table;
1040
+ const days = Number(stale);
1041
+ if (!Number.isInteger(days) || days < 0) {
1042
+ throw new ArgumentError(`--stale wants a whole number of days, got "${stale}"`);
1043
+ }
1044
+ return table.withOptions({ maxStaleDays: days });
1045
+ }
1046
+ function splitPair(text) {
1047
+ const parts = text.split("/");
1048
+ if (parts.length !== 2)
1049
+ throw new ArgumentError("--pair wants BASE/QUOTE, e.g. EUR/GBP");
1050
+ const base = parts[0].trim().toUpperCase();
1051
+ const quote = parts[1].trim().toUpperCase();
1052
+ if (base === "" || quote === "")
1053
+ throw new ArgumentError("--pair wants BASE/QUOTE, e.g. EUR/GBP");
1054
+ return { base, quote };
1055
+ }
1056
+ /**
1057
+ * Look at a rate table: what it holds, what it says a pair is worth on a day,
1058
+ * and what an amount converts to at that price.
1059
+ *
1060
+ * Every answer carries its provenance. A rate that came through two legs and a
1061
+ * three-day-old quote is a different kind of answer from a rate published that
1062
+ * morning, and a revaluation posted from the first one should be able to say
1063
+ * so when someone asks where the number came from.
1064
+ */
1065
+ function ratesCommand(environment, argv) {
1066
+ const parsed = parseArgs(argv, COMMANDS["rates"].flags);
1067
+ const table = loadRates(environment, parsed);
1068
+ const json = booleanFlag(parsed, "json");
1069
+ const pairText = stringFlag(parsed, "pair");
1070
+ if (pairText === undefined) {
1071
+ const quotes = table.all();
1072
+ const first = quotes[0];
1073
+ const last = quotes[quotes.length - 1];
1074
+ if (json) {
1075
+ return {
1076
+ stdout: JSON.stringify({
1077
+ quotes: table.size,
1078
+ pairs: table.pairs(),
1079
+ currencies: table.currencies(),
1080
+ from: first?.date ?? null,
1081
+ to: last?.date ?? null,
1082
+ maxStaleDays: table.maxStaleDays,
1083
+ maxLegs: table.maxLegs,
1084
+ }, null, 2),
1085
+ stderr: "",
1086
+ code: 0,
1087
+ };
1088
+ }
1089
+ const lines = [
1090
+ `${table.size} ${table.size === 1 ? "quote" : "quotes"} over ${table.pairs().length} ` +
1091
+ `${table.pairs().length === 1 ? "pair" : "pairs"}` +
1092
+ (first === undefined ? "" : `, ${first.date} to ${last.date}`),
1093
+ "",
1094
+ ...table.pairs().map((pair) => {
1095
+ const [base, quote] = pair.split("/");
1096
+ const list = table.quotesFor(base, quote);
1097
+ const oldest = list[0];
1098
+ const newest = list[list.length - 1];
1099
+ const noun = list.length === 1 ? "quote " : "quotes";
1100
+ return (` ${pair.padEnd(9)}${String(list.length).padStart(4)} ${noun} ` +
1101
+ `${oldest.date} to ${newest.date} latest ${newest.rate.toDecimalString(6)}`);
1102
+ }),
1103
+ "",
1104
+ `Currencies: ${table.currencies().join(", ")}`,
1105
+ `A quote may be up to ${table.maxStaleDays} days behind the date asked for, ` +
1106
+ `and a price may cross up to ${table.maxLegs} pairs.`,
1107
+ ];
1108
+ return { stdout: lines.join("\n"), stderr: "", code: 0 };
1109
+ }
1110
+ const { base, quote } = splitPair(pairText);
1111
+ const averageText = stringFlag(parsed, "average");
1112
+ if (averageText !== undefined) {
1113
+ const parts = averageText.split(":");
1114
+ if (parts.length !== 2)
1115
+ throw new ArgumentError("--average wants from:to");
1116
+ const methodText = stringFlag(parsed, "method") ?? "daily";
1117
+ if (methodText !== "daily" && methodText !== "quoted") {
1118
+ throw new ArgumentError(`--method wants daily or quoted, got "${methodText}"`);
1119
+ }
1120
+ const found = averageRate(table, base, quote, parts[0], parts[1], methodText);
1121
+ if (json) {
1122
+ return {
1123
+ stdout: JSON.stringify({
1124
+ pair: found.rate.pair,
1125
+ rate: found.rate.toDecimalString(10),
1126
+ method: found.method,
1127
+ from: found.range.from,
1128
+ to: found.range.to,
1129
+ observations: found.observations,
1130
+ lowest: found.lowest.toDecimalString(10),
1131
+ highest: found.highest.toDecimalString(10),
1132
+ }, null, 2),
1133
+ stderr: "",
1134
+ code: 0,
1135
+ };
1136
+ }
1137
+ return {
1138
+ stdout: [
1139
+ `${found.rate.pair} average ${found.range.from} to ${found.range.to}`,
1140
+ ` ${found.rate.toDecimalString(6)} (${found.method}, ${found.observations} ` +
1141
+ `${found.observations === 1 ? "observation" : "observations"})`,
1142
+ ` low ${found.lowest.toDecimalString(6)} high ${found.highest.toDecimalString(6)}`,
1143
+ ].join("\n"),
1144
+ stderr: "",
1145
+ code: 0,
1146
+ };
1147
+ }
1148
+ const onText = stringFlag(parsed, "on");
1149
+ const on = onText === undefined ? environment.today() : date(onText);
1150
+ const found = table.lookup(base, quote, on);
1151
+ const amountText = stringFlag(parsed, "amount");
1152
+ const converted = amountText === undefined
1153
+ ? undefined
1154
+ : found.rate.convert(Money.parse(amountText, lookupCurrency(base)));
1155
+ if (json) {
1156
+ return {
1157
+ stdout: JSON.stringify({
1158
+ pair: found.rate.pair,
1159
+ on,
1160
+ rate: found.rate.toDecimalString(10),
1161
+ direct: found.direct,
1162
+ via: found.via,
1163
+ quoteDates: found.quoteDates,
1164
+ staleDays: found.staleDays,
1165
+ sources: found.sources,
1166
+ ...(converted === undefined
1167
+ ? {}
1168
+ : {
1169
+ amount: Money.parse(amountText, lookupCurrency(base)).toDecimalString(),
1170
+ converted: converted.toDecimalString(),
1171
+ currency: converted.currency.code,
1172
+ }),
1173
+ }, null, 2),
1174
+ stderr: "",
1175
+ code: 0,
1176
+ };
1177
+ }
1178
+ const provenance = found.direct
1179
+ ? `direct quote from ${found.quoteDates[0]}`
1180
+ : `via ${found.via.join(" -> ")}, quotes ${found.quoteDates.join(", ")}`;
1181
+ const lines = [
1182
+ `${found.rate.toString()} on ${on}`,
1183
+ ` ${provenance}`,
1184
+ found.staleDays === 0
1185
+ ? " published that day"
1186
+ : ` ${found.staleDays} ${found.staleDays === 1 ? "day" : "days"} behind the date asked for`,
1187
+ ` source: ${found.sources.join(", ")}`,
1188
+ ];
1189
+ if (converted !== undefined) {
1190
+ const original = Money.parse(amountText, lookupCurrency(base));
1191
+ lines.push("", ` ${original.toString()} -> ${converted.toString()}`);
1192
+ }
1193
+ return { stdout: lines.join("\n"), stderr: "", code: 0 };
1194
+ }
1195
+ /**
1196
+ * Retranslate what the books hold in another currency.
1197
+ *
1198
+ * The command computes and prints by default and only writes when told to,
1199
+ * because a revaluation is a judgement about a date rather than a fact about
1200
+ * one: the rate you use at the close is a choice, and the person making it
1201
+ * should see the numbers before the ledger grows an entry.
1202
+ */
1203
+ function revalueCommand(environment, argv) {
1204
+ const parsed = parseArgs(argv, COMMANDS["revalue"].flags);
1205
+ const ledger = loadLedger(environment, requiredFlag(parsed, "ledger"));
1206
+ const asAtText = stringFlag(parsed, "as-at");
1207
+ const asAt = asAtText === undefined ? environment.today() : date(asAtText);
1208
+ const excludeText = stringFlag(parsed, "exclude");
1209
+ const exclude = excludeText === undefined
1210
+ ? undefined
1211
+ : excludeText
1212
+ .split(",")
1213
+ .map((part) => part.trim())
1214
+ .filter((part) => part !== "");
1215
+ const functional = ledger.chart?.defaultCurrency.code ?? "GBP";
1216
+ if (booleanFlag(parsed, "show")) {
1217
+ const list = exposures(ledger, {
1218
+ asAt,
1219
+ ...(exclude === undefined ? {} : { exclude }),
1220
+ });
1221
+ if (booleanFlag(parsed, "json")) {
1222
+ return {
1223
+ stdout: JSON.stringify({
1224
+ asAt,
1225
+ functional,
1226
+ exposures: list.map((item) => ({
1227
+ account: item.account,
1228
+ name: item.name,
1229
+ currency: item.currency.code,
1230
+ foreignBalance: item.foreignBalance.toDecimalString(),
1231
+ carryingAmount: item.carryingAmount.toDecimalString(),
1232
+ impliedRate: item.impliedRate?.toDecimalString(10) ?? null,
1233
+ lastMovement: item.lastMovement,
1234
+ })),
1235
+ }, null, 2),
1236
+ stderr: "",
1237
+ code: 0,
1238
+ };
1239
+ }
1240
+ return { stdout: renderExposures(list, functional), stderr: "", code: 0 };
1241
+ }
1242
+ const rates = loadRates(environment, parsed);
1243
+ const options = {
1244
+ asAt,
1245
+ rates,
1246
+ ...(exclude === undefined ? {} : { exclude }),
1247
+ ...(stringFlag(parsed, "gain") === undefined
1248
+ ? {}
1249
+ : { gainAccount: stringFlag(parsed, "gain") }),
1250
+ ...(stringFlag(parsed, "loss") === undefined
1251
+ ? {}
1252
+ : { lossAccount: stringFlag(parsed, "loss") }),
1253
+ };
1254
+ let result;
1255
+ try {
1256
+ result = revalue(ledger, options);
1257
+ }
1258
+ catch (error) {
1259
+ if (error instanceof RevaluationError)
1260
+ throw new ArgumentError(error.message);
1261
+ throw error;
1262
+ }
1263
+ const out = stringFlag(parsed, "out");
1264
+ let written;
1265
+ if (out !== undefined) {
1266
+ if (environment.writeFile === undefined) {
1267
+ return { stdout: "", stderr: "This environment cannot write files.\n", code: 1 };
1268
+ }
1269
+ environment.writeFile(out, ledgerToJson(result.entry === null ? ledger : ledger.post(result.entry)));
1270
+ written = out;
1271
+ }
1272
+ if (booleanFlag(parsed, "json")) {
1273
+ return {
1274
+ stdout: JSON.stringify({
1275
+ asAt: result.asAt,
1276
+ functional: result.functional.code,
1277
+ lines: result.lines.map((line) => ({
1278
+ account: line.account,
1279
+ name: line.name,
1280
+ currency: line.currency.code,
1281
+ foreignBalance: line.foreignBalance.toDecimalString(),
1282
+ carryingAmount: line.carryingAmount.toDecimalString(),
1283
+ closingAmount: line.closingAmount.toDecimalString(),
1284
+ adjustment: line.adjustment.toDecimalString(),
1285
+ closingRate: line.closingRate.toDecimalString(10),
1286
+ via: line.lookup.via,
1287
+ staleDays: line.lookup.staleDays,
1288
+ })),
1289
+ net: result.net.toDecimalString(),
1290
+ gain: result.gain.toDecimalString(),
1291
+ loss: result.loss.toDecimalString(),
1292
+ entry: result.entry?.id ?? null,
1293
+ ...(written === undefined ? {} : { written }),
1294
+ }, null, 2),
1295
+ stderr: "",
1296
+ code: 0,
1297
+ };
1298
+ }
1299
+ const lines = [renderRevaluation(result)];
1300
+ if (written !== undefined) {
1301
+ lines.push(result.entry === null
1302
+ ? `Wrote ${written} unchanged.`
1303
+ : `Wrote ${written} with ${result.entry.id} applied.`);
1304
+ }
1305
+ else if (result.entry !== null) {
1306
+ lines.push("Nothing was written. Pass --out to apply it.");
1307
+ }
1308
+ return { stdout: lines.join("\n"), stderr: "", code: 0 };
1309
+ }
1310
+ /**
1311
+ * Consolidate a group.
1312
+ *
1313
+ * The group document says who holds whom, where each company's books are, what
1314
+ * faces what across the group, and what was paid for each subsidiary. This
1315
+ * reads all of it, does the work, and prints as much of the working as asked
1316
+ * for — because the number a reader will want to argue with is never the
1317
+ * consolidated total, it is one of the figures that went into it.
1318
+ *
1319
+ * Exit code 2 when the consolidated ledger does not balance or its accounting
1320
+ * equation leaves a residual: a consolidation that came out wrong ran to
1321
+ * completion, and a job that treated that as a pass would be worse than none.
1322
+ */
1323
+ /** A movement schedule as JSON: the lines as given, and what does not tie. */
1324
+ function scheduleToJson(schedule) {
1325
+ return {
1326
+ what: schedule.what,
1327
+ opening: schedule.opening.toDecimalString(),
1328
+ lines: schedule.lines.map((line) => ({
1329
+ label: line.label,
1330
+ amount: line.amount.toDecimalString(),
1331
+ })),
1332
+ rolledForward: schedule.rolledForward.toDecimalString(),
1333
+ unexplained: schedule.unexplained.toDecimalString(),
1334
+ closing: schedule.closing.toDecimalString(),
1335
+ reconciles: schedule.reconciles,
1336
+ };
1337
+ }
1338
+ function consolidateCommand(environment, argv) {
1339
+ const parsed = parseArgs(argv, COMMANDS["consolidate"].flags);
1340
+ const groupPath = requiredFlag(parsed, "group");
1341
+ let group;
1342
+ try {
1343
+ group = groupFromJson(environment.readFile(groupPath));
1344
+ }
1345
+ catch (error) {
1346
+ if (error instanceof GroupDocumentError || error instanceof GroupError) {
1347
+ throw new ArgumentError(error.message);
1348
+ }
1349
+ throw error;
1350
+ }
1351
+ const missing = group.structure
1352
+ .consolidated()
1353
+ .filter((entity) => !group.ledgers.has(entity.code));
1354
+ if (missing.length > 0) {
1355
+ throw new ArgumentError(`${missing.map((e) => e.code).join(", ")} ${missing.length === 1 ? "has" : "have"} no ` +
1356
+ `"ledger" in ${groupPath}, so there are no books to consolidate.`);
1357
+ }
1358
+ const ledgers = new Map();
1359
+ for (const entity of group.structure.consolidated()) {
1360
+ ledgers.set(entity.code, loadLedger(environment, group.ledgers.get(entity.code)));
1361
+ }
1362
+ const rates = stringFlag(parsed, "rates") === undefined
1363
+ ? RateTable.empty()
1364
+ : loadRates(environment, parsed);
1365
+ if (!group.structure.isSingleCurrency && stringFlag(parsed, "rates") === undefined) {
1366
+ throw new ArgumentError(`The group keeps books in ${group.structure.currencies().join(", ")}, so it needs --rates.`);
1367
+ }
1368
+ const asAtText = stringFlag(parsed, "as-at") ?? group.asAt;
1369
+ if (asAtText === null || asAtText === undefined) {
1370
+ throw new ArgumentError("A consolidation needs a reporting date: pass --as-at or set asAt.");
1371
+ }
1372
+ const asAt = date(asAtText);
1373
+ const fromText = stringFlag(parsed, "from");
1374
+ const toText = stringFlag(parsed, "to");
1375
+ const period = fromText !== undefined || toText !== undefined
1376
+ ? dateRange(fromText ?? String(asAt), toText ?? String(asAt))
1377
+ : (group.period ?? undefined);
1378
+ const methodText = stringFlag(parsed, "average-method") ?? group.averageMethod ?? undefined;
1379
+ if (methodText !== undefined && methodText !== "daily" && methodText !== "quoted") {
1380
+ throw new ArgumentError(`--average-method wants daily or quoted, got "${methodText}"`);
1381
+ }
1382
+ const consolidationOptions = (on, over) => ({
1383
+ rates,
1384
+ asAt: String(on),
1385
+ ...(over === undefined ? {} : { period: over }),
1386
+ ...(methodText === undefined ? {} : { averageMethod: methodText }),
1387
+ ...(group.equityBasis === null ? {} : { equityBasis: group.equityBasis }),
1388
+ intercompany: group.intercompany,
1389
+ intragroupStock: group.intragroupStock,
1390
+ acquisitions: group.acquisitions,
1391
+ disposals: group.disposals,
1392
+ });
1393
+ // The comparative period defaults to one of the same length ending on the
1394
+ // comparative date, which for the ordinary annual case is exactly the year
1395
+ // before and needs nobody to type it out. Where the two periods are not the
1396
+ // same length, --comparative-from says so.
1397
+ const comparativeText = stringFlag(parsed, "comparative");
1398
+ let priorPeriod;
1399
+ let priorAsAt;
1400
+ if (comparativeText !== undefined) {
1401
+ priorAsAt = date(comparativeText);
1402
+ if (compareDates(priorAsAt, asAt) !== -1) {
1403
+ throw new ArgumentError(`--comparative ${comparativeText} is not before the reporting date ${asAt}.`);
1404
+ }
1405
+ const fromComparative = stringFlag(parsed, "comparative-from");
1406
+ if (fromComparative !== undefined) {
1407
+ priorPeriod = dateRange(fromComparative, String(priorAsAt));
1408
+ }
1409
+ else if (period !== undefined) {
1410
+ priorPeriod = dateRange(String(addDays(priorAsAt, -daysBetween(period.from, period.to))), String(priorAsAt));
1411
+ }
1412
+ }
1413
+ let result;
1414
+ let compared;
1415
+ try {
1416
+ result = consolidate(group.structure, ledgers, consolidationOptions(asAt, period));
1417
+ if (priorAsAt !== undefined) {
1418
+ const priorResult = consolidate(group.structure, ledgers, consolidationOptions(priorAsAt, priorPeriod));
1419
+ compared = compareConsolidations(priorResult, result, { rates });
1420
+ }
1421
+ }
1422
+ catch (error) {
1423
+ if (error instanceof GroupError)
1424
+ throw new ArgumentError(error.message);
1425
+ throw error;
1426
+ }
1427
+ const code = result.balanced && result.residual.isZero ? 0 : 2;
1428
+ let written;
1429
+ const outPath = stringFlag(parsed, "out");
1430
+ if (outPath !== undefined) {
1431
+ if (environment.writeFile === undefined) {
1432
+ throw new ArgumentError("This environment cannot write files, so --out has nowhere to go.");
1433
+ }
1434
+ environment.writeFile(outPath, ledgerToJson(result.ledger));
1435
+ written = outPath;
1436
+ }
1437
+ if (booleanFlag(parsed, "json")) {
1438
+ return {
1439
+ stdout: JSON.stringify({
1440
+ group: result.group.name,
1441
+ presentation: result.presentation.code,
1442
+ asAt: result.asAt,
1443
+ entities: result.group.consolidated().map((entity) => ({
1444
+ code: entity.code,
1445
+ name: entity.name,
1446
+ currency: entity.currency.code,
1447
+ effectiveInterest: entity.effective.toJSON(),
1448
+ nonControllingInterest: entity.nonControlling.toJSON(),
1449
+ })),
1450
+ notConsolidated: result.aggregation.excluded,
1451
+ controlWindows: result.aggregation.entities.map((contribution) => ({
1452
+ entity: contribution.entity,
1453
+ from: contribution.control.window?.from ?? null,
1454
+ to: contribution.control.window?.to ?? null,
1455
+ whole: contribution.control.whole,
1456
+ acquiredDuring: contribution.control.acquiredDuring,
1457
+ disposedDuring: contribution.control.disposedDuring,
1458
+ readAt: contribution.readAt,
1459
+ applied: contribution.windowApplied,
1460
+ preAcquisitionResult: contribution.preAcquisitionResult.toDecimalString(),
1461
+ reason: contribution.control.reason,
1462
+ })),
1463
+ workings: result.workings.map((working) => ({
1464
+ entity: working.entity,
1465
+ acquired: working.acquisition.acquired,
1466
+ measurement: working.acquisition.measurement,
1467
+ consideration: working.acquisition.consideration.toDecimalString(),
1468
+ netAssetsAcquired: working.acquisition.netAssetsAcquired.toDecimalString(),
1469
+ goodwill: working.acquisition.goodwill.toDecimalString(),
1470
+ bargainGain: working.acquisition.bargainGain.toDecimalString(),
1471
+ netAssetsNow: working.netAssetsNow.toDecimalString(),
1472
+ profitForPeriod: working.profitForPeriod.toDecimalString(),
1473
+ nciProfitShare: working.nciProfitShare.toDecimalString(),
1474
+ nciClosing: working.nciClosing.toDecimalString(),
1475
+ postAcquisitionReserves: working.postAcquisitionReserves.toDecimalString(),
1476
+ })),
1477
+ disposals: result.disposals.map((working) => ({
1478
+ entity: working.entity,
1479
+ disposed: working.disposal.disposed,
1480
+ proceeds: working.disposal.proceeds.toDecimalString(),
1481
+ netAssetsRemoved: working.netAssetsRemoved.toDecimalString(),
1482
+ nciRemoved: working.nciRemoved.toDecimalString(),
1483
+ goodwillRemoved: working.goodwillRemoved.toDecimalString(),
1484
+ carryingAmount: working.disposal.carryingAmount.toDecimalString(),
1485
+ result: working.result.toDecimalString(),
1486
+ resultToDisposal: working.resultToDisposal.toDecimalString(),
1487
+ holderResult: working.disposal.holderResult.toDecimalString(),
1488
+ })),
1489
+ intercompany: {
1490
+ pairs: result.eliminations.pairs.length,
1491
+ eliminated: result.eliminations.totalEliminated.toDecimalString(),
1492
+ difference: result.eliminations.totalDifference.toDecimalString(),
1493
+ unpaired: result.eliminations.unmatched.map((item) => ({
1494
+ entity: item.side.entity,
1495
+ account: item.side.account,
1496
+ reason: item.reason,
1497
+ })),
1498
+ },
1499
+ unrealisedProfit: {
1500
+ consignments: result.unrealisedProfit.items.length,
1501
+ provided: result.unrealisedProfit.totalProvision.toDecimalString(),
1502
+ groupShare: result.unrealisedProfit.totalGroupShare.toDecimalString(),
1503
+ nciShare: result.unrealisedProfit.totalNciShare.toDecimalString(),
1504
+ notProvided: result.unrealisedProfit.unprovided.map((item) => ({
1505
+ seller: item.declaration.seller,
1506
+ holder: item.declaration.holder,
1507
+ account: item.declaration.account,
1508
+ reason: item.reason,
1509
+ })),
1510
+ },
1511
+ goodwill: result.goodwill.toDecimalString(),
1512
+ nonControllingInterest: result.nonControllingInterest.toDecimalString(),
1513
+ translationReserve: result.translationReserve.toDecimalString(),
1514
+ investmentResidual: result.investmentResidual.toDecimalString(),
1515
+ disposalResult: result.disposalResult.toDecimalString(),
1516
+ disposalResidual: result.disposalResidual.toDecimalString(),
1517
+ residual: result.residual.toDecimalString(),
1518
+ balanced: result.balanced,
1519
+ ...(compared === undefined
1520
+ ? {}
1521
+ : {
1522
+ comparative: {
1523
+ asAt: compared.comparativeAsAt,
1524
+ entered: compared.entered,
1525
+ left: compared.left,
1526
+ sound: compared.sound,
1527
+ rows: compared.rows.map((row) => ({
1528
+ account: row.account,
1529
+ name: row.name,
1530
+ current: row.current.toDecimalString(),
1531
+ prior: row.prior.toDecimalString(),
1532
+ movement: row.movement.toDecimalString(),
1533
+ })),
1534
+ netAssets: compared.netAssets.map((movement) => ({
1535
+ entity: movement.entity,
1536
+ opening: movement.opening.toDecimalString(),
1537
+ translationEffect: movement.translationEffect.toDecimalString(),
1538
+ result: movement.result.toDecimalString(),
1539
+ other: movement.other.toDecimalString(),
1540
+ closing: movement.closing.toDecimalString(),
1541
+ comparable: movement.comparable,
1542
+ })),
1543
+ nonControllingInterest: scheduleToJson(compared.nci),
1544
+ translationReserve: scheduleToJson(compared.translationReserve),
1545
+ goodwill: scheduleToJson(compared.goodwill),
1546
+ },
1547
+ }),
1548
+ ...(written === undefined ? {} : { written }),
1549
+ }, null, 2),
1550
+ stderr: "",
1551
+ code,
1552
+ };
1553
+ }
1554
+ const show = stringFlag(parsed, "show") ?? "workings";
1555
+ const known = ["workings", "combined", "eliminations", "windows", "movement", "statements", "all"];
1556
+ if (!known.includes(show)) {
1557
+ throw new ArgumentError(`--show wants one of ${known.join(", ")}, got "${show}"`);
1558
+ }
1559
+ const wants = (name) => show === "all" || show === name;
1560
+ const sections = [];
1561
+ sections.push(result.group.render());
1562
+ sections.push("");
1563
+ // Printed whenever any entity was not the group's for the whole period,
1564
+ // whatever --show says: a consolidation covering nine months of one company
1565
+ // and twelve of another should never be read without knowing that.
1566
+ const partial = result.aggregation.entities.some((c) => c.control.acquiredDuring || c.control.disposedDuring);
1567
+ if (wants("windows") || partial) {
1568
+ sections.push(renderControlWindows(result.aggregation.entities.map((c) => c.control), (entity) => result.group.get(entity).name));
1569
+ sections.push("");
1570
+ }
1571
+ if (wants("combined")) {
1572
+ sections.push(renderAggregation(result.aggregation));
1573
+ sections.push("");
1574
+ }
1575
+ if (wants("eliminations") && result.eliminations.pairs.length + result.eliminations.unmatched.length > 0) {
1576
+ sections.push(renderEliminations(result.eliminations));
1577
+ sections.push("");
1578
+ }
1579
+ if (wants("workings")) {
1580
+ for (const working of result.workings) {
1581
+ sections.push(renderAcquisition(working.acquisition));
1582
+ sections.push("");
1583
+ }
1584
+ for (const working of result.disposals) {
1585
+ sections.push(renderDisposal(working.disposal));
1586
+ sections.push("");
1587
+ }
1588
+ }
1589
+ sections.push(renderConsolidation(result));
1590
+ if (compared !== undefined) {
1591
+ sections.push("");
1592
+ sections.push(renderComparative(compared));
1593
+ }
1594
+ if (compared !== undefined && (wants("movement") || show === "workings")) {
1595
+ sections.push("");
1596
+ sections.push(renderNetAssetsMovements(compared.netAssets, (entity) => compared.group.get(entity).name));
1597
+ sections.push("");
1598
+ sections.push(renderMovementSchedule(compared.nci));
1599
+ sections.push("");
1600
+ sections.push(renderMovementSchedule(compared.translationReserve));
1601
+ sections.push("");
1602
+ sections.push(renderMovementSchedule(compared.goodwill));
1603
+ }
1604
+ if (wants("statements")) {
1605
+ sections.push("");
1606
+ sections.push(renderTrialBalance(result.trialBalance));
1607
+ sections.push("");
1608
+ sections.push(renderIncomeStatement(incomeStatement(result.ledger, result.period, { currency: result.presentation })));
1609
+ sections.push("");
1610
+ sections.push(renderBalanceSheet(balanceSheet(result.ledger, result.asAt, { currency: result.presentation })));
1611
+ }
1612
+ if (written !== undefined) {
1613
+ sections.push("");
1614
+ sections.push(`Wrote the consolidated ledger to ${written}.`);
1615
+ }
1616
+ return {
1617
+ stdout: sections.join("\n"),
1618
+ stderr: code === 0 ? "" : "The consolidated ledger does not agree with itself.\n",
1619
+ code,
1620
+ };
1621
+ }
1622
+ const HANDLERS = {
1623
+ report: reportCommand,
1624
+ ageing: ageingCommand,
1625
+ reconcile: reconcileCommand,
1626
+ import: importCommand,
1627
+ accounts: accountsCommand,
1628
+ generate: generateCommand,
1629
+ bench: benchCommand,
1630
+ learn: learnCommand,
1631
+ rates: ratesCommand,
1632
+ revalue: revalueCommand,
1633
+ consolidate: consolidateCommand,
1634
+ dashboard: dashboardCommand,
1635
+ post: postCommand,
1636
+ };
1637
+ /** Run one invocation. Never throws; every failure becomes an exit code. */
1638
+ export function run(argv, environment) {
1639
+ const [command, ...rest] = argv;
1640
+ if (command === undefined || command === "--help" || command === "-h" || command === "help") {
1641
+ return { stdout: usage(), stderr: "", code: command === undefined ? 1 : 0 };
1642
+ }
1643
+ if (command === "--version" || command === "-v") {
1644
+ return { stdout: "tallyd 0.1.0", stderr: "", code: 0 };
1645
+ }
1646
+ const handler = HANDLERS[command];
1647
+ if (handler === undefined) {
1648
+ return { stdout: "", stderr: `Unknown command: ${command}\n\n${usage()}\n`, code: 1 };
1649
+ }
1650
+ if (rest.includes("--help") || rest.includes("-h")) {
1651
+ return { stdout: commandHelp(command), stderr: "", code: 0 };
1652
+ }
1653
+ try {
1654
+ return handler(environment, rest);
1655
+ }
1656
+ catch (error) {
1657
+ const message = error instanceof Error ? error.message : String(error);
1658
+ return { stdout: "", stderr: `${message}\n`, code: 1 };
1659
+ }
1660
+ }