@openlup/platform 0.19.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 (217) hide show
  1. package/CHANGELOG.md +161 -231
  2. package/README.md +182 -8
  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/dist/shipping/bindings.d.ts +5 -0
  139. package/dist/shipping/bindings.js +12 -2
  140. package/dist/shipping/index.d.ts +14 -0
  141. package/dist/shipping/index.js +5 -0
  142. package/package.json +22 -2
  143. package/release-gates.json +64 -3
  144. package/sql/20260810180000_payment_settlement_rail.sql +574 -0
  145. package/sql/20260815150000_payment_truth_reconciliation.sql +479 -0
  146. package/sql/20260826100000_late_payment_shifts_the_subscription_cycle.sql +215 -0
  147. package/sql/20261021200000_shipping_flat_rate_seed.sql +1 -0
  148. package/src/composition/composeBundle.ts +9 -0
  149. package/src/composition/index.ts +1 -0
  150. package/src/payments/adapterConformance.ts +118 -0
  151. package/src/payments/bindings.ts +17 -0
  152. package/src/payments/checkoutActivePaymentActionResolver.ts +97 -0
  153. package/src/payments/checkoutRecoveryPaymentResolver.ts +108 -0
  154. package/src/payments/contracts/adapter.ts +96 -0
  155. package/src/payments/contracts/conformance.ts +171 -0
  156. package/src/payments/contracts/evidence.ts +142 -0
  157. package/src/payments/contracts/index.ts +54 -0
  158. package/src/payments/contracts/preparedAttempt.ts +86 -0
  159. package/src/payments/contracts/reconciliation.ts +296 -0
  160. package/src/payments/contracts/repair.ts +95 -0
  161. package/src/payments/contracts/truth.ts +108 -0
  162. package/src/payments/eventConformanceRunner.ts +149 -0
  163. package/src/payments/index.ts +21 -0
  164. package/src/payments/paymentAttemptSilenceWindow.ts +26 -0
  165. package/src/payments/paymentMethodLifecycle.ts +50 -0
  166. package/src/payments/paymentProviderReconciliationAbsenceReopen.ts +130 -0
  167. package/src/payments/paymentProviderReconciliationEvidence.ts +117 -0
  168. package/src/payments/paymentProviderReconciliationWorker.ts +291 -0
  169. package/src/payments/paymentTruth.ts +131 -0
  170. package/src/payments/paymentVerifyNowHandler.ts +94 -0
  171. package/src/payments/paymentVerifyNowService.ts +125 -0
  172. package/src/payments/paymentWebhookPostProcessing.ts +36 -0
  173. package/src/payments/postgresPaymentTruth.ts +149 -0
  174. package/src/payments/preparedAttempt.ts +90 -0
  175. package/src/payments/preparedAttemptFinalization.ts +13 -0
  176. package/src/payments/schemaCapabilities.ts +10 -0
  177. package/src/payments/sql.ts +6 -0
  178. package/src/payments/stripe/classifyDecline.ts +62 -0
  179. package/src/payments/stripe/declineFailureHints.ts +89 -0
  180. package/src/payments/stripe/donorContracts.ts +87 -0
  181. package/src/payments/stripe/executionDecline.ts +126 -0
  182. package/src/payments/stripe/index.ts +235 -0
  183. package/src/payments/stripe/intentMapper.ts +58 -0
  184. package/src/payments/stripe/methodFacts.ts +21 -0
  185. package/src/payments/stripe/paymentFailureEvidence.ts +58 -0
  186. package/src/payments/stripe/stripeFailureEvidence.ts +115 -0
  187. package/src/payments/stripe/stripeIntentConfirmationEvidence.ts +34 -0
  188. package/src/payments/stripe/stripePaymentReconciliationProvider.ts +263 -0
  189. package/src/payments/stripe/stripeRefundEvidence.ts +19 -0
  190. package/src/payments/stripe/stripeSandboxPaymentExecutionAdapter.ts +283 -0
  191. package/src/payments/stripe/stripeWebhookNormalizer.ts +277 -0
  192. package/src/payments/stripe/webhookSignature.ts +55 -0
  193. package/src/payments/tpay/blikRecurringMapper.ts +42 -0
  194. package/src/payments/tpay/capabilities.ts +21 -0
  195. package/src/payments/tpay/classifyDecline.ts +62 -0
  196. package/src/payments/tpay/declineFailureHints.ts +90 -0
  197. package/src/payments/tpay/donorContracts.ts +104 -0
  198. package/src/payments/tpay/index.ts +257 -0
  199. package/src/payments/tpay/paymentFailureEvidence.ts +58 -0
  200. package/src/payments/tpay/preparedProviderAttempt.ts +38 -0
  201. package/src/payments/tpay/providerAttemptFailureDiagnostic.ts +99 -0
  202. package/src/payments/tpay/tpayCreateTransactionInput.ts +158 -0
  203. package/src/payments/tpay/tpayDeclineEvidence.ts +233 -0
  204. package/src/payments/tpay/tpayDispatchFailure.ts +186 -0
  205. package/src/payments/tpay/tpayFailureEvidence.ts +75 -0
  206. package/src/payments/tpay/tpayHttpClient.ts +290 -0
  207. package/src/payments/tpay/tpayHttpFailure.ts +186 -0
  208. package/src/payments/tpay/tpayPaymentExecutionAdapter.ts +297 -0
  209. package/src/payments/tpay/tpayPaymentReconciliationProvider.ts +285 -0
  210. package/src/payments/tpay/tpayTransactionPayload.ts +207 -0
  211. package/src/payments/tpay/wallClockTime.ts +57 -0
  212. package/src/payments/tpay/webhookJwsVerifier.ts +82 -0
  213. package/src/payments/tpay/webhookNormalizer.ts +279 -0
  214. package/src/payments/verifiedEvidenceProjection.ts +179 -0
  215. package/src/payments/verifiedIngress.ts +317 -0
  216. package/src/shipping/bindings.ts +18 -2
  217. package/src/shipping/index.ts +6 -0
