@classytic/primitives 0.19.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 -751
- 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 +1 -1
- 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 +2 -2
- package/dist/proration.mjs +18 -6
- 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 -232
- 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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
/**
|
|
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,82 @@
|
|
|
1
|
+
import { PersonName } from "./person.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/identity/person-parse.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Does this `PersonName` carry at least one usable, non-empty name part?
|
|
6
|
+
*
|
|
7
|
+
* Used by both the customer-model virtuals (`fullName` / `displayName`) and
|
|
8
|
+
* any code path that needs to decide "is this customer effectively unnamed".
|
|
9
|
+
* Defends against the legacy data shape and any future write that lands an
|
|
10
|
+
* all-empty `{ given: '', family: '' }`. Without this, `formatDisplayName`
|
|
11
|
+
* from `@classytic/primitives` returns the literal string `"undefined
|
|
12
|
+
* undefined"` for empty names — a bug we've patched at the write boundary
|
|
13
|
+
* but keep guarding at the read boundary too (defense in depth).
|
|
14
|
+
*/
|
|
15
|
+
declare function hasUsableName(name: PersonName | undefined | null): boolean;
|
|
16
|
+
/**
|
|
17
|
+
* Detect a "looks like an identifier, not a person name" string.
|
|
18
|
+
*
|
|
19
|
+
* Better Auth signs OAuth users in with `user.name` populated from the
|
|
20
|
+
* provider — but Apple-private-relay / Google-custom-name flows (and our
|
|
21
|
+
* own guest-checkout fallback) sometimes put the BA `user.id` string into
|
|
22
|
+
* `user.name`. That id is a 20-char base64-url-safe nanoid like
|
|
23
|
+
* `gcqAUBgGpRnDZbyPgKbS`, and storing it as `name.given` leaks the
|
|
24
|
+
* internal id into the dashboard customers table.
|
|
25
|
+
*
|
|
26
|
+
* Heuristic (cheap, no false-positives observed on real BD names):
|
|
27
|
+
* - 12+ chars
|
|
28
|
+
* - no whitespace (real "full names" have spaces; single-name locales
|
|
29
|
+
* are usually < 12 chars: "Sadman", "Nirjhar", "Subarna", etc.)
|
|
30
|
+
* - at least 3 uppercase AND 3 lowercase letters (Title-Case names
|
|
31
|
+
* don't qualify — "Sadman Chowdhury" has 2 uppercase chars; even
|
|
32
|
+
* the longest single Bangla romanisation rarely hits 3+ uppercase
|
|
33
|
+
* in one token)
|
|
34
|
+
* - OR a bare 24-char lowercase hex (ObjectId)
|
|
35
|
+
*
|
|
36
|
+
* If a real name ever gets misclassified, the customer can edit it from
|
|
37
|
+
* the profile page and the heuristic only runs at create / link time.
|
|
38
|
+
*/
|
|
39
|
+
declare function looksLikeIdentifierNotName(value: string): boolean;
|
|
40
|
+
/**
|
|
41
|
+
* Pick a display fallback when `user.name` is missing or token-like.
|
|
42
|
+
* Prefers the email local-part (capitalised) so customers see a recognisable
|
|
43
|
+
* label in the dashboard instead of "Unknown" or a leaked id.
|
|
44
|
+
*/
|
|
45
|
+
declare function fallbackNameFromUser(user: {
|
|
46
|
+
name?: string;
|
|
47
|
+
email?: string;
|
|
48
|
+
}, defaultLabel: string): string;
|
|
49
|
+
/**
|
|
50
|
+
* Sanitize a display-name string before it gets snapshotted into a
|
|
51
|
+
* derivative collection (order, invoice, shipment, transaction, etc.).
|
|
52
|
+
*
|
|
53
|
+
* The customer-creation boundary (`customer.repository.ts`) already
|
|
54
|
+
* scrubs token-shaped names. This helper is the second line of defence
|
|
55
|
+
* for write paths that copy `customer.name` somewhere else — if the
|
|
56
|
+
* source ever regressed, the snapshot still wouldn't carry the leak.
|
|
57
|
+
*
|
|
58
|
+
* Returns:
|
|
59
|
+
* - the trimmed input when it's a real-looking name
|
|
60
|
+
* - `fallback` when the input is empty, undefined, or token-shaped
|
|
61
|
+
*
|
|
62
|
+
* Caller is responsible for picking a sensible `fallback` (e.g. the
|
|
63
|
+
* email local-part, "Customer", "Walk-in", etc.).
|
|
64
|
+
*/
|
|
65
|
+
declare function sanitizeDisplayName(value: string | undefined | null, fallback: string): string;
|
|
66
|
+
/**
|
|
67
|
+
* Split a flat "Full Name" string into a structured `PersonName`.
|
|
68
|
+
*
|
|
69
|
+
* Callers at the auth / POS boundary pass flat names; storage is structured.
|
|
70
|
+
* Single-token names go in `given` with an empty `family` (many BD and
|
|
71
|
+
* single-name locales store a single name, which is valid).
|
|
72
|
+
*
|
|
73
|
+
* `user` (optional) supplies an `email` so we can derive a sensible display
|
|
74
|
+
* fallback when the input is empty or looks like a BA user id rather than
|
|
75
|
+
* a real name. Without `user`, the static `fallback` arg is the floor.
|
|
76
|
+
*/
|
|
77
|
+
declare function nameFromString(flat: string | undefined, fallback: string, user?: {
|
|
78
|
+
name?: string;
|
|
79
|
+
email?: string;
|
|
80
|
+
}): PersonName;
|
|
81
|
+
//#endregion
|
|
82
|
+
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/proration.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Money } from "./money.mjs";
|
|
1
|
+
import { n as Money } from "./money-DPo9fLBi.mjs";
|
|
2
2
|
|
|
3
3
|
//#region src/money/proration.d.ts
|
|
4
4
|
/**
|
|
@@ -33,7 +33,7 @@ interface PeriodProgressInput {
|
|
|
33
33
|
/** Day-counting granularity. Default `'whole_day'`. */
|
|
34
34
|
readonly granularity?: ProrationGranularity;
|
|
35
35
|
}
|
|
36
|
-
type ProrationErrorCode = 'INVALID_PERIOD' | 'INVALID_FRACTION';
|
|
36
|
+
type ProrationErrorCode = 'INVALID_PERIOD' | 'INVALID_FRACTION' | 'ALLOCATION_INVARIANT';
|
|
37
37
|
declare class ProrationError extends Error {
|
|
38
38
|
readonly name = "ProrationError";
|
|
39
39
|
readonly code: ProrationErrorCode;
|
package/dist/proration.mjs
CHANGED
|
@@ -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")
|
|
127
|
+
}], "by-weight"), "part"),
|
|
116
128
|
currency: amount.currency
|
|
117
129
|
};
|
|
118
130
|
}
|
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