tallyd 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +1493 -2
- package/dist/accounts/chart.d.ts +96 -0
- package/dist/accounts/chart.js +244 -0
- package/dist/accounts/index.d.ts +3 -0
- package/dist/accounts/index.js +3 -0
- package/dist/accounts/standard.d.ts +12 -0
- package/dist/accounts/standard.js +95 -0
- package/dist/accounts/types.d.ts +20 -0
- package/dist/accounts/types.js +40 -0
- package/dist/cli/args.d.ts +45 -0
- package/dist/cli/args.js +147 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +2 -0
- package/dist/cli/main.d.ts +6 -0
- package/dist/cli/main.js +25 -0
- package/dist/cli/run.d.ts +34 -0
- package/dist/cli/run.js +1660 -0
- package/dist/dashboard/charts.d.ts +38 -0
- package/dist/dashboard/charts.js +173 -0
- package/dist/dashboard/index.d.ts +3 -0
- package/dist/dashboard/index.js +3 -0
- package/dist/dashboard/model.d.ts +137 -0
- package/dist/dashboard/model.js +190 -0
- package/dist/dashboard/render.d.ts +23 -0
- package/dist/dashboard/render.js +231 -0
- package/dist/dashboard/script.d.ts +28 -0
- package/dist/dashboard/script.js +704 -0
- package/dist/dashboard/styles.d.ts +14 -0
- package/dist/dashboard/styles.js +726 -0
- package/dist/demo/disposal.d.ts +80 -0
- package/dist/demo/disposal.js +215 -0
- package/dist/demo/evidence.d.ts +153 -0
- package/dist/demo/evidence.js +348 -0
- package/dist/demo/foreign.d.ts +29 -0
- package/dist/demo/foreign.js +113 -0
- package/dist/demo/foreignReport.d.ts +10 -0
- package/dist/demo/foreignReport.js +89 -0
- package/dist/demo/generator.d.ts +108 -0
- package/dist/demo/generator.js +445 -0
- package/dist/demo/group.d.ts +70 -0
- package/dist/demo/group.js +195 -0
- package/dist/demo/groupPeriods.d.ts +27 -0
- package/dist/demo/groupPeriods.js +125 -0
- package/dist/demo/groupReport.d.ts +13 -0
- package/dist/demo/groupReport.js +87 -0
- package/dist/demo/ingest.d.ts +6 -0
- package/dist/demo/ingest.js +61 -0
- package/dist/demo/main.d.ts +6 -0
- package/dist/demo/main.js +7 -0
- package/dist/demo/mainDisposal.d.ts +1 -0
- package/dist/demo/mainDisposal.js +2 -0
- package/dist/demo/mainEvidence.d.ts +8 -0
- package/dist/demo/mainEvidence.js +9 -0
- package/dist/demo/mainForeign.d.ts +6 -0
- package/dist/demo/mainForeign.js +7 -0
- package/dist/demo/mainGroup.d.ts +6 -0
- package/dist/demo/mainGroup.js +7 -0
- package/dist/demo/mainGroupPeriods.d.ts +6 -0
- package/dist/demo/mainGroupPeriods.js +7 -0
- package/dist/demo/mainIngest.d.ts +6 -0
- package/dist/demo/mainIngest.js +7 -0
- package/dist/demo/mainMidYear.d.ts +6 -0
- package/dist/demo/mainMidYear.js +7 -0
- package/dist/demo/mainReconcile.d.ts +6 -0
- package/dist/demo/mainReconcile.js +7 -0
- package/dist/demo/mainStatements.d.ts +6 -0
- package/dist/demo/mainStatements.js +7 -0
- package/dist/demo/midYear.d.ts +51 -0
- package/dist/demo/midYear.js +162 -0
- package/dist/demo/month.d.ts +13 -0
- package/dist/demo/month.js +142 -0
- package/dist/demo/random.d.ts +48 -0
- package/dist/demo/random.js +94 -0
- package/dist/demo/receivables.d.ts +36 -0
- package/dist/demo/receivables.js +104 -0
- package/dist/demo/reconcile.d.ts +20 -0
- package/dist/demo/reconcile.js +104 -0
- package/dist/demo/report.d.ts +6 -0
- package/dist/demo/report.js +26 -0
- package/dist/demo/statement.d.ts +13 -0
- package/dist/demo/statement.js +51 -0
- package/dist/demo/statements.d.ts +6 -0
- package/dist/demo/statements.js +29 -0
- package/dist/demo/supplierRun.d.ts +46 -0
- package/dist/demo/supplierRun.js +94 -0
- package/dist/demo/unrealisedEvidence.d.ts +51 -0
- package/dist/demo/unrealisedEvidence.js +183 -0
- package/dist/fx/average.d.ts +60 -0
- package/dist/fx/average.js +139 -0
- package/dist/fx/document.d.ts +60 -0
- package/dist/fx/document.js +205 -0
- package/dist/fx/exposure.d.ts +108 -0
- package/dist/fx/exposure.js +262 -0
- package/dist/fx/index.d.ts +8 -0
- package/dist/fx/index.js +8 -0
- package/dist/fx/rate.d.ts +77 -0
- package/dist/fx/rate.js +200 -0
- package/dist/fx/revaluation.d.ts +92 -0
- package/dist/fx/revaluation.js +197 -0
- package/dist/fx/settlement.d.ts +84 -0
- package/dist/fx/settlement.js +162 -0
- package/dist/fx/table.d.ts +120 -0
- package/dist/fx/table.js +271 -0
- package/dist/fx/translate.d.ts +126 -0
- package/dist/fx/translate.js +258 -0
- package/dist/group/accounts.d.ts +74 -0
- package/dist/group/accounts.js +179 -0
- package/dist/group/acquisition.d.ts +324 -0
- package/dist/group/acquisition.js +416 -0
- package/dist/group/aggregate.d.ts +143 -0
- package/dist/group/aggregate.js +288 -0
- package/dist/group/comparative.d.ts +102 -0
- package/dist/group/comparative.js +166 -0
- package/dist/group/consolidate.d.ts +196 -0
- package/dist/group/consolidate.js +635 -0
- package/dist/group/disposal.d.ts +181 -0
- package/dist/group/disposal.js +233 -0
- package/dist/group/document.d.ts +41 -0
- package/dist/group/document.js +326 -0
- package/dist/group/index.d.ts +12 -0
- package/dist/group/index.js +12 -0
- package/dist/group/intercompany.d.ts +136 -0
- package/dist/group/intercompany.js +238 -0
- package/dist/group/interest.d.ts +109 -0
- package/dist/group/interest.js +224 -0
- package/dist/group/movement.d.ts +206 -0
- package/dist/group/movement.js +432 -0
- package/dist/group/structure.d.ts +151 -0
- package/dist/group/structure.js +327 -0
- package/dist/group/timeline.d.ts +109 -0
- package/dist/group/timeline.js +169 -0
- package/dist/group/unrealised.d.ts +165 -0
- package/dist/group/unrealised.js +314 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +10 -0
- package/dist/ledger/close.d.ts +75 -0
- package/dist/ledger/close.js +142 -0
- package/dist/ledger/date.d.ts +48 -0
- package/dist/ledger/date.js +141 -0
- package/dist/ledger/entry.d.ts +150 -0
- package/dist/ledger/entry.js +264 -0
- package/dist/ledger/index.d.ts +6 -0
- package/dist/ledger/index.js +6 -0
- package/dist/ledger/ledger.d.ts +101 -0
- package/dist/ledger/ledger.js +308 -0
- package/dist/ledger/serialise.d.ts +68 -0
- package/dist/ledger/serialise.js +204 -0
- package/dist/ledger/trialBalance.d.ts +49 -0
- package/dist/ledger/trialBalance.js +103 -0
- package/dist/money/currency.d.ts +37 -0
- package/dist/money/currency.js +67 -0
- package/dist/money/index.d.ts +3 -0
- package/dist/money/index.js +3 -0
- package/dist/money/money.d.ts +106 -0
- package/dist/money/money.js +300 -0
- package/dist/money/rounding.d.ts +41 -0
- package/dist/money/rounding.js +117 -0
- package/dist/reconcile/accuracy.d.ts +65 -0
- package/dist/reconcile/accuracy.js +162 -0
- package/dist/reconcile/assignment.d.ts +83 -0
- package/dist/reconcile/assignment.js +326 -0
- package/dist/reconcile/bankView.d.ts +65 -0
- package/dist/reconcile/bankView.js +86 -0
- package/dist/reconcile/bridge.d.ts +76 -0
- package/dist/reconcile/bridge.js +129 -0
- package/dist/reconcile/candidates.d.ts +84 -0
- package/dist/reconcile/candidates.js +174 -0
- package/dist/reconcile/decisions.d.ts +116 -0
- package/dist/reconcile/decisions.js +168 -0
- package/dist/reconcile/index.d.ts +12 -0
- package/dist/reconcile/index.js +12 -0
- package/dist/reconcile/matcher.d.ts +89 -0
- package/dist/reconcile/matcher.js +306 -0
- package/dist/reconcile/memory.d.ts +120 -0
- package/dist/reconcile/memory.js +281 -0
- package/dist/reconcile/posting.d.ts +185 -0
- package/dist/reconcile/posting.js +481 -0
- package/dist/reconcile/scoring.d.ts +98 -0
- package/dist/reconcile/scoring.js +319 -0
- package/dist/reconcile/similarity.d.ts +88 -0
- package/dist/reconcile/similarity.js +272 -0
- package/dist/reconcile/subsetSum.d.ts +61 -0
- package/dist/reconcile/subsetSum.js +136 -0
- package/dist/reports/ageing.d.ts +75 -0
- package/dist/reports/ageing.js +149 -0
- package/dist/reports/balanceSheet.d.ts +57 -0
- package/dist/reports/balanceSheet.js +107 -0
- package/dist/reports/incomeStatement.d.ts +59 -0
- package/dist/reports/incomeStatement.js +132 -0
- package/dist/reports/index.d.ts +4 -0
- package/dist/reports/index.js +4 -0
- package/dist/reports/period.d.ts +37 -0
- package/dist/reports/period.js +78 -0
- package/dist/statement/amount.d.ts +55 -0
- package/dist/statement/amount.js +211 -0
- package/dist/statement/columns.d.ts +39 -0
- package/dist/statement/columns.js +221 -0
- package/dist/statement/csv.d.ts +50 -0
- package/dist/statement/csv.js +185 -0
- package/dist/statement/dates.d.ts +52 -0
- package/dist/statement/dates.js +204 -0
- package/dist/statement/duplicates.d.ts +51 -0
- package/dist/statement/duplicates.js +101 -0
- package/dist/statement/import.d.ts +52 -0
- package/dist/statement/import.js +172 -0
- package/dist/statement/index.d.ts +20 -0
- package/dist/statement/index.js +32 -0
- package/dist/statement/line.d.ts +60 -0
- package/dist/statement/line.js +73 -0
- package/dist/statement/ofx.d.ts +47 -0
- package/dist/statement/ofx.js +199 -0
- package/package.json +72 -4
|
@@ -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
|
+
}
|