@openlup/platform 0.20.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (210) hide show
  1. package/CHANGELOG.md +62 -231
  2. package/README.md +172 -6
  3. package/dist/composition/composeBundle.d.ts +6 -0
  4. package/dist/composition/composeBundle.js +2 -0
  5. package/dist/composition/index.js +1 -0
  6. package/dist/payments/adapterConformance.d.ts +8 -0
  7. package/dist/payments/adapterConformance.js +109 -0
  8. package/dist/payments/bindings.d.ts +3 -0
  9. package/dist/payments/bindings.js +17 -0
  10. package/dist/payments/checkoutActivePaymentActionResolver.d.ts +7 -0
  11. package/dist/payments/checkoutActivePaymentActionResolver.js +77 -0
  12. package/dist/payments/checkoutRecoveryPaymentResolver.d.ts +8 -0
  13. package/dist/payments/checkoutRecoveryPaymentResolver.js +87 -0
  14. package/dist/payments/contracts/adapter.d.ts +113 -0
  15. package/dist/payments/contracts/adapter.js +1 -0
  16. package/dist/payments/contracts/conformance.d.ts +155 -0
  17. package/dist/payments/contracts/conformance.js +1 -0
  18. package/dist/payments/contracts/evidence.d.ts +157 -0
  19. package/dist/payments/contracts/evidence.js +1 -0
  20. package/dist/payments/contracts/index.d.ts +10 -0
  21. package/dist/payments/contracts/index.js +1 -0
  22. package/dist/payments/contracts/preparedAttempt.d.ts +69 -0
  23. package/dist/payments/contracts/preparedAttempt.js +1 -0
  24. package/dist/payments/contracts/reconciliation.d.ts +303 -0
  25. package/dist/payments/contracts/reconciliation.js +1 -0
  26. package/dist/payments/contracts/repair.d.ts +92 -0
  27. package/dist/payments/contracts/repair.js +1 -0
  28. package/dist/payments/contracts/truth.d.ts +92 -0
  29. package/dist/payments/contracts/truth.js +1 -0
  30. package/dist/payments/eventConformanceRunner.d.ts +12 -0
  31. package/dist/payments/eventConformanceRunner.js +125 -0
  32. package/dist/payments/index.d.ts +16 -0
  33. package/dist/payments/index.js +16 -0
  34. package/dist/payments/paymentAttemptSilenceWindow.d.ts +10 -0
  35. package/dist/payments/paymentAttemptSilenceWindow.js +19 -0
  36. package/dist/payments/paymentMethodLifecycle.d.ts +11 -0
  37. package/dist/payments/paymentMethodLifecycle.js +37 -0
  38. package/dist/payments/paymentProviderReconciliationAbsenceReopen.d.ts +7 -0
  39. package/dist/payments/paymentProviderReconciliationAbsenceReopen.js +100 -0
  40. package/dist/payments/paymentProviderReconciliationEvidence.d.ts +36 -0
  41. package/dist/payments/paymentProviderReconciliationEvidence.js +91 -0
  42. package/dist/payments/paymentProviderReconciliationWorker.d.ts +4 -0
  43. package/dist/payments/paymentProviderReconciliationWorker.js +245 -0
  44. package/dist/payments/paymentTruth.d.ts +8 -0
  45. package/dist/payments/paymentTruth.js +125 -0
  46. package/dist/payments/paymentVerifyNowHandler.d.ts +27 -0
  47. package/dist/payments/paymentVerifyNowHandler.js +70 -0
  48. package/dist/payments/paymentVerifyNowService.d.ts +23 -0
  49. package/dist/payments/paymentVerifyNowService.js +93 -0
  50. package/dist/payments/paymentWebhookPostProcessing.d.ts +22 -0
  51. package/dist/payments/paymentWebhookPostProcessing.js +15 -0
  52. package/dist/payments/postgresPaymentTruth.d.ts +10 -0
  53. package/dist/payments/postgresPaymentTruth.js +126 -0
  54. package/dist/payments/preparedAttempt.d.ts +6 -0
  55. package/dist/payments/preparedAttempt.js +70 -0
  56. package/dist/payments/preparedAttemptFinalization.d.ts +5 -0
  57. package/dist/payments/preparedAttemptFinalization.js +12 -0
  58. package/dist/payments/schemaCapabilities.d.ts +10 -0
  59. package/dist/payments/schemaCapabilities.js +10 -0
  60. package/dist/payments/sql.d.ts +20 -0
  61. package/dist/payments/sql.js +6 -0
  62. package/dist/payments/stripe/classifyDecline.d.ts +46 -0
  63. package/dist/payments/stripe/classifyDecline.js +58 -0
  64. package/dist/payments/stripe/declineFailureHints.d.ts +46 -0
  65. package/dist/payments/stripe/declineFailureHints.js +87 -0
  66. package/dist/payments/stripe/donorContracts.d.ts +120 -0
  67. package/dist/payments/stripe/donorContracts.js +1 -0
  68. package/dist/payments/stripe/executionDecline.d.ts +53 -0
  69. package/dist/payments/stripe/executionDecline.js +107 -0
  70. package/dist/payments/stripe/index.d.ts +16 -0
  71. package/dist/payments/stripe/index.js +239 -0
  72. package/dist/payments/stripe/intentMapper.d.ts +26 -0
  73. package/dist/payments/stripe/intentMapper.js +51 -0
  74. package/dist/payments/stripe/methodFacts.d.ts +10 -0
  75. package/dist/payments/stripe/methodFacts.js +12 -0
  76. package/dist/payments/stripe/paymentFailureEvidence.d.ts +26 -0
  77. package/dist/payments/stripe/paymentFailureEvidence.js +33 -0
  78. package/dist/payments/stripe/stripeFailureEvidence.d.ts +41 -0
  79. package/dist/payments/stripe/stripeFailureEvidence.js +82 -0
  80. package/dist/payments/stripe/stripeIntentConfirmationEvidence.d.ts +28 -0
  81. package/dist/payments/stripe/stripeIntentConfirmationEvidence.js +32 -0
  82. package/dist/payments/stripe/stripePaymentReconciliationProvider.d.ts +35 -0
  83. package/dist/payments/stripe/stripePaymentReconciliationProvider.js +222 -0
  84. package/dist/payments/stripe/stripeRefundEvidence.d.ts +8 -0
  85. package/dist/payments/stripe/stripeRefundEvidence.js +17 -0
  86. package/dist/payments/stripe/stripeSandboxPaymentExecutionAdapter.d.ts +45 -0
  87. package/dist/payments/stripe/stripeSandboxPaymentExecutionAdapter.js +220 -0
  88. package/dist/payments/stripe/stripeWebhookNormalizer.d.ts +14 -0
  89. package/dist/payments/stripe/stripeWebhookNormalizer.js +243 -0
  90. package/dist/payments/stripe/webhookSignature.d.ts +14 -0
  91. package/dist/payments/stripe/webhookSignature.js +43 -0
  92. package/dist/payments/tpay/blikRecurringMapper.d.ts +37 -0
  93. package/dist/payments/tpay/blikRecurringMapper.js +24 -0
  94. package/dist/payments/tpay/capabilities.d.ts +3 -0
  95. package/dist/payments/tpay/capabilities.js +18 -0
  96. package/dist/payments/tpay/classifyDecline.d.ts +46 -0
  97. package/dist/payments/tpay/classifyDecline.js +58 -0
  98. package/dist/payments/tpay/declineFailureHints.d.ts +72 -0
  99. package/dist/payments/tpay/declineFailureHints.js +37 -0
  100. package/dist/payments/tpay/donorContracts.d.ts +140 -0
  101. package/dist/payments/tpay/donorContracts.js +1 -0
  102. package/dist/payments/tpay/index.d.ts +43 -0
  103. package/dist/payments/tpay/index.js +264 -0
  104. package/dist/payments/tpay/paymentFailureEvidence.d.ts +26 -0
  105. package/dist/payments/tpay/paymentFailureEvidence.js +33 -0
  106. package/dist/payments/tpay/preparedProviderAttempt.d.ts +22 -0
  107. package/dist/payments/tpay/preparedProviderAttempt.js +24 -0
  108. package/dist/payments/tpay/providerAttemptFailureDiagnostic.d.ts +3 -0
  109. package/dist/payments/tpay/providerAttemptFailureDiagnostic.js +95 -0
  110. package/dist/payments/tpay/tpayCreateTransactionInput.d.ts +25 -0
  111. package/dist/payments/tpay/tpayCreateTransactionInput.js +128 -0
  112. package/dist/payments/tpay/tpayDeclineEvidence.d.ts +126 -0
  113. package/dist/payments/tpay/tpayDeclineEvidence.js +177 -0
  114. package/dist/payments/tpay/tpayDispatchFailure.d.ts +60 -0
  115. package/dist/payments/tpay/tpayDispatchFailure.js +121 -0
  116. package/dist/payments/tpay/tpayFailureEvidence.d.ts +20 -0
  117. package/dist/payments/tpay/tpayFailureEvidence.js +61 -0
  118. package/dist/payments/tpay/tpayHttpClient.d.ts +52 -0
  119. package/dist/payments/tpay/tpayHttpClient.js +185 -0
  120. package/dist/payments/tpay/tpayHttpFailure.d.ts +57 -0
  121. package/dist/payments/tpay/tpayHttpFailure.js +143 -0
  122. package/dist/payments/tpay/tpayPaymentExecutionAdapter.d.ts +20 -0
  123. package/dist/payments/tpay/tpayPaymentExecutionAdapter.js +249 -0
  124. package/dist/payments/tpay/tpayPaymentReconciliationProvider.d.ts +5 -0
  125. package/dist/payments/tpay/tpayPaymentReconciliationProvider.js +244 -0
  126. package/dist/payments/tpay/tpayTransactionPayload.d.ts +120 -0
  127. package/dist/payments/tpay/tpayTransactionPayload.js +96 -0
  128. package/dist/payments/tpay/wallClockTime.d.ts +31 -0
  129. package/dist/payments/tpay/wallClockTime.js +57 -0
  130. package/dist/payments/tpay/webhookJwsVerifier.d.ts +17 -0
  131. package/dist/payments/tpay/webhookJwsVerifier.js +51 -0
  132. package/dist/payments/tpay/webhookNormalizer.d.ts +54 -0
  133. package/dist/payments/tpay/webhookNormalizer.js +200 -0
  134. package/dist/payments/verifiedEvidenceProjection.d.ts +12 -0
  135. package/dist/payments/verifiedEvidenceProjection.js +166 -0
  136. package/dist/payments/verifiedIngress.d.ts +119 -0
  137. package/dist/payments/verifiedIngress.js +220 -0
  138. package/package.json +22 -2
  139. package/release-gates.json +64 -3
  140. package/sql/20260810180000_payment_settlement_rail.sql +574 -0
  141. package/sql/20260815150000_payment_truth_reconciliation.sql +479 -0
  142. package/sql/20260826100000_late_payment_shifts_the_subscription_cycle.sql +215 -0
  143. package/src/composition/composeBundle.ts +9 -0
  144. package/src/composition/index.ts +1 -0
  145. package/src/payments/adapterConformance.ts +118 -0
  146. package/src/payments/bindings.ts +17 -0
  147. package/src/payments/checkoutActivePaymentActionResolver.ts +97 -0
  148. package/src/payments/checkoutRecoveryPaymentResolver.ts +108 -0
  149. package/src/payments/contracts/adapter.ts +96 -0
  150. package/src/payments/contracts/conformance.ts +171 -0
  151. package/src/payments/contracts/evidence.ts +142 -0
  152. package/src/payments/contracts/index.ts +54 -0
  153. package/src/payments/contracts/preparedAttempt.ts +86 -0
  154. package/src/payments/contracts/reconciliation.ts +296 -0
  155. package/src/payments/contracts/repair.ts +95 -0
  156. package/src/payments/contracts/truth.ts +108 -0
  157. package/src/payments/eventConformanceRunner.ts +149 -0
  158. package/src/payments/index.ts +21 -0
  159. package/src/payments/paymentAttemptSilenceWindow.ts +26 -0
  160. package/src/payments/paymentMethodLifecycle.ts +50 -0
  161. package/src/payments/paymentProviderReconciliationAbsenceReopen.ts +130 -0
  162. package/src/payments/paymentProviderReconciliationEvidence.ts +117 -0
  163. package/src/payments/paymentProviderReconciliationWorker.ts +291 -0
  164. package/src/payments/paymentTruth.ts +131 -0
  165. package/src/payments/paymentVerifyNowHandler.ts +94 -0
  166. package/src/payments/paymentVerifyNowService.ts +125 -0
  167. package/src/payments/paymentWebhookPostProcessing.ts +36 -0
  168. package/src/payments/postgresPaymentTruth.ts +149 -0
  169. package/src/payments/preparedAttempt.ts +90 -0
  170. package/src/payments/preparedAttemptFinalization.ts +13 -0
  171. package/src/payments/schemaCapabilities.ts +10 -0
  172. package/src/payments/sql.ts +6 -0
  173. package/src/payments/stripe/classifyDecline.ts +62 -0
  174. package/src/payments/stripe/declineFailureHints.ts +89 -0
  175. package/src/payments/stripe/donorContracts.ts +87 -0
  176. package/src/payments/stripe/executionDecline.ts +126 -0
  177. package/src/payments/stripe/index.ts +235 -0
  178. package/src/payments/stripe/intentMapper.ts +58 -0
  179. package/src/payments/stripe/methodFacts.ts +21 -0
  180. package/src/payments/stripe/paymentFailureEvidence.ts +58 -0
  181. package/src/payments/stripe/stripeFailureEvidence.ts +115 -0
  182. package/src/payments/stripe/stripeIntentConfirmationEvidence.ts +34 -0
  183. package/src/payments/stripe/stripePaymentReconciliationProvider.ts +263 -0
  184. package/src/payments/stripe/stripeRefundEvidence.ts +19 -0
  185. package/src/payments/stripe/stripeSandboxPaymentExecutionAdapter.ts +283 -0
  186. package/src/payments/stripe/stripeWebhookNormalizer.ts +277 -0
  187. package/src/payments/stripe/webhookSignature.ts +55 -0
  188. package/src/payments/tpay/blikRecurringMapper.ts +42 -0
  189. package/src/payments/tpay/capabilities.ts +21 -0
  190. package/src/payments/tpay/classifyDecline.ts +62 -0
  191. package/src/payments/tpay/declineFailureHints.ts +90 -0
  192. package/src/payments/tpay/donorContracts.ts +104 -0
  193. package/src/payments/tpay/index.ts +257 -0
  194. package/src/payments/tpay/paymentFailureEvidence.ts +58 -0
  195. package/src/payments/tpay/preparedProviderAttempt.ts +38 -0
  196. package/src/payments/tpay/providerAttemptFailureDiagnostic.ts +99 -0
  197. package/src/payments/tpay/tpayCreateTransactionInput.ts +158 -0
  198. package/src/payments/tpay/tpayDeclineEvidence.ts +233 -0
  199. package/src/payments/tpay/tpayDispatchFailure.ts +186 -0
  200. package/src/payments/tpay/tpayFailureEvidence.ts +75 -0
  201. package/src/payments/tpay/tpayHttpClient.ts +290 -0
  202. package/src/payments/tpay/tpayHttpFailure.ts +186 -0
  203. package/src/payments/tpay/tpayPaymentExecutionAdapter.ts +297 -0
  204. package/src/payments/tpay/tpayPaymentReconciliationProvider.ts +285 -0
  205. package/src/payments/tpay/tpayTransactionPayload.ts +207 -0
  206. package/src/payments/tpay/wallClockTime.ts +57 -0
  207. package/src/payments/tpay/webhookJwsVerifier.ts +82 -0
  208. package/src/payments/tpay/webhookNormalizer.ts +279 -0
  209. package/src/payments/verifiedEvidenceProjection.ts +179 -0
  210. package/src/payments/verifiedIngress.ts +317 -0
