@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/money.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { minorUnitFactor } from "./currency.mjs";
1
+ import { CurrencyMismatchError, currencyCode, isCurrencyCode, minorUnitFactor } from "./currency.mjs";
2
2
  //#region src/money/money.ts
3
3
  /**
4
4
  * Cast a raw number to {@link MinorUnits}. Pass the currency as the second
@@ -12,26 +12,17 @@ import { minorUnitFactor } from "./currency.mjs";
12
12
  function MinorUnits(amount, _currency) {
13
13
  return amount;
14
14
  }
15
- var CurrencyMismatchError = class extends Error {
16
- name = "CurrencyMismatchError";
17
- left;
18
- right;
19
- constructor(left, right) {
20
- super(`Currency mismatch: ${left} vs ${right}`);
21
- this.left = left;
22
- this.right = right;
23
- }
24
- };
25
15
  /**
26
16
  * Construct {@link Money} from an integer minor-unit amount.
27
17
  *
28
18
  * @throws TypeError if `amount` is not a finite integer.
19
+ * @throws InvalidCurrencyCodeError if `currency` is not ISO 4217 format.
29
20
  */
30
21
  function money(amount, currency) {
31
22
  if (!Number.isFinite(amount) || !Number.isInteger(amount)) throw new TypeError(`Money.amount must be a finite integer in minor units, got ${amount}`);
32
23
  return {
33
24
  amount,
34
- currency
25
+ currency: currencyCode(currency)
35
26
  };
36
27
  }
37
28
  /**
@@ -60,7 +51,7 @@ function fromMajor(major, currency) {
60
51
  const cleaned = Number(raw.toPrecision(15));
61
52
  return {
62
53
  amount: Math.round(Math.abs(cleaned)) * (cleaned < 0 ? -1 : 1),
63
- currency
54
+ currency: currencyCode(currency)
64
55
  };
65
56
  }
66
57
  /** Convert {@link Money} back to its major-unit floating-point representation. */
