@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,220 @@
1
+ import { failureEvidencePayload } from "./paymentFailureEvidence.js";
2
+ import { declineFromError, declinedExecutionFacts } from "./executionDecline.js";
3
+ import { noIssuerWasAsked } from "./stripeIntentConfirmationEvidence.js";
4
+ /** The one place this adapter spells its own provider identity. */
5
+ export const PROVIDER_KIND = "stripe";
6
+ export function createStripeSandboxPaymentExecutionAdapter({ client, defaultFlow = "one_time_payment", }) {
7
+ return {
8
+ async execute(input) {
9
+ const flow = stripeFlow(input.providerFlow ?? defaultFlow);
10
+ // A reusable-mandate PaymentIntent (setup_future_usage) requires a Stripe
11
+ // Customer. Ensure-or-create one for the client when none was passed.
12
+ let customerRef = input.customerRef;
13
+ if (!customerRef && input.saveForFutureUse && input.clientId && client.ensureCustomer) {
14
+ customerRef = await client.ensureCustomer({ clientId: input.clientId, metadata: { clientId: input.clientId } },
15
+ // A provider attempt is per click; a Stripe Customer is per OpenLup
16
+ // client. A stable key avoids orphan Customers when checkout recovery
17
+ // is reloaded before the first method webhook stores the reference.
18
+ { idempotencyKey: `openlup:stripe:customer:${input.clientId}` });
19
+ }
20
+ const created = flow === "setup_reusable_method"
21
+ ? await client.createSetupIntent({
22
+ customer: customerRef,
23
+ paymentMethod: input.paymentMethodRef,
24
+ usage: "off_session",
25
+ metadata: metadataFor(input, flow),
26
+ }, { idempotencyKey: input.providerIdempotencyKey })
27
+ : await client.createPaymentIntent({
28
+ amount: input.amountMinor,
29
+ currency: input.currency.toLowerCase(),
30
+ customer: customerRef,
31
+ paymentMethod: input.paymentMethodRef,
32
+ offSession: flow === "off_session_payment" ? true : undefined,
33
+ setupFutureUsage: input.saveForFutureUse ? "off_session" : undefined,
34
+ metadata: metadataFor(input, flow),
35
+ }, { idempotencyKey: input.providerIdempotencyKey }).catch((error) => {
36
+ // An off-session charge is confirmed inside this call, so the payer's
37
+ // issuer can refuse it here and nowhere else. That refusal is a FACT,
38
+ // not an ambiguous call: rethrowing it makes the renewal orchestrator
39
+ // record an indeterminate outcome, which applies no result, opens no
40
+ // dunning case and tells the customer nothing. Every other error class
41
+ // — network, auth, config, rate limit — keeps throwing, because after
42
+ // those the charge may still have been accepted and only the
43
+ // fail-closed path is safe.
44
+ const decline = flow === "off_session_payment" ? declineFromError(error) : null;
45
+ if (!decline)
46
+ throw error;
47
+ return decline;
48
+ });
49
+ if ("decline" in created)
50
+ return { ...executionResultBase(input, flow), ...declinedExecutionFacts(created) };
51
+ const intent = created;
52
+ return {
53
+ ...executionResultBase(input, flow),
54
+ providerAttemptId: intent.id,
55
+ providerSessionId: intent.id,
56
+ attemptStatus: attemptStatusFromStripe(intent),
57
+ nextActionKind: nextActionKindFromStripe(intent),
58
+ // `clientSecret` is the single canonical exit for the FE to confirm with
59
+ // the provider's browser SDK. It MUST NOT be persisted or duplicated
60
+ // into `responsePayload` (the latter is logged); the consumer reads it
61
+ // once from the top-level result and discards.
62
+ clientSecret: intent.client_secret ?? null,
63
+ customerProviderRef: intent.customer ?? null,
64
+ paymentMethodRef: intent.payment_method ?? null,
65
+ webhookExpected: true,
66
+ recoveryRequired: intent.status === "requires_action" && flow === "off_session_payment",
67
+ responsePayload: {
68
+ providerStatus: intent.status,
69
+ providerAttemptId: intent.id,
70
+ providerSessionIdPresent: Boolean(intent.client_secret),
71
+ latestChargePresent: Boolean(intent.latest_charge),
72
+ customerRefPresent: Boolean(intent.customer),
73
+ paymentMethodRefPresent: Boolean(intent.payment_method),
74
+ ...failureEvidencePayload(intent.diagnosticFailureEvidence),
75
+ },
76
+ };
77
+ },
78
+ };
79
+ }
80
+ export function parseStripePaymentWebhookEvent(rawPayload) {
81
+ const eventId = readString(rawPayload, "id");
82
+ const eventType = readString(rawPayload, "type");
83
+ const data = readObject(rawPayload, "data");
84
+ const object = readObject(data, "object");
85
+ const providerPaymentId = readString(object, "id");
86
+ const amount = readOptionalNumber(object, "amount_received") ?? readOptionalNumber(object, "amount");
87
+ const currency = readOptionalString(object, "currency")?.toUpperCase();
88
+ return {
89
+ provider_event_id: eventId,
90
+ event_type: canonicalStripeEventType(eventType, object),
91
+ payment_provider_id: providerPaymentId,
92
+ amount_minor: amount,
93
+ next_action_kind: nextActionKindFromStripe({
94
+ status: readOptionalString(object, "status") ?? "",
95
+ next_action: readOptionalObject(object, "next_action"),
96
+ }) ?? undefined,
97
+ raw_payload: {
98
+ provider: PROVIDER_KIND,
99
+ eventId,
100
+ eventType,
101
+ providerPaymentId,
102
+ amountMinor: amount,
103
+ currency,
104
+ },
105
+ };
106
+ }
107
+ /** The facts every result of this adapter carries, whatever the provider answered. */
108
+ function executionResultBase(input, flow) {
109
+ return {
110
+ provider: PROVIDER_KIND,
111
+ requestPayload: {
112
+ providerIdempotencyKey: input.providerIdempotencyKey,
113
+ providerRequestFingerprint: input.providerRequestFingerprint,
114
+ providerFlow: flow,
115
+ amountMinor: input.amountMinor,
116
+ currency: input.currency,
117
+ orderRef: input.orderRef,
118
+ },
119
+ };
120
+ }
121
+ function stripeFlow(flow) {
122
+ if (flow === "one_time_payment" || flow === "setup_reusable_method" || flow === "off_session_payment") {
123
+ return flow;
124
+ }
125
+ throw new Error(`Unsupported Stripe provider flow: ${flow}`);
126
+ }
127
+ function metadataFor(input, flow) {
128
+ const metadata = {
129
+ source: "openlup.payment-control",
130
+ paymentIntentId: input.paymentIntentId,
131
+ orderRef: input.orderRef,
132
+ providerRequestFingerprint: input.providerRequestFingerprint,
133
+ providerFlow: flow,
134
+ };
135
+ if (input.clientId)
136
+ metadata.clientId = input.clientId;
137
+ if (input.mode === "subscription_cycle")
138
+ metadata.mode = input.mode;
139
+ return metadata;
140
+ }
141
+ /**
142
+ * What the durable attempt row is allowed to claim about this execution.
143
+ *
144
+ * Takes the intent, not just its status, because the two states sharing
145
+ * `requires_payment_method` differ only in what else is on the object: a fresh
146
+ * intent the payer never confirmed, and one an issuer has just refused (which
147
+ * carries a payment error and usually a charge).
148
+ *
149
+ * `processing` claims money is moving, and a catch-all used to reach it — so a
150
+ * never-confirmed intent was recorded as a charge in flight, and a payer was
151
+ * told their payment was already processing when no issuer had been asked.
152
+ * `sent_to_provider` is the truth: the request reached the provider, no outcome
153
+ * is known. It belongs to every live-attempt set the platform keeps, so the
154
+ * correction costs no watchdog coverage, and it is fail-closed at the
155
+ * provider-attempt admission gate exactly as `processing` is — admission does
156
+ * not widen by one state.
157
+ *
158
+ * The catch-all stays `processing` on purpose: an unrecognised provider status is
159
+ * not evidence that nothing happened.
160
+ */
161
+ function attemptStatusFromStripe(intent) {
162
+ if (intent.status === "requires_action" || intent.status === "requires_confirmation") {
163
+ return "requires_action";
164
+ }
165
+ if (noIssuerWasAsked(intent))
166
+ return "sent_to_provider";
167
+ return "processing";
168
+ }
169
+ function nextActionKindFromStripe(intent) {
170
+ if (intent.status !== "requires_action" && intent.status !== "requires_confirmation")
171
+ return null;
172
+ if (intent.next_action?.type === "redirect_to_url")
173
+ return "redirect";
174
+ if (intent.next_action?.type === "use_stripe_sdk")
175
+ return "3ds_challenge";
176
+ return "sca_required";
177
+ }
178
+ function canonicalStripeEventType(stripeEventType, object) {
179
+ if (stripeEventType === "payment_intent.succeeded")
180
+ return "payment.succeeded";
181
+ if (stripeEventType === "payment_intent.payment_failed" || stripeEventType === "payment_intent.canceled") {
182
+ return "payment.failed";
183
+ }
184
+ if (stripeEventType === "payment_intent.requires_action")
185
+ return "payment.requires_action";
186
+ if (stripeEventType === "charge.refunded")
187
+ return "payment.refunded";
188
+ if (stripeEventType.startsWith("charge.dispute."))
189
+ return "payment.disputed";
190
+ const status = readOptionalString(object, "status");
191
+ if (status === "requires_action")
192
+ return "payment.requires_action";
193
+ throw new Error(`Unsupported Stripe webhook event: ${stripeEventType}`);
194
+ }
195
+ function readObject(value, key) {
196
+ const raw = value[key];
197
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
198
+ throw new Error(`Stripe payload missing object ${key}`);
199
+ }
200
+ return raw;
201
+ }
202
+ function readOptionalObject(value, key) {
203
+ const raw = value[key];
204
+ return raw && typeof raw === "object" && !Array.isArray(raw) ? raw : null;
205
+ }
206
+ function readString(value, key) {
207
+ const raw = value[key];
208
+ if (typeof raw !== "string" || raw.length === 0) {
209
+ throw new Error(`Stripe payload missing string ${key}`);
210
+ }
211
+ return raw;
212
+ }
213
+ function readOptionalString(value, key) {
214
+ const raw = value[key];
215
+ return typeof raw === "string" && raw.length > 0 ? raw : undefined;
216
+ }
217
+ function readOptionalNumber(value, key) {
218
+ const raw = value[key];
219
+ return typeof raw === "number" ? raw : undefined;
220
+ }
@@ -0,0 +1,14 @@
1
+ import { type ProviderWebhookWithMethodLifecycle } from "./methodFacts.js";
2
+ /**
3
+ * Convert a verified raw Stripe webhook payload into the plural handler's
4
+ * `NormalizedProviderPaymentWebhook` shape.
5
+ *
6
+ * Reusable method extraction (Wave C): populated for `setup_intent.succeeded`
7
+ * always, and for `payment_intent.succeeded` when `setup_future_usage` was
8
+ * requested at PaymentIntent creation. Requires `metadata.clientId` to be set
9
+ * (the adapter does this when the runtime caller passes `clientId` through
10
+ * `PaymentExecutionInput`). When metadata is missing, `reusableMethod` stays
11
+ * null and the plural handler skips the method-ref upsert — safe degradation
12
+ * rather than crashing the webhook.
13
+ */
14
+ export declare function normalizeStripeWebhookEvent(rawPayload: Record<string, unknown>): ProviderWebhookWithMethodLifecycle;
@@ -0,0 +1,243 @@
1
+ import { stripeWebhookPayload } from "./stripeFailureEvidence.js";
2
+ import { stripeRefundEvidence } from "./stripeRefundEvidence.js";
3
+ import { expiryInstantFromMonthYear } from "@openlup/core/payment";
4
+ import { methodFactSnapshotKeys } from "./methodFacts.js";
5
+ const PROVIDER_KIND = "stripe";
6
+ const METHOD_LIFECYCLE_EVENTS = {
7
+ "payment_method.attached": { kind: "method_registered", canonical: "setup.succeeded" },
8
+ "payment_method.automatically_updated": { kind: "method_updated", canonical: "setup.succeeded" },
9
+ "payment_method.detached": { kind: "method_revoked", canonical: "setup.failed" },
10
+ };
11
+ const SUPPORTED_EVENT_TYPES = new Set([
12
+ "payment.requires_action",
13
+ "payment.succeeded",
14
+ "payment.failed",
15
+ "payment.refunded",
16
+ "payment.disputed",
17
+ "setup.succeeded",
18
+ "setup.failed",
19
+ "setup.requires_action",
20
+ ]);
21
+ /**
22
+ * Convert a verified raw Stripe webhook payload into the plural handler's
23
+ * `NormalizedProviderPaymentWebhook` shape.
24
+ *
25
+ * Reusable method extraction (Wave C): populated for `setup_intent.succeeded`
26
+ * always, and for `payment_intent.succeeded` when `setup_future_usage` was
27
+ * requested at PaymentIntent creation. Requires `metadata.clientId` to be set
28
+ * (the adapter does this when the runtime caller passes `clientId` through
29
+ * `PaymentExecutionInput`). When metadata is missing, `reusableMethod` stays
30
+ * null and the plural handler skips the method-ref upsert — safe degradation
31
+ * rather than crashing the webhook.
32
+ */
33
+ export function normalizeStripeWebhookEvent(rawPayload) {
34
+ const providerEventId = readString(rawPayload, "id");
35
+ const eventType = readString(rawPayload, "type");
36
+ const data = readObject(rawPayload, "data");
37
+ const object = readObject(data, "object");
38
+ const canonicalEventType = mapStripeEventType(eventType, object);
39
+ const providerPaymentId = resolveLinkagePaymentId(eventType, object);
40
+ // Payment-control compares this against the intent's `amount_cents` (the order
41
+ // total) as a tamper guard, so we want the PaymentIntent's authorized `amount`,
42
+ // not `amount_received`. On a declined/canceled PI Stripe reports
43
+ // `amount_received: 0` while `amount` still holds the full total — and `??`
44
+ // does NOT treat `0` as nullish, so reading `amount_received` first yielded a
45
+ // `0` that tripped the amount-mismatch guard and left every failed event
46
+ // unreconciled (intent stuck `processing`, inventory holds leaked). Prefer
47
+ // `amount`; fall back to `amount_received` only when `amount` is absent.
48
+ const amountMinor = readOptionalNumber(object, "amount") ??
49
+ readOptionalNumber(object, "amount_received") ??
50
+ null;
51
+ const currency = readOptionalString(object, "currency")?.toUpperCase() ?? null;
52
+ const occurredAt = readOccurredAt(rawPayload);
53
+ const reusableMethod = extractReusableMethod(canonicalEventType, object);
54
+ const refund = stripeRefundEvidence(eventType, object);
55
+ return {
56
+ provider: PROVIDER_KIND,
57
+ providerEventId,
58
+ eventType: canonicalEventType,
59
+ providerPaymentId,
60
+ paymentIntentId: null,
61
+ paymentAttemptId: null,
62
+ amountMinor,
63
+ currency,
64
+ occurredAt,
65
+ refundKind: refund.refundKind,
66
+ rawPayload: {
67
+ ...stripeWebhookPayload({ eventId: providerEventId, eventType, providerPaymentId, amountMinor, currency }, object),
68
+ ...(refund.snapshot ? { stripeRefundSnapshot: refund.snapshot } : {}),
69
+ },
70
+ reusableMethod,
71
+ methodLifecycle: extractMethodLifecycle(eventType, object, providerEventId, occurredAt),
72
+ };
73
+ }
74
+ /** The neutral transition this delivery reports, or null when it reports none. */
75
+ function extractMethodLifecycle(rawEventType, object, providerEventId, occurredAt) {
76
+ const mapping = METHOD_LIFECYCLE_EVENTS[rawEventType];
77
+ // On a method-family event the webhook object IS the method, so its own id is
78
+ // the reference the stored row is keyed by.
79
+ const providerMethodRef = readOptionalString(object, "id");
80
+ if (!mapping || !providerMethodRef)
81
+ return null;
82
+ const replacement = readMethodFacts(object);
83
+ return { kind: mapping.kind, providerKind: PROVIDER_KIND, providerMethodRef, providerEventId, occurredAt, replacement };
84
+ }
85
+ /**
86
+ * Scheme label, trailing digits and expiry, from whichever of the three shapes
87
+ * the payload carries them in: the method object itself at the top level, an
88
+ * expanded method one level down, or a charge-shaped object's method details.
89
+ * A payload carrying none of them yields null, not a row of empty facts.
90
+ */
91
+ function readMethodFacts(object) {
92
+ const card = readOptionalObject(object, "card")
93
+ ?? readOptionalObject(readOptionalObject(object, "payment_method") ?? {}, "card")
94
+ ?? readOptionalObject(readOptionalObject(object, "payment_method_details") ?? {}, "card");
95
+ if (!card)
96
+ return null;
97
+ const schemeLabel = readOptionalString(card, "brand") ?? null;
98
+ const lastDigits = readOptionalString(card, "last4") ?? null;
99
+ const expiresAt = expiryInstantFromMonthYear(readOptionalNumber(card, "exp_month") ?? null, readOptionalNumber(card, "exp_year") ?? null);
100
+ if (!schemeLabel && !lastDigits && !expiresAt)
101
+ return null;
102
+ return { schemeLabel, lastDigits, expiresAt };
103
+ }
104
+ /**
105
+ * Resolve the provider payment id used to LINK this event to the local payment
106
+ * attempt. Payment-control matches inbound events to an attempt by
107
+ * `commerce_payment_attempts.provider_attempt_id`, which holds the Stripe
108
+ * PaymentIntent id (`pi_…`).
109
+ *
110
+ * For `payment_intent.*` events the webhook object IS the PaymentIntent, so its
111
+ * `id` is already the linkage key. For `charge.*` (incl. `charge.dispute.*`)
112
+ * the object is a Charge (`ch_…`) or Dispute (`dp_…`) whose own id never
113
+ * matches an attempt — but it carries `payment_intent` pointing back at the PI.
114
+ * Using that field is what lets `charge.refunded` / `charge.dispute.created`
115
+ * resolve to the order and drive `applyPaymentResult`; without it refunds and
116
+ * disputes were ingested but silently never applied. Falls back to the object
117
+ * id when `payment_intent` is absent (defensive — keeps the event recordable).
118
+ */
119
+ function resolveLinkagePaymentId(stripeEventType, object) {
120
+ if (stripeEventType.startsWith("charge.")) {
121
+ const paymentIntentId = readOptionalString(object, "payment_intent");
122
+ if (paymentIntentId)
123
+ return paymentIntentId;
124
+ }
125
+ return readString(object, "id");
126
+ }
127
+ function mapStripeEventType(stripeEventType, object) {
128
+ if (stripeEventType === "payment_intent.succeeded")
129
+ return "payment.succeeded";
130
+ if (stripeEventType === "payment_intent.payment_failed")
131
+ return "payment.failed";
132
+ if (stripeEventType === "payment_intent.canceled")
133
+ return "payment.failed";
134
+ if (stripeEventType === "payment_intent.requires_action")
135
+ return "payment.requires_action";
136
+ if (stripeEventType === "setup_intent.succeeded")
137
+ return "setup.succeeded";
138
+ if (stripeEventType === "setup_intent.setup_failed")
139
+ return "setup.failed";
140
+ if (stripeEventType === "setup_intent.requires_action")
141
+ return "setup.requires_action";
142
+ if (stripeEventType === "charge.refunded")
143
+ return "payment.refunded";
144
+ if (stripeEventType.startsWith("charge.dispute."))
145
+ return "payment.disputed";
146
+ const methodLifecycle = METHOD_LIFECYCLE_EVENTS[stripeEventType];
147
+ if (methodLifecycle)
148
+ return methodLifecycle.canonical;
149
+ const status = readOptionalString(object, "status");
150
+ if (status === "requires_action")
151
+ return "payment.requires_action";
152
+ const guess = `payment.${stripeEventType}`;
153
+ if (SUPPORTED_EVENT_TYPES.has(guess))
154
+ return guess;
155
+ throw new Error(`Unsupported Stripe webhook event: ${stripeEventType}`);
156
+ }
157
+ function extractReusableMethod(canonicalEventType, object) {
158
+ const isSetupSucceeded = canonicalEventType === "setup.succeeded";
159
+ const isPaymentSucceededWithSaveCard = canonicalEventType === "payment.succeeded" &&
160
+ readOptionalString(object, "setup_future_usage") === "off_session";
161
+ if (!isSetupSucceeded && !isPaymentSucceededWithSaveCard)
162
+ return null;
163
+ const customer = readOptionalString(object, "customer");
164
+ // The method arrives as a bare id, or as an expanded object when the payload
165
+ // also carries its facts; both address the same stored method.
166
+ const paymentMethod = readOptionalString(object, "payment_method")
167
+ ?? readOptionalString(readOptionalObject(object, "payment_method") ?? {}, "id");
168
+ const metadata = readOptionalObject(object, "metadata");
169
+ const clientId = metadata ? readOptionalString(metadata, "clientId") : undefined;
170
+ const subscriptionId = metadata ? readOptionalString(metadata, "subscriptionId") : undefined;
171
+ const mode = metadata ? readOptionalString(metadata, "mode") : undefined;
172
+ const recoveryCaseId = metadata ? readOptionalString(metadata, "recoveryCaseId") : undefined;
173
+ const metadataSource = metadata ? readOptionalString(metadata, "source") : undefined;
174
+ if (!customer || !paymentMethod || !clientId)
175
+ return null;
176
+ return {
177
+ clientId,
178
+ // W11.7 Wave D-4a — recovery setup-method flow tags `subscriptionId` in
179
+ // SetupIntent metadata so this upsert binds the new method to the
180
+ // subscription the dunning case targets. One-time + initial save-card
181
+ // (Wave C) flows leave the field null because no subscription exists yet.
182
+ subscriptionId: subscriptionId ?? null,
183
+ providerCustomerRef: customer,
184
+ providerMethodRef: paymentMethod,
185
+ providerMandateRef: null,
186
+ methodKind: "card",
187
+ status: "active",
188
+ consentSnapshot: {
189
+ source: "stripe_webhook",
190
+ eventType: canonicalEventType,
191
+ checkoutMode: mode ?? null,
192
+ recoveryCaseId: recoveryCaseId ?? null,
193
+ metadataSource: metadataSource ?? null,
194
+ // Additive facts only, and only when the payload actually carried them.
195
+ // The setup event carries the method's id and nothing else, so this is
196
+ // usually empty there and filled later by the method-family delivery.
197
+ ...methodFactSnapshotKeys(readMethodFacts(object)),
198
+ },
199
+ };
200
+ }
201
+ function readOccurredAt(rawPayload) {
202
+ const created = rawPayload.created;
203
+ if (typeof created === "number" && Number.isFinite(created)) {
204
+ return new Date(created * 1000).toISOString();
205
+ }
206
+ if (typeof created === "string") {
207
+ const parsed = new Date(created);
208
+ if (!Number.isNaN(parsed.getTime()))
209
+ return parsed.toISOString();
210
+ }
211
+ // Stripe always sends `created` on event payloads; fall back defensively if
212
+ // missing rather than refusing the event — downstream RPC still records the
213
+ // signature-verified facts.
214
+ return new Date(0).toISOString();
215
+ }
216
+ function readObject(value, key) {
217
+ const raw = value[key];
218
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
219
+ throw new Error(`Stripe payload missing object ${key}`);
220
+ }
221
+ return raw;
222
+ }
223
+ function readOptionalObject(value, key) {
224
+ const raw = value[key];
225
+ return raw && typeof raw === "object" && !Array.isArray(raw)
226
+ ? raw
227
+ : null;
228
+ }
229
+ function readString(value, key) {
230
+ const raw = value[key];
231
+ if (typeof raw !== "string" || raw.length === 0) {
232
+ throw new Error(`Stripe payload missing string ${key}`);
233
+ }
234
+ return raw;
235
+ }
236
+ function readOptionalString(value, key) {
237
+ const raw = value[key];
238
+ return typeof raw === "string" && raw.length > 0 ? raw : undefined;
239
+ }
240
+ function readOptionalNumber(value, key) {
241
+ const raw = value[key];
242
+ return typeof raw === "number" ? raw : undefined;
243
+ }
@@ -0,0 +1,14 @@
1
+ export declare const STRIPE_TIMESTAMP_TOLERANCE_SECONDS = 300;
2
+ export declare function verifyStripeSignature(input: {
3
+ rawBody: string;
4
+ signatureHeader: string | undefined;
5
+ /**
6
+ * One or many endpoint secrets to accept. Multi-secret support lets a single
7
+ * route accept signatures from both the dashboard webhook endpoint and a
8
+ * local `stripe listen` CLI tunnel; the signature is accepted if ANY secret
9
+ * verifies. An empty array (or missing single secret) fails closed.
10
+ */
11
+ endpointSecret: string | readonly string[];
12
+ /** Injectable clock (UTC seconds) for deterministic tests; defaults to now. */
13
+ now?: () => number;
14
+ }): boolean;
@@ -0,0 +1,43 @@
1
+ import { createHmac, timingSafeEqual } from "node:crypto";
2
+ export const STRIPE_TIMESTAMP_TOLERANCE_SECONDS = 300;
3
+ export function verifyStripeSignature(input) {
4
+ const parts = Object.fromEntries((input.signatureHeader ?? "")
5
+ .split(",")
6
+ .map((part) => part.split("="))
7
+ .filter(([key, value]) => key && value));
8
+ const timestamp = parts.t;
9
+ const signature = parts.v1;
10
+ if (!timestamp || !signature)
11
+ return false;
12
+ // Reject replays outside the tolerance window before doing the HMAC work. A
13
+ // valid signature on a stale timestamp (captured-and-replayed event) must not
14
+ // pass; the future-skew bound tolerates small clock drift between Stripe and us.
15
+ const timestampSec = Number(timestamp);
16
+ if (!Number.isFinite(timestampSec))
17
+ return false;
18
+ const now = input.now ?? (() => Math.floor(Date.now() / 1000));
19
+ const ageSec = now() - timestampSec;
20
+ if (ageSec > STRIPE_TIMESTAMP_TOLERANCE_SECONDS)
21
+ return false;
22
+ if (ageSec < -STRIPE_TIMESTAMP_TOLERANCE_SECONDS)
23
+ return false;
24
+ const secrets = Array.isArray(input.endpointSecret)
25
+ ? input.endpointSecret
26
+ : input.endpointSecret
27
+ ? [input.endpointSecret]
28
+ : [];
29
+ return secrets.some((secret) => {
30
+ if (!secret)
31
+ return false;
32
+ const expected = hmac(`${timestamp}.${input.rawBody}`, secret);
33
+ return timingSafeStringEqual(signature, expected);
34
+ });
35
+ }
36
+ function hmac(value, secret) {
37
+ return createHmac("sha256", secret).update(value).digest("hex");
38
+ }
39
+ function timingSafeStringEqual(left, right) {
40
+ const leftBuffer = Buffer.from(left);
41
+ const rightBuffer = Buffer.from(right);
42
+ return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer);
43
+ }
@@ -0,0 +1,37 @@
1
+ export interface TpayBlikAliasRegistrationInput {
2
+ orderRef: string;
3
+ amountMinor: number;
4
+ currency: string;
5
+ customerEmail: string;
6
+ staticWebhookUrl: string;
7
+ consentRef: string;
8
+ }
9
+ export interface TpayBlikAliasChargeInput {
10
+ orderRef: string;
11
+ payId: string;
12
+ amountMinor: number;
13
+ currency: string;
14
+ staticWebhookUrl: string;
15
+ }
16
+ export declare function buildTpayBlikAliasRegistrationDraft(input: TpayBlikAliasRegistrationInput): {
17
+ provider: string;
18
+ requestKind: string;
19
+ orderRef: string;
20
+ amount: number;
21
+ currency: string;
22
+ customer: {
23
+ email: string;
24
+ };
25
+ webhookUrl: string;
26
+ consentRef: string;
27
+ fallbackRequiredWhenAliasUnavailable: boolean;
28
+ };
29
+ export declare function buildTpayBlikAliasChargeDraft(input: TpayBlikAliasChargeInput): {
30
+ provider: string;
31
+ requestKind: string;
32
+ orderRef: string;
33
+ payId: string;
34
+ amount: number;
35
+ currency: string;
36
+ webhookUrl: string;
37
+ };
@@ -0,0 +1,24 @@
1
+ export function buildTpayBlikAliasRegistrationDraft(input) {
2
+ return {
3
+ provider: "tpay",
4
+ requestKind: "blik_alias_registration",
5
+ orderRef: input.orderRef,
6
+ amount: input.amountMinor,
7
+ currency: input.currency,
8
+ customer: { email: input.customerEmail },
9
+ webhookUrl: input.staticWebhookUrl,
10
+ consentRef: input.consentRef,
11
+ fallbackRequiredWhenAliasUnavailable: true,
12
+ };
13
+ }
14
+ export function buildTpayBlikAliasChargeDraft(input) {
15
+ return {
16
+ provider: "tpay",
17
+ requestKind: "blik_alias_charge",
18
+ orderRef: input.orderRef,
19
+ payId: input.payId,
20
+ amount: input.amountMinor,
21
+ currency: input.currency,
22
+ webhookUrl: input.staticWebhookUrl,
23
+ };
24
+ }
@@ -0,0 +1,3 @@
1
+ import type { PaymentAdapterCapabilities } from "../contracts/index.js";
2
+ /** Bank-redirect, one-time-code and unattended Model O attempts are supported with host payer/method input; account availability remains verification_pending. */
3
+ export declare const tpayPaymentCapabilities: Readonly<PaymentAdapterCapabilities>;
@@ -0,0 +1,18 @@
1
+ /** Bank-redirect, one-time-code and unattended Model O attempts are supported with host payer/method input; account availability remains verification_pending. */
2
+ export const tpayPaymentCapabilities = Object.freeze({
3
+ providerKind: "tpay",
4
+ captureFlows: Object.freeze([
5
+ Object.freeze({ kind: "bank_redirect", handoff: "redirect", methodFamilies: Object.freeze(["bank-redirect"]) }),
6
+ Object.freeze({ kind: "one_time_code", handoff: "customer-authorization", methodFamilies: Object.freeze(["one-time-code"]) }),
7
+ ]),
8
+ requiresStoredMandateEvidence: true,
9
+ assessMandate: snapshot => snapshot.recurringModel === "O" ? { chargeable: true }
10
+ : { chargeable: false, blockReason: "mandate_model_unsupported_for_unattended_charge" },
11
+ unattendedChargeFlow: "recurring_charge",
12
+ payerContext: Object.freeze({ requiresPayerBlock: true, customerRefFallsBackToContactEmail: false }),
13
+ methodHealth: Object.freeze({ requiresCustomerRef: false, requiredMethodKind: "blik_payid", requiresPayerContact: true }),
14
+ terminalOutcomeReporting: Object.freeze({ kind: "attempt_records", silenceBecomesSuspectAfterMinutes: 360 }),
15
+ mandateUpsertIsSubscriptionScoped: true,
16
+ supportedCurrencies: Object.freeze(["PLN"]), unattended: "supported", readback: "supported", close: "unsupported",
17
+ exactAbsence: "unsupported", refundObservation: "supported", refundCreate: "unsupported", authorizationCaptureSplit: "unsupported",
18
+ });