@classytic/primitives 0.19.0 → 0.21.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 (65) hide show
  1. package/CHANGELOG.md +920 -751
  2. package/README.md +160 -159
  3. package/dist/approval.d.mts +50 -11
  4. package/dist/approval.mjs +44 -10
  5. package/dist/bank-transaction.d.mts +1 -2
  6. package/dist/{brand-LLXqoh5J.d.mts → brand-Lj_75Y_r.d.mts} +6 -6
  7. package/dist/brand.d.mts +1 -1
  8. package/dist/cadence-5X6TiD49.d.mts +107 -0
  9. package/dist/cadence.d.mts +1 -92
  10. package/dist/cadence.mjs +135 -6
  11. package/dist/calendar-DJWSbcgE.d.mts +46 -0
  12. package/dist/calendar.d.mts +1 -45
  13. package/dist/calendar.mjs +2 -2
  14. package/dist/canonical.mjs +17 -0
  15. package/dist/condition.mjs +2 -2
  16. package/dist/context.d.mts +28 -2
  17. package/dist/currency.d.mts +2 -2
  18. package/dist/currency.mjs +134 -19
  19. package/dist/environment.d.mts +85 -0
  20. package/dist/environment.mjs +48 -0
  21. package/dist/event-infra.d.mts +16 -32
  22. package/dist/event-infra.mjs +16 -46
  23. package/dist/idempotency.d.mts +263 -0
  24. package/dist/idempotency.mjs +226 -0
  25. package/dist/identity-verification.d.mts +0 -1
  26. package/dist/memory-outbox.d.mts +63 -0
  27. package/dist/memory-outbox.mjs +172 -0
  28. package/dist/mixin.d.mts +1 -1
  29. package/dist/monetization.d.mts +1 -2
  30. package/dist/money-CfAvfNiM.d.mts +346 -0
  31. package/dist/money.d.mts +1 -140
  32. package/dist/money.mjs +6 -15
  33. package/dist/otp.mjs +1 -1
  34. package/dist/outbox.d.mts +49 -2
  35. package/dist/outbox.mjs +64 -1
  36. package/dist/payment-allocation-status.d.mts +10 -4
  37. package/dist/payment-allocation-status.mjs +14 -0
  38. package/dist/payment-events.d.mts +132 -5
  39. package/dist/payment-events.mjs +34 -2
  40. package/dist/payment-gateway.d.mts +211 -8
  41. package/dist/payment-gateway.mjs +18 -1
  42. package/dist/payment-method-kind.d.mts +23 -11
  43. package/dist/payment-method-kind.mjs +17 -0
  44. package/dist/period.d.mts +149 -7
  45. package/dist/period.mjs +316 -5
  46. package/dist/person-parse.d.mts +81 -0
  47. package/dist/person-parse.mjs +109 -0
  48. package/dist/phone.d.mts +0 -1
  49. package/dist/phone.mjs +3 -3
  50. package/dist/proration.d.mts +2 -3
  51. package/dist/proration.mjs +18 -6
  52. package/dist/retention.mjs +3 -3
  53. package/dist/shelf-life.mjs +1 -1
  54. package/dist/sla-policy.d.mts +1 -2
  55. package/dist/state-diagram.d.mts +0 -1
  56. package/dist/state-diagram.mjs +1 -1
  57. package/dist/state-machine.mjs +13 -0
  58. package/dist/suspension.d.mts +6 -4
  59. package/dist/suspension.mjs +3 -2
  60. package/dist/timezone-CxUjxmRU.d.mts +113 -0
  61. package/dist/timezone.d.mts +2 -100
  62. package/dist/timezone.mjs +38 -3
  63. package/dist/unit-cost-rate.d.mts +1 -3
  64. package/package.json +249 -232
  65. package/dist/currency-BCJnJBlb.d.mts +0 -119
package/dist/period.mjs CHANGED
@@ -1,13 +1,93 @@
1
+ import { addCivilDays, civilDate, civilDateOf, civilDateToInstant, isValidTimeZone } from "./timezone.mjs";
1
2
  //#region src/scheduling/period.ts
