@classytic/primitives 0.28.0 → 0.29.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.
@@ -0,0 +1,27 @@
1
+ //#region src/identity/legal-form.ts
2
+ /**
3
+ * The legal form of a person or entity — what the law treats it as, for the reporting company and its
4
+ * counterparties alike. A sole proprietorship is an `individual`: the business has no legal personality
5
+ * of its own. Country packs decide what follows from a form (equity accounts, the year-end close, tax).
6
+ */
7
+ const LEGAL_FORMS = [
8
+ "individual",
9
+ "partnership",
10
+ "company",
11
+ "cooperative",
12
+ "nonprofit",
13
+ "government"
14
+ ];
15
+ const LEGAL_FORM_LABELS = {
16
+ individual: "Individual / sole proprietorship",
17
+ partnership: "Partnership firm",
18
+ company: "Company",
19
+ cooperative: "Cooperative society",
20
+ nonprofit: "Nonprofit / NGO / trust",
21
+ government: "Government body"
22
+ };
23
+ function isLegalForm(value) {
24
+ return typeof value === "string" && LEGAL_FORMS.includes(value);
25
+ }
26
+ //#endregion
27
+ export { LEGAL_FORMS, LEGAL_FORM_LABELS, isLegalForm };
@@ -0,0 +1,70 @@
1
+ import { Result } from "./result.mjs";
2
+ //#region src/inventory/line-settlement.d.ts
3
+ /**
4
+ * A document line's quantity, split into OUTCOME buckets — the one definition
5
+ * of "how much of this line is still open".
6
+ *
7
+ * Every kernel that moves goods against a document asks this: an order line is
8
+ * fulfilled / returned / canceled, a transfer line is received / returned /
9
+ * written off, a purchase line is received / rejected. The buckets are the
10
+ * kernel's vocabulary; the arithmetic, the over-settlement guard and the state
11
+ * derivation are this file's, so no two kernels can answer differently.
12
+ *
13
+ * Quantities are whole units. A bucket that is absent counts as zero.
14
+ */
15
+ interface SettlementLine<B extends string> {
16
+ /** What the line promised to move. */
17
+ readonly quantity: number;
18
+ /** What has been settled so far, per outcome. */
19
+ readonly settled: Readonly<Partial<Record<B, number>>>;
20
+ }
21
+ type SettlementState = "open" | "partial" | "settled";
22
+ /** Sum of every bucket. */
23
+ declare const settledTotal: <B extends string>(line: SettlementLine<B>) => number;
24
+ /**
25
+ * `quantity − Σ buckets`. NOT clamped at zero: a negative means the line was
26
+ * over-settled by data written outside {@link planSettlement}, and hiding it
27
+ * would erase the only signal that happened.
28
+ */
29
+ declare const outstandingOf: <B extends string>(line: SettlementLine<B>) => number;
30
+ /** `open` — nothing settled; `settled` — nothing outstanding; otherwise `partial`. */
31
+ declare const settlementState: <B extends string>(line: SettlementLine<B>) => SettlementState;
32
+ /** Every line settled ⇒ `settled`; every line untouched ⇒ `open`; else `partial`. */
33
+ declare const documentSettlementState: <B extends string>(lines: ReadonlyArray<SettlementLine<B>>) => SettlementState;
34
+ /** `true` when every line is settled entirely through `bucket` (e.g. all received, nothing lost). */
35
+ declare const settledEntirelyIn: <B extends string>(lines: ReadonlyArray<SettlementLine<B>>, bucket: B) => boolean;
36
+ type SettlementRefusal = {
37
+ readonly code: "not_positive_integer";
38
+ readonly requested: number;
39
+ } | {
40
+ readonly code: "exceeds_outstanding";
41
+ readonly requested: number;
42
+ readonly outstanding: number;
43
+ };
44
+ /**
45
+ * Settle `quantity` more units into `bucket`, or refuse.
46
+ *
47
+ * Refuses a non-positive or fractional quantity and anything above what is
48
+ * outstanding — an over-settlement is an input error, never silently trimmed.
49
+ * Use {@link clampSettlement} only where the surface's CONTRACT is "take what fits".
50
+ */
51
+ declare const planSettlement: <B extends string>(line: SettlementLine<B>, bucket: B, quantity: number) => Result<SettlementLine<B>, SettlementRefusal>;
52
+ /**
53
+ * Settle up to `requested` units into `bucket`, taking only what is outstanding.
54
+ * `applied` is what was actually settled; `0` leaves the line unchanged.
55
+ */
56
+ declare const clampSettlement: <B extends string>(line: SettlementLine<B>, bucket: B, requested: number) => {
57
+ readonly line: SettlementLine<B>;
58
+ readonly applied: number;
59
+ };
60
+ /**
61
+ * The share of a line-level amount (a value or a cost, integer MINOR units) owed by one
62
+ * settlement batch, given how many units the whole line had settled before and after it.
63
+ *
64
+ * `floor(total·after/q) − floor(total·before/q)` TELESCOPES: however a line is split into
65
+ * batches, and across every outcome bucket, the shares sum to exactly `total` once the line
66
+ * is fully settled — no remainder to chase, no batch-order dependence.
67
+ */
68
+ declare const shareOfSettled: (total: number, quantity: number, settledBefore: number, settledAfter: number) => number;
69
+ //#endregion
70
+ export { SettlementLine, SettlementRefusal, SettlementState, clampSettlement, documentSettlementState, outstandingOf, planSettlement, settledEntirelyIn, settledTotal, settlementState, shareOfSettled };
@@ -0,0 +1,79 @@
1
+ import { err, ok } from "./result.mjs";
2
+ //#region src/inventory/line-settlement.ts
3
+ /** Sum of every bucket. */
4
+ const settledTotal = (line) => Object.values(line.settled).reduce((sum, n) => sum + (n ?? 0), 0);
5
+ /**
6
+ * `quantity − Σ buckets`. NOT clamped at zero: a negative means the line was
7
+ * over-settled by data written outside {@link planSettlement}, and hiding it
8
+ * would erase the only signal that happened.
9
+ */
10
+ const outstandingOf = (line) => line.quantity - settledTotal(line);
11
+ /** `open` — nothing settled; `settled` — nothing outstanding; otherwise `partial`. */
12
+ const settlementState = (line) => {
13
+ if (outstandingOf(line) <= 0) return "settled";
14
+ return settledTotal(line) === 0 ? "open" : "partial";
15
+ };
16
+ /** Every line settled ⇒ `settled`; every line untouched ⇒ `open`; else `partial`. */
17
+ const documentSettlementState = (lines) => {
18
+ const states = lines.map(settlementState);
19
+ if (states.every((s) => s === "settled")) return "settled";
20
+ if (states.every((s) => s === "open")) return "open";
21
+ return "partial";
22
+ };
23
+ /** `true` when every line is settled entirely through `bucket` (e.g. all received, nothing lost). */
24
+ const settledEntirelyIn = (lines, bucket) => lines.every((l) => (l.settled[bucket] ?? 0) >= l.quantity);
25
+ /**
26
+ * Settle `quantity` more units into `bucket`, or refuse.
27
+ *
28
+ * Refuses a non-positive or fractional quantity and anything above what is
29
+ * outstanding — an over-settlement is an input error, never silently trimmed.
30
+ * Use {@link clampSettlement} only where the surface's CONTRACT is "take what fits".
31
+ */
32
+ const planSettlement = (line, bucket, quantity) => {
33
+ if (!Number.isInteger(quantity) || quantity <= 0) return err({
34
+ code: "not_positive_integer",
35
+ requested: quantity
36
+ });
37
+ const outstanding = outstandingOf(line);
38
+ if (quantity > outstanding) return err({
39
+ code: "exceeds_outstanding",
40
+ requested: quantity,
41
+ outstanding
42
+ });
43
+ return ok(withBucket(line, bucket, quantity));
44
+ };
45
+ /**
46
+ * Settle up to `requested` units into `bucket`, taking only what is outstanding.
47
+ * `applied` is what was actually settled; `0` leaves the line unchanged.
48
+ */
49
+ const clampSettlement = (line, bucket, requested) => {
50
+ const applied = Math.max(0, Math.min(Math.trunc(requested), outstandingOf(line)));
51
+ return {
52
+ line: applied > 0 ? withBucket(line, bucket, applied) : line,
53
+ applied
54
+ };
55
+ };
56
+ /**
57
+ * The share of a line-level amount (a value or a cost, integer MINOR units) owed by one
58
+ * settlement batch, given how many units the whole line had settled before and after it.
59
+ *
60
+ * `floor(total·after/q) − floor(total·before/q)` TELESCOPES: however a line is split into
61
+ * batches, and across every outcome bucket, the shares sum to exactly `total` once the line
62
+ * is fully settled — no remainder to chase, no batch-order dependence.
63
+ */
64
+ const shareOfSettled = (total, quantity, settledBefore, settledAfter) => {
65
+ if (!Number.isInteger(total) || total < 0) throw new RangeError(`total must be a non-negative integer, got ${total}`);
66
+ if (!Number.isInteger(quantity) || quantity <= 0) throw new RangeError(`quantity must be a positive integer, got ${quantity}`);
67
+ if (!(0 <= settledBefore && settledBefore <= settledAfter && settledAfter <= quantity)) throw new RangeError(`need 0 <= before (${settledBefore}) <= after (${settledAfter}) <= quantity (${quantity})`);
68
+ const at = (n) => Number(BigInt(total) * BigInt(n) / BigInt(quantity));
69
+ return at(settledAfter) - at(settledBefore);
70
+ };
71
+ const withBucket = (line, bucket, quantity) => ({
72
+ quantity: line.quantity,
73
+ settled: {
74
+ ...line.settled,
75
+ [bucket]: (line.settled[bucket] ?? 0) + quantity
76
+ }
77
+ });
78
+ //#endregion
79
+ export { clampSettlement, documentSettlementState, outstandingOf, planSettlement, settledEntirelyIn, settledTotal, settlementState, shareOfSettled };
@@ -74,7 +74,8 @@ var MemoryOutboxStore = class {
74
74
  entry.fenceToken = (entry.fenceToken ?? 0) + 1;
75
75
  claimed.push({
76
76
  event: entry.event,
77
- fencingToken: entry.fenceToken
77
+ fencingToken: entry.fenceToken,
78
+ attempts: entry.attempts
78
79
  });
79
80
  }
