@classytic/primitives 0.18.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/CHANGELOG.md +906 -715
  2. package/README.md +160 -159
  3. package/dist/approval.d.mts +49 -10
  4. package/dist/approval.mjs +44 -10
  5. package/dist/bank-transaction.d.mts +1 -1
  6. package/dist/cadence-BFQVC1Qp.d.mts +107 -0
  7. package/dist/cadence.d.mts +1 -92
  8. package/dist/cadence.mjs +133 -4
  9. package/dist/calendar-B6fm-FbR.d.mts +46 -0
  10. package/dist/calendar.d.mts +1 -45
  11. package/dist/canonical.mjs +17 -0
  12. package/dist/context.d.mts +28 -1
  13. package/dist/currency.d.mts +2 -2
  14. package/dist/currency.mjs +134 -19
  15. package/dist/event-infra.d.mts +1 -34
  16. package/dist/event-infra.mjs +16 -46
  17. package/dist/idempotency.d.mts +253 -0
  18. package/dist/idempotency.mjs +226 -0
  19. package/dist/memory-outbox.d.mts +64 -0
  20. package/dist/memory-outbox.mjs +172 -0
  21. package/dist/monetization.d.mts +195 -0
  22. package/dist/monetization.mjs +79 -0
  23. package/dist/money-DPo9fLBi.d.mts +347 -0
  24. package/dist/money.d.mts +1 -140
  25. package/dist/money.mjs +6 -15
  26. package/dist/outbox.d.mts +49 -1
  27. package/dist/outbox.mjs +64 -1
  28. package/dist/payment-events.d.mts +132 -4
  29. package/dist/payment-events.mjs +22 -2
  30. package/dist/payment-gateway.d.mts +210 -7
  31. package/dist/payment-gateway.mjs +18 -1
  32. package/dist/period.d.mts +138 -7
  33. package/dist/period.mjs +316 -5
  34. package/dist/person-parse.d.mts +82 -0
  35. package/dist/person-parse.mjs +109 -0
  36. package/dist/proration.d.mts +76 -0
  37. package/dist/proration.mjs +132 -0
  38. package/dist/sla-policy.d.mts +1 -1
  39. package/dist/state-machine.mjs +13 -0
  40. package/dist/suspension.d.mts +1 -1
  41. package/dist/timezone-tlG7ygwx.d.mts +114 -0
  42. package/dist/timezone.d.mts +2 -100
  43. package/dist/timezone.mjs +38 -3
  44. package/dist/unit-cost-rate.d.mts +1 -2
  45. package/package.json +244 -224
  46. package/dist/currency-BCJnJBlb.d.mts +0 -119
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 };
@@ -1,5 +1,6 @@
1
- import { Money } from "./money.mjs";
1
+ import { n as Money } from "./money-DPo9fLBi.mjs";
2
2
  import { PaymentMethodKind } from "./payment-method-kind.mjs";
3
+ import { ProviderUnknownCause } from "./payment-gateway.mjs";
3
4
 
4
5
  //#region src/events/payment-events.d.ts
5
6
  /**
@@ -21,6 +22,19 @@ declare const PAYMENT_EVENT_TYPE: {
21
22
  readonly DISPUTE_WON: "payment.dispute_won";
22
23
  readonly DISPUTE_LOST: "payment.dispute_lost";
23
24
  readonly SETTLED: "payment.settled";
25
+ /**
26
+ * The provider command produced NO observable outcome — timeout, abort,
27
+ * network fault, or a provider that holds no record and refuses to guess.
28
+ *
29
+ * This member exists because the port contract (`ProviderCommandResult` in
30
+ * `/payment-gateway`) is three-valued — `confirmed | declined | unknown` —
31
+ * while this catalogue was two-valued. An adapter that hit the third case had
32
+ * nowhere to publish it, so the only shapes on the bus were SUCCEEDED and
33
+ * FAILED, and an unobserved outcome had to be forced into one of them.
34
+ * Forcing it into FAILED is the one that double-charges: it licenses a retry
35
+ * for a command that may already have moved money.
36
+ */
37
+ readonly UNKNOWN: "payment.unknown";
24
38
  };
25
39
  type PaymentEventType = (typeof PAYMENT_EVENT_TYPE)[keyof typeof PAYMENT_EVENT_TYPE];
