@book.dev/sdk 3.5.0 → 3.6.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/dist/backup.d.ts +75 -1
- package/dist/backup.js +8 -1
- package/dist/backup.js.map +1 -1
- package/dist/client.d.ts +134 -2
- package/dist/client.js +171 -0
- package/dist/client.js.map +1 -1
- package/dist/csv.d.ts +49 -0
- package/dist/csv.js +116 -0
- package/dist/csv.js.map +1 -0
- package/dist/importAssets.d.ts +10 -0
- package/dist/importAssets.js +17 -0
- package/dist/importAssets.js.map +1 -1
- package/dist/index.d.ts +10 -4
- package/dist/index.js +9 -3
- package/dist/index.js.map +1 -1
- package/dist/ledger.d.ts +772 -0
- package/dist/ledger.js +493 -0
- package/dist/ledger.js.map +1 -0
- package/dist/ledgerBeancount.d.ts +117 -0
- package/dist/ledgerBeancount.js +309 -0
- package/dist/ledgerBeancount.js.map +1 -0
- package/dist/ledgerBeancountFixture.d.ts +61 -0
- package/dist/ledgerBeancountFixture.js +459 -0
- package/dist/ledgerBeancountFixture.js.map +1 -0
- package/dist/ledgerCsv.d.ts +76 -0
- package/dist/ledgerCsv.js +197 -0
- package/dist/ledgerCsv.js.map +1 -0
- package/dist/money.d.ts +179 -0
- package/dist/money.js +283 -0
- package/dist/money.js.map +1 -0
- package/dist/notionImport.d.ts +5 -3
- package/dist/notionImport.js +6 -56
- package/dist/notionImport.js.map +1 -1
- package/dist/plugins.d.ts +24 -0
- package/dist/plugins.js +25 -0
- package/dist/plugins.js.map +1 -1
- package/dist/provenance.d.ts +17 -0
- package/dist/provenance.js.map +1 -1
- package/dist/routes.d.ts +121 -0
- package/dist/routes.js +122 -0
- package/dist/routes.js.map +1 -1
- package/dist/types.d.ts +8 -0
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical postings CSV (LGR-7): the ledger's insurance export.
|
|
3
|
+
*
|
|
4
|
+
* A book must always be able to LEAVE the app in a canonical, tool-agnostic
|
|
5
|
+
* form. This module is a PURE function from ledger entities to CSV bytes —
|
|
6
|
+
* no I/O, no clock, no locale — with the hard guarantee that the SAME DATA
|
|
7
|
+
* always produces IDENTICAL BYTES:
|
|
8
|
+
*
|
|
9
|
+
* - one row per posting, long form (a transaction with N postings emits N rows);
|
|
10
|
+
* - deterministic ordering: transactions by (entry_no ascending, drafts —
|
|
11
|
+
* which have no entry number — after all numbered entries by createdAt then
|
|
12
|
+
* id), postings in creation order within their transaction;
|
|
13
|
+
* - RFC-4180 quoting (fields containing `"` `,` CR or LF are quoted, inner
|
|
14
|
+
* quotes doubled), LF line endings, UTF-8 with NO BOM, trailing newline;
|
|
15
|
+
* - amounts appear twice: `amount_minor` (the raw signed integer of minor
|
|
16
|
+
* units — the authoritative value) and `amount_formatted` (the locale-pinned
|
|
17
|
+
* {@link formatAmount} display string, e.g. `$1,234.56`);
|
|
18
|
+
* - output depends only on FIELD VALUES, never on object key order.
|
|
19
|
+
*/
|
|
20
|
+
import { formatAmount } from './money';
|
|
21
|
+
/**
|
|
22
|
+
* The canonical column set, in order.
|
|
23
|
+
*
|
|
24
|
+
* | column | content |
|
|
25
|
+
* |-------------------|--------------------------------------------------------------------|
|
|
26
|
+
* | entry_no | server-assigned monotonic entry number; empty for drafts |
|
|
27
|
+
* | transaction_id | transaction row id (UUID) |
|
|
28
|
+
* | date | transaction ISO date (`YYYY-MM-DD`) |
|
|
29
|
+
* | description | transaction description (free text — see the apostrophe note) |
|
|
30
|
+
* | state | `draft` / `posted` / `void` |
|
|
31
|
+
* | posting_id | posting row id (UUID) |
|
|
32
|
+
* | account_name | hierarchical account name (`Assets:Bank:Checking`) |
|
|
33
|
+
* | account_type | `asset` / `liability` / `equity` / `revenue` / `expense` |
|
|
34
|
+
* | amount_minor | signed integer minor units (authoritative; ALWAYS verbatim) |
|
|
35
|
+
* | amount_formatted | {@link formatAmount} display string; EMPTY if the amount is corrupt |
|
|
36
|
+
* | currency | the account's ISO-4217-shaped code |
|
|
37
|
+
* | cleared | `pending` / `cleared` / `reconciled` |
|
|
38
|
+
* | reconciliation_id | reconciliation row id, empty when none |
|
|
39
|
+
* | reverses | id of the transaction this entry reverses, empty when none |
|
|
40
|
+
* | posted_at | ISO timestamp stamped at post time, empty for drafts |
|
|
41
|
+
* | posted_by | principal subject that posted, empty for drafts |
|
|
42
|
+
* | evidence_sha256s | semicolon-joined SHA-256 hashes of attached evidence |
|
|
43
|
+
* | memo | the POSTING's own free-text note (LGR-16); empty when none |
|
|
44
|
+
*
|
|
45
|
+
* **Adding a column.** `memo` was APPENDED (LGR-16), never inserted: this list
|
|
46
|
+
* is a documented contract, and a consumer that reads columns POSITIONALLY —
|
|
47
|
+
* the spreadsheet formula somebody wrote against `I2`, the awk one-liner in a
|
|
48
|
+
* runbook — keeps working across the change iff existing columns do not move.
|
|
49
|
+
* Every future column belongs at the end for the same reason.
|
|
50
|
+
*
|
|
51
|
+
* **Free-text columns and the leading apostrophe.** `description`, `memo`,
|
|
52
|
+
* `account_name` and `posted_by` carry text a ledger writer authored, so a
|
|
53
|
+
* value that would open as a spreadsheet FORMULA (leading `=` `+` `-` `@`, tab
|
|
54
|
+
* or CR) is emitted with a single leading apostrophe — the universal
|
|
55
|
+
* "treat this as text" marker. A value that ALREADY begins with `'` is prefixed
|
|
56
|
+
* too, which is what makes the escape INJECTIVE: a RE-IMPORTER strips ONE
|
|
57
|
+
* leading `'` from exactly these four columns and recovers the original value
|
|
58
|
+
* exactly, whether it was a formula lead-in or a genuine apostrophe. No other
|
|
59
|
+
* column is ever prefixed (notably `amount_minor`, whose negatives legitimately
|
|
60
|
+
* start with `-`), so machine columns round-trip byte-exactly.
|
|
61
|
+
*/
|
|
62
|
+
export const LEDGER_CSV_COLUMNS = [
|
|
63
|
+
'entry_no',
|
|
64
|
+
'transaction_id',
|
|
65
|
+
'date',
|
|
66
|
+
'description',
|
|
67
|
+
'state',
|
|
68
|
+
'posting_id',
|
|
69
|
+
'account_name',
|
|
70
|
+
'account_type',
|
|
71
|
+
'amount_minor',
|
|
72
|
+
'amount_formatted',
|
|
73
|
+
'currency',
|
|
74
|
+
'cleared',
|
|
75
|
+
'reconciliation_id',
|
|
76
|
+
'reverses',
|
|
77
|
+
'posted_at',
|
|
78
|
+
'posted_by',
|
|
79
|
+
'evidence_sha256s',
|
|
80
|
+
'memo',
|
|
81
|
+
];
|
|
82
|
+
/**
|
|
83
|
+
* The FREE-TEXT columns — the ones whose content a ledger writer authors. Only
|
|
84
|
+
* these get spreadsheet-formula neutralization ({@link csvText}); the machine
|
|
85
|
+
* columns (ids, enums, dates, and above all `amount_minor`) are emitted
|
|
86
|
+
* verbatim so the file stays exactly parseable as data.
|
|
87
|
+
*/
|
|
88
|
+
const TEXT_COLUMNS = new Set(['description', 'memo', 'account_name', 'posted_by']);
|
|
89
|
+
/**
|
|
90
|
+
* Leading characters a spreadsheet treats as the start of a FORMULA rather than
|
|
91
|
+
* text (`=SUM(...)`, `+`, `-`, `@`, and the tab/CR lead-ins some parsers strip
|
|
92
|
+
* before dispatching). See {@link csvText}.
|
|
93
|
+
*/
|
|
94
|
+
const FORMULA_LEAD = /^[=+\-@\t\r]/;
|
|
95
|
+
/** RFC-4180: quote a field iff it contains a quote, comma, CR, or LF; double inner quotes. */
|
|
96
|
+
function csvField(value) {
|
|
97
|
+
return /[",\r\n]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* A free-text field, neutralized against CSV formula injection: a value that
|
|
101
|
+
* would otherwise open as a spreadsheet FORMULA is prefixed with a single
|
|
102
|
+
* apostrophe (the universal "treat as text" marker) before RFC-4180 quoting.
|
|
103
|
+
* Scoped deliberately to {@link TEXT_COLUMNS} — a blanket prefix would corrupt
|
|
104
|
+
* `amount_minor` (every negative amount starts with `-`) and break the
|
|
105
|
+
* byte-canonical contract for machine columns.
|
|
106
|
+
*/
|
|
107
|
+
function csvText(value) {
|
|
108
|
+
// Prefix a value that ALREADY starts with `'` too, so the escape stays
|
|
109
|
+
// INJECTIVE: without it `=SUM(1)` and `'=SUM(1)` would both emit `'=SUM(1)`,
|
|
110
|
+
// and the documented "strip ONE leading apostrophe" re-import rule would
|
|
111
|
+
// silently corrupt every value that legitimately begins with an apostrophe.
|
|
112
|
+
const needsPrefix = FORMULA_LEAD.test(value) || value.startsWith('\'');
|
|
113
|
+
return csvField(needsPrefix ? `'${value}` : value);
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* The display amount, or `''` when the stored amount is too corrupt to format.
|
|
117
|
+
*
|
|
118
|
+
* The insurance export must NEVER throw on the very corruption it exists to
|
|
119
|
+
* survive: a raw-mutated `amount_minor` (a float, a string, an unsafe integer)
|
|
120
|
+
* makes `formatAmount` reject, which would fail the whole export at the exact
|
|
121
|
+
* moment the book most needs to leave the building. The raw `amount_minor`
|
|
122
|
+
* column still carries the stored value verbatim, and the verifier reports the
|
|
123
|
+
* corruption as `invalid-amount`.
|
|
124
|
+
*/
|
|
125
|
+
function money(minor, currency) {
|
|
126
|
+
try {
|
|
127
|
+
return currency === undefined ? formatAmount(minor) : formatAmount(minor, { currency });
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return '';
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/** Locale-independent, byte-deterministic string compare (never `localeCompare`). */
|
|
134
|
+
function byteCompare(a, b) {
|
|
135
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Build the canonical postings CSV from the full account list and the full
|
|
139
|
+
* transaction list (with postings). Pure and deterministic — see module doc.
|
|
140
|
+
*
|
|
141
|
+
* Ordering: numbered entries (posted/void) ascend by `entryNo`; drafts (no
|
|
142
|
+
* entry number) follow, ordered by (`createdAt`, `id`). Postings keep the
|
|
143
|
+
* creation order their transaction carries. A posting whose account does not
|
|
144
|
+
* resolve (should never happen — the verifier flags it) emits empty
|
|
145
|
+
* account_name/account_type/currency and a currency-less formatted amount
|
|
146
|
+
* rather than throwing, and an unformattable (raw-corrupted) amount emits an
|
|
147
|
+
* empty `amount_formatted` while `amount_minor` keeps the stored value
|
|
148
|
+
* verbatim — the insurance export always leaves the building.
|
|
149
|
+
*/
|
|
150
|
+
export function buildLedgerPostingsCsv(accounts, transactions) {
|
|
151
|
+
const accountById = new Map();
|
|
152
|
+
for (const account of accounts)
|
|
153
|
+
accountById.set(account.id, account);
|
|
154
|
+
const ordered = [...transactions].sort((a, b) => {
|
|
155
|
+
const an = a.entryNo ?? Number.POSITIVE_INFINITY;
|
|
156
|
+
const bn = b.entryNo ?? Number.POSITIVE_INFINITY;
|
|
157
|
+
if (an !== bn)
|
|
158
|
+
return an < bn ? -1 : 1;
|
|
159
|
+
return byteCompare(a.createdAt, b.createdAt) || byteCompare(a.id, b.id);
|
|
160
|
+
});
|
|
161
|
+
const lines = [LEDGER_CSV_COLUMNS.join(',')];
|
|
162
|
+
for (const tx of ordered) {
|
|
163
|
+
// Defensive per ELEMENT, not just per array: raw storage can hold a null
|
|
164
|
+
// (or otherwise shapeless) evidence entry, and the insurance export must
|
|
165
|
+
// never throw on the corruption it exists to survive.
|
|
166
|
+
const evidence = tx.evidence.map((e) => String(e?.sha256 ?? '')).join(';');
|
|
167
|
+
for (const posting of tx.postings) {
|
|
168
|
+
const account = accountById.get(posting.accountId);
|
|
169
|
+
const fields = [
|
|
170
|
+
tx.entryNo == null ? '' : String(tx.entryNo),
|
|
171
|
+
tx.id,
|
|
172
|
+
tx.date,
|
|
173
|
+
tx.description,
|
|
174
|
+
tx.state,
|
|
175
|
+
posting.id,
|
|
176
|
+
account?.name ?? '',
|
|
177
|
+
account?.type ?? '',
|
|
178
|
+
String(posting.amountMinor),
|
|
179
|
+
money(posting.amountMinor, account?.currency),
|
|
180
|
+
account?.currency ?? '',
|
|
181
|
+
posting.cleared,
|
|
182
|
+
posting.reconciliationId ?? '',
|
|
183
|
+
tx.reverses ?? '',
|
|
184
|
+
tx.postedAt ?? '',
|
|
185
|
+
tx.postedBy ?? '',
|
|
186
|
+
evidence,
|
|
187
|
+
// Defensive `?? ''`, not just for `null`: a raw-mutated row can hold a
|
|
188
|
+
// non-string here, and the insurance export must emit SOMETHING rather
|
|
189
|
+
// than `undefined` or a throw.
|
|
190
|
+
typeof posting.memo === 'string' ? posting.memo : '',
|
|
191
|
+
];
|
|
192
|
+
lines.push(fields.map((value, i) => (TEXT_COLUMNS.has(LEDGER_CSV_COLUMNS[i]) ? csvText(value) : csvField(value))).join(','));
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return `${lines.join('\n')}\n`;
|
|
196
|
+
}
|
|
197
|
+
//# sourceMappingURL=ledgerCsv.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ledgerCsv.js","sourceRoot":"","sources":["../src/ledgerCsv.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,EAAC,YAAY,EAAC,MAAM,SAAS,CAAC;AAGrC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AACH,MAAM,CAAC,MAAM,kBAAkB,GAAG;IAChC,UAAU;IACV,gBAAgB;IAChB,MAAM;IACN,aAAa;IACb,OAAO;IACP,YAAY;IACZ,cAAc;IACd,cAAc;IACd,cAAc;IACd,kBAAkB;IAClB,UAAU;IACV,SAAS;IACT,mBAAmB;IACnB,UAAU;IACV,WAAW;IACX,WAAW;IACX,kBAAkB;IAClB,MAAM;CACE,CAAC;AAEX;;;;;GAKG;AACH,MAAM,YAAY,GAAwB,IAAI,GAAG,CAAC,CAAC,aAAa,EAAE,MAAM,EAAE,cAAc,EAAE,WAAW,CAAC,CAAC,CAAC;AAExG;;;;GAIG;AACH,MAAM,YAAY,GAAG,cAAc,CAAC;AAEpC,8FAA8F;AAC9F,SAAS,QAAQ,CAAC,KAAa;IAC7B,OAAO,UAAU,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,KAAK,CAAC,OAAO,CAAC,IAAI,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC;AAC3E,CAAC;AAED;;;;;;;GAOG;AACH,SAAS,OAAO,CAAC,KAAa;IAC5B,uEAAuE;IACvE,6EAA6E;IAC7E,yEAAyE;IACzE,4EAA4E;IAC5E,MAAM,WAAW,GAAG,YAAY,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,KAAK,CAAC,UAAU,CAAC,IAAI,CAAC,CAAC;IACvE,OAAO,QAAQ,CAAC,WAAW,CAAC,CAAC,CAAC,IAAI,KAAK,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC;AACrD,CAAC;AAED;;;;;;;;;GASG;AACH,SAAS,KAAK,CAAC,KAAa,EAAE,QAAiB;IAC7C,IAAI,CAAC;QACH,OAAO,QAAQ,KAAK,SAAS,CAAC,CAAC,CAAC,YAAY,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,YAAY,CAAC,KAAK,EAAE,EAAC,QAAQ,EAAC,CAAC,CAAC;IACxF,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,EAAE,CAAC;IACZ,CAAC;AACH,CAAC;AAED,qFAAqF;AACrF,SAAS,WAAW,CAAC,CAAS,EAAE,CAAS;IACvC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;AACpC,CAAC;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,sBAAsB,CACpC,QAAkC,EAClC,YAA0C;IAE1C,MAAM,WAAW,GAAG,IAAI,GAAG,EAAyB,CAAC;IACrD,KAAK,MAAM,OAAO,IAAI,QAAQ;QAAE,WAAW,CAAC,GAAG,CAAC,OAAO,CAAC,EAAE,EAAE,OAAO,CAAC,CAAC;IAErE,MAAM,OAAO,GAAG,CAAC,GAAG,YAAY,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE;QAC9C,MAAM,EAAE,GAAG,CAAC,CAAC,OAAO,IAAI,MAAM,CAAC,iBAAiB,CAAC;QACjD,MAAM,EAAE,GAAG,CAAC,CAAC,OAAO,IAAI,MAAM,CAAC,iBAAiB,CAAC;QACjD,IAAI,EAAE,KAAK,EAAE;YAAE,OAAO,EAAE,GAAG,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;QACvC,OAAO,WAAW,CAAC,CAAC,CAAC,SAAS,EAAE,CAAC,CAAC,SAAS,CAAC,IAAI,WAAW,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,EAAE,CAAC,CAAC;IAC1E,CAAC,CAAC,CAAC;IAEH,MAAM,KAAK,GAAa,CAAC,kBAAkB,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC;IACvD,KAAK,MAAM,EAAE,IAAI,OAAO,EAAE,CAAC;QACzB,yEAAyE;QACzE,yEAAyE;QACzE,sDAAsD;QACtD,MAAM,QAAQ,GAAG,EAAE,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,MAAM,CAAE,CAA+B,EAAE,MAAM,IAAI,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;QAC1G,KAAK,MAAM,OAAO,IAAI,EAAE,CAAC,QAAQ,EAAE,CAAC;YAClC,MAAM,OAAO,GAAG,WAAW,CAAC,GAAG,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC;YACnD,MAAM,MAAM,GAAa;gBACvB,EAAE,CAAC,OAAO,IAAI,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,MAAM,CAAC,EAAE,CAAC,OAAO,CAAC;gBAC5C,EAAE,CAAC,EAAE;gBACL,EAAE,CAAC,IAAI;gBACP,EAAE,CAAC,WAAW;gBACd,EAAE,CAAC,KAAK;gBACR,OAAO,CAAC,EAAE;gBACV,OAAO,EAAE,IAAI,IAAI,EAAE;gBACnB,OAAO,EAAE,IAAI,IAAI,EAAE;gBACnB,MAAM,CAAC,OAAO,CAAC,WAAW,CAAC;gBAC3B,KAAK,CAAC,OAAO,CAAC,WAAW,EAAE,OAAO,EAAE,QAAQ,CAAC;gBAC7C,OAAO,EAAE,QAAQ,IAAI,EAAE;gBACvB,OAAO,CAAC,OAAO;gBACf,OAAO,CAAC,gBAAgB,IAAI,EAAE;gBAC9B,EAAE,CAAC,QAAQ,IAAI,EAAE;gBACjB,EAAE,CAAC,QAAQ,IAAI,EAAE;gBACjB,EAAE,CAAC,QAAQ,IAAI,EAAE;gBACjB,QAAQ;gBACR,uEAAuE;gBACvE,uEAAuE;gBACvE,+BAA+B;gBAC/B,OAAO,OAAO,CAAC,IAAI,KAAK,QAAQ,CAAC,CAAC,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE;aACrD,CAAC;YACF,KAAK,CAAC,IAAI,CACR,MAAM,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,YAAY,CAAC,GAAG,CAAC,kBAAkB,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CACjH,CAAC;QACJ,CAAC;IACH,CAAC;IACD,OAAO,GAAG,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC;AACjC,CAAC"}
|
package/dist/money.d.ts
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Money core (LGR-2): signed integer minor-unit amounts for the ledger.
|
|
3
|
+
*
|
|
4
|
+
* INVARIANT — amounts are SIGNED INTEGER MINOR UNITS (cents) everywhere:
|
|
5
|
+
* storage, arithmetic, wire, and the boundaries of this API. No floats ever
|
|
6
|
+
* cross this API: every function throws a typed {@link MoneyError} subclass on
|
|
7
|
+
* non-integer or out-of-range numbers instead of rounding, truncating, or
|
|
8
|
+
* returning `NaN`.
|
|
9
|
+
*
|
|
10
|
+
* Bounds: amounts must satisfy `Number.isSafeInteger`, i.e. lie within
|
|
11
|
+
* ±(2^53 − 1) minor units (`±9,007,199,254,740,991` ≈ ±$90 trillion at 2
|
|
12
|
+
* decimal places). Anything beyond is rejected with {@link MoneyRangeError}.
|
|
13
|
+
* Internally, parsing and formatting route digit math through `BigInt` so the
|
|
14
|
+
* bound check itself is exact.
|
|
15
|
+
*
|
|
16
|
+
* Scale (v1): a fixed 2-decimal minor-unit scale (1 major unit = 100 minor
|
|
17
|
+
* units) for ALL currencies. Zero-decimal currencies (JPY-style) are
|
|
18
|
+
* v1-unsupported as a scale: `formatAmount(minor, {currency: 'JPY'})` still
|
|
19
|
+
* renders `minor / 100` with 2 decimals. Per-currency exponents are a later
|
|
20
|
+
* ledger task.
|
|
21
|
+
*/
|
|
22
|
+
/** Discriminant for {@link MoneyError} subclasses. */
|
|
23
|
+
export type MoneyErrorCode = 'parse' | 'range' | 'currency';
|
|
24
|
+
/**
|
|
25
|
+
* Base class for all money errors. Callers can `instanceof`-match the
|
|
26
|
+
* subclasses ({@link MoneyParseError}, {@link MoneyRangeError},
|
|
27
|
+
* {@link MoneyCurrencyError}) or switch on {@link MoneyError.code}.
|
|
28
|
+
*/
|
|
29
|
+
export declare class MoneyError extends Error {
|
|
30
|
+
/** Machine-readable error category. */
|
|
31
|
+
readonly code: MoneyErrorCode;
|
|
32
|
+
constructor(code: MoneyErrorCode, message: string);
|
|
33
|
+
}
|
|
34
|
+
/** Input text could not be parsed as a money amount (malformed, ambiguous, empty…). */
|
|
35
|
+
export declare class MoneyParseError extends MoneyError {
|
|
36
|
+
constructor(message: string);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* A numeric amount is not an integer or exceeds the safe bound of
|
|
40
|
+
* ±(2^53 − 1) minor units (non-integer input, unsafe magnitude, or overflow
|
|
41
|
+
* during arithmetic).
|
|
42
|
+
*/
|
|
43
|
+
export declare class MoneyRangeError extends MoneyError {
|
|
44
|
+
constructor(message: string);
|
|
45
|
+
}
|
|
46
|
+
/** A currency code is malformed, or amounts mix currencies where one is required. */
|
|
47
|
+
export declare class MoneyCurrencyError extends MoneyError {
|
|
48
|
+
constructor(message: string);
|
|
49
|
+
}
|
|
50
|
+
/** Largest representable amount: 2^53 − 1 minor units (≈ $90,071,992,547,409.91). */
|
|
51
|
+
export declare const MAX_AMOUNT_MINOR: number;
|
|
52
|
+
/** Smallest representable amount: −(2^53 − 1) minor units. */
|
|
53
|
+
export declare const MIN_AMOUNT_MINOR: number;
|
|
54
|
+
/**
|
|
55
|
+
* True iff `n` is a valid minor-unit amount: a `number` that is a safe integer
|
|
56
|
+
* (`Number.isSafeInteger`), i.e. within ±(2^53 − 1). Narrowing type guard.
|
|
57
|
+
*/
|
|
58
|
+
export declare function isValidMinor(n: unknown): n is number;
|
|
59
|
+
/** Options for {@link parseAmount}. */
|
|
60
|
+
export interface ParseAmountOptions {
|
|
61
|
+
/** Accept accounting-style parentheses for negatives, e.g. `(12.30)`. Default `true`. */
|
|
62
|
+
allowParens?: boolean;
|
|
63
|
+
/** Accept a leading currency symbol (`$ € £ ¥ ₹`), e.g. `$1,234.56`. Default `true`. */
|
|
64
|
+
allowCurrencySymbol?: boolean;
|
|
65
|
+
/**
|
|
66
|
+
* What DECIMAL-POINT-FREE input means (`"1234"`, `"+1,000"`, `"0"`):
|
|
67
|
+
*
|
|
68
|
+
* - `'major'` (default, and the historical behaviour): whole MAJOR units —
|
|
69
|
+
* `"1234"` → `123400`.
|
|
70
|
+
* - `'reject'`: throw {@link MoneyParseError}. For MACHINE input whose scale
|
|
71
|
+
* is not established by the data itself.
|
|
72
|
+
*
|
|
73
|
+
* WHY THIS EXISTS. For a human typing into a form, "1234" unambiguously means
|
|
74
|
+
* 1234 dollars, and `'major'` is right. For a BANK or PROCESSOR export it is
|
|
75
|
+
* not: Stripe-style feeds denominate in MINOR units ("amount in cents"), where
|
|
76
|
+
* the same bare integer means 1/100th as much. Defaulting silently in that
|
|
77
|
+
* context is the single worst failure this module exists to prevent — a
|
|
78
|
+
* 100× import error that balances perfectly and looks plausible. So an
|
|
79
|
+
* importer must DECIDE (from profile detection, or by asking) and pass an
|
|
80
|
+
* explicit value; `'reject'` is how it makes an unestablished scale fail
|
|
81
|
+
* LOUDLY at the parse instead of quietly at the wrong magnitude.
|
|
82
|
+
*
|
|
83
|
+
* `'reject'` keys off the DECIMAL POINT, not the value: `".5"` and `"12.30"`
|
|
84
|
+
* pass, `"0"` and `"1,000"` do not.
|
|
85
|
+
*/
|
|
86
|
+
bareDigits?: 'major' | 'reject';
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Parse human-typed money input into signed integer minor units (cents).
|
|
90
|
+
*
|
|
91
|
+
* Rules:
|
|
92
|
+
* - Input is in MAJOR units; input without a decimal point is whole major
|
|
93
|
+
* units: `"1234"` → `123400`, `"1,234.56"` → `123456`, `".5"` → `50`.
|
|
94
|
+
* Machine input whose scale is not established by the data can opt out of
|
|
95
|
+
* that assumption with `bareDigits: 'reject'` (see
|
|
96
|
+
* {@link ParseAmountOptions.bareDigits}) — the default is unchanged.
|
|
97
|
+
* - Negatives: leading sign (`"-12.30"`) or accounting parentheses
|
|
98
|
+
* (`"(12.30)"`) → `-1230`. Combining both is rejected as ambiguous.
|
|
99
|
+
* - A single leading currency symbol (`$ € £ ¥ ₹`) is tolerated, before or
|
|
100
|
+
* after the sign: `"$-1,234.56"` and `"-$1,234.56"` both → `-123456`.
|
|
101
|
+
* - Thousands grouping uses commas in strict 3-digit groups; the decimal
|
|
102
|
+
* separator is `.` with at most 2 digits. (Locale-specific separators —
|
|
103
|
+
* `1.234,56`, space grouping — are v1-unsupported and rejected.)
|
|
104
|
+
*
|
|
105
|
+
* Rejections (typed errors, never `NaN`):
|
|
106
|
+
* - {@link MoneyParseError}: empty/sign-only input, non-numeric text, more
|
|
107
|
+
* than 2 decimals, ambiguous or misplaced separators (`"1,23.45"`),
|
|
108
|
+
* trailing dot (`"12."`), unbalanced parentheses, conflicting signs, and —
|
|
109
|
+
* under `bareDigits: 'reject'` — any input with no decimal point.
|
|
110
|
+
* - {@link MoneyRangeError}: magnitude beyond ±(2^53 − 1) minor units.
|
|
111
|
+
*
|
|
112
|
+
* @param input Human-typed amount text.
|
|
113
|
+
* @param opts See {@link ParseAmountOptions}.
|
|
114
|
+
* @returns The amount in signed integer minor units.
|
|
115
|
+
*/
|
|
116
|
+
export declare function parseAmount(input: string, opts?: ParseAmountOptions): number;
|
|
117
|
+
/** Options for {@link formatAmount}. */
|
|
118
|
+
export interface FormatAmountOptions {
|
|
119
|
+
/**
|
|
120
|
+
* ISO-4217-shaped currency code (3 uppercase letters). Known codes
|
|
121
|
+
* (USD, EUR, GBP, JPY, INR) render their symbol as a prefix
|
|
122
|
+
* (`$1,234.56`); other valid codes render as a code prefix
|
|
123
|
+
* (`CAD 1,234.56`). Invalid codes throw {@link MoneyCurrencyError}.
|
|
124
|
+
*/
|
|
125
|
+
currency?: string;
|
|
126
|
+
/** Negative style: `'sign'` → `-1,234.56` (default); `'parens'` → `(1,234.56)`. */
|
|
127
|
+
negative?: 'sign' | 'parens';
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Format signed integer minor units for display: fixed 2 decimals, comma
|
|
131
|
+
* thousands grouping, locale-pinned (the output is built from digits — it
|
|
132
|
+
* never consults the host locale, so it is byte-identical across
|
|
133
|
+
* machines/runs). `123456` → `"1,234.56"`.
|
|
134
|
+
*
|
|
135
|
+
* Negative zero normalises to positive `"0.00"`. Round-trips through
|
|
136
|
+
* {@link parseAmount} exactly for every valid amount (including `'parens'`
|
|
137
|
+
* style and symbol-prefixed currencies).
|
|
138
|
+
*
|
|
139
|
+
* Zero-decimal currencies (JPY-style) are v1-unsupported as a scale: the
|
|
140
|
+
* amount still renders as `minor / 100` with 2 decimals (see module doc).
|
|
141
|
+
*
|
|
142
|
+
* @param minor Amount in signed integer minor units; invalid amounts throw {@link MoneyRangeError}.
|
|
143
|
+
* @param opts See {@link FormatAmountOptions}.
|
|
144
|
+
*/
|
|
145
|
+
export declare function formatAmount(minor: number, opts?: FormatAmountOptions): string;
|
|
146
|
+
/**
|
|
147
|
+
* Add minor-unit amounts exactly. Every input and the running sum must be a
|
|
148
|
+
* safe integer; otherwise {@link MoneyRangeError} is thrown (no silent float
|
|
149
|
+
* drift, no wrap-around). `addAmounts()` → `0`.
|
|
150
|
+
*/
|
|
151
|
+
export declare function addAmounts(...amounts: number[]): number;
|
|
152
|
+
/**
|
|
153
|
+
* Sum an iterable of minor-unit amounts exactly. Each value is validated with
|
|
154
|
+
* {@link isValidMinor}, and the running sum is re-validated after every step
|
|
155
|
+
* so overflow past ±(2^53 − 1) throws {@link MoneyRangeError} instead of
|
|
156
|
+
* losing precision. An empty iterable sums to `0`.
|
|
157
|
+
*/
|
|
158
|
+
export declare function sumAmounts(amounts: Iterable<number>): number;
|
|
159
|
+
/** Negate a minor-unit amount (`negateAmount(0)` stays `0`, never `-0`). Throws {@link MoneyRangeError} on invalid input. */
|
|
160
|
+
export declare function negateAmount(minor: number): number;
|
|
161
|
+
/**
|
|
162
|
+
* Three-way comparison of minor-unit amounts: `-1` if `a < b`, `0` if equal,
|
|
163
|
+
* `1` if `a > b`. Suitable as an `Array.prototype.sort` comparator. Throws
|
|
164
|
+
* {@link MoneyRangeError} on invalid inputs.
|
|
165
|
+
*/
|
|
166
|
+
export declare function compareAmounts(a: number, b: number): -1 | 0 | 1;
|
|
167
|
+
/**
|
|
168
|
+
* True iff `code` is ISO-4217-shaped: exactly 3 uppercase ASCII letters
|
|
169
|
+
* (`USD`, `EUR`…). Shape-only — this does not check the code against the
|
|
170
|
+
* live ISO registry. Narrowing type guard.
|
|
171
|
+
*/
|
|
172
|
+
export declare function isValidCurrencyCode(code: unknown): code is string;
|
|
173
|
+
/**
|
|
174
|
+
* Assert every code in `codes` is valid ({@link isValidCurrencyCode}) and
|
|
175
|
+
* identical — ledger entries must not silently mix currencies. Returns the
|
|
176
|
+
* uniform code, or `undefined` for an empty iterable. Throws
|
|
177
|
+
* {@link MoneyCurrencyError} on an invalid code or a mix.
|
|
178
|
+
*/
|
|
179
|
+
export declare function assertUniformCurrency(codes: Iterable<string>): string | undefined;
|
package/dist/money.js
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Money core (LGR-2): signed integer minor-unit amounts for the ledger.
|
|
3
|
+
*
|
|
4
|
+
* INVARIANT — amounts are SIGNED INTEGER MINOR UNITS (cents) everywhere:
|
|
5
|
+
* storage, arithmetic, wire, and the boundaries of this API. No floats ever
|
|
6
|
+
* cross this API: every function throws a typed {@link MoneyError} subclass on
|
|
7
|
+
* non-integer or out-of-range numbers instead of rounding, truncating, or
|
|
8
|
+
* returning `NaN`.
|
|
9
|
+
*
|
|
10
|
+
* Bounds: amounts must satisfy `Number.isSafeInteger`, i.e. lie within
|
|
11
|
+
* ±(2^53 − 1) minor units (`±9,007,199,254,740,991` ≈ ±$90 trillion at 2
|
|
12
|
+
* decimal places). Anything beyond is rejected with {@link MoneyRangeError}.
|
|
13
|
+
* Internally, parsing and formatting route digit math through `BigInt` so the
|
|
14
|
+
* bound check itself is exact.
|
|
15
|
+
*
|
|
16
|
+
* Scale (v1): a fixed 2-decimal minor-unit scale (1 major unit = 100 minor
|
|
17
|
+
* units) for ALL currencies. Zero-decimal currencies (JPY-style) are
|
|
18
|
+
* v1-unsupported as a scale: `formatAmount(minor, {currency: 'JPY'})` still
|
|
19
|
+
* renders `minor / 100` with 2 decimals. Per-currency exponents are a later
|
|
20
|
+
* ledger task.
|
|
21
|
+
*/
|
|
22
|
+
/**
|
|
23
|
+
* Base class for all money errors. Callers can `instanceof`-match the
|
|
24
|
+
* subclasses ({@link MoneyParseError}, {@link MoneyRangeError},
|
|
25
|
+
* {@link MoneyCurrencyError}) or switch on {@link MoneyError.code}.
|
|
26
|
+
*/
|
|
27
|
+
export class MoneyError extends Error {
|
|
28
|
+
constructor(code, message) {
|
|
29
|
+
super(message);
|
|
30
|
+
this.name = new.target.name;
|
|
31
|
+
this.code = code;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** Input text could not be parsed as a money amount (malformed, ambiguous, empty…). */
|
|
35
|
+
export class MoneyParseError extends MoneyError {
|
|
36
|
+
constructor(message) {
|
|
37
|
+
super('parse', message);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* A numeric amount is not an integer or exceeds the safe bound of
|
|
42
|
+
* ±(2^53 − 1) minor units (non-integer input, unsafe magnitude, or overflow
|
|
43
|
+
* during arithmetic).
|
|
44
|
+
*/
|
|
45
|
+
export class MoneyRangeError extends MoneyError {
|
|
46
|
+
constructor(message) {
|
|
47
|
+
super('range', message);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/** A currency code is malformed, or amounts mix currencies where one is required. */
|
|
51
|
+
export class MoneyCurrencyError extends MoneyError {
|
|
52
|
+
constructor(message) {
|
|
53
|
+
super('currency', message);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/** Largest representable amount: 2^53 − 1 minor units (≈ $90,071,992,547,409.91). */
|
|
57
|
+
export const MAX_AMOUNT_MINOR = Number.MAX_SAFE_INTEGER;
|
|
58
|
+
/** Smallest representable amount: −(2^53 − 1) minor units. */
|
|
59
|
+
export const MIN_AMOUNT_MINOR = -Number.MAX_SAFE_INTEGER;
|
|
60
|
+
const MAX_MINOR_BIG = BigInt(Number.MAX_SAFE_INTEGER);
|
|
61
|
+
/**
|
|
62
|
+
* True iff `n` is a valid minor-unit amount: a `number` that is a safe integer
|
|
63
|
+
* (`Number.isSafeInteger`), i.e. within ±(2^53 − 1). Narrowing type guard.
|
|
64
|
+
*/
|
|
65
|
+
export function isValidMinor(n) {
|
|
66
|
+
return typeof n === 'number' && Number.isSafeInteger(n);
|
|
67
|
+
}
|
|
68
|
+
/** Throw {@link MoneyRangeError} unless `n` is a valid minor-unit amount. */
|
|
69
|
+
function assertMinor(n, context) {
|
|
70
|
+
if (!isValidMinor(n)) {
|
|
71
|
+
throw new MoneyRangeError(`${context}: amount must be a safe integer of minor units, got ${String(n)}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const CURRENCY_SYMBOLS = new Set(['$', '€', '£', '¥', '₹']);
|
|
75
|
+
// Digits of a human-typed amount, after sign/symbol/paren stripping:
|
|
76
|
+
// either comma-grouped thousands (strict 3-digit groups) or plain digits,
|
|
77
|
+
// with an optional dot and exactly 1–2 fraction digits. The integer part may
|
|
78
|
+
// be empty only when a fraction is present (".5"). Anything else — >2
|
|
79
|
+
// decimals, misplaced separators ("1,23.45"), trailing dot, stray text —
|
|
80
|
+
// fails to match and is rejected.
|
|
81
|
+
const AMOUNT_DIGITS_RE = /^(\d{1,3}(?:,\d{3})+|\d*)(?:\.(\d{1,2}))?$/;
|
|
82
|
+
/**
|
|
83
|
+
* Parse human-typed money input into signed integer minor units (cents).
|
|
84
|
+
*
|
|
85
|
+
* Rules:
|
|
86
|
+
* - Input is in MAJOR units; input without a decimal point is whole major
|
|
87
|
+
* units: `"1234"` → `123400`, `"1,234.56"` → `123456`, `".5"` → `50`.
|
|
88
|
+
* Machine input whose scale is not established by the data can opt out of
|
|
89
|
+
* that assumption with `bareDigits: 'reject'` (see
|
|
90
|
+
* {@link ParseAmountOptions.bareDigits}) — the default is unchanged.
|
|
91
|
+
* - Negatives: leading sign (`"-12.30"`) or accounting parentheses
|
|
92
|
+
* (`"(12.30)"`) → `-1230`. Combining both is rejected as ambiguous.
|
|
93
|
+
* - A single leading currency symbol (`$ € £ ¥ ₹`) is tolerated, before or
|
|
94
|
+
* after the sign: `"$-1,234.56"` and `"-$1,234.56"` both → `-123456`.
|
|
95
|
+
* - Thousands grouping uses commas in strict 3-digit groups; the decimal
|
|
96
|
+
* separator is `.` with at most 2 digits. (Locale-specific separators —
|
|
97
|
+
* `1.234,56`, space grouping — are v1-unsupported and rejected.)
|
|
98
|
+
*
|
|
99
|
+
* Rejections (typed errors, never `NaN`):
|
|
100
|
+
* - {@link MoneyParseError}: empty/sign-only input, non-numeric text, more
|
|
101
|
+
* than 2 decimals, ambiguous or misplaced separators (`"1,23.45"`),
|
|
102
|
+
* trailing dot (`"12."`), unbalanced parentheses, conflicting signs, and —
|
|
103
|
+
* under `bareDigits: 'reject'` — any input with no decimal point.
|
|
104
|
+
* - {@link MoneyRangeError}: magnitude beyond ±(2^53 − 1) minor units.
|
|
105
|
+
*
|
|
106
|
+
* @param input Human-typed amount text.
|
|
107
|
+
* @param opts See {@link ParseAmountOptions}.
|
|
108
|
+
* @returns The amount in signed integer minor units.
|
|
109
|
+
*/
|
|
110
|
+
export function parseAmount(input, opts) {
|
|
111
|
+
const allowParens = opts?.allowParens ?? true;
|
|
112
|
+
const allowSymbol = opts?.allowCurrencySymbol ?? true;
|
|
113
|
+
if (typeof input !== 'string')
|
|
114
|
+
throw new MoneyParseError('amount must be a string');
|
|
115
|
+
let s = input.trim();
|
|
116
|
+
if (s === '')
|
|
117
|
+
throw new MoneyParseError('empty amount');
|
|
118
|
+
let parenNegative = false;
|
|
119
|
+
if (s.startsWith('(') || s.endsWith(')')) {
|
|
120
|
+
if (!allowParens)
|
|
121
|
+
throw new MoneyParseError(`parentheses not allowed: ${JSON.stringify(input)}`);
|
|
122
|
+
if (!s.startsWith('(') || !s.endsWith(')')) {
|
|
123
|
+
throw new MoneyParseError(`unbalanced parentheses: ${JSON.stringify(input)}`);
|
|
124
|
+
}
|
|
125
|
+
parenNegative = true;
|
|
126
|
+
s = s.slice(1, -1).trim();
|
|
127
|
+
}
|
|
128
|
+
// Optional sign and currency symbol, in either order, then the digits.
|
|
129
|
+
let sign = '';
|
|
130
|
+
const takeSign = () => {
|
|
131
|
+
if (s.startsWith('-') || s.startsWith('+')) {
|
|
132
|
+
if (sign !== '')
|
|
133
|
+
throw new MoneyParseError(`conflicting signs: ${JSON.stringify(input)}`);
|
|
134
|
+
sign = s[0];
|
|
135
|
+
s = s.slice(1).trimStart();
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
takeSign();
|
|
139
|
+
if (s.length > 0 && CURRENCY_SYMBOLS.has(s[0])) {
|
|
140
|
+
if (!allowSymbol)
|
|
141
|
+
throw new MoneyParseError(`currency symbol not allowed: ${JSON.stringify(input)}`);
|
|
142
|
+
s = s.slice(1).trimStart();
|
|
143
|
+
}
|
|
144
|
+
takeSign();
|
|
145
|
+
if (parenNegative && sign !== '') {
|
|
146
|
+
throw new MoneyParseError(`ambiguous negative (both sign and parentheses): ${JSON.stringify(input)}`);
|
|
147
|
+
}
|
|
148
|
+
const m = AMOUNT_DIGITS_RE.exec(s);
|
|
149
|
+
if (m === null)
|
|
150
|
+
throw new MoneyParseError(`unparseable amount: ${JSON.stringify(input)}`);
|
|
151
|
+
const intDigits = m[1].replace(/,/g, '');
|
|
152
|
+
const fracDigits = m[2] ?? '';
|
|
153
|
+
if (intDigits === '' && fracDigits === '') {
|
|
154
|
+
throw new MoneyParseError(`no digits in amount: ${JSON.stringify(input)}`);
|
|
155
|
+
}
|
|
156
|
+
// A caller that has NOT established the scale of bare digits refuses them
|
|
157
|
+
// outright rather than inheriting the major-units default. The test is
|
|
158
|
+
// structural — group 2 is absent exactly when the input carried no `.` — so
|
|
159
|
+
// it does not depend on the value, the sign, or any separator.
|
|
160
|
+
if (opts?.bareDigits === 'reject' && m[2] === undefined) {
|
|
161
|
+
throw new MoneyParseError(`amount has no decimal point and its scale is not established — major or minor units is ambiguous: ${JSON.stringify(input)}`);
|
|
162
|
+
}
|
|
163
|
+
// Exact digit math via BigInt so the safe-integer bound check cannot drift.
|
|
164
|
+
const magnitude = BigInt(intDigits === '' ? '0' : intDigits) * 100n + BigInt(fracDigits.padEnd(2, '0') || '0');
|
|
165
|
+
if (magnitude > MAX_MINOR_BIG) {
|
|
166
|
+
throw new MoneyRangeError(`amount exceeds ±(2^53 − 1) minor units: ${JSON.stringify(input)}`);
|
|
167
|
+
}
|
|
168
|
+
const minor = Number(magnitude);
|
|
169
|
+
return parenNegative || sign === '-' ? (minor === 0 ? 0 : -minor) : minor;
|
|
170
|
+
}
|
|
171
|
+
const CURRENCY_SYMBOL_BY_CODE = {
|
|
172
|
+
USD: '$',
|
|
173
|
+
EUR: '€',
|
|
174
|
+
GBP: '£',
|
|
175
|
+
JPY: '¥',
|
|
176
|
+
INR: '₹',
|
|
177
|
+
};
|
|
178
|
+
/**
|
|
179
|
+
* Format signed integer minor units for display: fixed 2 decimals, comma
|
|
180
|
+
* thousands grouping, locale-pinned (the output is built from digits — it
|
|
181
|
+
* never consults the host locale, so it is byte-identical across
|
|
182
|
+
* machines/runs). `123456` → `"1,234.56"`.
|
|
183
|
+
*
|
|
184
|
+
* Negative zero normalises to positive `"0.00"`. Round-trips through
|
|
185
|
+
* {@link parseAmount} exactly for every valid amount (including `'parens'`
|
|
186
|
+
* style and symbol-prefixed currencies).
|
|
187
|
+
*
|
|
188
|
+
* Zero-decimal currencies (JPY-style) are v1-unsupported as a scale: the
|
|
189
|
+
* amount still renders as `minor / 100` with 2 decimals (see module doc).
|
|
190
|
+
*
|
|
191
|
+
* @param minor Amount in signed integer minor units; invalid amounts throw {@link MoneyRangeError}.
|
|
192
|
+
* @param opts See {@link FormatAmountOptions}.
|
|
193
|
+
*/
|
|
194
|
+
export function formatAmount(minor, opts) {
|
|
195
|
+
assertMinor(minor, 'formatAmount');
|
|
196
|
+
const currency = opts?.currency;
|
|
197
|
+
if (currency !== undefined && !isValidCurrencyCode(currency)) {
|
|
198
|
+
throw new MoneyCurrencyError(`invalid currency code: ${JSON.stringify(currency)}`);
|
|
199
|
+
}
|
|
200
|
+
// Exact split into whole/fraction via BigInt (float division of large
|
|
201
|
+
// magnitudes is inexact; digit math is not).
|
|
202
|
+
const big = BigInt(minor);
|
|
203
|
+
const negative = big < 0n;
|
|
204
|
+
const abs = negative ? -big : big;
|
|
205
|
+
const whole = (abs / 100n).toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
|
206
|
+
const frac = (abs % 100n).toString().padStart(2, '0');
|
|
207
|
+
const digits = `${whole}.${frac}`;
|
|
208
|
+
const symbol = currency !== undefined ? CURRENCY_SYMBOL_BY_CODE[currency] : undefined;
|
|
209
|
+
const codePrefix = currency !== undefined && symbol === undefined ? `${currency} ` : '';
|
|
210
|
+
const body = `${symbol ?? ''}${digits}`;
|
|
211
|
+
if (!negative)
|
|
212
|
+
return `${codePrefix}${body}`;
|
|
213
|
+
return opts?.negative === 'parens' ? `${codePrefix}(${body})` : `${codePrefix}-${body}`;
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Add minor-unit amounts exactly. Every input and the running sum must be a
|
|
217
|
+
* safe integer; otherwise {@link MoneyRangeError} is thrown (no silent float
|
|
218
|
+
* drift, no wrap-around). `addAmounts()` → `0`.
|
|
219
|
+
*/
|
|
220
|
+
export function addAmounts(...amounts) {
|
|
221
|
+
return sumAmounts(amounts);
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Sum an iterable of minor-unit amounts exactly. Each value is validated with
|
|
225
|
+
* {@link isValidMinor}, and the running sum is re-validated after every step
|
|
226
|
+
* so overflow past ±(2^53 − 1) throws {@link MoneyRangeError} instead of
|
|
227
|
+
* losing precision. An empty iterable sums to `0`.
|
|
228
|
+
*/
|
|
229
|
+
export function sumAmounts(amounts) {
|
|
230
|
+
let sum = 0;
|
|
231
|
+
for (const amount of amounts) {
|
|
232
|
+
assertMinor(amount, 'sumAmounts');
|
|
233
|
+
sum += amount;
|
|
234
|
+
if (!Number.isSafeInteger(sum)) {
|
|
235
|
+
throw new MoneyRangeError('sumAmounts: sum exceeds ±(2^53 − 1) minor units');
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return sum;
|
|
239
|
+
}
|
|
240
|
+
/** Negate a minor-unit amount (`negateAmount(0)` stays `0`, never `-0`). Throws {@link MoneyRangeError} on invalid input. */
|
|
241
|
+
export function negateAmount(minor) {
|
|
242
|
+
assertMinor(minor, 'negateAmount');
|
|
243
|
+
return minor === 0 ? 0 : -minor;
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Three-way comparison of minor-unit amounts: `-1` if `a < b`, `0` if equal,
|
|
247
|
+
* `1` if `a > b`. Suitable as an `Array.prototype.sort` comparator. Throws
|
|
248
|
+
* {@link MoneyRangeError} on invalid inputs.
|
|
249
|
+
*/
|
|
250
|
+
export function compareAmounts(a, b) {
|
|
251
|
+
assertMinor(a, 'compareAmounts');
|
|
252
|
+
assertMinor(b, 'compareAmounts');
|
|
253
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* True iff `code` is ISO-4217-shaped: exactly 3 uppercase ASCII letters
|
|
257
|
+
* (`USD`, `EUR`…). Shape-only — this does not check the code against the
|
|
258
|
+
* live ISO registry. Narrowing type guard.
|
|
259
|
+
*/
|
|
260
|
+
export function isValidCurrencyCode(code) {
|
|
261
|
+
return typeof code === 'string' && /^[A-Z]{3}$/.test(code);
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Assert every code in `codes` is valid ({@link isValidCurrencyCode}) and
|
|
265
|
+
* identical — ledger entries must not silently mix currencies. Returns the
|
|
266
|
+
* uniform code, or `undefined` for an empty iterable. Throws
|
|
267
|
+
* {@link MoneyCurrencyError} on an invalid code or a mix.
|
|
268
|
+
*/
|
|
269
|
+
export function assertUniformCurrency(codes) {
|
|
270
|
+
let uniform;
|
|
271
|
+
for (const code of codes) {
|
|
272
|
+
if (!isValidCurrencyCode(code)) {
|
|
273
|
+
throw new MoneyCurrencyError(`invalid currency code: ${JSON.stringify(code)}`);
|
|
274
|
+
}
|
|
275
|
+
if (uniform === undefined)
|
|
276
|
+
uniform = code;
|
|
277
|
+
else if (code !== uniform) {
|
|
278
|
+
throw new MoneyCurrencyError(`mixed currencies: ${uniform} vs ${code}`);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return uniform;
|
|
282
|
+
}
|
|
283
|
+
//# sourceMappingURL=money.js.map
|