@openlup/core 0.0.0 → 0.7.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 (192) hide show
  1. package/CHANGELOG.md +225 -0
  2. package/CODE_OF_CONDUCT.md +39 -0
  3. package/CONTRIBUTING.md +34 -0
  4. package/LICENSE +204 -0
  5. package/MAINTAINERS.md +33 -0
  6. package/README.md +85 -3
  7. package/SECURITY.md +29 -0
  8. package/dist/bundle/contracts.d.ts +9 -0
  9. package/dist/bundle/contracts.js +1 -0
  10. package/dist/bundle/ports.d.ts +41 -0
  11. package/dist/bundle/ports.js +1 -0
  12. package/dist/catalog/identifiers.d.ts +31 -0
  13. package/dist/catalog/identifiers.js +46 -0
  14. package/dist/catalog/index.d.ts +2 -0
  15. package/dist/catalog/index.js +2 -0
  16. package/dist/checkout/idempotency.d.ts +29 -0
  17. package/dist/checkout/idempotency.js +37 -0
  18. package/dist/checkout/index.d.ts +2 -0
  19. package/dist/checkout/index.js +2 -0
  20. package/dist/company-identity/contracts.d.ts +220 -0
  21. package/dist/company-identity/contracts.js +167 -0
  22. package/dist/company-identity/index.d.ts +6 -0
  23. package/dist/company-identity/index.js +2 -0
  24. package/dist/company-identity/ports.d.ts +13 -0
  25. package/dist/company-identity/ports.js +1 -0
  26. package/dist/fulfillment/facts.d.ts +103 -0
  27. package/dist/fulfillment/facts.js +195 -0
  28. package/dist/fulfillment/index.d.ts +4 -0
  29. package/dist/fulfillment/index.js +2 -0
  30. package/dist/inventory/atp.d.ts +172 -0
  31. package/dist/inventory/atp.js +211 -0
  32. package/dist/inventory/index.d.ts +2 -0
  33. package/dist/inventory/index.js +2 -0
  34. package/dist/marketing/research/contracts.d.ts +75 -0
  35. package/dist/marketing/research/contracts.js +60 -0
  36. package/dist/marketing/research/index.d.ts +4 -0
  37. package/dist/marketing/research/index.js +4 -0
  38. package/dist/marketing/research/ports.d.ts +15 -0
  39. package/dist/marketing/research/ports.js +1 -0
  40. package/dist/partners/index.d.ts +4 -0
  41. package/dist/partners/index.js +4 -0
  42. package/dist/partners/ports.d.ts +12 -0
  43. package/dist/partners/ports.js +1 -0
  44. package/dist/partners/status.d.ts +48 -0
  45. package/dist/partners/status.js +35 -0
  46. package/dist/payment/executionBaseContracts.d.ts +81 -0
  47. package/dist/payment/executionBaseContracts.js +8 -0
  48. package/dist/payment/index.d.ts +36 -0
  49. package/dist/payment/index.js +18 -0
  50. package/dist/payment/methodLifecycle.d.ts +91 -0
  51. package/dist/payment/methodLifecycle.js +76 -0
  52. package/dist/payment/paymentControlTypes.d.ts +135 -0
  53. package/dist/payment/paymentControlTypes.js +110 -0
  54. package/dist/payment/paymentFailureTaxonomy.d.ts +80 -0
  55. package/dist/payment/paymentFailureTaxonomy.js +155 -0
  56. package/dist/payment/paymentFailureTaxonomyContracts.d.ts +106 -0
  57. package/dist/payment/paymentFailureTaxonomyContracts.js +177 -0
  58. package/dist/payment/paymentRecovery.d.ts +26 -0
  59. package/dist/payment/paymentRecovery.js +73 -0
  60. package/dist/payment/paymentRecoveryContracts.d.ts +53 -0
  61. package/dist/payment/paymentRecoveryContracts.js +10 -0
  62. package/dist/payment/paymentStateMachine.d.ts +28 -0
  63. package/dist/payment/paymentStateMachine.js +184 -0
  64. package/dist/payment/providerAttemptIdempotency.d.ts +23 -0
  65. package/dist/payment/providerAttemptIdempotency.js +31 -0
  66. package/dist/payment/providerCapability.d.ts +148 -0
  67. package/dist/payment/providerCapability.js +13 -0
  68. package/dist/platform-runtime/contracts.d.ts +9 -0
  69. package/dist/platform-runtime/contracts.js +13 -0
  70. package/dist/platform-runtime/index.d.ts +10 -0
  71. package/dist/platform-runtime/index.js +4 -0
  72. package/dist/platform-runtime/platformKernel.d.ts +44 -0
  73. package/dist/platform-runtime/platformKernel.js +39 -0
  74. package/dist/platform-runtime/ports.d.ts +100 -0
  75. package/dist/platform-runtime/ports.js +1 -0
  76. package/dist/pricing/index.d.ts +8 -0
  77. package/dist/pricing/index.js +8 -0
  78. package/dist/pricing/ports.d.ts +22 -0
  79. package/dist/pricing/ports.js +7 -0
  80. package/dist/pricing/pricingBreakdown.d.ts +86 -0
  81. package/dist/pricing/pricingBreakdown.js +155 -0
  82. package/dist/pricing/targetPriceAllocation.d.ts +97 -0
  83. package/dist/pricing/targetPriceAllocation.js +202 -0
  84. package/dist/pricing/types.d.ts +32 -0
  85. package/dist/pricing/types.js +4 -0
  86. package/dist/promo/adjustmentEngine.d.ts +4 -0
  87. package/dist/promo/adjustmentEngine.js +194 -0
  88. package/dist/promo/adjustmentTypes.d.ts +103 -0
  89. package/dist/promo/adjustmentTypes.js +13 -0
  90. package/dist/promo/index.d.ts +8 -0
  91. package/dist/promo/index.js +8 -0
  92. package/dist/promo/promoEvaluator.d.ts +25 -0
  93. package/dist/promo/promoEvaluator.js +135 -0
  94. package/dist/promo/types.d.ts +86 -0
  95. package/dist/promo/types.js +12 -0
  96. package/dist/risk/core/evaluator.d.ts +3 -0
  97. package/dist/risk/core/evaluator.js +32 -0
  98. package/dist/risk/core/rules.d.ts +3 -0
  99. package/dist/risk/core/rules.js +118 -0
  100. package/dist/risk/core/types.d.ts +65 -0
  101. package/dist/risk/core/types.js +1 -0
  102. package/dist/risk/index.d.ts +8 -0
  103. package/dist/risk/index.js +8 -0
  104. package/dist/risk/types.d.ts +32 -0
  105. package/dist/risk/types.js +43 -0
  106. package/dist/shipping/index.d.ts +4 -0
  107. package/dist/shipping/index.js +4 -0
  108. package/dist/shipping/shippingResolver.d.ts +26 -0
  109. package/dist/shipping/shippingResolver.js +88 -0
  110. package/dist/shipping/types.d.ts +45 -0
  111. package/dist/shipping/types.js +4 -0
  112. package/dist/subscription/cycleHardening.d.ts +95 -0
  113. package/dist/subscription/cycleHardening.js +118 -0
  114. package/dist/subscription/index.d.ts +14 -0
  115. package/dist/subscription/index.js +14 -0
  116. package/dist/subscription/subscriptionEngineCore.d.ts +13 -0
  117. package/dist/subscription/subscriptionEngineCore.js +145 -0
  118. package/dist/subscription/subscriptionEngineLifecycle.d.ts +150 -0
  119. package/dist/subscription/subscriptionEngineLifecycle.js +433 -0
  120. package/dist/subscription/subscriptionEnginePayment.d.ts +25 -0
  121. package/dist/subscription/subscriptionEnginePayment.js +127 -0
  122. package/dist/subscription/subscriptionEngineTypes.d.ts +94 -0
  123. package/dist/subscription/subscriptionEngineTypes.js +4 -0
  124. package/dist/subscription/subscriptionEngineUtils.d.ts +32 -0
  125. package/dist/subscription/subscriptionEngineUtils.js +74 -0
  126. package/dist/subscription/types.d.ts +58 -0
  127. package/dist/subscription/types.js +47 -0
  128. package/dist/testing.d.ts +48 -0
  129. package/dist/testing.js +105 -0
  130. package/package.json +215 -4
  131. package/release-gates.json +232 -0
  132. package/src/bundle/contracts.ts +15 -0
  133. package/src/bundle/ports.ts +32 -0
  134. package/src/catalog/identifiers.ts +50 -0
  135. package/src/catalog/index.ts +2 -0
  136. package/src/checkout/idempotency.ts +49 -0
  137. package/src/checkout/index.ts +2 -0
  138. package/src/company-identity/contracts.ts +208 -0
  139. package/src/company-identity/index.ts +28 -0
  140. package/src/company-identity/ports.ts +19 -0
  141. package/src/fulfillment/facts.ts +300 -0
  142. package/src/fulfillment/index.ts +34 -0
  143. package/src/inventory/atp.ts +249 -0
  144. package/src/inventory/index.ts +2 -0
  145. package/src/marketing/research/contracts.ts +91 -0
  146. package/src/marketing/research/index.ts +4 -0
  147. package/src/marketing/research/ports.ts +26 -0
  148. package/src/partners/index.ts +4 -0
  149. package/src/partners/ports.ts +18 -0
  150. package/src/partners/status.ts +49 -0
  151. package/src/payment/executionBaseContracts.ts +93 -0
  152. package/src/payment/index.ts +128 -0
  153. package/src/payment/methodLifecycle.ts +130 -0
  154. package/src/payment/paymentControlTypes.ts +197 -0
  155. package/src/payment/paymentFailureTaxonomy.ts +201 -0
  156. package/src/payment/paymentFailureTaxonomyContracts.ts +228 -0
  157. package/src/payment/paymentRecovery.ts +95 -0
  158. package/src/payment/paymentRecoveryContracts.ts +61 -0
  159. package/src/payment/paymentStateMachine.ts +263 -0
  160. package/src/payment/providerAttemptIdempotency.ts +57 -0
  161. package/src/payment/providerCapability.ts +172 -0
  162. package/src/platform-runtime/contracts.ts +33 -0
  163. package/src/platform-runtime/index.ts +36 -0
  164. package/src/platform-runtime/platformKernel.ts +86 -0
  165. package/src/platform-runtime/ports.ts +88 -0
  166. package/src/pricing/index.ts +8 -0
  167. package/src/pricing/ports.ts +27 -0
  168. package/src/pricing/pricingBreakdown.ts +243 -0
  169. package/src/pricing/targetPriceAllocation.ts +293 -0
  170. package/src/pricing/types.ts +36 -0
  171. package/src/promo/adjustmentEngine.ts +253 -0
  172. package/src/promo/adjustmentTypes.ts +94 -0
  173. package/src/promo/index.ts +8 -0
  174. package/src/promo/promoEvaluator.ts +137 -0
  175. package/src/promo/types.ts +97 -0
  176. package/src/risk/core/evaluator.ts +41 -0
  177. package/src/risk/core/rules.ts +128 -0
  178. package/src/risk/core/types.ts +73 -0
  179. package/src/risk/index.ts +8 -0
  180. package/src/risk/types.ts +60 -0
  181. package/src/shipping/index.ts +4 -0
  182. package/src/shipping/shippingResolver.ts +101 -0
  183. package/src/shipping/types.ts +49 -0
  184. package/src/subscription/cycleHardening.ts +174 -0
  185. package/src/subscription/index.ts +14 -0
  186. package/src/subscription/subscriptionEngineCore.ts +181 -0
  187. package/src/subscription/subscriptionEngineLifecycle.ts +574 -0
  188. package/src/subscription/subscriptionEnginePayment.ts +179 -0
  189. package/src/subscription/subscriptionEngineTypes.ts +114 -0
  190. package/src/subscription/subscriptionEngineUtils.ts +110 -0
  191. package/src/subscription/types.ts +104 -0
  192. package/src/testing.ts +169 -0
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Neutral payment-method lifecycle vocabulary.
3
+ *
4
+ * A stored consent does not only come into existence. It is rotated, revoked
5
+ * and suspended, and it expires — and every rail announces those transitions in
6
+ * its own words. This module is the vocabulary those announcements are
7
+ * translated INTO, so the engine that reacts (deactivating a dead method,
8
+ * warning a payer before a renewal that can no longer be charged) never has to
9
+ * learn a rail's event names.
10
+ *
11
+ * Types and pure functions only: nothing here reads a clock, a network or a
12
+ * database, and no rail is named.
13
+ */
14
+ /**
15
+ * Every transition a rail may report about a method it holds on file.
16
+ *
17
+ * `method_suspended` is declared but published by no rail in this deployment.
18
+ * It is here because the consuming branch is exhaustive over this list: a rail
19
+ * that starts emitting a suspension must be handled, never silently ignored.
20
+ */
21
+ export declare const PAYMENT_METHOD_LIFECYCLE_EVENT_KINDS: readonly ["method_registered", "method_updated", "method_revoked", "method_expired", "method_suspended"];
22
+ export type PaymentMethodLifecycleEventKind = (typeof PAYMENT_METHOD_LIFECYCLE_EVENT_KINDS)[number];
23
+ /**
24
+ * What the rail reports about the method AFTER the transition.
25
+ *
26
+ * Deliberately three fields and no more: the label a payer recognises the
27
+ * method by, the trailing digits shown beside it, and the instant it stops
28
+ * being chargeable. ⛔ The full instrument number is never part of this
29
+ * contract, and an adapter that put one here would be widening a surface this
30
+ * type exists to keep narrow.
31
+ *
32
+ * Every field is independently nullable because rails report them
33
+ * independently: an alias rail may carry an expiry and no digits at all.
34
+ */
35
+ export interface PaymentMethodReplacementFacts {
36
+ /** Scheme or rail label the payer recognises, e.g. a card network name. */
37
+ schemeLabel: string | null;
38
+ /** Trailing digits of the instrument, never more than the payer is shown. */
39
+ lastDigits: string | null;
40
+ /** ISO-8601 instant after which the method can no longer be charged. */
41
+ expiresAt: string | null;
42
+ }
43
+ /**
44
+ * One rail transition, expressed neutrally.
45
+ *
46
+ * `providerKind` and `providerMethodRef` are opaque identity, not a branch
47
+ * condition: together they address exactly one stored method. A consumer that
48
+ * compares `providerKind` against a known name re-creates the coupling this
49
+ * contract removes.
50
+ */
51
+ export interface PaymentMethodLifecycleEvent {
52
+ kind: PaymentMethodLifecycleEventKind;
53
+ /** Registry key of the rail that reported this, used only for addressing. */
54
+ providerKind: string;
55
+ /** The rail's own reference for the stored method this transition is about. */
56
+ providerMethodRef: string;
57
+ /** The rail's identifier for this delivery, used for replay-safe writes. */
58
+ providerEventId: string;
59
+ /** ISO-8601 instant the rail reported the transition at. */
60
+ occurredAt: string;
61
+ /** Fresh facts the rail published with the transition, when it published any. */
62
+ replacement?: PaymentMethodReplacementFacts | null;
63
+ }
64
+ /**
65
+ * Whether this transition means the stored method can no longer back a charge.
66
+ *
67
+ * The single place that answers it. ⛔ A rotation (`method_updated`) is NOT a
68
+ * death: the same consent continues under fresh details, and treating it as one
69
+ * would strip a healthy subscription of the method it renews on.
70
+ */
71
+ export declare function endsStoredMethodUsability(kind: PaymentMethodLifecycleEventKind): boolean;
72
+ export declare function isPaymentMethodLifecycleEventKind(value: unknown): value is PaymentMethodLifecycleEventKind;
73
+ /**
74
+ * The instant a month-and-year validity actually runs out.
75
+ *
76
+ * Convention, pinned here once so no adapter has to re-decide it: an
77
+ * instrument valid "through 06/2027" is chargeable for the whole of that month
78
+ * and stops at the LAST instant of it, in UTC — `2027-06-30T23:59:59.999Z`. The
79
+ * common alternative, the first instant of the month, would retire a method up
80
+ * to 31 days before the payer's own statement says it dies.
81
+ *
82
+ * UTC rather than a local zone on purpose: the value is compared against
83
+ * `now()` by storage and by health reads, both of which are UTC, and the
84
+ * one-day ambiguity a local zone would introduce is not worth a timezone
85
+ * dependency in a kernel.
86
+ *
87
+ * Returns null for anything that is not a plainly valid month and four-digit
88
+ * year, so a malformed rail payload yields "no expiry known" rather than a
89
+ * fabricated date.
90
+ */
91
+ export declare function expiryInstantFromMonthYear(month: number | null | undefined, year: number | null | undefined): string | null;
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Neutral payment-method lifecycle vocabulary.
3
+ *
4
+ * A stored consent does not only come into existence. It is rotated, revoked
5
+ * and suspended, and it expires — and every rail announces those transitions in
6
+ * its own words. This module is the vocabulary those announcements are
7
+ * translated INTO, so the engine that reacts (deactivating a dead method,
8
+ * warning a payer before a renewal that can no longer be charged) never has to
9
+ * learn a rail's event names.
10
+ *
11
+ * Types and pure functions only: nothing here reads a clock, a network or a
12
+ * database, and no rail is named.
13
+ */
14
+ /**
15
+ * Every transition a rail may report about a method it holds on file.
16
+ *
17
+ * `method_suspended` is declared but published by no rail in this deployment.
18
+ * It is here because the consuming branch is exhaustive over this list: a rail
19
+ * that starts emitting a suspension must be handled, never silently ignored.
20
+ */
21
+ export const PAYMENT_METHOD_LIFECYCLE_EVENT_KINDS = [
22
+ "method_registered",
23
+ "method_updated",
24
+ "method_revoked",
25
+ "method_expired",
26
+ "method_suspended",
27
+ ];
28
+ /**
29
+ * Whether this transition means the stored method can no longer back a charge.
30
+ *
31
+ * The single place that answers it. ⛔ A rotation (`method_updated`) is NOT a
32
+ * death: the same consent continues under fresh details, and treating it as one
33
+ * would strip a healthy subscription of the method it renews on.
34
+ */
35
+ export function endsStoredMethodUsability(kind) {
36
+ return kind === "method_revoked" || kind === "method_expired" || kind === "method_suspended";
37
+ }
38
+ export function isPaymentMethodLifecycleEventKind(value) {
39
+ return typeof value === "string"
40
+ && PAYMENT_METHOD_LIFECYCLE_EVENT_KINDS.includes(value);
41
+ }
42
+ /**
43
+ * The instant a month-and-year validity actually runs out.
44
+ *
45
+ * Convention, pinned here once so no adapter has to re-decide it: an
46
+ * instrument valid "through 06/2027" is chargeable for the whole of that month
47
+ * and stops at the LAST instant of it, in UTC — `2027-06-30T23:59:59.999Z`. The
48
+ * common alternative, the first instant of the month, would retire a method up
49
+ * to 31 days before the payer's own statement says it dies.
50
+ *
51
+ * UTC rather than a local zone on purpose: the value is compared against
52
+ * `now()` by storage and by health reads, both of which are UTC, and the
53
+ * one-day ambiguity a local zone would introduce is not worth a timezone
54
+ * dependency in a kernel.
55
+ *
56
+ * Returns null for anything that is not a plainly valid month and four-digit
57
+ * year, so a malformed rail payload yields "no expiry known" rather than a
58
+ * fabricated date.
59
+ */
60
+ export function expiryInstantFromMonthYear(month, year) {
61
+ if (!Number.isInteger(month) || !Number.isInteger(year))
62
+ return null;
63
+ const monthValue = month;
64
+ const yearValue = year;
65
+ if (monthValue < 1 || monthValue > 12)
66
+ return null;
67
+ // Below 1000 the platform's own two-digit-year remapping would silently turn
68
+ // 27 into 1927; a rail that abbreviates the year is refused, not guessed at.
69
+ if (yearValue < 1000 || yearValue > 9999)
70
+ return null;
71
+ // Month is 1-based here and 0-based in the platform date constructor, so
72
+ // passing it unchanged addresses the FIRST instant of the following month;
73
+ // one millisecond earlier is the last instant of the expiry month itself.
74
+ const firstInstantAfter = Date.UTC(yearValue, monthValue, 1);
75
+ return new Date(firstInstantAfter - 1).toISOString();
76
+ }
@@ -0,0 +1,135 @@
1
+ /** @beta */
2
+ export declare const PAYMENT_TARGET_KINDS: readonly ["one_time_order", "subscription_cycle"];
3
+ /** @beta */
4
+ export type PaymentTargetKind = (typeof PAYMENT_TARGET_KINDS)[number];
5
+ /** @beta */
6
+ export declare const PAYMENT_INTENT_STATUSES: readonly ["created", "requires_action", "processing", "succeeded", "failed", "expired", "cancelled", "refunded", "partially_refunded", "disputed"];
7
+ /** @beta */
8
+ export type PaymentIntentStatus = (typeof PAYMENT_INTENT_STATUSES)[number];
9
+ /** @beta */
10
+ export declare const PAYMENT_ATTEMPT_STATUSES: readonly ["created", "blocked_preflight", "sent_to_provider", "requires_action", "processing", "succeeded", "failed", "cancelled", "expired"];
11
+ /** @beta */
12
+ export type PaymentAttemptStatus = (typeof PAYMENT_ATTEMPT_STATUSES)[number];
13
+ /**
14
+ * The attempt statuses that prove no provider call can still be outstanding, and
15
+ * so allow a fresh attempt against the same intent.
16
+ *
17
+ * This is the admission gate's own set, not a convenience list: the durable
18
+ * gate is written in SQL as `status NOT IN (...)` over exactly these four values,
19
+ * at three call sites, and `src/domains/payment/payment.test.ts` pins all three
20
+ * against this constant. The gate is deliberately fail-closed — every status
21
+ * outside this set is treated as "a call may exist" even when it is terminal —
22
+ * because admitting a second call against a live attempt can take the payer's
23
+ * money twice, while refusing one only delays a retry.
24
+ *
25
+ * @beta
26
+ */
27
+ export declare const RETRYABLE_ATTEMPT_STATUSES: readonly ["blocked_preflight", "failed", "cancelled", "expired"];
28
+ /**
29
+ * The attempt statuses that describe an outcome still in the air: the request
30
+ * exists, and neither success nor refusal is known yet.
31
+ *
32
+ * NOT the complement of {@link RETRYABLE_ATTEMPT_STATUSES}. The complement also
33
+ * contains `succeeded`, which is settled rather than live, so the two predicates
34
+ * relate as `!isRetryableAttemptStatus(s) === isLiveAttemptStatus(s) || s ===
35
+ * "succeeded"` — an identity the canon test pins, because reasoning about it
36
+ * from the two lists alone has already gone wrong once in this repository.
37
+ *
38
+ * @beta
39
+ */
40
+ export declare const LIVE_ATTEMPT_STATUSES: readonly ["created", "sent_to_provider", "requires_action", "processing"];
41
+ /**
42
+ * Whether a fresh attempt may be admitted against the intent this attempt
43
+ * belongs to. See {@link RETRYABLE_ATTEMPT_STATUSES}.
44
+ *
45
+ * @beta
46
+ */
47
+ export declare function isRetryableAttemptStatus(status: string): boolean;
48
+ /**
49
+ * Whether this attempt's outcome is still unresolved — the row a status poller,
50
+ * a reconciliation sweep or a stuck-attempt watchdog is looking for. See
51
+ * {@link LIVE_ATTEMPT_STATUSES}.
52
+ *
53
+ * @beta
54
+ */
55
+ export declare function isLiveAttemptStatus(status: string): boolean;
56
+ /**
57
+ * Whether the request demonstrably reached the provider and nothing came back.
58
+ *
59
+ * The single status that says so is `sent_to_provider`, and it exists because the
60
+ * alternatives both lie. `created` claims the provider was never contacted, so a
61
+ * caller is free to contact it again. `processing` claims money is moving, which
62
+ * is what a payer is then told — and that exact claim was made about intents no
63
+ * issuer had ever been asked about (recorded in the deployment payment-status canon).
64
+ *
65
+ * Reaching for this predicate instead of comparing the string is the point: the
66
+ * mapping that produced the wrong verdict lived in an adapter that could not see
67
+ * the reasoning, which sat unexported in a file beside it.
68
+ *
69
+ * @beta
70
+ */
71
+ export declare function providerAckedButNeverConfirmed(status: string): boolean;
72
+ /** @beta */
73
+ export declare const PAYMENT_CONTROL_EVENT_TYPES: readonly ["payment.requires_action", "payment.succeeded", "payment.failed", "payment.refunded", "payment.disputed"];
74
+ /** @beta */
75
+ export type PaymentControlEventType = (typeof PAYMENT_CONTROL_EVENT_TYPES)[number];
76
+ /** @beta */
77
+ export interface PaymentIntentAggregate {
78
+ id: string;
79
+ targetKind: PaymentTargetKind;
80
+ targetId: string;
81
+ status: PaymentIntentStatus;
82
+ amountMinor: number;
83
+ currency: string;
84
+ activeAttemptId: string | null;
85
+ updatedAt: string;
86
+ providerPaymentId?: string | null;
87
+ failureReason?: string | null;
88
+ }
89
+ /** @beta */
90
+ export interface PaymentAttemptAggregate {
91
+ id: string;
92
+ intentId: string;
93
+ status: PaymentAttemptStatus;
94
+ provider: string;
95
+ providerAttemptId: string | null;
96
+ amountMinor: number;
97
+ currency: string;
98
+ idempotencyKey: string;
99
+ updatedAt: string;
100
+ nextActionKind?: string | null;
101
+ failureReason?: string | null;
102
+ }
103
+ /** @beta */
104
+ export interface CanonicalPaymentControlEvent {
105
+ provider: string;
106
+ providerEventId: string;
107
+ eventType: PaymentControlEventType;
108
+ providerPaymentId: string;
109
+ amountMinor?: number;
110
+ currency?: string;
111
+ occurredAt: string;
112
+ failureReason?: string;
113
+ nextActionKind?: string;
114
+ rawPayload: Record<string, unknown>;
115
+ }
116
+ /** @beta */
117
+ export type PaymentControlDecisionKind = "state_changed" | "noop" | "late_success_recovered";
118
+ /** @beta */
119
+ export interface PaymentControlDecision {
120
+ kind: PaymentControlDecisionKind;
121
+ reason: string;
122
+ intent: PaymentIntentAggregate;
123
+ attempt: PaymentAttemptAggregate;
124
+ }
125
+ /** @beta */
126
+ export type PaymentControlFailureReason = "amount_mismatch" | "currency_mismatch" | "invalid_transition" | "refund_requires_success" | "terminal_intent" | "wrong_attempt";
127
+ /** @beta */
128
+ export type PaymentControlResult<T> = {
129
+ ok: true;
130
+ value: T;
131
+ } | {
132
+ ok: false;
133
+ reason: PaymentControlFailureReason;
134
+ message: string;
135
+ };
@@ -0,0 +1,110 @@
1
+ /** @beta */
2
+ export const PAYMENT_TARGET_KINDS = ["one_time_order", "subscription_cycle"];
3
+ /** @beta */
4
+ export const PAYMENT_INTENT_STATUSES = [
5
+ "created",
6
+ "requires_action",
7
+ "processing",
8
+ "succeeded",
9
+ "failed",
10
+ "expired",
11
+ "cancelled",
12
+ "refunded",
13
+ "partially_refunded",
14
+ "disputed",
15
+ ];
16
+ /** @beta */
17
+ export const PAYMENT_ATTEMPT_STATUSES = [
18
+ "created",
19
+ "blocked_preflight",
20
+ "sent_to_provider",
21
+ "requires_action",
22
+ "processing",
23
+ "succeeded",
24
+ "failed",
25
+ "cancelled",
26
+ "expired",
27
+ ];
28
+ /**
29
+ * The attempt statuses that prove no provider call can still be outstanding, and
30
+ * so allow a fresh attempt against the same intent.
31
+ *
32
+ * This is the admission gate's own set, not a convenience list: the durable
33
+ * gate is written in SQL as `status NOT IN (...)` over exactly these four values,
34
+ * at three call sites, and `src/domains/payment/payment.test.ts` pins all three
35
+ * against this constant. The gate is deliberately fail-closed — every status
36
+ * outside this set is treated as "a call may exist" even when it is terminal —
37
+ * because admitting a second call against a live attempt can take the payer's
38
+ * money twice, while refusing one only delays a retry.
39
+ *
40
+ * @beta
41
+ */
42
+ export const RETRYABLE_ATTEMPT_STATUSES = [
43
+ "blocked_preflight",
44
+ "failed",
45
+ "cancelled",
46
+ "expired",
47
+ ];
48
+ /**
49
+ * The attempt statuses that describe an outcome still in the air: the request
50
+ * exists, and neither success nor refusal is known yet.
51
+ *
52
+ * NOT the complement of {@link RETRYABLE_ATTEMPT_STATUSES}. The complement also
53
+ * contains `succeeded`, which is settled rather than live, so the two predicates
54
+ * relate as `!isRetryableAttemptStatus(s) === isLiveAttemptStatus(s) || s ===
55
+ * "succeeded"` — an identity the canon test pins, because reasoning about it
56
+ * from the two lists alone has already gone wrong once in this repository.
57
+ *
58
+ * @beta
59
+ */
60
+ export const LIVE_ATTEMPT_STATUSES = [
61
+ "created",
62
+ "sent_to_provider",
63
+ "requires_action",
64
+ "processing",
65
+ ];
66
+ /**
67
+ * Whether a fresh attempt may be admitted against the intent this attempt
68
+ * belongs to. See {@link RETRYABLE_ATTEMPT_STATUSES}.
69
+ *
70
+ * @beta
71
+ */
72
+ export function isRetryableAttemptStatus(status) {
73
+ return RETRYABLE_ATTEMPT_STATUSES.includes(status);
74
+ }
75
+ /**
76
+ * Whether this attempt's outcome is still unresolved — the row a status poller,
77
+ * a reconciliation sweep or a stuck-attempt watchdog is looking for. See
78
+ * {@link LIVE_ATTEMPT_STATUSES}.
79
+ *
80
+ * @beta
81
+ */
82
+ export function isLiveAttemptStatus(status) {
83
+ return LIVE_ATTEMPT_STATUSES.includes(status);
84
+ }
85
+ /**
86
+ * Whether the request demonstrably reached the provider and nothing came back.
87
+ *
88
+ * The single status that says so is `sent_to_provider`, and it exists because the
89
+ * alternatives both lie. `created` claims the provider was never contacted, so a
90
+ * caller is free to contact it again. `processing` claims money is moving, which
91
+ * is what a payer is then told — and that exact claim was made about intents no
92
+ * issuer had ever been asked about (recorded in the deployment payment-status canon).
93
+ *
94
+ * Reaching for this predicate instead of comparing the string is the point: the
95
+ * mapping that produced the wrong verdict lived in an adapter that could not see
96
+ * the reasoning, which sat unexported in a file beside it.
97
+ *
98
+ * @beta
99
+ */
100
+ export function providerAckedButNeverConfirmed(status) {
101
+ return status === "sent_to_provider";
102
+ }
103
+ /** @beta */
104
+ export const PAYMENT_CONTROL_EVENT_TYPES = [
105
+ "payment.requires_action",
106
+ "payment.succeeded",
107
+ "payment.failed",
108
+ "payment.refunded",
109
+ "payment.disputed",
110
+ ];
@@ -0,0 +1,80 @@
1
+ import { PAYMENT_RETRY_ADVICE_CODES, type PaymentFailureClass, type PaymentFailureEvidence, type PaymentFailureHint } from "./paymentFailureTaxonomyContracts.js";
2
+ /**
3
+ * Pure classification of a refused payment into {@link PaymentFailureClass}.
4
+ *
5
+ * No clock, no input/output, no acquirer code tables: everything the verdict
6
+ * depends on arrives in {@link PaymentFailureEvidence}, so the same evidence
7
+ * always produces the same class and a test can enumerate the whole surface.
8
+ *
9
+ * Nothing consumes this yet. It is the contract the adapter mappings and the
10
+ * cadence branching are written against, landed first and on its own so the
11
+ * table can be reviewed as a table rather than as a diff inside a scheduler.
12
+ */
13
+ /** Which rule produced the class. Published so a precedence conflict is assertable, not inferred from the class alone. */
14
+ /** @beta */
15
+ export declare const PAYMENT_FAILURE_CLASSIFICATION_SOURCES: readonly ["advice_code", "neutral_hint", "failure_reason_key", "default"];
16
+ /** @beta */
17
+ export type PaymentFailureClassificationSource = (typeof PAYMENT_FAILURE_CLASSIFICATION_SOURCES)[number];
18
+ /** @beta */
19
+ export interface PaymentFailureClassification {
20
+ failureClass: PaymentFailureClass;
21
+ decidedBy: PaymentFailureClassificationSource;
22
+ }
23
+ /** @beta */
24
+ export interface PaymentFailureClassificationOptions {
25
+ /**
26
+ * The adapter's own translation from its opaque decline codes to neutral
27
+ * hints. Supplied per call because the codes belong to the adapter, never to
28
+ * this kernel. Keys are matched exactly, so the adapter owns normalization.
29
+ */
30
+ declineCodeHints?: Readonly<Record<string, readonly PaymentFailureHint[]>>;
31
+ }
32
+ /**
33
+ * Precedence, strongest first:
34
+ *
35
+ * 1. `adviceCode` — the issuer's or network's own retry verdict. It outranks
36
+ * every local reading because it is the only evidence produced by the party
37
+ * that will refuse the next attempt. An unrecognized value decides nothing
38
+ * and falls through rather than being coerced.
39
+ * 2. neutral hints — what the adapter is willing to assert, whether stated
40
+ * directly or reached through its own decline-code table. Several hints at
41
+ * once resolve in {@link PAYMENT_FAILURE_HINTS} order, strictest first, so a
42
+ * refusal that is both dead and merely slow is never scheduled for a
43
+ * background retry.
44
+ * 3. `failureReasonKey` — the reason already persisted with the attempt. Weakest
45
+ * because it is our own summary of a refusal, written before anyone tried to
46
+ * classify it.
47
+ * 4. `indeterminate` — nothing decided.
48
+ */
49
+ /** @beta */
50
+ export declare function classifyPaymentFailure(evidence: PaymentFailureEvidence, options?: PaymentFailureClassificationOptions): PaymentFailureClassification;
51
+ /** True when a value is one of the advice codes this kernel acts on. */
52
+ /** @beta */
53
+ export declare function isPaymentRetryAdviceCode(value: string): value is (typeof PAYMENT_RETRY_ADVICE_CODES)[number];
54
+ /**
55
+ * The hard ceiling any retry schedule must stay under: at most 15 attempts on one
56
+ * instrument in 30 days. It is the stricter of the published card-scheme limits,
57
+ * chosen deliberately so a schedule that satisfies this one satisfies all of them.
58
+ *
59
+ * The current ladder tops out at 4, far below this. The constant exists so a later
60
+ * wave that widens the ladder has to measure itself against the scheme rule rather
61
+ * than discover it through fines.
62
+ */
63
+ /** @beta */
64
+ export declare const SCHEME_RETRY_ATTEMPT_CEILING = 15;
65
+ /** The rolling window the ceiling is counted over. */
66
+ /** @beta */
67
+ export declare const SCHEME_RETRY_WINDOW_DAYS = 30;
68
+ /**
69
+ * Whether an attempt is provably inside the scheme ceiling.
70
+ *
71
+ * `attemptCount` is the ordinal of the attempt being considered — the 15th
72
+ * attempt is inside, the 16th is not. `windowDays` is the span those attempts
73
+ * were counted over; a span longer than {@link SCHEME_RETRY_WINDOW_DAYS} cannot
74
+ * prove compliance, because the attempts may still cluster inside one 30-day
75
+ * sub-window, so it answers false rather than pretending to know.
76
+ *
77
+ * Fail-closed for anything that is not a positive integer.
78
+ */
79
+ /** @beta */
80
+ export declare function withinSchemeRetryCeiling(attemptCount: number, windowDays: number): boolean;
@@ -0,0 +1,155 @@
1
+ import { PAYMENT_FAILURE_HINTS, PAYMENT_RETRY_ADVICE_CODES, } from "./paymentFailureTaxonomyContracts.js";
2
+ /**
3
+ * Pure classification of a refused payment into {@link PaymentFailureClass}.
4
+ *
5
+ * No clock, no input/output, no acquirer code tables: everything the verdict
6
+ * depends on arrives in {@link PaymentFailureEvidence}, so the same evidence
7
+ * always produces the same class and a test can enumerate the whole surface.
8
+ *
9
+ * Nothing consumes this yet. It is the contract the adapter mappings and the
10
+ * cadence branching are written against, landed first and on its own so the
11
+ * table can be reviewed as a table rather than as a diff inside a scheduler.
12
+ */
13
+ /** Which rule produced the class. Published so a precedence conflict is assertable, not inferred from the class alone. */
14
+ /** @beta */
15
+ export const PAYMENT_FAILURE_CLASSIFICATION_SOURCES = [
16
+ "advice_code",
17
+ "neutral_hint",
18
+ "failure_reason_key",
19
+ "default",
20
+ ];
21
+ const ADVICE_CLASSES = Object.freeze({
22
+ do_not_try_again: "hard_do_not_retry",
23
+ confirm_card_data: "fix_and_retry_customer_action",
24
+ try_again_later: "soft_retryable",
25
+ });
26
+ const HINT_CLASSES = Object.freeze({
27
+ credentialDead: "hard_do_not_retry",
28
+ mandateUnsupported: "mandate_dead",
29
+ authenticationRequired: "sca_required",
30
+ dataInvalid: "fix_and_retry_customer_action",
31
+ limitExceeded: "soft_retry_delayed",
32
+ transient: "soft_retryable",
33
+ });
34
+ /**
35
+ * The internal reason strings already persisted by the control plane.
36
+ *
37
+ * The `provider_*` four resolve to `indeterminate` on purpose: they record THAT
38
+ * the charge failed and by which rail the news arrived, never WHY the issuer
39
+ * refused. Mapping them to anything softer would invent a verdict that was never
40
+ * returned. They are listed rather than left to the default so the absence of
41
+ * information is pinned by a test instead of re-derived by each reader.
42
+ *
43
+ * The rest are renewal PREFLIGHT reasons: refusals reached on local evidence,
44
+ * before any rail is asked. They belong here for the reason
45
+ * `off_session_sca_required` does — the key IS the whole evidence, there is no
46
+ * issuer verdict to defer to, and each states something about the stored method
47
+ * rather than about one scheme's vocabulary.
48
+ *
49
+ * A key naming a specific scheme belongs to the adapter that owns it, which
50
+ * asserts the matching neutral hint instead. Hence the vendor-prefixed preflight
51
+ * reasons are absent: they resolve to `indeterminate` until that adapter maps
52
+ * them, which is the honest answer rather than a kernel guess.
53
+ */
54
+ const REASON_KEY_CLASSES = Object.freeze({
55
+ off_session_sca_required: "sca_required",
56
+ provider_declined: "indeterminate",
57
+ provider_failed: "indeterminate",
58
+ provider_webhook_failed: "indeterminate",
59
+ provider_execution_indeterminate: "indeterminate",
60
+ // Authorization withdrawn. The instrument may live on; this reference cannot
61
+ // be charged.
62
+ payment_method_revoked: "hard_do_not_retry",
63
+ // Stored evidence says the payer must be present — an issuer's authentication
64
+ // demand, reached one step earlier.
65
+ payment_method_requires_action: "sca_required",
66
+ // Nothing usable stored, or what is stored failed its integrity read. Both
67
+ // need the payer to supply a method; neither is retriable as-is.
68
+ missing_provider_method_ref: "fix_and_retry_customer_action",
69
+ payment_method_invalid: "fix_and_retry_customer_action",
70
+ });
71
+ function ownValue(table, key) {
72
+ return key !== undefined && Object.prototype.hasOwnProperty.call(table, key) ? table[key] : undefined;
73
+ }
74
+ function classified(failureClass, decidedBy) {
75
+ return Object.freeze({ failureClass, decidedBy });
76
+ }
77
+ function assertedHints(evidence, options) {
78
+ const hints = new Set(evidence.neutralReasonHints ?? []);
79
+ for (const hint of ownValue(options.declineCodeHints ?? {}, evidence.declineCode) ?? []) {
80
+ hints.add(hint);
81
+ }
82
+ return hints;
83
+ }
84
+ /**
85
+ * Precedence, strongest first:
86
+ *
87
+ * 1. `adviceCode` — the issuer's or network's own retry verdict. It outranks
88
+ * every local reading because it is the only evidence produced by the party
89
+ * that will refuse the next attempt. An unrecognized value decides nothing
90
+ * and falls through rather than being coerced.
91
+ * 2. neutral hints — what the adapter is willing to assert, whether stated
92
+ * directly or reached through its own decline-code table. Several hints at
93
+ * once resolve in {@link PAYMENT_FAILURE_HINTS} order, strictest first, so a
94
+ * refusal that is both dead and merely slow is never scheduled for a
95
+ * background retry.
96
+ * 3. `failureReasonKey` — the reason already persisted with the attempt. Weakest
97
+ * because it is our own summary of a refusal, written before anyone tried to
98
+ * classify it.
99
+ * 4. `indeterminate` — nothing decided.
100
+ */
101
+ /** @beta */
102
+ export function classifyPaymentFailure(evidence, options = {}) {
103
+ const advised = ownValue(ADVICE_CLASSES, evidence.adviceCode);
104
+ if (advised !== undefined)
105
+ return classified(advised, "advice_code");
106
+ const hints = assertedHints(evidence, options);
107
+ for (const hint of PAYMENT_FAILURE_HINTS) {
108
+ if (hints.has(hint))
109
+ return classified(HINT_CLASSES[hint], "neutral_hint");
110
+ }
111
+ const byReason = ownValue(REASON_KEY_CLASSES, evidence.failureReasonKey);
112
+ if (byReason !== undefined)
113
+ return classified(byReason, "failure_reason_key");
114
+ return classified("indeterminate", "default");
115
+ }
116
+ /** True when a value is one of the advice codes this kernel acts on. */
117
+ /** @beta */
118
+ export function isPaymentRetryAdviceCode(value) {
119
+ return PAYMENT_RETRY_ADVICE_CODES.includes(value);
120
+ }
121
+ /**
122
+ * The hard ceiling any retry schedule must stay under: at most 15 attempts on one
123
+ * instrument in 30 days. It is the stricter of the published card-scheme limits,
124
+ * chosen deliberately so a schedule that satisfies this one satisfies all of them.
125
+ *
126
+ * The current ladder tops out at 4, far below this. The constant exists so a later
127
+ * wave that widens the ladder has to measure itself against the scheme rule rather
128
+ * than discover it through fines.
129
+ */
130
+ /** @beta */
131
+ export const SCHEME_RETRY_ATTEMPT_CEILING = 15;
132
+ /** The rolling window the ceiling is counted over. */
133
+ /** @beta */
134
+ export const SCHEME_RETRY_WINDOW_DAYS = 30;
135
+ /**
136
+ * Whether an attempt is provably inside the scheme ceiling.
137
+ *
138
+ * `attemptCount` is the ordinal of the attempt being considered — the 15th
139
+ * attempt is inside, the 16th is not. `windowDays` is the span those attempts
140
+ * were counted over; a span longer than {@link SCHEME_RETRY_WINDOW_DAYS} cannot
141
+ * prove compliance, because the attempts may still cluster inside one 30-day
142
+ * sub-window, so it answers false rather than pretending to know.
143
+ *
144
+ * Fail-closed for anything that is not a positive integer.
145
+ */
146
+ /** @beta */
147
+ export function withinSchemeRetryCeiling(attemptCount, windowDays) {
148
+ if (!Number.isInteger(attemptCount) || !Number.isInteger(windowDays))
149
+ return false;
150
+ if (attemptCount < 1 || windowDays < 1)
151
+ return false;
152
+ if (windowDays > SCHEME_RETRY_WINDOW_DAYS)
153
+ return false;
154
+ return attemptCount <= SCHEME_RETRY_ATTEMPT_CEILING;
155
+ }