26
40
  /**
@@ -30,6 +44,8 @@ type PaymentEventType = (typeof PAYMENT_EVENT_TYPE)[keyof typeof PAYMENT_EVENT_T
30
44
  * "money in flight" not "money received".
31
45
  */
32
46
  interface PaymentInitiatedPayload {
47
+ /** Discriminant — narrows {@link PaymentEventPayload}. */
48
+ readonly eventType: typeof PAYMENT_EVENT_TYPE.INITIATED;
33
49
  /** Stable payment identifier owned by the emitting package. */
34
50
  paymentId: string;
35
51
  /**
@@ -57,6 +73,8 @@ interface PaymentInitiatedPayload {
57
73
  * downstream fulfilment unlocks.
58
74
  */
59
75
  interface PaymentSucceededPayload {
76
+ /** Discriminant — narrows {@link PaymentEventPayload}. */
77
+ readonly eventType: typeof PAYMENT_EVENT_TYPE.SUCCEEDED;
60
78
  paymentId: string;
61
79
  providerRef?: string;
62
80
  providerCode: string;
@@ -73,6 +91,8 @@ interface PaymentSucceededPayload {
73
91
  * overdue.
74
92
  */
75
93
  interface PaymentFailedPayload {
94
+ /** Discriminant — narrows {@link PaymentEventPayload}. */
95
+ readonly eventType: typeof PAYMENT_EVENT_TYPE.FAILED;
76
96
  paymentId: string;
77
97
  providerRef?: string;
78
98
  providerCode: string;
@@ -93,6 +113,8 @@ interface PaymentFailedPayload {
93
113
  * itself (pre- or post-clearing, often due to operator error).
94
114
  */
95
115
  interface PaymentRefundedPayload {
116
+ /** Discriminant — narrows {@link PaymentEventPayload}. */
117
+ readonly eventType: typeof PAYMENT_EVENT_TYPE.REFUNDED;
96
118
  paymentId: string;
97
119
  /** Refund identifier (separate from `paymentId`). */
98
120
  refundId: string;
@@ -116,6 +138,8 @@ interface PaymentRefundedPayload {
116
138
  * state.
117
139
  */
118
140
  interface PaymentReversedPayload {
141
+ /** Discriminant — narrows {@link PaymentEventPayload}. */
142
+ readonly eventType: typeof PAYMENT_EVENT_TYPE.REVERSED;
119
143
  paymentId: string;
120
144
  providerRef?: string;
121
145
  providerCode: string;
@@ -136,6 +160,8 @@ interface PaymentReversedPayload {
136
160
  * ledger bridges and closes the audit loop.
137
161
  */
138
162
  interface PaymentReconciledPayload {
163
+ /** Discriminant — narrows {@link PaymentEventPayload}. */
164
+ readonly eventType: typeof PAYMENT_EVENT_TYPE.RECONCILED;
139
165
  /** Allocation identifier — NOT the payment id (a payment can have many). */
140
166
  allocationId: string;
141
167
  paymentId: string;
@@ -156,6 +182,8 @@ interface PaymentReconciledPayload {
156
182
  * "hold" from "settle"; consumers should NOT treat this as cleared funds.
157
183
  */
158
184
  interface PaymentAuthorizedPayload {
185
+ /** Discriminant — narrows {@link PaymentEventPayload}. */
186
+ readonly eventType: typeof PAYMENT_EVENT_TYPE.AUTHORIZED;
159
187
  paymentId: string;
160
188
  providerRef?: string;
161
189
  providerCode: string;
@@ -173,6 +201,8 @@ interface PaymentAuthorizedPayload {
173
201
  * Pairs with {@link PaymentAuthorizedPayload} for two-phase flows.
174
202
  */
175
203
  interface PaymentCapturedPayload {
204
+ /** Discriminant — narrows {@link PaymentEventPayload}. */
205
+ readonly eventType: typeof PAYMENT_EVENT_TYPE.CAPTURED;
176
206
  paymentId: string;
177
207
  providerRef?: string;
178
208
  providerCode: string;
@@ -191,6 +221,8 @@ interface PaymentCapturedPayload {
191
221
  * Authorisation hold released — uncaptured auth voided before settlement.
192
222
  */
193
223
  interface PaymentAuthVoidedPayload {
224
+ /** Discriminant — narrows {@link PaymentEventPayload}. */
225
+ readonly eventType: typeof PAYMENT_EVENT_TYPE.AUTH_VOIDED;
194
226
  paymentId: string;
195
227
  providerRef?: string;
196
228
  providerCode: string;
@@ -205,6 +237,8 @@ interface PaymentAuthVoidedPayload {
205
237
  * payment. The merchant typically has a deadline to submit evidence.
206
238
  */
207
239
  interface PaymentDisputedPayload {
240
+ /** Discriminant — narrows {@link PaymentEventPayload}. */
241
+ readonly eventType: typeof PAYMENT_EVENT_TYPE.DISPUTED;
208
242
  paymentId: string;
209
243
  /** Provider's dispute / chargeback id. */
210
244
  disputeId: string;
@@ -223,6 +257,8 @@ interface PaymentDisputedPayload {
223
257
  }
224
258
  /** Dispute resolved in merchant's favour — funds restored. */
225
259
  interface PaymentDisputeWonPayload {
260
+ /** Discriminant — narrows {@link PaymentEventPayload}. */
261
+ readonly eventType: typeof PAYMENT_EVENT_TYPE.DISPUTE_WON;
226
262
  paymentId: string;
227
263
  disputeId: string;
228
264
  providerRef?: string;
@@ -234,6 +270,8 @@ interface PaymentDisputeWonPayload {
234
270
  }
235
271
  /** Dispute resolved against merchant — funds permanently lost plus fee. */
236
272
  interface PaymentDisputeLostPayload {
273
+ /** Discriminant — narrows {@link PaymentEventPayload}. */
274
+ readonly eventType: typeof PAYMENT_EVENT_TYPE.DISPUTE_LOST;
237
275
  paymentId: string;
238
276
  disputeId: string;
239
277
  providerRef?: string;
@@ -251,6 +289,8 @@ interface PaymentDisputeLostPayload {
251
289
  * reconciliation in country-aware ledger bridges.
252
290
  */
253
291
  interface PaymentSettledPayload {
292
+ /** Discriminant — narrows {@link PaymentEventPayload}. */
293
+ readonly eventType: typeof PAYMENT_EVENT_TYPE.SETTLED;
254
294
  paymentId: string;
255
295
  providerRef?: string;
256
296
  providerCode: string;
@@ -265,7 +305,95 @@ interface PaymentSettledPayload {
265
305
  occurredAt: Date;
266
306
  metadata?: Record<string, unknown>;
267
307
  }
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;
308
+ /**
309
+ * The provider command produced no observable outcome. **This is not a
310
+ * failure**, and it is not a success — it is the absence of an answer.
311
+ *
312
+ * The obligation's state is genuinely unknown: the capture may have landed at
313
+ * the gateway with the acknowledgement lost in transit. The ONLY correct
314
+ * reaction is to reconcile the payment's real status before acting. A blind
315
+ * retry is a double charge; marking it failed and releasing the order is a
316
+ * shipped-but-unpaid order. Both are silent.
317
+ *
318
+ * Consumers MUST NOT treat this as a terminal state. It is a prompt to go and
319
+ * find out, and it resolves later into `payment.succeeded` or `payment.failed`.
320
+ */
321
+ interface PaymentUnknownPayload {
322
+ /** Discriminant — narrows {@link PaymentEventPayload}. */
323
+ readonly eventType: typeof PAYMENT_EVENT_TYPE.UNKNOWN;
324
+ paymentId: string;
325
+ providerRef?: string;
326
+ providerCode: string;
327
+ /** Which command went unanswered — the retry policy differs per verb. */
328
+ operation: 'create_intent' | 'verify' | 'authorize' | 'capture' | 'refund' | 'void';
329
+ /** The amount at stake, when the command carried one. */
330
+ amount?: Money;
331
+ methodKind?: PaymentMethodKind;
332
+ methodCode?: string;
333
+ /**
334
+ * NORMALIZED reason the outcome was not observed — mirrors
335
+ * `ProviderUnknownCause` from `/payment-gateway`. Never a raw vendor error
336
+ * string: this value is persisted and displayed, and vendor errors embed
337
+ * request URLs, tokens and body fragments.
338
+ */
339
+ causeCode: ProviderUnknownCause;
340
+ /** The idempotency key the command carried — what reconciliation asks with. */
341
+ idempotencyKey?: string;
342
+ occurredAt: Date;
343
+ metadata?: Record<string, unknown>;
344
+ }
345
+ /**
346
+ * Union of all payment event payload types.
347
+ *
348
+ * DISCRIMINATED on `eventType`. Before the discriminant existed,
349
+ * `PaymentInitiatedPayload` and `PaymentSucceededPayload` were structurally
350
+ * IDENTICAL — so a handler typed for "funds received" accepted "money in
351
+ * flight" with no error anywhere, and TypeScript could not tell them apart even
352
+ * in principle. Structural identity between two events that mean opposite
353
+ * things is precisely where a nominal tag has to carry the meaning.
354
+ */
355
+ type PaymentEventPayload = PaymentInitiatedPayload | PaymentSucceededPayload | PaymentFailedPayload | PaymentRefundedPayload | PaymentReversedPayload | PaymentReconciledPayload | PaymentAuthorizedPayload | PaymentCapturedPayload | PaymentAuthVoidedPayload | PaymentDisputedPayload | PaymentDisputeWonPayload | PaymentDisputeLostPayload | PaymentSettledPayload | PaymentUnknownPayload;
356
+ /** Event type → its payload. The lookup a typed publisher/handler pair needs. */
357
+ interface PaymentEventPayloadMap {
358
+ 'payment.initiated': PaymentInitiatedPayload;
359
+ 'payment.succeeded': PaymentSucceededPayload;
360
+ 'payment.failed': PaymentFailedPayload;
361
+ 'payment.refunded': PaymentRefundedPayload;
362
+ 'payment.reversed': PaymentReversedPayload;
363
+ 'payment.reconciled': PaymentReconciledPayload;
364
+ 'payment.authorized': PaymentAuthorizedPayload;
365
+ 'payment.captured': PaymentCapturedPayload;
366
+ 'payment.auth_voided': PaymentAuthVoidedPayload;
367
+ 'payment.disputed': PaymentDisputedPayload;
368
+ 'payment.dispute_won': PaymentDisputeWonPayload;
369
+ 'payment.dispute_lost': PaymentDisputeLostPayload;
370
+ 'payment.settled': PaymentSettledPayload;
371
+ 'payment.unknown': PaymentUnknownPayload;
372
+ }
373
+ type Assert<T extends true> = T;
374
+ /** Every declared event type has a payload in the map. */
375
+ type _EveryTypeMapped = Assert<PaymentEventType extends keyof PaymentEventPayloadMap ? true : false>;
376
+ /** The map declares no key that is not a real event type. */
377
+ type _NoStrayMapKeys = Assert<keyof PaymentEventPayloadMap extends PaymentEventType ? true : false>;
378
+ /** Each payload's own `eventType` literal matches the key it is filed under. */
379
+ type _DiscriminantMatchesKey = Assert<{ [K in keyof PaymentEventPayloadMap]: PaymentEventPayloadMap[K] extends {
380
+ eventType: K;
381
+ } ? true : false }[keyof PaymentEventPayloadMap] extends true ? true : false>;
382
+ type PaymentEventContractGuards = [_EveryTypeMapped, _NoStrayMapKeys, _DiscriminantMatchesKey];
383
+ /**
384
+ * Narrow a payload by event type at RUNTIME, with the compile-time narrowing
385
+ * to match. `if (isPaymentEvent(PAYMENT_EVENT_TYPE.SUCCEEDED, p)) { … }` —
386
+ * inside the branch `p` is `PaymentSucceededPayload` and cannot be an
387
+ * initiated payload, which is the whole point of the discriminant.
388
+ */
389
+ declare function isPaymentEvent<T extends PaymentEventType>(type: T, payload: PaymentEventPayload): payload is PaymentEventPayloadMap[T];
390
+ /**
391
+ * Is this payload a CLEARED-FUNDS event?
392
+ *
393
+ * The named answer to the question that used to be answerable only by
394
+ * structural shape — and answered wrong, because `initiated` had the same
395
+ * shape. `captured` counts; `initiated`, `authorized` and `unknown` do not.
396
+ */
397
+ declare function isFundsReceived(payload: PaymentEventPayload): boolean;
270
398
  //#endregion
271
- export { PAYMENT_EVENT_TYPE, PaymentAuthVoidedPayload, PaymentAuthorizedPayload, PaymentCapturedPayload, PaymentDisputeLostPayload, PaymentDisputeWonPayload, PaymentDisputedPayload, PaymentEventPayload, PaymentEventType, PaymentFailedPayload, PaymentInitiatedPayload, PaymentReconciledPayload, PaymentRefundedPayload, PaymentReversedPayload, PaymentSettledPayload, PaymentSucceededPayload };
399
+ 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,27 @@ 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
+ UNKNOWN: "payment.unknown"
21
22
  };
23
+ /**
24
+ * Narrow a payload by event type at RUNTIME, with the compile-time narrowing
25
+ * to match. `if (isPaymentEvent(PAYMENT_EVENT_TYPE.SUCCEEDED, p)) { … }` —
26
+ * inside the branch `p` is `PaymentSucceededPayload` and cannot be an
27
+ * initiated payload, which is the whole point of the discriminant.
28
+ */
29
+ function isPaymentEvent(type, payload) {
30
+ return payload.eventType === type;
31
+ }
32
+ /**
33
+ * Is this payload a CLEARED-FUNDS event?
34
+ *
35
+ * The named answer to the question that used to be answerable only by
36
+ * structural shape — and answered wrong, because `initiated` had the same
37
+ * shape. `captured` counts; `initiated`, `authorized` and `unknown` do not.
38
+ */
39
+ function isFundsReceived(payload) {
40
+ return payload.eventType === PAYMENT_EVENT_TYPE.SUCCEEDED || payload.eventType === PAYMENT_EVENT_TYPE.CAPTURED;
41
+ }
22
42
  //#endregion
23
- export { PAYMENT_EVENT_TYPE };
43
+ export { PAYMENT_EVENT_TYPE, isFundsReceived, isPaymentEvent };
@@ -1,4 +1,4 @@
1
- import { Money } from "./money.mjs";
1
+ import { n as Money } from "./money-DPo9fLBi.mjs";
2
2
  import { PaymentMethodKind } from "./payment-method-kind.mjs";
3
3
 
4
4
  //#region src/money/payment-gateway.d.ts
@@ -25,7 +25,7 @@ interface ProviderCapabilities {
25
25
  * Arguments to `provider.createIntent(...)`. Engine constructs this
26
26
  * from the host's checkout payload and the resolved currency. The
27
27
  * `metadata` blob is opaque pass-through — providers MAY forward it to
28
- * the upstream gateway (Stripe attaches it to the PaymentIntent), but
28
+ * the upstream gateway (Stripe attaches it to its own PaymentIntent), but
29
29
  * MUST NOT mutate or interpret it.
30
30
  */
31
31
  interface CreateIntentParams {
@@ -49,7 +49,7 @@ interface CreateIntentParams {
49
49
  * Stripe's PaymentIntent status because every other gateway maps cleanly
50
50
  * INTO this set; gateways that lack a state simply never emit it.
51
51
  */
52
- type PaymentIntentStatus = 'requires_payment_method' | 'requires_confirmation' | 'requires_action' | 'processing' | 'requires_capture' | 'canceled' | 'succeeded';
52
+ type ProviderIntentStatus = 'requires_payment_method' | 'requires_confirmation' | 'requires_action' | 'processing' | 'requires_capture' | 'canceled' | 'succeeded';
53
53
  /** Closed set of refund lifecycle states the engine recognises. */
54
54
  type RefundResultStatus = 'pending' | 'requires_action' | 'succeeded' | 'failed' | 'canceled';
55
55
  /**
@@ -58,7 +58,17 @@ type RefundResultStatus = 'pending' | 'requires_action' | 'succeeded' | 'failed'
58
58
  * `gateway` block so subsequent `verify` / webhook lookups can find
59
59
  * the row.
60
60
  */
61
- interface PaymentIntent {
61
+ /**
62
+ * A PROVIDER-side intent reference — what the gateway calls this payment.
63
+ *
64
+ * Named `ProviderIntent`, not `PaymentIntent`, because the domain aggregate that models the
65
+ * customer's obligation is also called a payment intent, and one name cannot span both layers.
66
+ * This is the narrow thing: a gateway-scoped id, the provider's own status mapped onto our closed
67
+ * union, and the amount it locked in. It belongs to ONE attempt against ONE provider account.
68
+ *
69
+ * See `spine/docs/payments-architecture.md` §3.3.
70
+ */
71
+ interface ProviderIntent {
62
72
  /** Unique gateway-scoped id for this intent (`pi_xxx`, `ch_xxx`, etc.). */
63
73
  id: string;
64
74
  /** Lowercase gateway name (`'stripe'`, `'razorpay'`, `'sslcommerz'`, `'bkash'`, `'manual'`). */
@@ -67,7 +77,7 @@ interface PaymentIntent {
67
77
  * Lifecycle status — closed union the engine state-machines branch on.
68
78
  * Adapters MUST map provider-native strings into this set.
69
79
  */
70
- status: PaymentIntentStatus;
80
+ status: ProviderIntentStatus;
71
81
  /** Amount + currency that the intent locks in. */
72
82
  amount: Money;
73
83
  /**
@@ -104,7 +114,7 @@ interface PaymentResult {
104
114
  /** Lowercase gateway name. */
105
115
  provider: string;
106
116
  /**
107
- * Closed status set — narrower than `PaymentIntent.status` because
117
+ * Closed status set — narrower than `ProviderIntent.status` because
108
118
  * verification has settled the lifecycle.
109
119
  *
110
120
  * - `'succeeded'` — payment captured, funds owed to merchant.
@@ -185,5 +195,198 @@ interface WebhookEvent {
185
195
  /** Raw provider payload — audit only. */
186
196
  raw?: unknown;
187
197
  }
198
+ /**
199
+ * The contract a payment provider must satisfy — declared HERE, in primitives, so an
200
+ * adapter can be written against it with **no runtime dependency on the engine**.
201
+ *
202
+ * ## Why this moved
203
+ *
204
+ * `@classytic/revenue-manual` imported the abstract `PaymentProvider` CLASS from
205
+ * `@classytic/revenue` — a runtime value, not a type. That coupled every adapter to the
206
+ * engine package: an adapter could not be versioned or published independently, and the
207
+ * dependency ran adapter → engine while the engine also registers adapters.
208
+ *
209
+ * This is the same correction made for `OutboxStore`: primitives owns the CONTRACT, the
210
+ * engine adapts to it, and a kit or a third party can implement it knowing only this file.
211
+ *
212
+ * ## Extending vs implementing
213
+ *
214
+ * `revenue`'s abstract `PaymentProvider` class now *implements* this interface and stays
215
+ * available for the config plumbing it provides (`config`, `defaultCurrency`). Existing
216
+ * adapters that `extends PaymentProvider` are unaffected. New adapters should `implement
217
+ * PaymentProviderPort` directly and depend only on primitives.
218
+ *
219
+ * See `spine/docs/payments-architecture.md` §3.4.1 and phase 1.
220
+ */
221
+ /**
222
+ * How reconciliation asks a provider about a specific REFUND. The refund is identified
223
+ * by the original `paymentId` plus the refund's `idempotencyKey` (the value the provider
224
+ * deduped on); `refundRef` is the gateway refund id when the engine observed one.
225
+ */
226
+ interface RefundStatusQuery {
227
+ paymentId: string;
228
+ idempotencyKey: string;
229
+ refundRef?: string;
230
+ }
231
+ interface PaymentProviderPort {
232
+ /** Stable lowercase provider name — the registry key. */
233
+ readonly name: string;
234
+ /**
235
+ * Create a provider-side intent for an amount.
236
+ *
237
+ * `command` is REQUIRED, not optional. Creating an intent is side-effecting upstream: a
238
+ * timed-out create can leave a live intent at the gateway with no local record, and a
239
+ * retry without a stable idempotency key produces a SECOND one. Making the envelope
240
+ * mandatory is what stops an adapter quietly omitting it.
241
+ */
242
+ createIntent(params: CreateIntentParams, command: PaymentCommandContext): Promise<ProviderIntent>;
243
+ /**
244
+ * Confirm an intent actually succeeded at the provider.
245
+ *
246
+ * A provider that CANNOT know — a stateless manual provider, for instance — must not
247
+ * answer `succeeded`. Reporting success for something it never observed is how a
248
+ * reconciliation path gets a false positive.
249
+ */
250
+ verifyPayment(intentId: string, command?: PaymentCommandContext): Promise<PaymentResult>;
251
+ /**
252
+ * Current provider-side status. Same honesty requirement as `verifyPayment`.
253
+ *
254
+ * `command` is OPTIONAL here and on `verifyPayment`: both are reads, so there is nothing
255
+ * to make idempotent. It is accepted purely for correlation across our logs and the
256
+ * provider's.
257
+ */
258
+ getStatus(intentId: string, command?: PaymentCommandContext): Promise<PaymentResult>;
259
+ /**
260
+ * Reverse a payment, in full or in part.
261
+ *
262
+ * `command` is REQUIRED and carries the idempotency key. It used to be an optional field
263
+ * inside `options`, which made the single most dangerous operation on this port the one
264
+ * where deduplication was easiest to forget. Providers whose gateway supports an
265
+ * idempotency key MUST forward it; those without may ignore it, and the engine still
266
+ * guards with its own claim and a unique index.
267
+ */
268
+ refund(paymentId: string, amount: number | null | undefined, command: PaymentCommandContext, options?: {
269
+ reason?: string;
270
+ }): Promise<RefundResult>;
271
+ /**
272
+ * Current status of a REFUND (not the payment). OPTIONAL and feature-detected: only
273
+ * a provider that can look a refund up implements it. Reconciliation MUST use this,
274
+ * never `getStatus` (which reports the PAYMENT) — asking the payment about a refund
275
+ * misclassifies a successful charge as a successful reversal. The query carries the
276
+ * original `paymentId` and the refund's `idempotencyKey` (and `refundRef` when we
277
+ * observed one) so the provider can find the specific reversal it deduped. A provider
278
+ * without this cannot be auto-reconciled — the engine then RETAINS the reservation.
279
+ */
280
+ getRefundStatus?(query: RefundStatusQuery, command?: PaymentCommandContext): Promise<PaymentResult>;
281
+ handleWebhook(payload: unknown, headers?: Record<string, string>): Promise<WebhookEvent>;
282
+ /**
283
+ * Reject a forged webhook. Returning `true` unconditionally is only acceptable for a
284
+ * provider with no webhook transport at all (manual/dev).
285
+ */
286
+ verifyWebhookSignature(payload: unknown, signature: string): boolean;
287
+ /** Declared capabilities. Callers gate on these rather than probing by failing. */
288
+ getCapabilities(): ProviderCapabilities;
289
+ }
290
+ /**
291
+ * `setDefaultCurrency` is deliberately NOT on the port.
292
+ *
293
+ * A default currency is ENGINE configuration, not part of the provider execution contract,
294
+ * and every `Money` already carries its own currency. Requiring the mutator would make each
295
+ * adapter stateful and awkward to share across accounts — the same instance cannot then
296
+ * serve two accounts with different defaults.
297
+ *
298
+ * Adapters that want the convenience expose it and the registry feature-detects it; the
299
+ * rest simply read the currency off the amount they were handed.
300
+ */
301
+ interface DefaultCurrencyAware {
302
+ setDefaultCurrency(currency: string): void;
303
+ }
304
+ /**
305
+ * The envelope every money-changing provider command carries.
306
+ *
307
+ * Idempotency used to be supplied on `refund` alone. It belongs on every command: a
308
+ * retried authorize or capture double-charges just as readily as a retried refund, and the
309
+ * retry is usually the client's, not ours.
310
+ *
311
+ * Adapters whose gateway supports an idempotency key MUST forward it. The engine still keeps
312
+ * its own atomic claim/result store, because not every provider offers one and a provider's
313
+ * word is not a guarantee we control.
314
+ */
315
+ interface PaymentCommandContext {
316
+ /**
317
+ * Caller-supplied, stable across retries of the SAME logical operation. Two different
318
+ * operations must never share one; one operation retried must never change it.
319
+ */
320
+ idempotencyKey: string;
321
+ /** Correlates this attempt across our logs, the provider's, and the audit trail. */
322
+ requestId: string;
323
+ /** Our reference for the thing being paid for — order number, invoice number. */
324
+ merchantReference: string;
325
+ /**
326
+ * Branch / tenant scope. OPTIONAL: a revenue engine can run unscoped
327
+ * (single-tenant / company-global), where there is no branch id to carry —
328
+ * so this is absent rather than an empty or asserted string. Scoped engines
329
+ * always set it; consumers that require scope must check, not cast.
330
+ */
331
+ organizationId?: string;
332
+ /** Cancels an in-flight command (a cashier abandoning a terminal prompt, a shutdown). */
333
+ signal?: AbortSignal;
334
+ }
335
+ /** Why a provider said no. A DECISION, distinct from an unanswered question. */
336
+ interface PaymentDecline {
337
+ /** Normalized reason. Adapters map their vendor's vocabulary onto this. */
338
+ reason: 'insufficient_funds' | 'card_declined' | 'expired' | 'invalid_details' | 'limit_exceeded' | 'duplicate' | 'not_permitted' | 'provider_error' | 'other';
339
+ /** The provider's own code and message, preserved verbatim for support and reconciliation. */
340
+ providerCode?: string;
341
+ providerMessage?: string;
342
+ /** Whether trying again could plausibly succeed. `false` for a hard decline. */
343
+ retryable: boolean;
344
+ }
345
+ /**
346
+ * The outcome of a provider command. **Three-valued, and the third one is the point.**
347
+ *
348
+ * A timeout is NOT a failure. When a capture request times out, the money may well have
349
+ * moved — we simply did not hear back. Treating that as `declined` and retrying is the
350
+ * canonical way to double-charge a customer, and it is invisible until reconciliation.
351
+ *
352
+ * `unknown` also covers a provider that legitimately cannot answer: a stateless manual
353
+ * provider asked for the status of a payment it never stored. Today's two-valued shape
354
+ * forces such a provider to invent an answer, and `revenue-manual` invented `succeeded`.
355
+ *
356
+ * **The only correct response to `unknown` is to reconcile status before acting** — never
357
+ * a blind retry.
358
+ */
359
+ type ProviderCommandResult<T> = {
360
+ outcome: 'confirmed';
361
+ value: T;
362
+ } | {
363
+ outcome: 'declined';
364
+ error: PaymentDecline;
365
+ } | {
366
+ outcome: 'unknown'; /** Whatever handle we have for asking again later. */
367
+ providerReference?: string;
368
+ /**
369
+ * NORMALIZED reason we do not know — never a raw exception message.
370
+ *
371
+ * Vendor errors routinely embed request URLs, tokens, account identifiers and
372
+ * fragments of the request body. This value is persisted on the transaction, returned
373
+ * to callers and read by operators, so it must be safe to store and display. The raw
374
+ * error belongs in a redacted diagnostic log, not here.
375
+ */
376
+ causeCode?: ProviderUnknownCause;
377
+ };
378
+ /** Why an outcome went unobserved. Coarse on purpose — it drives handling, not diagnosis. */
379
+ type ProviderUnknownCause = 'timeout' | 'aborted' | 'network' | 'status_unavailable' | 'unclassified';
380
+ /**
381
+ * Thrown by a provider that cannot answer a status query, rather than guessing.
382
+ *
383
+ * Classified as `unknown`. The engine's correct reaction is to consult the stored record:
384
+ * for a manual method there is no external money-movement authority, so the record IS the
385
+ * authority (see `spine/docs/payments-architecture.md` §1).
386
+ */
387
+ declare class ProviderStatusUnavailableError extends Error {
388
+ readonly providerName: string;
389
+ constructor(providerName: string, message?: string);
390
+ }
188
391
  //#endregion
189
- export { CreateIntentParams, PaymentIntent, PaymentIntentStatus, PaymentResult, ProviderCapabilities, RefundResult, RefundResultStatus, WebhookEvent };
392
+ export { CreateIntentParams, DefaultCurrencyAware, PaymentCommandContext, PaymentDecline, PaymentProviderPort, PaymentResult, ProviderCapabilities, ProviderCommandResult, ProviderIntent, ProviderIntentStatus, ProviderStatusUnavailableError, ProviderUnknownCause, RefundResult, RefundResultStatus, RefundStatusQuery, WebhookEvent };
@@ -1 +1,18 @@
1
- export {};
1
+ //#region src/money/payment-gateway.ts
2
+ /**
3
+ * Thrown by a provider that cannot answer a status query, rather than guessing.
4
+ *
5
+ * Classified as `unknown`. The engine's correct reaction is to consult the stored record:
6
+ * for a manual method there is no external money-movement authority, so the record IS the
7
+ * authority (see `spine/docs/payments-architecture.md` §1).
8
+ */
9
+ var ProviderStatusUnavailableError = class extends Error {
10
+ providerName;
11
+ constructor(providerName, message) {
12
+ super(message ?? `provider "${providerName}" cannot report status for this payment — it holds no record of it. Read the stored transaction instead of asking the provider.`);
13
+ this.name = "ProviderStatusUnavailableError";
14
+ this.providerName = providerName;
15
+ }
16
+ };
17
+ //#endregion
18
+ export { ProviderStatusUnavailableError };