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,238 @@
1
+ /**
2
+ * Taking back out what the group owes and sells to itself.
3
+ *
4
+ * Aggregation adds every controlled entity in full, so a group that lends
5
+ * itself a million pounds shows a million of assets and a million of
6
+ * liabilities that no outsider is party to, and a group that sells to itself
7
+ * shows revenue it never earned. Neither survives into a set of consolidated
8
+ * accounts: from outside, the group is one economic entity and it cannot owe
9
+ * itself anything.
10
+ *
11
+ * The model is a declaration rather than an inference. An account is
12
+ * intercompany because someone said so and because it names which entity it
13
+ * faces — there is nothing in a balance that reveals whether the person on the
14
+ * other end of it is inside the group. That forces one account per
15
+ * counterparty, which is the shape real groups use anyway: "owed by S", "owed
16
+ * by T", not one "intercompany" account whose balance is a net of three
17
+ * relationships that each have to be eliminated against a different set of
18
+ * books.
19
+ *
20
+ * The interesting part is that the two sides rarely agree. Money sent on the
21
+ * 30th and received on the 3rd sits in neither company's cash and in both
22
+ * companies' intercompany accounts on the wrong side; goods despatched and not
23
+ * received do the same; and two legs translated at rates that do not
24
+ * reciprocate disagree by a few pence for no reason anyone can act on. That
25
+ * residual is the finding. It is booked to a named account, reported with the
26
+ * pair that produced it, and never plugged into a total — a group whose
27
+ * intercompany accounts are out by six figures has a problem, and a
28
+ * consolidation that made the number disappear would be hiding it.
29
+ */
30
+ import { Money, sumMoney } from "../money/money.js";
31
+ import { JournalEntry } from "../ledger/entry.js";
32
+ import { GROUP_ACCOUNTS } from "./accounts.js";
33
+ import { GroupError } from "./structure.js";
34
+ function key(...parts) {
35
+ return parts.join(" ");
36
+ }
37
+ function classOf(type) {
38
+ return type === "income" || type === "expense" ? "profit-and-loss" : "balance-sheet";
39
+ }
40
+ /**
41
+ * Read the declared balances out of an aggregation.
42
+ *
43
+ * The aggregation already holds every entity's translated trial balance, so
44
+ * nothing is recomputed here and the eliminations are guaranteed to be against
45
+ * the same figures that were added up.
46
+ */
47
+ export function intercompanyBalances(group, aggregation, declarations) {
48
+ const zero = Money.zero(aggregation.presentation);
49
+ const seen = new Set();
50
+ const sides = [];
51
+ for (const declaration of declarations) {
52
+ const { entity, account, counterparty } = declaration;
53
+ if (entity === counterparty) {
54
+ throw new GroupError(`${entity} cannot be its own counterparty (account ${account})`);
55
+ }
56
+ if (!group.has(entity)) {
57
+ throw new GroupError(`Intercompany declaration names ${entity}, which is not in the group`);
58
+ }
59
+ if (!group.has(counterparty)) {
60
+ throw new GroupError(`Intercompany declaration in ${entity} names ${counterparty} as counterparty, ` +
61
+ `which is not in the group`);
62
+ }
63
+ const id = key(entity, account);
64
+ if (seen.has(id)) {
65
+ throw new GroupError(`Account ${account} in ${entity} is declared intercompany twice. One account faces ` +
66
+ `one counterparty; split the balance if it faces more.`);
67
+ }
68
+ seen.add(id);
69
+ const contribution = aggregation.entities.find((c) => c.entity === entity);
70
+ if (contribution === undefined) {
71
+ // Declared against an entity that is not being consolidated. Not an
72
+ // error — an associate's balances stay where they are — but there is
73
+ // nothing to eliminate, and saying so beats silently dropping it.
74
+ continue;
75
+ }
76
+ const row = contribution.translation.rows.find((r) => r.account === account);
77
+ sides.push(Object.freeze({
78
+ entity,
79
+ account,
80
+ counterparty,
81
+ name: row?.name ?? account,
82
+ type: row?.type ?? null,
83
+ functional: row?.functional ?? Money.zero(contribution.functional),
84
+ presentation: row?.presentation ?? zero,
85
+ link: declaration.link ?? classOf(row?.type ?? null),
86
+ note: declaration.note ?? "",
87
+ }));
88
+ }
89
+ return Object.freeze(sides);
90
+ }
91
+ function kindOf(a, b) {
92
+ const left = classOf(a.type);
93
+ const right = classOf(b.type);
94
+ return left === right ? left : "mixed";
95
+ }
96
+ /**
97
+ * Pair the declarations up and produce the entries that remove them.
98
+ *
99
+ * Each entry reverses both sides and books whatever is left to the difference
100
+ * account, so it balances by construction and the combined trial balance
101
+ * balances after it as it did before.
102
+ */
103
+ export function eliminateIntercompany(group, aggregation, declarations, options = {}) {
104
+ const presentation = aggregation.presentation;
105
+ const zero = Money.zero(presentation);
106
+ const differenceAccount = options.differenceAccount ?? GROUP_ACCOUNTS.itemsInTransit;
107
+ const prefix = options.idPrefix ?? "ELIM";
108
+ const tolerance = options.tolerance ?? zero;
109
+ const sides = intercompanyBalances(group, aggregation, declarations);
110
+ // Keyed on the relationship and not just the two entities: a loan account
111
+ // and a trading account both face the same counterparty, and pairing on the
112
+ // entities alone would match the loan against the purchases.
113
+ const byPair = new Map();
114
+ for (const side of sides) {
115
+ const id = key(side.entity, side.counterparty, side.link);
116
+ if (byPair.has(id)) {
117
+ throw new GroupError(`${side.entity} has two accounts facing ${side.counterparty} on the same "${side.link}" ` +
118
+ `relationship (${byPair.get(id).account} and ${side.account}). ` +
119
+ `Name the relationships with "link" so each side knows what it pairs with.`);
120
+ }
121
+ byPair.set(id, side);
122
+ }
123
+ const pairs = [];
124
+ const unmatched = [];
125
+ const handled = new Set();
126
+ for (const side of sides) {
127
+ if (handled.has(side))
128
+ continue;
129
+ const mirror = byPair.get(key(side.counterparty, side.entity, side.link));
130
+ if (mirror === undefined || handled.has(mirror)) {
131
+ unmatched.push(Object.freeze({
132
+ side,
133
+ reason: mirror === undefined
134
+ ? `${side.counterparty} declares no "${side.link}" account facing ${side.entity}`
135
+ : `${side.counterparty}'s "${side.link}" account facing ${side.entity} is already paired`,
136
+ }));
137
+ handled.add(side);
138
+ continue;
139
+ }
140
+ handled.add(side);
141
+ handled.add(mirror);
142
+ const ordered = side.entity.localeCompare(mirror.entity) <= 0 ? [side, mirror] : [mirror, side];
143
+ const difference = ordered[0].presentation.plus(ordered[1].presentation);
144
+ pairs.push(Object.freeze({
145
+ sides: ordered,
146
+ kind: kindOf(ordered[0], ordered[1]),
147
+ difference,
148
+ agrees: difference.abs().lessThanOrEqual(tolerance) || difference.isZero,
149
+ }));
150
+ }
151
+ pairs.sort((a, b) => {
152
+ const byEntity = a.sides[0].entity.localeCompare(b.sides[0].entity);
153
+ if (byEntity !== 0)
154
+ return byEntity;
155
+ return a.sides[0].account.localeCompare(b.sides[0].account);
156
+ });
157
+ const entries = [];
158
+ let sequence = 0;
159
+ for (const pair of pairs) {
160
+ const [first, second] = pair.sides;
161
+ if (first.presentation.isZero && second.presentation.isZero)
162
+ continue;
163
+ sequence += 1;
164
+ const postings = [
165
+ {
166
+ account: first.account,
167
+ amount: first.presentation.negated(),
168
+ memo: `${first.entity} facing ${first.counterparty}`,
169
+ },
170
+ {
171
+ account: second.account,
172
+ amount: second.presentation.negated(),
173
+ memo: `${second.entity} facing ${second.counterparty}`,
174
+ },
175
+ ];
176
+ if (!pair.difference.isZero) {
177
+ postings.push({
178
+ account: differenceAccount,
179
+ amount: pair.difference,
180
+ memo: `${first.entity}/${second.entity} do not agree`,
181
+ });
182
+ }
183
+ entries.push(JournalEntry.create({
184
+ id: `${prefix}-${String(sequence).padStart(3, "0")}`,
185
+ date: aggregation.asAt,
186
+ narration: `Eliminate ${first.entity}/${second.entity} intercompany` +
187
+ (first.note === "" ? "" : ` — ${first.note}`),
188
+ postings,
189
+ tags: ["consolidation", "intercompany"],
190
+ }, options.chart));
191
+ }
192
+ // Measured on the debit side of the pairs actually removed. An unpaired
193
+ // declaration is not eliminated, so it does not count towards this.
194
+ const totalEliminated = sumMoney(pairs
195
+ .flatMap((p) => [...p.sides])
196
+ .filter((s) => s.presentation.isPositive)
197
+ .map((s) => s.presentation), presentation);
198
+ return Object.freeze({
199
+ presentation,
200
+ pairs: Object.freeze(pairs),
201
+ unmatched: Object.freeze(unmatched),
202
+ disagreements: Object.freeze(pairs.filter((p) => !p.agrees)),
203
+ entries: Object.freeze(entries),
204
+ totalDifference: sumMoney(pairs.map((p) => p.difference), presentation),
205
+ totalEliminated,
206
+ });
207
+ }
208
+ export function renderEliminations(result) {
209
+ const lines = [];
210
+ lines.push(`Intercompany eliminations — ${result.pairs.length} ` +
211
+ `${result.pairs.length === 1 ? "pair" : "pairs"}, ` +
212
+ `${result.totalEliminated.toDecimalString()} ${result.presentation.code} removed`);
213
+ lines.push("-".repeat(96));
214
+ for (const pair of result.pairs) {
215
+ const [a, b] = pair.sides;
216
+ lines.push(`${a.entity} ${a.account} ${a.name}`.padEnd(40) +
217
+ a.presentation.toDecimalString().padStart(14) +
218
+ ` ${pair.kind}`);
219
+ lines.push(`${b.entity} ${b.account} ${b.name}`.padEnd(40) + b.presentation.toDecimalString().padStart(14));
220
+ if (pair.agrees) {
221
+ lines.push(" agrees".padEnd(40) + "".padStart(14));
222
+ }
223
+ else {
224
+ const larger = pair.difference.isPositive ? a : b;
225
+ lines.push(` out by ${pair.difference.abs().toDecimalString()} — ${larger.entity}'s side is the larger`);
226
+ }
227
+ }
228
+ for (const item of result.unmatched) {
229
+ lines.push(`UNPAIRED ${item.side.entity} ${item.side.account} ` +
230
+ `${item.side.presentation.toDecimalString()} — ${item.reason}`);
231
+ }
232
+ if (!result.totalDifference.isZero) {
233
+ lines.push("-".repeat(96));
234
+ lines.push(`${result.totalDifference.toDecimalString()} ${result.presentation.code} of differences, ` +
235
+ `carried as items in transit rather than plugged.`);
236
+ }
237
+ return lines.join("\n");
238
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * An ownership interest, exactly.
3
+ *
4
+ * This has to be a rational and not a decimal, because the interests that
5
+ * matter are the ones that come out of a chain. A parent holding two thirds of
6
+ * a company that holds three quarters of a third owns exactly half of the
7
+ * third, and there is no decimal expansion of two thirds to write that with.
8
+ * Rounded to four places and multiplied through, 66.67% of 75% is 50.0025%,
9
+ * which is then multiplied by a number of pounds and shows up in a balance
10
+ * sheet as a non-controlling interest that is out by a few pence in a way
11
+ * nobody can trace.
12
+ *
13
+ * So: a numerator and a denominator over bigints, normalised, never converted
14
+ * to a float except for display, and constrained to [0, 1] because an interest
15
+ * outside that range is not an interest.
16
+ */
17
+ import { Money } from "../money/money.js";
18
+ import { type RoundingMode } from "../money/rounding.js";
19
+ export declare class InterestError extends Error {
20
+ constructor(message: string);
21
+ }
22
+ export declare class Interest {
23
+ readonly numerator: bigint;
24
+ readonly denominator: bigint;
25
+ private constructor();
26
+ /**
27
+ * A fraction, given as a ratio. Normalised on the way in, so `of(2n, 4n)`
28
+ * and `of(1n, 2n)` are the same object by `equals` and print the same.
29
+ */
30
+ static of(numerator: bigint | number, denominator?: bigint | number): Interest;
31
+ /**
32
+ * From a percentage written as a decimal string or a number. The string form
33
+ * is exact — `ofPercent("33.333")` is 33333/100000 and not the nearest
34
+ * double — and is what a document loader should use.
35
+ */
36
+ static ofPercent(value: string | number): Interest;
37
+ /** From basis points: 8000 is 80%. */
38
+ static ofBasisPoints(points: bigint | number): Interest;
39
+ static readonly whole: Interest;
40
+ static readonly none: Interest;
41
+ /**
42
+ * The interest held through this one. A parent's 80% of a subsidiary that
43
+ * itself holds 75% of a third company gives the parent 60% of the third.
44
+ */
45
+ times(other: Interest): Interest;
46
+ /** Two holdings in the same company, added. Throws if they exceed the whole. */
47
+ plus(other: Interest): Interest;
48
+ minus(other: Interest): Interest;
49
+ /** What everybody else holds. The non-controlling interest, when this is the group's. */
50
+ complement(): Interest;
51
+ get isZero(): boolean;
52
+ get isWhole(): boolean;
53
+ /**
54
+ * More than half. Control is a question about voting rights and in principle
55
+ * a bare majority is neither necessary nor sufficient for it; this is the
56
+ * arithmetic part of the test and the group structure treats it as a default
57
+ * that a definition can override.
58
+ */
59
+ get isControlling(): boolean;
60
+ compare(other: Interest): -1 | 0 | 1;
61
+ equals(other: Interest): boolean;
62
+ /**
63
+ * This share of an amount, rounded to whole minor units.
64
+ *
65
+ * Exact until the last step: the multiplication and the division happen in
66
+ * one bigint operation, so 40% of 3 pence is decided by the rounding mode
67
+ * rather than by an intermediate float.
68
+ */
69
+ share(amount: Money, rounding?: RoundingMode): Money;
70
+ /**
71
+ * This share and the rest, summing exactly back to the amount.
72
+ *
73
+ * `share` on its own cannot promise that: 50% of an odd number of pence
74
+ * rounds one way, its complement rounds the same way, and the two halves
75
+ * come to a penny more or less than the whole. The remainder is given to the
76
+ * other side, so that a group share and a non-controlling interest always
77
+ * add up to what was there.
78
+ */
79
+ splitOf(amount: Money, rounding?: RoundingMode): {
80
+ mine: Money;
81
+ theirs: Money;
82
+ };
83
+ toNumber(): number;
84
+ /**
85
+ * As a percentage, trailing zeros trimmed. Exact when the interest has a
86
+ * terminating expansion, and truncated with a trailing marker when it does
87
+ * not, so a third never reads as if it were 33.33% on the nose.
88
+ */
89
+ toPercentString(places?: number): string;
90
+ toString(): string;
91
+ /**
92
+ * A string that `parse` reads back to the same interest.
93
+ *
94
+ * Percentages are the readable form and the one a document should normally
95
+ * carry, but two thirds has no decimal expansion, and writing 66.6667% into
96
+ * a file that is later read back is a quiet loss of the exactness the whole
97
+ * type exists for. So a non-terminating interest serialises as its ratio.
98
+ */
99
+ toJSON(): string;
100
+ /** Whether the percentage has a finite decimal expansion. */
101
+ isTerminating(): boolean;
102
+ /**
103
+ * Read either form: `"80%"`, `"80"`, `"0.6"` as a percentage is ambiguous and
104
+ * is not accepted without the sign, and `"2/3"` for the ones that need it.
105
+ */
106
+ static parse(value: string): Interest;
107
+ /** The ratio itself, for a reader who wants to see where a third came from. */
108
+ toRatioString(): string;
109
+ }
@@ -0,0 +1,224 @@
1
+ /**
2
+ * An ownership interest, exactly.
3
+ *
4
+ * This has to be a rational and not a decimal, because the interests that
5
+ * matter are the ones that come out of a chain. A parent holding two thirds of
6
+ * a company that holds three quarters of a third owns exactly half of the
7
+ * third, and there is no decimal expansion of two thirds to write that with.
8
+ * Rounded to four places and multiplied through, 66.67% of 75% is 50.0025%,
9
+ * which is then multiplied by a number of pounds and shows up in a balance
10
+ * sheet as a non-controlling interest that is out by a few pence in a way
11
+ * nobody can trace.
12
+ *
13
+ * So: a numerator and a denominator over bigints, normalised, never converted
14
+ * to a float except for display, and constrained to [0, 1] because an interest
15
+ * outside that range is not an interest.
16
+ */
17
+ import { Money } from "../money/money.js";
18
+ import { decimalToRational, divideRound } from "../money/rounding.js";
19
+ export class InterestError extends Error {
20
+ constructor(message) {
21
+ super(message);
22
+ this.name = "InterestError";
23
+ }
24
+ }
25
+ function gcd(a, b) {
26
+ let x = a < 0n ? -a : a;
27
+ let y = b < 0n ? -b : b;
28
+ while (y !== 0n) {
29
+ const t = x % y;
30
+ x = y;
31
+ y = t;
32
+ }
33
+ return x;
34
+ }
35
+ export class Interest {
36
+ numerator;
37
+ denominator;
38
+ constructor(numerator, denominator) {
39
+ this.numerator = numerator;
40
+ this.denominator = denominator;
41
+ Object.freeze(this);
42
+ }
43
+ // ---------------------------------------------------------------- factories
44
+ /**
45
+ * A fraction, given as a ratio. Normalised on the way in, so `of(2n, 4n)`
46
+ * and `of(1n, 2n)` are the same object by `equals` and print the same.
47
+ */
48
+ static of(numerator, denominator = 1n) {
49
+ let n = BigInt(numerator);
50
+ let d = BigInt(denominator);
51
+ if (d === 0n)
52
+ throw new InterestError("An interest cannot have a zero denominator");
53
+ if (d < 0n) {
54
+ n = -n;
55
+ d = -d;
56
+ }
57
+ if (n < 0n)
58
+ throw new InterestError(`An interest cannot be negative: ${n}/${d}`);
59
+ if (n > d) {
60
+ throw new InterestError(`An interest cannot exceed the whole: ${n}/${d} is more than 100%`);
61
+ }
62
+ const g = gcd(n, d);
63
+ return new Interest(g === 0n ? 0n : n / g, g === 0n ? 1n : d / g);
64
+ }
65
+ /**
66
+ * From a percentage written as a decimal string or a number. The string form
67
+ * is exact — `ofPercent("33.333")` is 33333/100000 and not the nearest
68
+ * double — and is what a document loader should use.
69
+ */
70
+ static ofPercent(value) {
71
+ if (typeof value === "number") {
72
+ if (!Number.isFinite(value))
73
+ throw new InterestError(`Not a percentage: ${value}`);
74
+ return Interest.ofPercent(String(value));
75
+ }
76
+ const trimmed = value.trim().replace(/%$/, "").trim();
77
+ if (trimmed === "")
78
+ throw new InterestError("Not a percentage: an empty string");
79
+ let rational;
80
+ try {
81
+ rational = decimalToRational(trimmed);
82
+ }
83
+ catch {
84
+ throw new InterestError(`Not a percentage: ${JSON.stringify(value)}`);
85
+ }
86
+ return Interest.of(rational.numerator, rational.denominator * 100n);
87
+ }
88
+ /** From basis points: 8000 is 80%. */
89
+ static ofBasisPoints(points) {
90
+ return Interest.of(BigInt(points), 10000n);
91
+ }
92
+ static whole = new Interest(1n, 1n);
93
+ static none = new Interest(0n, 1n);
94
+ // ------------------------------------------------------------------ algebra
95
+ /**
96
+ * The interest held through this one. A parent's 80% of a subsidiary that
97
+ * itself holds 75% of a third company gives the parent 60% of the third.
98
+ */
99
+ times(other) {
100
+ return Interest.of(this.numerator * other.numerator, this.denominator * other.denominator);
101
+ }
102
+ /** Two holdings in the same company, added. Throws if they exceed the whole. */
103
+ plus(other) {
104
+ return Interest.of(this.numerator * other.denominator + other.numerator * this.denominator, this.denominator * other.denominator);
105
+ }
106
+ minus(other) {
107
+ return Interest.of(this.numerator * other.denominator - other.numerator * this.denominator, this.denominator * other.denominator);
108
+ }
109
+ /** What everybody else holds. The non-controlling interest, when this is the group's. */
110
+ complement() {
111
+ return Interest.of(this.denominator - this.numerator, this.denominator);
112
+ }
113
+ // ---------------------------------------------------------------- predicates
114
+ get isZero() {
115
+ return this.numerator === 0n;
116
+ }
117
+ get isWhole() {
118
+ return this.numerator === this.denominator;
119
+ }
120
+ /**
121
+ * More than half. Control is a question about voting rights and in principle
122
+ * a bare majority is neither necessary nor sufficient for it; this is the
123
+ * arithmetic part of the test and the group structure treats it as a default
124
+ * that a definition can override.
125
+ */
126
+ get isControlling() {
127
+ return this.numerator * 2n > this.denominator;
128
+ }
129
+ compare(other) {
130
+ const left = this.numerator * other.denominator;
131
+ const right = other.numerator * this.denominator;
132
+ return left < right ? -1 : left > right ? 1 : 0;
133
+ }
134
+ equals(other) {
135
+ return this.numerator === other.numerator && this.denominator === other.denominator;
136
+ }
137
+ // ------------------------------------------------------------------- money
138
+ /**
139
+ * This share of an amount, rounded to whole minor units.
140
+ *
141
+ * Exact until the last step: the multiplication and the division happen in
142
+ * one bigint operation, so 40% of 3 pence is decided by the rounding mode
143
+ * rather than by an intermediate float.
144
+ */
145
+ share(amount, rounding = "half-even") {
146
+ return Money.ofMinor(divideRound(amount.minorUnits * this.numerator, this.denominator, rounding), amount.currency);
147
+ }
148
+ /**
149
+ * This share and the rest, summing exactly back to the amount.
150
+ *
151
+ * `share` on its own cannot promise that: 50% of an odd number of pence
152
+ * rounds one way, its complement rounds the same way, and the two halves
153
+ * come to a penny more or less than the whole. The remainder is given to the
154
+ * other side, so that a group share and a non-controlling interest always
155
+ * add up to what was there.
156
+ */
157
+ splitOf(amount, rounding = "half-even") {
158
+ const mine = this.share(amount, rounding);
159
+ return { mine, theirs: amount.minus(mine) };
160
+ }
161
+ // ------------------------------------------------------------------ display
162
+ toNumber() {
163
+ return Number(this.numerator) / Number(this.denominator);
164
+ }
165
+ /**
166
+ * As a percentage, trailing zeros trimmed. Exact when the interest has a
167
+ * terminating expansion, and truncated with a trailing marker when it does
168
+ * not, so a third never reads as if it were 33.33% on the nose.
169
+ */
170
+ toPercentString(places = 4) {
171
+ const scale = 10n ** BigInt(places);
172
+ const scaled = (this.numerator * 100n * scale) / this.denominator;
173
+ const exact = this.numerator * 100n * scale === scaled * this.denominator;
174
+ const whole = scaled / scale;
175
+ const fraction = scaled % scale;
176
+ let text = whole.toString();
177
+ if (fraction > 0n) {
178
+ const digits = fraction.toString().padStart(places, "0").replace(/0+$/, "");
179
+ text += `.${digits}`;
180
+ }
181
+ return `${text}${exact ? "" : "…"}%`;
182
+ }
183
+ toString() {
184
+ return this.toPercentString();
185
+ }
186
+ /**
187
+ * A string that `parse` reads back to the same interest.
188
+ *
189
+ * Percentages are the readable form and the one a document should normally
190
+ * carry, but two thirds has no decimal expansion, and writing 66.6667% into
191
+ * a file that is later read back is a quiet loss of the exactness the whole
192
+ * type exists for. So a non-terminating interest serialises as its ratio.
193
+ */
194
+ toJSON() {
195
+ return this.isTerminating() ? this.toPercentString(20) : this.toRatioString();
196
+ }
197
+ /** Whether the percentage has a finite decimal expansion. */
198
+ isTerminating() {
199
+ // A fraction in lowest terms terminates in base 10 exactly when its
200
+ // denominator's only prime factors are 2 and 5. The percentage carries a
201
+ // factor of 100, which supplies two of each.
202
+ let d = this.denominator;
203
+ for (const prime of [2n, 5n]) {
204
+ while (d % prime === 0n)
205
+ d /= prime;
206
+ }
207
+ return d === 1n;
208
+ }
209
+ /**
210
+ * Read either form: `"80%"`, `"80"`, `"0.6"` as a percentage is ambiguous and
211
+ * is not accepted without the sign, and `"2/3"` for the ones that need it.
212
+ */
213
+ static parse(value) {
214
+ const text = value.trim();
215
+ const ratio = /^(\d+)\s*\/\s*(\d+)$/.exec(text);
216
+ if (ratio !== null)
217
+ return Interest.of(BigInt(ratio[1]), BigInt(ratio[2]));
218
+ return Interest.ofPercent(text);
219
+ }
220
+ /** The ratio itself, for a reader who wants to see where a third came from. */
221
+ toRatioString() {
222
+ return `${this.numerator}/${this.denominator}`;
223
+ }
224
+ }