@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.
Files changed (46) hide show
  1. package/CHANGELOG.md +906 -715
  2. package/README.md +160 -159
  3. package/dist/approval.d.mts +49 -10
  4. package/dist/approval.mjs +44 -10
  5. package/dist/bank-transaction.d.mts +1 -1
  6. package/dist/cadence-BFQVC1Qp.d.mts +107 -0
  7. package/dist/cadence.d.mts +1 -92
  8. package/dist/cadence.mjs +133 -4
  9. package/dist/calendar-B6fm-FbR.d.mts +46 -0
  10. package/dist/calendar.d.mts +1 -45
  11. package/dist/canonical.mjs +17 -0
  12. package/dist/context.d.mts +28 -1
  13. package/dist/currency.d.mts +2 -2
  14. package/dist/currency.mjs +134 -19
  15. package/dist/event-infra.d.mts +1 -34
  16. package/dist/event-infra.mjs +16 -46
  17. package/dist/idempotency.d.mts +253 -0
  18. package/dist/idempotency.mjs +226 -0
  19. package/dist/memory-outbox.d.mts +64 -0
  20. package/dist/memory-outbox.mjs +172 -0
  21. package/dist/monetization.d.mts +195 -0
  22. package/dist/monetization.mjs +79 -0
  23. package/dist/money-DPo9fLBi.d.mts +347 -0
  24. package/dist/money.d.mts +1 -140
  25. package/dist/money.mjs +6 -15
  26. package/dist/outbox.d.mts +49 -1
  27. package/dist/outbox.mjs +64 -1
  28. package/dist/payment-events.d.mts +132 -4
  29. package/dist/payment-events.mjs +22 -2
  30. package/dist/payment-gateway.d.mts +210 -7
  31. package/dist/payment-gateway.mjs +18 -1
  32. package/dist/period.d.mts +138 -7
  33. package/dist/period.mjs +316 -5
  34. package/dist/person-parse.d.mts +82 -0
  35. package/dist/person-parse.mjs +109 -0
  36. package/dist/proration.d.mts +76 -0
  37. package/dist/proration.mjs +132 -0
  38. package/dist/sla-policy.d.mts +1 -1
  39. package/dist/state-machine.mjs +13 -0
  40. package/dist/suspension.d.mts +1 -1
  41. package/dist/timezone-tlG7ygwx.d.mts +114 -0
  42. package/dist/timezone.d.mts +2 -100
  43. package/dist/timezone.mjs +38 -3
  44. package/dist/unit-cost-rate.d.mts +1 -2
  45. package/package.json +244 -224
  46. 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 };
@@ -1,4 +1,4 @@
1
- import { IsoWeekday } from "./cadence.mjs";
1
+ import { s as IsoWeekday } from "./cadence-BFQVC1Qp.mjs";
2
2
  import { SLA } from "./sla.mjs";
3
3
 
4
4
  //#region src/scheduling/sla-policy.d.ts
@@ -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,
@@ -1,4 +1,4 @@
1
- import { UtcOffsetMinutes } from "./calendar.mjs";
1
+ import { t as UtcOffsetMinutes } from "./calendar-B6fm-FbR.mjs";
2
2
  import { DateRange } from "./period.mjs";
3
3
  import { StatusChangeEntry } from "./status-history.mjs";
4
4
 
@@ -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 };
@@ -1,100 +1,2 @@
1
- import { t as Brand } from "./brand-LLXqoh5J.mjs";
2
- import { IsoWeekday } from "./cadence.mjs";
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
- const OFFSET_RE = /GMT(?:([+-])(\d{2}):(\d{2}))?/;
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 wallAsUtc = Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]), hour, minute);
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 { r as CurrencyCode } from "./currency-BCJnJBlb.mjs";
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. */