80
81
  return claimed;
package/dist/outbox.d.mts CHANGED
@@ -46,6 +46,20 @@ interface OutboxClaimOptions {
46
46
  interface OutboxClaimedEvent {
47
47
  readonly event: DomainEvent;
48
48
  readonly fencingToken: number;
49
+ /**
50
+ * DURABLE delivery-attempt count for this event, INCLUDING the claim that
51
+ * returned it (1 on the first claim). Optional: stores that persist a
52
+ * per-row counter report it; stores that don't leave it absent.
53
+ *
54
+ * Why it exists: a relay can only count failures in its own memory, and
55
+ * that count is wrong in exactly the cases a failure policy exists for.
56
+ * A poison event that crashes the relay restarts at 1 on every boot, so
57
+ * `attempts >= 5 → deadLetter` never fires; and across N relays sharing a
58
+ * store, each counts its own attempts, so "5" means 5×N. The store already
59
+ * increments a counter atomically inside the claim — reporting it here is
60
+ * what lets the policy see the true number.
61
+ */
62
+ readonly attempts?: number;
49
63
  }
50
64
  /** Options for {@link OutboxStore.acknowledge}. */
51
65
  interface OutboxAcknowledgeOptions {
@@ -81,9 +95,12 @@ interface OutboxFailureContext {
81
95
  /** The error returned by the transport / handler. */
82
96
  readonly error: Error;
83
97
  /**
84
- * Attempt count including this failure (1 on the first fail). Tracked
85
- * in-process by the relay; accurate within a single relay process, resets
86
- * on restart. For durable attempt counts, the policy can query the store.
98
+ * Attempt count including this failure (1 on the first fail).
99
+ *
100
+ * DURABLE when the store reports {@link OutboxClaimedEvent.attempts} from a
101
+ * fenced claim — survives relay restarts and is shared by every relay on the
102
+ * store. Otherwise the relay's in-process count: accurate within one relay
103
+ * process, reset on restart. Relays take the larger of the two.
87
104
  */
88
105
  readonly attempts: number;
89
106
  }
@@ -119,6 +119,9 @@ interface PaymentRefundedPayload {
119
119
  refundId: string;
120
120
  providerRef?: string;
121
121
  providerCode: string;
122
+ /** How the original payment moved — money goes back the way it came, so this places the refund too. */
123
+ methodKind: PaymentMethodKind;
124
+ methodCode?: string;
122
125
  /** Amount refunded in this event — may be less than the original payment. */
123
126
  refundedAmount: Money;
124
127
  /** Original payment total — present so consumers can compute remaining refundable without a Payment lookup. */
@@ -142,6 +145,9 @@ interface PaymentReversedPayload {
142
145
  paymentId: string;
143
146
  providerRef?: string;
144
147
  providerCode: string;
148
+ /** How the payment being unwound moved. */
149
+ methodKind: PaymentMethodKind;
150
+ methodCode?: string;
145
151
  /** Amount being unwound — equals the original payment for full reversal, less for partial. */
146
152
  reversedAmount: Money;
147
153
  /** Original payment total — present so consumers can detect partial vs full without a Payment lookup. */
package/dist/period.d.mts CHANGED
@@ -123,6 +123,27 @@ declare function resolveMonth(year: number, month: number, timezone: string): Da
123
123
  declare function resolveQuarter(year: number, quarter: 1 | 2 | 3 | 4, timezone: string): DateRange;
124
124
  /** Half-open `[start, end)` range covering one local calendar year. */
125
125
  declare function resolveYear(year: number, timezone: string): DateRange;
126
+ /** A fiscal year: the calendar year it starts in, its label, and its half-open local range. */
127
+ interface FiscalYear {
128
+ startYear: number;
129
+ /** `2026-27` for a year starting mid-calendar-year; `2026` for one starting in January. */
130
+ label: string;
131
+ range: DateRange;
132
+ }
133
+ /** Half-open range of the fiscal year starting on the 1st of `startMonth` of `startYear`, local to `timezone`. */
134
+ declare function resolveFiscalYear(startYear: number, opts: {
135
+ startMonth: number;
136
+ timezone: string;
137
+ }): DateRange;
138
+ /**
139
+ * The fiscal year `date` falls in — 1 July–30 June (Bangladesh, Australia), 1 April–31 March (India),
140
+ * 1 January (most) — decided on the LOCAL calendar day in `timezone`, so the last evening of June in Dhaka
141
+ * is still the old year.
142
+ */
143
+ declare function fiscalYearOf(date: Date, opts: {
144
+ startMonth: number;
145
+ timezone: string;
146
+ }): FiscalYear;
126
147
  /**
127
148
  * Half-open range spanning two local dates, INCLUSIVE of both days — what an
128
149
  * operator means by "1st to 31st". The half-open end is therefore the start of
@@ -209,4 +230,4 @@ declare function parsePeriod(label: string, timezone?: string): Period;
209
230
  */
210
231
  declare function formatPeriod(period: Period): string;
211
232
  //#endregion
212
- export { DateRange, DateRangeError, Period, PeriodError, PeriodErrorCode, PeriodGranularity, dayStart, formatPeriod, granularityOf, inclusiveEnd, isDateRange, isWithin, parsePeriod, periodOf, periodTimeZone, rangeDurationMs, rangesOverlap, resolveDateSpan, resolveDay, resolveMonth, resolvePeriod, resolveQuarter, resolveYear, shiftPeriod };
233
+ export { DateRange, DateRangeError, FiscalYear, Period, PeriodError, PeriodErrorCode, PeriodGranularity, dayStart, fiscalYearOf, formatPeriod, granularityOf, inclusiveEnd, isDateRange, isWithin, parsePeriod, periodOf, periodTimeZone, rangeDurationMs, rangesOverlap, resolveDateSpan, resolveDay, resolveFiscalYear, resolveMonth, resolvePeriod, resolveQuarter, resolveYear, shiftPeriod };
package/dist/period.mjs CHANGED
@@ -181,6 +181,35 @@ function resolveYear(year, timezone) {
181
181
  end: dayStart(`${y + 1}-01-01`, zone)
182
182
  };
183
183
  }
184
+ /** Half-open range of the fiscal year starting on the 1st of `startMonth` of `startYear`, local to `timezone`. */
185
+ function resolveFiscalYear(startYear, opts) {
186
+ const zone = assertZone(opts.timezone);
187
+ const y = assertYear(startYear);
188
+ const m = assertMonth(opts.startMonth);
189
+ return {
190
+ start: dayStart(`${y}-${pad2(m)}-01`, zone),
191
+ end: dayStart(`${y + 1}-${pad2(m)}-01`, zone)
192
+ };
193
+ }
194
+ /**
195
+ * The fiscal year `date` falls in — 1 July–30 June (Bangladesh, Australia), 1 April–31 March (India),
196
+ * 1 January (most) — decided on the LOCAL calendar day in `timezone`, so the last evening of June in Dhaka
197
+ * is still the old year.
198
+ */
199
+ function fiscalYearOf(date, opts) {
200
+ const zone = assertZone(opts.timezone);
201
+ const m = assertMonth(opts.startMonth);
202
+ const [y, mm] = String(civilDateOf(date, zone)).split("-").map(Number);
203
+ const startYear = mm >= m ? y : y - 1;
204
+ return {
205
+ startYear,
206
+ label: m === 1 ? String(startYear) : `${startYear}-${pad2((startYear + 1) % 100)}`,
207
+ range: resolveFiscalYear(startYear, {
208
+ startMonth: m,
209
+ timezone: zone
210
+ })
211
+ };
212
+ }
184
213
  /**
185
214
  * Half-open range spanning two local dates, INCLUSIVE of both days — what an
186
215
  * operator means by "1st to 31st". The half-open end is therefore the start of
@@ -388,4 +417,4 @@ function formatPeriod(period) {
388
417
  throw new PeriodError("INVALID_LABEL", "An ad-hoc Period (start + end) has no canonical label.");
389
418
  }
390
419
  //#endregion
391
- export { DateRangeError, PeriodError, dayStart, formatPeriod, granularityOf, inclusiveEnd, isDateRange, isWithin, parsePeriod, periodOf, periodTimeZone, rangeDurationMs, rangesOverlap, resolveDateSpan, resolveDay, resolveMonth, resolvePeriod, resolveQuarter, resolveYear, shiftPeriod };
420
+ export { DateRangeError, PeriodError, dayStart, fiscalYearOf, formatPeriod, granularityOf, inclusiveEnd, isDateRange, isWithin, parsePeriod, periodOf, periodTimeZone, rangeDurationMs, rangesOverlap, resolveDateSpan, resolveDay, resolveFiscalYear, resolveMonth, resolvePeriod, resolveQuarter, resolveYear, shiftPeriod };
package/dist/phone.d.mts CHANGED
@@ -40,7 +40,19 @@ declare function formatPhone(phone: PhoneNumber): string;
40
40
  * @example formatNational(parsePhone('+14155550182').value!) → '+1 415 555 0182'
41
41
  */
42
42
  declare function formatNational(phone: PhoneNumber): string;
43
+ /**
44
+ * Prefix of a stored PLACEHOLDER phone: a record whose `phone` is required and
45
+ * unique but was created before a number was known gets `pending_<id>`. It means
46
+ * "no number collected" — never a number to show, dial, dedupe or search on.
47
+ */
48
+ declare const PLACEHOLDER_PHONE_PREFIX = "pending_";
49
+ /** Mint the placeholder for a phone-less record — unique per `id`. */
50
+ declare function placeholderPhone(id: string): string;
51
+ /** True for a stored placeholder (see {@link PLACEHOLDER_PHONE_PREFIX}); whitespace-tolerant. */
52
+ declare function isPlaceholderPhone(value: unknown): boolean;
53
+ /** A stored phone worth showing: trimmed, or `null` for empty or a placeholder. */
54
+ declare function shownPhone(value: unknown): string | null;
43
55
  /** Type guard. */
44
56
  declare function isPhoneNumber(value: unknown): value is PhoneNumber;
45
57
  //#endregion
46
- export { PhoneError, PhoneErrorCode, PhoneNumber, formatNational, formatPhone, isPhoneNumber, parsePhone };
58
+ export { PLACEHOLDER_PHONE_PREFIX, PhoneError, PhoneErrorCode, PhoneNumber, formatNational, formatPhone, isPhoneNumber, isPlaceholderPhone, parsePhone, placeholderPhone, shownPhone };
package/dist/phone.mjs CHANGED
@@ -302,9 +302,29 @@ function formatNational(phone) {
302
302
  const groups = phone.nationalNumber.match(/.{1,3}/g) ?? [];
303
303
  return `+${phone.callingCode} ${groups.join(" ")}`.trim();
304
304
  }
305
+ /**
306
+ * Prefix of a stored PLACEHOLDER phone: a record whose `phone` is required and
307
+ * unique but was created before a number was known gets `pending_<id>`. It means
308
+ * "no number collected" — never a number to show, dial, dedupe or search on.
309
+ */
310
+ const PLACEHOLDER_PHONE_PREFIX = "pending_";
311
+ /** Mint the placeholder for a phone-less record — unique per `id`. */
312
+ function placeholderPhone(id) {
313
+ return `${PLACEHOLDER_PHONE_PREFIX}${id}`;
314
+ }
315
+ /** True for a stored placeholder (see {@link PLACEHOLDER_PHONE_PREFIX}); whitespace-tolerant. */
316
+ function isPlaceholderPhone(value) {
317
+ return typeof value === "string" && value.trim().startsWith("pending_");
318
+ }
319
+ /** A stored phone worth showing: trimmed, or `null` for empty or a placeholder. */
320
+ function shownPhone(value) {
321
+ if (typeof value !== "string") return null;
322
+ const v = value.trim();
323
+ return v && !isPlaceholderPhone(v) ? v : null;
324
+ }
305
325
  /** Type guard. */
306
326
  function isPhoneNumber(value) {
307
327
  return typeof value === "object" && value !== null && typeof value.e164 === "string" && typeof value.callingCode === "string" && typeof value.nationalNumber === "string" && value.e164.startsWith("+");
308
328
  }
309
329
  //#endregion
310
- export { PhoneError, formatNational, formatPhone, isPhoneNumber, parsePhone };
330
+ export { PLACEHOLDER_PHONE_PREFIX, PhoneError, formatNational, formatPhone, isPhoneNumber, isPlaceholderPhone, parsePhone, placeholderPhone, shownPhone };
@@ -62,6 +62,16 @@ declare function splitByPeriodFraction(amount: Money, fraction: PeriodFraction):
62
62
  readonly consumed: Money;
63
63
  readonly remaining: Money;
64
64
  };
65
+ /**
66
+ * The share of `total` carried by `part` of `whole`, in integer minor units — the VAT on a partial
67
+ * refund, the tax on one tender of a split sale. Largest-remainder rounding, so the share and its
68
+ * complement sum to `total` exactly; `part === whole` returns `total` untouched.
69
+ *
70
+ * @throws ProrationError('INVALID_FRACTION') when an input is not an integer, `whole` is not
71
+ * positive, or `part` lies outside `[0, whole]` — a refund larger than its payment is a defect
72
+ * upstream, and clamping it would book a plausible number.
73
+ */
74
+ declare function apportion(total: number, part: number, whole: number): number;
65
75
  /**
66
76
  * Allocate the `fraction`-weighted part of a `Money` amount, drift-free — the
67
77
  * complement (`1 - fraction`) is discarded but computed so rounding is exact
@@ -72,4 +82,4 @@ declare function splitByPeriodFraction(amount: Money, fraction: PeriodFraction):
72
82
  */
73
83
  declare function allocateMoneyByFraction(amount: Money, fraction: number): Money;
74
84
  //#endregion
75
- export { PeriodFraction, PeriodProgressInput, ProrationError, ProrationErrorCode, ProrationGranularity, allocateMoneyByFraction, periodProgress, splitByPeriodFraction };
85
+ export { PeriodFraction, PeriodProgressInput, ProrationError, ProrationErrorCode, ProrationGranularity, allocateMoneyByFraction, apportion, periodProgress, splitByPeriodFraction };
@@ -108,6 +108,29 @@ function requirePart(result, id) {
108
108
  return part.amount;
109
109
  }
110
110
  /**
111
+ * The share of `total` carried by `part` of `whole`, in integer minor units — the VAT on a partial
112
+ * refund, the tax on one tender of a split sale. Largest-remainder rounding, so the share and its
113
+ * complement sum to `total` exactly; `part === whole` returns `total` untouched.
114
+ *
115
+ * @throws ProrationError('INVALID_FRACTION') when an input is not an integer, `whole` is not
116
+ * positive, or `part` lies outside `[0, whole]` — a refund larger than its payment is a defect
117
+ * upstream, and clamping it would book a plausible number.
118
+ */
119
+ function apportion(total, part, whole) {
120
+ if (![
121
+ total,
122
+ part,
123
+ whole
124
+ ].every(Number.isInteger) || whole <= 0 || part < 0 || part > whole) throw new ProrationError("INVALID_FRACTION", `cannot apportion ${total} by ${part} of ${whole}`);
125
+ return requirePart(allocate(total, [{
126
+ id: "part",
127
+ weight: part
128
+ }, {
129
+ id: "rest",
130
+ weight: whole - part
131
+ }], "by-weight"), "part");
132
+ }
133
+ /**
111
134
  * Allocate the `fraction`-weighted part of a `Money` amount, drift-free — the
112
135
  * complement (`1 - fraction`) is discarded but computed so rounding is exact
113
136
  * against the whole. `fraction` must be in `[0, 1]`.
@@ -129,4 +152,4 @@ function allocateMoneyByFraction(amount, fraction) {
129
152
  };
130
153
  }
131
154
  //#endregion
132
- export { ProrationError, allocateMoneyByFraction, periodProgress, splitByPeriodFraction };
155
+ export { ProrationError, allocateMoneyByFraction, apportion, periodProgress, splitByPeriodFraction };
@@ -0,0 +1,54 @@
1
+ //#region src/security/signed-token.d.ts
2
+ /**
3
+ * Signed tokens: compact JWS (RFC 7515), ES256 over WebCrypto — a server and an offline device sign
4
+ * and verify the same bytes with no dependency. Claims follow RFC 7519 (`exp`/`nbf`/`iat` in
5
+ * seconds).
6
+ *
7
+ * const token = await signToken({ sub, exp }, { kid, privateKey }, { typ: 'till-grant+jwt' });
8
+ * const read = await verifyToken<Grant>(token, (kid) => keys.get(kid), { typ: 'till-grant+jwt' });
9
+ *
10
+ * `typ` is required on both sides, so one kind of token is never accepted as another, and a token
11
+ * without `exp` is refused.
12
+ */
13
+ declare const SIGNED_TOKEN_ALG = "ES256";
14
+ /** A P-256 key as JWK; `d` only on a private key. */
15
+ interface EcJwk {
16
+ kty?: string;
17
+ crv?: string;
18
+ x?: string;
19
+ y?: string;
20
+ d?: string;
21
+ [member: string]: unknown;
22
+ }
23
+ interface SigningKey {
24
+ /** Published with the public key; names it in each token's header. */
25
+ readonly kid: string;
26
+ readonly privateKey: CryptoKey;
27
+ }
28
+ type TokenRefusal = 'malformed' | 'unsupported' | 'wrong_type' | 'unknown_key' | 'bad_signature' | 'no_expiry' | 'expired' | 'not_yet_valid';
29
+ type VerifiedToken<T> = {
30
+ readonly ok: true;
31
+ readonly claims: T;
32
+ readonly kid: string;
33
+ } | {
34
+ readonly ok: false;
35
+ readonly reason: TokenRefusal;
36
+ };
37
+ declare function signToken(claims: Record<string, unknown>, key: SigningKey, options: {
38
+ typ: string;
39
+ }): Promise<string>;
40
+ declare function verifyToken<T>(token: string, keyOf: (kid: string) => CryptoKey | undefined | Promise<CryptoKey | undefined>, options: {
41
+ typ: string;
42
+ now?: Date | undefined;
43
+ leewaySeconds?: number | undefined;
44
+ }): Promise<VerifiedToken<T>>;
45
+ /** A P-256 private JWK as a non-extractable signing key. */
46
+ declare function importSigningKey(privateJwk: EcJwk): Promise<CryptoKey>;
47
+ /** A P-256 public JWK as a verifying key. */
48
+ declare function importVerifyingKey(publicJwk: EcJwk): Promise<CryptoKey>;
49
+ /** The public half of a P-256 private JWK — what is published. */
50
+ declare function publicJwkOf(privateJwk: EcJwk): EcJwk;
51
+ /** A fresh key pair as JWKs — for provisioning a deployment's signing key. */
52
+ declare function generateSigningJwk(): Promise<EcJwk>;
53
+ //#endregion
54
+ export { EcJwk, SIGNED_TOKEN_ALG, SigningKey, TokenRefusal, VerifiedToken, generateSigningJwk, importSigningKey, importVerifyingKey, publicJwkOf, signToken, verifyToken };
@@ -0,0 +1,139 @@
1
+ //#region src/security/signed-token.ts
2
+ /**
3
+ * Signed tokens: compact JWS (RFC 7515), ES256 over WebCrypto — a server and an offline device sign
4
+ * and verify the same bytes with no dependency. Claims follow RFC 7519 (`exp`/`nbf`/`iat` in
5
+ * seconds).
6
+ *
7
+ * const token = await signToken({ sub, exp }, { kid, privateKey }, { typ: 'till-grant+jwt' });
8
+ * const read = await verifyToken<Grant>(token, (kid) => keys.get(kid), { typ: 'till-grant+jwt' });
9
+ *
10
+ * `typ` is required on both sides, so one kind of token is never accepted as another, and a token
11
+ * without `exp` is refused.
12
+ */
13
+ const SIGNED_TOKEN_ALG = "ES256";
14
+ const ECDSA = {
15
+ name: "ECDSA",
16
+ namedCurve: "P-256"
17
+ };
18
+ const SIGN = {
19
+ name: "ECDSA",
20
+ hash: "SHA-256"
21
+ };
22
+ const encoder = new TextEncoder();
23
+ const decoder = new TextDecoder();
24
+ function toBase64Url(bytes) {
25
+ let binary = "";
26
+ for (const byte of bytes) binary += String.fromCharCode(byte);
27
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
28
+ }
29
+ function fromBase64Url(text) {
30
+ if (!/^[A-Za-z0-9_-]*$/.test(text)) return null;
31
+ const binary = atob(text.replace(/-/g, "+").replace(/_/g, "/"));
32
+ const bytes = new Uint8Array(new ArrayBuffer(binary.length));
33
+ for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
34
+ return bytes;
35
+ }
36
+ function parseJson(segment) {
37
+ const bytes = fromBase64Url(segment);
38
+ if (!bytes) return null;
39
+ try {
40
+ const value = JSON.parse(decoder.decode(bytes));
41
+ return value && typeof value === "object" && !Array.isArray(value) ? value : null;
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+ async function signToken(claims, key, options) {
47
+ const input = `${toBase64Url(encoder.encode(JSON.stringify({
48
+ alg: SIGNED_TOKEN_ALG,
49
+ typ: options.typ,
50
+ kid: key.kid
51
+ })))}.${toBase64Url(encoder.encode(JSON.stringify(claims)))}`;
52
+ return `${input}.${toBase64Url(new Uint8Array(await crypto.subtle.sign(SIGN, key.privateKey, encoder.encode(input))))}`;
53
+ }
54
+ async function verifyToken(token, keyOf, options) {
55
+ const parts = typeof token === "string" ? token.split(".") : [];
56
+ if (parts.length !== 3) return {
57
+ ok: false,
58
+ reason: "malformed"
59
+ };
60
+ const [headerPart, payloadPart, signaturePart] = parts;
61
+ const header = parseJson(headerPart);
62
+ const claims = parseJson(payloadPart);
63
+ const signature = fromBase64Url(signaturePart);
64
+ if (!header || !claims || !signature) return {
65
+ ok: false,
66
+ reason: "malformed"
67
+ };
68
+ if (header.alg !== "ES256" || header.crit !== void 0) return {
69
+ ok: false,
70
+ reason: "unsupported"
71
+ };
72
+ if (header.typ !== options.typ) return {
73
+ ok: false,
74
+ reason: "wrong_type"
75
+ };
76
+ if (typeof header.kid !== "string") return {
77
+ ok: false,
78
+ reason: "unknown_key"
79
+ };
80
+ const key = await keyOf(header.kid);
81
+ if (!key) return {
82
+ ok: false,
83
+ reason: "unknown_key"
84
+ };
85
+ if (!await crypto.subtle.verify(SIGN, key, signature, encoder.encode(`${headerPart}.${payloadPart}`))) return {
86
+ ok: false,
87
+ reason: "bad_signature"
88
+ };
89
+ const now = Math.floor((options.now ?? /* @__PURE__ */ new Date()).getTime() / 1e3);
90
+ const leeway = options.leewaySeconds ?? 0;
91
+ if (typeof claims.exp !== "number") return {
92
+ ok: false,
93
+ reason: "no_expiry"
94
+ };
95
+ if (now >= claims.exp + leeway) return {
96
+ ok: false,
97
+ reason: "expired"
98
+ };
99
+ if (typeof claims.nbf === "number" && now < claims.nbf - leeway) return {
100
+ ok: false,
101
+ reason: "not_yet_valid"
102
+ };
103
+ return {
104
+ ok: true,
105
+ claims,
106
+ kid: header.kid
107
+ };
108
+ }
109
+ /** A P-256 private JWK as a non-extractable signing key. */
110
+ function importSigningKey(privateJwk) {
111
+ return crypto.subtle.importKey("jwk", privateJwk, ECDSA, false, ["sign"]);
112
+ }
113
+ /** A P-256 public JWK as a verifying key. */
114
+ function importVerifyingKey(publicJwk) {
115
+ const { kty, crv, x, y } = publicJwk;
116
+ return crypto.subtle.importKey("jwk", {
117
+ kty,
118
+ crv,
119
+ x,
120
+ y
121
+ }, ECDSA, false, ["verify"]);
122
+ }
123
+ /** The public half of a P-256 private JWK — what is published. */
124
+ function publicJwkOf(privateJwk) {
125
+ const { kty, crv, x, y } = privateJwk;
126
+ return {
127
+ kty,
128
+ crv,
129
+ x,
130
+ y
131
+ };
132
+ }
133
+ /** A fresh key pair as JWKs — for provisioning a deployment's signing key. */
134
+ async function generateSigningJwk() {
135
+ const pair = await crypto.subtle.generateKey(ECDSA, true, ["sign", "verify"]);
136
+ return await crypto.subtle.exportKey("jwk", pair.privateKey);
137
+ }
138
+ //#endregion
139
+ export { SIGNED_TOKEN_ALG, generateSigningJwk, importSigningKey, importVerifyingKey, publicJwkOf, signToken, verifyToken };