@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.
Files changed (45) hide show
  1. package/CHANGELOG.md +906 -751
  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 +1 -1
  22. package/dist/money-DPo9fLBi.d.mts +347 -0
  23. package/dist/money.d.mts +1 -140
  24. package/dist/money.mjs +6 -15
  25. package/dist/outbox.d.mts +49 -1
  26. package/dist/outbox.mjs +64 -1
  27. package/dist/payment-events.d.mts +132 -4
  28. package/dist/payment-events.mjs +22 -2
  29. package/dist/payment-gateway.d.mts +210 -7
  30. package/dist/payment-gateway.mjs +18 -1
  31. package/dist/period.d.mts +138 -7
  32. package/dist/period.mjs +316 -5
  33. package/dist/person-parse.d.mts +82 -0
  34. package/dist/person-parse.mjs +109 -0
  35. package/dist/proration.d.mts +2 -2
  36. package/dist/proration.mjs +18 -6
  37. package/dist/sla-policy.d.mts +1 -1
  38. package/dist/state-machine.mjs +13 -0
  39. package/dist/suspension.d.mts +1 -1
  40. package/dist/timezone-tlG7ygwx.d.mts +114 -0
  41. package/dist/timezone.d.mts +2 -100
  42. package/dist/timezone.mjs +38 -3
  43. package/dist/unit-cost-rate.d.mts +1 -2
  44. package/package.json +244 -232
  45. package/dist/currency-BCJnJBlb.d.mts +0 -119
@@ -1,93 +1,2 @@
1
- //#region src/scheduling/cadence.d.ts
2
- /**
3
- * Recurrence spec + pure date arithmetic.
4
- *
5
- * Describes "every N days", "every weekday", "on the 15th of each month", etc.
6
- * Consumed by `@classytic/order` (blanket / recurring orders, subscription
7
- * renewal), `@classytic/flow` (replenishment schedules), and
8
- * `@classytic/commission` (vesting periods, statement close).
9
- *
10
- * No cron parser dep — the `cron` kind stores an opaque string that the host
11
- * parses with its own library (node-cron, croner, etc.). This keeps primitives
12
- * zero-dep and lets hosts pick whatever cron dialect they want.
13
- *
14
- * @example
15
- * const monthly: Cadence = {
16
- * kind: 'monthly',
17
- * interval: 1,
18
- * dayOfMonth: 15,
19
- * startAt: new Date('2026-01-15T00:00:00Z'),
20
- * };
21
- * nextOccurrence(monthly, new Date('2026-02-20T00:00:00Z'));
22
- * // → 2026-03-15T00:00:00Z
23
- */
24
- type CadenceKind = 'daily' | 'weekly' | 'monthly' | 'yearly' | 'cron';
25
- /** ISO weekday — 1=Mon … 7=Sun. Matches Temporal + ISO 8601. */
26
- type IsoWeekday = 1 | 2 | 3 | 4 | 5 | 6 | 7;
27
- interface CadenceBase {
28
- readonly interval: number;
29
- readonly startAt: Date;
30
- readonly endAt?: Date;
31
- /**
32
- * Optional timezone IANA name (e.g. `Asia/Dhaka`). Pure functions in this
33
- * module operate on UTC Date values; the field is carried for the host to
34
- * localize occurrences when displaying. Occurrence math here is UTC-based.
35
- */
36
- readonly timezone?: string;
37
- }
38
- interface DailyCadence extends CadenceBase {
39
- readonly kind: 'daily';
40
- }
41
- interface WeeklyCadence extends CadenceBase {
42
- readonly kind: 'weekly';
43
- /**
44
- * If provided, only these ISO weekdays count as occurrences; the earliest
45
- * matching weekday on or after `startAt` is the first occurrence. If omitted,
46
- * the weekday of `startAt` is used.
47
- */
48
- readonly daysOfWeek?: readonly IsoWeekday[];
49
- }
50
- interface MonthlyCadence extends CadenceBase {
51
- readonly kind: 'monthly';
52
- /**
53
- * Day of month, 1–31. If the target month has fewer days (e.g. 31st → Feb),
54
- * occurrence snaps to the last day of the month.
55
- */
56
- readonly dayOfMonth: number;
57
- }
58
- interface YearlyCadence extends CadenceBase {
59
- readonly kind: 'yearly';
60
- /** Calendar month 1–12. */
61
- readonly month: number;
62
- /** Day of month 1–31. Snaps to last day of month if needed (e.g. Feb 29 → Feb 28 in non-leap years). */
63
- readonly dayOfMonth: number;
64
- }
65
- interface CronCadence extends CadenceBase {
66
- readonly kind: 'cron';
67
- /** Opaque cron expression — caller parses with its own cron library. */
68
- readonly expression: string;
69
- }
70
- type Cadence = DailyCadence | WeeklyCadence | MonthlyCadence | YearlyCadence | CronCadence;
71
- type CadenceErrorCode = 'INVALID_INTERVAL' | 'INVALID_DAY_OF_MONTH' | 'INVALID_MONTH' | 'INVALID_DAYS_OF_WEEK' | 'INVALID_START_AT' | 'INVALID_END_AT' | 'UNSUPPORTED_CRON';
72
- declare class CadenceError extends Error {
73
- readonly name = "CadenceError";
74
- readonly code: CadenceErrorCode;
75
- constructor(code: CadenceErrorCode, message: string);
76
- }
77
- /** Validate a Cadence spec. Call this before storing host-side. */
78
- declare function validateCadence(c: Cadence): void;
79
- /**
80
- * Next occurrence strictly after `from`. Returns `null` if the cadence has
81
- * ended (endAt passed) or the first occurrence is still in the future.
82
- *
83
- * Cron cadences return `null` — hosts must parse the expression themselves.
84
- */
85
- declare function nextOccurrence(cadence: Cadence, from: Date): Date | null;
86
- /**
87
- * Enumerate occurrences in `[from, to)` inclusive at `from`, exclusive at `to`.
88
- * Returns up to `limit` results (default 10_000) to guard against runaway
89
- * misconfigured cadences. Cron returns an empty array.
90
- */
91
- declare function occurrencesBetween(cadence: Cadence, from: Date, to: Date, limit?: number): Date[];
92
- //#endregion
1
+ import { a as CronCadence, c as MonthlyCadence, d as nextOccurrence, f as occurrencesBetween, i as CadenceKind, l as WeeklyCadence, n as CadenceError, o as DailyCadence, p as validateCadence, r as CadenceErrorCode, s as IsoWeekday, t as Cadence, u as YearlyCadence } from "./cadence-BFQVC1Qp.mjs";
93
2
  export { Cadence, CadenceError, CadenceErrorCode, CadenceKind, CronCadence, DailyCadence, IsoWeekday, MonthlyCadence, WeeklyCadence, YearlyCadence, nextOccurrence, occurrencesBetween, validateCadence };
