@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,264 @@
|
|
|
1
|
+
import { createHash, X509Certificate } from "node:crypto";
|
|
2
|
+
import { classifyPaymentFailure, isPaymentRetryAdviceCode } from "@openlup/core/payment";
|
|
3
|
+
import { tpayPaymentCapabilities } from "./capabilities.js";
|
|
4
|
+
export { tpayPaymentCapabilities } from "./capabilities.js";
|
|
5
|
+
import { createTpayHttpClient } from "./tpayHttpClient.js";
|
|
6
|
+
import { createTpayPaymentExecutionAdapter } from "./tpayPaymentExecutionAdapter.js";
|
|
7
|
+
import { ProviderAttemptPreDispatchError } from "./preparedProviderAttempt.js";
|
|
8
|
+
import { createTpayPaymentReconciliationProvider } from "./tpayPaymentReconciliationProvider.js";
|
|
9
|
+
import { verifyTpayJwsSignature } from "./webhookJwsVerifier.js";
|
|
10
|
+
import { normalizeTpayWebhook, toNormalizedProviderTpayWebhook } from "./webhookNormalizer.js";
|
|
11
|
+
const refusal = (reason) => ({ kind: "refusal", reason });
|
|
12
|
+
/** Construction explicitly selects this adapter; the host owns account activation and I/O policy. */
|
|
13
|
+
export function createTpayPaymentAdapter(config) {
|
|
14
|
+
const capabilities = tpayPaymentCapabilities;
|
|
15
|
+
const notification = config.notification;
|
|
16
|
+
if (!notification?.rootCertificatePem || typeof notification.resolveCertificate !== "function")
|
|
17
|
+
throw new Error("Required certificate configuration missing");
|
|
18
|
+
const prefix = new URL(notification.allowedCertificateUrlPrefix);
|
|
19
|
+
if (prefix.protocol !== "https:" || prefix.username || prefix.password || prefix.search || prefix.hash
|
|
20
|
+
|| !notification.allowedCertificateUrlPrefix.endsWith("/"))
|
|
21
|
+
throw new Error("Invalid certificate URL prefix");
|
|
22
|
+
const root = new X509Certificate(notification.rootCertificatePem);
|
|
23
|
+
const maxAge = notification.maxNotificationAgeSeconds ?? 7 * 24 * 60 * 60;
|
|
24
|
+
const maxClockSkew = notification.maxClockSkewSeconds ?? 300;
|
|
25
|
+
if (!Number.isFinite(maxAge) || maxAge < 0 || !Number.isFinite(maxClockSkew) || maxClockSkew < 0 || !["test", "live"].includes(config.mode)
|
|
26
|
+
|| typeof config.http.fetchImpl !== "function" || !config.http.clientId || !config.http.clientSecret
|
|
27
|
+
|| typeof config.modelOActivationEnabled !== "boolean")
|
|
28
|
+
throw new Error("Invalid payment configuration");
|
|
29
|
+
for (const url of [config.http.baseUrl, ...Object.values(config.urls)]) {
|
|
30
|
+
const parsed = new URL(url);
|
|
31
|
+
if (parsed.protocol !== "https:" || parsed.username || parsed.password)
|
|
32
|
+
throw new Error("Invalid payment URL");
|
|
33
|
+
}
|
|
34
|
+
const now = config.clock ?? (() => Date.now() / 1000);
|
|
35
|
+
const client = createTpayHttpClient({ ...config.http, now: () => now() * 1000 });
|
|
36
|
+
const execution = createTpayPaymentExecutionAdapter({ client, ...config.urls,
|
|
37
|
+
modelOActivationEnabled: config.modelOActivationEnabled });
|
|
38
|
+
const reconciliation = createTpayPaymentReconciliationProvider(client);
|
|
39
|
+
const validateHostInput = (input) => {
|
|
40
|
+
if (input.mode !== config.mode || !["initial", "recovery", "renewal-unattended"].includes(input.purpose)
|
|
41
|
+
|| !capabilities.supportedCurrencies.includes(input.money.currency))
|
|
42
|
+
throw new Error("Unsupported payment input");
|
|
43
|
+
};
|
|
44
|
+
const donorInput = (input) => {
|
|
45
|
+
if (!input.attemptRef || !input.intentRef || !input.fingerprint || !input.providerIdempotencyKey
|
|
46
|
+
|| !Number.isSafeInteger(input.money.amountMinor) || input.money.amountMinor <= 0
|
|
47
|
+
|| !input.payer?.email || (input.payer.name !== undefined && typeof input.payer.name !== "string")) {
|
|
48
|
+
throw new Error("Invalid payment input");
|
|
49
|
+
}
|
|
50
|
+
const renewal = input.purpose === "renewal-unattended";
|
|
51
|
+
const family = input.methodSelection?.family;
|
|
52
|
+
if (renewal ? !input.storedMethod?.reference || input.storedMethod.mandate.recurringModel !== "O"
|
|
53
|
+
|| input.customerAuthorization !== undefined
|
|
54
|
+
: family !== "bank-redirect" && family !== "one-time-code")
|
|
55
|
+
throw new Error("Unsupported payment flow");
|
|
56
|
+
if (!renewal && (family === "bank-redirect" ? !input.methodSelection?.channelRef || input.customerAuthorization !== undefined
|
|
57
|
+
: !input.customerAuthorization))
|
|
58
|
+
throw new Error("Required payment context missing");
|
|
59
|
+
return {
|
|
60
|
+
idempotencyKey: input.attemptRef, providerIdempotencyKey: input.providerIdempotencyKey,
|
|
61
|
+
providerRequestFingerprint: input.fingerprint, paymentIntentId: input.intentRef, orderRef: input.intentRef,
|
|
62
|
+
amountMinor: input.money.amountMinor, currency: input.money.currency,
|
|
63
|
+
mode: renewal ? "subscription_cycle" : "one_time",
|
|
64
|
+
payer: { email: input.payer.email, name: input.payer.name?.trim() ? input.payer.name : "Customer" },
|
|
65
|
+
...(renewal ? { providerFlow: "recurring_charge", paymentMethodRef: input.storedMethod.reference,
|
|
66
|
+
paymentMethodAliasType: "PAYID", paymentMethodRecurringModel: "O" } : {
|
|
67
|
+
transientProviderInput: family === "bank-redirect"
|
|
68
|
+
? { provider: "tpay", flow: "pbl_one_time", channelId: input.methodSelection.channelRef }
|
|
69
|
+
: { provider: "tpay", flow: "blik_one_time", blikToken: input.customerAuthorization },
|
|
70
|
+
}),
|
|
71
|
+
};
|
|
72
|
+
};
|
|
73
|
+
const validateInput = (input) => {
|
|
74
|
+
validateHostInput(input);
|
|
75
|
+
execution.validateInput?.(donorInput(input));
|
|
76
|
+
};
|
|
77
|
+
return {
|
|
78
|
+
capabilities,
|
|
79
|
+
attemptPort: {
|
|
80
|
+
validateInput,
|
|
81
|
+
async execute(input) {
|
|
82
|
+
validateHostInput(input);
|
|
83
|
+
let prepared;
|
|
84
|
+
try {
|
|
85
|
+
prepared = donorInput(input);
|
|
86
|
+
execution.validateInput?.(prepared);
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
const reason = { code: "fix_and_retry_customer_action", hints: ["dataInvalid"] };
|
|
90
|
+
return { outcome: { kind: "decline", refusal: reason },
|
|
91
|
+
dispatch: { kind: "notDispatched", evidence: { exact: true, phase: "before-dispatch", reason } }, diagnostics: {} };
|
|
92
|
+
}
|
|
93
|
+
let result;
|
|
94
|
+
try {
|
|
95
|
+
result = await execution.execute(prepared);
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
if (!(error instanceof ProviderAttemptPreDispatchError))
|
|
99
|
+
throw error;
|
|
100
|
+
// A proven document refusal needs corrected data; other trusted no-charge failures can retry.
|
|
101
|
+
const hints = error.phase === "response_decode"
|
|
102
|
+
&& error.code === "tpay_request_refused" && error.dispatchState === "refused" ? ["dataInvalid"] : ["transient"];
|
|
103
|
+
const reason = { code: classifyPaymentFailure({ neutralReasonHints: hints }).failureClass, hints };
|
|
104
|
+
return { outcome: { kind: "decline", refusal: reason },
|
|
105
|
+
dispatch: { kind: "notDispatched", evidence: { exact: true, phase: "before-dispatch", reason } }, diagnostics: {} };
|
|
106
|
+
}
|
|
107
|
+
// Only these neutral facts cross the boundary; donor payloads and transient payer/authorization stay local.
|
|
108
|
+
return {
|
|
109
|
+
outcome: result.providerDecline ? { kind: "decline", refusal: neutralDecline(result.providerDecline) }
|
|
110
|
+
: { kind: "attempt", status: result.attemptStatus },
|
|
111
|
+
// API transaction ID is required for readback; notification titles remain distinct.
|
|
112
|
+
dispatch: result.providerSessionId ? { kind: "dispatched", providerPaymentRef: result.providerSessionId } : { kind: "unknown" },
|
|
113
|
+
...(result.nextActionKind === "redirect" && result.redirectUrl
|
|
114
|
+
? { handoff: { kind: "redirect", url: result.redirectUrl } } : {}),
|
|
115
|
+
diagnostics: { webhookExpected: result.webhookExpected === true, recoveryRequired: result.recoveryRequired === true },
|
|
116
|
+
};
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
verifyPort: {
|
|
120
|
+
async verifyAndNormalize(input) {
|
|
121
|
+
const header = Object.entries(input.headers).find(([key]) => key.toLowerCase() === "x-jws-signature")?.[1];
|
|
122
|
+
if (typeof header !== "string")
|
|
123
|
+
return refusal("signature");
|
|
124
|
+
const rawBody = Buffer.from(input.rawBody).toString("utf8");
|
|
125
|
+
if (!Buffer.from(rawBody).equals(Buffer.from(input.rawBody)))
|
|
126
|
+
return refusal("malformed");
|
|
127
|
+
try {
|
|
128
|
+
const current = now() * 1000;
|
|
129
|
+
if (!validAt(root, current))
|
|
130
|
+
return refusal("signature");
|
|
131
|
+
const verified = await verifyTpayJwsSignature({ rawBody, signatureHeader: header,
|
|
132
|
+
rootCertificatePem: notification.rootCertificatePem, allowedX5uPrefix: notification.allowedCertificateUrlPrefix,
|
|
133
|
+
resolveCertificate: async (url) => {
|
|
134
|
+
const pem = await notification.resolveCertificate(url);
|
|
135
|
+
if (!validAt(new X509Certificate(pem), current))
|
|
136
|
+
throw new Error("Certificate outside validity window");
|
|
137
|
+
return pem;
|
|
138
|
+
} });
|
|
139
|
+
if (!verified)
|
|
140
|
+
return refusal("signature");
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return refusal("signature");
|
|
144
|
+
}
|
|
145
|
+
try {
|
|
146
|
+
// Only a signed protected-header issued-at assertion supplies notification time.
|
|
147
|
+
const protectedHeader = JSON.parse(Buffer.from(header.split(".")[0], "base64url").toString("utf8"));
|
|
148
|
+
const asserted = protectedHeader.iat;
|
|
149
|
+
if (asserted !== undefined && (typeof asserted !== "number" || !Number.isFinite(asserted)
|
|
150
|
+
|| !Number.isFinite(new Date(asserted * 1000).getTime())))
|
|
151
|
+
return refusal("malformed");
|
|
152
|
+
const occurred = typeof asserted === "number" ? new Date(asserted * 1000).toISOString() : null;
|
|
153
|
+
if (occurred !== null) {
|
|
154
|
+
const received = Date.parse(input.receivedAt) / 1000;
|
|
155
|
+
if (!Number.isFinite(received))
|
|
156
|
+
return refusal("malformed");
|
|
157
|
+
if (received - asserted > maxAge || asserted - received > maxClockSkew)
|
|
158
|
+
return refusal("stale");
|
|
159
|
+
}
|
|
160
|
+
return mapVerifiedTpayEvent(rawBody, occurred, config.mode, capabilities.supportedCurrencies);
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return refusal("malformed");
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
readbackPort: {
|
|
168
|
+
async readPayment(input) {
|
|
169
|
+
const base = { identity: input.identity, expectedMoney: input.expectedMoney, purpose: input.purpose };
|
|
170
|
+
if (input.identity.provider !== "tpay" || !input.identity.providerPaymentRef)
|
|
171
|
+
return { ...base, kind: "unsupported" };
|
|
172
|
+
try {
|
|
173
|
+
const reading = await reconciliation.readRecoveryPayment({
|
|
174
|
+
providerPaymentId: input.identity.providerPaymentRef, purpose: input.purpose, attempt: input.expectedMoney,
|
|
175
|
+
});
|
|
176
|
+
const observed = reading.status;
|
|
177
|
+
if (!reading.identityMatches || !reading.configuredMoneyMatches || reading.manualReviewRequired
|
|
178
|
+
|| !Number.isSafeInteger(input.expectedMoney.amountMinor) || input.expectedMoney.amountMinor <= 0
|
|
179
|
+
|| observed.status === "unknown")
|
|
180
|
+
return unknown(base);
|
|
181
|
+
// Success and nonzero refused money must match; unpaid refusals retain the configured obligation.
|
|
182
|
+
if ((observed.status === "succeeded" || (observed.status === "failed" && observed.amountMinor !== null && observed.amountMinor !== 0))
|
|
183
|
+
&& (observed.amountMinor !== input.expectedMoney.amountMinor
|
|
184
|
+
|| observed.currency !== input.expectedMoney.currency.toUpperCase()))
|
|
185
|
+
return unknown(base);
|
|
186
|
+
return { ...base, kind: observed.status, observedMoney: { amountMinor: input.expectedMoney.amountMinor,
|
|
187
|
+
currency: input.expectedMoney.currency.toUpperCase() },
|
|
188
|
+
providerOccurredAt: observed.occurredAt };
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
return unknown(base);
|
|
192
|
+
}
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
function neutralDecline(decline) {
|
|
198
|
+
const hints = decline.neutralReasonHints ?? [];
|
|
199
|
+
return { code: classifyPaymentFailure({ adviceCode: decline.adviceCode, neutralReasonHints: hints }).failureClass, hints,
|
|
200
|
+
...(decline.adviceCode && isPaymentRetryAdviceCode(decline.adviceCode) ? { adviceCode: decline.adviceCode } : {}) };
|
|
201
|
+
}
|
|
202
|
+
function validAt(certificate, instant) {
|
|
203
|
+
return Number.isFinite(instant) && Date.parse(certificate.validFrom) <= instant && instant <= Date.parse(certificate.validTo);
|
|
204
|
+
}
|
|
205
|
+
function unknown(base) {
|
|
206
|
+
return { ...base, kind: "unknown", reason: "indeterminate" };
|
|
207
|
+
}
|
|
208
|
+
/** All donor outcomes outside the current neutral union are refused here. */
|
|
209
|
+
function mapTpayOutcome(event) {
|
|
210
|
+
switch (event) {
|
|
211
|
+
case "payment.succeeded": return "succeeded";
|
|
212
|
+
case "payment.failed": return "failed";
|
|
213
|
+
case "payment.requires_action": return "pending";
|
|
214
|
+
case "payment.refunded": return "refunded";
|
|
215
|
+
default: return null;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
function mapVerifiedTpayEvent(rawBody, occurred, mode, supportedCurrencies) {
|
|
219
|
+
const webhook = normalizeTpayWebhook(rawBody);
|
|
220
|
+
const event = webhook.event;
|
|
221
|
+
const payload = event.raw_payload;
|
|
222
|
+
if (payload.testMode !== null && payload.testMode !== undefined
|
|
223
|
+
&& payload.testMode !== (mode === "test" ? "1" : "0"))
|
|
224
|
+
return refusal("unsupported-event");
|
|
225
|
+
const identity = { provider: "tpay", providerOccurredAt: occurred };
|
|
226
|
+
let evidence;
|
|
227
|
+
if (payload.eventKind === "blik_alias") {
|
|
228
|
+
const kinds = {
|
|
229
|
+
method_registered: "registered", method_updated: "updated", method_revoked: "revoked", method_expired: "expired",
|
|
230
|
+
};
|
|
231
|
+
const outcome = kinds[webhook.methodLifecycleKind ?? ""];
|
|
232
|
+
if (!outcome)
|
|
233
|
+
return refusal("unsupported-event");
|
|
234
|
+
const lifecycle = occurred !== null ? toNormalizedProviderTpayWebhook(webhook, occurred).methodLifecycle : null;
|
|
235
|
+
evidence = { ...identity, kind: "method", outcome, providerEventId: `${event.provider_event_id}:${outcome}`,
|
|
236
|
+
methodRef: event.payment_provider_id, setupRef: null, intentRef: event.payment_intent_id ?? null,
|
|
237
|
+
...(lifecycle ? { methodLifecycle: lifecycle } : {}) };
|
|
238
|
+
}
|
|
239
|
+
else {
|
|
240
|
+
const outcome = mapTpayOutcome(event.event_type);
|
|
241
|
+
if (!outcome)
|
|
242
|
+
return refusal("unsupported-event");
|
|
243
|
+
const amount = event.amount_minor;
|
|
244
|
+
const reportedCurrency = payload.currency;
|
|
245
|
+
if (reportedCurrency == null && supportedCurrencies.length !== 1)
|
|
246
|
+
return refusal("malformed");
|
|
247
|
+
// Currency binds to the adapter's declared single supported currency because
|
|
248
|
+
// attempts in any other currency are refused before dispatch; this is not a silent default.
|
|
249
|
+
const currency = reportedCurrency ?? supportedCurrencies[0];
|
|
250
|
+
const money = Number.isSafeInteger(amount) && amount > 0 && typeof currency === "string" && /^[A-Za-z]{3}$/u.test(currency)
|
|
251
|
+
? { amountMinor: amount, currency: currency.toUpperCase() } : null;
|
|
252
|
+
const originalAmount = typeof payload.transactionAmount === "string"
|
|
253
|
+
? Math.round(Number(payload.transactionAmount.replace(",", ".")) * 100) : NaN;
|
|
254
|
+
if (outcome === "refunded" && (!money || !Number.isSafeInteger(originalAmount) || originalAmount <= 0))
|
|
255
|
+
return refusal("malformed");
|
|
256
|
+
evidence = { ...identity, kind: "money", providerEventId: `${event.payment_provider_id}:${outcome}`,
|
|
257
|
+
providerPaymentRef: event.payment_provider_id, intentRef: event.payment_intent_id ?? null, attemptRef: null, outcome, money,
|
|
258
|
+
...(outcome === "refunded" && money ? { refund: { kind: "full-reversal", reason: "chargeback",
|
|
259
|
+
operationId: `${event.payment_provider_id}:chargeback`, originalPaymentMoney: { amountMinor: originalAmount, currency: money.currency } } } : {}),
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
return { ...evidence, fingerprint: createHash("sha256").update(JSON.stringify(evidence)).digest("hex"),
|
|
263
|
+
providerAuditPayload: payload };
|
|
264
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { PaymentRecoveryMethod, PaymentRecoveryOperation } from "@openlup/core/payment";
|
|
2
|
+
export type FailureObservationSource = "execution" | "webhook" | "readback";
|
|
3
|
+
export type FailureObservationDisposition = "present" | "absent" | "unreadable" | "read_failed" | "read_timeout";
|
|
4
|
+
/** Protected provider facts, not a customer message or a retry-policy input. */
|
|
5
|
+
export interface PaymentFailureEvidence {
|
|
6
|
+
version: 1;
|
|
7
|
+
source: FailureObservationSource;
|
|
8
|
+
disposition: FailureObservationDisposition;
|
|
9
|
+
refusalVerified: boolean;
|
|
10
|
+
providerPaymentId: string | null;
|
|
11
|
+
providerChargeId: string | null;
|
|
12
|
+
code: string | null;
|
|
13
|
+
declineCode: string | null;
|
|
14
|
+
adviceCode: string | null;
|
|
15
|
+
adviceOrigin: "provider" | "inferred" | "unknown";
|
|
16
|
+
operation: PaymentRecoveryOperation | null;
|
|
17
|
+
method: PaymentRecoveryMethod | null;
|
|
18
|
+
methodSource: "provider" | "execution_contract" | "unknown";
|
|
19
|
+
}
|
|
20
|
+
/** Bounded machine tokens only. Free-form response fields never cross this seam. */
|
|
21
|
+
export declare function evidenceToken(value: unknown): string | null;
|
|
22
|
+
export declare function evidenceRecord(value: unknown): Record<string, unknown>;
|
|
23
|
+
/** Explicit construction prevents accidental payload expansion by object spreading. */
|
|
24
|
+
export declare function serializeFailureEvidence(input: PaymentFailureEvidence): PaymentFailureEvidence;
|
|
25
|
+
/** Persist beside legacy fields; never merge the diagnostic codes into them. */
|
|
26
|
+
export declare function failureEvidencePayload(evidence: PaymentFailureEvidence | null | undefined): Record<string, unknown>;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** Bounded machine tokens only. Free-form response fields never cross this seam. */
|
|
2
|
+
export function evidenceToken(value) {
|
|
3
|
+
return typeof value === "string" && /^[a-zA-Z0-9_:.-]{1,96}$/.test(value) ? value : null;
|
|
4
|
+
}
|
|
5
|
+
export function evidenceRecord(value) {
|
|
6
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
7
|
+
? value : {};
|
|
8
|
+
}
|
|
9
|
+
/** Explicit construction prevents accidental payload expansion by object spreading. */
|
|
10
|
+
export function serializeFailureEvidence(input) {
|
|
11
|
+
const method = input.method;
|
|
12
|
+
const kind = evidenceToken(method?.kind);
|
|
13
|
+
const recoveryMethodKey = evidenceToken(method?.recoveryMethodKey);
|
|
14
|
+
return {
|
|
15
|
+
version: 1,
|
|
16
|
+
source: input.source,
|
|
17
|
+
disposition: input.disposition,
|
|
18
|
+
refusalVerified: input.refusalVerified,
|
|
19
|
+
providerPaymentId: evidenceToken(input.providerPaymentId),
|
|
20
|
+
providerChargeId: evidenceToken(input.providerChargeId),
|
|
21
|
+
code: evidenceToken(input.code),
|
|
22
|
+
declineCode: evidenceToken(input.declineCode),
|
|
23
|
+
adviceCode: evidenceToken(input.adviceCode),
|
|
24
|
+
adviceOrigin: input.adviceOrigin,
|
|
25
|
+
operation: input.operation,
|
|
26
|
+
method: kind && recoveryMethodKey && method ? { kind, recoveryMethodKey, interaction: method.interaction } : null,
|
|
27
|
+
methodSource: kind && recoveryMethodKey ? input.methodSource : "unknown",
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
/** Persist beside legacy fields; never merge the diagnostic codes into them. */
|
|
31
|
+
export function failureEvidencePayload(evidence) {
|
|
32
|
+
return evidence ? { failureEvidence: serializeFailureEvidence(evidence) } : {};
|
|
33
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export interface TrustedProviderAttemptPreDispatchFailure {
|
|
2
|
+
phase: "oauth" | "transaction_dispatch";
|
|
3
|
+
code: "tpay_oauth_failed" | "tpay_oauth_timeout" | "tpay_oauth_invalid_response" | "tpay_request_encode_failed" | "tpay_request_deadline_exhausted";
|
|
4
|
+
dispatchState: "not_dispatched";
|
|
5
|
+
}
|
|
6
|
+
/** Either proof the provider holds no transaction: the POST was never invoked, or it was and the
|
|
7
|
+
* provider refused the request document. Two closed shapes, so neither absorbs the other. */
|
|
8
|
+
export type TrustedProviderAttemptNoChargeFailure = TrustedProviderAttemptPreDispatchFailure | {
|
|
9
|
+
phase: "response_decode";
|
|
10
|
+
code: "tpay_request_refused";
|
|
11
|
+
dispatchState: "refused";
|
|
12
|
+
};
|
|
13
|
+
export declare class ProviderAttemptPreDispatchError extends Error {
|
|
14
|
+
readonly phase: TrustedProviderAttemptNoChargeFailure["phase"];
|
|
15
|
+
readonly code: TrustedProviderAttemptNoChargeFailure["code"];
|
|
16
|
+
readonly dispatchState: TrustedProviderAttemptNoChargeFailure["dispatchState"];
|
|
17
|
+
paymentAttemptId: string | null;
|
|
18
|
+
retryCount: number;
|
|
19
|
+
constructor(failure: TrustedProviderAttemptNoChargeFailure, paymentAttemptId?: string | null);
|
|
20
|
+
withPaymentAttempt(paymentAttemptId: string): this;
|
|
21
|
+
withRetryCount(retryCount: number): this;
|
|
22
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export class ProviderAttemptPreDispatchError extends Error {
|
|
2
|
+
phase;
|
|
3
|
+
code;
|
|
4
|
+
dispatchState;
|
|
5
|
+
paymentAttemptId;
|
|
6
|
+
retryCount = 0;
|
|
7
|
+
constructor(failure, paymentAttemptId = null) {
|
|
8
|
+
const kind = failure.dispatchState === "refused" ? "provider_refused_pre_transaction" : "provider_pre_dispatch_failed";
|
|
9
|
+
super(`${kind}:${failure.phase}:${failure.code}`);
|
|
10
|
+
this.name = "ProviderAttemptPreDispatchError";
|
|
11
|
+
this.phase = failure.phase;
|
|
12
|
+
this.code = failure.code;
|
|
13
|
+
this.dispatchState = failure.dispatchState;
|
|
14
|
+
this.paymentAttemptId = paymentAttemptId;
|
|
15
|
+
}
|
|
16
|
+
withPaymentAttempt(paymentAttemptId) {
|
|
17
|
+
this.paymentAttemptId = paymentAttemptId;
|
|
18
|
+
return this;
|
|
19
|
+
}
|
|
20
|
+
withRetryCount(retryCount) {
|
|
21
|
+
this.retryCount = retryCount;
|
|
22
|
+
return this;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
const TPAY_REQUEST_ID = /^[a-f0-9]{20}$/;
|
|
2
|
+
const LOGGABLE_PROVIDER_ERROR_CODES = new Set([
|
|
3
|
+
"bank_group_does_not_exist",
|
|
4
|
+
"blik_token_not_present",
|
|
5
|
+
"cannot_create_transaction",
|
|
6
|
+
"channel_passed_with_group",
|
|
7
|
+
"channel_unavailable",
|
|
8
|
+
"channel_unavailable_for_source",
|
|
9
|
+
"configuration_error",
|
|
10
|
+
"country_not_valid",
|
|
11
|
+
"crc_not_unique",
|
|
12
|
+
"field_required",
|
|
13
|
+
"general",
|
|
14
|
+
"incorrect_content_type",
|
|
15
|
+
"internal_server_error",
|
|
16
|
+
"invalid_request_body",
|
|
17
|
+
"is_required",
|
|
18
|
+
"no_payment_data",
|
|
19
|
+
"not_array",
|
|
20
|
+
"not_natural_int",
|
|
21
|
+
"not_string",
|
|
22
|
+
"not_valid",
|
|
23
|
+
"partner_cannot_create_transaction",
|
|
24
|
+
"payment_failed",
|
|
25
|
+
"too_low",
|
|
26
|
+
"transaction_blocked",
|
|
27
|
+
"transaction_lock",
|
|
28
|
+
"ts:channel:amount_outside_range",
|
|
29
|
+
"ts:channel:both_channel_and_group_specified",
|
|
30
|
+
"ts:channel:channel_blocked",
|
|
31
|
+
"ts:channel:channel_disabled",
|
|
32
|
+
"ts:channel:channel_disabled_for_pos",
|
|
33
|
+
"ts:channel:collect_prohibited",
|
|
34
|
+
"ts:channel:group_disabled_for_pos",
|
|
35
|
+
"ts:channel:not_available",
|
|
36
|
+
"ts:channel:not_found",
|
|
37
|
+
"ts:channel:source_prohibited",
|
|
38
|
+
"ts:channel:surcharge_prohibited",
|
|
39
|
+
"ts:country:not_valid",
|
|
40
|
+
"ts:merchant:disabled",
|
|
41
|
+
"ts:merchant:is_partner",
|
|
42
|
+
"ts:merchant:not_found",
|
|
43
|
+
"ts:payer:phone:not_valid",
|
|
44
|
+
"ts:pos:not_found",
|
|
45
|
+
"ts:pos:result_url_not_overridable",
|
|
46
|
+
"ts:request:no_ip",
|
|
47
|
+
"ts:transaction:not_inserted",
|
|
48
|
+
"ts:transaction:payer_data_not_inserted",
|
|
49
|
+
"ts:transaction:payer_info_not_inserted",
|
|
50
|
+
"unexpected_field",
|
|
51
|
+
"unsupported_blik_token",
|
|
52
|
+
"url_override_is_disabled",
|
|
53
|
+
"wrong_alias",
|
|
54
|
+
"wrong_autopayment_alias",
|
|
55
|
+
"wrong_model_data_combination",
|
|
56
|
+
]);
|
|
57
|
+
const LOGGABLE_PROVIDER_FIELD_NAMES = new Set([
|
|
58
|
+
"amount",
|
|
59
|
+
"currency",
|
|
60
|
+
"description",
|
|
61
|
+
"hiddenDescription",
|
|
62
|
+
"payer.email",
|
|
63
|
+
"payer.name",
|
|
64
|
+
"payer.phone",
|
|
65
|
+
"payer.address",
|
|
66
|
+
"payer.city",
|
|
67
|
+
"payer.country",
|
|
68
|
+
"payer.postalCode",
|
|
69
|
+
// This list carries FIELD NAMES ONLY - `ProviderAttemptFailureDiagnostic` has
|
|
70
|
+
// no field that holds a provider value - so these two entries log the literal
|
|
71
|
+
// strings, never a user agent or an IP address. Their absence is why the
|
|
72
|
+
// 2026-08-28 BLIK outage logged `fieldNames: []` and stayed unattributed for
|
|
73
|
+
// days: `readTpayHttpFailure` filters Tpay's returned `fieldName` through this
|
|
74
|
+
// set, so an unlisted name is silently dropped.
|
|
75
|
+
"payer.ip",
|
|
76
|
+
"payer.userAgent",
|
|
77
|
+
"pay.groupId",
|
|
78
|
+
"pay.channelId",
|
|
79
|
+
"pay.blikPaymentData.blikToken",
|
|
80
|
+
"pay.blikPaymentData.type",
|
|
81
|
+
"pay.blikPaymentData.aliases.value",
|
|
82
|
+
"pay.blikPaymentData.aliases.type",
|
|
83
|
+
"callbacks.notification.url",
|
|
84
|
+
"callbacks.payerUrls.success",
|
|
85
|
+
"callbacks.payerUrls.error",
|
|
86
|
+
]);
|
|
87
|
+
export function loggableProviderRequestId(value) {
|
|
88
|
+
return typeof value === "string" && TPAY_REQUEST_ID.test(value);
|
|
89
|
+
}
|
|
90
|
+
export function loggableProviderErrorCode(value) {
|
|
91
|
+
return typeof value === "string" && LOGGABLE_PROVIDER_ERROR_CODES.has(value);
|
|
92
|
+
}
|
|
93
|
+
export function loggableProviderFieldName(value) {
|
|
94
|
+
return typeof value === "string" && LOGGABLE_PROVIDER_FIELD_NAMES.has(value);
|
|
95
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { PaymentExecutionInput, TpayTransientProviderInput } from "./donorContracts.js";
|
|
2
|
+
import type { TpayPayerInput, TpayTransactionCreateInput } from "./tpayHttpClient.js";
|
|
3
|
+
export declare function buildCreateTransactionInput(input: {
|
|
4
|
+
input: PaymentExecutionInput;
|
|
5
|
+
transient: TpayTransientProviderInput;
|
|
6
|
+
payer: TpayPayerInput;
|
|
7
|
+
notificationUrl: string;
|
|
8
|
+
successUrl: string;
|
|
9
|
+
errorUrl: string;
|
|
10
|
+
aliasRegistrationOnCodeEnabled: boolean;
|
|
11
|
+
modelOActivationEnabled: boolean;
|
|
12
|
+
}): TpayTransactionCreateInput;
|
|
13
|
+
/**
|
|
14
|
+
* The autopayment model actually sent to Tpay.
|
|
15
|
+
*
|
|
16
|
+
* Once the Model O activation switch is on, every recurring activation registers
|
|
17
|
+
* as model O: it is the only model that permits a variable amount without the
|
|
18
|
+
* payer confirming each charge, which is what a configurable subscription needs.
|
|
19
|
+
* Model M would silently degrade every renewal into an approval prompt.
|
|
20
|
+
*
|
|
21
|
+
* Single source of truth on purpose — the new-activation switch and the alias
|
|
22
|
+
* payload must never disagree about which model this activation attempt is.
|
|
23
|
+
*/
|
|
24
|
+
export declare function resolveRecurringModel(requested: "O" | "M" | undefined, modelOActivationEnabled: boolean): "O" | "M";
|
|
25
|
+
export declare function aliasValue(paymentIntentId: string): string;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
const TPAY_BLIK_GROUP_ID = 150;
|
|
2
|
+
/**
|
|
3
|
+
* In-shell account return path (account terminal, localized client-side). When the
|
|
4
|
+
* checkout declares `returnContext: "account"`, the Tpay redirect/return targets
|
|
5
|
+
* this in-account terminal instead of the public `/checkout/payment`
|
|
6
|
+
* page — the origin is preserved, only the path is swapped. Mirrors how the
|
|
7
|
+
* public path is selected server-side and re-localized on the client.
|
|
8
|
+
*/
|
|
9
|
+
const ACCOUNT_RETURN_PATH = "/account/order/status";
|
|
10
|
+
/**
|
|
11
|
+
* Swap the path of an origin-derived return URL to the in-account terminal while
|
|
12
|
+
* preserving the env/origin-driven host. `returnContext` defaults to public, so a
|
|
13
|
+
* caller that omits it gets the byte-identical public URL.
|
|
14
|
+
*/
|
|
15
|
+
function returnUrlForContext(baseUrl, returnContext) {
|
|
16
|
+
if (returnContext !== "account")
|
|
17
|
+
return baseUrl;
|
|
18
|
+
const url = new URL(baseUrl);
|
|
19
|
+
url.pathname = ACCOUNT_RETURN_PATH;
|
|
20
|
+
return url.toString();
|
|
21
|
+
}
|
|
22
|
+
export function buildCreateTransactionInput(input) {
|
|
23
|
+
const { transient } = input;
|
|
24
|
+
const effectiveModel = resolveRecurringModel(transient.recurringModel, input.modelOActivationEnabled);
|
|
25
|
+
const shouldRegisterOneTimeAlias = transient.flow === "blik_one_time" && input.aliasRegistrationOnCodeEnabled;
|
|
26
|
+
const returnContext = input.input.returnContext;
|
|
27
|
+
const successUrl = returnUrlForContext(input.successUrl, returnContext);
|
|
28
|
+
const errorUrl = returnUrlForContext(input.errorUrl, returnContext);
|
|
29
|
+
return {
|
|
30
|
+
amount: input.input.amountMinor / 100,
|
|
31
|
+
currency: "PLN",
|
|
32
|
+
description: input.input.orderRef,
|
|
33
|
+
hiddenDescription: input.input.paymentIntentId,
|
|
34
|
+
payer: input.payer,
|
|
35
|
+
notificationUrl: input.notificationUrl,
|
|
36
|
+
successUrl: payerReturnUrl(successUrl, input.input),
|
|
37
|
+
errorUrl: payerReturnUrl(errorUrl, input.input),
|
|
38
|
+
groupId: transient.flow === "pbl_one_time" ? undefined : TPAY_BLIK_GROUP_ID,
|
|
39
|
+
channelId: transient.flow === "pbl_one_time" ? transient.channelId : undefined,
|
|
40
|
+
blikPaymentType: transient.flow === "recurring_charge" ||
|
|
41
|
+
(transient.flow === "blik_one_click" && (input.input.paymentMethodAliasType ?? "PAYID") === "PAYID")
|
|
42
|
+
? 2
|
|
43
|
+
: undefined,
|
|
44
|
+
blikToken: transient.flow === "blik_one_time" || transient.flow === "blik_recurring_activation"
|
|
45
|
+
? transient.blikToken
|
|
46
|
+
: undefined,
|
|
47
|
+
// Only meaningful while registering a PAYID mandate: it makes BLIK refuse an
|
|
48
|
+
// incapable bank outright instead of taking the money and silently never
|
|
49
|
+
// showing the mandate invitation. Never set for a plain one-off BLIK.
|
|
50
|
+
refuseNoPayId: transient.flow === "blik_recurring_activation" && effectiveModel === "O"
|
|
51
|
+
? true
|
|
52
|
+
: undefined,
|
|
53
|
+
alias: transient.flow === "blik_recurring_activation"
|
|
54
|
+
? {
|
|
55
|
+
value: aliasValue(input.input.paymentIntentId),
|
|
56
|
+
type: "PAYID",
|
|
57
|
+
label: "openlup",
|
|
58
|
+
...(effectiveModel === "O" ? { recommendedAuthLevel: "NOCONFREQ" } : {}),
|
|
59
|
+
autopayment: { model: effectiveModel },
|
|
60
|
+
}
|
|
61
|
+
: shouldRegisterOneTimeAlias
|
|
62
|
+
? { value: aliasValue(input.input.paymentIntentId), type: "UID", label: "openlup" }
|
|
63
|
+
: (transient.flow === "recurring_charge" || transient.flow === "blik_one_click") && input.input.paymentMethodRef
|
|
64
|
+
? chargeAlias(input.input.paymentMethodRef, input.input.paymentMethodAliasType ?? "PAYID", input.input.paymentMethodRecurringModel)
|
|
65
|
+
: null,
|
|
66
|
+
simulatorContext: {
|
|
67
|
+
orderId: input.input.orderId,
|
|
68
|
+
paymentIntentId: input.input.paymentIntentId,
|
|
69
|
+
clientId: input.input.clientId,
|
|
70
|
+
providerAttemptKey: input.input.providerIdempotencyKey,
|
|
71
|
+
...(returnContext === "account" ? { returnPath: ACCOUNT_RETURN_PATH } : {}),
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Alias sent when charging an already-registered mandate.
|
|
77
|
+
*
|
|
78
|
+
* `noDelay` and `recommendedAuthLevel` are autopayment fields: they belong to a
|
|
79
|
+
* `PAYID` mandate under model O and are meaningless on a `UID` one-click alias,
|
|
80
|
+
* which carries no autopayment agreement at all.
|
|
81
|
+
*
|
|
82
|
+
* Keyed on the model the MANDATE was registered under, never on the switch that
|
|
83
|
+
* admits new Model O activations. Production may already hold model M mandates;
|
|
84
|
+
* charging those as model O would both contradict the payer's consent and risk
|
|
85
|
+
* rejection. Unknown or absent means not-model-O — fail closed, never guess.
|
|
86
|
+
*/
|
|
87
|
+
function chargeAlias(value, type, registeredModel) {
|
|
88
|
+
const autopaymentCharge = registeredModel === "O" && type === "PAYID";
|
|
89
|
+
return {
|
|
90
|
+
value,
|
|
91
|
+
type,
|
|
92
|
+
// Needed on every model O charge, not only at registration: without them
|
|
93
|
+
// Tpay emails the payer an approval link instead of collecting, which is
|
|
94
|
+
// model M behaviour. Verified against production 2026-07-20.
|
|
95
|
+
...(autopaymentCharge ? { noDelay: true, recommendedAuthLevel: "NOCONFREQ" } : {}),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* The autopayment model actually sent to Tpay.
|
|
100
|
+
*
|
|
101
|
+
* Once the Model O activation switch is on, every recurring activation registers
|
|
102
|
+
* as model O: it is the only model that permits a variable amount without the
|
|
103
|
+
* payer confirming each charge, which is what a configurable subscription needs.
|
|
104
|
+
* Model M would silently degrade every renewal into an approval prompt.
|
|
105
|
+
*
|
|
106
|
+
* Single source of truth on purpose — the new-activation switch and the alias
|
|
107
|
+
* payload must never disagree about which model this activation attempt is.
|
|
108
|
+
*/
|
|
109
|
+
export function resolveRecurringModel(requested, modelOActivationEnabled) {
|
|
110
|
+
if (modelOActivationEnabled)
|
|
111
|
+
return "O";
|
|
112
|
+
return requested ?? "M";
|
|
113
|
+
}
|
|
114
|
+
function payerReturnUrl(baseUrl, input) {
|
|
115
|
+
const url = new URL(baseUrl);
|
|
116
|
+
if (input.orderId)
|
|
117
|
+
url.searchParams.set("orderId", input.orderId);
|
|
118
|
+
url.searchParams.set("paymentIntentId", input.paymentIntentId);
|
|
119
|
+
if (input.clientId)
|
|
120
|
+
url.searchParams.set("clientId", input.clientId);
|
|
121
|
+
if (input.returnContext === "account" && input.subjectId)
|
|
122
|
+
url.searchParams.set("subject", input.subjectId);
|
|
123
|
+
url.searchParams.set("order", input.orderRef);
|
|
124
|
+
return url.toString();
|
|
125
|
+
}
|
|
126
|
+
export function aliasValue(paymentIntentId) {
|
|
127
|
+
return `openlup_${paymentIntentId}`;
|
|
128
|
+
}
|