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,264 @@
1
+ /**
2
+ * Journal entries and the balancing invariant.
3
+ *
4
+ * The central rule of this file: an unbalanced `JournalEntry` cannot be
5
+ * constructed. The check lives in the factory, the constructor is private, and
6
+ * every field is frozen — so no code downstream of this module has to remember
7
+ * to validate anything, and no code can un-balance an entry after the fact.
8
+ *
9
+ * Sign convention: a posting's amount is positive for a debit and negative for
10
+ * a credit. One signed number is easier to sum than a (side, magnitude) pair,
11
+ * and "the postings sum to zero" is then literally the invariant.
12
+ */
13
+ import { Money, sumMoney } from "../money/money.js";
14
+ import { date as parseDate } from "./date.js";
15
+ export class UnbalancedEntryError extends Error {
16
+ residuals;
17
+ narration;
18
+ constructor(residuals, narration) {
19
+ const detail = residuals.map((r) => r.toString()).join(", ");
20
+ super(`Entry "${narration}" does not balance; residual: ${detail}`);
21
+ this.residuals = residuals;
22
+ this.narration = narration;
23
+ this.name = "UnbalancedEntryError";
24
+ }
25
+ }
26
+ export class InvalidEntryError extends Error {
27
+ constructor(message) {
28
+ super(message);
29
+ this.name = "InvalidEntryError";
30
+ }
31
+ }
32
+ export class JournalEntry {
33
+ id;
34
+ date;
35
+ narration;
36
+ postings;
37
+ reference;
38
+ tags;
39
+ reverses;
40
+ constructor(fields) {
41
+ this.id = fields.id;
42
+ this.date = fields.date;
43
+ this.narration = fields.narration;
44
+ this.postings = Object.freeze(fields.postings);
45
+ this.reference = fields.reference;
46
+ this.tags = Object.freeze(fields.tags);
47
+ this.reverses = fields.reverses;
48
+ Object.freeze(this);
49
+ }
50
+ /**
51
+ * Build a validated entry. Throws unless:
52
+ * - there are at least two postings,
53
+ * - no posting is for zero,
54
+ * - the postings sum to zero *within each currency*,
55
+ * - and, when a chart is supplied, every account exists and is postable.
56
+ */
57
+ static create(input, chart) {
58
+ if (input.id.trim() === "")
59
+ throw new InvalidEntryError("Entry id must not be blank");
60
+ if (input.narration.trim() === "") {
61
+ throw new InvalidEntryError(`Entry ${input.id} must have a narration`);
62
+ }
63
+ if (input.postings.length < 2) {
64
+ throw new InvalidEntryError(`Entry ${input.id} has ${input.postings.length} posting(s); at least 2 are required`);
65
+ }
66
+ const when = parseDate(input.date);
67
+ const postings = input.postings.map((p, index) => {
68
+ if (p.amount.isZero) {
69
+ throw new InvalidEntryError(`Entry ${input.id} posting ${index} to ${p.account} is for zero`);
70
+ }
71
+ if (chart !== undefined)
72
+ chart.assertPostable(p.account);
73
+ const foreign = p.foreign ?? null;
74
+ if (foreign !== null) {
75
+ if (foreign.isZero) {
76
+ throw new InvalidEntryError(`Entry ${input.id} posting ${index} to ${p.account} has a foreign amount of zero`);
77
+ }
78
+ if (foreign.currency.code === p.amount.currency.code) {
79
+ throw new InvalidEntryError(`Entry ${input.id} posting ${index} to ${p.account} gives a ${foreign.currency.code} ` +
80
+ `amount as the foreign side of a ${p.amount.currency.code} posting`);
81
+ }
82
+ if (foreign.sign !== p.amount.sign) {
83
+ throw new InvalidEntryError(`Entry ${input.id} posting ${index} to ${p.account}: ${foreign.toString()} and ` +
84
+ `${p.amount.toString()} are on opposite sides`);
85
+ }
86
+ // An account's declared currency is what its foreign balance is in.
87
+ // Letting a EUR posting land on a USD account would produce a balance
88
+ // that no single closing rate can retranslate.
89
+ const declared = chart?.find(p.account)?.currency;
90
+ if (declared !== undefined && declared.code !== foreign.currency.code) {
91
+ throw new InvalidEntryError(`Entry ${input.id} posting ${index}: account ${p.account} is denominated in ` +
92
+ `${declared.code}, not ${foreign.currency.code}`);
93
+ }
94
+ }
95
+ return Object.freeze({
96
+ account: p.account,
97
+ amount: p.amount,
98
+ memo: p.memo ?? "",
99
+ index,
100
+ reference: p.reference ?? null,
101
+ foreign,
102
+ });
103
+ });
104
+ const residuals = residualsByCurrency(postings.map((p) => p.amount));
105
+ if (residuals.length > 0)
106
+ throw new UnbalancedEntryError(residuals, input.narration);
107
+ return new JournalEntry({
108
+ id: input.id,
109
+ date: when,
110
+ narration: input.narration,
111
+ postings,
112
+ reference: input.reference ?? null,
113
+ tags: Object.freeze([...(input.tags ?? [])]),
114
+ reverses: input.reverses ?? null,
115
+ });
116
+ }
117
+ /** Convenience for the overwhelmingly common two-line entry. */
118
+ static simple(input, chart) {
119
+ if (input.amount.isNegative) {
120
+ throw new InvalidEntryError(`Entry ${input.id}: use a positive amount and name the debit and credit sides`);
121
+ }
122
+ const create = {
123
+ id: input.id,
124
+ date: input.date,
125
+ narration: input.narration,
126
+ postings: [
127
+ { account: input.debit, amount: input.amount },
128
+ { account: input.credit, amount: input.amount.negated() },
129
+ ],
130
+ };
131
+ if (input.reference !== undefined)
132
+ create.reference = input.reference;
133
+ if (input.tags !== undefined)
134
+ create.tags = input.tags;
135
+ return JournalEntry.create(create, chart);
136
+ }
137
+ // ---------------------------------------------------------------- accessors
138
+ get debits() {
139
+ return this.postings.filter((p) => p.amount.isPositive);
140
+ }
141
+ get credits() {
142
+ return this.postings.filter((p) => p.amount.isNegative);
143
+ }
144
+ get accounts() {
145
+ return [...new Set(this.postings.map((p) => p.account))];
146
+ }
147
+ get currencies() {
148
+ const seen = new Map();
149
+ for (const p of this.postings)
150
+ seen.set(p.amount.currency.code, p.amount.currency);
151
+ return [...seen.values()];
152
+ }
153
+ get isMultiCurrency() {
154
+ return this.currencies.length > 1;
155
+ }
156
+ /** Postings that record something that happened in another currency. */
157
+ get foreignPostings() {
158
+ return this.postings.filter((p) => p.foreign !== null);
159
+ }
160
+ get hasForeignAmounts() {
161
+ return this.postings.some((p) => p.foreign !== null);
162
+ }
163
+ /** The transaction currencies this entry touches, deduplicated. */
164
+ get foreignCurrencies() {
165
+ const seen = new Map();
166
+ for (const p of this.postings) {
167
+ if (p.foreign !== null)
168
+ seen.set(p.foreign.currency.code, p.foreign.currency);
169
+ }
170
+ return [...seen.values()];
171
+ }
172
+ /** Net foreign-currency effect on one account within this entry. */
173
+ foreignAmountFor(account, currency) {
174
+ const code = typeof currency === "string" ? currency.toUpperCase() : currency.code;
175
+ return sumMoney(this.postings
176
+ .filter((p) => p.account === account && p.foreign?.currency.code === code)
177
+ .map((p) => p.foreign), code);
178
+ }
179
+ /** Total of the debit side in the given currency — the entry's "size". */
180
+ total(currency) {
181
+ const code = currency === undefined
182
+ ? this.currencies[0].code
183
+ : typeof currency === "string"
184
+ ? currency.toUpperCase()
185
+ : currency.code;
186
+ const matching = this.debits.filter((p) => p.amount.currency.code === code);
187
+ return sumMoney(matching.map((p) => p.amount), code);
188
+ }
189
+ /** The open item a posting belongs to: its own, or the entry's. */
190
+ referenceFor(posting) {
191
+ return posting.reference ?? this.reference;
192
+ }
193
+ touches(account) {
194
+ return this.postings.some((p) => p.account === account);
195
+ }
196
+ /** Net effect on one account, summed across its postings in this entry. */
197
+ amountFor(account, currency) {
198
+ const relevant = this.postings.filter((p) => p.account === account);
199
+ const code = currency === undefined
200
+ ? (relevant[0]?.amount.currency ?? this.currencies[0]).code
201
+ : typeof currency === "string"
202
+ ? currency.toUpperCase()
203
+ : currency.code;
204
+ return sumMoney(relevant.filter((p) => p.amount.currency.code === code).map((p) => p.amount), code);
205
+ }
206
+ /** A mirror-image entry that undoes this one. */
207
+ reversal(input) {
208
+ return JournalEntry.create({
209
+ id: input.id,
210
+ date: input.date,
211
+ narration: input.narration ?? `Reversal of ${this.narration}`,
212
+ postings: this.postings.map((p) => ({
213
+ account: p.account,
214
+ amount: p.amount.negated(),
215
+ memo: p.memo,
216
+ ...(p.reference === null ? {} : { reference: p.reference }),
217
+ ...(p.foreign === null ? {} : { foreign: p.foreign.negated() }),
218
+ })),
219
+ ...(this.reference !== null ? { reference: this.reference } : {}),
220
+ tags: this.tags,
221
+ reverses: this.id,
222
+ });
223
+ }
224
+ toString() {
225
+ const lines = this.postings.map((p) => {
226
+ const base = ` ${p.account.padEnd(8)} ${p.amount.toDecimalString().padStart(12)}`;
227
+ return p.foreign === null ? base : `${base} (${p.foreign.toString()})`;
228
+ });
229
+ return [`${this.date} ${this.id} ${this.narration}`, ...lines].join("\n");
230
+ }
231
+ toJSON() {
232
+ return {
233
+ id: this.id,
234
+ date: this.date,
235
+ narration: this.narration,
236
+ reference: this.reference,
237
+ tags: this.tags,
238
+ reverses: this.reverses,
239
+ postings: this.postings.map((p) => ({
240
+ account: p.account,
241
+ amount: p.amount.toJSON(),
242
+ memo: p.memo,
243
+ reference: p.reference,
244
+ foreign: p.foreign === null ? null : p.foreign.toJSON(),
245
+ })),
246
+ };
247
+ }
248
+ }
249
+ /**
250
+ * Per-currency sums that are not zero. An empty result means the set balances.
251
+ * Exported because the ledger uses it to re-check invariants in bulk.
252
+ */
253
+ export function residualsByCurrency(amounts) {
254
+ const totals = new Map();
255
+ for (const amount of amounts) {
256
+ const code = amount.currency.code;
257
+ const running = totals.get(code);
258
+ totals.set(code, running === undefined ? amount : running.plus(amount));
259
+ }
260
+ return [...totals.values()].filter((m) => !m.isZero);
261
+ }
262
+ export function isBalanced(amounts) {
263
+ return residualsByCurrency(amounts).length === 0;
264
+ }
@@ -0,0 +1,6 @@
1
+ export { type CalendarDate, type DateRange, InvalidDateError, date, isValidDate, parts, daysInMonth, toEpochDay, fromEpochDay, daysBetween, addDays, compareDates, minDate, maxDate, dayOfWeek, isWeekend, startOfMonth, endOfMonth, dateRange, withinRange, } from "./date.js";
2
+ export { type Posting, type PostingInput, type JournalEntryInput, JournalEntry, UnbalancedEntryError, InvalidEntryError, residualsByCurrency, isBalanced, } from "./entry.js";
3
+ export { type AccountBalance, Ledger, DuplicateEntryError, LedgerIntegrityError, } from "./ledger.js";
4
+ export { type TrialBalance, type TrialBalanceRow, type TrialBalanceOptions, trialBalance, balancesByType, equationResidual, renderTrialBalance, } from "./trialBalance.js";
5
+ export { type LedgerDocument, type EntryDocument, type PostingDocument, type AccountDocument, LedgerDocumentError, entryToDocument, ledgerToDocument, ledgerToJson, ledgerFromDocument, ledgerFromJson, } from "./serialise.js";
6
+ export { type CloseOptions, CloseError, closingEntry, resultOver, withResultClosed, } from "./close.js";
@@ -0,0 +1,6 @@
1
+ export { InvalidDateError, date, isValidDate, parts, daysInMonth, toEpochDay, fromEpochDay, daysBetween, addDays, compareDates, minDate, maxDate, dayOfWeek, isWeekend, startOfMonth, endOfMonth, dateRange, withinRange, } from "./date.js";
2
+ export { JournalEntry, UnbalancedEntryError, InvalidEntryError, residualsByCurrency, isBalanced, } from "./entry.js";
3
+ export { Ledger, DuplicateEntryError, LedgerIntegrityError, } from "./ledger.js";
4
+ export { trialBalance, balancesByType, equationResidual, renderTrialBalance, } from "./trialBalance.js";
5
+ export { LedgerDocumentError, entryToDocument, ledgerToDocument, ledgerToJson, ledgerFromDocument, ledgerFromJson, } from "./serialise.js";
6
+ export { CloseError, closingEntry, resultOver, withResultClosed, } from "./close.js";
@@ -0,0 +1,101 @@
1
+ /**
2
+ * The ledger: an append-only sequence of journal entries with derived balances.
3
+ *
4
+ * `post` returns a *new* `Ledger` rather than mutating. That is not persistence
5
+ * theatre — reconciliation wants to try a set of proposed entries, look at the
6
+ * resulting balances, and throw the attempt away. Structural sharing keeps the
7
+ * entry list cheap; only the balance index is copied, and it is one small map.
8
+ */
9
+ import { Money } from "../money/money.js";
10
+ import type { Currency } from "../money/currency.js";
11
+ import type { ChartOfAccounts } from "../accounts/chart.js";
12
+ import { type CalendarDate, type DateRange } from "./date.js";
13
+ import { JournalEntry, type JournalEntryInput } from "./entry.js";
14
+ export declare class DuplicateEntryError extends Error {
15
+ readonly id: string;
16
+ constructor(id: string);
17
+ }
18
+ export declare class LedgerIntegrityError extends Error {
19
+ constructor(message: string);
20
+ }
21
+ export interface AccountBalance {
22
+ readonly account: string;
23
+ /** Signed: positive means a net debit, negative a net credit. */
24
+ readonly balance: Money;
25
+ /**
26
+ * The balance as an accountant would read it — positive when the account
27
+ * sits on its normal side. Income of 1,000 reads +1,000, not -1,000.
28
+ */
29
+ readonly natural: Money;
30
+ readonly debitTotal: Money;
31
+ readonly creditTotal: Money;
32
+ readonly postingCount: number;
33
+ }
34
+ export declare class Ledger {
35
+ private readonly entries;
36
+ private readonly index;
37
+ private readonly balances;
38
+ private readonly debitTotals;
39
+ private readonly creditTotals;
40
+ private readonly postingCounts;
41
+ readonly chart: ChartOfAccounts | undefined;
42
+ private constructor();
43
+ static empty(chart?: ChartOfAccounts): Ledger;
44
+ static from(entries: Iterable<JournalEntry>, chart?: ChartOfAccounts): Ledger;
45
+ /** Append an entry, returning a new ledger. Rejects duplicate ids. */
46
+ post(entry: JournalEntry): Ledger;
47
+ /** Build and post in one step, validating against the ledger's chart. */
48
+ record(input: JournalEntryInput): Ledger;
49
+ postMany(entries: Iterable<JournalEntry>): Ledger;
50
+ /** Append the reversal of an existing entry. */
51
+ reverse(entryId: string, options: {
52
+ id: string;
53
+ date: string;
54
+ narration?: string;
55
+ }): Ledger;
56
+ get size(): number;
57
+ get isEmpty(): boolean;
58
+ /** Entries in the order they were posted. */
59
+ all(): readonly JournalEntry[];
60
+ entry(id: string): JournalEntry | undefined;
61
+ has(id: string): boolean;
62
+ /** Entries sorted by date, ties broken by insertion order (stable). */
63
+ chronological(): readonly JournalEntry[];
64
+ filter(predicate: (entry: JournalEntry) => boolean): readonly JournalEntry[];
65
+ entriesFor(account: string): readonly JournalEntry[];
66
+ entriesIn(range: DateRange): readonly JournalEntry[];
67
+ entriesOn(day: CalendarDate): readonly JournalEntry[];
68
+ byReference(reference: string): readonly JournalEntry[];
69
+ /** Entries that have not been reversed by a later entry. */
70
+ live(): readonly JournalEntry[];
71
+ private currencyFor;
72
+ /** Signed balance of one account: positive is a net debit. */
73
+ balanceOf(account: string, currency?: Currency | string): Money;
74
+ /** Balance read on the account's normal side, using the chart's type. */
75
+ naturalBalanceOf(account: string, currency?: Currency | string): Money;
76
+ /** Balance of an account plus all its descendants. Requires a chart. */
77
+ rolledUpBalanceOf(account: string, currency?: Currency | string): Money;
78
+ /** Full detail for one account. */
79
+ accountBalance(account: string, currency?: Currency | string): AccountBalance;
80
+ /** Every account that has been posted to, sorted by code. */
81
+ activeAccounts(): readonly string[];
82
+ currenciesUsed(): readonly string[];
83
+ /**
84
+ * Balance of an account as at the end of a date, computed by replaying the
85
+ * relevant postings rather than from the index.
86
+ */
87
+ balanceAsAt(account: string, asAt: CalendarDate, currency?: Currency | string): Money;
88
+ /** Running balance after each posting to an account, in chronological order. */
89
+ statement(account: string, currency?: Currency | string): readonly {
90
+ entry: JournalEntry;
91
+ amount: Money;
92
+ running: Money;
93
+ }[];
94
+ /**
95
+ * Re-derive every balance from the entry list and compare against the index.
96
+ * The index is an optimisation; this is the check that it has not drifted.
97
+ */
98
+ verify(): void;
99
+ /** True when the whole ledger sums to zero in every currency. */
100
+ get isBalanced(): boolean;
101
+ }
@@ -0,0 +1,308 @@
1
+ /**
2
+ * The ledger: an append-only sequence of journal entries with derived balances.
3
+ *
4
+ * `post` returns a *new* `Ledger` rather than mutating. That is not persistence
5
+ * theatre — reconciliation wants to try a set of proposed entries, look at the
6
+ * resulting balances, and throw the attempt away. Structural sharing keeps the
7
+ * entry list cheap; only the balance index is copied, and it is one small map.
8
+ */
9
+ import { Money, sumMoney } from "../money/money.js";
10
+ import { debitSign } from "../accounts/types.js";
11
+ import { withinRange } from "./date.js";
12
+ import { JournalEntry, residualsByCurrency } from "./entry.js";
13
+ export class DuplicateEntryError extends Error {
14
+ id;
15
+ constructor(id) {
16
+ super(`Entry ${id} is already in the ledger`);
17
+ this.id = id;
18
+ this.name = "DuplicateEntryError";
19
+ }
20
+ }
21
+ export class LedgerIntegrityError extends Error {
22
+ constructor(message) {
23
+ super(message);
24
+ this.name = "LedgerIntegrityError";
25
+ }
26
+ }
27
+ /** Key for the balance index: one running total per account *and* currency. */
28
+ function key(account, currencyCode) {
29
+ return `${account}\u0000${currencyCode}`;
30
+ }
31
+ export class Ledger {
32
+ entries;
33
+ index;
34
+ balances;
35
+ debitTotals;
36
+ creditTotals;
37
+ postingCounts;
38
+ chart;
39
+ constructor(entries, index, balances, debitTotals, creditTotals, postingCounts, chart) {
40
+ this.entries = entries;
41
+ this.index = index;
42
+ this.balances = balances;
43
+ this.debitTotals = debitTotals;
44
+ this.creditTotals = creditTotals;
45
+ this.postingCounts = postingCounts;
46
+ this.chart = chart;
47
+ Object.freeze(this);
48
+ }
49
+ static empty(chart) {
50
+ return new Ledger([], new Map(), new Map(), new Map(), new Map(), new Map(), chart);
51
+ }
52
+ static from(entries, chart) {
53
+ let ledger = Ledger.empty(chart);
54
+ for (const entry of entries)
55
+ ledger = ledger.post(entry);
56
+ return ledger;
57
+ }
58
+ // ------------------------------------------------------------------ writing
59
+ /** Append an entry, returning a new ledger. Rejects duplicate ids. */
60
+ post(entry) {
61
+ if (this.index.has(entry.id))
62
+ throw new DuplicateEntryError(entry.id);
63
+ if (this.chart !== undefined) {
64
+ for (const posting of entry.postings)
65
+ this.chart.assertPostable(posting.account);
66
+ }
67
+ const balances = new Map(this.balances);
68
+ const debitTotals = new Map(this.debitTotals);
69
+ const creditTotals = new Map(this.creditTotals);
70
+ const postingCounts = new Map(this.postingCounts);
71
+ for (const posting of entry.postings) {
72
+ const k = key(posting.account, posting.amount.currency.code);
73
+ const minor = posting.amount.minorUnits;
74
+ balances.set(k, (balances.get(k) ?? 0n) + minor);
75
+ if (minor > 0n)
76
+ debitTotals.set(k, (debitTotals.get(k) ?? 0n) + minor);
77
+ else
78
+ creditTotals.set(k, (creditTotals.get(k) ?? 0n) - minor);
79
+ postingCounts.set(k, (postingCounts.get(k) ?? 0) + 1);
80
+ }
81
+ const index = new Map(this.index);
82
+ index.set(entry.id, entry.id);
83
+ return new Ledger([...this.entries, entry], index, balances, debitTotals, creditTotals, postingCounts, this.chart);
84
+ }
85
+ /** Build and post in one step, validating against the ledger's chart. */
86
+ record(input) {
87
+ return this.post(JournalEntry.create(input, this.chart));
88
+ }
89
+ postMany(entries) {
90
+ let ledger = this;
91
+ for (const entry of entries)
92
+ ledger = ledger.post(entry);
93
+ return ledger;
94
+ }
95
+ /** Append the reversal of an existing entry. */
96
+ reverse(entryId, options) {
97
+ const original = this.entry(entryId);
98
+ if (original === undefined) {
99
+ throw new LedgerIntegrityError(`Cannot reverse unknown entry ${entryId}`);
100
+ }
101
+ return this.post(original.reversal(options));
102
+ }
103
+ // ------------------------------------------------------------------ reading
104
+ get size() {
105
+ return this.entries.length;
106
+ }
107
+ get isEmpty() {
108
+ return this.entries.length === 0;
109
+ }
110
+ /** Entries in the order they were posted. */
111
+ all() {
112
+ return this.entries;
113
+ }
114
+ entry(id) {
115
+ return this.index.has(id) ? this.entries.find((e) => e.id === id) : undefined;
116
+ }
117
+ has(id) {
118
+ return this.index.has(id);
119
+ }
120
+ /** Entries sorted by date, ties broken by insertion order (stable). */
121
+ chronological() {
122
+ return this.entries
123
+ .map((entry, position) => ({ entry, position }))
124
+ .sort((a, b) => a.entry.date === b.entry.date
125
+ ? a.position - b.position
126
+ : a.entry.date < b.entry.date
127
+ ? -1
128
+ : 1)
129
+ .map((x) => x.entry);
130
+ }
131
+ filter(predicate) {
132
+ return this.entries.filter(predicate);
133
+ }
134
+ entriesFor(account) {
135
+ return this.entries.filter((e) => e.touches(account));
136
+ }
137
+ entriesIn(range) {
138
+ return this.entries.filter((e) => withinRange(e.date, range));
139
+ }
140
+ entriesOn(day) {
141
+ return this.entries.filter((e) => e.date === day);
142
+ }
143
+ byReference(reference) {
144
+ return this.entries.filter((e) => e.reference === reference);
145
+ }
146
+ /** Entries that have not been reversed by a later entry. */
147
+ live() {
148
+ const reversed = new Set(this.entries.map((e) => e.reverses).filter((id) => id !== null));
149
+ return this.entries.filter((e) => !reversed.has(e.id) && e.reverses === null);
150
+ }
151
+ // ----------------------------------------------------------------- balances
152
+ currencyFor(account) {
153
+ const fromChart = this.chart?.find(account)?.currency;
154
+ if (fromChart !== undefined)
155
+ return fromChart;
156
+ for (const entry of this.entries) {
157
+ for (const posting of entry.postings) {
158
+ if (posting.account === account)
159
+ return posting.amount.currency;
160
+ }
161
+ }
162
+ return this.chart?.defaultCurrency ?? Money.parse("0", "GBP").currency;
163
+ }
164
+ /** Signed balance of one account: positive is a net debit. */
165
+ balanceOf(account, currency) {
166
+ const c = currency ?? this.currencyFor(account);
167
+ const code = typeof c === "string" ? c.toUpperCase() : c.code;
168
+ return Money.ofMinor(this.balances.get(key(account, code)) ?? 0n, code);
169
+ }
170
+ /** Balance read on the account's normal side, using the chart's type. */
171
+ naturalBalanceOf(account, currency) {
172
+ const signed = this.balanceOf(account, currency);
173
+ const type = this.chart?.find(account)?.type;
174
+ if (type === undefined)
175
+ return signed;
176
+ return debitSign(type) === 1 ? signed : signed.negated();
177
+ }
178
+ /** Balance of an account plus all its descendants. Requires a chart. */
179
+ rolledUpBalanceOf(account, currency) {
180
+ if (this.chart === undefined) {
181
+ throw new LedgerIntegrityError("Rolled-up balances need a chart of accounts");
182
+ }
183
+ const codes = this.chart.subtree(account).map((a) => a.code);
184
+ const c = currency ?? this.chart.get(account).currency;
185
+ return sumMoney(codes.map((code) => this.balanceOf(code, c)), typeof c === "string" ? c : c.code);
186
+ }
187
+ /** Full detail for one account. */
188
+ accountBalance(account, currency) {
189
+ const c = currency ?? this.currencyFor(account);
190
+ const code = typeof c === "string" ? c.toUpperCase() : c.code;
191
+ const k = key(account, code);
192
+ const balance = Money.ofMinor(this.balances.get(k) ?? 0n, code);
193
+ const type = this.chart?.find(account)?.type;
194
+ return Object.freeze({
195
+ account,
196
+ balance,
197
+ natural: type === undefined || debitSign(type) === 1 ? balance : balance.negated(),
198
+ debitTotal: Money.ofMinor(this.debitTotals.get(k) ?? 0n, code),
199
+ creditTotal: Money.ofMinor(this.creditTotals.get(k) ?? 0n, code),
200
+ postingCount: this.postingCounts.get(k) ?? 0,
201
+ });
202
+ }
203
+ /** Every account that has been posted to, sorted by code. */
204
+ activeAccounts() {
205
+ const codes = new Set();
206
+ for (const k of this.balances.keys())
207
+ codes.add(k.split("\u0000")[0]);
208
+ return [...codes].sort();
209
+ }
210
+ currenciesUsed() {
211
+ const codes = new Set();
212
+ for (const k of this.balances.keys())
213
+ codes.add(k.split("\u0000")[1]);
214
+ return [...codes].sort();
215
+ }
216
+ /**
217
+ * Balance of an account as at the end of a date, computed by replaying the
218
+ * relevant postings rather than from the index.
219
+ */
220
+ balanceAsAt(account, asAt, currency) {
221
+ const c = currency ?? this.currencyFor(account);
222
+ const code = typeof c === "string" ? c.toUpperCase() : c.code;
223
+ let total = 0n;
224
+ for (const entry of this.entries) {
225
+ if (entry.date > asAt)
226
+ continue;
227
+ for (const posting of entry.postings) {
228
+ if (posting.account === account && posting.amount.currency.code === code) {
229
+ total += posting.amount.minorUnits;
230
+ }
231
+ }
232
+ }
233
+ return Money.ofMinor(total, code);
234
+ }
235
+ /** Running balance after each posting to an account, in chronological order. */
236
+ statement(account, currency) {
237
+ const c = currency ?? this.currencyFor(account);
238
+ const code = typeof c === "string" ? c.toUpperCase() : c.code;
239
+ let running = 0n;
240
+ const rows = [];
241
+ for (const entry of this.chronological()) {
242
+ for (const posting of entry.postings) {
243
+ if (posting.account !== account)
244
+ continue;
245
+ if (posting.amount.currency.code !== code)
246
+ continue;
247
+ running += posting.amount.minorUnits;
248
+ rows.push({
249
+ entry,
250
+ amount: posting.amount,
251
+ running: Money.ofMinor(running, code),
252
+ });
253
+ }
254
+ }
255
+ return rows;
256
+ }
257
+ // ---------------------------------------------------------------- integrity
258
+ /**
259
+ * Re-derive every balance from the entry list and compare against the index.
260
+ * The index is an optimisation; this is the check that it has not drifted.
261
+ */
262
+ verify() {
263
+ const recomputed = new Map();
264
+ const seen = new Set();
265
+ for (const entry of this.entries) {
266
+ if (seen.has(entry.id)) {
267
+ throw new LedgerIntegrityError(`Duplicate entry id in ledger: ${entry.id}`);
268
+ }
269
+ seen.add(entry.id);
270
+ const residuals = residualsByCurrency(entry.postings.map((p) => p.amount));
271
+ if (residuals.length > 0) {
272
+ throw new LedgerIntegrityError(`Entry ${entry.id} does not balance`);
273
+ }
274
+ for (const posting of entry.postings) {
275
+ const k = key(posting.account, posting.amount.currency.code);
276
+ recomputed.set(k, (recomputed.get(k) ?? 0n) + posting.amount.minorUnits);
277
+ }
278
+ }
279
+ for (const [k, expected] of recomputed) {
280
+ if ((this.balances.get(k) ?? 0n) !== expected) {
281
+ throw new LedgerIntegrityError(`Balance index drifted for ${k.replace("\u0000", "/")}`);
282
+ }
283
+ }
284
+ for (const [k, actual] of this.balances) {
285
+ if ((recomputed.get(k) ?? 0n) !== actual) {
286
+ throw new LedgerIntegrityError(`Stale balance index entry for ${k.replace("\u0000", "/")}`);
287
+ }
288
+ }
289
+ for (const code of this.currenciesUsed()) {
290
+ const total = [...this.balances.entries()]
291
+ .filter(([k]) => k.endsWith(`\u0000${code}`))
292
+ .reduce((a, [, v]) => a + v, 0n);
293
+ if (total !== 0n) {
294
+ throw new LedgerIntegrityError(`Ledger does not balance in ${code}: residual ${total} minor units`);
295
+ }
296
+ }
297
+ }
298
+ /** True when the whole ledger sums to zero in every currency. */
299
+ get isBalanced() {
300
+ try {
301
+ this.verify();
302
+ return true;
303
+ }
304
+ catch {
305
+ return false;
306
+ }
307
+ }
308
+ }