@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,291 @@
1
+ import { tryReopenPreparedAbsence } from "./paymentProviderReconciliationAbsenceReopen.js";
2
+ import {
3
+ applyResultKey,
4
+ bucket,
5
+ clamp,
6
+ classificationOf,
7
+ evidencePayload,
8
+ observedEvidenceKey,
9
+ preparedAttemptEvidenceKey,
10
+ safeMessage,
11
+ terminalAmountMismatch,
12
+ } from "./paymentProviderReconciliationEvidence.js";
13
+ import { silenceIsOverdue } from "./paymentAttemptSilenceWindow.js";
14
+ import type {
15
+ ClaimedPaymentAttempt,
16
+ PaymentProviderReconciliationPort,
17
+ PaymentProviderReconciliationWorkerInput,
18
+ PaymentProviderReconciliationWorkerResult,
19
+ ProviderReconciliationStatus,
20
+ SilenceWindowLookup,
21
+ } from "./contracts/reconciliation.js";
22
+
23
+ export type {
24
+ ClaimedPaymentAttempt,
25
+ PaymentProviderReconciliationPort,
26
+ PaymentProviderReconciliationProvider,
27
+ PaymentProviderReconciliationWorkerInput,
28
+ PaymentProviderReconciliationWorkerResult,
29
+ ProviderReconciliationStatus,
30
+ ProviderReconciliationStatusKind,
31
+ ReconciliationProviderKind,
32
+ SilenceWindowLookup,
33
+ } from "./contracts/reconciliation.js";
34
+
35
+ const MAX_ATTEMPTS_PER_RUN = 4;
36
+ const DEFAULT_BATCH_SIZE = MAX_ATTEMPTS_PER_RUN;
37
+
38
+ export const DEFAULT_STALE_AFTER_SECONDS = 15 * 60;
39
+
40
+ export async function runPaymentProviderReconciliationWorker(
41
+ input: PaymentProviderReconciliationWorkerInput,
42
+ ): Promise<PaymentProviderReconciliationWorkerResult> {
43
+ const now = input.now ?? new Date().toISOString();
44
+ const staleAfterSeconds = clamp(input.staleAfterSeconds ?? DEFAULT_STALE_AFTER_SECONDS, 60, 86_400);
45
+ // Provider reads and optional prepared-absence probes are sequential and may
46
+ // be slow. Preserve the per-run ceiling even for an oversized requested batch.
47
+ const batchSize = clamp(input.batchSize ?? DEFAULT_BATCH_SIZE, 1, MAX_ATTEMPTS_PER_RUN);
48
+ const claimKey = input.claimKey ?? `payment-provider-reconciliation:${bucket(now)}`;
49
+ const result: PaymentProviderReconciliationWorkerResult = {
50
+ ok: true,
51
+ checked: 0,
52
+ corrected: 0,
53
+ succeeded: 0,
54
+ failed: 0,
55
+ pending: 0,
56
+ unknown: 0,
57
+ ignored: 0,
58
+ dunningOpened: 0,
59
+ replayed: 0,
60
+ providerCalls: 0,
61
+ preparedWithoutProviderAck: 0,
62
+ preparedAttemptsReopened: 0,
63
+ manualReview: 0,
64
+ silenceOverdue: 0,
65
+ amountCurrencyMismatches: 0,
66
+ failures: 0,
67
+ skipped: false,
68
+ };
69
+
70
+ // Prepared attempts can remain operator-manual indefinitely. Bound their
71
+ // share so they can never consume a whole worker batch and starve provider
72
+ // readback for finalized attempts. A batch of one intentionally prioritizes
73
+ // provider readback; normal scheduled runs use the larger default batch.
74
+ const preparedLimit = Math.floor(batchSize / 2);
75
+ const preparedAttempts = preparedLimit > 0
76
+ ? await input.port.claimPreparedAttempts({
77
+ now,
78
+ staleAfterSeconds,
79
+ limit: preparedLimit,
80
+ claimKey: `${claimKey}:prepared`,
81
+ })
82
+ : [];
83
+ for (const attempt of preparedAttempts) {
84
+ const providerStatus: ProviderReconciliationStatus = {
85
+ status: "unknown",
86
+ providerStatus: "prepared_without_provider_ack",
87
+ occurredAt: null,
88
+ failureReason: "prepared_without_provider_ack",
89
+ amountMinor: null,
90
+ currency: null,
91
+ rawPayload: {
92
+ source: "payment-provider-reconciliation.prepared-watchdog.v0",
93
+ provider: attempt.provider,
94
+ providerCallPlanned: true,
95
+ providerAcknowledged: false,
96
+ },
97
+ };
98
+ const evidence = await input.port.recordEvidence({
99
+ idempotencyKey: preparedAttemptEvidenceKey(attempt, now),
100
+ provider: attempt.provider,
101
+ providerPaymentId: null,
102
+ paymentIntentId: attempt.paymentIntentId,
103
+ paymentAttemptId: attempt.paymentAttemptId,
104
+ localStatus: attempt.attemptStatus,
105
+ providerStatus: providerStatus.providerStatus,
106
+ correctionStatus: "failed",
107
+ checkedAt: now,
108
+ payload: evidencePayload(attempt, providerStatus, {
109
+ applied: false,
110
+ operatorReviewRequired: true,
111
+ }),
112
+ });
113
+ if (evidence.replayed) result.replayed += 1;
114
+ result.preparedWithoutProviderAck += 1;
115
+ result.failures += 1;
116
+ result.ok = false;
117
+ result.reason = result.reason ?? "prepared_without_provider_ack";
118
+
119
+ // Auto-reopen after a GENUINE provider-side absence proof. A purely local
120
+ // "no ack recorded" signal cannot rule out the crash window between
121
+ // executionPort.execute() (provider charged) and finalizeProviderAttempt
122
+ // (refs persisted) — reopening in that window would mint a fresh PSP
123
+ // idempotency key and double-charge. Providers without an absence probe
124
+
125
+ if (input.autoReopenPreparedAbsence) {
126
+ const reopened = await tryReopenPreparedAbsence(input, attempt, now);
127
+ if (reopened) result.preparedAttemptsReopened += 1;
128
+ }
129
+ }
130
+
131
+ const remainingLimit = batchSize - preparedAttempts.length;
132
+ const attempts = remainingLimit > 0
133
+ ? await input.port.claimStaleAttempts({
134
+ now,
135
+ staleAfterSeconds,
136
+ limit: remainingLimit,
137
+ claimKey,
138
+ })
139
+ : [];
140
+ result.checked = preparedAttempts.length + attempts.length;
141
+
142
+ for (const attempt of attempts) {
143
+ const provider = input.providers[attempt.provider];
144
+ if (!provider) {
145
+ await recordNonTerminalEvidence(input.port, attempt, now, {
146
+ status: "unknown",
147
+ providerStatus: "provider_not_configured",
148
+ occurredAt: null,
149
+ failureReason: "provider_not_configured",
150
+ amountMinor: null,
151
+ currency: null,
152
+ rawPayload: { provider: attempt.provider, source: "payment-provider-reconciliation.v0" },
153
+ }, "failed");
154
+ result.unknown += 1;
155
+ result.failures += 1;
156
+ result.ok = false;
157
+ result.reason = result.reason ?? `${attempt.provider}_provider_not_configured`;
158
+ continue;
159
+ }
160
+
161
+ let providerStatus: ProviderReconciliationStatus;
162
+ try {
163
+ if (!attempt.providerPaymentId) {
164
+ throw new Error("provider_payment_id_missing");
165
+ }
166
+ result.providerCalls += 1;
167
+ providerStatus = await provider.readPayment({
168
+ providerPaymentId: attempt.providerPaymentId,
169
+ attempt,
170
+ });
171
+ } catch (error) {
172
+ providerStatus = {
173
+ status: "unknown",
174
+ providerStatus: "provider_read_failed",
175
+ occurredAt: null,
176
+ failureReason: safeMessage(error),
177
+ amountMinor: null,
178
+ currency: null,
179
+ rawPayload: {
180
+ provider: attempt.provider,
181
+ providerPaymentId: attempt.providerPaymentId,
182
+ readError: safeMessage(error),
183
+ },
184
+ };
185
+ await recordNonTerminalEvidence(input.port, attempt, now, providerStatus, "failed");
186
+ result.unknown += 1;
187
+ result.failures += 1;
188
+ result.ok = false;
189
+ result.reason = result.reason ?? safeMessage(error);
190
+ continue;
191
+ }
192
+
193
+ if (providerStatus.status === "pending" || providerStatus.status === "unknown") {
194
+ // Polling cannot separate "still running" from "stuck" - both answer
195
+ // non-terminally forever - so ask the rail how long its own silence stays
196
+ // normal and surface the attempt past that. NOT a terminal decision:
197
+ // elapsed time says nothing about whether money moved.
198
+ const overdue = silenceIsOverdue(input.capabilities, attempt, now);
199
+ await recordNonTerminalEvidence(input.port, attempt, now, providerStatus, "observed", overdue);
200
+ if (providerStatus.status === "pending") result.pending += 1;
201
+ else result.unknown += 1;
202
+ if (overdue) {
203
+ result.silenceOverdue += 1;
204
+ result.manualReview += 1;
205
+ }
206
+ continue;
207
+ }
208
+
209
+ const mismatch = terminalAmountMismatch(attempt, providerStatus);
210
+ if (mismatch) {
211
+ await recordNonTerminalEvidence(input.port, attempt, now, {
212
+ ...providerStatus,
213
+ failureReason: mismatch,
214
+ rawPayload: { ...providerStatus.rawPayload, reconciliationMismatch: mismatch },
215
+ }, "failed");
216
+ result.manualReview += 1;
217
+ result.amountCurrencyMismatches += 1;
218
+ continue;
219
+ }
220
+
221
+ const occurredAt = providerStatus.occurredAt ?? now;
222
+ const resultStatus = providerStatus.status;
223
+ try {
224
+ const applied = await input.port.applyTerminalResult({
225
+ idempotencyKey: applyResultKey(attempt, resultStatus),
226
+ expectedOrderId: attempt.orderId,
227
+ expectedPaymentIntentId: attempt.paymentIntentId,
228
+ expectedPaymentAttemptId: attempt.paymentAttemptId,
229
+ expectedPaymentId: attempt.paymentId,
230
+ provider: attempt.provider,
231
+ providerPaymentId: attempt.providerPaymentId,
232
+ localStatus: attempt.attemptStatus,
233
+ providerStatus: providerStatus.providerStatus,
234
+ resultStatus,
235
+ occurredAt,
236
+ failureReason: resultStatus === "failed"
237
+ ? providerStatus.failureReason ?? `provider_reconciliation_${providerStatus.providerStatus}`
238
+ : null,
239
+ ...classificationOf(resultStatus, providerStatus),
240
+ checkedAt: now,
241
+ payload: evidencePayload(attempt, providerStatus, { applied: true, resultStatus }),
242
+ });
243
+ if (applied.replayed) result.replayed += 1;
244
+
245
+ if (applied.correctionStatus === "ignored") {
246
+ result.ignored += 1;
247
+ continue;
248
+ }
249
+
250
+ result.corrected += applied.replayed ? 0 : 1;
251
+ if (resultStatus === "succeeded") result.succeeded += 1;
252
+ else result.failed += 1;
253
+ if (applied.subscriptionWebhookDunning?.opened && !applied.subscriptionWebhookDunning.replayed) {
254
+ result.dunningOpened += 1;
255
+ }
256
+ } catch (error) {
257
+ await recordNonTerminalEvidence(input.port, attempt, now, {
258
+ ...providerStatus,
259
+ failureReason: safeMessage(error),
260
+ rawPayload: { ...providerStatus.rawPayload, applyResultError: safeMessage(error) },
261
+ }, "failed");
262
+ result.failures += 1;
263
+ result.ok = false;
264
+ result.reason = result.reason ?? safeMessage(error);
265
+ }
266
+ }
267
+
268
+ return result;
269
+ }
270
+
271
+ function recordNonTerminalEvidence(
272
+ port: PaymentProviderReconciliationPort,
273
+ attempt: ClaimedPaymentAttempt,
274
+ checkedAt: string,
275
+ providerStatus: ProviderReconciliationStatus,
276
+ correctionStatus: "observed" | "failed",
277
+ silenceOverdue = false,
278
+ ): Promise<{ replayed: boolean }> {
279
+ return port.recordEvidence({
280
+ idempotencyKey: observedEvidenceKey(attempt, providerStatus.status, correctionStatus, checkedAt),
281
+ provider: attempt.provider,
282
+ providerPaymentId: attempt.providerPaymentId,
283
+ paymentIntentId: attempt.paymentIntentId,
284
+ paymentAttemptId: attempt.paymentAttemptId,
285
+ localStatus: attempt.attemptStatus,
286
+ providerStatus: providerStatus.providerStatus,
287
+ correctionStatus,
288
+ checkedAt,
289
+ payload: evidencePayload(attempt, providerStatus, { applied: false, silenceOverdue }),
290
+ });
291
+ }
@@ -0,0 +1,131 @@
1
+ import type { PaymentTruthRefusalClass, PaymentSourceEvidence, PaymentTruthEvent, PaymentReconciliationResult, CanonicalPaymentSource } from "./contracts/truth.js";
2
+ import { createHash } from "node:crypto";
3
+
4
+ const PAYMENT_TRUTH_REFUSAL_CLASSES: readonly PaymentTruthRefusalClass[] = [
5
+ "authorization_refused",
6
+ "insufficient_funds",
7
+ "method_unavailable",
8
+ "risk_refused",
9
+ "unknown_refusal",
10
+ ];
11
+
12
+ export function paymentReconciliationFingerprint(input: PaymentReconciliationResult): string {
13
+ if (!input.eventId.trim() || input.idempotencyKey.trim().length < 8 || !isInstant(input.occurredAt)) {
14
+ throw new Error("payment_reconciliation_input_invalid");
15
+ }
16
+ if (input.evidenceStatus === "failed" && input.outcome !== "indeterminate") {
17
+ throw new Error("payment_reconciliation_failure_cannot_terminalize");
18
+ }
19
+ if (input.outcome === "refused" && !input.failure) {
20
+ throw new Error("payment_truth_refusal_classification_required");
21
+ }
22
+ if (input.outcome !== "refused" && input.failure) {
23
+ throw new Error("payment_truth_failure_only_for_refusal");
24
+ }
25
+ assertPaymentFailure(input.failure);
26
+ assertPaymentSourceEvidence(input.evidence);
27
+ return digest({
28
+ eventId: input.eventId,
29
+ evidenceStatus: input.evidenceStatus,
30
+ outcome: input.outcome,
31
+ occurredAt: input.occurredAt,
32
+ failure: input.failure,
33
+ evidence: input.evidence,
34
+ });
35
+ }
36
+
37
+ /** Fingerprint accepted at a provider boundary before local ownership is known. */
38
+ export function paymentSourceFingerprint(input: CanonicalPaymentSource): string {
39
+ if (!input.sourceEventId.trim() || !input.eventKind.trim()
40
+ || (input.occurredAt !== null && !isInstant(input.occurredAt))) {
41
+ throw new Error("payment_source_fingerprint_input_invalid");
42
+ }
43
+ if (!/^[0-9a-f]{64}$/.test(input.payloadFingerprint)) {
44
+ throw new Error("payment_truth_payload_fingerprint_invalid");
45
+ }
46
+ return digest(input);
47
+ }
48
+
49
+ export function paymentPayloadFingerprint(payload: unknown): string {
50
+ return digest(payload);
51
+ }
52
+
53
+ /** Canonical identity for one normalized event; idempotency is deliberately not part of it. */
54
+ export function paymentTruthFingerprint(input: PaymentTruthEvent): string {
55
+ assertPaymentTruthEvent(input);
56
+ return digest({
57
+ sourceEventId: input.sourceEventId,
58
+ settlementIntentId: input.settlementIntentId,
59
+ outcome: input.outcome,
60
+ amountMinor: input.amountMinor,
61
+ currency: input.currency,
62
+ occurredAt: input.occurredAt,
63
+ failure: input.failure,
64
+ evidence: input.evidence,
65
+ });
66
+ }
67
+
68
+ export function assertPaymentTruthEvent(input: PaymentTruthEvent): void {
69
+ if (!input.sourceEventId.trim()) throw new Error("payment_truth_source_event_id_required");
70
+ if (input.idempotencyKey.trim().length < 8) throw new Error("payment_truth_idempotency_key_invalid");
71
+ if (!input.settlementIntentId.trim()) throw new Error("payment_truth_settlement_intent_required");
72
+ if (!Number.isSafeInteger(input.amountMinor) || input.amountMinor <= 0) {
73
+ throw new Error("payment_truth_amount_invalid");
74
+ }
75
+ if (!/^[A-Z]{3}$/.test(input.currency)) throw new Error("payment_truth_currency_invalid");
76
+ if (!isInstant(input.occurredAt) || !isInstant(input.evidence.observedAt)) {
77
+ throw new Error("payment_truth_time_invalid");
78
+ }
79
+ if (!input.evidence.sourceReference.trim() || !input.evidence.observedStatus.trim()) {
80
+ throw new Error("payment_truth_source_evidence_invalid");
81
+ }
82
+ if (!/^[0-9a-f]{64}$/.test(input.evidence.payloadFingerprint)) {
83
+ throw new Error("payment_truth_payload_fingerprint_invalid");
84
+ }
85
+ if (input.outcome === "refused" && !input.failure) {
86
+ throw new Error("payment_truth_refusal_classification_required");
87
+ }
88
+ if (input.outcome !== "refused" && input.failure) {
89
+ throw new Error("payment_truth_failure_only_for_refusal");
90
+ }
91
+ assertPaymentFailure(input.failure);
92
+ assertPaymentSourceEvidence(input.evidence);
93
+ }
94
+
95
+ function isInstant(value: string): boolean {
96
+ return value.trim().length > 0 && Number.isFinite(Date.parse(value));
97
+ }
98
+
99
+ function stableJson(value: unknown): string {
100
+ if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
101
+ if (value && typeof value === "object") {
102
+ return `{${Object.entries(value as Record<string, unknown>)
103
+ .sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0)
104
+ .map(([key, entry]) => `${JSON.stringify(key)}:${stableJson(entry)}`)
105
+ .join(",")}}`;
106
+ }
107
+ return JSON.stringify(value);
108
+ }
109
+
110
+ function assertPaymentFailure(failure: PaymentTruthEvent["failure"]): void {
111
+ if (failure && !PAYMENT_TRUTH_REFUSAL_CLASSES.includes(failure.classification)) {
112
+ throw new Error("payment_truth_failure_class_invalid");
113
+ }
114
+ }
115
+
116
+ function assertPaymentSourceEvidence(evidence: PaymentSourceEvidence): void {
117
+ if (evidence.sourceKind !== "accepted_event" && evidence.sourceKind !== "reconciliation_read") {
118
+ throw new Error("payment_truth_source_kind_invalid");
119
+ }
120
+ if (!evidence.sourceReference.trim() || !evidence.observedStatus.trim()) {
121
+ throw new Error("payment_truth_source_evidence_invalid");
122
+ }
123
+ if (!isInstant(evidence.observedAt)) throw new Error("payment_truth_time_invalid");
124
+ if (!/^[0-9a-f]{64}$/.test(evidence.payloadFingerprint)) {
125
+ throw new Error("payment_truth_payload_fingerprint_invalid");
126
+ }
127
+ }
128
+
129
+ function digest(value: unknown): string {
130
+ return createHash("sha256").update(stableJson(value)).digest("hex");
131
+ }
@@ -0,0 +1,94 @@
1
+ import { z } from "zod";
2
+ import type { HttpRequest, HttpResponse } from "../http/index.js";
3
+ import type { BffResponder } from "../bff/index.js";
4
+ import type {
5
+ PaymentProviderReconciliationProvider,
6
+ PaymentProviderReconciliationPort,
7
+ } from "./contracts/reconciliation.js";
8
+ import { verifyPaymentAttemptNow } from "./paymentVerifyNowService.js";
9
+ import type { PaymentVerifyReadPort } from "./contracts/reconciliation.js";
10
+
11
+ /**
12
+ * Buyer-triggered "verify now": after a CLIENT-side confirm failure the FE has
13
+ * the only copy of the outcome (a pre-charge provider rejection emits no
14
+ * webhook), so it asks the server to read the provider immediately instead of
15
+ * waiting for the next adopter-configured reconciliation pass. The client can never assert a result — truth is the live
16
+ * provider readback, and the only write path is the reconciliation apply RPC
17
+ * with the cron's own idempotency key, so both paths converge replay-safely.
18
+ */
19
+
20
+ export const paymentVerifyRequestSchema = z.object({
21
+ orderId: z.guid(),
22
+ paymentIntentId: z.guid(),
23
+ clientId: z.guid(),
24
+ });
25
+
26
+ export type PaymentVerifyRequest = z.infer<typeof paymentVerifyRequestSchema>;
27
+
28
+
29
+
30
+ export function createCommercePaymentVerifyHandler(deps: {
31
+ readPort: PaymentVerifyReadPort;
32
+ applyPort: Pick<PaymentProviderReconciliationPort, "applyTerminalResult">;
33
+ providers: Partial<Record<string, PaymentProviderReconciliationProvider>>;
34
+ mutationsEnabled: () => boolean;
35
+ now?: () => Date;
36
+ responder: BffResponder;
37
+ }): (req: HttpRequest, res: HttpResponse) => Promise<void> {
38
+ const now = deps.now ?? (() => new Date());
39
+ const { sendBffError, sendBffSuccess, sendMethodNotAllowed } = deps.responder;
40
+ return async (req, res) => {
41
+ if (req.method !== "POST") {
42
+ sendMethodNotAllowed(res, ["POST"]);
43
+ return;
44
+ }
45
+ if (!deps.mutationsEnabled()) {
46
+ sendBffError(res, "UPSTREAM_UNAVAILABLE", "Provider payment verify is disabled", {
47
+ details: {
48
+ feature: "payment-verify",
49
+ featureFlag: "COMMERCE_PROVIDER_PAYMENTS_ENABLED",
50
+ reason: "feature_flag_disabled",
51
+ },
52
+ });
53
+ return;
54
+ }
55
+
56
+ const parsed = paymentVerifyRequestSchema.safeParse(req.body);
57
+ if (!parsed.success) {
58
+ sendBffError(res, "BAD_REQUEST", "Invalid payment verify request", {
59
+ details: parsed.error.flatten(),
60
+ });
61
+ return;
62
+ }
63
+
64
+ const snapshot = await deps.readPort.readVerifiableAttempt(parsed.data);
65
+ if (!snapshot) {
66
+ sendBffError(res, "NOT_FOUND", "Payment not found");
67
+ return;
68
+ }
69
+ // Same non-leaking ownership shape as payment-status: a non-owning triple
70
+ // reads as absent rather than confirming the payment exists.
71
+ if (snapshot.orderClientId !== parsed.data.clientId) {
72
+ sendBffError(res, "NOT_FOUND", "Payment not found");
73
+ return;
74
+ }
75
+
76
+ const result = await verifyPaymentAttemptNow({
77
+ snapshot,
78
+ applyPort: deps.applyPort,
79
+ providers: deps.providers,
80
+ now,
81
+ // This route has exactly one kind of caller: a browser sitting in a live
82
+ // checkout, asking about the attempt it is currently waiting on. The
83
+ // reconciliation worker never comes through here — it holds the provider
84
+ // and calls `readPayment` itself — so the purpose is a property of the
85
+ // seam, not something a request may choose.
86
+ purpose: "active_checkout",
87
+ });
88
+ sendBffSuccess(res, {
89
+ status: result.status,
90
+ verified: result.verified,
91
+ applied: result.applied,
92
+ });
93
+ };
94
+ }
@@ -0,0 +1,125 @@
1
+ import type { ProviderReadPurpose } from "./contracts/evidence.js";
2
+ import type { VerifiableAttemptSnapshot, PaymentVerifyReadPort, PaymentVerifyNowResult } from "./contracts/reconciliation.js";
3
+ import type {
4
+ ClaimedPaymentAttempt,
5
+ PaymentProviderReconciliationPort,
6
+ PaymentProviderReconciliationProvider,
7
+ ProviderReconciliationStatus,
8
+ } from "./contracts/reconciliation.js";
9
+ import { applyResultKey, evidencePayload, terminalAmountMismatch } from "./paymentProviderReconciliationEvidence.js";
10
+
11
+ const LIVE_ATTEMPT_STATUSES = new Set(["sent_to_provider", "requires_action", "processing", "created"]);
12
+
13
+ /**
14
+ * Shared buyer/cron-compatible live PSP read. It never accepts a browser
15
+ * outcome and terminal writes still pass through the canonical reconciliation
16
+ * RPC, so recovery cannot create a second payment truth path.
17
+ */
18
+ export async function verifyPaymentAttemptNow(input: {
19
+ snapshot: VerifiableAttemptSnapshot;
20
+ applyPort: Pick<PaymentProviderReconciliationPort, "applyTerminalResult">;
21
+ providers: Partial<Record<string, PaymentProviderReconciliationProvider>>;
22
+ now: () => Date;
23
+ providerPaymentId?: string;
24
+ providerStatus?: ProviderReconciliationStatus;
25
+ /**
26
+ * Passed straight to the provider read. A caller that omits it gets the
27
+ * reconciliation reading, in which an intent still waiting for its first
28
+ * payment method is a finished, abandoned attempt.
29
+ */
30
+ purpose?: ProviderReadPurpose;
31
+ }): Promise<PaymentVerifyNowResult> {
32
+ const { snapshot } = input;
33
+ if (!LIVE_ATTEMPT_STATUSES.has(snapshot.attemptStatus)) {
34
+ return { status: snapshot.attemptStatus, verified: false, applied: false, providerPaymentId: null, attempt: null };
35
+ }
36
+
37
+ const provider = input.providers[snapshot.provider];
38
+ const providerPaymentId = input.providerPaymentId
39
+ ?? snapshot.providerAttemptId
40
+ ?? snapshot.intentProviderPaymentId
41
+ ?? snapshot.providerSessionId;
42
+ if (!provider || !providerPaymentId) {
43
+ return { status: "unavailable", verified: false, applied: false, providerPaymentId, attempt: null };
44
+ }
45
+
46
+ const attempt = toClaimedAttemptShape(snapshot, providerPaymentId);
47
+ const providerStatus = input.providerStatus
48
+ ?? await provider.readPayment({
49
+ providerPaymentId,
50
+ attempt,
51
+ ...(input.purpose ? { purpose: input.purpose } : {}),
52
+ });
53
+ if (providerStatus.status !== "succeeded" && providerStatus.status !== "failed") {
54
+ return {
55
+ // Preserve the established payment-verify contract: an unknown provider
56
+ // vocabulary is still non-terminal and therefore reported as pending.
57
+ status: "pending",
58
+ verified: true,
59
+ applied: false,
60
+ providerPaymentId,
61
+ attempt,
62
+ };
63
+ }
64
+
65
+ if (terminalAmountMismatch(attempt, providerStatus)) {
66
+ return { status: "manual_review", verified: true, applied: false, providerPaymentId, attempt };
67
+ }
68
+
69
+ const checkedAt = input.now().toISOString();
70
+ const applied = await input.applyPort.applyTerminalResult({
71
+ idempotencyKey: applyResultKey(attempt, providerStatus.status),
72
+ expectedOrderId: snapshot.orderId,
73
+ expectedPaymentIntentId: snapshot.paymentIntentId,
74
+ expectedPaymentAttemptId: snapshot.paymentAttemptId,
75
+ expectedPaymentId: snapshot.paymentId,
76
+ provider: attempt.provider,
77
+ providerPaymentId,
78
+ localStatus: snapshot.attemptStatus,
79
+ providerStatus: providerStatus.providerStatus,
80
+ resultStatus: providerStatus.status,
81
+ occurredAt: providerStatus.occurredAt ?? checkedAt,
82
+ failureReason: providerStatus.status === "failed"
83
+ ? providerStatus.failureReason ?? `provider_verify_${providerStatus.providerStatus}`
84
+ : null,
85
+ checkedAt,
86
+ payload: evidencePayload(attempt, providerStatus, {
87
+ source: "commerce-payment-verify.v0",
88
+ trigger: "buyer_confirm_settled",
89
+ }),
90
+ });
91
+
92
+ return {
93
+ status: providerStatus.status,
94
+ verified: true,
95
+ applied: applied.correctionStatus === "corrected" && !applied.replayed,
96
+ providerPaymentId,
97
+ attempt,
98
+ };
99
+ }
100
+
101
+ export function toClaimedAttemptShape(
102
+ snapshot: VerifiableAttemptSnapshot,
103
+ providerPaymentId: string,
104
+ ): ClaimedPaymentAttempt {
105
+ return {
106
+ paymentAttemptId: snapshot.paymentAttemptId,
107
+ paymentIntentId: snapshot.paymentIntentId,
108
+ paymentId: snapshot.paymentId,
109
+ orderId: snapshot.orderId,
110
+ subscriptionId: null,
111
+ subscriptionCycleId: null,
112
+ provider: snapshot.provider as ClaimedPaymentAttempt["provider"],
113
+ providerPaymentId,
114
+ providerAttemptId: snapshot.providerAttemptId,
115
+ providerSessionId: snapshot.providerSessionId,
116
+ attemptStatus: snapshot.attemptStatus,
117
+ intentStatus: snapshot.intentStatus,
118
+ amountMinor: snapshot.amountMinor,
119
+ currency: snapshot.currency,
120
+ orderMode: snapshot.orderMode as ClaimedPaymentAttempt["orderMode"],
121
+ cycleRetryAttempt: 0,
122
+ cycleNextRetryAt: null,
123
+ localUpdatedAt: snapshot.localUpdatedAt,
124
+ };
125
+ }
@@ -0,0 +1,36 @@
1
+ export type PaymentRefusalHandoff = {
2
+ outcome: "refused" | "not_refused";
3
+ idempotencyKey: string;
4
+ paymentIntentId: string | null;
5
+ sourceEventId: string;
6
+ failureReason: string | null;
7
+ occurredAt: string;
8
+ };
9
+
10
+ export interface PaymentFailureDunningPort {
11
+ openFromExplicitRefusal(input: {
12
+ idempotencyKey: string;
13
+ paymentIntentId: string;
14
+ sourceEventId: string;
15
+ failureReason: string | null;
16
+ occurredAt: string;
17
+ }): Promise<void>;
18
+ }
19
+
20
+ /**
21
+ * Neutral terminal handoff. Only explicit refusal evidence may enter dunning;
22
+ * silence, timeout and every other non-terminal answer are deliberate no-ops.
23
+ */
24
+ export async function handoffExplicitPaymentRefusal(
25
+ input: PaymentRefusalHandoff,
26
+ port: PaymentFailureDunningPort,
27
+ ): Promise<void> {
28
+ if (input.outcome !== "refused" || !input.paymentIntentId) return;
29
+ await port.openFromExplicitRefusal({
30
+ idempotencyKey: input.idempotencyKey,
31
+ paymentIntentId: input.paymentIntentId,
32
+ sourceEventId: input.sourceEventId,
33
+ failureReason: input.failureReason,
34
+ occurredAt: input.occurredAt,
35
+ });
36
+ }