@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,10 @@
1
+ import type { ClaimedPaymentAttempt, SilenceWindowLookup } from "./contracts/reconciliation.js";
2
+ /**
3
+ * Whether this rail has answered non-terminally for longer than it declares
4
+ * normal. The window comes from the rail's published capability - never a
5
+ * provider identity, never a global constant, because a pushed-event rail and a
6
+ * polled rail have genuinely different normal silences and a self-hoster
7
+ * declares their own. Absent or unusable means never overdue: stay quiet rather
8
+ * than accuse a healthy rail.
9
+ */
10
+ export declare function silenceIsOverdue(capabilities: SilenceWindowLookup | undefined, attempt: ClaimedPaymentAttempt, now: string): boolean;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Whether this rail has answered non-terminally for longer than it declares
3
+ * normal. The window comes from the rail's published capability - never a
4
+ * provider identity, never a global constant, because a pushed-event rail and a
5
+ * polled rail have genuinely different normal silences and a self-hoster
6
+ * declares their own. Absent or unusable means never overdue: stay quiet rather
7
+ * than accuse a healthy rail.
8
+ */
9
+ export function silenceIsOverdue(capabilities, attempt, now) {
10
+ const minutes = capabilities?.get(attempt.provider)?.terminalOutcomeReporting
11
+ ?.silenceBecomesSuspectAfterMinutes;
12
+ if (typeof minutes !== "number" || !Number.isFinite(minutes) || minutes <= 0)
13
+ return false;
14
+ const since = Date.parse(attempt.localUpdatedAt);
15
+ const at = Date.parse(now);
16
+ if (!Number.isFinite(since) || !Number.isFinite(at))
17
+ return false;
18
+ return at - since >= minutes * 60_000;
19
+ }
@@ -0,0 +1,11 @@
1
+ import { type PaymentMethodLifecycleEvent, type PaymentMethodReplacementFacts } from "@openlup/core/payment";
2
+ import type { NormalizedProviderPaymentWebhook } from "./verifiedIngress.js";
3
+ export interface ProviderWebhookWithMethodLifecycle extends NormalizedProviderPaymentWebhook {
4
+ methodLifecycle?: PaymentMethodLifecycleEvent | null;
5
+ }
6
+ export declare const METHOD_SCHEME_SNAPSHOT_KEY = "methodScheme";
7
+ export declare const METHOD_LAST_DIGITS_SNAPSHOT_KEY = "methodLastDigits";
8
+ export declare const METHOD_EXPIRES_AT_SNAPSHOT_KEY = "methodExpiresAt";
9
+ export declare function methodFactSnapshotKeys(facts: PaymentMethodReplacementFacts | null | undefined): Record<string, unknown>;
10
+ export declare function expiryForMethodRefWrite(consentSnapshot: Record<string, unknown> | undefined, occurredAt: string, active: boolean): string | null;
11
+ export declare function readMethodLifecycleEvent(event: NormalizedProviderPaymentWebhook): PaymentMethodLifecycleEvent | null;
@@ -0,0 +1,37 @@
1
+ import { isPaymentMethodLifecycleEventKind, } from "@openlup/core/payment";
2
+ export const METHOD_SCHEME_SNAPSHOT_KEY = "methodScheme";
3
+ export const METHOD_LAST_DIGITS_SNAPSHOT_KEY = "methodLastDigits";
4
+ export const METHOD_EXPIRES_AT_SNAPSHOT_KEY = "methodExpiresAt";
5
+ export function methodFactSnapshotKeys(facts) {
6
+ if (!facts)
7
+ return {};
8
+ return {
9
+ ...(facts.schemeLabel ? { [METHOD_SCHEME_SNAPSHOT_KEY]: facts.schemeLabel } : {}),
10
+ ...(facts.lastDigits ? { [METHOD_LAST_DIGITS_SNAPSHOT_KEY]: facts.lastDigits } : {}),
11
+ ...(facts.expiresAt ? { [METHOD_EXPIRES_AT_SNAPSHOT_KEY]: facts.expiresAt } : {}),
12
+ };
13
+ }
14
+ export function expiryForMethodRefWrite(consentSnapshot, occurredAt, active) {
15
+ const raw = consentSnapshot?.[METHOD_EXPIRES_AT_SNAPSHOT_KEY];
16
+ if (typeof raw !== "string" || !raw.trim())
17
+ return null;
18
+ const expiry = Date.parse(raw);
19
+ if (!Number.isFinite(expiry))
20
+ return null;
21
+ if (!active)
22
+ return raw;
23
+ const reference = Date.parse(occurredAt);
24
+ return Number.isFinite(reference) && expiry <= reference ? null : raw;
25
+ }
26
+ export function readMethodLifecycleEvent(event) {
27
+ const candidate = event.methodLifecycle;
28
+ if (!candidate || typeof candidate !== "object")
29
+ return null;
30
+ if (!isPaymentMethodLifecycleEventKind(candidate.kind))
31
+ return null;
32
+ if (!candidate.providerKind?.trim() || !candidate.providerMethodRef?.trim())
33
+ return null;
34
+ if (!candidate.providerEventId?.trim() || !candidate.occurredAt?.trim())
35
+ return null;
36
+ return candidate;
37
+ }
@@ -0,0 +1,7 @@
1
+ import type { ClaimedPaymentAttempt, PaymentProviderReconciliationWorkerInput } from "./contracts/reconciliation.js";
2
+ /** Reopen only after genuine provider-side absence proof. A missing local
3
+ * acknowledgement cannot exclude a charge before reference persistence failed;
4
+ * minting a fresh idempotency key in that crash window could double-charge.
5
+ * Providers without an exact absence probe require operator review.
6
+ */
7
+ export declare function tryReopenPreparedAbsence(input: PaymentProviderReconciliationWorkerInput, attempt: ClaimedPaymentAttempt, now: string): Promise<boolean>;
@@ -0,0 +1,100 @@
1
+ import { absenceProbeEvidenceKey, absenceReopenKey, safeMessage, } from "./paymentProviderReconciliationEvidence.js";
2
+ // Mirrors the RPC's v_min_stranded_age guard (migration 20260711153000). The
3
+ // worker pre-checks so an under-age attempt is a silent skip for this pass
4
+ // instead of a guaranteed `prepared_attempt_absence_reopen_too_fresh` reject.
5
+ const MIN_PREPARED_ABSENCE_REOPEN_AGE_SECONDS = 30 * 60;
6
+ /** Reopen only after genuine provider-side absence proof. A missing local
7
+ * acknowledgement cannot exclude a charge before reference persistence failed;
8
+ * minting a fresh idempotency key in that crash window could double-charge.
9
+ * Providers without an exact absence probe require operator review.
10
+ */
11
+ export async function tryReopenPreparedAbsence(input, attempt, now) {
12
+ // Reopen schedules another subscription-cycle charge. One-time prepared
13
+ // attempts remain operator-manual so this path can never create a blind
14
+ // second provider call for them.
15
+ if (attempt.orderMode !== "subscription_cycle" || !attempt.subscriptionCycleId)
16
+ return false;
17
+ const subscriptionCycleId = attempt.subscriptionCycleId;
18
+ const provider = input.providers[attempt.provider];
19
+ // Without an exact absence probe, stranded attempts stay operator-manual.
20
+ // Guessing absence could schedule a second charge.
21
+ if (!provider?.findPaymentByLocalIntent)
22
+ return false;
23
+ const ageMs = Date.parse(now) - Date.parse(attempt.localUpdatedAt);
24
+ if (!Number.isFinite(ageMs) || ageMs < MIN_PREPARED_ABSENCE_REOPEN_AGE_SECONDS * 1000) {
25
+ // Under the RPC's stranded-age guard — the next configured reconciliation pass catches it.
26
+ return false;
27
+ }
28
+ let probe;
29
+ try {
30
+ probe = await provider.findPaymentByLocalIntent({ attempt });
31
+ }
32
+ catch (error) {
33
+ await recordProbeEvidence(input, attempt, now, "absence_probe_failed", {
34
+ probeError: safeMessage(error),
35
+ operatorReviewRequired: true,
36
+ });
37
+ return false;
38
+ }
39
+ if (probe === "found") {
40
+ // Crash-after-charge window: the provider HAS a payment for this intent
41
+ // but our attempt never recorded it. Reopening would double-charge —
42
+ // leave it for operator ref-repair; the stale-attempt readback path takes
43
+ // over once refs are restored.
44
+ await recordProbeEvidence(input, attempt, now, "provider_payment_found_after_prepare", {
45
+ providerAbsenceConfirmed: false,
46
+ operatorReviewRequired: true,
47
+ reopenBlocked: "provider_payment_exists",
48
+ });
49
+ return false;
50
+ }
51
+ try {
52
+ await input.port.reopenPreparedAttemptAfterAbsence({
53
+ idempotencyKey: absenceReopenKey(attempt),
54
+ paymentAttemptId: attempt.paymentAttemptId,
55
+ expectedPaymentIntentId: attempt.paymentIntentId,
56
+ expectedSubscriptionCycleId: subscriptionCycleId,
57
+ operatorRef: "payment-provider-reconciliation@auto",
58
+ absenceCheckedAt: now,
59
+ nextRetryAt: now,
60
+ absenceEvidence: {
61
+ providerAbsenceConfirmed: true,
62
+ source: "payment-provider-reconciliation.auto-reopen.v0",
63
+ basis: `${attempt.provider}_intent_correlated_search_absent`,
64
+ probedAt: now,
65
+ },
66
+ });
67
+ // The RPC records its own 'prepared_attempt_provider_absent' corrected
68
+ // evidence, cancels the attempt, and requeues the cycle — nothing more to
69
+ // write here.
70
+ return true;
71
+ }
72
+ catch (error) {
73
+ const message = safeMessage(error);
74
+ // The RPC's 22023 precondition rejects (too_fresh, provider_event_exists,
75
+ // terminal reconciliation, state drift since claim, ...) are expected
76
+ // races, not run failures — the attempt stays operator-visible either way.
77
+ await recordProbeEvidence(input, attempt, now, message.includes("prepared_attempt_absence_reopen_")
78
+ ? "absence_reopen_precondition_reject"
79
+ : "absence_reopen_failed", { reopenError: message, operatorReviewRequired: true });
80
+ return false;
81
+ }
82
+ }
83
+ function recordProbeEvidence(input, attempt, now, providerStatus, extra) {
84
+ return input.port.recordEvidence({
85
+ idempotencyKey: absenceProbeEvidenceKey(attempt, now),
86
+ provider: attempt.provider,
87
+ providerPaymentId: null,
88
+ paymentIntentId: attempt.paymentIntentId,
89
+ paymentAttemptId: attempt.paymentAttemptId,
90
+ localStatus: attempt.attemptStatus,
91
+ providerStatus,
92
+ correctionStatus: "observed",
93
+ checkedAt: now,
94
+ payload: {
95
+ source: "payment-provider-reconciliation.auto-reopen.v0",
96
+ provider: attempt.provider,
97
+ ...extra,
98
+ },
99
+ });
100
+ }
@@ -0,0 +1,36 @@
1
+ import type { ClaimedPaymentAttempt, ProviderReconciliationStatus, ProviderReconciliationStatusKind } from "./contracts/reconciliation.js";
2
+ export declare function evidencePayload(attempt: ClaimedPaymentAttempt, providerStatus: ProviderReconciliationStatus, extra: Record<string, unknown>): Record<string, unknown>;
3
+ export declare function terminalAmountMismatch(attempt: ClaimedPaymentAttempt, status: ProviderReconciliationStatus): string | null;
4
+ export declare function applyResultKey(attempt: ClaimedPaymentAttempt, status: "succeeded" | "failed"): string;
5
+ export declare function observedEvidenceKey(attempt: ClaimedPaymentAttempt, status: ProviderReconciliationStatusKind, correction: "observed" | "failed", checkedAt: string): string;
6
+ export declare function preparedAttemptEvidenceKey(attempt: ClaimedPaymentAttempt, checkedAt: string): string;
7
+ export declare function absenceReopenKey(attempt: ClaimedPaymentAttempt): string;
8
+ export declare function absenceProbeEvidenceKey(attempt: ClaimedPaymentAttempt, checkedAt: string): string;
9
+ export declare function bucket(value: string): string;
10
+ export declare function clamp(value: number, min: number, max: number): number;
11
+ export declare function safeMessage(error: unknown): string;
12
+ /**
13
+ * The class the reading adapter already decided, read back off the normalized
14
+ * status so the terminal write can carry it.
15
+ *
16
+ * READ, never re-derived. Both shipped adapters classify what they read through
17
+ * the shared `classifyDecline` seam and put the verdict in `rawPayload`; deriving
18
+ * a second one here would be a second classifier, free to disagree with the one
19
+ * whose reasoning the evidence payload records. It is also why this function is
20
+ * provider-agnostic: it never learns which rail refused, only what that rail
21
+ * concluded.
22
+ *
23
+ * Returns a spreadable fragment rather than a nullable value, so an unclassified
24
+ * refusal leaves the key genuinely ABSENT from the composed call instead of
25
+ * present-and-null. Absence is what the port's contract defines as "behave
26
+ * exactly as before the field existed", and it is what the two non-classifying
27
+ * consumers of that contract are pinned to.
28
+ *
29
+ * A success carries no class by construction: there is no refusal to classify.
30
+ */
31
+ export declare function classificationOf(resultStatus: "succeeded" | "failed", providerStatus: ProviderReconciliationStatus): {
32
+ failureClassification?: {
33
+ failureClass: string;
34
+ decidedBy: string;
35
+ };
36
+ };
@@ -0,0 +1,91 @@
1
+ export function evidencePayload(attempt, providerStatus, extra) {
2
+ return {
3
+ source: "payment-provider-reconciliation.v0",
4
+ provider: attempt.provider,
5
+ providerPaymentId: attempt.providerPaymentId,
6
+ paymentIntentId: attempt.paymentIntentId,
7
+ paymentAttemptId: attempt.paymentAttemptId,
8
+ orderId: attempt.orderId,
9
+ subscriptionId: attempt.subscriptionId,
10
+ subscriptionCycleId: attempt.subscriptionCycleId,
11
+ localAttemptStatus: attempt.attemptStatus,
12
+ localIntentStatus: attempt.intentStatus,
13
+ providerStatus: providerStatus.providerStatus,
14
+ normalizedStatus: providerStatus.status,
15
+ failureReason: providerStatus.failureReason,
16
+ amountMinorPresent: providerStatus.amountMinor !== null,
17
+ currency: providerStatus.currency,
18
+ providerPayload: providerStatus.rawPayload,
19
+ ...extra,
20
+ };
21
+ }
22
+ export function terminalAmountMismatch(attempt, status) {
23
+ if (status.amountMinor !== null && status.amountMinor !== attempt.amountMinor) {
24
+ return "provider_amount_mismatch";
25
+ }
26
+ if (status.currency && status.currency.toUpperCase() !== attempt.currency.toUpperCase()) {
27
+ return "provider_currency_mismatch";
28
+ }
29
+ return null;
30
+ }
31
+ export function applyResultKey(attempt, status) {
32
+ return `payment-provider-reconciliation:${attempt.paymentAttemptId}:apply:${status}`;
33
+ }
34
+ export function observedEvidenceKey(attempt, status, correction, checkedAt) {
35
+ return `payment-provider-reconciliation:${attempt.paymentAttemptId}:${correction}:${status}:${bucket(checkedAt)}`;
36
+ }
37
+ export function preparedAttemptEvidenceKey(attempt, checkedAt) {
38
+ return `payment-provider-reconciliation:${attempt.paymentAttemptId}:failed:prepared_without_provider_ack:${bucket(checkedAt)}`;
39
+ }
40
+ // Stable (no time bucket): a successful reopen cancels the attempt (it leaves
41
+ // `created`), so the RPC is called at most once per attempt; replays after a
42
+ // crash return the recorded response via commerce_idempotency_keys.
43
+ export function absenceReopenKey(attempt) {
44
+ return `payment-provider-reconciliation:${attempt.paymentAttemptId}:absence-reopen`;
45
+ }
46
+ export function absenceProbeEvidenceKey(attempt, checkedAt) {
47
+ return `payment-provider-reconciliation:${attempt.paymentAttemptId}:absence-probe:${bucket(checkedAt)}`;
48
+ }
49
+ export function bucket(value) {
50
+ const ms = Date.parse(value);
51
+ if (!Number.isFinite(ms))
52
+ return "unknown";
53
+ return String(Math.floor(ms / (30 * 60 * 1000)));
54
+ }
55
+ export function clamp(value, min, max) {
56
+ if (!Number.isFinite(value))
57
+ return min;
58
+ return Math.max(min, Math.min(max, Math.trunc(value)));
59
+ }
60
+ export function safeMessage(error) {
61
+ return (error instanceof Error ? error.message : String(error)).slice(0, 240);
62
+ }
63
+ /**
64
+ * The class the reading adapter already decided, read back off the normalized
65
+ * status so the terminal write can carry it.
66
+ *
67
+ * READ, never re-derived. Both shipped adapters classify what they read through
68
+ * the shared `classifyDecline` seam and put the verdict in `rawPayload`; deriving
69
+ * a second one here would be a second classifier, free to disagree with the one
70
+ * whose reasoning the evidence payload records. It is also why this function is
71
+ * provider-agnostic: it never learns which rail refused, only what that rail
72
+ * concluded.
73
+ *
74
+ * Returns a spreadable fragment rather than a nullable value, so an unclassified
75
+ * refusal leaves the key genuinely ABSENT from the composed call instead of
76
+ * present-and-null. Absence is what the port's contract defines as "behave
77
+ * exactly as before the field existed", and it is what the two non-classifying
78
+ * consumers of that contract are pinned to.
79
+ *
80
+ * A success carries no class by construction: there is no refusal to classify.
81
+ */
82
+ export function classificationOf(resultStatus, providerStatus) {
83
+ if (resultStatus !== "failed")
84
+ return {};
85
+ const { failureClass, failureClassDecidedBy } = providerStatus.rawPayload;
86
+ if (typeof failureClass !== "string" || failureClass.length === 0)
87
+ return {};
88
+ if (typeof failureClassDecidedBy !== "string" || failureClassDecidedBy.length === 0)
89
+ return {};
90
+ return { failureClassification: { failureClass, decidedBy: failureClassDecidedBy } };
91
+ }
@@ -0,0 +1,4 @@
1
+ import type { PaymentProviderReconciliationWorkerInput, PaymentProviderReconciliationWorkerResult } from "./contracts/reconciliation.js";
2
+ export type { ClaimedPaymentAttempt, PaymentProviderReconciliationPort, PaymentProviderReconciliationProvider, PaymentProviderReconciliationWorkerInput, PaymentProviderReconciliationWorkerResult, ProviderReconciliationStatus, ProviderReconciliationStatusKind, ReconciliationProviderKind, SilenceWindowLookup, } from "./contracts/reconciliation.js";
3
+ export declare const DEFAULT_STALE_AFTER_SECONDS: number;
4
+ export declare function runPaymentProviderReconciliationWorker(input: PaymentProviderReconciliationWorkerInput): Promise<PaymentProviderReconciliationWorkerResult>;
@@ -0,0 +1,245 @@
1
+ import { tryReopenPreparedAbsence } from "./paymentProviderReconciliationAbsenceReopen.js";
2
+ import { applyResultKey, bucket, clamp, classificationOf, evidencePayload, observedEvidenceKey, preparedAttemptEvidenceKey, safeMessage, terminalAmountMismatch, } from "./paymentProviderReconciliationEvidence.js";
3
+ import { silenceIsOverdue } from "./paymentAttemptSilenceWindow.js";
4
+ const MAX_ATTEMPTS_PER_RUN = 4;
5
+ const DEFAULT_BATCH_SIZE = MAX_ATTEMPTS_PER_RUN;
6
+ export const DEFAULT_STALE_AFTER_SECONDS = 15 * 60;
7
+ export async function runPaymentProviderReconciliationWorker(input) {
8
+ const now = input.now ?? new Date().toISOString();
9
+ const staleAfterSeconds = clamp(input.staleAfterSeconds ?? DEFAULT_STALE_AFTER_SECONDS, 60, 86_400);
10
+ // Provider reads and optional prepared-absence probes are sequential and may
11
+ // be slow. Preserve the per-run ceiling even for an oversized requested batch.
12
+ const batchSize = clamp(input.batchSize ?? DEFAULT_BATCH_SIZE, 1, MAX_ATTEMPTS_PER_RUN);
13
+ const claimKey = input.claimKey ?? `payment-provider-reconciliation:${bucket(now)}`;
14
+ const result = {
15
+ ok: true,
16
+ checked: 0,
17
+ corrected: 0,
18
+ succeeded: 0,
19
+ failed: 0,
20
+ pending: 0,
21
+ unknown: 0,
22
+ ignored: 0,
23
+ dunningOpened: 0,
24
+ replayed: 0,
25
+ providerCalls: 0,
26
+ preparedWithoutProviderAck: 0,
27
+ preparedAttemptsReopened: 0,
28
+ manualReview: 0,
29
+ silenceOverdue: 0,
30
+ amountCurrencyMismatches: 0,
31
+ failures: 0,
32
+ skipped: false,
33
+ };
34
+ // Prepared attempts can remain operator-manual indefinitely. Bound their
35
+ // share so they can never consume a whole worker batch and starve provider
36
+ // readback for finalized attempts. A batch of one intentionally prioritizes
37
+ // provider readback; normal scheduled runs use the larger default batch.
38
+ const preparedLimit = Math.floor(batchSize / 2);
39
+ const preparedAttempts = preparedLimit > 0
40
+ ? await input.port.claimPreparedAttempts({
41
+ now,
42
+ staleAfterSeconds,
43
+ limit: preparedLimit,
44
+ claimKey: `${claimKey}:prepared`,
45
+ })
46
+ : [];
47
+ for (const attempt of preparedAttempts) {
48
+ const providerStatus = {
49
+ status: "unknown",
50
+ providerStatus: "prepared_without_provider_ack",
51
+ occurredAt: null,
52
+ failureReason: "prepared_without_provider_ack",
53
+ amountMinor: null,
54
+ currency: null,
55
+ rawPayload: {
56
+ source: "payment-provider-reconciliation.prepared-watchdog.v0",
57
+ provider: attempt.provider,
58
+ providerCallPlanned: true,
59
+ providerAcknowledged: false,
60
+ },
61
+ };
62
+ const evidence = await input.port.recordEvidence({
63
+ idempotencyKey: preparedAttemptEvidenceKey(attempt, now),
64
+ provider: attempt.provider,
65
+ providerPaymentId: null,
66
+ paymentIntentId: attempt.paymentIntentId,
67
+ paymentAttemptId: attempt.paymentAttemptId,
68
+ localStatus: attempt.attemptStatus,
69
+ providerStatus: providerStatus.providerStatus,
70
+ correctionStatus: "failed",
71
+ checkedAt: now,
72
+ payload: evidencePayload(attempt, providerStatus, {
73
+ applied: false,
74
+ operatorReviewRequired: true,
75
+ }),
76
+ });
77
+ if (evidence.replayed)
78
+ result.replayed += 1;
79
+ result.preparedWithoutProviderAck += 1;
80
+ result.failures += 1;
81
+ result.ok = false;
82
+ result.reason = result.reason ?? "prepared_without_provider_ack";
83
+ // Auto-reopen after a GENUINE provider-side absence proof. A purely local
84
+ // "no ack recorded" signal cannot rule out the crash window between
85
+ // executionPort.execute() (provider charged) and finalizeProviderAttempt
86
+ // (refs persisted) — reopening in that window would mint a fresh PSP
87
+ // idempotency key and double-charge. Providers without an absence probe
88
+ if (input.autoReopenPreparedAbsence) {
89
+ const reopened = await tryReopenPreparedAbsence(input, attempt, now);
90
+ if (reopened)
91
+ result.preparedAttemptsReopened += 1;
92
+ }
93
+ }
94
+ const remainingLimit = batchSize - preparedAttempts.length;
95
+ const attempts = remainingLimit > 0
96
+ ? await input.port.claimStaleAttempts({
97
+ now,
98
+ staleAfterSeconds,
99
+ limit: remainingLimit,
100
+ claimKey,
101
+ })
102
+ : [];
103
+ result.checked = preparedAttempts.length + attempts.length;
104
+ for (const attempt of attempts) {
105
+ const provider = input.providers[attempt.provider];
106
+ if (!provider) {
107
+ await recordNonTerminalEvidence(input.port, attempt, now, {
108
+ status: "unknown",
109
+ providerStatus: "provider_not_configured",
110
+ occurredAt: null,
111
+ failureReason: "provider_not_configured",
112
+ amountMinor: null,
113
+ currency: null,
114
+ rawPayload: { provider: attempt.provider, source: "payment-provider-reconciliation.v0" },
115
+ }, "failed");
116
+ result.unknown += 1;
117
+ result.failures += 1;
118
+ result.ok = false;
119
+ result.reason = result.reason ?? `${attempt.provider}_provider_not_configured`;
120
+ continue;
121
+ }
122
+ let providerStatus;
123
+ try {
124
+ if (!attempt.providerPaymentId) {
125
+ throw new Error("provider_payment_id_missing");
126
+ }
127
+ result.providerCalls += 1;
128
+ providerStatus = await provider.readPayment({
129
+ providerPaymentId: attempt.providerPaymentId,
130
+ attempt,
131
+ });
132
+ }
133
+ catch (error) {
134
+ providerStatus = {
135
+ status: "unknown",
136
+ providerStatus: "provider_read_failed",
137
+ occurredAt: null,
138
+ failureReason: safeMessage(error),
139
+ amountMinor: null,
140
+ currency: null,
141
+ rawPayload: {
142
+ provider: attempt.provider,
143
+ providerPaymentId: attempt.providerPaymentId,
144
+ readError: safeMessage(error),
145
+ },
146
+ };
147
+ await recordNonTerminalEvidence(input.port, attempt, now, providerStatus, "failed");
148
+ result.unknown += 1;
149
+ result.failures += 1;
150
+ result.ok = false;
151
+ result.reason = result.reason ?? safeMessage(error);
152
+ continue;
153
+ }
154
+ if (providerStatus.status === "pending" || providerStatus.status === "unknown") {
155
+ // Polling cannot separate "still running" from "stuck" - both answer
156
+ // non-terminally forever - so ask the rail how long its own silence stays
157
+ // normal and surface the attempt past that. NOT a terminal decision:
158
+ // elapsed time says nothing about whether money moved.
159
+ const overdue = silenceIsOverdue(input.capabilities, attempt, now);
160
+ await recordNonTerminalEvidence(input.port, attempt, now, providerStatus, "observed", overdue);
161
+ if (providerStatus.status === "pending")
162
+ result.pending += 1;
163
+ else
164
+ result.unknown += 1;
165
+ if (overdue) {
166
+ result.silenceOverdue += 1;
167
+ result.manualReview += 1;
168
+ }
169
+ continue;
170
+ }
171
+ const mismatch = terminalAmountMismatch(attempt, providerStatus);
172
+ if (mismatch) {
173
+ await recordNonTerminalEvidence(input.port, attempt, now, {
174
+ ...providerStatus,
175
+ failureReason: mismatch,
176
+ rawPayload: { ...providerStatus.rawPayload, reconciliationMismatch: mismatch },
177
+ }, "failed");
178
+ result.manualReview += 1;
179
+ result.amountCurrencyMismatches += 1;
180
+ continue;
181
+ }
182
+ const occurredAt = providerStatus.occurredAt ?? now;
183
+ const resultStatus = providerStatus.status;
184
+ try {
185
+ const applied = await input.port.applyTerminalResult({
186
+ idempotencyKey: applyResultKey(attempt, resultStatus),
187
+ expectedOrderId: attempt.orderId,
188
+ expectedPaymentIntentId: attempt.paymentIntentId,
189
+ expectedPaymentAttemptId: attempt.paymentAttemptId,
190
+ expectedPaymentId: attempt.paymentId,
191
+ provider: attempt.provider,
192
+ providerPaymentId: attempt.providerPaymentId,
193
+ localStatus: attempt.attemptStatus,
194
+ providerStatus: providerStatus.providerStatus,
195
+ resultStatus,
196
+ occurredAt,
197
+ failureReason: resultStatus === "failed"
198
+ ? providerStatus.failureReason ?? `provider_reconciliation_${providerStatus.providerStatus}`
199
+ : null,
200
+ ...classificationOf(resultStatus, providerStatus),
201
+ checkedAt: now,
202
+ payload: evidencePayload(attempt, providerStatus, { applied: true, resultStatus }),
203
+ });
204
+ if (applied.replayed)
205
+ result.replayed += 1;
206
+ if (applied.correctionStatus === "ignored") {
207
+ result.ignored += 1;
208
+ continue;
209
+ }
210
+ result.corrected += applied.replayed ? 0 : 1;
211
+ if (resultStatus === "succeeded")
212
+ result.succeeded += 1;
213
+ else
214
+ result.failed += 1;
215
+ if (applied.subscriptionWebhookDunning?.opened && !applied.subscriptionWebhookDunning.replayed) {
216
+ result.dunningOpened += 1;
217
+ }
218
+ }
219
+ catch (error) {
220
+ await recordNonTerminalEvidence(input.port, attempt, now, {
221
+ ...providerStatus,
222
+ failureReason: safeMessage(error),
223
+ rawPayload: { ...providerStatus.rawPayload, applyResultError: safeMessage(error) },
224
+ }, "failed");
225
+ result.failures += 1;
226
+ result.ok = false;
227
+ result.reason = result.reason ?? safeMessage(error);
228
+ }
229
+ }
230
+ return result;
231
+ }
232
+ function recordNonTerminalEvidence(port, attempt, checkedAt, providerStatus, correctionStatus, silenceOverdue = false) {
233
+ return port.recordEvidence({
234
+ idempotencyKey: observedEvidenceKey(attempt, providerStatus.status, correctionStatus, checkedAt),
235
+ provider: attempt.provider,
236
+ providerPaymentId: attempt.providerPaymentId,
237
+ paymentIntentId: attempt.paymentIntentId,
238
+ paymentAttemptId: attempt.paymentAttemptId,
239
+ localStatus: attempt.attemptStatus,
240
+ providerStatus: providerStatus.providerStatus,
241
+ correctionStatus,
242
+ checkedAt,
243
+ payload: evidencePayload(attempt, providerStatus, { applied: false, silenceOverdue }),
244
+ });
245
+ }
@@ -0,0 +1,8 @@
1
+ import type { PaymentTruthEvent, PaymentReconciliationResult, CanonicalPaymentSource } from "./contracts/truth.js";
2
+ export declare function paymentReconciliationFingerprint(input: PaymentReconciliationResult): string;
3
+ /** Fingerprint accepted at a provider boundary before local ownership is known. */
4
+ export declare function paymentSourceFingerprint(input: CanonicalPaymentSource): string;
5
+ export declare function paymentPayloadFingerprint(payload: unknown): string;
6
+ /** Canonical identity for one normalized event; idempotency is deliberately not part of it. */
7
+ export declare function paymentTruthFingerprint(input: PaymentTruthEvent): string;
8
+ export declare function assertPaymentTruthEvent(input: PaymentTruthEvent): void;