3
+ /**
4
+ * Named accounting / reporting periods, and the ONE place a `Period` becomes a
5
+ * concrete `[start, end)` pair of instants — in a business timezone.
6
+ *
7
+ * ## Why the resolver lives HERE
8
+ *
9
+ * `Period` used to be a bare `{ year, month?, quarter? }` with no timezone and
10
+ * no resolver, so every consumer had to answer "which instants is August?"
11
+ * itself. Two resolvers grew ABOVE the kernels (`@spinekit/kit/period` and
12
+ * be-prod's `business-date`), which meant a kernel could not reach either and
13
+ * re-derived the boundary inline with `Date.UTC`. That is deterministic,
14
+ * TZ-env-proof, reviews clean — and wrong for every deployment not sitting on
15
+ * UTC. Asia/Dhaka is UTC+6, so a UTC month disagrees with the business month at
16
+ * BOTH ends by six hours.
17
+ *
18
+ * Five documented instances of that exact bug, all silent, none of which threw:
19
+ *
20
+ * | where | what it did |
21
+ * |---|---|
22
+ * | `aggregateMonthlyVat` | reported August revenue as July AND dropped early-July revenue entirely |
23
+ * | input-VAT aggregator | credited purchases to the wrong filing month |
24
+ * | Mushak serial year | an invoice issued 01:00 on 1 Jan drew from the OUTGOING year's counter |
25
+ * | sales-fact reconciler | reported total drift for every cell in every window |
26
+ * | analytics "today" | a daily KPI wrong for six hours of every day |
27
+ *
28
+ * The resolution logic below is a MOVE of the proven implementation from
29
+ * `@spinekit/kit`'s `createBusinessCalendar` — same civil-date round-trip, same
30
+ * half-open ranges, same DST-exactness. Layers above should now bind a zone and
31
+ * delegate rather than re-derive.
32
+ *
33
+ * ## DST-exactness
34
+ *
35
+ * Every boundary resolves through `/timezone`'s ICU civil-date primitives
36
+ * (`civilDate` → `civilDateToInstant`), so a day is the day the local clock
37
+ * actually had — never "the previous boundary + 24h", which is an hour off
38
+ * twice a year in any DST zone and silently corrupts a daily rollup on exactly
39
+ * those two days.
40
+ */
41
+ var DateRangeError = class extends Error {
42
+ name = "DateRangeError";
43
+ };
44
+ var PeriodError = class extends Error {
45
+ name = "PeriodError";
46
+ code;
47
+ constructor(code, message) {
48
+ super(message);
49
+ this.code = code;
50
+ }
51
+ };
52
+ /**
53
+ * Reject an Invalid Date before it reaches a comparison.
54
+ *
55
+ * Every comparison against `NaN` is `false`, so an unvalidated Invalid Date does
56
+ * not fail here — it answers "not within" and "does not overlap". For the two
57
+ * documented consumers of {@link rangesOverlap} (order booking collision, flow
58
+ * reservation-window collision) "does not overlap" is the PERMISSIVE answer: a
59
+ * corrupt range silently double-books instead of erroring. A range you cannot
60
+ * interpret must fail, not widen.
61
+ */
62
+ function assertUsableRange(range, label) {
63
+ const start = range.start?.getTime?.();
64
+ const end = range.end?.getTime?.();
65
+ if (!Number.isFinite(start) || !Number.isFinite(end)) throw new DateRangeError(`${label} has a non-finite endpoint (start=${String(range.start)}, end=${String(range.end)}) — an Invalid Date compares false against everything, which would silently report "no overlap".`);
66
+ }
2
67
  function isDateRange(value) {
3
- return typeof value === "object" && value !== null && value.start instanceof Date && value.end instanceof Date;
68
+ if (typeof value !== "object" || value === null) return false;
69
+ const { start, end } = value;
70
+ return start instanceof Date && end instanceof Date && Number.isFinite(start.getTime()) && Number.isFinite(end.getTime());
4
71
  }
5
- /** True if `d` is inclusively within `range`. */
72
+ /**
73
+ * True if `d` is inclusively within `range`.
74
+ *
75
+ * @throws DateRangeError if `d` or either endpoint is an Invalid Date.
76
+ */
6
77
  function isWithin(d, range) {
7
- return d.getTime() >= range.start.getTime() && d.getTime() <= range.end.getTime();
78
+ const t = d?.getTime?.();
79
+ if (!Number.isFinite(t)) throw new DateRangeError(`isWithin received a non-finite instant: ${String(d)}`);
80
+ assertUsableRange(range, "range");
81
+ return t >= range.start.getTime() && t <= range.end.getTime();
8
82
  }
