@classytic/primitives 0.18.0 → 0.20.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 +906 -715
- package/README.md +160 -159
- package/dist/approval.d.mts +49 -10
- package/dist/approval.mjs +44 -10
- package/dist/bank-transaction.d.mts +1 -1
- package/dist/cadence-BFQVC1Qp.d.mts +107 -0
- package/dist/cadence.d.mts +1 -92
- package/dist/cadence.mjs +133 -4
- package/dist/calendar-B6fm-FbR.d.mts +46 -0
- package/dist/calendar.d.mts +1 -45
- package/dist/canonical.mjs +17 -0
- package/dist/context.d.mts +28 -1
- package/dist/currency.d.mts +2 -2
- package/dist/currency.mjs +134 -19
- package/dist/event-infra.d.mts +1 -34
- package/dist/event-infra.mjs +16 -46
- package/dist/idempotency.d.mts +253 -0
- package/dist/idempotency.mjs +226 -0
- package/dist/memory-outbox.d.mts +64 -0
- package/dist/memory-outbox.mjs +172 -0
- package/dist/monetization.d.mts +195 -0
- package/dist/monetization.mjs +79 -0
- package/dist/money-DPo9fLBi.d.mts +347 -0
- package/dist/money.d.mts +1 -140
- package/dist/money.mjs +6 -15
- package/dist/outbox.d.mts +49 -1
- package/dist/outbox.mjs +64 -1
- package/dist/payment-events.d.mts +132 -4
- package/dist/payment-events.mjs +22 -2
- package/dist/payment-gateway.d.mts +210 -7
- package/dist/payment-gateway.mjs +18 -1
- package/dist/period.d.mts +138 -7
- package/dist/period.mjs +316 -5
- package/dist/person-parse.d.mts +82 -0
- package/dist/person-parse.mjs +109 -0
- package/dist/proration.d.mts +76 -0
- package/dist/proration.mjs +132 -0
- package/dist/sla-policy.d.mts +1 -1
- package/dist/state-machine.mjs +13 -0
- package/dist/suspension.d.mts +1 -1
- package/dist/timezone-tlG7ygwx.d.mts +114 -0
- package/dist/timezone.d.mts +2 -100
- package/dist/timezone.mjs +38 -3
- package/dist/unit-cost-rate.d.mts +1 -2
- package/package.json +244 -224
- package/dist/currency-BCJnJBlb.d.mts +0 -119
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
//#region src/identity/person-parse.ts
|
|
2
|
+
/**
|
|
3
|
+
* Does this `PersonName` carry at least one usable, non-empty name part?
|
|
4
|
+
*
|
|
5
|
+
* Used by both the customer-model virtuals (`fullName` / `displayName`) and
|
|
6
|
+
* any code path that needs to decide "is this customer effectively unnamed".
|
|
7
|
+
* Defends against the legacy data shape and any future write that lands an
|
|
8
|
+
* all-empty `{ given: '', family: '' }`. Without this, `formatDisplayName`
|
|
9
|
+
* from `@classytic/primitives` returns the literal string `"undefined
|
|
10
|
+
* undefined"` for empty names — a bug we've patched at the write boundary
|
|
11
|
+
* but keep guarding at the read boundary too (defense in depth).
|
|
12
|
+
*/
|
|
13
|
+
function hasUsableName(name) {
|
|
14
|
+
if (!name) return false;
|
|
15
|
+
return Boolean(name.preferred?.trim() || name.given?.trim() || name.family?.trim() || name.middle?.trim() || name.prefix?.trim() || name.suffix?.trim());
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Detect a "looks like an identifier, not a person name" string.
|
|
19
|
+
*
|
|
20
|
+
* Better Auth signs OAuth users in with `user.name` populated from the
|
|
21
|
+
* provider — but Apple-private-relay / Google-custom-name flows (and our
|
|
22
|
+
* own guest-checkout fallback) sometimes put the BA `user.id` string into
|
|
23
|
+
* `user.name`. That id is a 20-char base64-url-safe nanoid like
|
|
24
|
+
* `gcqAUBgGpRnDZbyPgKbS`, and storing it as `name.given` leaks the
|
|
25
|
+
* internal id into the dashboard customers table.
|
|
26
|
+
*
|
|
27
|
+
* Heuristic (cheap, no false-positives observed on real BD names):
|
|
28
|
+
* - 12+ chars
|
|
29
|
+
* - no whitespace (real "full names" have spaces; single-name locales
|
|
30
|
+
* are usually < 12 chars: "Sadman", "Nirjhar", "Subarna", etc.)
|
|
31
|
+
* - at least 3 uppercase AND 3 lowercase letters (Title-Case names
|
|
32
|
+
* don't qualify — "Sadman Chowdhury" has 2 uppercase chars; even
|
|
33
|
+
* the longest single Bangla romanisation rarely hits 3+ uppercase
|
|
34
|
+
* in one token)
|
|
35
|
+
* - OR a bare 24-char lowercase hex (ObjectId)
|
|
36
|
+
*
|
|
37
|
+
* If a real name ever gets misclassified, the customer can edit it from
|
|
38
|
+
* the profile page and the heuristic only runs at create / link time.
|
|
39
|
+
*/
|
|
40
|
+
function looksLikeIdentifierNotName(value) {
|
|
41
|
+
if (!value) return false;
|
|
42
|
+
if (/^[a-f0-9]{24}$/.test(value)) return true;
|
|
43
|
+
if (value.length < 12) return false;
|
|
44
|
+
if (/\s/.test(value)) return false;
|
|
45
|
+
const upper = (value.match(/[A-Z]/g) ?? []).length;
|
|
46
|
+
const lower = (value.match(/[a-z]/g) ?? []).length;
|
|
47
|
+
return upper >= 3 && lower >= 3;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Pick a display fallback when `user.name` is missing or token-like.
|
|
51
|
+
* Prefers the email local-part (capitalised) so customers see a recognisable
|
|
52
|
+
* label in the dashboard instead of "Unknown" or a leaked id.
|
|
53
|
+
*/
|
|
54
|
+
function fallbackNameFromUser(user, defaultLabel) {
|
|
55
|
+
const email = user.email?.trim();
|
|
56
|
+
if (email?.includes("@")) {
|
|
57
|
+
const cleaned = (email.split("@", 1)[0] ?? "").replace(/[^a-zA-Z]+/g, " ").trim();
|
|
58
|
+
if (cleaned.length >= 2) return cleaned.split(/\s+/).map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()).join(" ");
|
|
59
|
+
}
|
|
60
|
+
return defaultLabel;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Sanitize a display-name string before it gets snapshotted into a
|
|
64
|
+
* derivative collection (order, invoice, shipment, transaction, etc.).
|
|
65
|
+
*
|
|
66
|
+
* The customer-creation boundary (`customer.repository.ts`) already
|
|
67
|
+
* scrubs token-shaped names. This helper is the second line of defence
|
|
68
|
+
* for write paths that copy `customer.name` somewhere else — if the
|
|
69
|
+
* source ever regressed, the snapshot still wouldn't carry the leak.
|
|
70
|
+
*
|
|
71
|
+
* Returns:
|
|
72
|
+
* - the trimmed input when it's a real-looking name
|
|
73
|
+
* - `fallback` when the input is empty, undefined, or token-shaped
|
|
74
|
+
*
|
|
75
|
+
* Caller is responsible for picking a sensible `fallback` (e.g. the
|
|
76
|
+
* email local-part, "Customer", "Walk-in", etc.).
|
|
77
|
+
*/
|
|
78
|
+
function sanitizeDisplayName(value, fallback) {
|
|
79
|
+
const trimmed = (value ?? "").trim();
|
|
80
|
+
if (!trimmed) return fallback;
|
|
81
|
+
if (looksLikeIdentifierNotName(trimmed)) return fallback;
|
|
82
|
+
return trimmed;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Split a flat "Full Name" string into a structured `PersonName`.
|
|
86
|
+
*
|
|
87
|
+
* Callers at the auth / POS boundary pass flat names; storage is structured.
|
|
88
|
+
* Single-token names go in `given` with an empty `family` (many BD and
|
|
89
|
+
* single-name locales store a single name, which is valid).
|
|
90
|
+
*
|
|
91
|
+
* `user` (optional) supplies an `email` so we can derive a sensible display
|
|
92
|
+
* fallback when the input is empty or looks like a BA user id rather than
|
|
93
|
+
* a real name. Without `user`, the static `fallback` arg is the floor.
|
|
94
|
+
*/
|
|
95
|
+
function nameFromString(flat, fallback, user) {
|
|
96
|
+
const trimmed = (flat ?? "").trim();
|
|
97
|
+
const parts = (trimmed && !looksLikeIdentifierNotName(trimmed) ? trimmed : user ? fallbackNameFromUser(user, fallback) : fallback).split(/\s+/);
|
|
98
|
+
if (parts.length <= 1) return {
|
|
99
|
+
given: parts[0] ?? fallback,
|
|
100
|
+
family: ""
|
|
101
|
+
};
|
|
102
|
+
const family = parts.pop() ?? "";
|
|
103
|
+
return {
|
|
104
|
+
given: parts.join(" "),
|
|
105
|
+
family
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
//#endregion
|
|
109
|
+
export { fallbackNameFromUser, hasUsableName, looksLikeIdentifierNotName, nameFromString, sanitizeDisplayName };
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { n as Money } from "./money-DPo9fLBi.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/money/proration.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* How to count days within a period.
|
|
6
|
+
*
|
|
7
|
+
* - `'whole_day'` (default) — integer calendar-day counts. Callers normalize
|
|
8
|
+
* `periodStart`/`periodEnd`/`asOf` to business-local midnight first, so this
|
|
9
|
+
* matches "day-of-month" proration (the Stripe default, how ~99% of SaaS
|
|
10
|
+
* bill). `periodDays` is clamped to `>= 1`.
|
|
11
|
+
* - `'exact'` — fractional wall-clock day counts (`ms / 86_400_000`). Use when
|
|
12
|
+
* cycles are short and a partial day must be billed precisely.
|
|
13
|
+
*/
|
|
14
|
+
type ProrationGranularity = 'whole_day' | 'exact';
|
|
15
|
+
/** The elapsed/remaining split of a billing period at a change instant. */
|
|
16
|
+
interface PeriodFraction {
|
|
17
|
+
/** Total days in the period. `>= 1` for `whole_day`; `> 0` for `exact`. */
|
|
18
|
+
readonly periodDays: number;
|
|
19
|
+
/** Days consumed as of the change instant, clamped to `[0, periodDays]`. */
|
|
20
|
+
readonly elapsedDays: number;
|
|
21
|
+
/** `periodDays - elapsedDays`. */
|
|
22
|
+
readonly remainingDays: number;
|
|
23
|
+
/** `remainingDays / periodDays`, in `[0, 1]`. */
|
|
24
|
+
readonly remainingFraction: number;
|
|
25
|
+
}
|
|
26
|
+
interface PeriodProgressInput {
|
|
27
|
+
/** Period start instant (business-tz-normalized by the caller). */
|
|
28
|
+
readonly periodStart: Date;
|
|
29
|
+
/** Period end instant — must be strictly after `periodStart`. */
|
|
30
|
+
readonly periodEnd: Date;
|
|
31
|
+
/** The change/quote instant. Clamped into `[periodStart, periodEnd]`. */
|
|
32
|
+
readonly asOf: Date;
|
|
33
|
+
/** Day-counting granularity. Default `'whole_day'`. */
|
|
34
|
+
readonly granularity?: ProrationGranularity;
|
|
35
|
+
}
|
|
36
|
+
type ProrationErrorCode = 'INVALID_PERIOD' | 'INVALID_FRACTION' | 'ALLOCATION_INVARIANT';
|
|
37
|
+
declare class ProrationError extends Error {
|
|
38
|
+
readonly name = "ProrationError";
|
|
39
|
+
readonly code: ProrationErrorCode;
|
|
40
|
+
constructor(code: ProrationErrorCode, message: string);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Compute how much of a period is elapsed vs remaining at `asOf`.
|
|
44
|
+
*
|
|
45
|
+
* `asOf` before the period start ⇒ nothing elapsed (full remaining);
|
|
46
|
+
* `asOf` after the period end ⇒ fully elapsed (nothing remaining).
|
|
47
|
+
*
|
|
48
|
+
* @throws ProrationError('INVALID_PERIOD') if a date is invalid or
|
|
49
|
+
* `periodEnd <= periodStart`.
|
|
50
|
+
*/
|
|
51
|
+
declare function periodProgress(input: PeriodProgressInput): PeriodFraction;
|
|
52
|
+
/**
|
|
53
|
+
* Split a `Money` amount into its consumed and remaining parts across a
|
|
54
|
+
* {@link PeriodFraction}, drift-free: `consumed.amount + remaining.amount ===
|
|
55
|
+
* amount.amount` exactly (largest-remainder allocation). Sign is preserved, so
|
|
56
|
+
* a negative amount (a reversal) splits symmetrically.
|
|
57
|
+
*
|
|
58
|
+
* This is the load-bearing primitive for proration: the "unused value" a
|
|
59
|
+
* plan-change credits is `remaining`; the "already-consumed value" is
|
|
60
|
+
* `consumed`.
|
|
61
|
+
*/
|
|
62
|
+
declare function splitByPeriodFraction(amount: Money, fraction: PeriodFraction): {
|
|
63
|
+
readonly consumed: Money;
|
|
64
|
+
readonly remaining: Money;
|
|
65
|
+
};
|
|
66
|
+
/**
|
|
67
|
+
* Allocate the `fraction`-weighted part of a `Money` amount, drift-free — the
|
|
68
|
+
* complement (`1 - fraction`) is discarded but computed so rounding is exact
|
|
69
|
+
* against the whole. `fraction` must be in `[0, 1]`.
|
|
70
|
+
*
|
|
71
|
+
* @throws ProrationError('INVALID_FRACTION') if `fraction` is outside `[0, 1]`
|
|
72
|
+
* or not finite.
|
|
73
|
+
*/
|
|
74
|
+
declare function allocateMoneyByFraction(amount: Money, fraction: number): Money;
|
|
75
|
+
//#endregion
|
|
76
|
+
export { PeriodFraction, PeriodProgressInput, ProrationError, ProrationErrorCode, ProrationGranularity, allocateMoneyByFraction, periodProgress, splitByPeriodFraction };
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { allocate } from "./split-allocation.mjs";
|
|
2
|
+
//#region src/money/proration.ts
|
|
3
|
+
/**
|
|
4
|
+
* Pure proration ARITHMETIC — fraction / period / allocation / rounding only.
|
|
5
|
+
*
|
|
6
|
+
* This module deliberately holds **no commercial policy**. It does not know
|
|
7
|
+
* what "an upgrade" is, whether a discount is creditable, how tax is treated,
|
|
8
|
+
* or whether a downgrade yields cash vs store credit. Those decisions live in
|
|
9
|
+
* the subscription/contract kernel (`@classytic/contract`
|
|
10
|
+
* `calculatePlanChangeProration(...)`), which composes these primitives.
|
|
11
|
+
*
|
|
12
|
+
* It is also **timezone-agnostic**: callers pass already-resolved `Date`
|
|
13
|
+
* instants (normalized to the deployment's business timezone via
|
|
14
|
+
* `@classytic/primitives/timezone` upstream). This module never reads a clock
|
|
15
|
+
* and never applies an offset — pass instants, get numbers.
|
|
16
|
+
*
|
|
17
|
+
* Money is integer minor units throughout; splits use the largest-remainder
|
|
18
|
+
* allocator so `consumed + remaining === amount` exactly, with no penny drift.
|
|
19
|
+
*/
|
|
20
|
+
const MS_PER_DAY = 864e5;
|
|
21
|
+
var ProrationError = class extends Error {
|
|
22
|
+
name = "ProrationError";
|
|
23
|
+
code;
|
|
24
|
+
constructor(code, message) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.code = code;
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
function isValidDate(d) {
|
|
30
|
+
return d instanceof Date && !Number.isNaN(d.getTime());
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Compute how much of a period is elapsed vs remaining at `asOf`.
|
|
34
|
+
*
|
|
35
|
+
* `asOf` before the period start ⇒ nothing elapsed (full remaining);
|
|
36
|
+
* `asOf` after the period end ⇒ fully elapsed (nothing remaining).
|
|
37
|
+
*
|
|
38
|
+
* @throws ProrationError('INVALID_PERIOD') if a date is invalid or
|
|
39
|
+
* `periodEnd <= periodStart`.
|
|
40
|
+
*/
|
|
41
|
+
function periodProgress(input) {
|
|
42
|
+
const { periodStart, periodEnd, asOf, granularity = "whole_day" } = input;
|
|
43
|
+
if (!isValidDate(periodStart) || !isValidDate(periodEnd) || !isValidDate(asOf)) throw new ProrationError("INVALID_PERIOD", "periodStart, periodEnd and asOf must be valid Dates");
|
|
44
|
+
const startMs = periodStart.getTime();
|
|
45
|
+
const endMs = periodEnd.getTime();
|
|
46
|
+
if (endMs <= startMs) throw new ProrationError("INVALID_PERIOD", `periodEnd (${periodEnd.toISOString()}) must be after periodStart (${periodStart.toISOString()})`);
|
|
47
|
+
const clampedAsOfMs = Math.min(Math.max(asOf.getTime(), startMs), endMs);
|
|
48
|
+
const rawPeriodDays = (endMs - startMs) / MS_PER_DAY;
|
|
49
|
+
const rawElapsedDays = (clampedAsOfMs - startMs) / MS_PER_DAY;
|
|
50
|
+
let periodDays;
|
|
51
|
+
let elapsedDays;
|
|
52
|
+
if (granularity === "whole_day") {
|
|
53
|
+
periodDays = Math.max(1, Math.round(rawPeriodDays));
|
|
54
|
+
elapsedDays = Math.min(periodDays, Math.max(0, Math.round(rawElapsedDays)));
|
|
55
|
+
} else {
|
|
56
|
+
periodDays = rawPeriodDays;
|
|
57
|
+
elapsedDays = Math.min(periodDays, Math.max(0, rawElapsedDays));
|
|
58
|
+
}
|
|
59
|
+
const remainingDays = periodDays - elapsedDays;
|
|
60
|
+
return {
|
|
61
|
+
periodDays,
|
|
62
|
+
elapsedDays,
|
|
63
|
+
remainingDays,
|
|
64
|
+
remainingFraction: remainingDays / periodDays
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Split a `Money` amount into its consumed and remaining parts across a
|
|
69
|
+
* {@link PeriodFraction}, drift-free: `consumed.amount + remaining.amount ===
|
|
70
|
+
* amount.amount` exactly (largest-remainder allocation). Sign is preserved, so
|
|
71
|
+
* a negative amount (a reversal) splits symmetrically.
|
|
72
|
+
*
|
|
73
|
+
* This is the load-bearing primitive for proration: the "unused value" a
|
|
74
|
+
* plan-change credits is `remaining`; the "already-consumed value" is
|
|
75
|
+
* `consumed`.
|
|
76
|
+
*/
|
|
77
|
+
function splitByPeriodFraction(amount, fraction) {
|
|
78
|
+
const result = allocate(amount.amount, [{
|
|
79
|
+
id: "consumed",
|
|
80
|
+
weight: fraction.elapsedDays
|
|
81
|
+
}, {
|
|
82
|
+
id: "remaining",
|
|
83
|
+
weight: fraction.remainingDays
|
|
84
|
+
}], "by-weight");
|
|
85
|
+
return {
|
|
86
|
+
consumed: {
|
|
87
|
+
amount: requirePart(result, "consumed"),
|
|
88
|
+
currency: amount.currency
|
|
89
|
+
},
|
|
90
|
+
remaining: {
|
|
91
|
+
amount: requirePart(result, "remaining"),
|
|
92
|
+
currency: amount.currency
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Read one allocated part by id, or THROW.
|
|
98
|
+
*
|
|
99
|
+
* This used to be `…?.amount ?? 0`. A missing part is not "no money" — it is a
|
|
100
|
+
* broken allocator, and defaulting it to zero turns that into a silently
|
|
101
|
+
* plausible number: the unused-value credit on a plan change becomes 0 and the
|
|
102
|
+
* customer is quietly not credited. Nothing downstream can tell that apart from
|
|
103
|
+
* a genuinely fully-consumed period.
|
|
104
|
+
*/
|
|
105
|
+
function requirePart(result, id) {
|
|
106
|
+
const part = result.parts.find((p) => p.id === id);
|
|
107
|
+
if (part === void 0) throw new ProrationError("ALLOCATION_INVARIANT", `allocator returned no part '${id}' (got: ${result.parts.map((p) => p.id).join(", ") || "none"})`);
|
|
108
|
+
return part.amount;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Allocate the `fraction`-weighted part of a `Money` amount, drift-free — the
|
|
112
|
+
* complement (`1 - fraction`) is discarded but computed so rounding is exact
|
|
113
|
+
* against the whole. `fraction` must be in `[0, 1]`.
|
|
114
|
+
*
|
|
115
|
+
* @throws ProrationError('INVALID_FRACTION') if `fraction` is outside `[0, 1]`
|
|
116
|
+
* or not finite.
|
|
117
|
+
*/
|
|
118
|
+
function allocateMoneyByFraction(amount, fraction) {
|
|
119
|
+
if (!Number.isFinite(fraction) || fraction < 0 || fraction > 1) throw new ProrationError("INVALID_FRACTION", `fraction must be in [0, 1], got ${fraction}`);
|
|
120
|
+
return {
|
|
121
|
+
amount: requirePart(allocate(amount.amount, [{
|
|
122
|
+
id: "part",
|
|
123
|
+
weight: fraction
|
|
124
|
+
}, {
|
|
125
|
+
id: "rest",
|
|
126
|
+
weight: 1 - fraction
|
|
127
|
+
}], "by-weight"), "part"),
|
|
128
|
+
currency: amount.currency
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
//#endregion
|
|
132
|
+
export { ProrationError, allocateMoneyByFraction, periodProgress, splitByPeriodFraction };
|
package/dist/sla-policy.d.mts
CHANGED
package/dist/state-machine.mjs
CHANGED
|
@@ -149,6 +149,19 @@ function defineStateMachine(definition) {
|
|
|
149
149
|
*/
|
|
150
150
|
async function assertAndClaim(machine, repo, id, args) {
|
|
151
151
|
const sources = Array.isArray(args.from) ? args.from : [args.from];
|
|
152
|
+
/**
|
|
153
|
+
* An EMPTY source list must never reach `claim()`.
|
|
154
|
+
*
|
|
155
|
+
* `validSources(to)` returns `[]` for a status nothing transitions into, and
|
|
156
|
+
* the multi-source idiom in the docblock above (`from: machine.validSources(x)`)
|
|
157
|
+
* makes that easy to hit. With `[]` the assert loop below runs ZERO times — the
|
|
158
|
+
* domain check silently does not happen — and the CAS is then handed an empty
|
|
159
|
+
* allow-list. Mongo's `$in: []` matches nothing so today that fails closed and
|
|
160
|
+
* looks exactly like a race-loss (`null`), which is the wrong diagnosis; a kit
|
|
161
|
+
* that read an empty list as "no constraint" would fail OPEN and claim from any
|
|
162
|
+
* state at all. An empty allow-list must never mean "any".
|
|
163
|
+
*/
|
|
164
|
+
if (sources.length === 0) throw new IllegalTransitionError(machine.name, id, "(none)", args.to);
|
|
152
165
|
for (const from of sources) machine.assertTransition(id, from, args.to);
|
|
153
166
|
return repo.claim(id, {
|
|
154
167
|
from: Array.isArray(args.from) ? args.from : args.from,
|
package/dist/suspension.d.mts
CHANGED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { t as Brand } from "./brand-LLXqoh5J.mjs";
|
|
2
|
+
import { s as IsoWeekday } from "./cadence-BFQVC1Qp.mjs";
|
|
3
|
+
import { t as UtcOffsetMinutes } from "./calendar-B6fm-FbR.mjs";
|
|
4
|
+
|
|
5
|
+
//#region src/scheduling/timezone.d.ts
|
|
6
|
+
declare class TimeZoneError extends Error {
|
|
7
|
+
constructor(message: string);
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Is `zone` a valid IANA timezone name on this runtime's ICU data?
|
|
11
|
+
* Use at BOOT to validate host config (an offer's `timezone`, an org
|
|
12
|
+
* setting) — fail loud at wiring time, not on the first localized read.
|
|
13
|
+
*/
|
|
14
|
+
declare function isValidTimeZone(zone: string): boolean;
|
|
15
|
+
/**
|
|
16
|
+
* Every IANA timezone name this runtime supports — the data source for a
|
|
17
|
+
* Google-Calendar-style zone picker. Straight from ICU via
|
|
18
|
+
* `Intl.supportedValuesOf` (Node >= 18); no bundled tz list to go stale.
|
|
19
|
+
*
|
|
20
|
+
* UIs typically label each entry with its current offset:
|
|
21
|
+
* `listTimeZones().map((z) => ({ zone: z, label: zoneOffsetLabel(now, z) }))`.
|
|
22
|
+
*/
|
|
23
|
+
declare function listTimeZones(): readonly string[];
|
|
24
|
+
/**
|
|
25
|
+
* The zone's UTC offset AT `instant`, in minutes east of UTC — DST-exact.
|
|
26
|
+
* `zoneOffsetMinutes(july, 'Europe/London')` → 60; `(january, …)` → 0;
|
|
27
|
+
* `(any, 'Asia/Dhaka')` → 360.
|
|
28
|
+
*
|
|
29
|
+
* This is the value `/calendar`'s docstring tells hosts to "resolve and pass
|
|
30
|
+
* in": `startOfMonth(instant, zoneOffsetMinutes(instant, zone))`.
|
|
31
|
+
*/
|
|
32
|
+
declare function zoneOffsetMinutes(instant: Date, zone: string): UtcOffsetMinutes;
|
|
33
|
+
/**
|
|
34
|
+
* The zone's offset label AT `instant` — `'GMT+06:00'` / `'GMT-05:30'` /
|
|
35
|
+
* `'GMT'`. Ready-made display text for zone pickers.
|
|
36
|
+
*/
|
|
37
|
+
declare function zoneOffsetLabel(instant: Date, zone: string): string;
|
|
38
|
+
interface LocalTimeParts {
|
|
39
|
+
/** ISO weekday in the zone — 1=Mon … 7=Sun (matches `/cadence`). */
|
|
40
|
+
isoWeekday: IsoWeekday;
|
|
41
|
+
/** Hour of day in the zone, 0–23. */
|
|
42
|
+
hour: number;
|
|
43
|
+
/** Minute of hour, 0–59. */
|
|
44
|
+
minute: number;
|
|
45
|
+
/** `hour * 60 + minute` — the shape "HH:MM window" checks want. */
|
|
46
|
+
minutesOfDay: number;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The wall-clock weekday + time of `instant` in `zone` — what pricing
|
|
50
|
+
* schedules ("peak Fri 17:00–22:00"), slot grids, and working-hours checks
|
|
51
|
+
* evaluate against. DST-exact per instant.
|
|
52
|
+
*/
|
|
53
|
+
declare function localTimeParts(instant: Date, zone: string): LocalTimeParts;
|
|
54
|
+
/**
|
|
55
|
+
* A calendar day — `'YYYY-MM-DD'` — with NO time and NO zone until one is
|
|
56
|
+
* bound. The type for hotel nights, POS business dates, statement periods,
|
|
57
|
+
* VAT filing years. Lexicographic order == chronological order, so it's
|
|
58
|
+
* index- and range-query-friendly as a Mongo string key.
|
|
59
|
+
*/
|
|
60
|
+
type CivilDate = Brand<string, 'CivilDate'>;
|
|
61
|
+
/** Runtime guard for external input. */
|
|
62
|
+
declare function isCivilDate(value: unknown): value is CivilDate;
|
|
63
|
+
/** Validate + brand a `'YYYY-MM-DD'` string. Throws on malformed input. */
|
|
64
|
+
declare function civilDate(value: string): CivilDate;
|
|
65
|
+
/**
|
|
66
|
+
* The calendar day `instant` falls on IN `zone` — e.g. the POS business
|
|
67
|
+
* date: `civilDateOf(new Date(), 'Asia/Dhaka')`. Never depends on the
|
|
68
|
+
* server's TZ env.
|
|
69
|
+
*/
|
|
70
|
+
declare function civilDateOf(instant: Date, zone: string): CivilDate;
|
|
71
|
+
/**
|
|
72
|
+
* Bind a civil date (+ optional wall time) to a zone → the exact instant.
|
|
73
|
+
* `civilDateToInstant(night, hotelZone, { hour: 14 })` = check-in instant;
|
|
74
|
+
* with no `time`, local midnight — the DST-exact "start of that day in that
|
|
75
|
+
* zone".
|
|
76
|
+
*
|
|
77
|
+
* Two-pass offset resolution: the offset is looked up AT the candidate
|
|
78
|
+
* instant, then re-checked, so days where a DST transition sits between the
|
|
79
|
+
* wall time and the first guess still resolve correctly. Honest limit: a
|
|
80
|
+
* wall time that does not exist (inside a spring-forward gap) resolves to
|
|
81
|
+
* the instant the shifted clock reaches — the same choice Temporal's
|
|
82
|
+
* `disambiguation: 'compatible'` makes.
|
|
83
|
+
*/
|
|
84
|
+
declare function civilDateToInstant(cd: CivilDate, zone: string, time?: {
|
|
85
|
+
hour?: number;
|
|
86
|
+
minute?: number;
|
|
87
|
+
second?: number;
|
|
88
|
+
millisecond?: number;
|
|
89
|
+
}): Date;
|
|
90
|
+
/**
|
|
91
|
+
* Pure calendar-day arithmetic — `addCivilDays('2026-02-28', 1)` →
|
|
92
|
+
* `'2026-03-01'`. Zone-free by definition (a civil date has no zone);
|
|
93
|
+
* use it to iterate hotel nights / statement days.
|
|
94
|
+
*/
|
|
95
|
+
declare function addCivilDays(cd: CivilDate, days: number): CivilDate;
|
|
96
|
+
/**
|
|
97
|
+
* Whole days from `a` to `b` (positive when `b` is later) — the NIGHTS
|
|
98
|
+
* count for a `[checkIn, checkOut)` stay expressed as civil dates.
|
|
99
|
+
*/
|
|
100
|
+
/**
|
|
101
|
+
* The ISO-8601 week a civil date belongs to, as `YYYY-Www`.
|
|
102
|
+
*
|
|
103
|
+
* Zone-free by definition — a civil date has no zone — so callers resolve the
|
|
104
|
+
* date in their business timezone FIRST (`civilDateOf`) and label it here.
|
|
105
|
+
*
|
|
106
|
+
* ISO week 1 is the week containing the first Thursday, which means the week
|
|
107
|
+
* YEAR can differ from the date's own: 2025-12-29 is `2026-W01`. Naive
|
|
108
|
+
* "day-of-year / 7" numbering gets that wrong every few years, silently
|
|
109
|
+
* splitting one business week across two report buckets.
|
|
110
|
+
*/
|
|
111
|
+
declare function isoWeekOfCivilDate(cd: CivilDate): string;
|
|
112
|
+
declare function civilDaysBetween(a: CivilDate, b: CivilDate): number;
|
|
113
|
+
//#endregion
|
|
114
|
+
export { civilDate as a, civilDaysBetween as c, isoWeekOfCivilDate as d, listTimeZones as f, zoneOffsetMinutes as h, addCivilDays as i, isCivilDate as l, zoneOffsetLabel as m, LocalTimeParts as n, civilDateOf as o, localTimeParts as p, TimeZoneError as r, civilDateToInstant as s, CivilDate as t, isValidTimeZone as u };
|
package/dist/timezone.d.mts
CHANGED
|
@@ -1,100 +1,2 @@
|
|
|
1
|
-
import { t as
|
|
2
|
-
|
|
3
|
-
import { UtcOffsetMinutes } from "./calendar.mjs";
|
|
4
|
-
|
|
5
|
-
//#region src/scheduling/timezone.d.ts
|
|
6
|
-
declare class TimeZoneError extends Error {
|
|
7
|
-
constructor(message: string);
|
|
8
|
-
}
|
|
9
|
-
/**
|
|
10
|
-
* Is `zone` a valid IANA timezone name on this runtime's ICU data?
|
|
11
|
-
* Use at BOOT to validate host config (an offer's `timezone`, an org
|
|
12
|
-
* setting) — fail loud at wiring time, not on the first localized read.
|
|
13
|
-
*/
|
|
14
|
-
declare function isValidTimeZone(zone: string): boolean;
|
|
15
|
-
/**
|
|
16
|
-
* Every IANA timezone name this runtime supports — the data source for a
|
|
17
|
-
* Google-Calendar-style zone picker. Straight from ICU via
|
|
18
|
-
* `Intl.supportedValuesOf` (Node >= 18); no bundled tz list to go stale.
|
|
19
|
-
*
|
|
20
|
-
* UIs typically label each entry with its current offset:
|
|
21
|
-
* `listTimeZones().map((z) => ({ zone: z, label: zoneOffsetLabel(now, z) }))`.
|
|
22
|
-
*/
|
|
23
|
-
declare function listTimeZones(): readonly string[];
|
|
24
|
-
/**
|
|
25
|
-
* The zone's UTC offset AT `instant`, in minutes east of UTC — DST-exact.
|
|
26
|
-
* `zoneOffsetMinutes(july, 'Europe/London')` → 60; `(january, …)` → 0;
|
|
27
|
-
* `(any, 'Asia/Dhaka')` → 360.
|
|
28
|
-
*
|
|
29
|
-
* This is the value `/calendar`'s docstring tells hosts to "resolve and pass
|
|
30
|
-
* in": `startOfMonth(instant, zoneOffsetMinutes(instant, zone))`.
|
|
31
|
-
*/
|
|
32
|
-
declare function zoneOffsetMinutes(instant: Date, zone: string): UtcOffsetMinutes;
|
|
33
|
-
/**
|
|
34
|
-
* The zone's offset label AT `instant` — `'GMT+06:00'` / `'GMT-05:30'` /
|
|
35
|
-
* `'GMT'`. Ready-made display text for zone pickers.
|
|
36
|
-
*/
|
|
37
|
-
declare function zoneOffsetLabel(instant: Date, zone: string): string;
|
|
38
|
-
interface LocalTimeParts {
|
|
39
|
-
/** ISO weekday in the zone — 1=Mon … 7=Sun (matches `/cadence`). */
|
|
40
|
-
isoWeekday: IsoWeekday;
|
|
41
|
-
/** Hour of day in the zone, 0–23. */
|
|
42
|
-
hour: number;
|
|
43
|
-
/** Minute of hour, 0–59. */
|
|
44
|
-
minute: number;
|
|
45
|
-
/** `hour * 60 + minute` — the shape "HH:MM window" checks want. */
|
|
46
|
-
minutesOfDay: number;
|
|
47
|
-
}
|
|
48
|
-
/**
|
|
49
|
-
* The wall-clock weekday + time of `instant` in `zone` — what pricing
|
|
50
|
-
* schedules ("peak Fri 17:00–22:00"), slot grids, and working-hours checks
|
|
51
|
-
* evaluate against. DST-exact per instant.
|
|
52
|
-
*/
|
|
53
|
-
declare function localTimeParts(instant: Date, zone: string): LocalTimeParts;
|
|
54
|
-
/**
|
|
55
|
-
* A calendar day — `'YYYY-MM-DD'` — with NO time and NO zone until one is
|
|
56
|
-
* bound. The type for hotel nights, POS business dates, statement periods,
|
|
57
|
-
* VAT filing years. Lexicographic order == chronological order, so it's
|
|
58
|
-
* index- and range-query-friendly as a Mongo string key.
|
|
59
|
-
*/
|
|
60
|
-
type CivilDate = Brand<string, 'CivilDate'>;
|
|
61
|
-
/** Runtime guard for external input. */
|
|
62
|
-
declare function isCivilDate(value: unknown): value is CivilDate;
|
|
63
|
-
/** Validate + brand a `'YYYY-MM-DD'` string. Throws on malformed input. */
|
|
64
|
-
declare function civilDate(value: string): CivilDate;
|
|
65
|
-
/**
|
|
66
|
-
* The calendar day `instant` falls on IN `zone` — e.g. the POS business
|
|
67
|
-
* date: `civilDateOf(new Date(), 'Asia/Dhaka')`. Never depends on the
|
|
68
|
-
* server's TZ env.
|
|
69
|
-
*/
|
|
70
|
-
declare function civilDateOf(instant: Date, zone: string): CivilDate;
|
|
71
|
-
/**
|
|
72
|
-
* Bind a civil date (+ optional wall time) to a zone → the exact instant.
|
|
73
|
-
* `civilDateToInstant(night, hotelZone, { hour: 14 })` = check-in instant;
|
|
74
|
-
* with no `time`, local midnight — the DST-exact "start of that day in that
|
|
75
|
-
* zone".
|
|
76
|
-
*
|
|
77
|
-
* Two-pass offset resolution: the offset is looked up AT the candidate
|
|
78
|
-
* instant, then re-checked, so days where a DST transition sits between the
|
|
79
|
-
* wall time and the first guess still resolve correctly. Honest limit: a
|
|
80
|
-
* wall time that does not exist (inside a spring-forward gap) resolves to
|
|
81
|
-
* the instant the shifted clock reaches — the same choice Temporal's
|
|
82
|
-
* `disambiguation: 'compatible'` makes.
|
|
83
|
-
*/
|
|
84
|
-
declare function civilDateToInstant(cd: CivilDate, zone: string, time?: {
|
|
85
|
-
hour?: number;
|
|
86
|
-
minute?: number;
|
|
87
|
-
}): Date;
|
|
88
|
-
/**
|
|
89
|
-
* Pure calendar-day arithmetic — `addCivilDays('2026-02-28', 1)` →
|
|
90
|
-
* `'2026-03-01'`. Zone-free by definition (a civil date has no zone);
|
|
91
|
-
* use it to iterate hotel nights / statement days.
|
|
92
|
-
*/
|
|
93
|
-
declare function addCivilDays(cd: CivilDate, days: number): CivilDate;
|
|
94
|
-
/**
|
|
95
|
-
* Whole days from `a` to `b` (positive when `b` is later) — the NIGHTS
|
|
96
|
-
* count for a `[checkIn, checkOut)` stay expressed as civil dates.
|
|
97
|
-
*/
|
|
98
|
-
declare function civilDaysBetween(a: CivilDate, b: CivilDate): number;
|
|
99
|
-
//#endregion
|
|
100
|
-
export { CivilDate, LocalTimeParts, TimeZoneError, addCivilDays, civilDate, civilDateOf, civilDateToInstant, civilDaysBetween, isCivilDate, isValidTimeZone, listTimeZones, localTimeParts, zoneOffsetLabel, zoneOffsetMinutes };
|
|
1
|
+
import { a as civilDate, c as civilDaysBetween, d as isoWeekOfCivilDate, f as listTimeZones, h as zoneOffsetMinutes, i as addCivilDays, l as isCivilDate, m as zoneOffsetLabel, n as LocalTimeParts, o as civilDateOf, p as localTimeParts, r as TimeZoneError, s as civilDateToInstant, t as CivilDate, u as isValidTimeZone } from "./timezone-tlG7ygwx.mjs";
|
|
2
|
+
export { CivilDate, LocalTimeParts, TimeZoneError, addCivilDays, civilDate, civilDateOf, civilDateToInstant, civilDaysBetween, isCivilDate, isValidTimeZone, isoWeekOfCivilDate, listTimeZones, localTimeParts, zoneOffsetLabel, zoneOffsetMinutes };
|
package/dist/timezone.mjs
CHANGED
|
@@ -78,7 +78,19 @@ function listTimeZones() {
|
|
|
78
78
|
cachedZones = Object.freeze(intl.supportedValuesOf("timeZone"));
|
|
79
79
|
return cachedZones;
|
|
80
80
|
}
|
|
81
|
-
|
|
81
|
+
/**
|
|
82
|
+
* ANCHORED on purpose.
|
|
83
|
+
*
|
|
84
|
+
* The unanchored form (`/GMT(?:([+-])(\d{2}):(\d{2}))?/`) made the offset group
|
|
85
|
+
* OPTIONAL *and* skippable: any label the runtime emits that this pattern cannot
|
|
86
|
+
* fully parse — `'GMT+5:30'` from an ICU build that does not zero-pad, or a
|
|
87
|
+
* locale that prefixes text — still matched the bare `GMT` alternative and
|
|
88
|
+
* resolved to offset **0**. A half-read offset became "UTC" with no error, which
|
|
89
|
+
* silently shifts every business-day boundary derived from it. Anchoring makes an
|
|
90
|
+
* unrecognised label throw instead; `\d{1,2}` accepts the unpadded variant rather
|
|
91
|
+
* than mis-reading it.
|
|
92
|
+
*/
|
|
93
|
+
const OFFSET_RE = /^GMT(?:([+-])(\d{1,2}):(\d{2}))?$/;
|
|
82
94
|
/**
|
|
83
95
|
* The zone's UTC offset AT `instant`, in minutes east of UTC — DST-exact.
|
|
84
96
|
* `zoneOffsetMinutes(july, 'Europe/London')` → 60; `(january, …)` → 0;
|
|
@@ -204,7 +216,9 @@ function civilDateToInstant(cd, zone, time = {}) {
|
|
|
204
216
|
if (!m) throw new TimeZoneError(`Invalid civil date '${cd}'.`);
|
|
205
217
|
const hour = time.hour ?? 0;
|
|
206
218
|
const minute = time.minute ?? 0;
|
|
207
|
-
const
|
|
219
|
+
const second = time.second ?? 0;
|
|
220
|
+
const millisecond = time.millisecond ?? 0;
|
|
221
|
+
const wallAsUtc = Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]), hour, minute, second, millisecond);
|
|
208
222
|
const firstOffset = zoneOffsetMinutes(new Date(wallAsUtc), zone);
|
|
209
223
|
let instantMs = wallAsUtc - firstOffset * MINUTE_MS;
|
|
210
224
|
const refined = zoneOffsetMinutes(new Date(instantMs), zone);
|
|
@@ -226,6 +240,27 @@ function addCivilDays(cd, days) {
|
|
|
226
240
|
* Whole days from `a` to `b` (positive when `b` is later) — the NIGHTS
|
|
227
241
|
* count for a `[checkIn, checkOut)` stay expressed as civil dates.
|
|
228
242
|
*/
|
|
243
|
+
/**
|
|
244
|
+
* The ISO-8601 week a civil date belongs to, as `YYYY-Www`.
|
|
245
|
+
*
|
|
246
|
+
* Zone-free by definition — a civil date has no zone — so callers resolve the
|
|
247
|
+
* date in their business timezone FIRST (`civilDateOf`) and label it here.
|
|
248
|
+
*
|
|
249
|
+
* ISO week 1 is the week containing the first Thursday, which means the week
|
|
250
|
+
* YEAR can differ from the date's own: 2025-12-29 is `2026-W01`. Naive
|
|
251
|
+
* "day-of-year / 7" numbering gets that wrong every few years, silently
|
|
252
|
+
* splitting one business week across two report buckets.
|
|
253
|
+
*/
|
|
254
|
+
function isoWeekOfCivilDate(cd) {
|
|
255
|
+
const m = CIVIL_RE.exec(cd);
|
|
256
|
+
if (!m) throw new TimeZoneError(`Invalid civil date '${cd}'.`);
|
|
257
|
+
const utc = Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
|
|
258
|
+
const dow = new Date(utc).getUTCDay() || 7;
|
|
259
|
+
const thursday = new Date(utc + (4 - dow) * 864e5);
|
|
260
|
+
const weekYear = thursday.getUTCFullYear();
|
|
261
|
+
const week = Math.floor((thursday.getTime() - Date.UTC(weekYear, 0, 1)) / 864e5 / 7) + 1;
|
|
262
|
+
return `${weekYear}-W${String(week).padStart(2, "0")}`;
|
|
263
|
+
}
|
|
229
264
|
function civilDaysBetween(a, b) {
|
|
230
265
|
const pa = CIVIL_RE.exec(a);
|
|
231
266
|
const pb = CIVIL_RE.exec(b);
|
|
@@ -235,4 +270,4 @@ function civilDaysBetween(a, b) {
|
|
|
235
270
|
return Math.round((mb - ma) / 864e5);
|
|
236
271
|
}
|
|
237
272
|
//#endregion
|
|
238
|
-
export { TimeZoneError, addCivilDays, civilDate, civilDateOf, civilDateToInstant, civilDaysBetween, isCivilDate, isValidTimeZone, listTimeZones, localTimeParts, zoneOffsetLabel, zoneOffsetMinutes };
|
|
273
|
+
export { TimeZoneError, addCivilDays, civilDate, civilDateOf, civilDateToInstant, civilDaysBetween, isCivilDate, isValidTimeZone, isoWeekOfCivilDate, listTimeZones, localTimeParts, zoneOffsetLabel, zoneOffsetMinutes };
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { MinorUnits } from "./money.mjs";
|
|
1
|
+
import { t as MinorUnits, w as CurrencyCode } from "./money-DPo9fLBi.mjs";
|
|
3
2
|
|
|
4
3
|
//#region src/money/unit-cost-rate.d.ts
|
|
5
4
|
/** Fixed, ecosystem-wide rate scale: 6 decimal places below the minor unit. */
|