@trajectonomics/ledger 0.0.0-stage → 0.1.0-alpha.1
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 +40 -2
- package/dist/index.cjs +575 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +285 -0
- package/dist/index.d.mts +285 -0
- package/dist/index.mjs +555 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +58 -5
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
// src/account.ts
|
|
2
|
+
/** `asset` and `expense` are debit-normal; `liability`, `equity` and `income` credit-normal. */
|
|
3
|
+
const normalBalance = (type) => type === "asset" || type === "expense" ? "debit" : "credit";
|
|
4
|
+
// src/errors.ts
|
|
5
|
+
/** Thrown by every smart constructor and validator in this package. */
|
|
6
|
+
var LedgerValidationError = class extends Error {
|
|
7
|
+
code;
|
|
8
|
+
/** Path of the offending input, e.g. `postings[1].accountId`. */
|
|
9
|
+
field;
|
|
10
|
+
value;
|
|
11
|
+
reason;
|
|
12
|
+
constructor(code, field, value, reason) {
|
|
13
|
+
super(`${code}: ${field} ${reason}`);
|
|
14
|
+
this.name = "LedgerValidationError";
|
|
15
|
+
this.code = code;
|
|
16
|
+
this.field = field;
|
|
17
|
+
this.value = value;
|
|
18
|
+
this.reason = reason;
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
// src/date.ts
|
|
22
|
+
const SHAPE = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
23
|
+
const daysInMonth = (year, month) => month === 2 ? year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0) ? 29 : 28 : [
|
|
24
|
+
4,
|
|
25
|
+
6,
|
|
26
|
+
9,
|
|
27
|
+
11
|
|
28
|
+
].includes(month) ? 30 : 31;
|
|
29
|
+
const parseISODate = (value, field = "date") => {
|
|
30
|
+
const match = typeof value === "string" ? SHAPE.exec(value) : null;
|
|
31
|
+
const [year, month, day] = [
|
|
32
|
+
Number(match?.[1]),
|
|
33
|
+
Number(match?.[2]),
|
|
34
|
+
Number(match?.[3])
|
|
35
|
+
];
|
|
36
|
+
if (!match || month < 1 || month > 12 || day < 1 || day > daysInMonth(year, month)) throw new LedgerValidationError("INVALID_DATE", field, value, "must be a real YYYY-MM-DD calendar date");
|
|
37
|
+
return value;
|
|
38
|
+
};
|
|
39
|
+
// src/amount.ts
|
|
40
|
+
/** Accepts only safe integers; anything else is `INVALID_AMOUNT`. */
|
|
41
|
+
const assertSafeInteger = (value, field) => {
|
|
42
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value)) throw new LedgerValidationError("INVALID_AMOUNT", field, value, "must be a safe integer of minor units");
|
|
43
|
+
return value;
|
|
44
|
+
};
|
|
45
|
+
// src/currency.ts
|
|
46
|
+
let known;
|
|
47
|
+
/**
|
|
48
|
+
* Validate an ISO 4217 code against the runtime's own currency data
|
|
49
|
+
* (`Intl.supportedValuesOf("currency")`), so no code table ships in this package.
|
|
50
|
+
* The accepted set therefore follows the runtime's ICU version.
|
|
51
|
+
*/
|
|
52
|
+
const parseCurrency = (value, field = "currency") => {
|
|
53
|
+
known ??= new Set(Intl.supportedValuesOf("currency"));
|
|
54
|
+
if (typeof value !== "string" || !known.has(value)) throw new LedgerValidationError("INVALID_CURRENCY", field, value, "is not an ISO 4217 currency code");
|
|
55
|
+
return value;
|
|
56
|
+
};
|
|
57
|
+
/** Number of minor-unit decimals for a currency: THB 2, JPY 0, KWD 3, CLF 4. */
|
|
58
|
+
const minorUnitDigits = (currency) => new Intl.NumberFormat("en", {
|
|
59
|
+
style: "currency",
|
|
60
|
+
currency
|
|
61
|
+
}).resolvedOptions().maximumFractionDigits ?? 2;
|
|
62
|
+
// src/posting.ts
|
|
63
|
+
const fail = (code, field, value, reason) => {
|
|
64
|
+
throw new LedgerValidationError(code, field, value, reason);
|
|
65
|
+
};
|
|
66
|
+
const checkDimensions = (dimensions, field) => {
|
|
67
|
+
for (const [key, value] of Object.entries(dimensions)) if (key === "" || typeof value !== "string" || value === "") fail("INVALID_DIMENSION", `${field}.dimensions`, { [key]: value }, "keys and values must be non-empty strings");
|
|
68
|
+
};
|
|
69
|
+
/** Validate one posting against the chart; `path` is e.g. `postings[0]`. */
|
|
70
|
+
const validatePosting = (input, chart, path) => {
|
|
71
|
+
const amount = assertSafeInteger(input.amount, `${path}.amount`);
|
|
72
|
+
if (amount === 0) fail("ZERO_AMOUNT_POSTING", `${path}.amount`, amount, "must not be zero");
|
|
73
|
+
const currency = parseCurrency(input.currency, `${path}.currency`);
|
|
74
|
+
const account = chart.get(input.accountId);
|
|
75
|
+
if (!account) return fail("UNKNOWN_ACCOUNT", `${path}.accountId`, input.accountId, "is not in the chart of accounts");
|
|
76
|
+
if (!chart.isLeaf(account.id)) fail("NON_LEAF_ACCOUNT", `${path}.accountId`, account.id, "is a header account");
|
|
77
|
+
if (currency !== account.currency) fail("CURRENCY_MISMATCH", `${path}.currency`, currency, `account is ${account.currency}`);
|
|
78
|
+
if (input.dimensions) checkDimensions(input.dimensions, path);
|
|
79
|
+
return Object.freeze({
|
|
80
|
+
accountId: account.id,
|
|
81
|
+
amount,
|
|
82
|
+
currency,
|
|
83
|
+
...input.dimensions ? { dimensions: Object.freeze({ ...input.dimensions }) } : {}
|
|
84
|
+
});
|
|
85
|
+
};
|
|
86
|
+
// src/transaction.ts
|
|
87
|
+
const assertBalanced = (postings, chart) => {
|
|
88
|
+
const sums = /* @__PURE__ */ new Map();
|
|
89
|
+
for (const { accountId, amount, currency } of postings) {
|
|
90
|
+
const key = JSON.stringify([chart.get(accountId)?.entityId, currency]);
|
|
91
|
+
sums.set(key, (sums.get(key) ?? 0n) + BigInt(amount));
|
|
92
|
+
}
|
|
93
|
+
for (const [key, sum] of sums) if (sum !== 0n) {
|
|
94
|
+
const [entity, currency] = JSON.parse(key);
|
|
95
|
+
throw new LedgerValidationError("UNBALANCED_TRANSACTION", "postings", sum.toString(), `entity "${entity}" ${currency} sums to ${sum} instead of 0`);
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
/** Validate a transaction against a chart. Throws {@link LedgerValidationError}. */
|
|
99
|
+
const validateTransaction = (input, chart) => {
|
|
100
|
+
const date = parseISODate(input.date);
|
|
101
|
+
const count = Array.isArray(input.postings) ? input.postings.length : 0;
|
|
102
|
+
if (count < 2) throw new LedgerValidationError("SINGLE_POSTING", "postings", count, "needs at least 2 postings");
|
|
103
|
+
const postings = input.postings.map((p, i) => validatePosting(p, chart, `postings[${i}]`));
|
|
104
|
+
assertBalanced(postings, chart);
|
|
105
|
+
return Object.freeze({
|
|
106
|
+
id: input.id,
|
|
107
|
+
date,
|
|
108
|
+
postings: Object.freeze(postings),
|
|
109
|
+
...input.linkedTo === void 0 ? {} : { linkedTo: input.linkedTo }
|
|
110
|
+
});
|
|
111
|
+
};
|
|
112
|
+
// src/reverse.ts
|
|
113
|
+
/** Build a Reversal; `path` prefixes error fields so `amend` can mirror its own input shape. */
|
|
114
|
+
const reverseAt = (tx, input, chart, path) => {
|
|
115
|
+
const date = parseISODate(input.date, `${path}date`);
|
|
116
|
+
if (input.id === tx.id) throw new LedgerValidationError("DUPLICATE_TRANSACTION_ID", `${path}id`, input.id, "reuses the id of the transaction it reverses");
|
|
117
|
+
if (date < tx.date) throw new LedgerValidationError("REVERSAL_BEFORE_ORIGINAL", `${path}date`, input.date, `is before the original's date ${tx.date}`);
|
|
118
|
+
const postings = tx.postings.map(({ amount, ...rest }) => ({
|
|
119
|
+
...rest,
|
|
120
|
+
amount: -amount
|
|
121
|
+
}));
|
|
122
|
+
return validateTransaction({
|
|
123
|
+
id: input.id,
|
|
124
|
+
date,
|
|
125
|
+
postings,
|
|
126
|
+
linkedTo: tx.id
|
|
127
|
+
}, chart);
|
|
128
|
+
};
|
|
129
|
+
/** Build a Reversal: the original's Postings in order, every sign flipped, linked to it. */
|
|
130
|
+
const reverse = (tx, input, chart) => reverseAt(tx, input, chart, "");
|
|
131
|
+
// src/amend.ts
|
|
132
|
+
/**
|
|
133
|
+
* Build an Amendment: `[reversal, replacement]`, both linked to the original. The replacement's
|
|
134
|
+
* own `linkedTo` is overwritten with the original's id.
|
|
135
|
+
*/
|
|
136
|
+
const amend = (tx, input, chart) => {
|
|
137
|
+
const reversal = reverseAt(tx, input.reversal, chart, "reversal.");
|
|
138
|
+
const { id } = input.replacement;
|
|
139
|
+
if (id === tx.id || id === reversal.id) throw new LedgerValidationError("DUPLICATE_TRANSACTION_ID", "replacement.id", id, `reuses the id of the ${id === tx.id ? "original" : "reversal"} in the same amendment`);
|
|
140
|
+
const replacement = validateTransaction({
|
|
141
|
+
...input.replacement,
|
|
142
|
+
linkedTo: tx.id
|
|
143
|
+
}, chart);
|
|
144
|
+
return Object.freeze([reversal, replacement]);
|
|
145
|
+
};
|
|
146
|
+
// src/compare.ts
|
|
147
|
+
/** Order two strings by UTF-16 code unit. Never locale-aware: ICU data differs between runtimes. */
|
|
148
|
+
const byCodeUnit = (a, b) => a < b ? -1 : a > b ? 1 : 0;
|
|
149
|
+
// src/ledger-scan.ts
|
|
150
|
+
const recheck = (tx, index, chart) => {
|
|
151
|
+
try {
|
|
152
|
+
return validateTransaction(tx, chart);
|
|
153
|
+
} catch (error) {
|
|
154
|
+
if (!(error instanceof LedgerValidationError)) throw error;
|
|
155
|
+
throw new LedgerValidationError(error.code, `transactions[${index}].${error.field}`, error.value, error.reason);
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
/**
|
|
159
|
+
* Re-check every Transaction against the supplied chart (a Transaction is not tied to the chart
|
|
160
|
+
* that validated it) and flatten them into Postings. Throws on a duplicate Transaction id.
|
|
161
|
+
*/
|
|
162
|
+
const scanLedger = (transactions, chart) => {
|
|
163
|
+
const seen = /* @__PURE__ */ new Set();
|
|
164
|
+
return transactions.flatMap((tx, index) => {
|
|
165
|
+
if (seen.has(tx.id)) throw new LedgerValidationError("DUPLICATE_TRANSACTION_ID", `transactions[${index}].id`, tx.id, "is used by an earlier transaction");
|
|
166
|
+
seen.add(tx.id);
|
|
167
|
+
const checked = recheck(tx, index, chart);
|
|
168
|
+
return checked.postings.map((posting) => ({
|
|
169
|
+
account: chart.get(posting.accountId),
|
|
170
|
+
posting,
|
|
171
|
+
date: checked.date
|
|
172
|
+
}));
|
|
173
|
+
});
|
|
174
|
+
};
|
|
175
|
+
// src/safe-amount.ts
|
|
176
|
+
const MAX = BigInt(Number.MAX_SAFE_INTEGER);
|
|
177
|
+
/** Narrow a `BigInt` sum to a `number`, or throw `AMOUNT_OVERFLOW` when it is not a safe integer. */
|
|
178
|
+
const toSafeAmount = (sum, field) => {
|
|
179
|
+
if (sum > MAX || sum < -MAX) throw new LedgerValidationError("AMOUNT_OVERFLOW", field, sum.toString(), "exceeds the safe integer range");
|
|
180
|
+
return Number(sum);
|
|
181
|
+
};
|
|
182
|
+
// src/balances.ts
|
|
183
|
+
/** Order rows by `entityId`, `accountId`, then `currency`, by code unit. */
|
|
184
|
+
const compareRows = (a, b) => byCodeUnit(a.entityId, b.entityId) || byCodeUnit(a.accountId, b.accountId) || byCodeUnit(a.currency, b.currency);
|
|
185
|
+
/**
|
|
186
|
+
* Every Leaf Account's Balance up to and including `asOf`, zeros included, sorted. `fieldOf` gives
|
|
187
|
+
* the output path an `AMOUNT_OVERFLOW` reports for a Leaf, so it names the query the caller called.
|
|
188
|
+
*/
|
|
189
|
+
const leafBalances = (transactions, asOf, chart, fieldOf) => {
|
|
190
|
+
const cutoff = parseISODate(asOf, "asOf");
|
|
191
|
+
const sums = /* @__PURE__ */ new Map();
|
|
192
|
+
for (const { account, posting, date } of scanLedger(transactions, chart)) if (date <= cutoff) sums.set(account.id, (sums.get(account.id) ?? 0n) + BigInt(posting.amount));
|
|
193
|
+
return Object.freeze([...chart.values()].filter((account) => chart.isLeaf(account.id)).map((account) => Object.freeze({
|
|
194
|
+
entityId: account.entityId,
|
|
195
|
+
accountId: account.id,
|
|
196
|
+
currency: account.currency,
|
|
197
|
+
amount: toSafeAmount(sums.get(account.id) ?? 0n, fieldOf(account))
|
|
198
|
+
})).sort(compareRows));
|
|
199
|
+
};
|
|
200
|
+
/**
|
|
201
|
+
* Every Leaf Account's Balance up to and including `asOf`, zeros included, sorted. Header
|
|
202
|
+
* Accounts never appear, so summing the rows never double counts.
|
|
203
|
+
*
|
|
204
|
+
* TODO(#14): Header Account roll-ups live in a separate query, never in these rows.
|
|
205
|
+
*/
|
|
206
|
+
const balances = (transactions, asOf, chart) => leafBalances(transactions, asOf, chart, (account) => `balances[${account.id}]`);
|
|
207
|
+
// src/ids.ts
|
|
208
|
+
/** Tag a string as an {@link AccountId}, e.g. to look an account up in a chart. */
|
|
209
|
+
const accountId = (value) => value;
|
|
210
|
+
/** Tag a string as a {@link TransactionId}. */
|
|
211
|
+
const transactionId = (value) => value;
|
|
212
|
+
// src/balance.ts
|
|
213
|
+
/**
|
|
214
|
+
* One Leaf Account's Balance up to and including `asOf`: the row `balances` gives for it.
|
|
215
|
+
* Throws `UNKNOWN_ACCOUNT` for an id outside the chart and `NON_LEAF_ACCOUNT` for a Header.
|
|
216
|
+
*
|
|
217
|
+
* TODO(#14): a Header's roll-up Balance is a separate query, so a Header id is rejected here.
|
|
218
|
+
*/
|
|
219
|
+
const balance = (transactions, accountId$1, asOf, chart) => {
|
|
220
|
+
const id = accountId(accountId$1);
|
|
221
|
+
if (!chart.has(id)) throw new LedgerValidationError("UNKNOWN_ACCOUNT", "accountId", accountId$1, "is not in the chart of accounts");
|
|
222
|
+
if (!chart.isLeaf(id)) throw new LedgerValidationError("NON_LEAF_ACCOUNT", "accountId", accountId$1, "is a header account");
|
|
223
|
+
return leafBalances(transactions, asOf, chart, (account) => account.id === id ? "balance.amount" : `balance[${account.id}]`).find((row) => row.accountId === id);
|
|
224
|
+
};
|
|
225
|
+
// src/chart-map.ts
|
|
226
|
+
/**
|
|
227
|
+
* A validated, hierarchy-checked set of accounts, read-only at runtime. Only
|
|
228
|
+
* {@link createChartOfAccounts} constructs one (this module's constructor is not exported from
|
|
229
|
+
* the package), so holding one proves parents exist, share entity and type, and form no cycle.
|
|
230
|
+
* Its state lives in `#private` fields, so it cannot be mutated even via `Map.prototype`.
|
|
231
|
+
*/
|
|
232
|
+
var ChartOfAccounts = class {
|
|
233
|
+
#accounts;
|
|
234
|
+
#headers;
|
|
235
|
+
constructor(accounts) {
|
|
236
|
+
this.#accounts = new Map(accounts);
|
|
237
|
+
this.#headers = new Set([...accounts.values()].flatMap((a) => a.parentId === void 0 ? [] : [a.parentId]));
|
|
238
|
+
}
|
|
239
|
+
get size() {
|
|
240
|
+
return this.#accounts.size;
|
|
241
|
+
}
|
|
242
|
+
get(id) {
|
|
243
|
+
return this.#accounts.get(id);
|
|
244
|
+
}
|
|
245
|
+
has(id) {
|
|
246
|
+
return this.#accounts.has(id);
|
|
247
|
+
}
|
|
248
|
+
/** True for an account in this chart that no other account names as its parent. */
|
|
249
|
+
isLeaf(id) {
|
|
250
|
+
return this.#accounts.has(id) && !this.#headers.has(id);
|
|
251
|
+
}
|
|
252
|
+
forEach(callbackfn, thisArg) {
|
|
253
|
+
for (const [key, value] of this.#accounts) callbackfn.call(thisArg, value, key, this);
|
|
254
|
+
}
|
|
255
|
+
entries() {
|
|
256
|
+
return this.#accounts.entries();
|
|
257
|
+
}
|
|
258
|
+
keys() {
|
|
259
|
+
return this.#accounts.keys();
|
|
260
|
+
}
|
|
261
|
+
values() {
|
|
262
|
+
return this.#accounts.values();
|
|
263
|
+
}
|
|
264
|
+
[Symbol.iterator]() {
|
|
265
|
+
return this.#accounts.entries();
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
// src/hierarchy.ts
|
|
269
|
+
const reject = (index, field, value, reason) => {
|
|
270
|
+
throw new LedgerValidationError("INVALID_ACCOUNT_HIERARCHY", `accounts[${index}].${field}`, value, reason);
|
|
271
|
+
};
|
|
272
|
+
const checkParent = (account, parent, i) => {
|
|
273
|
+
if (account.parentId === void 0) return;
|
|
274
|
+
if (!parent) reject(i, "parentId", account.parentId, "parent account does not exist");
|
|
275
|
+
else if (parent.entityId !== account.entityId) reject(i, "parentId", parent.id, "parent belongs to another entity");
|
|
276
|
+
else if (parent.type !== account.type) reject(i, "parentId", parent.id, "parent has a different account type");
|
|
277
|
+
};
|
|
278
|
+
const checkAcyclic = (account, accounts, i) => {
|
|
279
|
+
const seen = /* @__PURE__ */ new Set([account.id]);
|
|
280
|
+
for (let up = account.parentId; up !== void 0; up = accounts.get(up)?.parentId) {
|
|
281
|
+
if (seen.has(up)) reject(i, "parentId", account.parentId, "hierarchy contains a cycle");
|
|
282
|
+
seen.add(up);
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
/** Reject missing, cross-entity and cross-type parents, and cycles. */
|
|
286
|
+
const checkHierarchy = (accounts) => {
|
|
287
|
+
for (const [i, account] of [...accounts.values()].entries()) {
|
|
288
|
+
checkParent(account, account.parentId === void 0 ? void 0 : accounts.get(account.parentId), i);
|
|
289
|
+
checkAcyclic(account, accounts, i);
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
// src/chart.ts
|
|
293
|
+
const toAccount = (input, index) => Object.freeze({
|
|
294
|
+
id: input.id,
|
|
295
|
+
type: input.type,
|
|
296
|
+
currency: parseCurrency(input.currency, `accounts[${index}].currency`),
|
|
297
|
+
entityId: input.entityId ?? "default",
|
|
298
|
+
...input.parentId === void 0 ? {} : { parentId: input.parentId }
|
|
299
|
+
});
|
|
300
|
+
/** Validate accounts and build the chart. Throws {@link LedgerValidationError}. */
|
|
301
|
+
const createChartOfAccounts = (inputs) => {
|
|
302
|
+
const accounts = /* @__PURE__ */ new Map();
|
|
303
|
+
for (const [index, input] of [...inputs].entries()) {
|
|
304
|
+
const account = toAccount(input, index);
|
|
305
|
+
if (accounts.has(account.id)) throw new LedgerValidationError("INVALID_ACCOUNT_HIERARCHY", `accounts[${index}].id`, input.id, "duplicate account id");
|
|
306
|
+
accounts.set(account.id, account);
|
|
307
|
+
}
|
|
308
|
+
checkHierarchy(accounts);
|
|
309
|
+
return new ChartOfAccounts(accounts);
|
|
310
|
+
};
|
|
311
|
+
// src/legs.ts
|
|
312
|
+
/** Resolve a leg's Account in the chart and emit its Posting; Currency comes from the Account. */
|
|
313
|
+
const legPosting = (leg, amount, chart, field) => {
|
|
314
|
+
const { accountId: id, dimensions } = typeof leg === "string" ? { accountId: leg } : leg;
|
|
315
|
+
const account = chart.get(accountId(id));
|
|
316
|
+
if (!account) throw new LedgerValidationError("UNKNOWN_ACCOUNT", typeof leg === "string" ? field : `${field}.accountId`, id, "is not in the chart of accounts");
|
|
317
|
+
return {
|
|
318
|
+
accountId: id,
|
|
319
|
+
amount,
|
|
320
|
+
currency: account.currency,
|
|
321
|
+
...dimensions ? { dimensions } : {}
|
|
322
|
+
};
|
|
323
|
+
};
|
|
324
|
+
/** A builder amount is a magnitude: the builder derives the sign, so a negative one is a bug. */
|
|
325
|
+
const magnitude = (amount, field) => {
|
|
326
|
+
if (amount < 0) throw new LedgerValidationError("NEGATIVE_AMOUNT", field, amount, "must not be negative");
|
|
327
|
+
return amount;
|
|
328
|
+
};
|
|
329
|
+
/** The Credit form of a magnitude; never produces negative zero. */
|
|
330
|
+
const credited = (amount) => 0 - amount;
|
|
331
|
+
/** Reject legs whose Accounts hold differing Currencies; the core has no FX. */
|
|
332
|
+
const assertSameCurrency = (postings, fields) => {
|
|
333
|
+
const first = postings[0]?.currency;
|
|
334
|
+
const index = postings.findIndex((p) => p.currency !== first);
|
|
335
|
+
if (index >= 0) throw new LedgerValidationError("CURRENCY_MISMATCH", fields[index] ?? "postings", postings[index]?.currency, `differs from ${first}; legs must share one currency`);
|
|
336
|
+
};
|
|
337
|
+
// src/compound.ts
|
|
338
|
+
const side = (legs, name, chart) => ({
|
|
339
|
+
postings: legs.map((leg, i) => {
|
|
340
|
+
const amount = magnitude(leg.amount, `${name}[${i}].amount`);
|
|
341
|
+
return legPosting(leg, name === "debit" ? amount : credited(amount), chart, `${name}[${i}]`);
|
|
342
|
+
}),
|
|
343
|
+
fields: legs.map((_, i) => `${name}[${i}]`)
|
|
344
|
+
});
|
|
345
|
+
/**
|
|
346
|
+
* Build a Compound Entry: many legs on either side, each with its own magnitude.
|
|
347
|
+
* The two sides must total the same amount.
|
|
348
|
+
*/
|
|
349
|
+
const compound = (input, chart) => {
|
|
350
|
+
const debit = side(input.debit, "debit", chart);
|
|
351
|
+
const credit = side(input.credit, "credit", chart);
|
|
352
|
+
const postings = [...debit.postings, ...credit.postings];
|
|
353
|
+
assertSameCurrency(postings, [...debit.fields, ...credit.fields]);
|
|
354
|
+
return validateTransaction({
|
|
355
|
+
id: input.id,
|
|
356
|
+
date: input.date,
|
|
357
|
+
postings
|
|
358
|
+
}, chart);
|
|
359
|
+
};
|
|
360
|
+
// src/money.ts
|
|
361
|
+
/**
|
|
362
|
+
* Convert a major-unit amount (`12.34`) to signed integer minor units (`1234`).
|
|
363
|
+
* Rounds half away from zero; `toPrecision` strips binary-float noise first.
|
|
364
|
+
*/
|
|
365
|
+
const toMinorUnits = (majorAmount, currency) => {
|
|
366
|
+
const digits = minorUnitDigits(parseCurrency(currency));
|
|
367
|
+
if (!Number.isFinite(majorAmount)) throw new LedgerValidationError("INVALID_AMOUNT", "majorAmount", majorAmount, "must be finite");
|
|
368
|
+
const raw = Math.abs(majorAmount) * 10 ** digits;
|
|
369
|
+
const scaled = raw < 0x38d7ea4c68000 ? Number(raw.toPrecision(15)) : raw;
|
|
370
|
+
return assertSafeInteger(Math.sign(majorAmount) * Math.round(scaled) + 0, "majorAmount");
|
|
371
|
+
};
|
|
372
|
+
/** Convert signed integer minor units (`1234`) to a major-unit amount (`12.34`). */
|
|
373
|
+
const fromMinorUnits = (minorUnits, currency) => {
|
|
374
|
+
const digits = minorUnitDigits(parseCurrency(currency));
|
|
375
|
+
return assertSafeInteger(minorUnits, "minorUnits") / 10 ** digits;
|
|
376
|
+
};
|
|
377
|
+
// src/totals.ts
|
|
378
|
+
/** Order totals by `entityId` then `currency`, by code unit. */
|
|
379
|
+
const compareTotals = (a, b) => byCodeUnit(a.entityId, b.entityId) || byCodeUnit(a.currency, b.currency);
|
|
380
|
+
/**
|
|
381
|
+
* Sum `sign * amount` per `(Entity, Currency)` with `BigInt`, checking each total against the safe
|
|
382
|
+
* integer range. One total per pair among the addends, sorted; a zero addend still yields its pair,
|
|
383
|
+
* which is how P&L seeds its totals. `fieldOf` gives the output path an overflow reports for a pair.
|
|
384
|
+
*/
|
|
385
|
+
const totalsOf = (addends, sign, fieldOf) => {
|
|
386
|
+
const slots = /* @__PURE__ */ new Map();
|
|
387
|
+
for (const { entityId, currency, amount } of addends) {
|
|
388
|
+
const key = JSON.stringify([entityId, currency]);
|
|
389
|
+
const slot = slots.get(key) ?? {
|
|
390
|
+
entityId,
|
|
391
|
+
currency,
|
|
392
|
+
sum: 0n
|
|
393
|
+
};
|
|
394
|
+
slot.sum += sign * BigInt(amount);
|
|
395
|
+
slots.set(key, slot);
|
|
396
|
+
}
|
|
397
|
+
return Object.freeze([...slots.values()].map(({ entityId, currency, sum }) => Object.freeze({
|
|
398
|
+
entityId,
|
|
399
|
+
currency,
|
|
400
|
+
amount: toSafeAmount(sum, fieldOf(`${entityId}/${currency}`))
|
|
401
|
+
})).sort(compareTotals));
|
|
402
|
+
};
|
|
403
|
+
// src/net-worth.ts
|
|
404
|
+
/**
|
|
405
|
+
* Assets minus Liabilities up to and including `asOf`, per `(Entity, Currency)`, positive when
|
|
406
|
+
* assets exceed liabilities. Rows stay raw signed; Equity is not part of it. Raw liabilities are
|
|
407
|
+
* negative, so the natural-sign total is the plain sum of the rows.
|
|
408
|
+
*/
|
|
409
|
+
const netWorth = (transactions, asOf, chart) => {
|
|
410
|
+
const counted = (account) => account.type === "asset" || account.type === "liability";
|
|
411
|
+
const rows = leafBalances(transactions, asOf, chart, (account) => counted(account) ? `netWorth.rows[${account.id}]` : `netWorth[${account.id}]`).filter((row) => counted(chart.get(row.accountId)));
|
|
412
|
+
return Object.freeze({
|
|
413
|
+
rows: Object.freeze(rows),
|
|
414
|
+
totals: totalsOf(rows, 1n, (pair) => `netWorth.totals[${pair}]`)
|
|
415
|
+
});
|
|
416
|
+
};
|
|
417
|
+
// src/opening-balance.ts
|
|
418
|
+
const assertEquity = (contra, chart) => {
|
|
419
|
+
const id = typeof contra === "string" ? contra : contra.accountId;
|
|
420
|
+
const type = chart.get(accountId(id))?.type;
|
|
421
|
+
if (type !== void 0 && type !== "equity") throw new LedgerValidationError("NON_EQUITY_CONTRA", typeof contra === "string" ? "contra" : "contra.accountId", id, `is ${type}; an opening balance is posted against an equity account`);
|
|
422
|
+
};
|
|
423
|
+
/**
|
|
424
|
+
* Build an Opening Balance: `amount` posts on `account`, the opposite Posting on the equity
|
|
425
|
+
* `contra`. `amount` is signed (an overdrawn Asset opens negative); direction is never inferred.
|
|
426
|
+
*/
|
|
427
|
+
const openingBalance = (input, chart) => {
|
|
428
|
+
const postings = [legPosting(input.account, input.amount, chart, "account"), legPosting(input.contra, credited(input.amount), chart, "contra")];
|
|
429
|
+
assertEquity(input.contra, chart);
|
|
430
|
+
assertSameCurrency(postings, ["account", "contra"]);
|
|
431
|
+
return validateTransaction({
|
|
432
|
+
id: input.id,
|
|
433
|
+
date: input.date,
|
|
434
|
+
postings
|
|
435
|
+
}, chart);
|
|
436
|
+
};
|
|
437
|
+
// src/dimensions.ts
|
|
438
|
+
/** A Posting's value for a Dimension key, or null when it lacks the key. Inherited keys never count. */
|
|
439
|
+
const dimensionOf = (posting, key) => posting.dimensions !== void 0 && Object.hasOwn(posting.dimensions, key) ? posting.dimensions[key] : null;
|
|
440
|
+
/** True when the Posting carries every `where` entry with exactly that value. */
|
|
441
|
+
const matchesAll = (posting, where) => Object.entries(where).every(([key, value]) => dimensionOf(posting, key) === value);
|
|
442
|
+
// src/period.ts
|
|
443
|
+
/** Parse an inclusive period, throwing `INVALID_DATE` for a bad end and `INVALID_PERIOD` if inverted. */
|
|
444
|
+
const parsePeriod = (input) => {
|
|
445
|
+
const from = input.from === void 0 ? void 0 : parseISODate(input.from, "options.from");
|
|
446
|
+
const to = parseISODate(input.to, "options.to");
|
|
447
|
+
if (from !== void 0 && from > to) throw new LedgerValidationError("INVALID_PERIOD", "options.from", input.from, `is after to (${to})`);
|
|
448
|
+
return from === void 0 ? { to } : {
|
|
449
|
+
from,
|
|
450
|
+
to
|
|
451
|
+
};
|
|
452
|
+
};
|
|
453
|
+
/** True when `date` falls on or between the period's ends. */
|
|
454
|
+
const inPeriod = (period, date) => date <= period.to && (period.from === void 0 || date >= period.from);
|
|
455
|
+
// src/profit-and-loss-rows.ts
|
|
456
|
+
const sumByAccount = (entries) => {
|
|
457
|
+
const sums = /* @__PURE__ */ new Map();
|
|
458
|
+
for (const { account, posting } of entries) sums.set(account.id, (sums.get(account.id) ?? 0n) + BigInt(posting.amount));
|
|
459
|
+
return sums;
|
|
460
|
+
};
|
|
461
|
+
/** One row per Income and Expense Leaf Account, zeros included. */
|
|
462
|
+
const plainRows = (accounts, entries) => {
|
|
463
|
+
const sums = sumByAccount(entries);
|
|
464
|
+
return accounts.map((a) => Object.freeze({
|
|
465
|
+
entityId: a.entityId,
|
|
466
|
+
accountId: a.id,
|
|
467
|
+
currency: a.currency,
|
|
468
|
+
amount: toSafeAmount(sums.get(a.id) ?? 0n, `profitAndLoss.rows[${a.id}]`)
|
|
469
|
+
})).sort(compareRows);
|
|
470
|
+
};
|
|
471
|
+
const compareGrouped = (a, b) => compareRows(a, b) || (a.dimensionValue === b.dimensionValue ? 0 : a.dimensionValue === null ? -1 : b.dimensionValue === null ? 1 : byCodeUnit(a.dimensionValue, b.dimensionValue));
|
|
472
|
+
/** One row per `(Account, value of the `groupBy` Dimension)` that has Postings. */
|
|
473
|
+
const groupedRows = (entries, groupBy) => {
|
|
474
|
+
const groups = /* @__PURE__ */ new Map();
|
|
475
|
+
for (const entry of entries) {
|
|
476
|
+
const value = dimensionOf(entry.posting, groupBy);
|
|
477
|
+
const key = JSON.stringify([entry.account.id, value]);
|
|
478
|
+
const sum = (groups.get(key)?.sum ?? 0n) + BigInt(entry.posting.amount);
|
|
479
|
+
groups.set(key, {
|
|
480
|
+
entry,
|
|
481
|
+
value,
|
|
482
|
+
sum
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
return [...groups.values()].map(({ entry: { account }, value, sum }) => Object.freeze({
|
|
486
|
+
entityId: account.entityId,
|
|
487
|
+
accountId: account.id,
|
|
488
|
+
currency: account.currency,
|
|
489
|
+
dimensionValue: value,
|
|
490
|
+
amount: toSafeAmount(sum, `profitAndLoss.rows[${account.id}]`)
|
|
491
|
+
})).sort(compareGrouped);
|
|
492
|
+
};
|
|
493
|
+
// src/profit-and-loss.ts
|
|
494
|
+
function profitAndLoss(transactions, options, chart) {
|
|
495
|
+
const period = parsePeriod(options);
|
|
496
|
+
const entries = scanLedger(transactions, chart).filter(({ account, posting, date }) => (account.type === "income" || account.type === "expense") && inPeriod(period, date) && matchesAll(posting, options.where ?? {}));
|
|
497
|
+
const { groupBy } = options;
|
|
498
|
+
const leaves = [...chart.values()].filter((a) => chart.isLeaf(a.id) && (a.type === "income" || a.type === "expense"));
|
|
499
|
+
const rows = groupBy === void 0 ? plainRows(leaves, entries) : groupedRows(entries, groupBy);
|
|
500
|
+
const pairs = leaves.map(({ entityId, currency }) => ({
|
|
501
|
+
entityId,
|
|
502
|
+
currency,
|
|
503
|
+
amount: 0
|
|
504
|
+
}));
|
|
505
|
+
return Object.freeze({
|
|
506
|
+
rows: Object.freeze(rows),
|
|
507
|
+
totals: totalsOf([...pairs, ...rows], -1n, (pair) => `profitAndLoss.totals[${pair}]`)
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
// src/simple.ts
|
|
511
|
+
/** Build a Simple Entry: `+amount` on `debit`, `-amount` on `credit`. `amount` is a magnitude. */
|
|
512
|
+
const simple = (input, chart) => {
|
|
513
|
+
const amount = magnitude(input.amount, "amount");
|
|
514
|
+
const postings = [legPosting(input.debit, amount, chart, "debit"), legPosting(input.credit, credited(amount), chart, "credit")];
|
|
515
|
+
assertSameCurrency(postings, ["debit", "credit"]);
|
|
516
|
+
return validateTransaction({
|
|
517
|
+
id: input.id,
|
|
518
|
+
date: input.date,
|
|
519
|
+
postings
|
|
520
|
+
}, chart);
|
|
521
|
+
};
|
|
522
|
+
// src/trial-balance.ts
|
|
523
|
+
/**
|
|
524
|
+
* Every Leaf Account's net Balance at `asOf`, zeros included: positive in `debit`, negative in
|
|
525
|
+
* `credit` as a magnitude (the one exception to raw signed rows).
|
|
526
|
+
*/
|
|
527
|
+
const trialBalance = (transactions, asOf, chart) => {
|
|
528
|
+
const rows = leafBalances(transactions, asOf, chart, (a) => `trialBalance.rows[${a.id}]`).map(({ entityId, accountId, currency, amount }) => Object.freeze({
|
|
529
|
+
entityId,
|
|
530
|
+
accountId,
|
|
531
|
+
currency,
|
|
532
|
+
debit: Math.max(amount, 0),
|
|
533
|
+
credit: Math.max(-amount, 0)
|
|
534
|
+
}));
|
|
535
|
+
const debits = totalsOf(rows.map((r) => ({
|
|
536
|
+
...r,
|
|
537
|
+
amount: r.debit
|
|
538
|
+
})), 1n, (pair) => `trialBalance.totals[${pair}].debit`);
|
|
539
|
+
const credits = new Map(totalsOf(rows.map((r) => ({
|
|
540
|
+
...r,
|
|
541
|
+
amount: r.credit
|
|
542
|
+
})), 1n, (pair) => `trialBalance.totals[${pair}].credit`).map((t) => [JSON.stringify([t.entityId, t.currency]), t.amount]));
|
|
543
|
+
return Object.freeze({
|
|
544
|
+
rows: Object.freeze(rows),
|
|
545
|
+
totals: Object.freeze(debits.map(({ entityId, currency, amount }) => Object.freeze({
|
|
546
|
+
entityId,
|
|
547
|
+
currency,
|
|
548
|
+
debit: amount,
|
|
549
|
+
credit: credits.get(JSON.stringify([entityId, currency]))
|
|
550
|
+
})))
|
|
551
|
+
});
|
|
552
|
+
};
|
|
553
|
+
export { LedgerValidationError, accountId, amend, balance, balances, compound, createChartOfAccounts, fromMinorUnits, netWorth, normalBalance, openingBalance, parseCurrency, parseISODate, profitAndLoss, reverse, simple, toMinorUnits, transactionId, trialBalance, validateTransaction };
|
|
554
|
+
|
|
555
|
+
//# sourceMappingURL=index.mjs.map
|