9
- /** Range duration in milliseconds. */
83
+ /**
84
+ * Range duration in milliseconds.
85
+ *
86
+ * @throws DateRangeError if either endpoint is an Invalid Date (which would
87
+ * otherwise return `NaN` and propagate silently into a duration total).
88
+ */
10
89
  function rangeDurationMs(range) {
90
+ assertUsableRange(range, "range");
11
91
  return range.end.getTime() - range.start.getTime();
12
92
  }
13
93
  /**
@@ -23,9 +103,240 @@ function rangeDurationMs(range) {
23
103
  * - `@classytic/order` booking overlap detection
24
104
  * - `@classytic/flow` reservation window collision
25
105
  * - promo / offer campaign window overlap checks
106
+ *
107
+ * @throws DateRangeError if either range carries an Invalid Date. For all three
108
+ * consumers above, `false` is the PERMISSIVE answer — "no conflict, go ahead
109
+ * and book" — so a range we cannot read must not be allowed to produce it.
26
110
  */
27
111
  function rangesOverlap(a, b) {
112
+ assertUsableRange(a, "range a");
113
+ assertUsableRange(b, "range b");
28
114
  return a.start.getTime() < b.end.getTime() && b.start.getTime() < a.end.getTime();
29
115
  }
116
+ const pad2 = (n) => String(n).padStart(2, "0");
117
+ function assertZone(timezone) {
118
+ if (typeof timezone !== "string" || timezone.length === 0) throw new PeriodError("MISSING_TIMEZONE", `A business period needs an IANA timezone (e.g. 'Asia/Dhaka'); received ${String(timezone)}. Resolving without one would silently answer in UTC, which is a different month at both ends for every deployment that isn't on UTC.`);
119
+ if (!isValidTimeZone(timezone)) throw new PeriodError("MISSING_TIMEZONE", `Unknown IANA timezone '${timezone}'. A misread zone resolves to a plausible but different calendar.`);
120
+ return timezone;
121
+ }
122
+ function assertYear(year) {
123
+ if (!Number.isInteger(year) || year < 1 || year > 9999) throw new PeriodError("INVALID_FIELD", `year must be an integer in [1, 9999], got ${String(year)}`);
124
+ return year;
125
+ }
126
+ function assertMonth(month) {
127
+ if (!Number.isInteger(month) || month < 1 || month > 12) throw new PeriodError("INVALID_FIELD", `month must be an integer in [1, 12], got ${String(month)}`);
128
+ return month;
129
+ }
130
+ function assertQuarter(quarter) {
131
+ if (!Number.isInteger(quarter) || quarter < 1 || quarter > 4) throw new PeriodError("INVALID_FIELD", `quarter must be an integer in [1, 4], got ${String(quarter)}`);
132
+ return quarter;
133
+ }
134
+ /**
135
+ * The first instant of a local calendar day, in `timezone`. DST-exact — the
136
+ * civil-date round-trip resolves the offset AT local midnight, not at "now".
137
+ */
138
+ function dayStart(date, timezone) {
139
+ return civilDateToInstant(civilDate(date), assertZone(timezone));
140
+ }
141
+ /** Half-open `[start, end)` range covering one local calendar day. */
142
+ function resolveDay(date, timezone) {
143
+ const zone = assertZone(timezone);
144
+ const cd = civilDate(date);
145
+ return {
146
+ start: dayStart(cd, zone),
147
+ end: dayStart(addCivilDays(cd, 1), zone)
148
+ };
149
+ }
150
+ /**
151
+ * Half-open `[start, end)` range covering one local calendar month.
152
+ * `resolveMonth(2026, 8, 'Asia/Dhaka')` →
153
+ * `[2026-07-31T18:00Z, 2026-08-31T18:00Z)` — the DHAKA month, six hours off
154
+ * the UTC one at both ends.
155
+ */
156
+ function resolveMonth(year, month, timezone) {
157
+ const zone = assertZone(timezone);
158
+ const y = assertYear(year);
159
+ const m = assertMonth(month);
160
+ return {
161
+ start: dayStart(`${y}-${pad2(m)}-01`, zone),
162
+ end: m === 12 ? dayStart(`${y + 1}-01-01`, zone) : dayStart(`${y}-${pad2(m + 1)}-01`, zone)
163
+ };
164
+ }
165
+ /** Half-open `[start, end)` range covering one local calendar quarter. */
166
+ function resolveQuarter(year, quarter, timezone) {
167
+ const zone = assertZone(timezone);
168
+ const y = assertYear(year);
169
+ const firstMonth = (assertQuarter(quarter) - 1) * 3 + 1;
170
+ return {
171
+ start: resolveMonth(y, firstMonth, zone).start,
172
+ end: resolveMonth(y, firstMonth + 2, zone).end
173
+ };
174
+ }
175
+ /** Half-open `[start, end)` range covering one local calendar year. */
176
+ function resolveYear(year, timezone) {
177
+ const zone = assertZone(timezone);
178
+ const y = assertYear(year);
179
+ return {
180
+ start: dayStart(`${y}-01-01`, zone),
181
+ end: dayStart(`${y + 1}-01-01`, zone)
182
+ };
183
+ }
184
+ /**
185
+ * Half-open range spanning two local dates, INCLUSIVE of both days — what an
186
+ * operator means by "1st to 31st". The half-open end is therefore the start of
187
+ * the day AFTER `to`.
188
+ */
189
+ function resolveDateSpan(from, to, timezone) {
190
+ const zone = assertZone(timezone);
191
+ const start = dayStart(civilDate(from), zone);
192
+ const end = dayStart(addCivilDays(civilDate(to), 1), zone);
193
+ if (end.getTime() < start.getTime()) throw new PeriodError("INVALID_FIELD", `date span '${from}'..'${to}' ends before it starts`);
194
+ return {
195
+ start,
196
+ end
197
+ };
198
+ }
199
+ /** Which resolution shape this period carries. Throws when it carries two. */
200
+ function granularityOf(period) {
201
+ const hasAdHoc = period.start !== void 0 || period.end !== void 0;
202
+ const hasMonth = period.month !== void 0;
203
+ const hasQuarter = period.quarter !== void 0;
204
+ if ([
205
+ hasAdHoc,
206
+ hasMonth,
207
+ hasQuarter
208
+ ].filter(Boolean).length > 1) throw new PeriodError("AMBIGUOUS_PERIOD", `Period declares more than one resolution shape (month=${String(period.month)}, quarter=${String(period.quarter)}, start=${String(period.start)}, end=${String(period.end)}). Two possible answers to "which instants is this?" must not be silently reduced to one — set exactly one of month, quarter, or start+end.`);
209
+ if (hasAdHoc) {
210
+ if (period.start === void 0 || period.end === void 0) throw new PeriodError("INCOMPLETE_RANGE", "An ad-hoc Period needs BOTH start and end; a half-supplied range would resolve as an open-ended one.");
211
+ return "custom";
212
+ }
213
+ if (hasMonth) return "month";
214
+ if (hasQuarter) return "quarter";
215
+ return "year";
216
+ }
217
+ /**
218
+ * Resolve the zone a period must be read in.
219
+ *
220
+ * `period.timezone` is the zone the period was DEFINED in; `timezone` is the
221
+ * caller's (usually the deployment default). Supplying both with different
222
+ * values is a bug — one of them is describing a different calendar than the
223
+ * data was computed against — so it throws rather than picking a winner.
224
+ */
225
+ function periodTimeZone(period, timezone) {
226
+ const own = period.timezone;
227
+ if (own !== void 0 && timezone !== void 0 && own !== timezone) throw new PeriodError("TIMEZONE_CONFLICT", `Period is defined in '${own}' but was asked to resolve in '${timezone}'. These are different calendars; silently choosing one produces a plausible range for the wrong month.`);
228
+ return assertZone(own ?? timezone);
229
+ }
230
+ /**
231
+ * `Period` → half-open `[start, end)` instants, in a business timezone.
232
+ *
233
+ * The single derivation every kernel, module and host should reach for.
234
+ *
235
+ * ```ts
236
+ * resolvePeriod({ year: 2026, month: 8 }, 'Asia/Dhaka');
237
+ * // → [2026-07-31T18:00:00Z, 2026-08-31T18:00:00Z)
238
+ * ```
239
+ *
240
+ * An ad-hoc period (`start` + `end`) needs no zone and is returned verbatim
241
+ * after an Invalid-Date check.
242
+ */
243
+ function resolvePeriod(period, timezone) {
244
+ const granularity = granularityOf(period);
245
+ if (granularity === "custom") {
246
+ const range = {
247
+ start: period.start,
248
+ end: period.end
249
+ };
250
+ assertUsableRange(range, "ad-hoc period");
251
+ return range;
252
+ }
253
+ const zone = periodTimeZone(period, timezone);
254
+ if (granularity === "month") return resolveMonth(period.year, period.month, zone);
255
+ if (granularity === "quarter") return resolveQuarter(period.year, period.quarter, zone);
256
+ return resolveYear(period.year, zone);
257
+ }
258
+ /**
259
+ * The LAST instant of a half-open range — `end - 1ms`.
260
+ *
261
+ * For the `$lte` call sites that predate half-open ranges. Derived from the
262
+ * resolved end rather than `start + 24h - 1`, so it stays DST-exact.
263
+ */
264
+ function inclusiveEnd(range) {
265
+ assertUsableRange(range, "range");
266
+ return /* @__PURE__ */ new Date(range.end.getTime() - 1);
267
+ }
268
+ /**
269
+ * The period an INSTANT falls in, on the local calendar.
270
+ *
271
+ * The inverse of {@link resolvePeriod}, and the answer to "which filing month
272
+ * is this posting in?" — asked at local midnight ±6h, `getUTCMonth()` answers a
273
+ * different month. The returned period carries the zone it was derived in, so
274
+ * a later `resolvePeriod` cannot re-read it against a different calendar.
275
+ */
276
+ function periodOf(instant, granularity, timezone) {
277
+ const zone = assertZone(timezone);
278
+ const cd = civilDateOf(instant, zone);
279
+ const year = Number(cd.slice(0, 4));
280
+ const month = Number(cd.slice(5, 7));
281
+ if (granularity === "year") return {
282
+ year,
283
+ timezone: zone
284
+ };
285
+ if (granularity === "quarter") return {
286
+ year,
287
+ quarter: Math.floor((month - 1) / 3) + 1,
288
+ timezone: zone
289
+ };
290
+ return {
291
+ year,
292
+ month,
293
+ timezone: zone
294
+ };
295
+ }
296
+ const YEAR_RE = /^(\d{4})$/;
297
+ const MONTH_RE = /^(\d{4})-(\d{2})$/;
298
+ const QUARTER_RE = /^(\d{4})-Q([1-4])$/;
299
+ const CIVIL_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
300
+ /**
301
+ * Parse a period LABEL — `'2026'`, `'2026-08'`, `'2026-Q3'`.
302
+ *
303
+ * Case-sensitive on the `Q` and strictly anchored: a label this cannot read
304
+ * throws instead of degrading to the year it could see. A `'YYYY-MM-DD'` is
305
+ * rejected with a pointer to {@link resolveDay}, because a `Period` has no day
306
+ * field and quietly reading it as its month is a 30× widening of the range.
307
+ */
308
+ function parsePeriod(label, timezone) {
309
+ const withZone = (p) => timezone === void 0 ? p : {
310
+ ...p,
311
+ timezone: assertZone(timezone)
312
+ };
313
+ const text = String(label);
314
+ const month = MONTH_RE.exec(text);
315
+ if (month) return withZone({
316
+ year: assertYear(Number(month[1])),
317
+ month: assertMonth(Number(month[2]))
318
+ });
319
+ const quarter = QUARTER_RE.exec(text);
320
+ if (quarter) return withZone({
321
+ year: assertYear(Number(quarter[1])),
322
+ quarter: assertQuarter(Number(quarter[2]))
323
+ });
324
+ const year = YEAR_RE.exec(text);
325
+ if (year) return withZone({ year: assertYear(Number(year[1])) });
326
+ if (CIVIL_DATE_RE.test(text)) throw new PeriodError("INVALID_LABEL", `'${text}' is a civil DATE, not a period label. A Period has no day field, so reading it as its month would widen the range ~30×. Use resolveDay('${text}', timezone).`);
327
+ throw new PeriodError("INVALID_LABEL", `Invalid period label '${text}' — expected 'YYYY', 'YYYY-MM' or 'YYYY-Qn'.`);
328
+ }
329
+ /**
330
+ * `Period` → its canonical label. Round-trips through {@link parsePeriod}.
331
+ * Throws for an ad-hoc period, which has no label — format its endpoints.
332
+ */
333
+ function formatPeriod(period) {
334
+ const granularity = granularityOf(period);
335
+ const year = assertYear(period.year);
336
+ if (granularity === "month") return `${year}-${pad2(assertMonth(period.month))}`;
337
+ if (granularity === "quarter") return `${year}-Q${assertQuarter(period.quarter)}`;
338
+ if (granularity === "year") return String(year);
339
+ throw new PeriodError("INVALID_LABEL", "An ad-hoc Period (start + end) has no canonical label.");
340
+ }
30
341
  //#endregion
