@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,235 @@
1
+ import { createHash } from "node:crypto";
2
+ import { classifyPaymentFailure, isPaymentRetryAdviceCode, type PaymentMethodLifecycleEvent } from "@openlup/core/payment";
3
+ import type {
4
+ PaymentAdapter, PaymentAdapterCapabilities, PaymentAttemptPort, PaymentReadbackEnvelope,
5
+ PaymentReadbackPort, PaymentRefusalEnvelope, PaymentVerifyNormalizePort, TransientSecret,
6
+ VerifiedMethodEvidence, VerifiedPaymentEvidence,
7
+ } from "../contracts/index.js";
8
+ import { createStripeSandboxPaymentExecutionAdapter, type StripeSandboxClient } from "./stripeSandboxPaymentExecutionAdapter.js";
9
+ import { createStripePaymentReconciliationProvider, type StripePaymentIntentReader } from "./stripePaymentReconciliationProvider.js";
10
+ import { normalizeStripeWebhookEvent } from "./stripeWebhookNormalizer.js";
11
+ import { verifyStripeSignature } from "./webhookSignature.js";
12
+ import { declineCodeHints } from "./declineFailureHints.js";
13
+ import type { PaymentExecutionResult } from "./donorContracts.js";
14
+
15
+ /** Host-owned client, credentials, SDK construction and mode policy. clock returns UTC seconds. */
16
+ export interface StripePaymentAdapterConfig {
17
+ client: Omit<StripeSandboxClient, "createSetupIntent"> & Partial<Pick<StripeSandboxClient, "createSetupIntent">> & StripePaymentIntentReader;
18
+ webhookSigningSecrets: readonly string[];
19
+ mode: "live" | "test";
20
+ supportedCurrencies: readonly string[];
21
+ toleranceSeconds?: number;
22
+ clock?: () => number;
23
+ }
24
+
25
+ /** Observed protocol capabilities; account activation remains host-owned. */
26
+ export const stripePaymentCapabilities: Readonly<PaymentAdapterCapabilities> = Object.freeze<PaymentAdapterCapabilities>({
27
+ providerKind: "stripe",
28
+ captureFlows: Object.freeze([Object.freeze({ kind: "card_payment", handoff: "client-confirmed", methodFamilies: Object.freeze(["card", "wallet"] as const) })]),
29
+ requiresStoredMandateEvidence: false,
30
+ assessMandate: () => ({ chargeable: true }),
31
+ unattendedChargeFlow: "off_session_payment",
32
+ payerContext: Object.freeze({ requiresPayerBlock: false, customerRefFallsBackToContactEmail: false }),
33
+ methodHealth: Object.freeze({ requiresCustomerRef: true, requiredMethodKind: null, requiresPayerContact: false }),
34
+ terminalOutcomeReporting: Object.freeze({ kind: "status_field", silenceBecomesSuspectAfterMinutes: 360 }),
35
+ mandateUpsertIsSubscriptionScoped: false,
36
+ /** declared by the host through the adapter config */
37
+ supportedCurrencies: Object.freeze([]),
38
+ unattended: "supported", readback: "supported", close: "unsupported", exactAbsence: "unsupported",
39
+ refundObservation: "supported", refundCreate: "unsupported", authorizationCaptureSplit: "unsupported",
40
+ });
41
+
42
+ type VerificationResult = Awaited<ReturnType<PaymentVerifyNormalizePort["verifyAndNormalize"]>>;
43
+ const refusal = (reason: Extract<VerificationResult, { kind: "refusal" }>["reason"]): VerificationResult => ({ kind: "refusal", reason });
44
+
45
+ /** Explicit construction is the only activation point; importing performs no I/O. */
46
+ export function createStripePaymentAdapter(config: StripePaymentAdapterConfig): PaymentAdapter {
47
+ if (config.mode !== "live" && config.mode !== "test") throw new Error("Invalid payment mode");
48
+ const declaredCurrencies = config.supportedCurrencies;
49
+ if (!Array.isArray(declaredCurrencies) || declaredCurrencies.length === 0
50
+ || ![...declaredCurrencies].every(currency => typeof currency === "string" && currency.length === 3 && /^[A-Z]{3}$/u.test(currency))) {
51
+ throw new Error("Invalid supported currencies");
52
+ }
53
+ const supportedCurrencies = Object.freeze([...declaredCurrencies]);
54
+ const tolerance = config.toleranceSeconds ?? 300;
55
+ if (!Number.isFinite(tolerance) || tolerance < 0) throw new Error("Invalid signature tolerance");
56
+ const secrets = [...config.webhookSigningSecrets];
57
+ const now = config.clock ?? (() => Math.floor(Date.now() / 1000));
58
+ const execution = createStripeSandboxPaymentExecutionAdapter({ client: {
59
+ createPaymentIntent: config.client.createPaymentIntent.bind(config.client),
60
+ createSetupIntent: inputSetup,
61
+ ensureCustomer: config.client.ensureCustomer?.bind(config.client),
62
+ } });
63
+ function inputSetup(...args: Parameters<StripeSandboxClient["createSetupIntent"]>) {
64
+ if (!config.client.createSetupIntent) throw new Error("Method setup unsupported by injected client");
65
+ return config.client.createSetupIntent(...args);
66
+ }
67
+ const reader = createStripePaymentReconciliationProvider(config.client);
68
+ const validateInput: PaymentAttemptPort["validateInput"] = input => {
69
+ if (input.mode !== config.mode || !["initial", "recovery", "renewal-unattended"].includes(input.purpose)
70
+ || !input.attemptRef || !input.intentRef || !input.fingerprint || !input.providerIdempotencyKey
71
+ || !Number.isSafeInteger(input.money.amountMinor) || input.money.amountMinor <= 0
72
+ || !supportedCurrencies.includes(input.money.currency)
73
+ || input.customerAuthorization !== undefined
74
+ || (input.purpose === "renewal-unattended" && !input.storedMethod?.reference)) {
75
+ throw new Error("Unsupported payment input");
76
+ }
77
+ };
78
+ const read: PaymentReadbackPort["readPayment"] = async input => {
79
+ const base = { identity: input.identity, expectedMoney: input.expectedMoney, purpose: input.purpose };
80
+ if (input.identity.provider !== "stripe" || !input.identity.providerPaymentRef) return { ...base, kind: "unsupported" };
81
+ try {
82
+ // Bind the response to the requested remote object before translating status.
83
+ const intent = await config.client.retrievePaymentIntent(input.identity.providerPaymentRef);
84
+ if (intent.id !== input.identity.providerPaymentRef || intent.amount !== input.expectedMoney.amountMinor
85
+ || intent.currency?.toUpperCase() !== input.expectedMoney.currency.toUpperCase()) {
86
+ return { ...base, kind: "unknown", reason: "indeterminate" };
87
+ }
88
+ const status = await createStripePaymentReconciliationProvider({ retrievePaymentIntent: async () => intent })
89
+ .readPayment({ providerPaymentId: input.identity.providerPaymentRef, purpose: input.purpose });
90
+ return readback(base, status);
91
+ } catch { return { ...base, kind: "unknown", reason: "indeterminate" }; }
92
+ };
93
+ return {
94
+ capabilities: { ...stripePaymentCapabilities, supportedCurrencies, close: config.client.cancelPaymentIntent ? "supported" : "unsupported" },
95
+ attemptPort: {
96
+ validateInput,
97
+ async execute(input) {
98
+ validateInput(input);
99
+ const result = await execution.execute({
100
+ idempotencyKey: input.attemptRef, providerIdempotencyKey: input.providerIdempotencyKey,
101
+ providerRequestFingerprint: input.fingerprint, paymentIntentId: input.intentRef,
102
+ amountMinor: input.money.amountMinor, currency: input.money.currency,
103
+ mode: input.purpose === "renewal-unattended" ? "subscription_cycle" : "one_time",
104
+ orderRef: input.intentRef,
105
+ providerFlow: input.purpose === "renewal-unattended" ? "off_session_payment" : "one_time_payment",
106
+ paymentMethodRef: input.storedMethod?.reference,
107
+ });
108
+ return {
109
+ outcome: result.providerDecline
110
+ ? { kind: "decline", refusal: neutralDecline(result.providerDecline) }
111
+ : { kind: "attempt", status: result.attemptStatus },
112
+ // A synchronous issuer decline is not proof of a pre-dispatch refusal.
113
+ dispatch: result.providerAttemptId ? { kind: "dispatched", providerPaymentRef: result.providerAttemptId } : { kind: "unknown" },
114
+ ...(result.clientSecret ? { handoff: { kind: "client-confirmed", payload: result.clientSecret as TransientSecret } as const } : {}),
115
+ diagnostics: { webhookExpected: result.webhookExpected === true, recoveryRequired: result.recoveryRequired === true },
116
+ };
117
+ },
118
+ },
119
+ verifyPort: {
120
+ async verifyAndNormalize(input) {
121
+ const header = Object.entries(input.headers).find(([key]) => key.toLowerCase() === "stripe-signature")?.[1];
122
+ if (typeof header !== "string") return refusal("signature");
123
+ const parts = Object.fromEntries(header.split(",").map(part => part.split("=")).filter(([key, value]) => key && value));
124
+ const timestamp = Number(parts.t);
125
+ const current = now();
126
+ if (!parts.t || !parts.v1 || !Number.isFinite(timestamp) || !Number.isFinite(current)) return refusal("signature");
127
+ // The preserved verifier supplies HMAC and comparison; this layer owns configurable freshness.
128
+ if (Math.abs(current - timestamp) > tolerance) return refusal("stale");
129
+ const rawBody = Buffer.from(input.rawBody).toString("utf8");
130
+ if (!Buffer.from(rawBody).equals(Buffer.from(input.rawBody))) return refusal("malformed");
131
+ if (!verifyStripeSignature({ rawBody, signatureHeader: header, endpointSecret: secrets, now: () => timestamp })) return refusal("signature");
132
+ try {
133
+ const raw: unknown = JSON.parse(rawBody);
134
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return refusal("malformed");
135
+ const event = raw as Record<string, unknown>;
136
+ if (typeof event.livemode === "boolean" && event.livemode !== (config.mode === "live")) return refusal("unsupported-event");
137
+ return mapVerifiedStripeEvent(event);
138
+ } catch { return refusal("malformed"); }
139
+ },
140
+ },
141
+ readbackPort: {
142
+ readPayment: read,
143
+ ...(reader.closePayment ? { close: async (input: Parameters<PaymentReadbackPort["readPayment"]>[0]): Promise<PaymentReadbackEnvelope> => {
144
+ const base = { identity: input.identity, expectedMoney: input.expectedMoney, purpose: input.purpose };
145
+ if (input.identity.provider !== "stripe" || !input.identity.providerPaymentRef) return { ...base, kind: "unsupported" };
146
+ try {
147
+ // Check configured money and object identity before a destructive provider operation.
148
+ const before = await read(input);
149
+ if (before.kind === "unknown" || before.kind === "unsupported") return before;
150
+ await reader.closePayment!({ providerPaymentId: input.identity.providerPaymentRef });
151
+ return read(input);
152
+ } catch { return { ...base, kind: "unknown", reason: "indeterminate" }; }
153
+ } } : {}),
154
+ },
155
+ };
156
+ }
157
+
158
+ function neutralDecline(decline: NonNullable<PaymentExecutionResult["providerDecline"]>): PaymentRefusalEnvelope {
159
+ const hints = decline.neutralReasonHints ?? [];
160
+ const classified = classifyPaymentFailure({ adviceCode: decline.adviceCode, neutralReasonHints: hints });
161
+ return { code: classified.failureClass, hints,
162
+ ...(decline.adviceCode && isPaymentRetryAdviceCode(decline.adviceCode) ? { adviceCode: decline.adviceCode } : {}) };
163
+ }
164
+
165
+ function readback(base: Pick<PaymentReadbackEnvelope, "identity" | "expectedMoney" | "purpose">,
166
+ status: Awaited<ReturnType<ReturnType<typeof createStripePaymentReconciliationProvider>["readPayment"]>>): PaymentReadbackEnvelope {
167
+ if (status.status === "unknown" || status.amountMinor === null || !Number.isSafeInteger(status.amountMinor) || !status.currency) {
168
+ return { ...base, kind: "unknown", reason: "indeterminate" };
169
+ }
170
+ return { ...base, kind: status.status, observedMoney: { amountMinor: status.amountMinor, currency: status.currency }, providerOccurredAt: status.occurredAt };
171
+ }
172
+
173
+ /** Preserve donor money classifications in the neutral evidence union. */
174
+ function mapStripeOutcome(eventType: string): VerifiedPaymentEvidence["outcome"] | null {
175
+ switch (eventType) {
176
+ case "payment.succeeded": return "succeeded";
177
+ case "payment.failed": return "failed";
178
+ case "payment.refunded": return "refunded";
179
+ case "payment.requires_action": return "pending";
180
+ case "payment.disputed": return "disputed";
181
+ default: return null;
182
+ }
183
+ }
184
+
185
+ function mapVerifiedStripeEvent(raw: Record<string, unknown>): VerificationResult {
186
+ let normalized: ReturnType<typeof normalizeStripeWebhookEvent>;
187
+ try { normalized = normalizeStripeWebhookEvent(raw); }
188
+ catch (error) {
189
+ return refusal(error instanceof Error && error.message.startsWith("Unsupported Stripe webhook event:") ? "unsupported-event" : "malformed");
190
+ }
191
+ const object = (raw.data as { object: Record<string, unknown> }).object;
192
+ const occurred = typeof raw.created === "number" && Number.isFinite(raw.created)
193
+ ? normalized.occurredAt : typeof raw.created === "string" && Number.isFinite(Date.parse(raw.created)) ? normalized.occurredAt : null;
194
+ const metadata = object.metadata && typeof object.metadata === "object" ? object.metadata as Record<string, unknown> : {};
195
+ const intentRef = typeof metadata.paymentIntentId === "string" ? metadata.paymentIntentId : null;
196
+ const identity = { provider: "stripe", providerEventId: normalized.providerEventId,
197
+ providerOccurredAt: occurred };
198
+ let result: Omit<VerifiedMethodEvidence, "fingerprint"> | Omit<VerifiedPaymentEvidence, "fingerprint">;
199
+ if (normalized.methodLifecycle || normalized.eventType.startsWith("setup.")) {
200
+ const lifecycle: PaymentMethodLifecycleEvent | null | undefined = normalized.methodLifecycle;
201
+ const method = lifecycle?.providerMethodRef ?? (typeof object.payment_method === "string" ? object.payment_method
202
+ : object.payment_method && typeof object.payment_method === "object" ? (object.payment_method as { id?: unknown }).id : null);
203
+ if (typeof method !== "string" || !method) return refusal("unsupported-event");
204
+ const outcomes: Record<string, VerifiedMethodEvidence["outcome"]> = {
205
+ method_registered: "registered", method_updated: "updated", method_revoked: "revoked", method_expired: "expired", method_suspended: "suspended",
206
+ "setup.succeeded": "registered", "setup.failed": "failed", "setup.requires_action": "requires-action",
207
+ };
208
+ const outcome = outcomes[lifecycle?.kind ?? normalized.eventType];
209
+ if (!outcome) return refusal("unsupported-event");
210
+ result = { ...identity, kind: "method", outcome, methodRef: method,
211
+ setupRef: typeof raw.type === "string" && raw.type.startsWith("setup_intent.") ? normalized.providerPaymentId : null, intentRef,
212
+ // Missing provider time must not turn the donor's epoch fallback into a lifecycle fact.
213
+ ...(lifecycle && occurred !== null ? { methodLifecycle: lifecycle } : {}) };
214
+ } else {
215
+ const outcome = mapStripeOutcome(normalized.eventType);
216
+ if (!outcome) return refusal("unsupported-event");
217
+ const money = Number.isSafeInteger(normalized.amountMinor) && normalized.amountMinor! > 0 && normalized.currency
218
+ ? { amountMinor: normalized.amountMinor!, currency: normalized.currency } : null;
219
+ const snapshot = normalized.rawPayload.stripeRefundSnapshot as { chargeId: string; amountRefundedMinor: number } | undefined;
220
+ if (outcome === "refunded" && (!snapshot || !money)) return refusal("malformed");
221
+ result = { ...identity, kind: "money", providerPaymentRef: normalized.providerPaymentId, intentRef, attemptRef: null, outcome, money,
222
+ ...(snapshot && money ? { refund: { kind: "cumulative-snapshot" as const, operationId: snapshot.chargeId,
223
+ originalPaymentMoney: money, refundedMoney: { amountMinor: snapshot.amountRefundedMinor, currency: money.currency } } } : {}),
224
+ ...(outcome === "failed" && object.last_payment_error && typeof object.last_payment_error === "object"
225
+ ? { refusal: neutralDecline({ code: "provider_declined", mandateUnsupported: false,
226
+ neutralReasonHints: declineCodeHints((object.last_payment_error as { decline_code?: string }).decline_code),
227
+ adviceCode: typeof (object.last_payment_error as { advice_code?: unknown }).advice_code === "string"
228
+ ? (object.last_payment_error as { advice_code: string }).advice_code : undefined }) } : {}),
229
+ };
230
+ }
231
+ // The donor normalizer constructs this filtered carrier, never the raw body.
232
+ // Keep its reader dialect and provenance outside normalized event identity.
233
+ return { ...result, fingerprint: createHash("sha256").update(JSON.stringify(result)).digest("hex"),
234
+ providerAuditPayload: normalized.rawPayload };
235
+ }
@@ -0,0 +1,58 @@
1
+ import { stripeFailureEvidence, type StripeIntentLike } from "./stripeFailureEvidence.js";
2
+ type Reference = string | { id: string } | null;
3
+ interface StripeIntentShape {
4
+ id: string; status: string; customer?: Reference; payment_method?: Reference; latest_charge?: Reference;
5
+ client_secret?: string | null; next_action?: { type?: string | null } | null;
6
+ amount?: number; amount_received?: number; currency?: string;
7
+ last_payment_error?: { code?: string | null; message?: string | null; type?: string | null } | null;
8
+ }
9
+ export function mapStripeIntent(intent: StripeIntentShape, knownCardOnlyRequest = false, source: "execution" | "readback" = "readback"): StripeIntentLike {
10
+ const customer = typeof intent.customer === "string" ? intent.customer : intent.customer?.id ?? null;
11
+ const paymentMethod =
12
+ typeof intent.payment_method === "string"
13
+ ? intent.payment_method
14
+ : intent.payment_method?.id ?? null;
15
+ const latestCharge =
16
+ typeof intent.latest_charge === "string"
17
+ ? intent.latest_charge
18
+ : intent.latest_charge?.id ?? null;
19
+ const mapped: StripeIntentLike = {
20
+ id: intent.id,
21
+ status: intent.status,
22
+ client_secret: intent.client_secret,
23
+ customer,
24
+ payment_method: paymentMethod,
25
+ latest_charge: latestCharge,
26
+ next_action: intent.next_action ? { type: intent.next_action.type ?? null } : null,
27
+ };
28
+ if (typeof intent.amount === "number") mapped.amount = intent.amount;
29
+ if (typeof intent.amount_received === "number") mapped.amount_received = intent.amount_received;
30
+ if (typeof intent.currency === "string") mapped.currency = intent.currency;
31
+ if (intent.last_payment_error) {
32
+ mapped.last_payment_error = {
33
+ code: intent.last_payment_error.code ?? null,
34
+ message: intent.last_payment_error.message ?? null,
35
+ type: intent.last_payment_error.type ?? null,
36
+ };
37
+ }
38
+ const evidence = stripeFailureEvidence(intent, { source, knownCardOnlyRequest });
39
+ if (evidence) mapped.diagnosticFailureEvidence = evidence;
40
+ return mapped;
41
+ }
42
+
43
+ export function mapSetupIntent(intent: StripeIntentShape): StripeIntentLike {
44
+ const customer = typeof intent.customer === "string" ? intent.customer : intent.customer?.id ?? null;
45
+ const paymentMethod =
46
+ typeof intent.payment_method === "string"
47
+ ? intent.payment_method
48
+ : intent.payment_method?.id ?? null;
49
+ return {
50
+ id: intent.id,
51
+ status: intent.status,
52
+ client_secret: intent.client_secret,
53
+ customer,
54
+ payment_method: paymentMethod,
55
+ latest_charge: null,
56
+ next_action: intent.next_action ? { type: intent.next_action.type ?? null } : null,
57
+ };
58
+ }
@@ -0,0 +1,21 @@
1
+ import type { PaymentMethodReplacementFacts } from "@openlup/core/payment";
2
+ import type { NormalizedProviderPaymentWebhook } from "./donorContracts.js";
3
+ import type { PaymentMethodLifecycleEvent } from "@openlup/core/payment";
4
+ export interface ProviderWebhookWithMethodLifecycle extends NormalizedProviderPaymentWebhook {
5
+ methodLifecycle?: PaymentMethodLifecycleEvent | null;
6
+ }
7
+ export const METHOD_SCHEME_SNAPSHOT_KEY = "methodScheme";
8
+ export const METHOD_LAST_DIGITS_SNAPSHOT_KEY = "methodLastDigits";
9
+ export const METHOD_EXPIRES_AT_SNAPSHOT_KEY = "methodExpiresAt";
10
+
11
+ export function methodFactSnapshotKeys(
12
+ facts: PaymentMethodReplacementFacts | null | undefined,
13
+ ): Record<string, unknown> {
14
+ if (!facts) return {};
15
+ return {
16
+ ...(facts.schemeLabel ? { [METHOD_SCHEME_SNAPSHOT_KEY]: facts.schemeLabel } : {}),
17
+ ...(facts.lastDigits ? { [METHOD_LAST_DIGITS_SNAPSHOT_KEY]: facts.lastDigits } : {}),
18
+ ...(facts.expiresAt ? { [METHOD_EXPIRES_AT_SNAPSHOT_KEY]: facts.expiresAt } : {}),
19
+ };
20
+ }
21
+
@@ -0,0 +1,58 @@
1
+ import type { PaymentRecoveryMethod, PaymentRecoveryOperation } from "@openlup/core/payment";
2
+
3
+ export type FailureObservationSource = "execution" | "webhook" | "readback";
4
+ export type FailureObservationDisposition = "present" | "absent" | "unreadable" | "read_failed" | "read_timeout";
5
+
6
+ /** Protected provider facts, not a customer message or a retry-policy input. */
7
+ export interface PaymentFailureEvidence {
8
+ version: 1;
9
+ source: FailureObservationSource;
10
+ disposition: FailureObservationDisposition;
11
+ refusalVerified: boolean;
12
+ providerPaymentId: string | null;
13
+ providerChargeId: string | null;
14
+ code: string | null;
15
+ declineCode: string | null;
16
+ adviceCode: string | null;
17
+ adviceOrigin: "provider" | "inferred" | "unknown";
18
+ operation: PaymentRecoveryOperation | null;
19
+ method: PaymentRecoveryMethod | null;
20
+ methodSource: "provider" | "execution_contract" | "unknown";
21
+ }
22
+
23
+ /** Bounded machine tokens only. Free-form response fields never cross this seam. */
24
+ export function evidenceToken(value: unknown): string | null {
25
+ return typeof value === "string" && /^[a-zA-Z0-9_:.-]{1,96}$/.test(value) ? value : null;
26
+ }
27
+
28
+ export function evidenceRecord(value: unknown): Record<string, unknown> {
29
+ return value !== null && typeof value === "object" && !Array.isArray(value)
30
+ ? value as Record<string, unknown> : {};
31
+ }
32
+
33
+ /** Explicit construction prevents accidental payload expansion by object spreading. */
34
+ export function serializeFailureEvidence(input: PaymentFailureEvidence): PaymentFailureEvidence {
35
+ const method = input.method;
36
+ const kind = evidenceToken(method?.kind);
37
+ const recoveryMethodKey = evidenceToken(method?.recoveryMethodKey);
38
+ return {
39
+ version: 1,
40
+ source: input.source,
41
+ disposition: input.disposition,
42
+ refusalVerified: input.refusalVerified,
43
+ providerPaymentId: evidenceToken(input.providerPaymentId),
44
+ providerChargeId: evidenceToken(input.providerChargeId),
45
+ code: evidenceToken(input.code),
46
+ declineCode: evidenceToken(input.declineCode),
47
+ adviceCode: evidenceToken(input.adviceCode),
48
+ adviceOrigin: input.adviceOrigin,
49
+ operation: input.operation,
50
+ method: kind && recoveryMethodKey && method ? { kind, recoveryMethodKey, interaction: method.interaction } : null,
51
+ methodSource: kind && recoveryMethodKey ? input.methodSource : "unknown",
52
+ };
53
+ }
54
+
55
+ /** Persist beside legacy fields; never merge the diagnostic codes into them. */
56
+ export function failureEvidencePayload(evidence: PaymentFailureEvidence | null | undefined): Record<string, unknown> {
57
+ return evidence ? { failureEvidence: serializeFailureEvidence(evidence) } : {};
58
+ }
@@ -0,0 +1,115 @@
1
+ import { isPaymentRetryAdviceCode, type PaymentRecoveryEvidence, type PaymentRecoveryMethod,
2
+ type PaymentRecoveryOperation } from "@openlup/core/payment";
3
+ import { evidenceRecord, evidenceToken, failureEvidencePayload, serializeFailureEvidence,
4
+ type FailureObservationSource, type PaymentFailureEvidence } from "./paymentFailureEvidence.js";
5
+
6
+ export interface StripeIntentLike {
7
+ id: string;
8
+ status: string;
9
+ amount?: number | null;
10
+ amount_received?: number | null;
11
+ currency?: string | null;
12
+ client_secret?: string | null;
13
+ customer?: string | null;
14
+ payment_method?: string | null;
15
+ latest_charge?: string | null;
16
+ next_action?: { type?: string | null } | null;
17
+ last_payment_error?: {
18
+ code?: string | null;
19
+ message?: string | null;
20
+ type?: string | null;
21
+ decline_code?: string | null;
22
+ advice_code?: string | null;
23
+ } | null;
24
+ /** Kept separate: populating legacy fine fields would change renewal classification. */
25
+ diagnosticFailureEvidence?: PaymentFailureEvidence;
26
+ }
27
+
28
+ /** Reads only one current PaymentIntent/error, never an arbitrary historical Charge. */
29
+ export function stripeFailureEvidence(input: unknown, options: {
30
+ source: FailureObservationSource;
31
+ confirmedFailureEvent?: boolean;
32
+ knownCardOnlyRequest?: boolean;
33
+ }): PaymentFailureEvidence | null {
34
+ const object = evidenceRecord(input);
35
+ const thrown = object.type === "StripeCardError";
36
+ const intent = thrown ? evidenceRecord(object.payment_intent) : object;
37
+ const error = thrown ? object : evidenceRecord(intent.last_payment_error);
38
+ if (!thrown && !options.confirmedFailureEvent && Object.keys(error).length === 0) return null;
39
+ const refusalVerified = thrown || options.confirmedFailureEvent === true
40
+ || (intent.status === "requires_payment_method" && Object.keys(error).length > 0
41
+ && (error.type === "card_error" || error.code === "card_declined" || Boolean(intent.latest_charge)));
42
+ const errorMethod = evidenceRecord(error.payment_method);
43
+ const currentMethod = evidenceRecord(intent.payment_method);
44
+ const errorMethodId = typeof error.payment_method === "string" ? error.payment_method : errorMethod.id;
45
+ const currentMethodId = typeof intent.payment_method === "string" ? intent.payment_method : currentMethod.id;
46
+ const conflictingMethod = Boolean(errorMethodId && currentMethodId && errorMethodId !== currentMethodId)
47
+ || Boolean(errorMethod.type && currentMethod.type && errorMethod.type !== currentMethod.type);
48
+ const actualMethod = errorMethod.type ?? currentMethod.type;
49
+ const cardContract = options.knownCardOnlyRequest === true
50
+ || (Array.isArray(intent.payment_method_types) && intent.payment_method_types.length === 1
51
+ && intent.payment_method_types[0] === "card");
52
+ const flow = evidenceRecord(intent.metadata).providerFlow;
53
+ const operation: PaymentRecoveryOperation | null = flow === "off_session_payment" ? "stored_method_payment"
54
+ : intent.setup_future_usage === "off_session" ? "recurring_setup"
55
+ : intent.setup_future_usage === null && flow === "one_time_payment" ? "one_time_payment" : null;
56
+ const methodKind = actualMethod === "card" || actualMethod === "blik" ? actualMethod
57
+ : actualMethod == null && cardContract ? "card" : null;
58
+ const method: PaymentRecoveryMethod | null = methodKind
59
+ ? { kind: methodKind, recoveryMethodKey: methodKind, interaction: operation === "stored_method_payment" ? "stored_instrument" : "new_instrument" } : null;
60
+ const latestCharge = typeof intent.latest_charge === "string" ? intent.latest_charge : evidenceRecord(intent.latest_charge).id;
61
+ const errorCharge = typeof error.charge === "string" ? error.charge : evidenceRecord(error.charge).id;
62
+ // A last error tied to another charge cannot explain the currently observed charge.
63
+ const conflictingEvidence = conflictingMethod || Boolean(latestCharge && errorCharge && latestCharge !== errorCharge);
64
+ return serializeFailureEvidence({
65
+ version: 1, source: options.source,
66
+ disposition: conflictingEvidence ? "unreadable" : Object.keys(error).length ? "present" : "absent",
67
+ refusalVerified: refusalVerified && !conflictingEvidence, providerPaymentId: evidenceToken(intent.id),
68
+ providerChargeId: evidenceToken(errorCharge ?? latestCharge),
69
+ code: conflictingEvidence ? null : evidenceToken(error.code),
70
+ declineCode: conflictingEvidence ? null : evidenceToken(error.decline_code),
71
+ adviceCode: conflictingEvidence ? null : evidenceToken(error.advice_code),
72
+ adviceOrigin: "provider", operation: conflictingEvidence ? null : operation, method: conflictingEvidence ? null : method,
73
+ methodSource: actualMethod != null ? "provider" : cardContract ? "execution_contract" : "unknown",
74
+ });
75
+ }
76
+
77
+ const SAFE_CAUSES: Readonly<Record<string, PaymentRecoveryEvidence["cause"]>> = {
78
+ insufficient_funds: "insufficient_funds", expired_card: "expired_card",
79
+ incorrect_cvc: "invalid_payment_data", invalid_cvc: "invalid_payment_data",
80
+ incorrect_number: "invalid_payment_data", invalid_number: "invalid_payment_data",
81
+ invalid_expiry_month: "invalid_payment_data", invalid_expiry_year: "invalid_payment_data",
82
+ authentication_required: "authentication_required",
83
+ card_not_supported: "operation_unsupported", currency_not_supported: "operation_unsupported",
84
+ transaction_not_allowed: "operation_unsupported",
85
+ };
86
+ const RESTRICTED = new Set(["fraudulent", "highest_risk_level", "lost_card", "stolen_card", "merchant_blacklist"]);
87
+
88
+ /** UI evidence only: does not call or alter the shared failure taxonomy. */
89
+ export function normalizeStripeFailureEvidence(input: PaymentFailureEvidence): PaymentRecoveryEvidence {
90
+ const evidence = serializeFailureEvidence(input);
91
+ const code = evidence.declineCode ?? evidence.code;
92
+ const restricted = code !== null && RESTRICTED.has(code);
93
+ const readable = evidence.refusalVerified && evidence.disposition === "present";
94
+ // A refused BLIK registration names its operation, not a setup cause: no code
95
+ // read here distinguishes a refused agreement from a mistyped or expired code.
96
+ const cause = readable && !restricted && code && Object.hasOwn(SAFE_CAUSES, code)
97
+ ? SAFE_CAUSES[code]! : "generic_decline";
98
+ return {
99
+ refusalVerified: evidence.refusalVerified, cause,
100
+ certainty: cause === "generic_decline" ? "unknown" : "verified",
101
+ disclosure: restricted ? "restricted" : "safe", method: evidence.method, operation: evidence.operation,
102
+ advice: readable && evidence.adviceOrigin === "provider" && evidence.adviceCode !== null && isPaymentRetryAdviceCode(evidence.adviceCode)
103
+ ? { code: evidence.adviceCode, scope: evidence.method?.kind === "card" ? "instrument" : "unknown" } : null,
104
+ };
105
+ }
106
+
107
+ /** Preserve the existing webhook projection and append only failure diagnostics. */
108
+ export function stripeWebhookPayload(input: {
109
+ eventId: string; eventType: string; providerPaymentId: string; amountMinor: number | null; currency: string | null;
110
+ }, object: unknown): Record<string, unknown> {
111
+ return { provider: "stripe", ...input,
112
+ ...(input.eventType === "payment_intent.payment_failed"
113
+ ? failureEvidencePayload(stripeFailureEvidence(object, { source: "webhook", confirmedFailureEvent: true })) : {}),
114
+ };
115
+ }
@@ -0,0 +1,34 @@
1
+ import type { StripeIntentLike } from "./stripeSandboxPaymentExecutionAdapter.js";
2
+
3
+ /**
4
+ * `requires_payment_method` on an intent that was never confirmed: no payment
5
+ * method attached, no charge, no payment error. The provider object exists —
6
+ * the request reached the provider and was accepted — but the payer never
7
+ * submitted an instrument, so no issuer was ever asked to approve anything.
8
+ *
9
+ * Both absences beside the status are load-bearing. A payment method attached
10
+ * but not yet confirmed means a charge may be seconds from moving; a charge or a
11
+ * payment error means an issuer HAS been asked and answered. Either presence
12
+ * makes this claim false, and the claim is used to decide what a durable row
13
+ * says about the payer's money, so it must stay narrow rather than convenient.
14
+ *
15
+ * Shared deliberately. The execution rail asserts it to record what a fresh
16
+ * intent is, and the reconciliation rail asserts it to answer a payer who is
17
+ * still standing in the checkout. Those are two readings of ONE provider state,
18
+ * and two copies of the predicate would let them drift into disagreeing about
19
+ * whether the same intent had been attempted — the kind of drift that is only
20
+ * discovered by a payer being told the wrong thing about their own payment.
21
+ *
22
+ * ⛔ Not to be widened into "the payer has gone" (abandonment). That is a
23
+ * separate verdict, passed on a payer who is no longer present, and it
24
+ * deliberately asserts fewer absences so a real refusal is never mislabelled as
25
+ * abandonment. Merging the two would put one predicate under two opposite
26
+ * pressures: this one must stay narrow so no live charge is called idle, that
27
+ * one must stay broad so no refusal is silently dropped out of dunning.
28
+ */
29
+ export function noIssuerWasAsked(intent: StripeIntentLike): boolean {
30
+ return intent.status === "requires_payment_method"
31
+ && !intent.payment_method
32
+ && !intent.latest_charge
33
+ && !intent.last_payment_error;
34
+ }