@@ -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
+ });
@@ -0,0 +1,46 @@
1
+ import { type PaymentExecutionProviderDecline, type PaymentFailureClassification } from "@openlup/core/payment";
2
+ export declare const DECLINE_FAILURE_REASONS: {
3
+ /** The payer's bank cannot register a reusable mandate — offer a card instead. */
4
+ readonly mandateUnsupported: "blik_recurring_unsupported_bank";
5
+ /** Declined for a reason the provider did not distinguish. */
6
+ readonly generic: "provider_declined";
7
+ };
8
+ /** Maps a provider refusal to the stable code persisted as `failure_reason`. */
9
+ export declare function declineFailureReason(decline: PaymentExecutionProviderDecline): string;
10
+ /**
11
+ * The one place a refused payment gets a class, for every rail that consumes a
12
+ * synchronous decline: interactive checkout, checkout recovery, and the
13
+ * off-session renewal cron.
14
+ *
15
+ * Provider-agnostic on purpose. Every code the adapter understood has already
16
+ * been translated into `adviceCode` and `neutralReasonHints` by the adapter that
17
+ * owns the vocabulary, so nothing here needs to know which provider refused.
18
+ *
19
+ * The one reading this seam adds is the mandate flag. `mandateUnsupported` is a
20
+ * neutral capability statement any adapter may set, and it is what produces the
21
+ * scheme-named failure reason below; asserting the matching hint is what turns
22
+ * that reason into `mandate_dead` instead of leaving it unclassified. Doing it
23
+ * here rather than per adapter means a second adapter setting the same flag gets
24
+ * the same class without repeating the mapping.
25
+ *
26
+ * DISPLAY-LOAD-BEARING, and NOT a cadence input. Three live readers turn the
27
+ * class this seam decides into what the payer is told: `dunningFailureClassPort`
28
+ * reads it off the case, `customerAccountActionRequiredReadModel` selects it into
29
+ * the account surface, and `paymentRecoverySetupHandler` renders it as the cause
30
+ * sentence on the recovery page. What still does NOT read it is the retry ladder:
31
+ * no cadence, retry decision or scheduling branch consults the class, and that
32
+ * wave stays gated on validating this taxonomy against the historical corpus.
33
+ *
34
+ * `failureReasonKey` overrides the reason this seam would derive itself, and is
35
+ * supplied by a rail that persists a DIFFERENT reason string with the attempt:
36
+ * the reconciliation rail writes its own adapter-prefixed key, not
37
+ * `provider_declined`. That key is the classifier's weakest evidence tier, so
38
+ * the override changes a class only where the rail's own key is mapped; what it
39
+ * always changes is `decidedBy`, and that is the point. The historical-corpus
40
+ * replay reproduces a verdict from the stored attempt row, so the key named in
41
+ * the telemetry has to be the key actually on that row rather than one this seam
42
+ * invented for itself.
43
+ */
44
+ export declare function classifyDecline(decline: PaymentExecutionProviderDecline, options?: {
45
+ failureReasonKey?: string;
46
+ }): PaymentFailureClassification;
@@ -0,0 +1,58 @@
1
+ import { classifyPaymentFailure } from "@openlup/core/payment";
2
+ export const DECLINE_FAILURE_REASONS = {
3
+ /** The payer's bank cannot register a reusable mandate — offer a card instead. */
4
+ mandateUnsupported: "blik_recurring_unsupported_bank",
5
+ /** Declined for a reason the provider did not distinguish. */
6
+ generic: "provider_declined",
7
+ };
8
+ /** Maps a provider refusal to the stable code persisted as `failure_reason`. */
9
+ export function declineFailureReason(decline) {
10
+ return decline.mandateUnsupported
11
+ ? DECLINE_FAILURE_REASONS.mandateUnsupported
12
+ : DECLINE_FAILURE_REASONS.generic;
13
+ }
14
+ /**
15
+ * The one place a refused payment gets a class, for every rail that consumes a
16
+ * synchronous decline: interactive checkout, checkout recovery, and the
17
+ * off-session renewal cron.
18
+ *
19
+ * Provider-agnostic on purpose. Every code the adapter understood has already
20
+ * been translated into `adviceCode` and `neutralReasonHints` by the adapter that
21
+ * owns the vocabulary, so nothing here needs to know which provider refused.
22
+ *
23
+ * The one reading this seam adds is the mandate flag. `mandateUnsupported` is a
24
+ * neutral capability statement any adapter may set, and it is what produces the
25
+ * scheme-named failure reason below; asserting the matching hint is what turns
26
+ * that reason into `mandate_dead` instead of leaving it unclassified. Doing it
27
+ * here rather than per adapter means a second adapter setting the same flag gets
28
+ * the same class without repeating the mapping.
29
+ *
30
+ * DISPLAY-LOAD-BEARING, and NOT a cadence input. Three live readers turn the
31
+ * class this seam decides into what the payer is told: `dunningFailureClassPort`
32
+ * reads it off the case, `customerAccountActionRequiredReadModel` selects it into
33
+ * the account surface, and `paymentRecoverySetupHandler` renders it as the cause
34
+ * sentence on the recovery page. What still does NOT read it is the retry ladder:
35
+ * no cadence, retry decision or scheduling branch consults the class, and that
36
+ * wave stays gated on validating this taxonomy against the historical corpus.
37
+ *
38
+ * `failureReasonKey` overrides the reason this seam would derive itself, and is
39
+ * supplied by a rail that persists a DIFFERENT reason string with the attempt:
40
+ * the reconciliation rail writes its own adapter-prefixed key, not
41
+ * `provider_declined`. That key is the classifier's weakest evidence tier, so
42
+ * the override changes a class only where the rail's own key is mapped; what it
43
+ * always changes is `decidedBy`, and that is the point. The historical-corpus
44
+ * replay reproduces a verdict from the stored attempt row, so the key named in
45
+ * the telemetry has to be the key actually on that row rather than one this seam
46
+ * invented for itself.
47
+ */
48
+ export function classifyDecline(decline, options = {}) {
49
+ const asserted = [...(decline.neutralReasonHints ?? [])];
50
+ if (decline.mandateUnsupported)
51
+ asserted.push("mandateUnsupported");
52
+ return classifyPaymentFailure({
53
+ adviceCode: decline.adviceCode,
54
+ declineCode: decline.declineCode,
55
+ neutralReasonHints: asserted,
56
+ failureReasonKey: options.failureReasonKey ?? declineFailureReason(decline),
57
+ });
58
+ }
@@ -0,0 +1,72 @@
1
+ import type { PaymentFailureHint, PaymentRetryAdviceCode } from "@openlup/core/payment";
2
+ /**
3
+ * ⚠️ UNOFFICIAL INFERENCE — the whole table below.
4
+ *
5
+ * The provider publishes no retryable-versus-terminal classification for these
6
+ * numeric refusal codes. Every row is this adapter's reading of the code's
7
+ * documented MEANING, not a documented retry verdict, and one of them
8
+ * (`"100"`) appears in no published table at all. Treat the table as a
9
+ * hypothesis under test. The class it produces is already DISPLAY-load-bearing:
10
+ * it becomes the sentence the payer reads about their own refusal, so a wrong
11
+ * row here misinforms a customer today. It is still not a cadence input: the
12
+ * dunning programme validates this table against the historical refusal corpus
13
+ * before anything in the retry ladder is allowed to branch on it.
14
+ *
15
+ * The contract that would end the inference is OUTSTANDING, not absent: the
16
+ * dunning programme's letter to the provider asks for the official meaning of
17
+ * these codes, and is tracked there as an open item. Until it is answered, a row
18
+ * may be read more narrowly than it is written — see `assertableHints` in
19
+ * `tpayDeclineEvidence.ts`, where each observation decides how much of its own
20
+ * reading it is entitled to state — but no row may be read more widely.
21
+ *
22
+ * Two rails read this table, and both reach it through the attempt log's
23
+ * `paymentErrorCode`: the reconciliation poll, and — since the 2026-08-26
24
+ * incident — the synchronous execution path, which until then decided a
25
+ * mandate refusal by pattern-matching the refusal MESSAGE.
26
+ *
27
+ * Codes arrive as strings because that is how they leave the transport layer.
28
+ *
29
+ * Two rows carry a decision worth stating rather than a lookup:
30
+ *
31
+ * - `"101"` (refused by the payer) and `"107"` (refused on security grounds)
32
+ * are NOT credential death — the instrument may be in perfect health and
33
+ * the payer may approve the very next charge on it. There is no hint for
34
+ * "terminal for this attempt, instrument fine", so this adapter instead
35
+ * states the retry verdict directly, as advice: an unattended repeat of a
36
+ * refusal the payer or their bank made deliberately is the retry that
37
+ * cannot succeed and still costs a scheme fee. Naming it as advice is also
38
+ * the more honest shape — it records what this adapter concludes about
39
+ * RETRYING, without asserting anything about the instrument.
40
+ * - `"100"` and `"102"` get NO reading. Nothing published says what they mean
41
+ * precisely enough to classify, and `indeterminate` is the correct answer to
42
+ * a code we cannot read. They are named in
43
+ * `KNOWN_UNREADABLE_DECLINE_CODES` below, so their absence stays a recorded
44
+ * decision rather than an oversight.
45
+ */
46
+ export interface DeclineCodeReading {
47
+ hints?: readonly PaymentFailureHint[];
48
+ adviceCode?: PaymentRetryAdviceCode;
49
+ }
50
+ export declare const DECLINE_CODE_READING_TABLE: Readonly<Record<string, DeclineCodeReading>>;
51
+ /**
52
+ * Codes this adapter has SEEN and still cannot read. They are deliberately absent
53
+ * from the table above rather than mapped to a guess: a row there must state
54
+ * vocabulary the kernel declares, and `indeterminate` is the honest answer to a
55
+ * code whose meaning is unconfirmed. Listing them here separates "we have met
56
+ * this code and refuse to invent its semantics" from "we have never seen it",
57
+ * which read identically at the lookup.
58
+ *
59
+ * - `"100"` — observed in production on 2026-08-26, as the first attempt of a
60
+ * refused mandate registration whose second attempt was `"105"`. Nothing
61
+ * published says what it means, so it maps to no hint and no advice.
62
+ * - `"102"` — same standing, from the same unpublished range.
63
+ *
64
+ * A code moving out of this list into the table is a semantics decision backed by
65
+ * evidence, not a lookup edit.
66
+ */
67
+ export declare const KNOWN_UNREADABLE_DECLINE_CODES: readonly string[];
68
+ /**
69
+ * This adapter's reading of one refusal code. Own-property lookup, so an
70
+ * inherited key (`constructor`, `toString`) is not a mapping.
71
+ */
72
+ export declare function declineCodeReading(code: string | undefined): DeclineCodeReading;
@@ -0,0 +1,37 @@
1
+ export const DECLINE_CODE_READING_TABLE = Object.freeze({
2
+ "101": Object.freeze({ adviceCode: "do_not_try_again" }),
3
+ "103": Object.freeze({ hints: Object.freeze(["transient"]) }),
4
+ "104": Object.freeze({ hints: Object.freeze(["transient"]) }),
5
+ "105": Object.freeze({ hints: Object.freeze(["mandateUnsupported"]) }),
6
+ "106": Object.freeze({ hints: Object.freeze(["limitExceeded"]) }),
7
+ "107": Object.freeze({ adviceCode: "do_not_try_again" }),
8
+ });
9
+ /**
10
+ * Codes this adapter has SEEN and still cannot read. They are deliberately absent
11
+ * from the table above rather than mapped to a guess: a row there must state
12
+ * vocabulary the kernel declares, and `indeterminate` is the honest answer to a
13
+ * code whose meaning is unconfirmed. Listing them here separates "we have met
14
+ * this code and refuse to invent its semantics" from "we have never seen it",
15
+ * which read identically at the lookup.
16
+ *
17
+ * - `"100"` — observed in production on 2026-08-26, as the first attempt of a
18
+ * refused mandate registration whose second attempt was `"105"`. Nothing
19
+ * published says what it means, so it maps to no hint and no advice.
20
+ * - `"102"` — same standing, from the same unpublished range.
21
+ *
22
+ * A code moving out of this list into the table is a semantics decision backed by
23
+ * evidence, not a lookup edit.
24
+ */
25
+ export const KNOWN_UNREADABLE_DECLINE_CODES = Object.freeze(["100", "102"]);
26
+ const NO_READING = Object.freeze({});
27
+ /**
28
+ * This adapter's reading of one refusal code. Own-property lookup, so an
29
+ * inherited key (`constructor`, `toString`) is not a mapping.
30
+ */
31
+ export function declineCodeReading(code) {
32
+ if (code === undefined)
33
+ return NO_READING;
34
+ return Object.prototype.hasOwnProperty.call(DECLINE_CODE_READING_TABLE, code)
35
+ ? DECLINE_CODE_READING_TABLE[code]
36
+ : NO_READING;
37
+ }