31
- export { isDateRange, isWithin, rangeDurationMs, rangesOverlap };
342
+ export { DateRangeError, PeriodError, dayStart, formatPeriod, granularityOf, inclusiveEnd, isDateRange, isWithin, parsePeriod, periodOf, periodTimeZone, rangeDurationMs, rangesOverlap, resolveDateSpan, resolveDay, resolveMonth, resolvePeriod, resolveQuarter, resolveYear };
@@ -0,0 +1,81 @@
1
+ import { PersonName } from "./person.mjs";
2
+ //#region src/identity/person-parse.d.ts
3
+ /**
4
+ * Does this `PersonName` carry at least one usable, non-empty name part?
5
+ *
6
+ * Used by both the customer-model virtuals (`fullName` / `displayName`) and
7
+ * any code path that needs to decide "is this customer effectively unnamed".
8
+ * Defends against the legacy data shape and any future write that lands an
9
+ * all-empty `{ given: '', family: '' }`. Without this, `formatDisplayName`
10
+ * from `@classytic/primitives` returns the literal string `"undefined
11
+ * undefined"` for empty names — a bug we've patched at the write boundary
12
+ * but keep guarding at the read boundary too (defense in depth).
13
+ */
14
+ declare function hasUsableName(name: PersonName | undefined | null): boolean;
15
+ /**
16
+ * Detect a "looks like an identifier, not a person name" string.
17
+ *
18
+ * Better Auth signs OAuth users in with `user.name` populated from the
19
+ * provider — but Apple-private-relay / Google-custom-name flows (and our
20
+ * own guest-checkout fallback) sometimes put the BA `user.id` string into
21
+ * `user.name`. That id is a 20-char base64-url-safe nanoid like
22
+ * `gcqAUBgGpRnDZbyPgKbS`, and storing it as `name.given` leaks the
23
+ * internal id into the dashboard customers table.
24
+ *
25
+ * Heuristic (cheap, no false-positives observed on real BD names):
26
+ * - 12+ chars
27
+ * - no whitespace (real "full names" have spaces; single-name locales
28
+ * are usually < 12 chars: "Sadman", "Nirjhar", "Subarna", etc.)
29
+ * - at least 3 uppercase AND 3 lowercase letters (Title-Case names
30
+ * don't qualify — "Sadman Chowdhury" has 2 uppercase chars; even
31
+ * the longest single Bangla romanisation rarely hits 3+ uppercase
32
+ * in one token)
33
+ * - OR a bare 24-char lowercase hex (ObjectId)
34
+ *
35
+ * If a real name ever gets misclassified, the customer can edit it from
36
+ * the profile page and the heuristic only runs at create / link time.
37
+ */
38
+ declare function looksLikeIdentifierNotName(value: string): boolean;
39
+ /**
40
+ * Pick a display fallback when `user.name` is missing or token-like.
41
+ * Prefers the email local-part (capitalised) so customers see a recognisable
42
+ * label in the dashboard instead of "Unknown" or a leaked id.
43
+ */
44
+ declare function fallbackNameFromUser(user: {
45
+ name?: string;
46
+ email?: string;
47
+ }, defaultLabel: string): string;
48
+ /**
49
+ * Sanitize a display-name string before it gets snapshotted into a
50
+ * derivative collection (order, invoice, shipment, transaction, etc.).
51
+ *
52
+ * The customer-creation boundary (`customer.repository.ts`) already
53
+ * scrubs token-shaped names. This helper is the second line of defence
54
+ * for write paths that copy `customer.name` somewhere else — if the
55
+ * source ever regressed, the snapshot still wouldn't carry the leak.
56
+ *
57
+ * Returns:
58
+ * - the trimmed input when it's a real-looking name
59
+ * - `fallback` when the input is empty, undefined, or token-shaped
60
+ *
61
+ * Caller is responsible for picking a sensible `fallback` (e.g. the
62
+ * email local-part, "Customer", "Walk-in", etc.).
63
+ */
64
+ declare function sanitizeDisplayName(value: string | undefined | null, fallback: string): string;
65
+ /**
66
+ * Split a flat "Full Name" string into a structured `PersonName`.
67
+ *
68
+ * Callers at the auth / POS boundary pass flat names; storage is structured.
69
+ * Single-token names go in `given` with an empty `family` (many BD and
70
+ * single-name locales store a single name, which is valid).
71
+ *
72
+ * `user` (optional) supplies an `email` so we can derive a sensible display
73
+ * fallback when the input is empty or looks like a BA user id rather than
74
+ * a real name. Without `user`, the static `fallback` arg is the floor.
75
+ */
76
+ declare function nameFromString(flat: string | undefined, fallback: string, user?: {
77
+ name?: string;
78
+ email?: string;
79
+ }): PersonName;
80
+ //#endregion
81
+ export { fallbackNameFromUser, hasUsableName, looksLikeIdentifierNotName, nameFromString, sanitizeDisplayName };
@@ -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 };
package/dist/phone.d.mts CHANGED
@@ -1,5 +1,4 @@
1
1
  import { Result } from "./result.mjs";