@@ -100,7 +91,7 @@ function multiplyMoney(m, scalar) {
100
91
  function sumMoney(values, currency) {
101
92
  return values.reduce((acc, v) => addMoney(acc, v), {
102
93
  amount: 0,
103
- currency
94
+ currency: currencyCode(currency)
104
95
  });
105
96
  }
106
97
  /** Strict equality (amount and currency both match). */
@@ -180,7 +171,7 @@ function formatMinorUnits(minor, minorUnitDecimals = 2) {
180
171
  }
181
172
  /** Structural type guard. */
182
173
  function isMoney(value) {
183
- return typeof value === "object" && value !== null && typeof value.amount === "number" && Number.isInteger(value.amount) && typeof value.currency === "string";
174
+ return typeof value === "object" && value !== null && typeof value.amount === "number" && Number.isInteger(value.amount) && isCurrencyCode(value.currency);
184
175
  }
185
176
  //#endregion
186
177
  export { CurrencyMismatchError, MinorUnits, absMoney, addMoney, compareMoney, equalsMoney, formatMinorUnits, fromMajor, isMoney, isNegativeMoney, isPositiveMoney, isZeroMoney, majorToMinorUnits, minorUnitsToMajor, money, multiplyMoney, negateMoney, percentOfMinor, subtractMoney, sumMoney, toMajor };
package/dist/otp.mjs CHANGED
@@ -26,7 +26,7 @@ import { createHmac, randomInt, timingSafeEqual } from "node:crypto";
26
26
  * modeled here so the verify primitive stays stateless between attempts.
27
27
  */
28
28
  const DEFAULT_LENGTH = 6;
29
- const DEFAULT_TTL_MS = 300 * 1e3;
29
+ const DEFAULT_TTL_MS = 3e5;
30
30
  const DEFAULT_MAX_ATTEMPTS = 5;
31
31
  function hmac(code, secret) {
32
32
  return createHmac("sha256", secret).update(code).digest("hex");
package/dist/outbox.d.mts CHANGED
@@ -1,5 +1,4 @@
1
1
  import { DeadLetteredEvent, DomainEvent } from "./events.mjs";
2
-
3
2
  //#region src/events/outbox.d.ts
4
3
  /**
5
4
  * Options passed to {@link OutboxStore.save} for richer write semantics.
@@ -157,6 +156,19 @@ declare class InvalidOutboxEventError extends Error {
157
156
  * `claimPending`.** If the underlying storage has corrupt rows, the
158
157
  * store is responsible for quarantining them.
159
158
  */
159
+ /**
160
+ * The delivery states an outbox row can hold.
161
+ *
162
+ * Named HERE, in the contract, rather than in each store. It went unnamed while every
163
+ * method took an event id and none took a state — `countByStatus` is the first member
164
+ * that makes the vocabulary part of the interface, and two stores had already written
165
+ * the same three strings independently by then.
166
+ *
167
+ * `dead_letter` is terminal until an operator calls `requeue`; it is a durable statement
168
+ * that something is owed, not a deletion.
169
+ */
170
+ declare const OUTBOX_STATUSES: readonly ["pending", "delivered", "dead_letter"];
171
+ type OutboxStatus = (typeof OUTBOX_STATUSES)[number];
160
172
  interface OutboxStore {
161
173
  /**
162
174
  * Declares that `save` ENLISTS `options.session` in the caller's transaction,
@@ -242,6 +254,41 @@ interface OutboxStore {
242
254
  * @returns Number of purged events.
243
255
  */
244
256
  purge?(olderThanMs: number): Promise<number>;
257
+ /**
258
+ * Re-drive ONE dead-lettered event: back to `pending`, attempts reset, lease cleared,
259
+ * immediately visible.
260
+ *
261
+ * The other half of `fail({ deadLetter: true })`. Dead-lettering is only a durable
262
+ * statement that something is owed; a store that can dead-letter but not requeue leaves
263
+ * an operator who has fixed the root cause with no way to act on the worklist except
264
+ * hand-editing rows in the database.
265
+ *
266
+ * MUST be scoped to `dead_letter` — requeuing a `pending` row would reset the attempt
267
+ * count on an event that is merely backing off, defeating the retry policy, and
268
+ * requeuing a `delivered` row would republish an event every subscriber already saw.
269
+ *
270
+ * @returns `true` if a dead-lettered event was requeued, `false` if no such event
271
+ * exists — NOT an error. An operator re-driving a worklist races the relay, and an
272
+ * id that has already been dealt with is an ordinary outcome.
273
+ */
274
+ requeue?(eventId: string): Promise<boolean>;
275
+ /**
276
+ * How many events are in `status`. The health surface behind an operator dashboard or
277
+ * an alert (`dead_letter > 0` means money or access may be unrecorded).
278
+ */
279
+ countByStatus?(status: OutboxStatus): Promise<number>;
280
+ /**
281
+ * Age in ms of the OLDEST pending event, or `null` when nothing is pending.
282
+ *
283
+ * The single most useful outbox health signal, and the one a naive dashboard misses: a
284
+ * pending COUNT looks identical whether the queue is draining briskly or has been
285
+ * wedged for an hour behind one poison row. Age distinguishes them.
286
+ *
287
+ * Measured from the event's creation, not its last attempt — the question being asked
288
+ * is "how long has this event gone undelivered", and resetting the clock on each retry
289
+ * would make a permanently-failing row look permanently fresh.
290
+ */
291
+ oldestPendingAgeMs?(): Promise<number | null>;
245
292
  }
246
293
  //#endregion
247
- export { InvalidOutboxEventError, OutboxAcknowledgeOptions, OutboxClaimOptions, OutboxErrorInfo, OutboxFailOptions, OutboxFailureContext, OutboxFailureDecision, OutboxFailurePolicy, OutboxOwnershipError, OutboxStore, OutboxWriteOptions };
294
+ export { InvalidOutboxEventError, OUTBOX_STATUSES, OutboxAcknowledgeOptions, OutboxClaimOptions, OutboxErrorInfo, OutboxFailOptions, OutboxFailureContext, OutboxFailureDecision, OutboxFailurePolicy, OutboxOwnershipError, OutboxStatus, OutboxStore, OutboxWriteOptions };
package/dist/outbox.mjs CHANGED
@@ -26,5 +26,68 @@ var InvalidOutboxEventError = class extends Error {
26
26
  super(`Invalid outbox event: ${reason}`);
27
27
  }
28
28
  };
29
+ /**
30
+ * Durable storage contract for the transactional outbox pattern.
31
+ *
32
+ * **Required methods**: `save`, `getPending`, `acknowledge`.
33
+ *
34
+ * **Optional capabilities** — stores opt-in to richer semantics:
35
+ *
36
+ * - `claimPending` — lease-based work claim for multi-worker relay
37
+ * - `fail` — failure tracking with retry scheduling and dead-letter
38
+ * - `getDeadLettered` — typed DLQ replay surface
39
+ * - `purge` — acknowledged event cleanup
40
+ *
41
+ * Arc's runtime detects capabilities at runtime and degrades gracefully for
42
+ * legacy stores that only implement the required methods.
43
+ *
44
+ * ## Required semantics
45
+ *
46
+ * Implementations MUST honor these contracts — arc's relay depends on them
47
+ * for correctness of at-least-once delivery:
48
+ *
49
+ * 1. **`save` must reject invalid events.** If `event.type` or `event.meta.id`
50
+ * is missing/empty, throw — do not persist. Callers pre-validate but
51
+ * stores should defend against direct-save code paths.
52
+ *
53
+ * 2. **`claimPending` must be atomic.** Two workers calling `claimPending`
54
+ * concurrently must never receive the same event. SQL: `SELECT ... FOR
55
+ * UPDATE SKIP LOCKED`; Mongo: `findOneAndUpdate` with `{ leaseOwner:
56
+ * null }` in the match condition.
57
+ *
58
+ * 3. **`acknowledge` / `fail` must throw {@link OutboxOwnershipError} on
59
+ * ownership mismatch.** When `options.consumerId` is provided and does
60
+ * not match the current lease owner, throw — never silently no-op. The
61
+ * relay relies on this signal to avoid counting hijacked events as
62
+ * successfully relayed.
63
+ *
64
+ * 4. **`acknowledge` on an unknown `eventId` is a no-op.** Keeps the relay
65
+ * idempotent when the store has been purged or the event was manually
66
+ * removed. Do NOT throw `OutboxOwnershipError` in this case.
67
+ *
68
+ * 5. **`fail` must deterministically update lease/visibility.** On success,
69
+ * the event MUST become re-claimable (immediately or at `retryAt`) or
70
+ * transition to dead-letter. Lease owner should be cleared.
71
+ *
72
+ * 6. **Malformed events must never be returned by `getPending` /
73
+ * `claimPending`.** If the underlying storage has corrupt rows, the
74
+ * store is responsible for quarantining them.
75
+ */
76
+ /**
77
+ * The delivery states an outbox row can hold.
78
+ *
79
+ * Named HERE, in the contract, rather than in each store. It went unnamed while every
80
+ * method took an event id and none took a state — `countByStatus` is the first member
81
+ * that makes the vocabulary part of the interface, and two stores had already written
82
+ * the same three strings independently by then.
83
+ *
84
+ * `dead_letter` is terminal until an operator calls `requeue`; it is a durable statement
85
+ * that something is owed, not a deletion.
86
+ */
87
+ const OUTBOX_STATUSES = [
88
+ "pending",
89
+ "delivered",
90
+ "dead_letter"
91
+ ];
29
92
  //#endregion
30
- export { InvalidOutboxEventError, OutboxOwnershipError };
93
+ export { InvalidOutboxEventError, OUTBOX_STATUSES, OutboxOwnershipError };
@@ -20,8 +20,11 @@
20
20
  * Zero runtime, zero deps.
21
21
  */
22
22
  declare const PAYMENT_ALLOCATION_STATUS: {
23
- /** No portion of the payment has been applied to any obligation yet. */readonly UNALLOCATED: "unallocated"; /** Some of the payment is applied; remainder is available credit. */
24
- readonly PARTIAL: "partial"; /** Fully applied — `sum(allocations) === payment.totalAmount`. */
23
+ /** No portion of the payment has been applied to any obligation yet. */
24
+ readonly UNALLOCATED: "unallocated";
25
+ /** Some of the payment is applied; remainder is available credit. */
26
+ readonly PARTIAL: "partial";
27
+ /** Fully applied — `sum(allocations) === payment.totalAmount`. */
25
28
  readonly ALLOCATED: "allocated";
26
29
  /**
27
30
  * Sum of allocations exceeds the payment total — invariant violation
@@ -31,8 +34,11 @@ declare const PAYMENT_ALLOCATION_STATUS: {
31
34
  };
32
35
  type PaymentAllocationStatus = (typeof PAYMENT_ALLOCATION_STATUS)[keyof typeof PAYMENT_ALLOCATION_STATUS];
33
36
  declare const RECONCILIATION_STATUS: {
34
- /** No bank statement line / settlement record has been linked yet. */readonly UNRECONCILED: "unreconciled"; /** Auto-matched to a `BankTransaction` by amount + date + reference. */
35
- readonly MATCHED: "matched"; /** Operator-confirmed match (auto-match failed or was overridden). */
37
+ /** No bank statement line / settlement record has been linked yet. */
38
+ readonly UNRECONCILED: "unreconciled";
39
+ /** Auto-matched to a `BankTransaction` by amount + date + reference. */
40
+ readonly MATCHED: "matched";
41
+ /** Operator-confirmed match (auto-match failed or was overridden). */
36
42
  readonly MANUAL: "manual";
37
43
  /**
38
44
  * Match was contested — amount mismatch, duplicate claim, chargeback
@@ -20,15 +20,29 @@
20
20
  * Zero runtime, zero deps.
21
21
  */
22
22
  const PAYMENT_ALLOCATION_STATUS = {
23
+ /** No portion of the payment has been applied to any obligation yet. */
23
24
  UNALLOCATED: "unallocated",
25
+ /** Some of the payment is applied; remainder is available credit. */
24
26
  PARTIAL: "partial",
27
+ /** Fully applied — `sum(allocations) === payment.totalAmount`. */
25
28
  ALLOCATED: "allocated",
29
+ /**
30
+ * Sum of allocations exceeds the payment total — invariant violation
31
+ * that should be flagged for operator review, never silently accepted.
32
+ */
26
33
  OVER_ALLOCATED: "over_allocated"
27
34
  };
28
35
  const RECONCILIATION_STATUS = {
36
+ /** No bank statement line / settlement record has been linked yet. */
29
37
  UNRECONCILED: "unreconciled",
38
+ /** Auto-matched to a `BankTransaction` by amount + date + reference. */
30
39
  MATCHED: "matched",
40
+ /** Operator-confirmed match (auto-match failed or was overridden). */
31
41
  MANUAL: "manual",
42
+ /**
43
+ * Match was contested — amount mismatch, duplicate claim, chargeback
44
+ * in flight. Halts further automated processing until resolved.
45
+ */
32
46
  DISPUTED: "disputed"
33
47
  };
34
48
  const ALLOCATION_VALUES = new Set(Object.values(PAYMENT_ALLOCATION_STATUS));
@@ -1,6 +1,6 @@
1
- import { Money } from "./money.mjs";
1
+ import { n as Money } from "./money-CfAvfNiM.mjs";
2
2
  import { PaymentMethodKind } from "./payment-method-kind.mjs";
3
-
3
+ import { ProviderUnknownCause } from "./payment-gateway.mjs";
4
4
  //#region src/events/payment-events.d.ts
5
5
  /**
6
6
  * Canonical event-name constants. Packages SHOULD publish events
@@ -21,6 +21,19 @@ declare const PAYMENT_EVENT_TYPE: {
21
21
  readonly DISPUTE_WON: "payment.dispute_won";
22
22
  readonly DISPUTE_LOST: "payment.dispute_lost";
23
23
  readonly SETTLED: "payment.settled";
24
+ /**
25
+ * The provider command produced NO observable outcome — timeout, abort,
26
+ * network fault, or a provider that holds no record and refuses to guess.
27
+ *
28
+ * This member exists because the port contract (`ProviderCommandResult` in
29
+ * `/payment-gateway`) is three-valued — `confirmed | declined | unknown` —
30
+ * while this catalogue was two-valued. An adapter that hit the third case had
31
+ * nowhere to publish it, so the only shapes on the bus were SUCCEEDED and
32
+ * FAILED, and an unobserved outcome had to be forced into one of them.
33
+ * Forcing it into FAILED is the one that double-charges: it licenses a retry
34
+ * for a command that may already have moved money.
35
+ */
36
+ readonly UNKNOWN: "payment.unknown";
24
37
  };
25
38
  type PaymentEventType = (typeof PAYMENT_EVENT_TYPE)[keyof typeof PAYMENT_EVENT_TYPE];
26
39
  /**
@@ -30,6 +43,8 @@ type PaymentEventType = (typeof PAYMENT_EVENT_TYPE)[keyof typeof PAYMENT_EVENT_T
30
43
  * "money in flight" not "money received".
31
44
  */
32
45
  interface PaymentInitiatedPayload {
46
+ /** Discriminant — narrows {@link PaymentEventPayload}. */
47
+ readonly eventType: typeof PAYMENT_EVENT_TYPE.INITIATED;
33
48
  /** Stable payment identifier owned by the emitting package. */
34
49
  paymentId: string;
35
50
  /**
@@ -57,6 +72,8 @@ interface PaymentInitiatedPayload {
57
72
  * downstream fulfilment unlocks.
58
73
  */
59
74
  interface PaymentSucceededPayload {
75
+ /** Discriminant — narrows {@link PaymentEventPayload}. */
76
+ readonly eventType: typeof PAYMENT_EVENT_TYPE.SUCCEEDED;
60
77
  paymentId: string;
61
78
  providerRef?: string;
62
79
  providerCode: string;
@@ -73,6 +90,8 @@ interface PaymentSucceededPayload {
73
90
  * overdue.
74
91
  */
75
92
  interface PaymentFailedPayload {
93
+ /** Discriminant — narrows {@link PaymentEventPayload}. */
94
+ readonly eventType: typeof PAYMENT_EVENT_TYPE.FAILED;
76
95
  paymentId: string;
77
96
  providerRef?: string;
78
97
  providerCode: string;
@@ -93,6 +112,8 @@ interface PaymentFailedPayload {
93
112
  * itself (pre- or post-clearing, often due to operator error).
94
113
  */
95
114
  interface PaymentRefundedPayload {
115
+ /** Discriminant — narrows {@link PaymentEventPayload}. */
116
+ readonly eventType: typeof PAYMENT_EVENT_TYPE.REFUNDED;
96
117
  paymentId: string;
97
118
  /** Refund identifier (separate from `paymentId`). */
98
119
  refundId: string;
@@ -116,6 +137,8 @@ interface PaymentRefundedPayload {
116
137
  * state.
117
138
  */
118
139
  interface PaymentReversedPayload {
140
+ /** Discriminant — narrows {@link PaymentEventPayload}. */
141
+ readonly eventType: typeof PAYMENT_EVENT_TYPE.REVERSED;
119
142
  paymentId: string;
120
143
  providerRef?: string;
121
144
  providerCode: string;
@@ -136,6 +159,8 @@ interface PaymentReversedPayload {
136
159
  * ledger bridges and closes the audit loop.
137
160
  */
138
161
  interface PaymentReconciledPayload {
162
+ /** Discriminant — narrows {@link PaymentEventPayload}. */
163
+ readonly eventType: typeof PAYMENT_EVENT_TYPE.RECONCILED;
139
164
  /** Allocation identifier — NOT the payment id (a payment can have many). */
140
165
  allocationId: string;
141
166
  paymentId: string;
@@ -156,6 +181,8 @@ interface PaymentReconciledPayload {
156
181
  * "hold" from "settle"; consumers should NOT treat this as cleared funds.
157
182
  */
158
183
  interface PaymentAuthorizedPayload {
184
+ /** Discriminant — narrows {@link PaymentEventPayload}. */
185
+ readonly eventType: typeof PAYMENT_EVENT_TYPE.AUTHORIZED;
159
186
  paymentId: string;
160
187
  providerRef?: string;
161
188
  providerCode: string;
@@ -173,6 +200,8 @@ interface PaymentAuthorizedPayload {
173
200
  * Pairs with {@link PaymentAuthorizedPayload} for two-phase flows.
174
201
  */
175
202
  interface PaymentCapturedPayload {
203
+ /** Discriminant — narrows {@link PaymentEventPayload}. */
204
+ readonly eventType: typeof PAYMENT_EVENT_TYPE.CAPTURED;
176
205
  paymentId: string;
177
206
  providerRef?: string;
178
207
  providerCode: string;
@@ -191,6 +220,8 @@ interface PaymentCapturedPayload {
191
220
  * Authorisation hold released — uncaptured auth voided before settlement.
192
221
  */
193
222
  interface PaymentAuthVoidedPayload {
223
+ /** Discriminant — narrows {@link PaymentEventPayload}. */
224
+ readonly eventType: typeof PAYMENT_EVENT_TYPE.AUTH_VOIDED;
194
225
  paymentId: string;
195
226
  providerRef?: string;
196
227
  providerCode: string;
@@ -205,6 +236,8 @@ interface PaymentAuthVoidedPayload {
205
236
  * payment. The merchant typically has a deadline to submit evidence.
206
237
  */
207
238
  interface PaymentDisputedPayload {
239
+ /** Discriminant — narrows {@link PaymentEventPayload}. */
240
+ readonly eventType: typeof PAYMENT_EVENT_TYPE.DISPUTED;
208
241
  paymentId: string;
209
242
  /** Provider's dispute / chargeback id. */
210
243
  disputeId: string;
@@ -223,6 +256,8 @@ interface PaymentDisputedPayload {
223
256
  }
224
257
  /** Dispute resolved in merchant's favour — funds restored. */
225
258
  interface PaymentDisputeWonPayload {
259
+ /** Discriminant — narrows {@link PaymentEventPayload}. */
260
+ readonly eventType: typeof PAYMENT_EVENT_TYPE.DISPUTE_WON;
226
261
  paymentId: string;
227
262
  disputeId: string;
228
263
  providerRef?: string;
@@ -234,6 +269,8 @@ interface PaymentDisputeWonPayload {
234
269
  }
235
270
  /** Dispute resolved against merchant — funds permanently lost plus fee. */
236
271
  interface PaymentDisputeLostPayload {
272
+ /** Discriminant — narrows {@link PaymentEventPayload}. */
273
+ readonly eventType: typeof PAYMENT_EVENT_TYPE.DISPUTE_LOST;
237
274
  paymentId: string;
238
275
  disputeId: string;
239
276
  providerRef?: string;
@@ -251,6 +288,8 @@ interface PaymentDisputeLostPayload {
251
288
  * reconciliation in country-aware ledger bridges.
252
289
  */
253
290
  interface PaymentSettledPayload {
291
+ /** Discriminant — narrows {@link PaymentEventPayload}. */
292
+ readonly eventType: typeof PAYMENT_EVENT_TYPE.SETTLED;
254
293
  paymentId: string;
255
294
  providerRef?: string;
256
295
  providerCode: string;
@@ -265,7 +304,95 @@ interface PaymentSettledPayload {
265
304
  occurredAt: Date;
266
305
  metadata?: Record<string, unknown>;
267
306
  }
268
- /** Union of all payment event payload types — convenient for exhaustive switches. */
269
- type PaymentEventPayload = PaymentInitiatedPayload | PaymentSucceededPayload | PaymentFailedPayload | PaymentRefundedPayload | PaymentReversedPayload | PaymentReconciledPayload | PaymentAuthorizedPayload | PaymentCapturedPayload | PaymentAuthVoidedPayload | PaymentDisputedPayload | PaymentDisputeWonPayload | PaymentDisputeLostPayload | PaymentSettledPayload;
307
+ /**
308
+ * The provider command produced no observable outcome. **This is not a
309
+ * failure**, and it is not a success — it is the absence of an answer.
310
+ *
311
+ * The obligation's state is genuinely unknown: the capture may have landed at
312
+ * the gateway with the acknowledgement lost in transit. The ONLY correct
313
+ * reaction is to reconcile the payment's real status before acting. A blind
314
+ * retry is a double charge; marking it failed and releasing the order is a
315
+ * shipped-but-unpaid order. Both are silent.
316
+ *
317
+ * Consumers MUST NOT treat this as a terminal state. It is a prompt to go and
318
+ * find out, and it resolves later into `payment.succeeded` or `payment.failed`.
319
+ */
320
+ interface PaymentUnknownPayload {
321
+ /** Discriminant — narrows {@link PaymentEventPayload}. */
322
+ readonly eventType: typeof PAYMENT_EVENT_TYPE.UNKNOWN;
323
+ paymentId: string;
324
+ providerRef?: string;
325
+ providerCode: string;
326
+ /** Which command went unanswered — the retry policy differs per verb. */
327
+ operation: 'create_intent' | 'verify' | 'authorize' | 'capture' | 'refund' | 'void';
328
+ /** The amount at stake, when the command carried one. */
329
+ amount?: Money;
330
+ methodKind?: PaymentMethodKind;
331
+ methodCode?: string;
332
+ /**
333
+ * NORMALIZED reason the outcome was not observed — mirrors
334
+ * `ProviderUnknownCause` from `/payment-gateway`. Never a raw vendor error
335
+ * string: this value is persisted and displayed, and vendor errors embed
336
+ * request URLs, tokens and body fragments.
337
+ */
338
+ causeCode: ProviderUnknownCause;
339
+ /** The idempotency key the command carried — what reconciliation asks with. */
340
+ idempotencyKey?: string;
341
+ occurredAt: Date;
342
+ metadata?: Record<string, unknown>;
343
+ }
344
+ /**
345
+ * Union of all payment event payload types.
346
+ *
347
+ * DISCRIMINATED on `eventType`. Before the discriminant existed,
348
+ * `PaymentInitiatedPayload` and `PaymentSucceededPayload` were structurally
349
+ * IDENTICAL — so a handler typed for "funds received" accepted "money in
350
+ * flight" with no error anywhere, and TypeScript could not tell them apart even
351
+ * in principle. Structural identity between two events that mean opposite
352
+ * things is precisely where a nominal tag has to carry the meaning.
353
+ */
354
+ type PaymentEventPayload = PaymentInitiatedPayload | PaymentSucceededPayload | PaymentFailedPayload | PaymentRefundedPayload | PaymentReversedPayload | PaymentReconciledPayload | PaymentAuthorizedPayload | PaymentCapturedPayload | PaymentAuthVoidedPayload | PaymentDisputedPayload | PaymentDisputeWonPayload | PaymentDisputeLostPayload | PaymentSettledPayload | PaymentUnknownPayload;
355
+ /** Event type → its payload. The lookup a typed publisher/handler pair needs. */
356
+ interface PaymentEventPayloadMap {
357
+ 'payment.initiated': PaymentInitiatedPayload;
358
+ 'payment.succeeded': PaymentSucceededPayload;
359
+ 'payment.failed': PaymentFailedPayload;
360
+ 'payment.refunded': PaymentRefundedPayload;
361
+ 'payment.reversed': PaymentReversedPayload;
362
+ 'payment.reconciled': PaymentReconciledPayload;
363
+ 'payment.authorized': PaymentAuthorizedPayload;
364
+ 'payment.captured': PaymentCapturedPayload;
365
+ 'payment.auth_voided': PaymentAuthVoidedPayload;
366
+ 'payment.disputed': PaymentDisputedPayload;
367
+ 'payment.dispute_won': PaymentDisputeWonPayload;
368
+ 'payment.dispute_lost': PaymentDisputeLostPayload;
369
+ 'payment.settled': PaymentSettledPayload;
370
+ 'payment.unknown': PaymentUnknownPayload;
371
+ }
372
+ type Assert<T extends true> = T;
373
+ /** Every declared event type has a payload in the map. */
374
+ type _EveryTypeMapped = Assert<PaymentEventType extends keyof PaymentEventPayloadMap ? true : false>;
375
+ /** The map declares no key that is not a real event type. */
376
+ type _NoStrayMapKeys = Assert<keyof PaymentEventPayloadMap extends PaymentEventType ? true : false>;
377
+ /** Each payload's own `eventType` literal matches the key it is filed under. */
378
+ type _DiscriminantMatchesKey = Assert<{ [K in keyof PaymentEventPayloadMap]: PaymentEventPayloadMap[K] extends {
379
+ eventType: K;
380
+ } ? true : false; }[keyof PaymentEventPayloadMap] extends true ? true : false>;
381
+ type PaymentEventContractGuards = [_EveryTypeMapped, _NoStrayMapKeys, _DiscriminantMatchesKey];
382
+ /**
383
+ * Narrow a payload by event type at RUNTIME, with the compile-time narrowing
384
+ * to match. `if (isPaymentEvent(PAYMENT_EVENT_TYPE.SUCCEEDED, p)) { … }` —
385
+ * inside the branch `p` is `PaymentSucceededPayload` and cannot be an
386
+ * initiated payload, which is the whole point of the discriminant.
387
+ */
388
+ declare function isPaymentEvent<T extends PaymentEventType>(type: T, payload: PaymentEventPayload): payload is PaymentEventPayloadMap[T];
389
+ /**
390
+ * Is this payload a CLEARED-FUNDS event?
391
+ *
392
+ * The named answer to the question that used to be answerable only by
393
+ * structural shape — and answered wrong, because `initiated` had the same
394
+ * shape. `captured` counts; `initiated`, `authorized` and `unknown` do not.
395
+ */
396
+ declare function isFundsReceived(payload: PaymentEventPayload): boolean;
270
397
  //#endregion
271
- export { PAYMENT_EVENT_TYPE, PaymentAuthVoidedPayload, PaymentAuthorizedPayload, PaymentCapturedPayload, PaymentDisputeLostPayload, PaymentDisputeWonPayload, PaymentDisputedPayload, PaymentEventPayload, PaymentEventType, PaymentFailedPayload, PaymentInitiatedPayload, PaymentReconciledPayload, PaymentRefundedPayload, PaymentReversedPayload, PaymentSettledPayload, PaymentSucceededPayload };
398
+ export { PAYMENT_EVENT_TYPE, PaymentAuthVoidedPayload, PaymentAuthorizedPayload, PaymentCapturedPayload, PaymentDisputeLostPayload, PaymentDisputeWonPayload, PaymentDisputedPayload, PaymentEventContractGuards, PaymentEventPayload, PaymentEventPayloadMap, PaymentEventType, PaymentFailedPayload, PaymentInitiatedPayload, PaymentReconciledPayload, PaymentRefundedPayload, PaymentReversedPayload, PaymentSettledPayload, PaymentSucceededPayload, PaymentUnknownPayload, isFundsReceived, isPaymentEvent };
@@ -17,7 +17,39 @@ const PAYMENT_EVENT_TYPE = {
17
17
  DISPUTED: "payment.disputed",
18
18
  DISPUTE_WON: "payment.dispute_won",
19
19
  DISPUTE_LOST: "payment.dispute_lost",
20
- SETTLED: "payment.settled"
20
+ SETTLED: "payment.settled",
21
+ /**
22
+ * The provider command produced NO observable outcome — timeout, abort,
23
+ * network fault, or a provider that holds no record and refuses to guess.
24
+ *
25
+ * This member exists because the port contract (`ProviderCommandResult` in
26
+ * `/payment-gateway`) is three-valued — `confirmed | declined | unknown` —
27
+ * while this catalogue was two-valued. An adapter that hit the third case had
28
+ * nowhere to publish it, so the only shapes on the bus were SUCCEEDED and
29
+ * FAILED, and an unobserved outcome had to be forced into one of them.
30
+ * Forcing it into FAILED is the one that double-charges: it licenses a retry
31
+ * for a command that may already have moved money.
32
+ */
33
+ UNKNOWN: "payment.unknown"
21
34
  };
35
+ /**
36
+ * Narrow a payload by event type at RUNTIME, with the compile-time narrowing
37
+ * to match. `if (isPaymentEvent(PAYMENT_EVENT_TYPE.SUCCEEDED, p)) { … }` —
38
+ * inside the branch `p` is `PaymentSucceededPayload` and cannot be an
39
+ * initiated payload, which is the whole point of the discriminant.
40
+ */
41
+ function isPaymentEvent(type, payload) {
42
+ return payload.eventType === type;
43
+ }
44
+ /**
45
+ * Is this payload a CLEARED-FUNDS event?
46
+ *
47
+ * The named answer to the question that used to be answerable only by
48
+ * structural shape — and answered wrong, because `initiated` had the same
49
+ * shape. `captured` counts; `initiated`, `authorized` and `unknown` do not.
50
+ */
51
+ function isFundsReceived(payload) {
52
+ return payload.eventType === PAYMENT_EVENT_TYPE.SUCCEEDED || payload.eventType === PAYMENT_EVENT_TYPE.CAPTURED;
53
+ }
22
54
  //#endregion
23
- export { PAYMENT_EVENT_TYPE };
55
+ export { PAYMENT_EVENT_TYPE, isFundsReceived, isPaymentEvent };