@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.
- package/CHANGELOG.md +62 -231
- package/README.md +172 -6
- package/dist/composition/composeBundle.d.ts +6 -0
- package/dist/composition/composeBundle.js +2 -0
- package/dist/composition/index.js +1 -0
- package/dist/payments/adapterConformance.d.ts +8 -0
- package/dist/payments/adapterConformance.js +109 -0
- package/dist/payments/bindings.d.ts +3 -0
- package/dist/payments/bindings.js +17 -0
- package/dist/payments/checkoutActivePaymentActionResolver.d.ts +7 -0
- package/dist/payments/checkoutActivePaymentActionResolver.js +77 -0
- package/dist/payments/checkoutRecoveryPaymentResolver.d.ts +8 -0
- package/dist/payments/checkoutRecoveryPaymentResolver.js +87 -0
- package/dist/payments/contracts/adapter.d.ts +113 -0
- package/dist/payments/contracts/adapter.js +1 -0
- package/dist/payments/contracts/conformance.d.ts +155 -0
- package/dist/payments/contracts/conformance.js +1 -0
- package/dist/payments/contracts/evidence.d.ts +157 -0
- package/dist/payments/contracts/evidence.js +1 -0
- package/dist/payments/contracts/index.d.ts +10 -0
- package/dist/payments/contracts/index.js +1 -0
- package/dist/payments/contracts/preparedAttempt.d.ts +69 -0
- package/dist/payments/contracts/preparedAttempt.js +1 -0
- package/dist/payments/contracts/reconciliation.d.ts +303 -0
- package/dist/payments/contracts/reconciliation.js +1 -0
- package/dist/payments/contracts/repair.d.ts +92 -0
- package/dist/payments/contracts/repair.js +1 -0
- package/dist/payments/contracts/truth.d.ts +92 -0
- package/dist/payments/contracts/truth.js +1 -0
- package/dist/payments/eventConformanceRunner.d.ts +12 -0
- package/dist/payments/eventConformanceRunner.js +125 -0
- package/dist/payments/index.d.ts +16 -0
- package/dist/payments/index.js +16 -0
- package/dist/payments/paymentAttemptSilenceWindow.d.ts +10 -0
- package/dist/payments/paymentAttemptSilenceWindow.js +19 -0
- package/dist/payments/paymentMethodLifecycle.d.ts +11 -0
- package/dist/payments/paymentMethodLifecycle.js +37 -0
- package/dist/payments/paymentProviderReconciliationAbsenceReopen.d.ts +7 -0
- package/dist/payments/paymentProviderReconciliationAbsenceReopen.js +100 -0
- package/dist/payments/paymentProviderReconciliationEvidence.d.ts +36 -0
- package/dist/payments/paymentProviderReconciliationEvidence.js +91 -0
- package/dist/payments/paymentProviderReconciliationWorker.d.ts +4 -0
- package/dist/payments/paymentProviderReconciliationWorker.js +245 -0
- package/dist/payments/paymentTruth.d.ts +8 -0
- package/dist/payments/paymentTruth.js +125 -0
- package/dist/payments/paymentVerifyNowHandler.d.ts +27 -0
- package/dist/payments/paymentVerifyNowHandler.js +70 -0
- package/dist/payments/paymentVerifyNowService.d.ts +23 -0
- package/dist/payments/paymentVerifyNowService.js +93 -0
- package/dist/payments/paymentWebhookPostProcessing.d.ts +22 -0
- package/dist/payments/paymentWebhookPostProcessing.js +15 -0
- package/dist/payments/postgresPaymentTruth.d.ts +10 -0
- package/dist/payments/postgresPaymentTruth.js +126 -0
- package/dist/payments/preparedAttempt.d.ts +6 -0
- package/dist/payments/preparedAttempt.js +70 -0
- package/dist/payments/preparedAttemptFinalization.d.ts +5 -0
- package/dist/payments/preparedAttemptFinalization.js +12 -0
- package/dist/payments/schemaCapabilities.d.ts +10 -0
- package/dist/payments/schemaCapabilities.js +10 -0
- package/dist/payments/sql.d.ts +20 -0
- package/dist/payments/sql.js +6 -0
- package/dist/payments/stripe/classifyDecline.d.ts +46 -0
- package/dist/payments/stripe/classifyDecline.js +58 -0
- package/dist/payments/stripe/declineFailureHints.d.ts +46 -0
- package/dist/payments/stripe/declineFailureHints.js +87 -0
- package/dist/payments/stripe/donorContracts.d.ts +120 -0
- package/dist/payments/stripe/donorContracts.js +1 -0
- package/dist/payments/stripe/executionDecline.d.ts +53 -0
- package/dist/payments/stripe/executionDecline.js +107 -0
- package/dist/payments/stripe/index.d.ts +16 -0
- package/dist/payments/stripe/index.js +239 -0
- package/dist/payments/stripe/intentMapper.d.ts +26 -0
- package/dist/payments/stripe/intentMapper.js +51 -0
- package/dist/payments/stripe/methodFacts.d.ts +10 -0
- package/dist/payments/stripe/methodFacts.js +12 -0
- package/dist/payments/stripe/paymentFailureEvidence.d.ts +26 -0
- package/dist/payments/stripe/paymentFailureEvidence.js +33 -0
- package/dist/payments/stripe/stripeFailureEvidence.d.ts +41 -0
- package/dist/payments/stripe/stripeFailureEvidence.js +82 -0
- package/dist/payments/stripe/stripeIntentConfirmationEvidence.d.ts +28 -0
- package/dist/payments/stripe/stripeIntentConfirmationEvidence.js +32 -0
- package/dist/payments/stripe/stripePaymentReconciliationProvider.d.ts +35 -0
- package/dist/payments/stripe/stripePaymentReconciliationProvider.js +222 -0
- package/dist/payments/stripe/stripeRefundEvidence.d.ts +8 -0
- package/dist/payments/stripe/stripeRefundEvidence.js +17 -0
- package/dist/payments/stripe/stripeSandboxPaymentExecutionAdapter.d.ts +45 -0
- package/dist/payments/stripe/stripeSandboxPaymentExecutionAdapter.js +220 -0
- package/dist/payments/stripe/stripeWebhookNormalizer.d.ts +14 -0
- package/dist/payments/stripe/stripeWebhookNormalizer.js +243 -0
- package/dist/payments/stripe/webhookSignature.d.ts +14 -0
- package/dist/payments/stripe/webhookSignature.js +43 -0
- package/dist/payments/tpay/blikRecurringMapper.d.ts +37 -0
- package/dist/payments/tpay/blikRecurringMapper.js +24 -0
- package/dist/payments/tpay/capabilities.d.ts +3 -0
- package/dist/payments/tpay/capabilities.js +18 -0
- package/dist/payments/tpay/classifyDecline.d.ts +46 -0
- package/dist/payments/tpay/classifyDecline.js +58 -0
- package/dist/payments/tpay/declineFailureHints.d.ts +72 -0
- package/dist/payments/tpay/declineFailureHints.js +37 -0
- package/dist/payments/tpay/donorContracts.d.ts +140 -0
- package/dist/payments/tpay/donorContracts.js +1 -0
- package/dist/payments/tpay/index.d.ts +43 -0
- package/dist/payments/tpay/index.js +264 -0
- package/dist/payments/tpay/paymentFailureEvidence.d.ts +26 -0
- package/dist/payments/tpay/paymentFailureEvidence.js +33 -0
- package/dist/payments/tpay/preparedProviderAttempt.d.ts +22 -0
- package/dist/payments/tpay/preparedProviderAttempt.js +24 -0
- package/dist/payments/tpay/providerAttemptFailureDiagnostic.d.ts +3 -0
- package/dist/payments/tpay/providerAttemptFailureDiagnostic.js +95 -0
- package/dist/payments/tpay/tpayCreateTransactionInput.d.ts +25 -0
- package/dist/payments/tpay/tpayCreateTransactionInput.js +128 -0
- package/dist/payments/tpay/tpayDeclineEvidence.d.ts +126 -0
- package/dist/payments/tpay/tpayDeclineEvidence.js +177 -0
- package/dist/payments/tpay/tpayDispatchFailure.d.ts +60 -0
- package/dist/payments/tpay/tpayDispatchFailure.js +121 -0
- package/dist/payments/tpay/tpayFailureEvidence.d.ts +20 -0
- package/dist/payments/tpay/tpayFailureEvidence.js +61 -0
- package/dist/payments/tpay/tpayHttpClient.d.ts +52 -0
- package/dist/payments/tpay/tpayHttpClient.js +185 -0
- package/dist/payments/tpay/tpayHttpFailure.d.ts +57 -0
- package/dist/payments/tpay/tpayHttpFailure.js +143 -0
- package/dist/payments/tpay/tpayPaymentExecutionAdapter.d.ts +20 -0
- package/dist/payments/tpay/tpayPaymentExecutionAdapter.js +249 -0
- package/dist/payments/tpay/tpayPaymentReconciliationProvider.d.ts +5 -0
- package/dist/payments/tpay/tpayPaymentReconciliationProvider.js +244 -0
- package/dist/payments/tpay/tpayTransactionPayload.d.ts +120 -0
- package/dist/payments/tpay/tpayTransactionPayload.js +96 -0
- package/dist/payments/tpay/wallClockTime.d.ts +31 -0
- package/dist/payments/tpay/wallClockTime.js +57 -0
- package/dist/payments/tpay/webhookJwsVerifier.d.ts +17 -0
- package/dist/payments/tpay/webhookJwsVerifier.js +51 -0
- package/dist/payments/tpay/webhookNormalizer.d.ts +54 -0
- package/dist/payments/tpay/webhookNormalizer.js +200 -0
- package/dist/payments/verifiedEvidenceProjection.d.ts +12 -0
- package/dist/payments/verifiedEvidenceProjection.js +166 -0
- package/dist/payments/verifiedIngress.d.ts +119 -0
- package/dist/payments/verifiedIngress.js +220 -0
- package/package.json +22 -2
- package/release-gates.json +64 -3
- package/sql/20260810180000_payment_settlement_rail.sql +574 -0
- package/sql/20260815150000_payment_truth_reconciliation.sql +479 -0
- package/sql/20260826100000_late_payment_shifts_the_subscription_cycle.sql +215 -0
- package/src/composition/composeBundle.ts +9 -0
- package/src/composition/index.ts +1 -0
- package/src/payments/adapterConformance.ts +118 -0
- package/src/payments/bindings.ts +17 -0
- package/src/payments/checkoutActivePaymentActionResolver.ts +97 -0
- package/src/payments/checkoutRecoveryPaymentResolver.ts +108 -0
- package/src/payments/contracts/adapter.ts +96 -0
- package/src/payments/contracts/conformance.ts +171 -0
- package/src/payments/contracts/evidence.ts +142 -0
- package/src/payments/contracts/index.ts +54 -0
- package/src/payments/contracts/preparedAttempt.ts +86 -0
- package/src/payments/contracts/reconciliation.ts +296 -0
- package/src/payments/contracts/repair.ts +95 -0
- package/src/payments/contracts/truth.ts +108 -0
- package/src/payments/eventConformanceRunner.ts +149 -0
- package/src/payments/index.ts +21 -0
- package/src/payments/paymentAttemptSilenceWindow.ts +26 -0
- package/src/payments/paymentMethodLifecycle.ts +50 -0
- package/src/payments/paymentProviderReconciliationAbsenceReopen.ts +130 -0
- package/src/payments/paymentProviderReconciliationEvidence.ts +117 -0
- package/src/payments/paymentProviderReconciliationWorker.ts +291 -0
- package/src/payments/paymentTruth.ts +131 -0
- package/src/payments/paymentVerifyNowHandler.ts +94 -0
- package/src/payments/paymentVerifyNowService.ts +125 -0
- package/src/payments/paymentWebhookPostProcessing.ts +36 -0
- package/src/payments/postgresPaymentTruth.ts +149 -0
- package/src/payments/preparedAttempt.ts +90 -0
- package/src/payments/preparedAttemptFinalization.ts +13 -0
- package/src/payments/schemaCapabilities.ts +10 -0
- package/src/payments/sql.ts +6 -0
- package/src/payments/stripe/classifyDecline.ts +62 -0
- package/src/payments/stripe/declineFailureHints.ts +89 -0
- package/src/payments/stripe/donorContracts.ts +87 -0
- package/src/payments/stripe/executionDecline.ts +126 -0
- package/src/payments/stripe/index.ts +235 -0
- package/src/payments/stripe/intentMapper.ts +58 -0
- package/src/payments/stripe/methodFacts.ts +21 -0
- package/src/payments/stripe/paymentFailureEvidence.ts +58 -0
- package/src/payments/stripe/stripeFailureEvidence.ts +115 -0
- package/src/payments/stripe/stripeIntentConfirmationEvidence.ts +34 -0
- package/src/payments/stripe/stripePaymentReconciliationProvider.ts +263 -0
- package/src/payments/stripe/stripeRefundEvidence.ts +19 -0
- package/src/payments/stripe/stripeSandboxPaymentExecutionAdapter.ts +283 -0
- package/src/payments/stripe/stripeWebhookNormalizer.ts +277 -0
- package/src/payments/stripe/webhookSignature.ts +55 -0
- package/src/payments/tpay/blikRecurringMapper.ts +42 -0
- package/src/payments/tpay/capabilities.ts +21 -0
- package/src/payments/tpay/classifyDecline.ts +62 -0
- package/src/payments/tpay/declineFailureHints.ts +90 -0
- package/src/payments/tpay/donorContracts.ts +104 -0
- package/src/payments/tpay/index.ts +257 -0
- package/src/payments/tpay/paymentFailureEvidence.ts +58 -0
- package/src/payments/tpay/preparedProviderAttempt.ts +38 -0
- package/src/payments/tpay/providerAttemptFailureDiagnostic.ts +99 -0
- package/src/payments/tpay/tpayCreateTransactionInput.ts +158 -0
- package/src/payments/tpay/tpayDeclineEvidence.ts +233 -0
- package/src/payments/tpay/tpayDispatchFailure.ts +186 -0
- package/src/payments/tpay/tpayFailureEvidence.ts +75 -0
- package/src/payments/tpay/tpayHttpClient.ts +290 -0
- package/src/payments/tpay/tpayHttpFailure.ts +186 -0
- package/src/payments/tpay/tpayPaymentExecutionAdapter.ts +297 -0
- package/src/payments/tpay/tpayPaymentReconciliationProvider.ts +285 -0
- package/src/payments/tpay/tpayTransactionPayload.ts +207 -0
- package/src/payments/tpay/wallClockTime.ts +57 -0
- package/src/payments/tpay/webhookJwsVerifier.ts +82 -0
- package/src/payments/tpay/webhookNormalizer.ts +279 -0
- package/src/payments/verifiedEvidenceProjection.ts +179 -0
- package/src/payments/verifiedIngress.ts +317 -0
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { stripeWebhookPayload } from "./stripeFailureEvidence.js";
|
|
2
|
+
import { stripeRefundEvidence } from "./stripeRefundEvidence.js";
|
|
3
|
+
import { expiryInstantFromMonthYear, type PaymentMethodLifecycleEvent, type PaymentMethodLifecycleEventKind, type PaymentMethodReplacementFacts } from "@openlup/core/payment";
|
|
4
|
+
import type { NormalizedProviderPaymentWebhook } from "./donorContracts.js";
|
|
5
|
+
import { methodFactSnapshotKeys, type ProviderWebhookWithMethodLifecycle } from "./methodFacts.js";
|
|
6
|
+
|
|
7
|
+
const PROVIDER_KIND = "stripe";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Method-family events, mapped to the neutral lifecycle vocabulary. They carry
|
|
11
|
+
* no money and match no local attempt, so they ride the canonical `setup.*`
|
|
12
|
+
* kinds and the ingest ledger's allowlist does not move. `attached` is the
|
|
13
|
+
* first delivery carrying the instrument's own facts (the setup event carries
|
|
14
|
+
* only its id); `automatically_updated` is the network rotating those facts
|
|
15
|
+
* under an UNCHANGED consent, never a death; `detached` is the method leaving
|
|
16
|
+
* the payer's file for good. ⚠️ Deploy note: the endpoint's subscribed event
|
|
17
|
+
* list must include these three names or none of them ever arrive — an
|
|
18
|
+
* operator console change, not a code change.
|
|
19
|
+
*/
|
|
20
|
+
type MethodLifecycleMapping = { kind: PaymentMethodLifecycleEventKind; canonical: NormalizedProviderPaymentWebhook["eventType"] };
|
|
21
|
+
const METHOD_LIFECYCLE_EVENTS: Record<string, MethodLifecycleMapping> = {
|
|
22
|
+
"payment_method.attached": { kind: "method_registered", canonical: "setup.succeeded" },
|
|
23
|
+
"payment_method.automatically_updated": { kind: "method_updated", canonical: "setup.succeeded" },
|
|
24
|
+
"payment_method.detached": { kind: "method_revoked", canonical: "setup.failed" },
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const SUPPORTED_EVENT_TYPES = new Set<NormalizedProviderPaymentWebhook["eventType"]>([
|
|
28
|
+
"payment.requires_action",
|
|
29
|
+
"payment.succeeded",
|
|
30
|
+
"payment.failed",
|
|
31
|
+
"payment.refunded",
|
|
32
|
+
"payment.disputed",
|
|
33
|
+
"setup.succeeded",
|
|
34
|
+
"setup.failed",
|
|
35
|
+
"setup.requires_action",
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Convert a verified raw Stripe webhook payload into the plural handler's
|
|
40
|
+
* `NormalizedProviderPaymentWebhook` shape.
|
|
41
|
+
*
|
|
42
|
+
* Reusable method extraction (Wave C): populated for `setup_intent.succeeded`
|
|
43
|
+
* always, and for `payment_intent.succeeded` when `setup_future_usage` was
|
|
44
|
+
* requested at PaymentIntent creation. Requires `metadata.clientId` to be set
|
|
45
|
+
* (the adapter does this when the runtime caller passes `clientId` through
|
|
46
|
+
* `PaymentExecutionInput`). When metadata is missing, `reusableMethod` stays
|
|
47
|
+
* null and the plural handler skips the method-ref upsert — safe degradation
|
|
48
|
+
* rather than crashing the webhook.
|
|
49
|
+
*/
|
|
50
|
+
export function normalizeStripeWebhookEvent(
|
|
51
|
+
rawPayload: Record<string, unknown>,
|
|
52
|
+
): ProviderWebhookWithMethodLifecycle {
|
|
53
|
+
const providerEventId = readString(rawPayload, "id");
|
|
54
|
+
const eventType = readString(rawPayload, "type");
|
|
55
|
+
const data = readObject(rawPayload, "data");
|
|
56
|
+
const object = readObject(data, "object");
|
|
57
|
+
|
|
58
|
+
const canonicalEventType = mapStripeEventType(eventType, object);
|
|
59
|
+
const providerPaymentId = resolveLinkagePaymentId(eventType, object);
|
|
60
|
+
// Payment-control compares this against the intent's `amount_cents` (the order
|
|
61
|
+
// total) as a tamper guard, so we want the PaymentIntent's authorized `amount`,
|
|
62
|
+
// not `amount_received`. On a declined/canceled PI Stripe reports
|
|
63
|
+
// `amount_received: 0` while `amount` still holds the full total — and `??`
|
|
64
|
+
// does NOT treat `0` as nullish, so reading `amount_received` first yielded a
|
|
65
|
+
// `0` that tripped the amount-mismatch guard and left every failed event
|
|
66
|
+
// unreconciled (intent stuck `processing`, inventory holds leaked). Prefer
|
|
67
|
+
// `amount`; fall back to `amount_received` only when `amount` is absent.
|
|
68
|
+
const amountMinor =
|
|
69
|
+
readOptionalNumber(object, "amount") ??
|
|
70
|
+
readOptionalNumber(object, "amount_received") ??
|
|
71
|
+
null;
|
|
72
|
+
const currency = readOptionalString(object, "currency")?.toUpperCase() ?? null;
|
|
73
|
+
const occurredAt = readOccurredAt(rawPayload);
|
|
74
|
+
const reusableMethod = extractReusableMethod(canonicalEventType, object);
|
|
75
|
+
const refund = stripeRefundEvidence(eventType, object);
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
provider: PROVIDER_KIND,
|
|
79
|
+
providerEventId,
|
|
80
|
+
eventType: canonicalEventType,
|
|
81
|
+
providerPaymentId,
|
|
82
|
+
paymentIntentId: null,
|
|
83
|
+
paymentAttemptId: null,
|
|
84
|
+
amountMinor,
|
|
85
|
+
currency,
|
|
86
|
+
occurredAt,
|
|
87
|
+
refundKind: refund.refundKind,
|
|
88
|
+
rawPayload: {
|
|
89
|
+
...stripeWebhookPayload({ eventId: providerEventId, eventType, providerPaymentId, amountMinor, currency }, object),
|
|
90
|
+
...(refund.snapshot ? { stripeRefundSnapshot: refund.snapshot } : {}),
|
|
91
|
+
},
|
|
92
|
+
reusableMethod,
|
|
93
|
+
methodLifecycle: extractMethodLifecycle(eventType, object, providerEventId, occurredAt),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The neutral transition this delivery reports, or null when it reports none. */
|
|
98
|
+
function extractMethodLifecycle(
|
|
99
|
+
rawEventType: string,
|
|
100
|
+
object: Record<string, unknown>,
|
|
101
|
+
providerEventId: string,
|
|
102
|
+
occurredAt: string,
|
|
103
|
+
): PaymentMethodLifecycleEvent | null {
|
|
104
|
+
const mapping = METHOD_LIFECYCLE_EVENTS[rawEventType];
|
|
105
|
+
// On a method-family event the webhook object IS the method, so its own id is
|
|
106
|
+
// the reference the stored row is keyed by.
|
|
107
|
+
const providerMethodRef = readOptionalString(object, "id");
|
|
108
|
+
if (!mapping || !providerMethodRef) return null;
|
|
109
|
+
const replacement = readMethodFacts(object);
|
|
110
|
+
return { kind: mapping.kind, providerKind: PROVIDER_KIND, providerMethodRef, providerEventId, occurredAt, replacement };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Scheme label, trailing digits and expiry, from whichever of the three shapes
|
|
115
|
+
* the payload carries them in: the method object itself at the top level, an
|
|
116
|
+
* expanded method one level down, or a charge-shaped object's method details.
|
|
117
|
+
* A payload carrying none of them yields null, not a row of empty facts.
|
|
118
|
+
*/
|
|
119
|
+
function readMethodFacts(object: Record<string, unknown>): PaymentMethodReplacementFacts | null {
|
|
120
|
+
const card = readOptionalObject(object, "card")
|
|
121
|
+
?? readOptionalObject(readOptionalObject(object, "payment_method") ?? {}, "card")
|
|
122
|
+
?? readOptionalObject(readOptionalObject(object, "payment_method_details") ?? {}, "card");
|
|
123
|
+
if (!card) return null;
|
|
124
|
+
const schemeLabel = readOptionalString(card, "brand") ?? null;
|
|
125
|
+
const lastDigits = readOptionalString(card, "last4") ?? null;
|
|
126
|
+
const expiresAt = expiryInstantFromMonthYear(readOptionalNumber(card, "exp_month") ?? null, readOptionalNumber(card, "exp_year") ?? null);
|
|
127
|
+
if (!schemeLabel && !lastDigits && !expiresAt) return null;
|
|
128
|
+
return { schemeLabel, lastDigits, expiresAt };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Resolve the provider payment id used to LINK this event to the local payment
|
|
133
|
+
* attempt. Payment-control matches inbound events to an attempt by
|
|
134
|
+
* `commerce_payment_attempts.provider_attempt_id`, which holds the Stripe
|
|
135
|
+
* PaymentIntent id (`pi_…`).
|
|
136
|
+
*
|
|
137
|
+
* For `payment_intent.*` events the webhook object IS the PaymentIntent, so its
|
|
138
|
+
* `id` is already the linkage key. For `charge.*` (incl. `charge.dispute.*`)
|
|
139
|
+
* the object is a Charge (`ch_…`) or Dispute (`dp_…`) whose own id never
|
|
140
|
+
* matches an attempt — but it carries `payment_intent` pointing back at the PI.
|
|
141
|
+
* Using that field is what lets `charge.refunded` / `charge.dispute.created`
|
|
142
|
+
* resolve to the order and drive `applyPaymentResult`; without it refunds and
|
|
143
|
+
* disputes were ingested but silently never applied. Falls back to the object
|
|
144
|
+
* id when `payment_intent` is absent (defensive — keeps the event recordable).
|
|
145
|
+
*/
|
|
146
|
+
function resolveLinkagePaymentId(
|
|
147
|
+
stripeEventType: string,
|
|
148
|
+
object: Record<string, unknown>,
|
|
149
|
+
): string {
|
|
150
|
+
if (stripeEventType.startsWith("charge.")) {
|
|
151
|
+
const paymentIntentId = readOptionalString(object, "payment_intent");
|
|
152
|
+
if (paymentIntentId) return paymentIntentId;
|
|
153
|
+
}
|
|
154
|
+
return readString(object, "id");
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function mapStripeEventType(
|
|
158
|
+
stripeEventType: string,
|
|
159
|
+
object: Record<string, unknown>,
|
|
160
|
+
): NormalizedProviderPaymentWebhook["eventType"] {
|
|
161
|
+
if (stripeEventType === "payment_intent.succeeded") return "payment.succeeded";
|
|
162
|
+
if (stripeEventType === "payment_intent.payment_failed") return "payment.failed";
|
|
163
|
+
if (stripeEventType === "payment_intent.canceled") return "payment.failed";
|
|
164
|
+
if (stripeEventType === "payment_intent.requires_action") return "payment.requires_action";
|
|
165
|
+
if (stripeEventType === "setup_intent.succeeded") return "setup.succeeded";
|
|
166
|
+
if (stripeEventType === "setup_intent.setup_failed") return "setup.failed";
|
|
167
|
+
if (stripeEventType === "setup_intent.requires_action") return "setup.requires_action";
|
|
168
|
+
if (stripeEventType === "charge.refunded") return "payment.refunded";
|
|
169
|
+
if (stripeEventType.startsWith("charge.dispute.")) return "payment.disputed";
|
|
170
|
+
const methodLifecycle = METHOD_LIFECYCLE_EVENTS[stripeEventType];
|
|
171
|
+
if (methodLifecycle) return methodLifecycle.canonical;
|
|
172
|
+
const status = readOptionalString(object, "status");
|
|
173
|
+
if (status === "requires_action") return "payment.requires_action";
|
|
174
|
+
const guess = `payment.${stripeEventType}` as NormalizedProviderPaymentWebhook["eventType"];
|
|
175
|
+
if (SUPPORTED_EVENT_TYPES.has(guess)) return guess;
|
|
176
|
+
throw new Error(`Unsupported Stripe webhook event: ${stripeEventType}`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function extractReusableMethod(
|
|
180
|
+
canonicalEventType: NormalizedProviderPaymentWebhook["eventType"],
|
|
181
|
+
object: Record<string, unknown>,
|
|
182
|
+
): NormalizedProviderPaymentWebhook["reusableMethod"] {
|
|
183
|
+
const isSetupSucceeded = canonicalEventType === "setup.succeeded";
|
|
184
|
+
const isPaymentSucceededWithSaveCard =
|
|
185
|
+
canonicalEventType === "payment.succeeded" &&
|
|
186
|
+
readOptionalString(object, "setup_future_usage") === "off_session";
|
|
187
|
+
if (!isSetupSucceeded && !isPaymentSucceededWithSaveCard) return null;
|
|
188
|
+
|
|
189
|
+
const customer = readOptionalString(object, "customer");
|
|
190
|
+
// The method arrives as a bare id, or as an expanded object when the payload
|
|
191
|
+
// also carries its facts; both address the same stored method.
|
|
192
|
+
const paymentMethod = readOptionalString(object, "payment_method")
|
|
193
|
+
?? readOptionalString(readOptionalObject(object, "payment_method") ?? {}, "id");
|
|
194
|
+
const metadata = readOptionalObject(object, "metadata");
|
|
195
|
+
const clientId = metadata ? readOptionalString(metadata, "clientId") : undefined;
|
|
196
|
+
const subscriptionId = metadata ? readOptionalString(metadata, "subscriptionId") : undefined;
|
|
197
|
+
const mode = metadata ? readOptionalString(metadata, "mode") : undefined;
|
|
198
|
+
const recoveryCaseId = metadata ? readOptionalString(metadata, "recoveryCaseId") : undefined;
|
|
199
|
+
const metadataSource = metadata ? readOptionalString(metadata, "source") : undefined;
|
|
200
|
+
if (!customer || !paymentMethod || !clientId) return null;
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
clientId,
|
|
204
|
+
// W11.7 Wave D-4a — recovery setup-method flow tags `subscriptionId` in
|
|
205
|
+
// SetupIntent metadata so this upsert binds the new method to the
|
|
206
|
+
// subscription the dunning case targets. One-time + initial save-card
|
|
207
|
+
// (Wave C) flows leave the field null because no subscription exists yet.
|
|
208
|
+
subscriptionId: subscriptionId ?? null,
|
|
209
|
+
providerCustomerRef: customer,
|
|
210
|
+
providerMethodRef: paymentMethod,
|
|
211
|
+
providerMandateRef: null,
|
|
212
|
+
methodKind: "card",
|
|
213
|
+
status: "active",
|
|
214
|
+
consentSnapshot: {
|
|
215
|
+
source: "stripe_webhook",
|
|
216
|
+
eventType: canonicalEventType,
|
|
217
|
+
checkoutMode: mode ?? null,
|
|
218
|
+
recoveryCaseId: recoveryCaseId ?? null,
|
|
219
|
+
metadataSource: metadataSource ?? null,
|
|
220
|
+
// Additive facts only, and only when the payload actually carried them.
|
|
221
|
+
// The setup event carries the method's id and nothing else, so this is
|
|
222
|
+
// usually empty there and filled later by the method-family delivery.
|
|
223
|
+
...methodFactSnapshotKeys(readMethodFacts(object)),
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function readOccurredAt(rawPayload: Record<string, unknown>): string {
|
|
229
|
+
const created = rawPayload.created;
|
|
230
|
+
if (typeof created === "number" && Number.isFinite(created)) {
|
|
231
|
+
return new Date(created * 1000).toISOString();
|
|
232
|
+
}
|
|
233
|
+
if (typeof created === "string") {
|
|
234
|
+
const parsed = new Date(created);
|
|
235
|
+
if (!Number.isNaN(parsed.getTime())) return parsed.toISOString();
|
|
236
|
+
}
|
|
237
|
+
// Stripe always sends `created` on event payloads; fall back defensively if
|
|
238
|
+
// missing rather than refusing the event — downstream RPC still records the
|
|
239
|
+
// signature-verified facts.
|
|
240
|
+
return new Date(0).toISOString();
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function readObject(value: Record<string, unknown>, key: string): Record<string, unknown> {
|
|
244
|
+
const raw = value[key];
|
|
245
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
246
|
+
throw new Error(`Stripe payload missing object ${key}`);
|
|
247
|
+
}
|
|
248
|
+
return raw as Record<string, unknown>;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function readOptionalObject(
|
|
252
|
+
value: Record<string, unknown>,
|
|
253
|
+
key: string,
|
|
254
|
+
): Record<string, unknown> | null {
|
|
255
|
+
const raw = value[key];
|
|
256
|
+
return raw && typeof raw === "object" && !Array.isArray(raw)
|
|
257
|
+
? (raw as Record<string, unknown>)
|
|
258
|
+
: null;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function readString(value: Record<string, unknown>, key: string): string {
|
|
262
|
+
const raw = value[key];
|
|
263
|
+
if (typeof raw !== "string" || raw.length === 0) {
|
|
264
|
+
throw new Error(`Stripe payload missing string ${key}`);
|
|
265
|
+
}
|
|
266
|
+
return raw;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function readOptionalString(value: Record<string, unknown>, key: string): string | undefined {
|
|
270
|
+
const raw = value[key];
|
|
271
|
+
return typeof raw === "string" && raw.length > 0 ? raw : undefined;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function readOptionalNumber(value: Record<string, unknown>, key: string): number | undefined {
|
|
275
|
+
const raw = value[key];
|
|
276
|
+
return typeof raw === "number" ? raw : undefined;
|
|
277
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
+
export const STRIPE_TIMESTAMP_TOLERANCE_SECONDS = 300;
|
|
3
|
+
|
|
4
|
+
export function verifyStripeSignature(input: {
|
|
5
|
+
rawBody: string;
|
|
6
|
+
signatureHeader: string | undefined;
|
|
7
|
+
/**
|
|
8
|
+
* One or many endpoint secrets to accept. Multi-secret support lets a single
|
|
9
|
+
* route accept signatures from both the dashboard webhook endpoint and a
|
|
10
|
+
* local `stripe listen` CLI tunnel; the signature is accepted if ANY secret
|
|
11
|
+
* verifies. An empty array (or missing single secret) fails closed.
|
|
12
|
+
*/
|
|
13
|
+
endpointSecret: string | readonly string[];
|
|
14
|
+
/** Injectable clock (UTC seconds) for deterministic tests; defaults to now. */
|
|
15
|
+
now?: () => number;
|
|
16
|
+
}): boolean {
|
|
17
|
+
const parts = Object.fromEntries(
|
|
18
|
+
(input.signatureHeader ?? "")
|
|
19
|
+
.split(",")
|
|
20
|
+
.map((part) => part.split("="))
|
|
21
|
+
.filter(([key, value]) => key && value) as Array<[string, string]>,
|
|
22
|
+
);
|
|
23
|
+
const timestamp = parts.t;
|
|
24
|
+
const signature = parts.v1;
|
|
25
|
+
if (!timestamp || !signature) return false;
|
|
26
|
+
// Reject replays outside the tolerance window before doing the HMAC work. A
|
|
27
|
+
// valid signature on a stale timestamp (captured-and-replayed event) must not
|
|
28
|
+
// pass; the future-skew bound tolerates small clock drift between Stripe and us.
|
|
29
|
+
const timestampSec = Number(timestamp);
|
|
30
|
+
if (!Number.isFinite(timestampSec)) return false;
|
|
31
|
+
const now = input.now ?? (() => Math.floor(Date.now() / 1000));
|
|
32
|
+
const ageSec = now() - timestampSec;
|
|
33
|
+
if (ageSec > STRIPE_TIMESTAMP_TOLERANCE_SECONDS) return false;
|
|
34
|
+
if (ageSec < -STRIPE_TIMESTAMP_TOLERANCE_SECONDS) return false;
|
|
35
|
+
const secrets = Array.isArray(input.endpointSecret)
|
|
36
|
+
? input.endpointSecret
|
|
37
|
+
: input.endpointSecret
|
|
38
|
+
? [input.endpointSecret]
|
|
39
|
+
: [];
|
|
40
|
+
return secrets.some((secret) => {
|
|
41
|
+
if (!secret) return false;
|
|
42
|
+
const expected = hmac(`${timestamp}.${input.rawBody}`, secret);
|
|
43
|
+
return timingSafeStringEqual(signature, expected);
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function hmac(value: string, secret: string): string {
|
|
48
|
+
return createHmac("sha256", secret).update(value).digest("hex");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function timingSafeStringEqual(left: string, right: string): boolean {
|
|
52
|
+
const leftBuffer = Buffer.from(left);
|
|
53
|
+
const rightBuffer = Buffer.from(right);
|
|
54
|
+
return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer);
|
|
55
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export interface TpayBlikAliasRegistrationInput {
|
|
2
|
+
orderRef: string;
|
|
3
|
+
amountMinor: number;
|
|
4
|
+
currency: string;
|
|
5
|
+
customerEmail: string;
|
|
6
|
+
staticWebhookUrl: string;
|
|
7
|
+
consentRef: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface TpayBlikAliasChargeInput {
|
|
11
|
+
orderRef: string;
|
|
12
|
+
payId: string;
|
|
13
|
+
amountMinor: number;
|
|
14
|
+
currency: string;
|
|
15
|
+
staticWebhookUrl: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function buildTpayBlikAliasRegistrationDraft(input: TpayBlikAliasRegistrationInput) {
|
|
19
|
+
return {
|
|
20
|
+
provider: "tpay",
|
|
21
|
+
requestKind: "blik_alias_registration",
|
|
22
|
+
orderRef: input.orderRef,
|
|
23
|
+
amount: input.amountMinor,
|
|
24
|
+
currency: input.currency,
|
|
25
|
+
customer: { email: input.customerEmail },
|
|
26
|
+
webhookUrl: input.staticWebhookUrl,
|
|
27
|
+
consentRef: input.consentRef,
|
|
28
|
+
fallbackRequiredWhenAliasUnavailable: true,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function buildTpayBlikAliasChargeDraft(input: TpayBlikAliasChargeInput) {
|
|
33
|
+
return {
|
|
34
|
+
provider: "tpay",
|
|
35
|
+
requestKind: "blik_alias_charge",
|
|
36
|
+
orderRef: input.orderRef,
|
|
37
|
+
payId: input.payId,
|
|
38
|
+
amount: input.amountMinor,
|
|
39
|
+
currency: input.currency,
|
|
40
|
+
webhookUrl: input.staticWebhookUrl,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { PaymentAdapterCapabilities } from "../contracts/index.js";
|
|
2
|
+
|
|
3
|
+
/** Bank-redirect, one-time-code and unattended Model O attempts are supported with host payer/method input; account availability remains verification_pending. */
|
|
4
|
+
export const tpayPaymentCapabilities: Readonly<PaymentAdapterCapabilities> = Object.freeze<PaymentAdapterCapabilities>({
|
|
5
|
+
providerKind: "tpay",
|
|
6
|
+
captureFlows: Object.freeze([
|
|
7
|
+
Object.freeze({ kind: "bank_redirect", handoff: "redirect", methodFamilies: Object.freeze(["bank-redirect"] as const) }),
|
|
8
|
+
Object.freeze({ kind: "one_time_code", handoff: "customer-authorization", methodFamilies: Object.freeze(["one-time-code"] as const) }),
|
|
9
|
+
]),
|
|
10
|
+
requiresStoredMandateEvidence: true,
|
|
11
|
+
assessMandate: snapshot => snapshot.recurringModel === "O" ? { chargeable: true }
|
|
12
|
+
: { chargeable: false, blockReason: "mandate_model_unsupported_for_unattended_charge" },
|
|
13
|
+
unattendedChargeFlow: "recurring_charge",
|
|
14
|
+
payerContext: Object.freeze({ requiresPayerBlock: true, customerRefFallsBackToContactEmail: false }),
|
|
15
|
+
methodHealth: Object.freeze({ requiresCustomerRef: false, requiredMethodKind: "blik_payid", requiresPayerContact: true }),
|
|
16
|
+
terminalOutcomeReporting: Object.freeze({ kind: "attempt_records", silenceBecomesSuspectAfterMinutes: 360 }),
|
|
17
|
+
mandateUpsertIsSubscriptionScoped: true,
|
|
18
|
+
supportedCurrencies: Object.freeze(["PLN"]), unattended: "supported", readback: "supported", close: "unsupported",
|
|
19
|
+
exactAbsence: "unsupported", refundObservation: "supported", refundCreate: "unsupported", authorizationCaptureSplit: "unsupported",
|
|
20
|
+
});
|
|
21
|
+
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { classifyPaymentFailure, type PaymentExecutionProviderDecline, type PaymentFailureClassification, type PaymentFailureHint } 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
|
+
} as const;
|
|
8
|
+
|
|
9
|
+
/** Maps a provider refusal to the stable code persisted as `failure_reason`. */
|
|
10
|
+
export function declineFailureReason(decline: PaymentExecutionProviderDecline): string {
|
|
11
|
+
return decline.mandateUnsupported
|
|
12
|
+
? DECLINE_FAILURE_REASONS.mandateUnsupported
|
|
13
|
+
: DECLINE_FAILURE_REASONS.generic;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The one place a refused payment gets a class, for every rail that consumes a
|
|
18
|
+
* synchronous decline: interactive checkout, checkout recovery, and the
|
|
19
|
+
* off-session renewal cron.
|
|
20
|
+
*
|
|
21
|
+
* Provider-agnostic on purpose. Every code the adapter understood has already
|
|
22
|
+
* been translated into `adviceCode` and `neutralReasonHints` by the adapter that
|
|
23
|
+
* owns the vocabulary, so nothing here needs to know which provider refused.
|
|
24
|
+
*
|
|
25
|
+
* The one reading this seam adds is the mandate flag. `mandateUnsupported` is a
|
|
26
|
+
* neutral capability statement any adapter may set, and it is what produces the
|
|
27
|
+
* scheme-named failure reason below; asserting the matching hint is what turns
|
|
28
|
+
* that reason into `mandate_dead` instead of leaving it unclassified. Doing it
|
|
29
|
+
* here rather than per adapter means a second adapter setting the same flag gets
|
|
30
|
+
* the same class without repeating the mapping.
|
|
31
|
+
*
|
|
32
|
+
* DISPLAY-LOAD-BEARING, and NOT a cadence input. Three live readers turn the
|
|
33
|
+
* class this seam decides into what the payer is told: `dunningFailureClassPort`
|
|
34
|
+
* reads it off the case, `customerAccountActionRequiredReadModel` selects it into
|
|
35
|
+
* the account surface, and `paymentRecoverySetupHandler` renders it as the cause
|
|
36
|
+
* sentence on the recovery page. What still does NOT read it is the retry ladder:
|
|
37
|
+
* no cadence, retry decision or scheduling branch consults the class, and that
|
|
38
|
+
* wave stays gated on validating this taxonomy against the historical corpus.
|
|
39
|
+
*
|
|
40
|
+
* `failureReasonKey` overrides the reason this seam would derive itself, and is
|
|
41
|
+
* supplied by a rail that persists a DIFFERENT reason string with the attempt:
|
|
42
|
+
* the reconciliation rail writes its own adapter-prefixed key, not
|
|
43
|
+
* `provider_declined`. That key is the classifier's weakest evidence tier, so
|
|
44
|
+
* the override changes a class only where the rail's own key is mapped; what it
|
|
45
|
+
* always changes is `decidedBy`, and that is the point. The historical-corpus
|
|
46
|
+
* replay reproduces a verdict from the stored attempt row, so the key named in
|
|
47
|
+
* the telemetry has to be the key actually on that row rather than one this seam
|
|
48
|
+
* invented for itself.
|
|
49
|
+
*/
|
|
50
|
+
export function classifyDecline(
|
|
51
|
+
decline: PaymentExecutionProviderDecline,
|
|
52
|
+
options: { failureReasonKey?: string } = {},
|
|
53
|
+
): PaymentFailureClassification {
|
|
54
|
+
const asserted: PaymentFailureHint[] = [...(decline.neutralReasonHints ?? [])];
|
|
55
|
+
if (decline.mandateUnsupported) asserted.push("mandateUnsupported");
|
|
56
|
+
return classifyPaymentFailure({
|
|
57
|
+
adviceCode: decline.adviceCode,
|
|
58
|
+
declineCode: decline.declineCode,
|
|
59
|
+
neutralReasonHints: asserted,
|
|
60
|
+
failureReasonKey: options.failureReasonKey ?? declineFailureReason(decline),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { PaymentFailureHint, PaymentRetryAdviceCode } from "@openlup/core/payment";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ⚠️ UNOFFICIAL INFERENCE — the whole table below.
|
|
5
|
+
*
|
|
6
|
+
* The provider publishes no retryable-versus-terminal classification for these
|
|
7
|
+
* numeric refusal codes. Every row is this adapter's reading of the code's
|
|
8
|
+
* documented MEANING, not a documented retry verdict, and one of them
|
|
9
|
+
* (`"100"`) appears in no published table at all. Treat the table as a
|
|
10
|
+
* hypothesis under test. The class it produces is already DISPLAY-load-bearing:
|
|
11
|
+
* it becomes the sentence the payer reads about their own refusal, so a wrong
|
|
12
|
+
* row here misinforms a customer today. It is still not a cadence input: the
|
|
13
|
+
* dunning programme validates this table against the historical refusal corpus
|
|
14
|
+
* before anything in the retry ladder is allowed to branch on it.
|
|
15
|
+
*
|
|
16
|
+
* The contract that would end the inference is OUTSTANDING, not absent: the
|
|
17
|
+
* dunning programme's letter to the provider asks for the official meaning of
|
|
18
|
+
* these codes, and is tracked there as an open item. Until it is answered, a row
|
|
19
|
+
* may be read more narrowly than it is written — see `assertableHints` in
|
|
20
|
+
* `tpayDeclineEvidence.ts`, where each observation decides how much of its own
|
|
21
|
+
* reading it is entitled to state — but no row may be read more widely.
|
|
22
|
+
*
|
|
23
|
+
* Two rails read this table, and both reach it through the attempt log's
|
|
24
|
+
* `paymentErrorCode`: the reconciliation poll, and — since the 2026-08-26
|
|
25
|
+
* incident — the synchronous execution path, which until then decided a
|
|
26
|
+
* mandate refusal by pattern-matching the refusal MESSAGE.
|
|
27
|
+
*
|
|
28
|
+
* Codes arrive as strings because that is how they leave the transport layer.
|
|
29
|
+
*
|
|
30
|
+
* Two rows carry a decision worth stating rather than a lookup:
|
|
31
|
+
*
|
|
32
|
+
* - `"101"` (refused by the payer) and `"107"` (refused on security grounds)
|
|
33
|
+
* are NOT credential death — the instrument may be in perfect health and
|
|
34
|
+
* the payer may approve the very next charge on it. There is no hint for
|
|
35
|
+
* "terminal for this attempt, instrument fine", so this adapter instead
|
|
36
|
+
* states the retry verdict directly, as advice: an unattended repeat of a
|
|
37
|
+
* refusal the payer or their bank made deliberately is the retry that
|
|
38
|
+
* cannot succeed and still costs a scheme fee. Naming it as advice is also
|
|
39
|
+
* the more honest shape — it records what this adapter concludes about
|
|
40
|
+
* RETRYING, without asserting anything about the instrument.
|
|
41
|
+
* - `"100"` and `"102"` get NO reading. Nothing published says what they mean
|
|
42
|
+
* precisely enough to classify, and `indeterminate` is the correct answer to
|
|
43
|
+
* a code we cannot read. They are named in
|
|
44
|
+
* `KNOWN_UNREADABLE_DECLINE_CODES` below, so their absence stays a recorded
|
|
45
|
+
* decision rather than an oversight.
|
|
46
|
+
*/
|
|
47
|
+
export interface DeclineCodeReading {
|
|
48
|
+
hints?: readonly PaymentFailureHint[];
|
|
49
|
+
adviceCode?: PaymentRetryAdviceCode;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const DECLINE_CODE_READING_TABLE: Readonly<Record<string, DeclineCodeReading>> = Object.freeze({
|
|
53
|
+
"101": Object.freeze({ adviceCode: "do_not_try_again" } as const),
|
|
54
|
+
"103": Object.freeze({ hints: Object.freeze(["transient"] as const) } as const),
|
|
55
|
+
"104": Object.freeze({ hints: Object.freeze(["transient"] as const) } as const),
|
|
56
|
+
"105": Object.freeze({ hints: Object.freeze(["mandateUnsupported"] as const) } as const),
|
|
57
|
+
"106": Object.freeze({ hints: Object.freeze(["limitExceeded"] as const) } as const),
|
|
58
|
+
"107": Object.freeze({ adviceCode: "do_not_try_again" } as const),
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Codes this adapter has SEEN and still cannot read. They are deliberately absent
|
|
63
|
+
* from the table above rather than mapped to a guess: a row there must state
|
|
64
|
+
* vocabulary the kernel declares, and `indeterminate` is the honest answer to a
|
|
65
|
+
* code whose meaning is unconfirmed. Listing them here separates "we have met
|
|
66
|
+
* this code and refuse to invent its semantics" from "we have never seen it",
|
|
67
|
+
* which read identically at the lookup.
|
|
68
|
+
*
|
|
69
|
+
* - `"100"` — observed in production on 2026-08-26, as the first attempt of a
|
|
70
|
+
* refused mandate registration whose second attempt was `"105"`. Nothing
|
|
71
|
+
* published says what it means, so it maps to no hint and no advice.
|
|
72
|
+
* - `"102"` — same standing, from the same unpublished range.
|
|
73
|
+
*
|
|
74
|
+
* A code moving out of this list into the table is a semantics decision backed by
|
|
75
|
+
* evidence, not a lookup edit.
|
|
76
|
+
*/
|
|
77
|
+
export const KNOWN_UNREADABLE_DECLINE_CODES: readonly string[] = Object.freeze(["100", "102"]);
|
|
78
|
+
|
|
79
|
+
const NO_READING: DeclineCodeReading = Object.freeze({});
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* This adapter's reading of one refusal code. Own-property lookup, so an
|
|
83
|
+
* inherited key (`constructor`, `toString`) is not a mapping.
|
|
84
|
+
*/
|
|
85
|
+
export function declineCodeReading(code: string | undefined): DeclineCodeReading {
|
|
86
|
+
if (code === undefined) return NO_READING;
|
|
87
|
+
return Object.prototype.hasOwnProperty.call(DECLINE_CODE_READING_TABLE, code)
|
|
88
|
+
? DECLINE_CODE_READING_TABLE[code]
|
|
89
|
+
: NO_READING;
|
|
90
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/** Private structural contracts for the unchanged execution and observation slices. */
|
|
2
|
+
import type { PaymentExecutionBaseInput, PaymentExecutionBaseResult } from "@openlup/core/payment";
|
|
3
|
+
import type { ProviderReadPurpose } from "../contracts/index.js";
|
|
4
|
+
export type PaymentProviderFlow = "one_time_payment" | "setup_reusable_method" | "off_session_payment" | "blik_one_time" | "blik_one_click" | "pbl_one_time" | "blik_recurring_activation" | "recurring_charge";
|
|
5
|
+
export const PAYMENT_NEXT_ACTION_KINDS = ["redirect", "3ds_challenge", "sca_required"] as const;
|
|
6
|
+
export type PaymentNextActionKind = (typeof PAYMENT_NEXT_ACTION_KINDS)[number];
|
|
7
|
+
export interface PaymentExecutionInput extends PaymentExecutionBaseInput {
|
|
8
|
+
orderRef: string;
|
|
9
|
+
orderId?: string;
|
|
10
|
+
subjectId?: string | null;
|
|
11
|
+
returnContext?: "public" | "account";
|
|
12
|
+
payer?: { email: string; name: string; ip?: string | null; userAgent?: string | null };
|
|
13
|
+
transientProviderInput?: TpayTransientProviderInput | null;
|
|
14
|
+
paymentMethodAliasType?: "UID" | "PAYID";
|
|
15
|
+
paymentMethodRecurringModel?: "O" | "M";
|
|
16
|
+
providerFlow?: PaymentProviderFlow;
|
|
17
|
+
customerRef?: string;
|
|
18
|
+
paymentMethodRef?: string;
|
|
19
|
+
saveForFutureUse?: boolean;
|
|
20
|
+
clientId?: string;
|
|
21
|
+
}
|
|
22
|
+
export interface PaymentExecutionResult extends PaymentExecutionBaseResult {
|
|
23
|
+
nextActionKind: PaymentNextActionKind | null;
|
|
24
|
+
redirectUrl?: string | null;
|
|
25
|
+
clientSecret?: string | null;
|
|
26
|
+
customerProviderRef?: string | null;
|
|
27
|
+
paymentMethodRef?: string | null;
|
|
28
|
+
webhookExpected?: boolean;
|
|
29
|
+
recoveryRequired?: boolean;
|
|
30
|
+
}
|
|
31
|
+
export interface PaymentExecutionPort {
|
|
32
|
+
validateInput?(input: PaymentExecutionInput): void;
|
|
33
|
+
execute(input: PaymentExecutionInput): Promise<PaymentExecutionResult>;
|
|
34
|
+
}
|
|
35
|
+
export type PaymentWebhookEventType = "payment.succeeded" | "payment.failed" | "payment.refunded" |
|
|
36
|
+
"payment.requires_action" | "payment.disputed" | "setup.succeeded" | "setup.failed" | "setup.requires_action";
|
|
37
|
+
export interface CanonicalPaymentEvent {
|
|
38
|
+
provider_event_id: string;
|
|
39
|
+
event_type: PaymentWebhookEventType;
|
|
40
|
+
payment_provider_id: string;
|
|
41
|
+
amount_minor?: number;
|
|
42
|
+
next_action_kind?: PaymentNextActionKind;
|
|
43
|
+
raw_payload: Record<string, unknown>;
|
|
44
|
+
}
|
|
45
|
+
export interface NormalizedProviderPaymentWebhook {
|
|
46
|
+
provider: string;
|
|
47
|
+
providerEventId: string;
|
|
48
|
+
eventType: PaymentWebhookEventType;
|
|
49
|
+
providerPaymentId: string;
|
|
50
|
+
paymentIntentId?: string | null;
|
|
51
|
+
paymentAttemptId?: string | null;
|
|
52
|
+
amountMinor?: number | null;
|
|
53
|
+
currency?: string | null;
|
|
54
|
+
occurredAt: string;
|
|
55
|
+
refundKind?: "full" | "partial" | null;
|
|
56
|
+
rawPayload: Record<string, unknown>;
|
|
57
|
+
reusableMethod?: {
|
|
58
|
+
clientId: string;
|
|
59
|
+
subscriptionId?: string | null;
|
|
60
|
+
providerCustomerRef?: string | null;
|
|
61
|
+
providerMethodRef: string;
|
|
62
|
+
providerMandateRef?: string | null;
|
|
63
|
+
methodKind: "card";
|
|
64
|
+
status: "active";
|
|
65
|
+
consentSnapshot?: Record<string, unknown>;
|
|
66
|
+
} | null;
|
|
67
|
+
}
|
|
68
|
+
export interface ProviderReconciliationStatus {
|
|
69
|
+
status: "succeeded" | "failed" | "pending" | "unknown";
|
|
70
|
+
providerStatus: string;
|
|
71
|
+
occurredAt: string | null;
|
|
72
|
+
failureReason: string | null;
|
|
73
|
+
amountMinor: number | null;
|
|
74
|
+
currency: string | null;
|
|
75
|
+
rawPayload: Record<string, unknown>;
|
|
76
|
+
}
|
|
77
|
+
export interface ClaimedPaymentAttempt {
|
|
78
|
+
paymentAttemptId: string; paymentIntentId: string; paymentId: string; orderId: string;
|
|
79
|
+
subscriptionId: string | null; subscriptionCycleId: string | null; provider: string;
|
|
80
|
+
providerPaymentId: string | null; providerAttemptId: string | null; providerSessionId: string | null;
|
|
81
|
+
attemptStatus: string; intentStatus: string; amountMinor: number; currency: string;
|
|
82
|
+
orderMode: "one_time" | "subscription_cycle"; cycleRetryAttempt: number; cycleNextRetryAt: string | null;
|
|
83
|
+
localUpdatedAt: string;
|
|
84
|
+
}
|
|
85
|
+
export type ProviderRecoveryAction = { kind: "redirect"; url: string };
|
|
86
|
+
export interface PaymentProviderRecoveryProvider {
|
|
87
|
+
resolvePaymentReference(input: { providerAttemptId: string | null; intentProviderPaymentId: string | null;
|
|
88
|
+
providerSessionId: string | null }): string | null;
|
|
89
|
+
readPayment(input: { providerPaymentId: string; attempt?: ClaimedPaymentAttempt;
|
|
90
|
+
purpose?: ProviderReadPurpose }): Promise<ProviderReconciliationStatus>;
|
|
91
|
+
readRecoveryPayment(input: { providerPaymentId: string; attempt: Pick<ClaimedPaymentAttempt, "amountMinor" | "currency">;
|
|
92
|
+
purpose?: ProviderReadPurpose }): Promise<{ status: ProviderReconciliationStatus; identityMatches: boolean;
|
|
93
|
+
configuredMoneyMatches: boolean; manualReviewRequired: boolean; clientAction: ProviderRecoveryAction | null }>;
|
|
94
|
+
closePayment?(input: { providerPaymentId: string }): Promise<ProviderReconciliationStatus>;
|
|
95
|
+
findPaymentByLocalIntent?(input: { attempt: ClaimedPaymentAttempt }): Promise<"found" | "absent">;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface TpayTransientProviderInput {
|
|
99
|
+
provider: "tpay";
|
|
100
|
+
flow: "blik_one_time" | "blik_one_click" | "pbl_one_time" | "blik_recurring_activation" | "recurring_charge";
|
|
101
|
+
blikToken?: string;
|
|
102
|
+
channelId?: string;
|
|
103
|
+
recurringModel?: "O" | "M";
|
|
104
|
+
}
|