@openlup/core 0.0.0 → 0.6.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 +184 -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 +64 -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 +168 -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 +72 -0
  113. package/dist/subscription/cycleHardening.js +95 -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 +52 -0
  119. package/dist/subscription/subscriptionEngineLifecycle.js +196 -0
  120. package/dist/subscription/subscriptionEnginePayment.d.ts +19 -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 +209 -0
  139. package/src/company-identity/index.ts +27 -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 +141 -0
  185. package/src/subscription/index.ts +14 -0
  186. package/src/subscription/subscriptionEngineCore.ts +181 -0
  187. package/src/subscription/subscriptionEngineLifecycle.ts +282 -0
  188. package/src/subscription/subscriptionEnginePayment.ts +174 -0
  189. package/src/subscription/subscriptionEngineTypes.ts +112 -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,201 @@
1
+ import {
2
+ PAYMENT_FAILURE_HINTS,
3
+ PAYMENT_RETRY_ADVICE_CODES,
4
+ type PaymentFailureClass,
5
+ type PaymentFailureEvidence,
6
+ type PaymentFailureHint,
7
+ } from "./paymentFailureTaxonomyContracts.js";
8
+
9
+ /**
10
+ * Pure classification of a refused payment into {@link PaymentFailureClass}.
11
+ *
12
+ * No clock, no input/output, no acquirer code tables: everything the verdict
13
+ * depends on arrives in {@link PaymentFailureEvidence}, so the same evidence
14
+ * always produces the same class and a test can enumerate the whole surface.
15
+ *
16
+ * Nothing consumes this yet. It is the contract the adapter mappings and the
17
+ * cadence branching are written against, landed first and on its own so the
18
+ * table can be reviewed as a table rather than as a diff inside a scheduler.
19
+ */
20
+
21
+ /** Which rule produced the class. Published so a precedence conflict is assertable, not inferred from the class alone. */
22
+ /** @beta */
23
+ export const PAYMENT_FAILURE_CLASSIFICATION_SOURCES = [
24
+ "advice_code",
25
+ "neutral_hint",
26
+ "failure_reason_key",
27
+ "default",
28
+ ] as const;
29
+ /** @beta */
30
+ export type PaymentFailureClassificationSource =
31
+ (typeof PAYMENT_FAILURE_CLASSIFICATION_SOURCES)[number];
32
+
33
+ /** @beta */
34
+ export interface PaymentFailureClassification {
35
+ failureClass: PaymentFailureClass;
36
+ decidedBy: PaymentFailureClassificationSource;
37
+ }
38
+
39
+ /** @beta */
40
+ export interface PaymentFailureClassificationOptions {
41
+ /**
42
+ * The adapter's own translation from its opaque decline codes to neutral
43
+ * hints. Supplied per call because the codes belong to the adapter, never to
44
+ * this kernel. Keys are matched exactly, so the adapter owns normalization.
45
+ */
46
+ declineCodeHints?: Readonly<Record<string, readonly PaymentFailureHint[]>>;
47
+ }
48
+
49
+ const ADVICE_CLASSES: Readonly<Record<string, PaymentFailureClass>> = Object.freeze({
50
+ do_not_try_again: "hard_do_not_retry",
51
+ confirm_card_data: "fix_and_retry_customer_action",
52
+ try_again_later: "soft_retryable",
53
+ });
54
+
55
+ const HINT_CLASSES: Readonly<Record<PaymentFailureHint, PaymentFailureClass>> = Object.freeze({
56
+ credentialDead: "hard_do_not_retry",
57
+ mandateUnsupported: "mandate_dead",
58
+ authenticationRequired: "sca_required",
59
+ dataInvalid: "fix_and_retry_customer_action",
60
+ limitExceeded: "soft_retry_delayed",
61
+ transient: "soft_retryable",
62
+ });
63
+
64
+ /**
65
+ * The internal reason strings already persisted by the control plane.
66
+ *
67
+ * The `provider_*` four resolve to `indeterminate` on purpose: they record THAT
68
+ * the charge failed and by which rail the news arrived, never WHY the issuer
69
+ * refused. Mapping them to anything softer would invent a verdict that was never
70
+ * returned. They are listed rather than left to the default so the absence of
71
+ * information is pinned by a test instead of re-derived by each reader.
72
+ *
73
+ * The rest are renewal PREFLIGHT reasons: refusals reached on local evidence,
74
+ * before any rail is asked. They belong here for the reason
75
+ * `off_session_sca_required` does — the key IS the whole evidence, there is no
76
+ * issuer verdict to defer to, and each states something about the stored method
77
+ * rather than about one scheme's vocabulary.
78
+ *
79
+ * A key naming a specific scheme belongs to the adapter that owns it, which
80
+ * asserts the matching neutral hint instead. Hence the vendor-prefixed preflight
81
+ * reasons are absent: they resolve to `indeterminate` until that adapter maps
82
+ * them, which is the honest answer rather than a kernel guess.
83
+ */
84
+ const REASON_KEY_CLASSES: Readonly<Record<string, PaymentFailureClass>> = Object.freeze({
85
+ off_session_sca_required: "sca_required",
86
+ provider_declined: "indeterminate",
87
+ provider_failed: "indeterminate",
88
+ provider_webhook_failed: "indeterminate",
89
+ provider_execution_indeterminate: "indeterminate",
90
+ // Authorization withdrawn. The instrument may live on; this reference cannot
91
+ // be charged.
92
+ payment_method_revoked: "hard_do_not_retry",
93
+ // Stored evidence says the payer must be present — an issuer's authentication
94
+ // demand, reached one step earlier.
95
+ payment_method_requires_action: "sca_required",
96
+ // Nothing usable stored, or what is stored failed its integrity read. Both
97
+ // need the payer to supply a method; neither is retriable as-is.
98
+ missing_provider_method_ref: "fix_and_retry_customer_action",
99
+ payment_method_invalid: "fix_and_retry_customer_action",
100
+ });
101
+
102
+ function ownValue<T>(table: Readonly<Record<string, T>>, key: string | undefined): T | undefined {
103
+ return key !== undefined && Object.prototype.hasOwnProperty.call(table, key) ? table[key] : undefined;
104
+ }
105
+
106
+ function classified(
107
+ failureClass: PaymentFailureClass,
108
+ decidedBy: PaymentFailureClassificationSource,
109
+ ): PaymentFailureClassification {
110
+ return Object.freeze({ failureClass, decidedBy });
111
+ }
112
+
113
+ function assertedHints(
114
+ evidence: PaymentFailureEvidence,
115
+ options: PaymentFailureClassificationOptions,
116
+ ): ReadonlySet<PaymentFailureHint> {
117
+ const hints = new Set<PaymentFailureHint>(evidence.neutralReasonHints ?? []);
118
+ for (const hint of ownValue(options.declineCodeHints ?? {}, evidence.declineCode) ?? []) {
119
+ hints.add(hint);
120
+ }
121
+ return hints;
122
+ }
123
+
124
+ /**
125
+ * Precedence, strongest first:
126
+ *
127
+ * 1. `adviceCode` — the issuer's or network's own retry verdict. It outranks
128
+ * every local reading because it is the only evidence produced by the party
129
+ * that will refuse the next attempt. An unrecognized value decides nothing
130
+ * and falls through rather than being coerced.
131
+ * 2. neutral hints — what the adapter is willing to assert, whether stated
132
+ * directly or reached through its own decline-code table. Several hints at
133
+ * once resolve in {@link PAYMENT_FAILURE_HINTS} order, strictest first, so a
134
+ * refusal that is both dead and merely slow is never scheduled for a
135
+ * background retry.
136
+ * 3. `failureReasonKey` — the reason already persisted with the attempt. Weakest
137
+ * because it is our own summary of a refusal, written before anyone tried to
138
+ * classify it.
139
+ * 4. `indeterminate` — nothing decided.
140
+ */
141
+ /** @beta */
142
+ export function classifyPaymentFailure(
143
+ evidence: PaymentFailureEvidence,
144
+ options: PaymentFailureClassificationOptions = {},
145
+ ): PaymentFailureClassification {
146
+ const advised = ownValue(ADVICE_CLASSES, evidence.adviceCode);
147
+ if (advised !== undefined) return classified(advised, "advice_code");
148
+
149
+ const hints = assertedHints(evidence, options);
150
+ for (const hint of PAYMENT_FAILURE_HINTS) {
151
+ if (hints.has(hint)) return classified(HINT_CLASSES[hint], "neutral_hint");
152
+ }
153
+
154
+ const byReason = ownValue(REASON_KEY_CLASSES, evidence.failureReasonKey);
155
+ if (byReason !== undefined) return classified(byReason, "failure_reason_key");
156
+
157
+ return classified("indeterminate", "default");
158
+ }
159
+
160
+ /** True when a value is one of the advice codes this kernel acts on. */
161
+ /** @beta */
162
+ export function isPaymentRetryAdviceCode(
163
+ value: string,
164
+ ): value is (typeof PAYMENT_RETRY_ADVICE_CODES)[number] {
165
+ return (PAYMENT_RETRY_ADVICE_CODES as readonly string[]).includes(value);
166
+ }
167
+
168
+ /**
169
+ * The hard ceiling any retry schedule must stay under: at most 15 attempts on one
170
+ * instrument in 30 days. It is the stricter of the published card-scheme limits,
171
+ * chosen deliberately so a schedule that satisfies this one satisfies all of them.
172
+ *
173
+ * The current ladder tops out at 4, far below this. The constant exists so a later
174
+ * wave that widens the ladder has to measure itself against the scheme rule rather
175
+ * than discover it through fines.
176
+ */
177
+ /** @beta */
178
+ export const SCHEME_RETRY_ATTEMPT_CEILING = 15;
179
+
180
+ /** The rolling window the ceiling is counted over. */
181
+ /** @beta */
182
+ export const SCHEME_RETRY_WINDOW_DAYS = 30;
183
+
184
+ /**
185
+ * Whether an attempt is provably inside the scheme ceiling.
186
+ *
187
+ * `attemptCount` is the ordinal of the attempt being considered — the 15th
188
+ * attempt is inside, the 16th is not. `windowDays` is the span those attempts
189
+ * were counted over; a span longer than {@link SCHEME_RETRY_WINDOW_DAYS} cannot
190
+ * prove compliance, because the attempts may still cluster inside one 30-day
191
+ * sub-window, so it answers false rather than pretending to know.
192
+ *
193
+ * Fail-closed for anything that is not a positive integer.
194
+ */
195
+ /** @beta */
196
+ export function withinSchemeRetryCeiling(attemptCount: number, windowDays: number): boolean {
197
+ if (!Number.isInteger(attemptCount) || !Number.isInteger(windowDays)) return false;
198
+ if (attemptCount < 1 || windowDays < 1) return false;
199
+ if (windowDays > SCHEME_RETRY_WINDOW_DAYS) return false;
200
+ return attemptCount <= SCHEME_RETRY_ATTEMPT_CEILING;
201
+ }
@@ -0,0 +1,228 @@
1
+ /**
2
+ * The vocabulary a refused payment is classified into, and the retry decision
3
+ * each class implies.
4
+ *
5
+ * The classes answer one question — *may this instrument be charged again, and
6
+ * by whom* — and nothing else. They are deliberately not a copy of any acquirer's
7
+ * code list: an adapter translates its own codes into the neutral evidence in
8
+ * {@link PaymentFailureEvidence}, and only that evidence reaches this kernel.
9
+ *
10
+ * `indeterminate` means the CLASS is unknown, never that the OUTCOME is unknown.
11
+ * A refusal whose money movement is itself ambiguous must be resolved by the
12
+ * idempotency and attempt-fingerprint layer before anything here is consulted;
13
+ * this kernel cannot make a repeat charge safe.
14
+ */
15
+
16
+ /**
17
+ * Ordered strongest-refusal-first. The order is documentation only — the
18
+ * classifier resolves a class from evidence, never from this array's index.
19
+ */
20
+ /** @beta */
21
+ export const PAYMENT_FAILURE_CLASSES = [
22
+ /** The credential is dead or the refusal is final: lost, stolen, reported fraudulent, closed account, or an authorization the payer revoked. Another attempt cannot succeed and still costs scheme fees. */
23
+ "hard_do_not_retry",
24
+ /** The stored mandate, alias, or standing consent no longer works even though the instrument may. Re-registration or a replacement method is required; a scheduled retry against the same stored reference repeats the same refusal. */
25
+ "mandate_dead",
26
+ /** Strong authentication is required. A retry is only meaningful through an on-session or hosted confirmation the payer can answer; a blind background retry cannot clear it. */
27
+ "sca_required",
28
+ /** Submitted data is wrong: number, security code, expiry, or address. Never retry automatically — the same wrong data produces the same refusal and, repeated, looks like enumeration. */
29
+ "fix_and_retry_customer_action",
30
+ /** Retriable, but only after a longer wait: a velocity, frequency, or amount limit was exceeded and needs time to decay. */
31
+ "soft_retry_delayed",
32
+ /** Transient: insufficient funds, a processing error, an unreachable issuer, or an explicit try-again-later verdict. A scheduled retry is the correct response. */
33
+ "soft_retryable",
34
+ /** Unclassifiable from the evidence supplied. Treated as soft with the default schedule, which is the conservative choice only because the retry ladder is bounded elsewhere. */
35
+ "indeterminate",
36
+ ] as const;
37
+ /** @beta */
38
+ export type PaymentFailureClass = (typeof PAYMENT_FAILURE_CLASSES)[number];
39
+
40
+ /**
41
+ * The retry verdict the network or issuer returned with the refusal. These three
42
+ * values are scheme-standard advice, not one acquirer's vocabulary, which is why
43
+ * a neutral kernel may name them.
44
+ */
45
+ /** @beta */
46
+ export const PAYMENT_RETRY_ADVICE_CODES = [
47
+ "do_not_try_again",
48
+ "try_again_later",
49
+ "confirm_card_data",
50
+ ] as const;
51
+ /** @beta */
52
+ export type PaymentRetryAdviceCode = (typeof PAYMENT_RETRY_ADVICE_CODES)[number];
53
+
54
+ /**
55
+ * Neutral assertions an adapter makes about a refusal it already understands.
56
+ *
57
+ * The array order IS the resolution order used when several hints are asserted
58
+ * at once: the strictest reading wins, so a refusal that is both dead and merely
59
+ * slow is never scheduled for a background retry.
60
+ */
61
+ /** @beta */
62
+ export const PAYMENT_FAILURE_HINTS = [
63
+ /** The instrument itself is finished: reported lost or stolen, flagged as fraud, closed, or the payer withdrew authorization. */
64
+ "credentialDead",
65
+ /** The payer's bank cannot honour the stored reusable reference, whatever the instrument's own state. */
66
+ "mandateUnsupported",
67
+ /** The refusal is an authentication demand, not a decline of the money. */
68
+ "authenticationRequired",
69
+ /** A submitted value was rejected as wrong rather than refused as unaffordable. */
70
+ "dataInvalid",
71
+ /** A velocity, frequency, or amount limit was hit; the same charge may pass once the limit decays. */
72
+ "limitExceeded",
73
+ /** An ordinary temporary refusal with no further detail. */
74
+ "transient",
75
+ ] as const;
76
+ /** @beta */
77
+ export type PaymentFailureHint = (typeof PAYMENT_FAILURE_HINTS)[number];
78
+
79
+ /**
80
+ * Everything the kernel is allowed to look at. Every field is optional: an
81
+ * adapter that knows nothing supplies nothing and gets `indeterminate`, which is
82
+ * an honest answer rather than a guess.
83
+ */
84
+ /** @beta */
85
+ export interface PaymentFailureEvidence {
86
+ /** The issuer or network retry verdict, when one was returned. Recognized values are {@link PaymentRetryAdviceCode}; anything else is ignored rather than guessed at. */
87
+ adviceCode?: string;
88
+ /** The acquirer's own refusal code, kept opaque here. It contributes a class only through a caller-supplied hint table — this kernel embeds no code lists. */
89
+ declineCode?: string;
90
+ /** Neutral assertions the adapter is willing to make about the refusal. */
91
+ neutralReasonHints?: readonly PaymentFailureHint[];
92
+ /** The internal reason string already persisted with the attempt, if the failure travelled through the control plane before being classified. */
93
+ failureReasonKey?: string;
94
+ }
95
+
96
+ /** What a retry is allowed to look like for a class. */
97
+ /** @beta */
98
+ export type PaymentRetryProfile = "standard" | "delayed" | "none" | "on_session_only";
99
+
100
+ /**
101
+ * The consequence of a class, as data. This record does not schedule anything and
102
+ * does not know the current ladder; it states what a scheduler is permitted to do.
103
+ */
104
+ /** @beta */
105
+ export interface PaymentFailureDecision {
106
+ retryAllowed: boolean;
107
+ retryProfile: PaymentRetryProfile;
108
+ customerActionRequired: boolean;
109
+ methodReplacementRequired: boolean;
110
+ }
111
+
112
+ const DECISIONS: Readonly<Record<PaymentFailureClass, PaymentFailureDecision>> = Object.freeze({
113
+ hard_do_not_retry: Object.freeze({
114
+ retryAllowed: false,
115
+ retryProfile: "none",
116
+ customerActionRequired: true,
117
+ methodReplacementRequired: true,
118
+ }),
119
+ mandate_dead: Object.freeze({
120
+ retryAllowed: false,
121
+ retryProfile: "none",
122
+ customerActionRequired: true,
123
+ methodReplacementRequired: true,
124
+ }),
125
+ sca_required: Object.freeze({
126
+ retryAllowed: true,
127
+ retryProfile: "on_session_only",
128
+ customerActionRequired: true,
129
+ methodReplacementRequired: false,
130
+ }),
131
+ fix_and_retry_customer_action: Object.freeze({
132
+ retryAllowed: false,
133
+ retryProfile: "none",
134
+ customerActionRequired: true,
135
+ methodReplacementRequired: false,
136
+ }),
137
+ soft_retry_delayed: Object.freeze({
138
+ retryAllowed: true,
139
+ retryProfile: "delayed",
140
+ customerActionRequired: false,
141
+ methodReplacementRequired: false,
142
+ }),
143
+ soft_retryable: Object.freeze({
144
+ retryAllowed: true,
145
+ retryProfile: "standard",
146
+ customerActionRequired: false,
147
+ methodReplacementRequired: false,
148
+ }),
149
+ indeterminate: Object.freeze({
150
+ retryAllowed: true,
151
+ retryProfile: "standard",
152
+ customerActionRequired: false,
153
+ methodReplacementRequired: false,
154
+ }),
155
+ });
156
+
157
+ /**
158
+ * The frozen decision for a class. Frozen so a consumer cannot quietly widen
159
+ * `retryAllowed` for one call site and leave every other reader believing the
160
+ * table still holds.
161
+ */
162
+ /** @beta */
163
+ export function failureClassDecision(failureClass: PaymentFailureClass): PaymentFailureDecision {
164
+ return DECISIONS[failureClass];
165
+ }
166
+
167
+ /**
168
+ * What a payer can be TOLD about a refused or impossible charge.
169
+ *
170
+ * Coarser than {@link PAYMENT_FAILURE_CLASSES} on purpose: a payer acts on
171
+ * remedies and there are fewer remedies than classes. Every class whose real
172
+ * content is "we do not know why" collapses into `unknown`, which exists so a
173
+ * caller can say nothing rather than guess — a surface rendering `unknown` must
174
+ * render exactly what it rendered before consulting this vocabulary.
175
+ *
176
+ * `method_missing` is the one cause no class produces: it belongs to the
177
+ * pre-renewal rails, which read a stored method that does not exist rather than a
178
+ * refusal that happened. It lives here so both kinds of surface quote one
179
+ * vocabulary instead of two.
180
+ */
181
+ /** @beta */
182
+ export const PAYMENT_FAILURE_CUSTOMER_CAUSES = [
183
+ /** Authentication is owed; the instrument may be fine, nobody was present. */
184
+ "needs_confirmation",
185
+ /** Stored consent cannot back an unattended charge, whatever the instrument. */
186
+ "method_cannot_recur",
187
+ /** The instrument is finished; another attempt on it cannot succeed. */
188
+ "method_dead",
189
+ /** Stored details rejected as wrong, not refused as unaffordable. */
190
+ "method_data_invalid",
191
+ /** No usable stored method. Pre-renewal state only, never a refusal. */
192
+ "method_missing",
193
+ /** Nothing may be claimed. Surfaces must add no sentence at all. */
194
+ "unknown",
195
+ ] as const;
196
+ /** @beta */
197
+ export type PaymentFailureCustomerCause = (typeof PAYMENT_FAILURE_CUSTOMER_CAUSES)[number];
198
+
199
+ const CUSTOMER_CAUSES: Readonly<Record<PaymentFailureClass, PaymentFailureCustomerCause>> =
200
+ Object.freeze({
201
+ hard_do_not_retry: "method_dead",
202
+ mandate_dead: "method_cannot_recur",
203
+ sca_required: "needs_confirmation",
204
+ fix_and_retry_customer_action: "method_data_invalid",
205
+ // The soft readings say only that a retry is allowed, which is not a cause
206
+ // anybody can act on; `indeterminate` says outright that nothing is known.
207
+ // Naming a cause here would invent an issuer verdict.
208
+ soft_retry_delayed: "unknown",
209
+ soft_retryable: "unknown",
210
+ indeterminate: "unknown",
211
+ });
212
+
213
+ /**
214
+ * The cause a payer may be told, for a class that may not have been recorded.
215
+ *
216
+ * Takes `string | null` rather than {@link PaymentFailureClass} on purpose: the
217
+ * caller reads a nullable column written by an older deployment, and an
218
+ * unrecognized value must degrade to `unknown` exactly like a missing one.
219
+ */
220
+ /** @beta */
221
+ export function customerCauseForFailureClass(
222
+ failureClass: string | null | undefined,
223
+ ): PaymentFailureCustomerCause {
224
+ if (failureClass === null || failureClass === undefined) return "unknown";
225
+ return Object.prototype.hasOwnProperty.call(CUSTOMER_CAUSES, failureClass)
226
+ ? CUSTOMER_CAUSES[failureClass as PaymentFailureClass]
227
+ : "unknown";
228
+ }
@@ -0,0 +1,95 @@
1
+ import { isLiveAttemptStatus, type PaymentAttemptStatus } from "./paymentControlTypes.js";
2
+ import type {
3
+ PaymentRecoveryAction, PaymentRecoveryCandidate, PaymentRecoveryEvidence,
4
+ PaymentRecoveryOperation,
5
+ } from "./paymentRecoveryContracts.js";
6
+
7
+ /** @beta */
8
+ export interface PaymentRecoveryAttempt {
9
+ id: string;
10
+ status: PaymentAttemptStatus;
11
+ evidence: PaymentRecoveryEvidence | null;
12
+ }
13
+ /** @beta */
14
+ export interface PaymentRecoveryGuidance extends PaymentRecoveryEvidence {
15
+ version: 1;
16
+ paymentAttemptId: string;
17
+ consecutiveRefusals: 0 | 1 | 2 | null;
18
+ emphasis: "normal" | "recommended";
19
+ actions: readonly PaymentRecoveryAction[];
20
+ }
21
+
22
+ /** Only an authoritative failed attempt can become refusal guidance. @beta */
23
+ export function derivePaymentRecoveryGuidance(input: {
24
+ paymentState: "failed" | "paid" | "pending" | "expired";
25
+ activeAttemptId: string | null;
26
+ /** Newest first, ordered by durable creation identity; never browser clicks. */
27
+ attempts: readonly PaymentRecoveryAttempt[];
28
+ historyComplete: boolean;
29
+ }): PaymentRecoveryGuidance | null {
30
+ if (input.paymentState !== "failed" || !input.activeAttemptId) return null;
31
+ if (input.attempts.some((attempt) => attempt.status === "succeeded")) return null;
32
+ const current = input.attempts.find((attempt) => attempt.id === input.activeAttemptId);
33
+ if (!current || current.status !== "failed" || !current.evidence?.refusalVerified) return null;
34
+ // A later local attempt makes this a stale observation even if a caller picked it.
35
+ if (input.attempts[0]?.id !== current.id) return null;
36
+ const evidence = safeEvidence(current.evidence);
37
+ const count = evidence.cause !== "generic_decline" ? 0
38
+ : refusalStreak(input.attempts, evidence.method?.recoveryMethodKey, input.historyComplete);
39
+ const actions: PaymentRecoveryAction[] = evidence.cause === "expired_card"
40
+ ? ["change_instrument", "change_method"]
41
+ : evidence.cause === "invalid_payment_data" ? ["correct_data", "change_method"]
42
+ : evidence.cause === "authentication_required" ? ["authenticate", "change_method"]
43
+ : ["change_instrument", "change_method"];
44
+ if (evidence.advice?.code === "do_not_try_again" && evidence.advice.scope === "method") {
45
+ actions.splice(0, actions.length, "change_method");
46
+ }
47
+ return { ...evidence, version: 1, paymentAttemptId: current.id,
48
+ consecutiveRefusals: count, emphasis: count === 2 ? "recommended" : "normal", actions };
49
+ }
50
+
51
+ function safeEvidence(evidence: PaymentRecoveryEvidence): PaymentRecoveryEvidence {
52
+ return evidence.disclosure === "restricted" || evidence.certainty !== "verified"
53
+ ? { ...evidence, cause: "generic_decline", certainty: "unknown" } : evidence;
54
+ }
55
+
56
+ function refusalStreak(
57
+ attempts: readonly PaymentRecoveryAttempt[], methodKey: string | undefined, complete: boolean,
58
+ ): 1 | 2 | null {
59
+ if (!methodKey) return null;
60
+ let count = 0;
61
+ const seen = new Set<string>();
62
+ for (const attempt of attempts) {
63
+ if (seen.has(attempt.id)) continue;
64
+ seen.add(attempt.id);
65
+ if (isLiveAttemptStatus(attempt.status) || ["blocked_preflight", "cancelled", "expired"].includes(attempt.status)) continue;
66
+ const evidence = attempt.evidence;
67
+ if (!evidence?.refusalVerified || !evidence.method) return null;
68
+ if (safeEvidence(evidence).cause !== "generic_decline" || evidence.method.recoveryMethodKey !== methodKey) {
69
+ return count > 0 ? 1 : null;
70
+ }
71
+ count += 1;
72
+ if (count >= 2) return 2;
73
+ }
74
+ return complete && count === 1 ? 1 : null;
75
+ }
76
+
77
+ /** Filter with currently implemented, eligible choices; this never enables a rail. @beta */
78
+ export function availablePaymentRecoveryActions(
79
+ guidance: PaymentRecoveryGuidance,
80
+ candidates: readonly PaymentRecoveryCandidate[],
81
+ operation: PaymentRecoveryOperation,
82
+ ): PaymentRecoveryAction[] {
83
+ const available = new Set<PaymentRecoveryAction>();
84
+ for (const candidate of candidates) {
85
+ if (candidate.capability !== "supported" || !candidate.available || candidate.operation !== operation) continue;
86
+ for (const action of candidate.actions) {
87
+ if (!guidance.actions.includes(action)) continue;
88
+ const sameChoice = candidate.method.recoveryMethodKey === guidance.method?.recoveryMethodKey;
89
+ if (action === "change_method" && sameChoice) continue;
90
+ if (action === "change_instrument" && (!sameChoice || candidate.method.interaction !== "new_instrument")) continue;
91
+ available.add(action);
92
+ }
93
+ }
94
+ return available.size ? [...available] : ["contact_support"];
95
+ }
@@ -0,0 +1,61 @@
1
+ /** Checkout guidance describes evidence; it never authorizes another charge. */
2
+ import type { PaymentRetryAdviceCode } from "./paymentFailureTaxonomyContracts.js";
3
+
4
+ /** @beta */
5
+ export const PAYMENT_RECOVERY_CAUSES = [
6
+ "generic_decline", "insufficient_funds", "expired_card", "invalid_payment_data",
7
+ "authentication_required", "operation_unsupported", "recurring_setup_failed",
8
+ ] as const;
9
+ /** @beta */
10
+ export type PaymentRecoveryCause = (typeof PAYMENT_RECOVERY_CAUSES)[number];
11
+ /** @beta */
12
+ export const PAYMENT_RECOVERY_ACTIONS = [
13
+ "change_method", "change_instrument", "correct_data", "authenticate",
14
+ "check_status", "review_order", "contact_support", "retry",
15
+ ] as const;
16
+ /** @beta */
17
+ export type PaymentRecoveryAction = (typeof PAYMENT_RECOVERY_ACTIONS)[number];
18
+ /** @beta */
19
+ export type PaymentRecoveryOperation =
20
+ "one_time_payment" | "recurring_setup" | "stored_method_payment";
21
+ /** @beta */
22
+ export interface PaymentRecoveryMethod {
23
+ /** A method identity, never an operator identity or an instrument fingerprint. */
24
+ kind: string;
25
+ /** Canonical visible choice; changing operator alone cannot change this key. */
26
+ recoveryMethodKey: string;
27
+ interaction: "new_instrument" | "stored_instrument";
28
+ }
29
+ /** @beta */
30
+ export interface PaymentRecoveryAdvice {
31
+ code: PaymentRetryAdviceCode;
32
+ scope: "instrument" | "method" | "operation" | "unknown";
33
+ }
34
+ /** @beta */
35
+ export interface PaymentRecoveryEvidence {
36
+ /** Established refusal, not merely an unconfirmed or locally failed request. */
37
+ refusalVerified: boolean;
38
+ cause: PaymentRecoveryCause;
39
+ certainty: "verified" | "unknown";
40
+ disclosure: "safe" | "restricted";
41
+ method: PaymentRecoveryMethod | null;
42
+ operation: PaymentRecoveryOperation | null;
43
+ advice: PaymentRecoveryAdvice | null;
44
+ }
45
+ /** @beta */
46
+ export interface PaymentRecoveryCandidate {
47
+ method: PaymentRecoveryMethod;
48
+ operation: PaymentRecoveryOperation;
49
+ capability: "supported" | "unsupported" | "unknown";
50
+ /** Resolved deployment, merchant and client eligibility, not operator marketing. */
51
+ available: boolean;
52
+ actions: readonly PaymentRecoveryAction[];
53
+ }
54
+ /**
55
+ * Companion to execution capabilities. A caller injects provider normalizers;
56
+ * the kernel sees only their neutral outputs. An absent normalizer adds no facts.
57
+ * @beta
58
+ */
59
+ export interface PaymentRecoveryNormalizer<TInput = unknown> {
60
+ normalize(input: TInput): PaymentRecoveryEvidence | null;
61
+ }