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