@classytic/primitives 0.28.0 → 0.30.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/CHANGELOG.md +1217 -1119
- package/dist/approval.d.mts +98 -6
- package/dist/approval.mjs +61 -4
- package/dist/canonical.d.mts +6 -1
- package/dist/canonical.mjs +10 -4
- package/dist/durable-events.d.mts +83 -0
- package/dist/durable-events.mjs +115 -0
- package/dist/environment.d.mts +30 -1
- package/dist/environment.mjs +72 -1
- package/dist/events.d.mts +45 -7
- package/dist/events.mjs +174 -7
- package/dist/idempotency.d.mts +4 -0
- package/dist/idempotency.mjs +4 -0
- package/dist/item-stock.d.mts +43 -0
- package/dist/item-stock.mjs +67 -0
- package/dist/legal-form.d.mts +12 -0
- package/dist/legal-form.mjs +27 -0
- package/dist/line-settlement.d.mts +70 -0
- package/dist/line-settlement.mjs +79 -0
- package/dist/memory-outbox.d.mts +1 -0
- package/dist/memory-outbox.mjs +18 -2
- package/dist/monetization.d.mts +3 -1
- package/dist/monetization.mjs +7 -1
- package/dist/outbox.d.mts +46 -5
- package/dist/outbox.mjs +43 -1
- package/dist/payment-events.d.mts +6 -0
- package/dist/period.d.mts +22 -1
- package/dist/period.mjs +30 -1
- package/dist/phone.d.mts +13 -1
- package/dist/phone.mjs +21 -1
- package/dist/proration.d.mts +11 -1
- package/dist/proration.mjs +24 -1
- package/dist/signed-token.d.mts +54 -0
- package/dist/signed-token.mjs +139 -0
- package/dist/split-allocation.d.mts +20 -2
- package/dist/split-allocation.mjs +57 -1
- package/package.json +364 -285
package/dist/monetization.mjs
CHANGED
|
@@ -164,6 +164,12 @@ function compareAtPriceOf(m) {
|
|
|
164
164
|
if (m.type === "pass") return defaultPassOf(m)?.compareAtPrice ?? null;
|
|
165
165
|
return null;
|
|
166
166
|
}
|
|
167
|
+
/** On sale: a compare-at price above the selling price, same currency. The ONE on-sale predicate. */
|
|
168
|
+
function isOnSale(m) {
|
|
169
|
+
const compareAt = compareAtPriceOf(m);
|
|
170
|
+
const price = unitPriceOf(m);
|
|
171
|
+
return compareAt !== null && price !== null && compareAt.currency === price.currency && compareAt.amount > price.amount;
|
|
172
|
+
}
|
|
167
173
|
/**
|
|
168
174
|
* Canonical kind → revenue wire type. Everything that is not `free` or
|
|
169
175
|
* `subscription` settles as a `purchase` in revenue's ledger vocabulary
|
|
@@ -184,4 +190,4 @@ function fromRevenueMonetizationType(type) {
|
|
|
184
190
|
return "one_time";
|
|
185
191
|
}
|
|
186
192
|
//#endregion
|
|
187
|
-
export { MONETIZATION_KINDS, UPFRONT_COLLECTION_MODES, approxTermDays, compareAtPriceOf, defaultPassOf, fromRevenueMonetizationType, isBundleMonetization, isFreeMonetization, isMonetizationKind, isOneTimeMonetization, isPassMonetization, isSubscriptionMonetization, isUpfrontCollectionMode, isUsageMonetization, monetizationKindOf, toRevenueMonetizationType, unitPriceOf };
|
|
193
|
+
export { MONETIZATION_KINDS, UPFRONT_COLLECTION_MODES, approxTermDays, compareAtPriceOf, defaultPassOf, fromRevenueMonetizationType, isBundleMonetization, isFreeMonetization, isMonetizationKind, isOnSale, isOneTimeMonetization, isPassMonetization, isSubscriptionMonetization, isUpfrontCollectionMode, isUsageMonetization, monetizationKindOf, toRevenueMonetizationType, unitPriceOf };
|
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).
|
|
85
|
-
*
|
|
86
|
-
*
|
|
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
|
}
|
|
@@ -124,6 +141,23 @@ declare class OutboxOwnershipError extends Error {
|
|
|
124
141
|
readonly currentOwner: string | null;
|
|
125
142
|
constructor(eventId: string, attemptedBy: string, currentOwner: string | null);
|
|
126
143
|
}
|
|
144
|
+
/**
|
|
145
|
+
* Thrown by `save` when a row with this event id already exists with a DIFFERENT payload.
|
|
146
|
+
* A same-id, same-payload save is an idempotent success; a different payload is two
|
|
147
|
+
* occurrences minted onto one id, and dropping either one loses money silently.
|
|
148
|
+
*/
|
|
149
|
+
declare class OutboxConflictError extends Error {
|
|
150
|
+
readonly name = "OutboxConflictError";
|
|
151
|
+
readonly code = "outbox.conflict";
|
|
152
|
+
readonly eventId: string;
|
|
153
|
+
readonly eventType: string;
|
|
154
|
+
constructor(eventId: string, eventType: string);
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Order-independent fingerprint of an event payload as it travels (JSON: `Date` becomes its
|
|
158
|
+
* ISO string, `undefined` drops). Stores compare it on a duplicate id; see {@link OutboxConflictError}.
|
|
159
|
+
*/
|
|
160
|
+
declare function outboxPayloadFingerprint(event: DomainEvent): string;
|
|
127
161
|
/** Thrown when an event missing `type` or `meta.id` is passed to the outbox. */
|
|
128
162
|
declare class InvalidOutboxEventError extends Error {
|
|
129
163
|
readonly name = "InvalidOutboxEventError";
|
|
@@ -218,7 +252,8 @@ interface OutboxStore {
|
|
|
218
252
|
/**
|
|
219
253
|
* Save an event to the outbox (typically called inside a business
|
|
220
254
|
* transaction). MUST reject events missing `type` or `meta.id` — throw
|
|
221
|
-
* rather than persist.
|
|
255
|
+
* rather than persist. A second save of an existing `meta.id` is a no-op when
|
|
256
|
+
* the payload matches, and throws {@link OutboxConflictError} when it does not.
|
|
222
257
|
*/
|
|
223
258
|
save(event: DomainEvent, options?: OutboxWriteOptions): Promise<void>;
|
|
224
259
|
/**
|
|
@@ -250,6 +285,12 @@ interface OutboxStore {
|
|
|
250
285
|
* @throws {@link OutboxOwnershipError} on ownership mismatch
|
|
251
286
|
*/
|
|
252
287
|
acknowledge(eventId: string, options?: OutboxAcknowledgeOptions): Promise<void>;
|
|
288
|
+
/**
|
|
289
|
+
* Mark every listed event delivered in ONE write — what a unit of work's post-commit flush
|
|
290
|
+
* calls, so N delivered events cost one command, not N. Unleased rows only (no fencing);
|
|
291
|
+
* unknown or already-delivered ids are ignored.
|
|
292
|
+
*/
|
|
293
|
+
acknowledgeMany?(eventIds: readonly string[]): Promise<void>;
|
|
253
294
|
/**
|
|
254
295
|
* Record a relay failure. When implemented, the relay calls this instead
|
|
255
296
|
* of stopping the batch — enables retry scheduling and DLQ.
|
|
@@ -318,4 +359,4 @@ interface OutboxStore {
|
|
|
318
359
|
oldestPendingAgeMs?(): Promise<number | null>;
|
|
319
360
|
}
|
|
320
361
|
//#endregion
|
|
321
|
-
export { InvalidOutboxEventError, OUTBOX_STATUSES, OutboxAcknowledgeOptions, OutboxClaimOptions, OutboxClaimedEvent, OutboxErrorInfo, OutboxFailOptions, OutboxFailureContext, OutboxFailureDecision, OutboxFailurePolicy, OutboxOwnershipError, OutboxStatus, OutboxStore, OutboxWriteOptions };
|
|
362
|
+
export { InvalidOutboxEventError, OUTBOX_STATUSES, OutboxAcknowledgeOptions, OutboxClaimOptions, OutboxClaimedEvent, OutboxConflictError, OutboxErrorInfo, OutboxFailOptions, OutboxFailureContext, OutboxFailureDecision, OutboxFailurePolicy, OutboxOwnershipError, OutboxStatus, OutboxStore, OutboxWriteOptions, outboxPayloadFingerprint };
|
package/dist/outbox.mjs
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
|
+
import { canonicalDigest } from "./canonical.mjs";
|
|
1
2
|
//#region src/events/outbox.ts
|
|
2
3
|
/**
|
|
4
|
+
* Transactional outbox CONTRACT — the canonical owner. `@classytic/arc`
|
|
5
|
+
* re-exports this; it keeps no copy of its own.
|
|
6
|
+
*
|
|
7
|
+
* Contract surface only: {@link OutboxStore}, its option types, the
|
|
8
|
+
* {@link OutboxFailurePolicy} retry/DLQ contract, and the error classes.
|
|
9
|
+
*
|
|
10
|
+
* The split exists because a domain package MUST NOT peer-dep arc. Owning the
|
|
11
|
+
* contract here lets a package write `implements OutboxStore` and hand the
|
|
12
|
+
* result to arc's `new EventOutbox({ store })`, and since arc re-exports these
|
|
13
|
+
* exact classes, `instanceof OutboxOwnershipError` works across the boundary.
|
|
14
|
+
*
|
|
15
|
+
* Runtimes live elsewhere: the DURABLE one (`EventOutbox` relay, Mongo store,
|
|
16
|
+
* backoff, Fastify wiring) in `@classytic/arc/events`; the dependency-free
|
|
17
|
+
* in-process reference (kernel bus + memory store, for tests/dev) in
|
|
18
|
+
* `@classytic/primitives/event-infra`.
|
|
19
|
+
*/
|
|
20
|
+
/**
|
|
3
21
|
* Thrown by a store when `acknowledge` / `fail` is called by a consumer that
|
|
4
22
|
* does not own the event's current lease.
|
|
5
23
|
*
|
|
@@ -19,6 +37,30 @@ var OutboxOwnershipError = class extends Error {
|
|
|
19
37
|
this.currentOwner = currentOwner;
|
|
20
38
|
}
|
|
21
39
|
};
|
|
40
|
+
/**
|
|
41
|
+
* Thrown by `save` when a row with this event id already exists with a DIFFERENT payload.
|
|
42
|
+
* A same-id, same-payload save is an idempotent success; a different payload is two
|
|
43
|
+
* occurrences minted onto one id, and dropping either one loses money silently.
|
|
44
|
+
*/
|
|
45
|
+
var OutboxConflictError = class extends Error {
|
|
46
|
+
name = "OutboxConflictError";
|
|
47
|
+
code = "outbox.conflict";
|
|
48
|
+
eventId;
|
|
49
|
+
eventType;
|
|
50
|
+
constructor(eventId, eventType) {
|
|
51
|
+
super(`Outbox already holds event "${eventId}" (${eventType}) with a different payload — two occurrences share one id. Derive the occurrence from the aggregate id plus the step.`);
|
|
52
|
+
this.eventId = eventId;
|
|
53
|
+
this.eventType = eventType;
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* Order-independent fingerprint of an event payload as it travels (JSON: `Date` becomes its
|
|
58
|
+
* ISO string, `undefined` drops). Stores compare it on a duplicate id; see {@link OutboxConflictError}.
|
|
59
|
+
*/
|
|
60
|
+
function outboxPayloadFingerprint(event) {
|
|
61
|
+
const wire = JSON.parse(JSON.stringify(event.payload ?? null));
|
|
62
|
+
return canonicalDigest(wire);
|
|
63
|
+
}
|
|
22
64
|
/** Thrown when an event missing `type` or `meta.id` is passed to the outbox. */
|
|
23
65
|
var InvalidOutboxEventError = class extends Error {
|
|
24
66
|
name = "InvalidOutboxEventError";
|
|
@@ -90,4 +132,4 @@ const OUTBOX_STATUSES = [
|
|
|
90
132
|
"dead_letter"
|
|
91
133
|
];
|
|
92
134
|
//#endregion
|
|
93
|
-
export { InvalidOutboxEventError, OUTBOX_STATUSES, OutboxOwnershipError };
|
|
135
|
+
export { InvalidOutboxEventError, OUTBOX_STATUSES, OutboxConflictError, OutboxOwnershipError, outboxPayloadFingerprint };
|
|
@@ -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 };
|
package/dist/proration.d.mts
CHANGED
|
@@ -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 };
|
package/dist/proration.mjs
CHANGED
|
@@ -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 };
|
|
@@ -60,7 +60,7 @@ interface SplitResult {
|
|
|
60
60
|
readonly method: SplitMethod;
|
|
61
61
|
readonly parts: readonly SplitPart[];
|
|
62
62
|
}
|
|
63
|
-
type SplitAllocationErrorCode = 'INVALID_TOTAL' | 'EMPTY_SUBJECTS' | 'INVALID_WEIGHT' | 'INVALID_PERCENT' | 'ZERO_WEIGHT_SUM' | 'DUPLICATE_SUBJECT_ID';
|
|
63
|
+
type SplitAllocationErrorCode = 'INVALID_TOTAL' | 'EMPTY_SUBJECTS' | 'INVALID_WEIGHT' | 'INVALID_PERCENT' | 'ZERO_WEIGHT_SUM' | 'DUPLICATE_SUBJECT_ID' | 'DISCOUNT_EXCEEDS_BASE';
|
|
64
64
|
declare class SplitAllocationError extends Error {
|
|
65
65
|
readonly name = "SplitAllocationError";
|
|
66
66
|
readonly code: SplitAllocationErrorCode;
|
|
@@ -80,5 +80,23 @@ declare class SplitAllocationError extends Error {
|
|
|
80
80
|
declare function allocate(total: number, subjects: readonly SplitSubject[], method: SplitMethod): SplitResult;
|
|
81
81
|
/** True if `sum(parts[i].amount) === result.total`. Useful as an assertion in host code. */
|
|
82
82
|
declare function isBalanced(result: SplitResult): boolean;
|
|
83
|
+
/** A line a document-level discount may be spread over. */
|
|
84
|
+
interface DiscountSubject {
|
|
85
|
+
readonly id: string;
|
|
86
|
+
/** The line's value before the discount, integer minor units (non-negative). */
|
|
87
|
+
readonly weight: number;
|
|
88
|
+
/** `false` for a line the discount was never computed on (a delivery charge, an auto-added fee). Absent ⇒ eligible. */
|
|
89
|
+
readonly discountable?: boolean;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* THE discount allocation: spread a document-level discount over its eligible lines, by value,
|
|
93
|
+
* exact largest remainder. Every document that splits one discount — the order, its invoice,
|
|
94
|
+
* its credit notes, a return's value — calls this, so they agree to the minor unit.
|
|
95
|
+
*
|
|
96
|
+
* Returns a FULL map in input order: every subject id, `0` for an ineligible or zero-weight one.
|
|
97
|
+
* `0` discount allocates zeros; a discount equal to the eligible base allocates all of it; a
|
|
98
|
+
* discount above it throws `DISCOUNT_EXCEEDS_BASE` — the figures disagree.
|
|
99
|
+
*/
|
|
100
|
+
declare function allocateDiscount(discount: number, subjects: readonly DiscountSubject[]): ReadonlyMap<string, number>;
|
|
83
101
|
//#endregion
|
|
84
|
-
export { SplitAllocationError, SplitAllocationErrorCode, SplitMethod, SplitPart, SplitResult, SplitSubject, allocate, isBalanced };
|
|
102
|
+
export { DiscountSubject, SplitAllocationError, SplitAllocationErrorCode, SplitMethod, SplitPart, SplitResult, SplitSubject, allocate, allocateDiscount, isBalanced };
|