@@ -0,0 +1,263 @@
1
+ import { failureEvidencePayload } from "./paymentFailureEvidence.js";
2
+ import type {
3
+ ProviderReconciliationStatus,
4
+ } from "./donorContracts.js";
5
+ import type {
6
+ PaymentProviderRecoveryProvider,
7
+ ProviderRecoveryAction,
8
+ } from "./donorContracts.js";
9
+ import { classifyDecline } from "./classifyDecline.js";
10
+ import { declineCodeHints } from "./declineFailureHints.js";
11
+ import { noIssuerWasAsked } from "./stripeIntentConfirmationEvidence.js";
12
+ import type { StripeIntentLike } from "./stripeSandboxPaymentExecutionAdapter.js";
13
+
14
+ export interface StripePaymentIntentReader {
15
+ retrievePaymentIntent(paymentIntentId: string): Promise<StripeIntentLike>;
16
+ cancelPaymentIntent?(paymentIntentId: string): Promise<StripeIntentLike>;
17
+ }
18
+
19
+ export interface StripePaymentIntentSearcher {
20
+ /**
21
+ * Search Stripe PaymentIntents by a metadata key/value pair (GET
22
+ * /v1/payment_intents/search). Every intent the payment-control adapter
23
+ * creates carries metadata.paymentIntentId = <local intent uuid>
24
+ * (stripeSandboxPaymentExecutionAdapter metadataFor), so this is the
25
+ * intent-correlated absence probe for prepared attempts that never recorded
26
+ * a provider payment id.
27
+ */
28
+ searchPaymentIntentsByMetadata(input: {
29
+ key: string;
30
+ value: string;
31
+ limit?: number;
32
+ }): Promise<{ ids: string[] }>;
33
+ }
34
+
35
+ export function createStripePaymentReconciliationProvider(
36
+ client: StripePaymentIntentReader & Partial<StripePaymentIntentSearcher>,
37
+ ): PaymentProviderRecoveryProvider {
38
+ const search = client.searchPaymentIntentsByMetadata?.bind(client);
39
+ const cancel = client.cancelPaymentIntent?.bind(client);
40
+ return {
41
+ resolvePaymentReference(input) {
42
+ return input.providerAttemptId ?? input.intentProviderPaymentId ?? input.providerSessionId;
43
+ },
44
+ async readRecoveryPayment(input) {
45
+ const intent = await client.retrievePaymentIntent(input.providerPaymentId);
46
+ let status = normalizeStripeIntent(intent);
47
+ let clientAction: ProviderRecoveryAction | null = null;
48
+ if (intent.status === "processing" || intent.status === "requires_capture") {
49
+ clientAction = null;
50
+ } else if (input.purpose === "active_checkout" && noIssuerWasAsked(intent)) {
51
+ clientAction = embeddedAction(intent.client_secret);
52
+ // The status is lowered whether or not a form can be handed back. Our
53
+ // inability to reopen the card panel is a fact about US; it is not an
54
+ // issuer's refusal, and it must not be recorded as one.
55
+ status = { ...status, status: "pending" };
56
+ } else if (intent.status === "requires_action" || intent.status === "requires_confirmation") {
57
+ clientAction = embeddedAction(intent.client_secret);
58
+ // Global reconciliation treats requires_action as terminal for
59
+ // off-session dunning. Buyer recovery is different: without a current
60
+ // client secret it is uncertain, not permission to open a new intent.
61
+ if (!clientAction || input.purpose === "active_checkout") {
62
+ status = { ...status, status: "pending" };
63
+ }
64
+ }
65
+ return {
66
+ status,
67
+ identityMatches: intent.id === input.providerPaymentId,
68
+ configuredMoneyMatches: stripeConfiguredMoneyMatches(intent, input.attempt),
69
+ manualReviewRequired: false,
70
+ clientAction,
71
+ };
72
+ },
73
+ async readPayment(input) {
74
+ const intent = await client.retrievePaymentIntent(input.providerPaymentId);
75
+ const status = normalizeStripeIntent(intent);
76
+ // Same rule as the recovery read above, on the rail the buyer's own
77
+ // "check it now" travels: an intent no issuer has been asked about is
78
+ // unsettled, not refused. Only `active_checkout` gets this reading, so
79
+ // the reconciliation worker — which names no purpose — goes on
80
+ // terminalizing abandonment exactly as before.
81
+ return input.purpose === "active_checkout" && noIssuerWasAsked(intent)
82
+ ? { ...status, status: "pending" }
83
+ : status;
84
+ },
85
+ ...(cancel
86
+ ? {
87
+ async closePayment(input): Promise<ProviderReconciliationStatus> {
88
+ return normalizeStripeIntent(await cancel(input.providerPaymentId));
89
+ },
90
+ }
91
+ : {}),
92
+ ...(search
93
+ ? {
94
+ async findPaymentByLocalIntent(input): Promise<"found" | "absent"> {
95
+ const result = await search({
96
+ key: "paymentIntentId",
97
+ value: input.attempt.paymentIntentId,
98
+ limit: 1,
99
+ });
100
+ return result.ids.length > 0 ? "found" : "absent";
101
+ },
102
+ }
103
+ : {}),
104
+ };
105
+ }
106
+
107
+ export function normalizeStripeIntent(intent: StripeIntentLike): ProviderReconciliationStatus {
108
+ const status = normalizeStripeStatus(intent.status);
109
+ return {
110
+ status,
111
+ providerStatus: intent.status,
112
+ occurredAt: null,
113
+ failureReason: failureReason(intent),
114
+ amountMinor: readAmountMinor(intent),
115
+ currency: typeof intent.currency === "string" ? intent.currency.toUpperCase() : null,
116
+ rawPayload: {
117
+ provider: "stripe",
118
+ paymentIntentId: intent.id,
119
+ status: intent.status,
120
+ latestChargePresent: Boolean(intent.latest_charge),
121
+ nextActionType: intent.next_action?.type ?? null,
122
+ lastPaymentErrorCode: intent.last_payment_error?.code ?? null,
123
+ lastPaymentErrorType: intent.last_payment_error?.type ?? null,
124
+ ...failureCapture(intent),
125
+ ...failureEvidencePayload(intent.diagnosticFailureEvidence),
126
+ },
127
+ };
128
+ }
129
+
130
+ /**
131
+ * The disposition of a failed intent nobody actually refused.
132
+ *
133
+ * Deliberately NOT a taxonomy class. The kernel classifies REFUSALS — it answers
134
+ * "may this instrument be charged again, and by whom" — and an absence of
135
+ * attempt is not a refusal, so an "abandonment" class would be a category error
136
+ * inside a vocabulary whose every other member describes an issuer's verdict.
137
+ */
138
+ export const ABANDONED_BEFORE_CONFIRMATION = "abandoned_before_confirmation";
139
+
140
+ /**
141
+ * `requires_payment_method` on an intent that was never confirmed: no
142
+ * `last_payment_error`, no charge. The buyer closed the tab; no issuer was ever
143
+ * asked. Six of the fifteen production failure rows in the historical corpus are
144
+ * this state.
145
+ *
146
+ * Both absences are required before the claim is made. Presence of either is
147
+ * evidence an attempt happened, and mislabelling a real decline as abandonment
148
+ * would silently drop it out of dunning — the more expensive of the two errors.
149
+ */
150
+ function abandonedBeforeConfirmation(intent: StripeIntentLike): boolean {
151
+ return intent.status === "requires_payment_method"
152
+ && !intent.last_payment_error
153
+ && !intent.latest_charge;
154
+ }
155
+
156
+ function embeddedAction(clientSecret: unknown): ProviderRecoveryAction | null {
157
+ return typeof clientSecret === "string" && clientSecret.length > 0
158
+ ? { kind: "provider_embedded", provider: "stripe", clientSecret }
159
+ : null;
160
+ }
161
+
162
+ /**
163
+ * The refusal evidence this rail can state, and the class it produces.
164
+ *
165
+ * This rail births most production failure rows, and it used to keep only
166
+ * `last_payment_error.code` — the coarse bucket ("card_declined") — while
167
+ * dropping `decline_code`, the fine code the adapter's hint table is written
168
+ * against, and `advice_code`, the issuer's own retry verdict. With both gone the
169
+ * taxonomy had nothing to read here, so the programme's classification telemetry
170
+ * stayed empty no matter how many refusals arrived.
171
+ *
172
+ * Classification goes through the shared `classifyDecline` seam, the same one
173
+ * the synchronous decline path uses, so there is exactly one place a refusal
174
+ * becomes a class. Only neutral evidence crosses: this provider's own code
175
+ * vocabulary is translated here, by this adapter's table, and travels no further.
176
+ *
177
+ * The class does not stop here. Since wave 3i-b it reaches the attempt and the
178
+ * dunning case through the terminal write's two classification parameters, and
179
+ * from there it becomes the cause sentence the payer is shown. It remains no
180
+ * part of any retry cadence.
181
+ */
182
+ function failureCapture(intent: StripeIntentLike): Record<string, unknown> {
183
+ if (normalizeStripeStatus(intent.status) !== "failed") return {};
184
+ if (abandonedBeforeConfirmation(intent)) {
185
+ return { nonDeclineDisposition: ABANDONED_BEFORE_CONFIRMATION };
186
+ }
187
+ const declineCode = readCode(intent.last_payment_error?.decline_code);
188
+ const adviceCode = readCode(intent.last_payment_error?.advice_code);
189
+ const neutralReasonHints = declineCodeHints(declineCode);
190
+ const classification = classifyDecline({
191
+ // Correlation handle only — the kernel never reads it. The intent's own
192
+ // status is the provider-native fact when the refusal named no code.
193
+ code: readCode(intent.last_payment_error?.code) ?? intent.status,
194
+ // A read of an existing intent says nothing about whether the payer's bank
195
+ // can hold a reusable mandate, so this rail never claims it.
196
+ mandateUnsupported: false,
197
+ ...(declineCode ? { declineCode } : {}),
198
+ ...(adviceCode ? { adviceCode } : {}),
199
+ ...(neutralReasonHints.length > 0 ? { neutralReasonHints } : {}),
200
+ }, { failureReasonKey: failureReason(intent) ?? undefined });
201
+ return {
202
+ declineCode: declineCode ?? null,
203
+ adviceCode: adviceCode ?? null,
204
+ neutralReasonHints,
205
+ failureClass: classification.failureClass,
206
+ failureClassDecidedBy: classification.decidedBy,
207
+ };
208
+ }
209
+
210
+ function readCode(value: string | null | undefined): string | undefined {
211
+ return typeof value === "string" && value.length > 0 ? value : undefined;
212
+ }
213
+
214
+ function stripeConfiguredMoneyMatches(
215
+ intent: StripeIntentLike,
216
+ attempt: { amountMinor: number; currency: string },
217
+ ): boolean {
218
+ return typeof intent.amount === "number"
219
+ && Number.isFinite(intent.amount)
220
+ && intent.amount === attempt.amountMinor
221
+ && typeof intent.currency === "string"
222
+ && intent.currency.toUpperCase() === attempt.currency.toUpperCase();
223
+ }
224
+
225
+ function normalizeStripeStatus(status: string): ProviderReconciliationStatus["status"] {
226
+ if (status === "succeeded") return "succeeded";
227
+ if (
228
+ status === "requires_action" ||
229
+ status === "requires_confirmation" ||
230
+ status === "requires_payment_method" ||
231
+ status === "canceled"
232
+ ) {
233
+ return "failed";
234
+ }
235
+ if (status === "processing" || status === "requires_capture") return "pending";
236
+ return "unknown";
237
+ }
238
+
239
+ function failureReason(intent: StripeIntentLike): string | null {
240
+ const status = normalizeStripeStatus(intent.status);
241
+ if (status !== "failed") return null;
242
+ return intent.last_payment_error?.code
243
+ ? `stripe_${intent.last_payment_error.code}`
244
+ : `stripe_${intent.status}`;
245
+ }
246
+
247
+ function readAmountMinor(intent: StripeIntentLike): number | null {
248
+ // amount_received is settlement truth only after success. For an incomplete,
249
+ // requires-payment-method, cancelled, or requires-capture intent Stripe
250
+ // legitimately returns 0 while `amount` still holds the configured amount.
251
+ // Comparing that zero to the local order created a false mismatch and made
252
+ // reconciliation 502-loop on an ordinary failed payment.
253
+ if (intent.status === "succeeded" && typeof intent.amount_received === "number" && Number.isFinite(intent.amount_received)) {
254
+ return intent.amount_received;
255
+ }
256
+ if (typeof intent.amount === "number" && Number.isFinite(intent.amount)) {
257
+ return intent.amount;
258
+ }
259
+ if (typeof intent.amount_received === "number" && Number.isFinite(intent.amount_received)) {
260
+ return intent.amount_received;
261
+ }
262
+ return null;
263
+ }
@@ -0,0 +1,19 @@
1
+ /** A Charge observation is cumulative; its amount never replaces payment amount. */
2
+ export function stripeRefundEvidence(eventType: string, charge: Record<string, unknown>): {
3
+ refundKind: "full" | "partial" | null;
4
+ snapshot: { chargeId: string; amountRefundedMinor: number } | null;
5
+ } {
6
+ if (eventType !== "charge.refunded") return { refundKind: null, snapshot: null };
7
+ const amount = charge.amount;
8
+ const refunded = charge.amount_refunded;
9
+ const chargeId = charge.id;
10
+ if (typeof chargeId !== "string" || !chargeId.trim()
11
+ || typeof amount !== "number" || !Number.isSafeInteger(amount) || amount <= 0
12
+ || typeof refunded !== "number" || !Number.isSafeInteger(refunded) || refunded <= 0 || refunded > amount) {
13
+ return { refundKind: null, snapshot: null };
14
+ }
15
+ return {
16
+ refundKind: refunded === amount ? "full" : "partial",
17
+ snapshot: { chargeId, amountRefundedMinor: refunded },
18
+ };
19
+ }
@@ -0,0 +1,283 @@
1
+ import { failureEvidencePayload } from "./paymentFailureEvidence.js";
2
+ import { declineFromError, declinedExecutionFacts } from "./executionDecline.js";
3
+ import { noIssuerWasAsked } from "./stripeIntentConfirmationEvidence.js";
4
+ import type { PaymentExecutionPort } from "./donorContracts.js";
5
+ import type {
6
+ CanonicalPaymentEvent,
7
+ PaymentExecutionInput,
8
+ PaymentExecutionResult,
9
+ PaymentNextActionKind,
10
+ PaymentProviderFlow,
11
+ } from "./donorContracts.js";
12
+
13
+ /** The one place this adapter spells its own provider identity. */
14
+ export const PROVIDER_KIND = "stripe" as const;
15
+
16
+ import type { StripeIntentLike } from "./stripeFailureEvidence.js";
17
+ export type { StripeIntentLike } from "./stripeFailureEvidence.js";
18
+
19
+ export interface StripeSandboxClient {
20
+ createPaymentIntent(input: {
21
+ amount: number;
22
+ currency: string;
23
+ customer?: string;
24
+ paymentMethod?: string;
25
+ offSession?: boolean;
26
+ setupFutureUsage?: "off_session";
27
+ metadata: Record<string, string>;
28
+ }, options: { idempotencyKey: string }): Promise<StripeIntentLike>;
29
+ createSetupIntent(input: {
30
+ customer?: string;
31
+ paymentMethod?: string;
32
+ usage: "off_session";
33
+ metadata: Record<string, string>;
34
+ }, options: { idempotencyKey: string }): Promise<StripeIntentLike>;
35
+ /**
36
+ * Ensure-or-create a provider Customer for the client. Required before a
37
+ * `setup_future_usage='off_session'` PaymentIntent (the provider rejects it
38
+ * without a customer). Idempotent via the idempotency key.
39
+ */
40
+ ensureCustomer?(input: {
41
+ clientId: string;
42
+ metadata: Record<string, string>;
43
+ }, options: { idempotencyKey: string }): Promise<string>;
44
+ retrievePaymentIntent?(paymentIntentId: string): Promise<StripeIntentLike>;
45
+ }
46
+
47
+ export interface StripeSandboxAdapterOptions {
48
+ client: StripeSandboxClient;
49
+ defaultFlow?: Extract<
50
+ PaymentProviderFlow,
51
+ "one_time_payment" | "setup_reusable_method" | "off_session_payment"
52
+ >;
53
+ }
54
+
55
+ export function createStripeSandboxPaymentExecutionAdapter({
56
+ client,
57
+ defaultFlow = "one_time_payment",
58
+ }: StripeSandboxAdapterOptions): PaymentExecutionPort {
59
+ return {
60
+ async execute(input: PaymentExecutionInput): Promise<PaymentExecutionResult> {
61
+ const flow = stripeFlow(input.providerFlow ?? defaultFlow);
62
+ // A reusable-mandate PaymentIntent (setup_future_usage) requires a Stripe
63
+ // Customer. Ensure-or-create one for the client when none was passed.
64
+ let customerRef = input.customerRef;
65
+ if (!customerRef && input.saveForFutureUse && input.clientId && client.ensureCustomer) {
66
+ customerRef = await client.ensureCustomer(
67
+ { clientId: input.clientId, metadata: { clientId: input.clientId } },
68
+ // A provider attempt is per click; a Stripe Customer is per OpenLup
69
+ // client. A stable key avoids orphan Customers when checkout recovery
70
+ // is reloaded before the first method webhook stores the reference.
71
+ { idempotencyKey: `openlup:stripe:customer:${input.clientId}` },
72
+ );
73
+ }
74
+ const created = flow === "setup_reusable_method"
75
+ ? await client.createSetupIntent({
76
+ customer: customerRef,
77
+ paymentMethod: input.paymentMethodRef,
78
+ usage: "off_session",
79
+ metadata: metadataFor(input, flow),
80
+ }, { idempotencyKey: input.providerIdempotencyKey })
81
+ : await client.createPaymentIntent({
82
+ amount: input.amountMinor,
83
+ currency: input.currency.toLowerCase(),
84
+ customer: customerRef,
85
+ paymentMethod: input.paymentMethodRef,
86
+ offSession: flow === "off_session_payment" ? true : undefined,
87
+ setupFutureUsage: input.saveForFutureUse ? "off_session" : undefined,
88
+ metadata: metadataFor(input, flow),
89
+ }, { idempotencyKey: input.providerIdempotencyKey }).catch((error: unknown) => {
90
+ // An off-session charge is confirmed inside this call, so the payer's
91
+ // issuer can refuse it here and nowhere else. That refusal is a FACT,
92
+ // not an ambiguous call: rethrowing it makes the renewal orchestrator
93
+ // record an indeterminate outcome, which applies no result, opens no
94
+ // dunning case and tells the customer nothing. Every other error class
95
+ // — network, auth, config, rate limit — keeps throwing, because after
96
+ // those the charge may still have been accepted and only the
97
+ // fail-closed path is safe.
98
+ const decline = flow === "off_session_payment" ? declineFromError(error) : null;
99
+ if (!decline) throw error;
100
+ return decline;
101
+ });
102
+ if ("decline" in created) return { ...executionResultBase(input, flow), ...declinedExecutionFacts(created) };
103
+ const intent = created;
104
+
105
+ return {
106
+ ...executionResultBase(input, flow),
107
+ providerAttemptId: intent.id,
108
+ providerSessionId: intent.id,
109
+ attemptStatus: attemptStatusFromStripe(intent),
110
+ nextActionKind: nextActionKindFromStripe(intent),
111
+ // `clientSecret` is the single canonical exit for the FE to confirm with
112
+ // the provider's browser SDK. It MUST NOT be persisted or duplicated
113
+ // into `responsePayload` (the latter is logged); the consumer reads it
114
+ // once from the top-level result and discards.
115
+ clientSecret: intent.client_secret ?? null,
116
+ customerProviderRef: intent.customer ?? null,
117
+ paymentMethodRef: intent.payment_method ?? null,
118
+ webhookExpected: true,
119
+ recoveryRequired: intent.status === "requires_action" && flow === "off_session_payment",
120
+ responsePayload: {
121
+ providerStatus: intent.status,
122
+ providerAttemptId: intent.id,
123
+ providerSessionIdPresent: Boolean(intent.client_secret),
124
+ latestChargePresent: Boolean(intent.latest_charge),
125
+ customerRefPresent: Boolean(intent.customer),
126
+ paymentMethodRefPresent: Boolean(intent.payment_method),
127
+ ...failureEvidencePayload(intent.diagnosticFailureEvidence),
128
+ },
129
+ };
130
+ },
131
+ };
132
+ }
133
+
134
+ export function parseStripePaymentWebhookEvent(rawPayload: Record<string, unknown>): CanonicalPaymentEvent {
135
+ const eventId = readString(rawPayload, "id");
136
+ const eventType = readString(rawPayload, "type");
137
+ const data = readObject(rawPayload, "data");
138
+ const object = readObject(data, "object");
139
+ const providerPaymentId = readString(object, "id");
140
+ const amount = readOptionalNumber(object, "amount_received") ?? readOptionalNumber(object, "amount");
141
+ const currency = readOptionalString(object, "currency")?.toUpperCase();
142
+
143
+ return {
144
+ provider_event_id: eventId,
145
+ event_type: canonicalStripeEventType(eventType, object),
146
+ payment_provider_id: providerPaymentId,
147
+ amount_minor: amount,
148
+ next_action_kind: nextActionKindFromStripe({
149
+ status: readOptionalString(object, "status") ?? "",
150
+ next_action: readOptionalObject(object, "next_action"),
151
+ }) ?? undefined,
152
+ raw_payload: {
153
+ provider: PROVIDER_KIND,
154
+ eventId,
155
+ eventType,
156
+ providerPaymentId,
157
+ amountMinor: amount,
158
+ currency,
159
+ },
160
+ };
161
+ }
162
+
163
+ /** The facts every result of this adapter carries, whatever the provider answered. */
164
+ function executionResultBase(
165
+ input: PaymentExecutionInput,
166
+ flow: string,
167
+ ): Pick<PaymentExecutionResult, "provider" | "requestPayload"> {
168
+ return {
169
+ provider: PROVIDER_KIND,
170
+ requestPayload: {
171
+ providerIdempotencyKey: input.providerIdempotencyKey,
172
+ providerRequestFingerprint: input.providerRequestFingerprint,
173
+ providerFlow: flow,
174
+ amountMinor: input.amountMinor,
175
+ currency: input.currency,
176
+ orderRef: input.orderRef,
177
+ },
178
+ };
179
+ }
180
+
181
+ function stripeFlow(flow: PaymentProviderFlow): "one_time_payment" | "setup_reusable_method" | "off_session_payment" {
182
+ if (flow === "one_time_payment" || flow === "setup_reusable_method" || flow === "off_session_payment") {
183
+ return flow;
184
+ }
185
+ throw new Error(`Unsupported Stripe provider flow: ${flow}`);
186
+ }
187
+
188
+ function metadataFor(input: PaymentExecutionInput, flow: string): Record<string, string> {
189
+ const metadata: Record<string, string> = {
190
+ source: "openlup.payment-control",
191
+ paymentIntentId: input.paymentIntentId,
192
+ orderRef: input.orderRef,
193
+ providerRequestFingerprint: input.providerRequestFingerprint,
194
+ providerFlow: flow,
195
+ };
196
+ if (input.clientId) metadata.clientId = input.clientId;
197
+ if (input.mode === "subscription_cycle") metadata.mode = input.mode;
198
+ return metadata;
199
+ }
200
+
201
+ /**
202
+ * What the durable attempt row is allowed to claim about this execution.
203
+ *
204
+ * Takes the intent, not just its status, because the two states sharing
205
+ * `requires_payment_method` differ only in what else is on the object: a fresh
206
+ * intent the payer never confirmed, and one an issuer has just refused (which
207
+ * carries a payment error and usually a charge).
208
+ *
209
+ * `processing` claims money is moving, and a catch-all used to reach it — so a
210
+ * never-confirmed intent was recorded as a charge in flight, and a payer was
211
+ * told their payment was already processing when no issuer had been asked.
212
+ * `sent_to_provider` is the truth: the request reached the provider, no outcome
213
+ * is known. It belongs to every live-attempt set the platform keeps, so the
214
+ * correction costs no watchdog coverage, and it is fail-closed at the
215
+ * provider-attempt admission gate exactly as `processing` is — admission does
216
+ * not widen by one state.
217
+ *
218
+ * The catch-all stays `processing` on purpose: an unrecognised provider status is
219
+ * not evidence that nothing happened.
220
+ */
221
+ function attemptStatusFromStripe(
222
+ intent: StripeIntentLike,
223
+ ): NonNullable<PaymentExecutionResult["attemptStatus"]> {
224
+ if (intent.status === "requires_action" || intent.status === "requires_confirmation") {
225
+ return "requires_action";
226
+ }
227
+ if (noIssuerWasAsked(intent)) return "sent_to_provider";
228
+ return "processing";
229
+ }
230
+
231
+ function nextActionKindFromStripe(intent: Pick<StripeIntentLike, "status" | "next_action">): PaymentNextActionKind | null {
232
+ if (intent.status !== "requires_action" && intent.status !== "requires_confirmation") return null;
233
+ if (intent.next_action?.type === "redirect_to_url") return "redirect";
234
+ if (intent.next_action?.type === "use_stripe_sdk") return "3ds_challenge";
235
+ return "sca_required";
236
+ }
237
+
238
+ function canonicalStripeEventType(
239
+ stripeEventType: string,
240
+ object: Record<string, unknown>,
241
+ ): CanonicalPaymentEvent["event_type"] {
242
+ if (stripeEventType === "payment_intent.succeeded") return "payment.succeeded";
243
+ if (stripeEventType === "payment_intent.payment_failed" || stripeEventType === "payment_intent.canceled") {
244
+ return "payment.failed";
245
+ }
246
+ if (stripeEventType === "payment_intent.requires_action") return "payment.requires_action";
247
+ if (stripeEventType === "charge.refunded") return "payment.refunded";
248
+ if (stripeEventType.startsWith("charge.dispute.")) return "payment.disputed";
249
+ const status = readOptionalString(object, "status");
250
+ if (status === "requires_action") return "payment.requires_action";
251
+ throw new Error(`Unsupported Stripe webhook event: ${stripeEventType}`);
252
+ }
253
+
254
+ function readObject(value: Record<string, unknown>, key: string): Record<string, unknown> {
255
+ const raw = value[key];
256
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
257
+ throw new Error(`Stripe payload missing object ${key}`);
258
+ }
259
+ return raw as Record<string, unknown>;
260
+ }
261
+
262
+ function readOptionalObject(value: Record<string, unknown>, key: string): Record<string, unknown> | null {
263
+ const raw = value[key];
264
+ return raw && typeof raw === "object" && !Array.isArray(raw) ? raw as Record<string, unknown> : null;
265
+ }
266
+
267
+ function readString(value: Record<string, unknown>, key: string): string {
268
+ const raw = value[key];
269
+ if (typeof raw !== "string" || raw.length === 0) {
270
+ throw new Error(`Stripe payload missing string ${key}`);
271
+ }
272
+ return raw;
273
+ }
274
+
275
+ function readOptionalString(value: Record<string, unknown>, key: string): string | undefined {
276
+ const raw = value[key];
277
+ return typeof raw === "string" && raw.length > 0 ? raw : undefined;
278
+ }
279
+
280
+ function readOptionalNumber(value: Record<string, unknown>, key: string): number | undefined {
281
+ const raw = value[key];
282
+ return typeof raw === "number" ? raw : undefined;
283
+ }