package/dist/cadence.mjs CHANGED
@@ -1,4 +1,27 @@
1
+ import { addCivilDays, civilDate, civilDateOf, civilDateToInstant, civilDaysBetween, isValidTimeZone, localTimeParts } from "./timezone.mjs";
1
2
  //#region src/scheduling/cadence.ts
3
+ /**
4
+ * Recurrence spec + pure date arithmetic.
5
+ *
6
+ * Describes "every N days", "every weekday", "on the 15th of each month", etc.
7
+ * Consumed by `@classytic/order` (blanket / recurring orders, subscription
8
+ * renewal), `@classytic/flow` (replenishment schedules), and
9
+ * `@classytic/commission` (vesting periods, statement close).
10
+ *
11
+ * No cron parser dep — the `cron` kind stores an opaque string that the host
12
+ * parses with its own library (node-cron, croner, etc.). This keeps primitives
13
+ * zero-dep and lets hosts pick whatever cron dialect they want.
14
+ *
15
+ * @example
16
+ * const monthly: Cadence = {
17
+ * kind: 'monthly',
18
+ * interval: 1,
19
+ * dayOfMonth: 15,
20
+ * startAt: new Date('2026-01-15T00:00:00Z'),
21
+ * };
22
+ * nextOccurrence(monthly, new Date('2026-02-20T00:00:00Z'));
23
+ * // → 2026-03-15T00:00:00Z
24
+ */
2
25
  var CadenceError = class extends Error {
3
26
  name = "CadenceError";
4
27
  code;
@@ -13,6 +36,7 @@ function validateCadence(c) {
13
36
  if (Number.isNaN(c.startAt.getTime())) throw new CadenceError("INVALID_START_AT", "startAt is not a valid Date");
14
37
  if (c.endAt && Number.isNaN(c.endAt.getTime())) throw new CadenceError("INVALID_END_AT", "endAt is not a valid Date");
15
38
  if (c.endAt && c.endAt.getTime() < c.startAt.getTime()) throw new CadenceError("INVALID_END_AT", "endAt must be >= startAt");
39
+ if (c.timezone !== void 0 && !isValidTimeZone(c.timezone)) throw new CadenceError("INVALID_TIMEZONE", `timezone must be a valid IANA zone name (e.g. 'Asia/Dhaka'), got '${c.timezone}'`);
16
40
  if (c.kind === "weekly" && c.daysOfWeek) {
17
41
  if (c.daysOfWeek.length === 0) throw new CadenceError("INVALID_DAYS_OF_WEEK", "daysOfWeek cannot be empty");
18
42
  for (const d of c.daysOfWeek) if (!Number.isInteger(d) || d < 1 || d > 7) throw new CadenceError("INVALID_DAYS_OF_WEEK", `daysOfWeek entry must be 1–7, got ${d}`);
@@ -61,14 +85,119 @@ function occurrencesBetween(cadence, from, to, limit = 1e4) {
61
85
  return result;
62
86
  }
63
87
  function stepForward(cadence, from) {
88
+ const zone = cadence.timezone;
64
89
  switch (cadence.kind) {
65
- case "daily": return stepDaily(cadence, from);
66
- case "weekly": return stepWeekly(cadence, from);
67
- case "monthly": return stepMonthly(cadence, from);
68
- case "yearly": return stepYearly(cadence, from);
90
+ case "daily": return zone === void 0 ? stepDaily(cadence, from) : stepDailyZoned(cadence, from, zone);
91
+ case "weekly": return zone === void 0 ? stepWeekly(cadence, from) : stepWeeklyZoned(cadence, from, zone);
92
+ case "monthly": return zone === void 0 ? stepMonthly(cadence, from) : stepMonthlyZoned(cadence, from, zone);
93
+ case "yearly": return zone === void 0 ? stepYearly(cadence, from) : stepYearlyZoned(cadence, from, zone);
69
94
  default: return null;
70
95
  }
71
96
  }
97
+ /**
98
+ * The anchor's LOCAL wall clock. Seconds and milliseconds come straight from
99
+ * the instant because every modern IANA offset is a whole number of minutes —
100
+ * they are the same in every zone.
101
+ */
102
+ function wallClockOf(instant, zone) {
103
+ const cd = civilDateOf(instant, zone);
104
+ const { hour, minute } = localTimeParts(instant, zone);
105
+ return {
106
+ year: Number(cd.slice(0, 4)),
107
+ month: Number(cd.slice(5, 7)),
108
+ day: Number(cd.slice(8, 10)),
109
+ hour,
110
+ minute,
111
+ second: instant.getUTCSeconds(),
112
+ millisecond: instant.getUTCMilliseconds()
113
+ };
114
+ }
115
+ const pad2 = (n) => String(n).padStart(2, "0");
116
+ function civilOf(year, month, day) {
117
+ return civilDate(`${String(year).padStart(4, "0")}-${pad2(month)}-${pad2(day)}`);
118
+ }
119
+ /** Bind a local calendar day + the anchor's local time-of-day to an instant. */
120
+ function bindWall(cd, wall, zone) {
121
+ return civilDateToInstant(cd, zone, {
122
+ hour: wall.hour,
123
+ minute: wall.minute,
124
+ second: wall.second,
125
+ millisecond: wall.millisecond
126
+ });
127
+ }
128
+ /** Occurrences are anchor-local-date + n×interval LOCAL days, at the anchor's local time. */
129
+ function stepDailyZoned(c, from, zone) {
130
+ const wall = wallClockOf(c.startAt, zone);
131
+ const anchorCd = civilOf(wall.year, wall.month, wall.day);
132
+ const elapsed = civilDaysBetween(anchorCd, civilDateOf(from, zone));
133
+ let n = Math.max(0, Math.floor(elapsed / c.interval) * c.interval);
134
+ for (let i = 0; i < 1e3; i++) {
135
+ const candidate = bindWall(addCivilDays(anchorCd, n), wall, zone);
136
+ if (candidate.getTime() > from.getTime()) return candidate;
137
+ n += c.interval;
138
+ }
139
+ return bindWall(addCivilDays(anchorCd, n), wall, zone);
140
+ }
141
+ /** ISO weekday of a civil date — zone-free, a calendar day has a determinate weekday. */
142
+ function isoWeekdayOfCivil(cd) {
143
+ const utc = Date.UTC(Number(cd.slice(0, 4)), Number(cd.slice(5, 7)) - 1, Number(cd.slice(8, 10)));
144
+ return (new Date(utc).getUTCDay() + 6) % 7 + 1;
145
+ }
146
+ function stepWeeklyZoned(c, from, zone) {
147
+ const wall = wallClockOf(c.startAt, zone);
148
+ const anchorCd = civilOf(wall.year, wall.month, wall.day);
149
+ const targetDays = c.daysOfWeek ?? [isoWeekdayOfCivil(anchorCd)];
150
+ let cursor = civilDateOf(from, zone);
151
+ const maxSteps = Math.max(60, c.interval * 14) + 2;
152
+ for (let i = 0; i < maxSteps; i++) {
153
+ if (targetDays.includes(isoWeekdayOfCivil(cursor))) {
154
+ const weeksSinceAnchor = Math.floor(civilDaysBetween(anchorCd, cursor) / 7);
155
+ if (weeksSinceAnchor >= 0 && weeksSinceAnchor % c.interval === 0) {
156
+ const candidate = bindWall(cursor, wall, zone);
157
+ if (candidate.getTime() > from.getTime()) return candidate;
158
+ }
159
+ }
160
+ cursor = addCivilDays(cursor, 1);
161
+ }
162
+ return bindWall(cursor, wall, zone);
163
+ }
164
+ function stepMonthlyZoned(c, from, zone) {
165
+ const wall = wallClockOf(c.startAt, zone);
166
+ let candYear = wall.year;
167
+ let candMonth = wall.month;
168
+ const fromCd = civilDateOf(from, zone);
169
+ const monthsDiff = (Number(fromCd.slice(0, 4)) - wall.year) * 12 + (Number(fromCd.slice(5, 7)) - wall.month);
170
+ if (monthsDiff > 0) {
171
+ const aligned = Math.floor(monthsDiff / c.interval) * c.interval;
172
+ const abs = candYear * 12 + (candMonth - 1) + aligned;
173
+ candYear = Math.floor(abs / 12);
174
+ candMonth = abs % 12 + 1;
175
+ }
176
+ for (let i = 0; i < 1e3; i++) {
177
+ const day = clampDayOfMonth(c.dayOfMonth, candYear, candMonth - 1);
178
+ const candidate = bindWall(civilOf(candYear, candMonth, day), wall, zone);
179
+ if (candidate.getTime() > from.getTime()) return candidate;
180
+ candMonth += c.interval;
181
+ while (candMonth > 12) {
182
+ candMonth -= 12;
183
+ candYear += 1;
184
+ }
185
+ }
186
+ return bindWall(civilOf(candYear, candMonth, clampDayOfMonth(c.dayOfMonth, candYear, candMonth - 1)), wall, zone);
187
+ }
188
+ function stepYearlyZoned(c, from, zone) {
189
+ const wall = wallClockOf(c.startAt, zone);
190
+ let candYear = wall.year;
191
+ const yearsDiff = Number(civilDateOf(from, zone).slice(0, 4)) - wall.year;
192
+ if (yearsDiff > 0) candYear += Math.floor(yearsDiff / c.interval) * c.interval;
193
+ for (let i = 0; i < 1e3; i++) {
194
+ const day = clampDayOfMonth(c.dayOfMonth, candYear, c.month - 1);
195
+ const candidate = bindWall(civilOf(candYear, c.month, day), wall, zone);
196
+ if (candidate.getTime() > from.getTime()) return candidate;
197
+ candYear += c.interval;
198
+ }
199
+ return bindWall(civilOf(candYear, c.month, clampDayOfMonth(c.dayOfMonth, candYear, c.month - 1)), wall, zone);
200
+ }
72
201
  function stepDaily(c, from) {
73
202
  const start = c.startAt;
74
203
  const diffDays = Math.floor((from.getTime() - start.getTime()) / (1440 * 60 * 1e3));
@@ -0,0 +1,46 @@
1
+ //#region src/scheduling/calendar.d.ts
2
+ /**
3
+ * Calendar boundaries + arithmetic — the ONE place the stack computes
4
+ * "start of day / month", weekday, and month/day/year addition, so no package
5
+ * hand-rolls it with server-LOCAL `Date` methods (`getFullYear`/`getMonth`/
6
+ * `getDate`/`getDay`), which silently shift with the deploy machine's `TZ` env.
7
+ *
8
+ * Every function is PURE and takes a fixed **UTC offset in minutes** (east of
9
+ * UTC — `360` = UTC+6 / Asia-Dhaka). Same convention as `cadence` / `sla-policy`:
10
+ * no IANA tz database, no DST. A fixed offset is exact for zones without DST
11
+ * (e.g. Bangladesh, India, most of Asia) and is what a "daily limit resets at
12
+ * local midnight" boundary needs. For DST-correct wall-clock math the host
13
+ * resolves the offset for the instant and passes it in.
14
+ *
15
+ * `Date` instants are UTC epoch and timezone-independent — these helpers only
16
+ * decide WHERE the day/month boundary falls, never how "now" is recorded.
17
+ *
18
+ * Resolving that offset from an IANA zone name is `/timezone`'s job — the
19
+ * two compose: `startOfMonth(instant, zoneOffsetMinutes(instant, zone))`.
20
+ * For a DST-exact DAY boundary prefer the civil-date round-trip from
21
+ * `/timezone`: `civilDateToInstant(civilDateOf(instant, zone), zone)`.
22
+ */
23
+ /** Minutes east of UTC. `0` = UTC, `360` = UTC+6 (Asia/Dhaka), `-300` = UTC-5. */
24
+ type UtcOffsetMinutes = number;
25
+ /**
26
+ * The absolute instant of local midnight for the day CONTAINING `instant`, in
27
+ * the given offset. `startOfDay(2026-07-02T03:00Z, 360)` → `2026-07-01T18:00Z`
28
+ * (Dhaka midnight). With `offsetMinutes = 0` it's plain UTC midnight.
29
+ */
30
+ declare function startOfDay(instant: Date, offsetMinutes?: UtcOffsetMinutes): Date;
31
+ /** The absolute instant of the first-of-month midnight, in the given offset. */
32
+ declare function startOfMonth(instant: Date, offsetMinutes?: UtcOffsetMinutes): Date;
33
+ /** ISO weekday (1 = Monday … 7 = Sunday) of `instant` in the given offset. */
34
+ declare function isoWeekday(instant: Date, offsetMinutes?: UtcOffsetMinutes): number;
35
+ /** Add whole days as an exact 24h × n duration (offset-agnostic, no DST skew). */
36
+ declare function addDays(instant: Date, days: number): Date;
37
+ /**
38
+ * Add calendar months in UTC, clamping the day so month-ends never overflow
39
+ * (`addMonths(Jan 31, 1)` → Feb 28/29). Use for subscription/access periods
40
+ * instead of local `setMonth(getMonth() + n)`.
41
+ */
42
+ declare function addMonths(instant: Date, months: number): Date;
43
+ /** Add calendar years in UTC (clamps Feb 29 → Feb 28 on non-leap years). */
44
+ declare function addYears(instant: Date, years: number): Date;
45
+ //#endregion
46
+ export { isoWeekday as a, addYears as i, addDays as n, startOfDay as o, addMonths as r, startOfMonth as s, UtcOffsetMinutes as t };
@@ -1,46 +1,2 @@
1
- //#region src/scheduling/calendar.d.ts
2
- /**
3
- * Calendar boundaries + arithmetic — the ONE place the stack computes
4
- * "start of day / month", weekday, and month/day/year addition, so no package
5
- * hand-rolls it with server-LOCAL `Date` methods (`getFullYear`/`getMonth`/
6
- * `getDate`/`getDay`), which silently shift with the deploy machine's `TZ` env.
7
- *
8
- * Every function is PURE and takes a fixed **UTC offset in minutes** (east of
9
- * UTC — `360` = UTC+6 / Asia-Dhaka). Same convention as `cadence` / `sla-policy`:
10
- * no IANA tz database, no DST. A fixed offset is exact for zones without DST
11
- * (e.g. Bangladesh, India, most of Asia) and is what a "daily limit resets at
12
- * local midnight" boundary needs. For DST-correct wall-clock math the host
13
- * resolves the offset for the instant and passes it in.
14
- *
15
- * `Date` instants are UTC epoch and timezone-independent — these helpers only
16
- * decide WHERE the day/month boundary falls, never how "now" is recorded.
17
- *
18
- * Resolving that offset from an IANA zone name is `/timezone`'s job — the
19
- * two compose: `startOfMonth(instant, zoneOffsetMinutes(instant, zone))`.
20
- * For a DST-exact DAY boundary prefer the civil-date round-trip from
21
- * `/timezone`: `civilDateToInstant(civilDateOf(instant, zone), zone)`.
22
- */
23
- /** Minutes east of UTC. `0` = UTC, `360` = UTC+6 (Asia/Dhaka), `-300` = UTC-5. */
24
- type UtcOffsetMinutes = number;
25
- /**
26
- * The absolute instant of local midnight for the day CONTAINING `instant`, in
27
- * the given offset. `startOfDay(2026-07-02T03:00Z, 360)` → `2026-07-01T18:00Z`
28
- * (Dhaka midnight). With `offsetMinutes = 0` it's plain UTC midnight.
29
- */
30
- declare function startOfDay(instant: Date, offsetMinutes?: UtcOffsetMinutes): Date;
31
- /** The absolute instant of the first-of-month midnight, in the given offset. */
32
- declare function startOfMonth(instant: Date, offsetMinutes?: UtcOffsetMinutes): Date;
33
- /** ISO weekday (1 = Monday … 7 = Sunday) of `instant` in the given offset. */
34
- declare function isoWeekday(instant: Date, offsetMinutes?: UtcOffsetMinutes): number;
35
- /** Add whole days as an exact 24h × n duration (offset-agnostic, no DST skew). */
36
- declare function addDays(instant: Date, days: number): Date;
37
- /**
38
- * Add calendar months in UTC, clamping the day so month-ends never overflow
39
- * (`addMonths(Jan 31, 1)` → Feb 28/29). Use for subscription/access periods
40
- * instead of local `setMonth(getMonth() + n)`.
41
- */
42
- declare function addMonths(instant: Date, months: number): Date;
43
- /** Add calendar years in UTC (clamps Feb 29 → Feb 28 on non-leap years). */
44
- declare function addYears(instant: Date, years: number): Date;
45
- //#endregion
1
+ import { a as isoWeekday, i as addYears, n as addDays, o as startOfDay, r as addMonths, s as startOfMonth, t as UtcOffsetMinutes } from "./calendar-B6fm-FbR.mjs";
46
2
  export { UtcOffsetMinutes, addDays, addMonths, addYears, isoWeekday, startOfDay, startOfMonth };
@@ -54,6 +54,23 @@ function canonicalize(value, seen) {
54
54
  seen.add(obj);
55
55
  try {
56
56
  if (Array.isArray(value)) return `[${value.map((v) => canonicalize(v, seen)).join(",")}]`;
57
+ /**
58
+ * Reject CLASS INSTANCES — the silent-collapse hole.
59
+ *
60
+ * `Object.keys()` sees only own enumerable properties, so a `Types.ObjectId`,
61
+ * a `Buffer`, a `URL` or a hydrated Mongoose subdocument serializes as `{}`
62
+ * (or as a digit-keyed byte map). The digest then ignores the value entirely:
63
+ * two manifests referencing DIFFERENT documents hash identically, and the
64
+ * integrity check this module exists to provide reports "unchanged". That is
65
+ * the same failure mode the explicit `Date` branch above was written to
66
+ * prevent, so it gets the same treatment — an unsupported type FAILS.
67
+ *
68
+ * Callers holding ORM values project them to plain data first (see
69
+ * spine-accounting's `plainRef` / `plainSummary`); this makes that discipline
70
+ * enforced rather than remembered.
71
+ */
72
+ const proto = Object.getPrototypeOf(value);
73
+ if (proto !== null && proto !== Object.prototype) throw new CanonicalizeError(`class instance '${value.constructor?.name ?? "unknown"}' is not serializable — its own enumerable keys are not its value, so it would digest as {} and silently defeat the checksum. Project it to a plain object first.`);
57
74
  const rec = value;
58
75
  return `{${Object.keys(rec).sort().map((k) => `${JSON.stringify(k)}:${canonicalize(rec[k], seen)}`).join(",")}}`;
59
76
  } finally {
@@ -38,6 +38,33 @@ interface OperationContext {
38
38
  /** Free-form metadata the host may attach (locale, timezone, user-agent…). */
39
39
  metadata?: Readonly<Record<string, unknown>>;
40
40
  }
41
+ /**
42
+ * Structured logger — OBJECT-FIRST (`warn(fields, message)`), the pino / `fastify.log`
43
+ * convention.
44
+ *
45
+ * ## Why this is separate from `ErrorLogger` / `EventLogger`
46
+ *
47
+ * Those two (in `primitives/events`) are MESSAGE-FIRST — `error(message, ...args)`, the console
48
+ * convention — and `EventLogger`'s own docblock notes it mirrors arc's. The two conventions are
49
+ * not interchangeable: swapping them silently logs an object where a format string was expected,
50
+ * so a port must say which one it wants.
51
+ *
52
+ * This lives here rather than being re-declared per package because it was already declared per
53
+ * package: an identical object-first shape existed in `spine-kit` as `PlacementLogger`, and the
54
+ * next package needing one would have made a fourth. A logger is shared vocabulary, not domain.
55
+ *
56
+ * `warn`/`error` are required; `info` is OPTIONAL on purpose — a hand-written test logger or a
57
+ * script's two-method stub is a legitimate caller, and requiring `info` would break every one of
58
+ * them the day something wanted to log a healthy tick. Reach it as `logger?.info?.(…)`.
59
+ *
60
+ * `fastify.log` and a pino instance both satisfy this structurally, so hosts pass `req.log`
61
+ * with no cast.
62
+ */
63
+ interface StructuredLogger {
64
+ warn(fields: Record<string, unknown>, message: string): void;
65
+ error(fields: Record<string, unknown>, message: string): void;
66
+ info?(fields: Record<string, unknown>, message: string): void;
67
+ }
41
68
  /** An identity shape — useful on audit fields (`createdBy`, `updatedBy`). */
42
69
  interface ActorRef {
43
70
  id: string;
@@ -45,4 +72,4 @@ interface ActorRef {
45
72
  name?: string;
46
73
  }
47
74
  //#endregion
48
- export { ActorRef, OperationContext };
75
+ export { ActorRef, OperationContext, StructuredLogger };
@@ -1,2 +1,2 @@
1
- import { a as MINOR_UNIT_FACTOR, c as isFxSnapshot, d as toCurrencyCode, i as FxSnapshot, l as minorUnitFactor, n as CURRENCY_PATTERN, o as convertWithSnapshot, r as CurrencyCode, s as isCurrencyCode, t as CURRENCIES, u as reverseWithSnapshot } from "./currency-BCJnJBlb.mjs";
2
- export { CURRENCIES, CURRENCY_PATTERN, CurrencyCode, FxSnapshot, MINOR_UNIT_FACTOR, convertWithSnapshot, isCurrencyCode, isFxSnapshot, minorUnitFactor, reverseWithSnapshot, toCurrencyCode };
1
+ import { A as convertWithSnapshot, C as CURRENCY_PATTERN, D as FxSnapshot, E as FxRounding, F as minorUnitFactor, I as reverseWithSnapshot, L as toCurrencyCode, M as isCurrencyCode, N as isFxSnapshot, O as InvalidCurrencyCodeError, P as isKnownCurrency, S as CURRENCIES, T as CurrencyMismatchError, j as currencyCode, k as MINOR_UNIT_FACTOR, w as CurrencyCode } from "./money-DPo9fLBi.mjs";
2
+ export { CURRENCIES, CURRENCY_PATTERN, CurrencyCode, CurrencyMismatchError, FxRounding, FxSnapshot, InvalidCurrencyCodeError, MINOR_UNIT_FACTOR, convertWithSnapshot, currencyCode, isCurrencyCode, isFxSnapshot, isKnownCurrency, minorUnitFactor, reverseWithSnapshot, toCurrencyCode };
package/dist/currency.mjs CHANGED
@@ -26,11 +26,20 @@ const CURRENCIES = {
26
26
  IDR: "IDR"
27
27
  };
28
28
  /**
29
- * Number of minor units per major unit for common currencies.
29
+ * Number of minor units per major unit, by ISO 4217 code.
30
30
  *
31
- * ISO 4217 defines most currencies with 2 decimal places (cents). A few are
32
- * zero-decimal (JPY, KRW) or three-decimal (BHD, KWD, JOD). When a currency
33
- * isn't in this table, {@link minorUnitFactor} returns 100 as the safe default.
31
+ * **This table must list EVERY currency whose exponent is not 2.** ISO 4217
32
+ * defaults to 2 decimals, and {@link minorUnitFactor} falls back to 100 for a
33
+ * code it does not know — which is correct for the ~150 two-decimal currencies
34
+ * and catastrophically wrong for the others. A missing zero-decimal entry does
35
+ * not throw: it makes `fromMajor(1500, 'GNF')` store 150 000 minor units, a
36
+ * 100× error that reads as a plausible number all the way to the gateway.
37
+ *
38
+ * So the exceptions are enumerated EXHAUSTIVELY (all 0-, 3- and 4-decimal
39
+ * codes), leaving the `?? 100` fallback reachable only by a code that is not a
40
+ * real ISO 4217 currency — a typo, a lowercased code, or a placeholder. The
41
+ * two-decimal entries below are redundant with the default and kept only as
42
+ * documentation of the common set.
34
43
  */
35
44
  const MINOR_UNIT_FACTOR = {
36
45
  USD: 100,
@@ -53,13 +62,31 @@ const MINOR_UNIT_FACTOR = {
53
62
  HKD: 100,
54
63
  THB: 100,
55
64
  IDR: 100,
65
+ BIF: 1,
66
+ CLP: 1,
67
+ DJF: 1,
68
+ GNF: 1,
69
+ ISK: 1,
70
+ KMF: 1,
56
71
  KRW: 1,
72
+ PYG: 1,
73
+ RWF: 1,
74
+ UGX: 1,
75
+ UYI: 1,
57
76
  VND: 1,
77
+ VUV: 1,
78
+ XAF: 1,
79
+ XOF: 1,
80
+ XPF: 1,
58
81
  BHD: 1e3,
59
- KWD: 1e3,
82
+ IQD: 1e3,
60
83
  JOD: 1e3,
84
+ KWD: 1e3,
85
+ LYD: 1e3,
61
86
  OMR: 1e3,
62
- TND: 1e3
87
+ TND: 1e3,
88
+ CLF: 1e4,
89
+ UYW: 1e4
63
90
  };
64
91
  /**
65
92
  * ISO 4217 format — 3 uppercase letters. Exported so consumers can embed
@@ -78,26 +105,107 @@ const CURRENCY_PATTERN = /^[A-Z]{3}$/;
78
105
  function toCurrencyCode(value) {
79
106
  return CURRENCY_PATTERN.test(value) ? value : null;
80
107
  }
108
+ /**
109
+ * Validate + brand, or throw. The constructor form — same relationship to
110
+ * {@link toCurrencyCode} that `civilDate` has to `isCivilDate`.
111
+ *
112
+ * `Money` carries a {@link CurrencyCode}, not a bare string, and this is where
113
+ * an untrusted value earns the brand. Rejecting `'jpy'` here matters: lowercase
114
+ * misses {@link MINOR_UNIT_FACTOR}, so it silently becomes a 2-decimal currency
115
+ * and every JPY amount is off by 100×.
116
+ */
117
+ function currencyCode(value) {
118
+ const code = toCurrencyCode(value);
119
+ if (code === null) throw new InvalidCurrencyCodeError(value);
120
+ return code;
121
+ }
122
+ var InvalidCurrencyCodeError = class extends Error {
123
+ name = "InvalidCurrencyCodeError";
124
+ value;
125
+ constructor(value) {
126
+ super(`Invalid ISO 4217 currency code '${value}' — expected three uppercase letters. A code that misses the minor-unit table is assumed to have two decimals, which is a 100× error for JPY-style currencies.`);
127
+ this.value = value;
128
+ }
129
+ };
130
+ /**
131
+ * Raised when an operation combines two currencies.
132
+ *
133
+ * Declared HERE rather than in `/money` so the FX seam below can throw it
134
+ * without a runtime import back into `/money` (which imports
135
+ * {@link minorUnitFactor} from this file). `/money` re-exports it, so
136
+ * `import { CurrencyMismatchError } from '@classytic/primitives/money'` still
137
+ * resolves.
138
+ */
139
+ var CurrencyMismatchError = class extends Error {
140
+ name = "CurrencyMismatchError";
141
+ left;
142
+ right;
143
+ constructor(left, right) {
144
+ super(`Currency mismatch: ${left} vs ${right}`);
145
+ this.left = left;
146
+ this.right = right;
147
+ }
148
+ };
81
149
  /** Type predicate for `unknown` input. */
82
150
  function isCurrencyCode(value) {
83
151
  return typeof value === "string" && CURRENCY_PATTERN.test(value);
84
152
  }
85
153
  /**
86
- * Minor units per major unit (e.g. 100 for USD, 1 for JPY, 1000 for KWD).
87
- * Falls back to 100 when the currency is unknown — matches the ISO 4217 default.
154
+ * Minor units per major unit (100 for USD, 1 for JPY, 1000 for KWD, 10 000 for CLF).
155
+ *
156
+ * Falls back to 100 for an unlisted code — the ISO 4217 default exponent. Because
157
+ * {@link MINOR_UNIT_FACTOR} enumerates every non-2-decimal currency, that fallback
158
+ * is now only reached by codes that are not ISO 4217 currencies at all; use
159
+ * {@link isKnownCurrency} at a config/boot boundary if you need to reject those
160
+ * rather than assume two decimals for them.
88
161
  */
89
162
  function minorUnitFactor(currency) {
90
163
  return MINOR_UNIT_FACTOR[currency] ?? 100;
91
164
  }
92
165
  /**
93
- * Apply an {@link FxSnapshot} to a source-currency amount. Returns the
94
- * equivalent base-currency amount. No rounding — callers that persist into
95
- * an integer minor-unit column must round explicitly with a deterministic
96
- * rule (`Math.round`, banker's rounding, floor for duty-conservative
97
- * postings, etc.).
166
+ * Does {@link MINOR_UNIT_FACTOR} carry an explicit exponent for this code?
167
+ *
168
+ * `false` means {@link minorUnitFactor} would ASSUME two decimals. That
169
+ * assumption is right for an ordinary ISO 4217 code and wrong by 100× for a
170
+ * mistyped or lowercased one (`'jpy'`), so a boot-time config validator that
171
+ * accepts an operator-supplied currency should gate on this instead of letting
172
+ * the default answer for it.
98
173
  */
99
- function convertWithSnapshot(sourceAmount, fx) {
100
- return sourceAmount * fx.rate;
174
+ function isKnownCurrency(currency) {
175
+ return Object.hasOwn(MINOR_UNIT_FACTOR, currency);
176
+ }
177
+ function roundMinor(raw, mode) {
178
+ if (mode === "floor") return Math.floor(raw);
179
+ if (mode === "ceil") return Math.ceil(raw);
180
+ return Math.round(Math.abs(raw)) * (raw < 0 ? -1 : 1);
181
+ }
182
+ /**
183
+ * Apply an {@link FxSnapshot} to a source-currency amount.
184
+ *
185
+ * ## Why this takes `Money` and not a number
186
+ *
187
+ * It used to take a bare `number`, which meant nothing could check the amount
188
+ * was denominated in `fx.sourceCurrency`. Applying a USD→BDT snapshot to a EUR
189
+ * amount type-checked, ran, and returned a plausible wrong number — the
190
+ * arithmetic is identical, only the meaning is wrong, so no test that asserts a
191
+ * total would notice. Taking `Money` lets the seam enforce its own
192
+ * precondition: a mismatch throws {@link CurrencyMismatchError}.
193
+ *
194
+ * ## Minor-unit exponents are handled
195
+ *
196
+ * The rate is quoted in MAJOR units (1 source = `rate` base), while `Money`
197
+ * holds minor units, and the two currencies need not share an exponent
198
+ * (USD has 2, JPY has 0). The conversion therefore goes
199
+ * `minor → major → ×rate → base minor`, not a naive `amount × rate`, which is
200
+ * off by 100× for any pair whose exponents differ.
201
+ */
202
+ function convertWithSnapshot(source, fx, options = {}) {
203
+ if (source.currency !== fx.sourceCurrency) throw new CurrencyMismatchError(source.currency, fx.sourceCurrency);
204
+ if (!Number.isFinite(fx.rate)) throw new TypeError(`FxSnapshot.rate must be finite, got ${fx.rate}`);
205
+ return {
206
+ amount: roundMinor(source.amount / minorUnitFactor(fx.sourceCurrency) * fx.rate * minorUnitFactor(fx.baseCurrency), options.rounding ?? "half-away-from-zero"),
207
+ currency: currencyCode(fx.baseCurrency)
208
+ };
101
209
  }
102
210
  /**
103
211
  * Reverse an {@link FxSnapshot} — reconstruct the source-currency amount
@@ -105,10 +213,17 @@ function convertWithSnapshot(sourceAmount, fx) {
105
213
  * currency". Not round-trip safe under rounding; prefer storing the
106
214
  * original source amount alongside the base amount when audit fidelity
107
215
  * matters.
216
+ *
217
+ * Symmetrically guarded: a base amount that is not in `fx.baseCurrency` throws
218
+ * rather than dividing by a rate that does not describe it.
108
219
  */
109
- function reverseWithSnapshot(baseAmount, fx) {
110
- if (fx.rate === 0) throw new Error("FxSnapshot.rate must be non-zero");
111
- return baseAmount / fx.rate;
220
+ function reverseWithSnapshot(base, fx, options = {}) {
221
+ if (base.currency !== fx.baseCurrency) throw new CurrencyMismatchError(base.currency, fx.baseCurrency);
222
+ if (fx.rate === 0 || !Number.isFinite(fx.rate)) throw new TypeError(`FxSnapshot.rate must be a non-zero finite number, got ${fx.rate}`);
223
+ return {
224
+ amount: roundMinor(base.amount / minorUnitFactor(fx.baseCurrency) / fx.rate * minorUnitFactor(fx.sourceCurrency), options.rounding ?? "half-away-from-zero"),
225
+ currency: currencyCode(fx.sourceCurrency)
226
+ };
112
227
  }
113
228
  /**
114
229
  * Structural validation — checks shape only. Does NOT verify the rate is
@@ -121,4 +236,4 @@ function isFxSnapshot(value) {
121
236
  return typeof v.sourceCurrency === "string" && typeof v.baseCurrency === "string" && typeof v.rate === "number" && Number.isFinite(v.rate) && v.rate > 0 && v.snapshotAt instanceof Date;
122
237
  }
123
238
  //#endregion
124
- export { CURRENCIES, CURRENCY_PATTERN, MINOR_UNIT_FACTOR, convertWithSnapshot, isCurrencyCode, isFxSnapshot, minorUnitFactor, reverseWithSnapshot, toCurrencyCode };
239
+ export { CURRENCIES, CURRENCY_PATTERN, CurrencyMismatchError, InvalidCurrencyCodeError, MINOR_UNIT_FACTOR, convertWithSnapshot, currencyCode, isCurrencyCode, isFxSnapshot, isKnownCurrency, minorUnitFactor, reverseWithSnapshot, toCurrencyCode };