2
-
3
2
  //#region src/identity/phone.d.ts
4
3
  type PhoneErrorCode = 'EMPTY' | 'MISSING_PLUS' | 'NOT_E164' | 'TOO_SHORT' | 'TOO_LONG';
5
4
  declare class PhoneError extends Error {
package/dist/phone.mjs CHANGED
@@ -42,7 +42,7 @@ var PhoneError = class extends Error {
42
42
  * Kept inline (not exported) because it's an implementation detail of the
43
43
  * parser. Length is the only thing we actually need for the split.
44
44
  */
45
- const E164_PREFIXES_3 = new Set([
45
+ const E164_PREFIXES_3 = /* @__PURE__ */ new Set([
46
46
  "210",
47
47
  "211",
48
48
  "212",
@@ -215,7 +215,7 @@ const E164_PREFIXES_3 = new Set([
215
215
  "996",
216
216
  "998"
217
217
  ]);
218
- const E164_PREFIXES_2 = new Set([
218
+ const E164_PREFIXES_2 = /* @__PURE__ */ new Set([
219
219
  "20",
220
220
  "27",
221
221
  "30",
@@ -261,7 +261,7 @@ const E164_PREFIXES_2 = new Set([
261
261
  "95",
262
262
  "98"
263
263
  ]);
264
- const E164_PREFIXES_1 = new Set(["1", "7"]);
264
+ const E164_PREFIXES_1 = /* @__PURE__ */ new Set(["1", "7"]);
265
265
  /**
266
266
  * Parse free-form phone input into a normalized `PhoneNumber`. Accepts:
267
267
  * - `'+14155550182'` → e164 unchanged
@@ -1,5 +1,4 @@
1
- import { Money } from "./money.mjs";
2
-
1
+ import { n as Money } from "./money-CfAvfNiM.mjs";
3
2
  //#region src/money/proration.d.ts
4
3
  /**
5
4
  * How to count days within a period.
@@ -33,7 +32,7 @@ interface PeriodProgressInput {
33
32
  /** Day-counting granularity. Default `'whole_day'`. */
34
33
  readonly granularity?: ProrationGranularity;
35
34
  }
36
- type ProrationErrorCode = 'INVALID_PERIOD' | 'INVALID_FRACTION';
35
+ type ProrationErrorCode = 'INVALID_PERIOD' | 'INVALID_FRACTION' | 'ALLOCATION_INVARIANT';
37
36
  declare class ProrationError extends Error {
38
37
  readonly name = "ProrationError";
39
38
  readonly code: ProrationErrorCode;
@@ -82,20 +82,32 @@ function splitByPeriodFraction(amount, fraction) {
82
82
  id: "remaining",
83
83
  weight: fraction.remainingDays
84
84
  }], "by-weight");
85
- const consumed = result.parts.find((p) => p.id === "consumed")?.amount ?? 0;
86
- const remaining = result.parts.find((p) => p.id === "remaining")?.amount ?? 0;
87
85
  return {
88
86
  consumed: {
89
- amount: consumed,
87
+ amount: requirePart(result, "consumed"),
90
88
  currency: amount.currency
91
89
  },
92
90
  remaining: {
93
- amount: remaining,
91
+ amount: requirePart(result, "remaining"),
94
92
  currency: amount.currency
95
93
  }
96
94
  };
97
95
  }
98
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
+ /**
99
111
  * Allocate the `fraction`-weighted part of a `Money` amount, drift-free — the
100
112
  * complement (`1 - fraction`) is discarded but computed so rounding is exact
101
113
  * against the whole. `fraction` must be in `[0, 1]`.
@@ -106,13 +118,13 @@ function splitByPeriodFraction(amount, fraction) {
106
118
  function allocateMoneyByFraction(amount, fraction) {
107
119
  if (!Number.isFinite(fraction) || fraction < 0 || fraction > 1) throw new ProrationError("INVALID_FRACTION", `fraction must be in [0, 1], got ${fraction}`);
108
120
  return {
109
- amount: allocate(amount.amount, [{
121
+ amount: requirePart(allocate(amount.amount, [{
110
122
  id: "part",
111
123
  weight: fraction
112
124
  }, {
113
125
  id: "rest",
114
126
  weight: 1 - fraction
115
- }], "by-weight").parts.find((p) => p.id === "part")?.amount ?? 0,
127
+ }], "by-weight"), "part"),
116
128
  currency: amount.currency
117
129
  };
118
130
  }
@@ -1,15 +1,15 @@
1
1
  //#region src/retention/purge-evidence.ts
2
- const STRATEGY_KINDS = new Set([
2
+ const STRATEGY_KINDS = /* @__PURE__ */ new Set([
3
3
  "hard",
4
4
  "soft",
5
5
  "anonymize"
6
6
  ]);
7
- const ACTOR_KINDS = new Set([
7
+ const ACTOR_KINDS = /* @__PURE__ */ new Set([
8
8
  "user",
9
9
  "system",
10
10
  "service"
11
11
  ]);
12
- const STATUS_KINDS = new Set([
12
+ const STATUS_KINDS = /* @__PURE__ */ new Set([
13
13
  "completed",
14
14
  "partial",
15
15
  "failed"
@@ -43,7 +43,7 @@ var ShelfLifeError = class extends Error {
43
43
  this.code = code;
44
44
  }
45
45
  };
46
- const DAY_MS = 1440 * 60 * 1e3;
46
+ const DAY_MS = 864e5;
47
47
  const TRACKING_MODES = [
48
48
  "none",
49
49
  "lot",
@@ -1,6 +1,5 @@
1
- import { IsoWeekday } from "./cadence.mjs";
1
+ import { s as IsoWeekday } from "./cadence-5X6TiD49.mjs";
2
2
  import { SLA } from "./sla.mjs";
3
-
4
3
  //#region src/scheduling/sla-policy.d.ts
5
4
  /**
6
5
  * Tier of severity. Hosts decide the keys ('urgent' / 'p0' / 'gold' / etc.) —
@@ -1,5 +1,4 @@
1
1
  import { StateMachine } from "./state-machine.mjs";
2
-
3
2
  //#region src/workflow/state-diagram.d.ts
4
3
  interface StateDiagramOptions<TStatus extends string> {
5
4
  /**