@openlup/platform 0.20.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (210) hide show
  1. package/CHANGELOG.md +62 -231
  2. package/README.md +172 -6
  3. package/dist/composition/composeBundle.d.ts +6 -0
  4. package/dist/composition/composeBundle.js +2 -0
  5. package/dist/composition/index.js +1 -0
  6. package/dist/payments/adapterConformance.d.ts +8 -0
  7. package/dist/payments/adapterConformance.js +109 -0
  8. package/dist/payments/bindings.d.ts +3 -0
  9. package/dist/payments/bindings.js +17 -0
  10. package/dist/payments/checkoutActivePaymentActionResolver.d.ts +7 -0
  11. package/dist/payments/checkoutActivePaymentActionResolver.js +77 -0
  12. package/dist/payments/checkoutRecoveryPaymentResolver.d.ts +8 -0
  13. package/dist/payments/checkoutRecoveryPaymentResolver.js +87 -0
  14. package/dist/payments/contracts/adapter.d.ts +113 -0
  15. package/dist/payments/contracts/adapter.js +1 -0
  16. package/dist/payments/contracts/conformance.d.ts +155 -0
  17. package/dist/payments/contracts/conformance.js +1 -0
  18. package/dist/payments/contracts/evidence.d.ts +157 -0
  19. package/dist/payments/contracts/evidence.js +1 -0
  20. package/dist/payments/contracts/index.d.ts +10 -0
  21. package/dist/payments/contracts/index.js +1 -0
  22. package/dist/payments/contracts/preparedAttempt.d.ts +69 -0
  23. package/dist/payments/contracts/preparedAttempt.js +1 -0
  24. package/dist/payments/contracts/reconciliation.d.ts +303 -0
  25. package/dist/payments/contracts/reconciliation.js +1 -0
  26. package/dist/payments/contracts/repair.d.ts +92 -0
  27. package/dist/payments/contracts/repair.js +1 -0
  28. package/dist/payments/contracts/truth.d.ts +92 -0
  29. package/dist/payments/contracts/truth.js +1 -0
  30. package/dist/payments/eventConformanceRunner.d.ts +12 -0
  31. package/dist/payments/eventConformanceRunner.js +125 -0
  32. package/dist/payments/index.d.ts +16 -0
  33. package/dist/payments/index.js +16 -0
  34. package/dist/payments/paymentAttemptSilenceWindow.d.ts +10 -0
  35. package/dist/payments/paymentAttemptSilenceWindow.js +19 -0
  36. package/dist/payments/paymentMethodLifecycle.d.ts +11 -0
  37. package/dist/payments/paymentMethodLifecycle.js +37 -0
  38. package/dist/payments/paymentProviderReconciliationAbsenceReopen.d.ts +7 -0
  39. package/dist/payments/paymentProviderReconciliationAbsenceReopen.js +100 -0
  40. package/dist/payments/paymentProviderReconciliationEvidence.d.ts +36 -0
  41. package/dist/payments/paymentProviderReconciliationEvidence.js +91 -0
  42. package/dist/payments/paymentProviderReconciliationWorker.d.ts +4 -0
  43. package/dist/payments/paymentProviderReconciliationWorker.js +245 -0
  44. package/dist/payments/paymentTruth.d.ts +8 -0
  45. package/dist/payments/paymentTruth.js +125 -0
  46. package/dist/payments/paymentVerifyNowHandler.d.ts +27 -0
  47. package/dist/payments/paymentVerifyNowHandler.js +70 -0
  48. package/dist/payments/paymentVerifyNowService.d.ts +23 -0
  49. package/dist/payments/paymentVerifyNowService.js +93 -0
  50. package/dist/payments/paymentWebhookPostProcessing.d.ts +22 -0
  51. package/dist/payments/paymentWebhookPostProcessing.js +15 -0
  52. package/dist/payments/postgresPaymentTruth.d.ts +10 -0
  53. package/dist/payments/postgresPaymentTruth.js +126 -0
  54. package/dist/payments/preparedAttempt.d.ts +6 -0
  55. package/dist/payments/preparedAttempt.js +70 -0
  56. package/dist/payments/preparedAttemptFinalization.d.ts +5 -0
  57. package/dist/payments/preparedAttemptFinalization.js +12 -0
  58. package/dist/payments/schemaCapabilities.d.ts +10 -0
  59. package/dist/payments/schemaCapabilities.js +10 -0
  60. package/dist/payments/sql.d.ts +20 -0
  61. package/dist/payments/sql.js +6 -0
  62. package/dist/payments/stripe/classifyDecline.d.ts +46 -0
  63. package/dist/payments/stripe/classifyDecline.js +58 -0
  64. package/dist/payments/stripe/declineFailureHints.d.ts +46 -0
  65. package/dist/payments/stripe/declineFailureHints.js +87 -0
  66. package/dist/payments/stripe/donorContracts.d.ts +120 -0
  67. package/dist/payments/stripe/donorContracts.js +1 -0
  68. package/dist/payments/stripe/executionDecline.d.ts +53 -0
  69. package/dist/payments/stripe/executionDecline.js +107 -0
  70. package/dist/payments/stripe/index.d.ts +16 -0
  71. package/dist/payments/stripe/index.js +239 -0
  72. package/dist/payments/stripe/intentMapper.d.ts +26 -0
  73. package/dist/payments/stripe/intentMapper.js +51 -0
  74. package/dist/payments/stripe/methodFacts.d.ts +10 -0
  75. package/dist/payments/stripe/methodFacts.js +12 -0
  76. package/dist/payments/stripe/paymentFailureEvidence.d.ts +26 -0
  77. package/dist/payments/stripe/paymentFailureEvidence.js +33 -0
  78. package/dist/payments/stripe/stripeFailureEvidence.d.ts +41 -0
  79. package/dist/payments/stripe/stripeFailureEvidence.js +82 -0
  80. package/dist/payments/stripe/stripeIntentConfirmationEvidence.d.ts +28 -0
  81. package/dist/payments/stripe/stripeIntentConfirmationEvidence.js +32 -0
  82. package/dist/payments/stripe/stripePaymentReconciliationProvider.d.ts +35 -0
  83. package/dist/payments/stripe/stripePaymentReconciliationProvider.js +222 -0
  84. package/dist/payments/stripe/stripeRefundEvidence.d.ts +8 -0
  85. package/dist/payments/stripe/stripeRefundEvidence.js +17 -0
  86. package/dist/payments/stripe/stripeSandboxPaymentExecutionAdapter.d.ts +45 -0
  87. package/dist/payments/stripe/stripeSandboxPaymentExecutionAdapter.js +220 -0
  88. package/dist/payments/stripe/stripeWebhookNormalizer.d.ts +14 -0
  89. package/dist/payments/stripe/stripeWebhookNormalizer.js +243 -0
  90. package/dist/payments/stripe/webhookSignature.d.ts +14 -0
  91. package/dist/payments/stripe/webhookSignature.js +43 -0
  92. package/dist/payments/tpay/blikRecurringMapper.d.ts +37 -0
  93. package/dist/payments/tpay/blikRecurringMapper.js +24 -0
  94. package/dist/payments/tpay/capabilities.d.ts +3 -0
  95. package/dist/payments/tpay/capabilities.js +18 -0
  96. package/dist/payments/tpay/classifyDecline.d.ts +46 -0
  97. package/dist/payments/tpay/classifyDecline.js +58 -0
  98. package/dist/payments/tpay/declineFailureHints.d.ts +72 -0
  99. package/dist/payments/tpay/declineFailureHints.js +37 -0
  100. package/dist/payments/tpay/donorContracts.d.ts +140 -0
  101. package/dist/payments/tpay/donorContracts.js +1 -0
  102. package/dist/payments/tpay/index.d.ts +43 -0
  103. package/dist/payments/tpay/index.js +264 -0
  104. package/dist/payments/tpay/paymentFailureEvidence.d.ts +26 -0
  105. package/dist/payments/tpay/paymentFailureEvidence.js +33 -0
  106. package/dist/payments/tpay/preparedProviderAttempt.d.ts +22 -0
  107. package/dist/payments/tpay/preparedProviderAttempt.js +24 -0
  108. package/dist/payments/tpay/providerAttemptFailureDiagnostic.d.ts +3 -0
  109. package/dist/payments/tpay/providerAttemptFailureDiagnostic.js +95 -0
  110. package/dist/payments/tpay/tpayCreateTransactionInput.d.ts +25 -0
  111. package/dist/payments/tpay/tpayCreateTransactionInput.js +128 -0
  112. package/dist/payments/tpay/tpayDeclineEvidence.d.ts +126 -0
  113. package/dist/payments/tpay/tpayDeclineEvidence.js +177 -0
  114. package/dist/payments/tpay/tpayDispatchFailure.d.ts +60 -0
  115. package/dist/payments/tpay/tpayDispatchFailure.js +121 -0
  116. package/dist/payments/tpay/tpayFailureEvidence.d.ts +20 -0
  117. package/dist/payments/tpay/tpayFailureEvidence.js +61 -0
  118. package/dist/payments/tpay/tpayHttpClient.d.ts +52 -0
  119. package/dist/payments/tpay/tpayHttpClient.js +185 -0
  120. package/dist/payments/tpay/tpayHttpFailure.d.ts +57 -0
  121. package/dist/payments/tpay/tpayHttpFailure.js +143 -0
  122. package/dist/payments/tpay/tpayPaymentExecutionAdapter.d.ts +20 -0
  123. package/dist/payments/tpay/tpayPaymentExecutionAdapter.js +249 -0
  124. package/dist/payments/tpay/tpayPaymentReconciliationProvider.d.ts +5 -0
  125. package/dist/payments/tpay/tpayPaymentReconciliationProvider.js +244 -0
  126. package/dist/payments/tpay/tpayTransactionPayload.d.ts +120 -0
  127. package/dist/payments/tpay/tpayTransactionPayload.js +96 -0
  128. package/dist/payments/tpay/wallClockTime.d.ts +31 -0
  129. package/dist/payments/tpay/wallClockTime.js +57 -0
  130. package/dist/payments/tpay/webhookJwsVerifier.d.ts +17 -0
  131. package/dist/payments/tpay/webhookJwsVerifier.js +51 -0
  132. package/dist/payments/tpay/webhookNormalizer.d.ts +54 -0
  133. package/dist/payments/tpay/webhookNormalizer.js +200 -0
  134. package/dist/payments/verifiedEvidenceProjection.d.ts +12 -0
  135. package/dist/payments/verifiedEvidenceProjection.js +166 -0
  136. package/dist/payments/verifiedIngress.d.ts +119 -0
  137. package/dist/payments/verifiedIngress.js +220 -0
  138. package/package.json +22 -2
  139. package/release-gates.json +64 -3
  140. package/sql/20260810180000_payment_settlement_rail.sql +574 -0
  141. package/sql/20260815150000_payment_truth_reconciliation.sql +479 -0
  142. package/sql/20260826100000_late_payment_shifts_the_subscription_cycle.sql +215 -0
  143. package/src/composition/composeBundle.ts +9 -0
  144. package/src/composition/index.ts +1 -0
  145. package/src/payments/adapterConformance.ts +118 -0
  146. package/src/payments/bindings.ts +17 -0
  147. package/src/payments/checkoutActivePaymentActionResolver.ts +97 -0
  148. package/src/payments/checkoutRecoveryPaymentResolver.ts +108 -0
  149. package/src/payments/contracts/adapter.ts +96 -0
  150. package/src/payments/contracts/conformance.ts +171 -0
  151. package/src/payments/contracts/evidence.ts +142 -0
  152. package/src/payments/contracts/index.ts +54 -0
  153. package/src/payments/contracts/preparedAttempt.ts +86 -0
  154. package/src/payments/contracts/reconciliation.ts +296 -0
  155. package/src/payments/contracts/repair.ts +95 -0
  156. package/src/payments/contracts/truth.ts +108 -0
  157. package/src/payments/eventConformanceRunner.ts +149 -0
  158. package/src/payments/index.ts +21 -0
  159. package/src/payments/paymentAttemptSilenceWindow.ts +26 -0
  160. package/src/payments/paymentMethodLifecycle.ts +50 -0
  161. package/src/payments/paymentProviderReconciliationAbsenceReopen.ts +130 -0
  162. package/src/payments/paymentProviderReconciliationEvidence.ts +117 -0
  163. package/src/payments/paymentProviderReconciliationWorker.ts +291 -0
  164. package/src/payments/paymentTruth.ts +131 -0
  165. package/src/payments/paymentVerifyNowHandler.ts +94 -0
  166. package/src/payments/paymentVerifyNowService.ts +125 -0
  167. package/src/payments/paymentWebhookPostProcessing.ts +36 -0
  168. package/src/payments/postgresPaymentTruth.ts +149 -0
  169. package/src/payments/preparedAttempt.ts +90 -0
  170. package/src/payments/preparedAttemptFinalization.ts +13 -0
  171. package/src/payments/schemaCapabilities.ts +10 -0
  172. package/src/payments/sql.ts +6 -0
  173. package/src/payments/stripe/classifyDecline.ts +62 -0
  174. package/src/payments/stripe/declineFailureHints.ts +89 -0
  175. package/src/payments/stripe/donorContracts.ts +87 -0
  176. package/src/payments/stripe/executionDecline.ts +126 -0
  177. package/src/payments/stripe/index.ts +235 -0
  178. package/src/payments/stripe/intentMapper.ts +58 -0
  179. package/src/payments/stripe/methodFacts.ts +21 -0
  180. package/src/payments/stripe/paymentFailureEvidence.ts +58 -0
  181. package/src/payments/stripe/stripeFailureEvidence.ts +115 -0
  182. package/src/payments/stripe/stripeIntentConfirmationEvidence.ts +34 -0
  183. package/src/payments/stripe/stripePaymentReconciliationProvider.ts +263 -0
  184. package/src/payments/stripe/stripeRefundEvidence.ts +19 -0
  185. package/src/payments/stripe/stripeSandboxPaymentExecutionAdapter.ts +283 -0
  186. package/src/payments/stripe/stripeWebhookNormalizer.ts +277 -0
  187. package/src/payments/stripe/webhookSignature.ts +55 -0
  188. package/src/payments/tpay/blikRecurringMapper.ts +42 -0
  189. package/src/payments/tpay/capabilities.ts +21 -0
  190. package/src/payments/tpay/classifyDecline.ts +62 -0
  191. package/src/payments/tpay/declineFailureHints.ts +90 -0
  192. package/src/payments/tpay/donorContracts.ts +104 -0
  193. package/src/payments/tpay/index.ts +257 -0
  194. package/src/payments/tpay/paymentFailureEvidence.ts +58 -0
  195. package/src/payments/tpay/preparedProviderAttempt.ts +38 -0
  196. package/src/payments/tpay/providerAttemptFailureDiagnostic.ts +99 -0
  197. package/src/payments/tpay/tpayCreateTransactionInput.ts +158 -0
  198. package/src/payments/tpay/tpayDeclineEvidence.ts +233 -0
  199. package/src/payments/tpay/tpayDispatchFailure.ts +186 -0
  200. package/src/payments/tpay/tpayFailureEvidence.ts +75 -0
  201. package/src/payments/tpay/tpayHttpClient.ts +290 -0
  202. package/src/payments/tpay/tpayHttpFailure.ts +186 -0
  203. package/src/payments/tpay/tpayPaymentExecutionAdapter.ts +297 -0
  204. package/src/payments/tpay/tpayPaymentReconciliationProvider.ts +285 -0
  205. package/src/payments/tpay/tpayTransactionPayload.ts +207 -0
  206. package/src/payments/tpay/wallClockTime.ts +57 -0
  207. package/src/payments/tpay/webhookJwsVerifier.ts +82 -0
  208. package/src/payments/tpay/webhookNormalizer.ts +279 -0
  209. package/src/payments/verifiedEvidenceProjection.ts +179 -0
  210. package/src/payments/verifiedIngress.ts +317 -0
@@ -0,0 +1,297 @@
1
+ import { failureEvidencePayload } from "./paymentFailureEvidence.js";
2
+ import { tpayFailureEvidence } from "./tpayFailureEvidence.js";
3
+ import type { PaymentExecutionPort } from "./donorContracts.js";
4
+ import type {
5
+ PaymentExecutionInput,
6
+ PaymentExecutionResult,
7
+ TpayTransientProviderInput,
8
+ } from "./donorContracts.js";
9
+ import {
10
+ aliasValue,
11
+ buildCreateTransactionInput,
12
+ resolveRecurringModel,
13
+ } from "./tpayCreateTransactionInput.js";
14
+ import { declineCodeReading } from "./declineFailureHints.js";
15
+ import {
16
+ assertableHints,
17
+ isMandateRefusal,
18
+ mandateCapabilityReadable,
19
+ readAttemptDeclineObservation,
20
+ declineEvidenceSource,
21
+ states,
22
+ } from "./tpayDeclineEvidence.js";
23
+ import type {
24
+ TpayHttpClient,
25
+ TpayPayerInput,
26
+ TpayTransactionCreateInput,
27
+ } from "./tpayHttpClient.js";
28
+ import {
29
+ tpayTrustedPreDispatchFailure,
30
+ tpayTrustedRefusalFailure,
31
+ } from "./tpayDispatchFailure.js";
32
+ import { ProviderAttemptPreDispatchError } from "./preparedProviderAttempt.js";
33
+
34
+ /** The one place this adapter spells its own provider identity. */
35
+ export const PROVIDER_KIND = "tpay" as const;
36
+
37
+ export interface TpayPaymentExecutionAdapterOptions {
38
+ client: TpayHttpClient;
39
+ notificationUrl: string;
40
+ successUrl: string;
41
+ errorUrl: string;
42
+ oneClickEnabled?: boolean;
43
+ aliasRegistrationOnCodeEnabled?: boolean;
44
+ /** One public switch for admitting new subscription BLIK Model O mandates. */
45
+ modelOActivationEnabled?: boolean;
46
+ resolvePayer?: (input: PaymentExecutionInput) => TpayPayerInput | null;
47
+ }
48
+
49
+ export function createTpayPaymentExecutionAdapter({
50
+ client,
51
+ notificationUrl,
52
+ successUrl,
53
+ errorUrl,
54
+ oneClickEnabled = false,
55
+ aliasRegistrationOnCodeEnabled = false,
56
+ modelOActivationEnabled = false,
57
+ resolvePayer = defaultPayer,
58
+ }: TpayPaymentExecutionAdapterOptions): PaymentExecutionPort {
59
+ const validateInput = (input: PaymentExecutionInput) => {
60
+ const transient = requireTpayTransientInput(input, {
61
+ oneClickEnabled,
62
+ modelOActivationEnabled,
63
+ });
64
+ if (input.currency !== "PLN") throw new TpayExecutionInputError("tpay_currency_not_supported");
65
+ const payer = resolvePayer(input);
66
+ if (!payer) throw new TpayExecutionInputError("tpay_payer_missing");
67
+ return { transient, payer };
68
+ };
69
+
70
+ return {
71
+ validateInput(input): void {
72
+ validateInput(input);
73
+ },
74
+ async execute(input): Promise<PaymentExecutionResult> {
75
+ const { transient, payer } = validateInput(input);
76
+
77
+ const request = buildCreateTransactionInput({
78
+ input,
79
+ transient,
80
+ payer,
81
+ notificationUrl,
82
+ successUrl,
83
+ errorUrl,
84
+ aliasRegistrationOnCodeEnabled,
85
+ modelOActivationEnabled,
86
+ });
87
+ let response;
88
+ try {
89
+ response = await client.createTransaction(request);
90
+ } catch (error) {
91
+ // Two independent proofs Tpay holds no transaction: never asked, or it refused the request
92
+ // document. Both release the attempt; everything else stays ambiguous and rethrows.
93
+ const noCharge = tpayTrustedPreDispatchFailure(error) ?? tpayTrustedRefusalFailure(error);
94
+ if (noCharge) throw new ProviderAttemptPreDispatchError(noCharge);
95
+ throw error;
96
+ }
97
+ const providerAttemptId = response.title || response.transactionId;
98
+ // This provider reports a synchronous decline ONLY here. `result`, `status`
99
+ // and `payments.status` all still read "success"/"pending" on a rejected
100
+ // transaction, so they cannot be used to detect one.
101
+ const rejections = response.errors ?? [];
102
+ const rejection = rejections[0] ?? null;
103
+ // The provider has carried a hard refusal code since the first minute, but
104
+ // only in the transaction's attempt log — never in the create response,
105
+ // whose codes are generic. So ask for it the moment the provider says no,
106
+ // instead of learning it hours later from the reconciliation poll.
107
+ // Fail-open: `null` means "read nothing", and every branch below then
108
+ // behaves exactly as it did before this read existed.
109
+ const observation = rejection
110
+ ? await readAttemptDeclineObservation(client, response.transactionId)
111
+ : null;
112
+ const attemptCode = observation?.code ?? null;
113
+ // A code the table cannot read decides nothing. Promoting a known-unknown
114
+ // such as `"100"` to a verdict would silence the message fallback for the
115
+ // one code we openly admit we cannot read — a regression, not a fix.
116
+ const attemptReading = attemptCode === null ? null : declineCodeReading(attemptCode);
117
+ const decidingAttemptReading = attemptReading !== null && states(attemptReading)
118
+ ? attemptReading
119
+ : null;
120
+ const mandateUnsupported = decidingAttemptReading
121
+ ? mandateCapabilityReadable(transient.flow)
122
+ && response.payIdEligible !== true
123
+ && (decidingAttemptReading.hints?.includes("mandateUnsupported") ?? false)
124
+ : rejection !== null && rejections.some((error) =>
125
+ isMandateRefusal(transient.flow, response.payIdEligible, error.errorMessage)
126
+ );
127
+ // Read here, where the code vocabulary is owned, so the control plane
128
+ // classifies the refusal without ever seeing a provider-native code. The
129
+ // attempt log wins when it says something, because it is the carrier the
130
+ // provider itself writes the refusal into; the create response's own code
131
+ // is the fallback reading.
132
+ // The advice half is suppressed when this adapter is already asserting a
133
+ // mandate refusal: advice outranks hints by design in the taxonomy, and
134
+ // the identity code is only the FIRST error, so a generic terminal code
135
+ // arriving ahead of the mandate explanation would otherwise mask the one
136
+ // reading that says the stored reference — not the instrument — is dead.
137
+ const reading = decidingAttemptReading ?? declineCodeReading(rejection?.errorCode);
138
+ const declineEvidence = rejection ? declineEvidenceSource({
139
+ attemptCode, decidingAttemptReading, mandateUnsupported, reading, errorCode: rejection.errorCode,
140
+ }) : null;
141
+ // A capability code is read as mandate evidence only while registering a
142
+ // mandate. The same opaque code on one-time or stored-mandate charges must
143
+ // not claim that a mandate is dead. The rule itself lives beside the
144
+ // refusal evidence it qualifies, because the reconciliation rail funnels
145
+ // its hints through the same one — a refusal must not classify by which
146
+ // rail saw it.
147
+ const neutralReasonHints = assertableHints(reading.hints, transient.flow);
148
+ const providerDecline = rejection
149
+ ? {
150
+ code: rejection.errorCode,
151
+ // Preserve the first provider code as the stable decline identity, but
152
+ // inspect every error for the capability signal. The provider may put a
153
+ // generic payment failure first and the mandate explanation later.
154
+ mandateUnsupported,
155
+ ...(reading.adviceCode && !mandateUnsupported ? { adviceCode: reading.adviceCode } : {}),
156
+ ...(neutralReasonHints?.length ? { neutralReasonHints } : {}),
157
+ }
158
+ : null;
159
+
160
+ return {
161
+ provider: PROVIDER_KIND,
162
+ providerAttemptId,
163
+ providerSessionId: response.transactionId,
164
+ // Stays non-terminal even on a decline: the control plane owns terminal
165
+ // transitions and reads `providerDecline` below to make one.
166
+ attemptStatus: "processing",
167
+ providerDecline,
168
+ nextActionKind: !providerDecline && transient.flow === "pbl_one_time" && response.transactionPaymentUrl
169
+ ? "redirect"
170
+ : null,
171
+ redirectUrl: providerDecline ? null : response.transactionPaymentUrl,
172
+ // A rejected activation must not publish a payment method reference.
173
+ paymentMethodRef: transient.flow === "blik_recurring_activation" && !providerDecline
174
+ ? aliasValue(input.paymentIntentId)
175
+ : null,
176
+ // A declined transaction produces no webhook at all: Tpay's `tr_status`
177
+ // only ever carries `true` or `chargeback`. Claiming otherwise would
178
+ // strand the attempt waiting for a callback that never arrives.
179
+ webhookExpected: !providerDecline,
180
+ recoveryRequired: false,
181
+ requestPayload: {
182
+ providerIdempotencyKey: input.providerIdempotencyKey,
183
+ providerRequestFingerprint: input.providerRequestFingerprint,
184
+ providerFlow: transient.flow,
185
+ amountMinor: input.amountMinor,
186
+ currency: input.currency,
187
+ orderRef: input.orderRef,
188
+ channelId: transient.flow === "pbl_one_time" ? transient.channelId : undefined,
189
+ // The model actually sent, not the one the client asked for — the single
190
+ // activation switch can make those diverge, and this field is where a
191
+ // diagnosis looks for the truth.
192
+ recurringModel: transient.flow === "blik_recurring_activation"
193
+ ? resolveRecurringModel(transient.recurringModel, modelOActivationEnabled)
194
+ : undefined,
195
+ methodRefPresent: transient.flow === "recurring_charge" || transient.flow === "blik_one_click"
196
+ ? Boolean(input.paymentMethodRef)
197
+ : undefined,
198
+ methodRefAliasType: transient.flow === "recurring_charge" || transient.flow === "blik_one_click"
199
+ ? input.paymentMethodAliasType ?? "PAYID"
200
+ : undefined,
201
+ aliasRegistrationRequested: transient.flow === "blik_one_time"
202
+ ? aliasRegistrationOnCodeEnabled
203
+ : undefined,
204
+ },
205
+ responsePayload: {
206
+ providerStatus: response.status,
207
+ providerAttemptId,
208
+ providerSessionId: response.transactionId,
209
+ requestId: response.requestId,
210
+ redirectUrlPresent: Boolean(response.transactionPaymentUrl),
211
+ payIdEligible: response.payIdEligible,
212
+ // Codes only. `errorMessage` is free-form prose that can carry
213
+ // payer-identifying text, so it never reaches our stored payload.
214
+ providerErrorCodes: (response.errors ?? []).map((error) => error.errorCode),
215
+ ...(declineEvidence ? { declineEvidence } : {}),
216
+ ...failureEvidencePayload(observation ? tpayFailureEvidence({
217
+ source: "execution", providerPaymentId: response.transactionId, refusalVerified: true,
218
+ flow: transient.flow, observation, errorCode: rejection?.errorCode, reading,
219
+ }) : null),
220
+ },
221
+ };
222
+ },
223
+ };
224
+ }
225
+
226
+ function requireTpayTransientInput(
227
+ input: PaymentExecutionInput,
228
+ options: { oneClickEnabled: boolean; modelOActivationEnabled: boolean },
229
+ ): TpayTransientProviderInput {
230
+ if (input.providerFlow === "blik_one_click") {
231
+ if (!options.oneClickEnabled) throw new TpayExecutionInputError("tpay_blik_one_click_not_enabled");
232
+ if (input.mode !== "one_time") throw new TpayExecutionInputError("tpay_blik_one_click_requires_one_time");
233
+ if (!input.paymentMethodRef) throw new TpayExecutionInputError("tpay_blik_one_click_payid_missing");
234
+ if (input.transientProviderInput && input.transientProviderInput.flow !== "blik_one_click") {
235
+ throw new TpayExecutionInputError("tpay_provider_flow_mismatch");
236
+ }
237
+ return { provider: PROVIDER_KIND, flow: "blik_one_click" };
238
+ }
239
+
240
+ if (input.providerFlow === "recurring_charge") {
241
+ if (input.mode !== "subscription_cycle") throw new TpayExecutionInputError("tpay_recurring_charge_requires_cycle");
242
+ if (!input.paymentMethodRef) throw new TpayExecutionInputError("tpay_recurring_charge_payid_missing");
243
+ if (input.paymentMethodAliasType && input.paymentMethodAliasType !== "PAYID") {
244
+ throw new TpayExecutionInputError("tpay_recurring_charge_requires_payid");
245
+ }
246
+ if (input.paymentMethodRecurringModel !== "O") {
247
+ throw new TpayExecutionInputError("tpay_recurring_charge_requires_model_o");
248
+ }
249
+ if (input.transientProviderInput && input.transientProviderInput.flow !== "recurring_charge") {
250
+ throw new TpayExecutionInputError("tpay_provider_flow_mismatch");
251
+ }
252
+ return { provider: PROVIDER_KIND, flow: "recurring_charge" };
253
+ }
254
+
255
+ const transient = input.transientProviderInput;
256
+ if (!transient || transient.provider !== PROVIDER_KIND) throw new TpayExecutionInputError("tpay_transient_input_missing");
257
+ if (input.providerFlow && input.providerFlow !== transient.flow) {
258
+ throw new TpayExecutionInputError("tpay_provider_flow_mismatch");
259
+ }
260
+ if (transient.flow === "blik_one_time" || transient.flow === "blik_recurring_activation") {
261
+ if (!transient.blikToken) throw new TpayExecutionInputError("tpay_blik_token_missing");
262
+ }
263
+ if (transient.flow === "pbl_one_time" && !transient.channelId) {
264
+ throw new TpayExecutionInputError("tpay_channel_id_missing");
265
+ }
266
+ if (transient.flow === "pbl_one_time" && input.mode !== "one_time") {
267
+ throw new TpayExecutionInputError("tpay_pbl_recurring_not_supported");
268
+ }
269
+ if (transient.flow === "blik_recurring_activation") {
270
+ if (!options.modelOActivationEnabled) {
271
+ throw new TpayExecutionInputError("tpay_blik_recurring_activation_not_enabled");
272
+ }
273
+ if (transient.recurringModel !== "O") {
274
+ throw new TpayExecutionInputError("tpay_blik_model_o_required");
275
+ }
276
+ if (input.mode !== "subscription_cycle") {
277
+ throw new TpayExecutionInputError("tpay_blik_recurring_activation_requires_subscription_cycle");
278
+ }
279
+ if (input.saveForFutureUse !== true) {
280
+ throw new TpayExecutionInputError("tpay_blik_recurring_activation_requires_save_for_future_use");
281
+ }
282
+ }
283
+ return transient;
284
+ }
285
+
286
+ function defaultPayer(input: PaymentExecutionInput): TpayPayerInput | null {
287
+ if (input.payer) return input.payer;
288
+ if (!input.customerRef) return null;
289
+ return { email: input.customerRef, name: "Customer" };
290
+ }
291
+
292
+ export class TpayExecutionInputError extends Error {
293
+ constructor(message: string) {
294
+ super(message);
295
+ this.name = "TpayExecutionInputError";
296
+ }
297
+ }
@@ -0,0 +1,285 @@
1
+ import type {
2
+ ProviderReconciliationStatus,
3
+ } from "./donorContracts.js";
4
+ import type {
5
+ PaymentProviderRecoveryProvider,
6
+ ProviderRecoveryAction,
7
+ } from "./donorContracts.js";
8
+ import type { TpayHttpClient } from "./tpayHttpClient.js";
9
+ import { refusalCapture, tpayFailureEvidence } from "./tpayFailureEvidence.js";
10
+ import { failureEvidencePayload } from "./paymentFailureEvidence.js";
11
+ import { parseInstant, wallClockToUtc } from "./wallClockTime.js";
12
+ import { declineCodeReading, type DeclineCodeReading } from "./declineFailureHints.js";
13
+ import { attemptDeclineObservation, lastRefusalRow } from "./tpayDeclineEvidence.js";
14
+
15
+ export function createTpayPaymentReconciliationProvider(
16
+ client: Pick<TpayHttpClient, "getTransaction">,
17
+ ): PaymentProviderRecoveryProvider {
18
+ return {
19
+ resolvePaymentReference(input) {
20
+ // Tpay's API address is its transactionId. `title`/TR-* is merchant
21
+ // display data, not a readback identifier.
22
+ return input.providerSessionId;
23
+ },
24
+ async readRecoveryPayment(input) {
25
+ const transaction = await client.getTransaction(input.providerPaymentId);
26
+ const status = normalizeTpayTransaction(transaction);
27
+ const identityMatches = tpayIdentityMatches(transaction, input.providerPaymentId);
28
+ const configuredMoneyMatches = tpayConfiguredMoneyMatches(transaction, input.attempt);
29
+ const manualReviewRequired = tpayMoneyMoved(transaction);
30
+ let clientAction: ProviderRecoveryAction | null = null;
31
+ if (identityMatches && !manualReviewRequired && status.status === "pending") {
32
+ const url = validatedTpayPaymentUrl(transaction);
33
+ clientAction = url ? { kind: "redirect", url } : null;
34
+ }
35
+ return { status, identityMatches, configuredMoneyMatches, manualReviewRequired, clientAction };
36
+ },
37
+ async readPayment(input) {
38
+ const transaction = await client.getTransaction(input.providerPaymentId);
39
+ return normalizeTpayTransaction(transaction);
40
+ },
41
+ };
42
+ }
43
+
44
+ function tpayMoneyMoved(transaction: Record<string, unknown>): boolean {
45
+ const status = readStatus(transaction).trim().toLowerCase();
46
+ return status === "chargeback" || status === "refunded";
47
+ }
48
+
49
+ function tpayIdentityMatches(transaction: Record<string, unknown>, providerPaymentId: string): boolean {
50
+ const actual = readOptionalString(transaction, "transactionId") ?? readOptionalString(transaction, "id");
51
+ return actual === providerPaymentId;
52
+ }
53
+
54
+ function tpayConfiguredMoneyMatches(
55
+ transaction: Record<string, unknown>,
56
+ attempt: { amountMinor: number; currency: string },
57
+ ): boolean {
58
+ return readConfiguredAmountMinor(transaction) === attempt.amountMinor
59
+ && readCurrency(transaction)?.toUpperCase() === attempt.currency.toUpperCase();
60
+ }
61
+
62
+ const TPAY_PAYMENT_HOSTS = new Set(["secure.tpay.com", "secure.sandbox.tpay.com"]);
63
+
64
+ function validatedTpayPaymentUrl(transaction: Record<string, unknown>): string | null {
65
+ const raw = transaction.transactionPaymentUrl;
66
+ if (typeof raw !== "string" || raw.length === 0) return null;
67
+ try {
68
+ const parsed = new URL(raw);
69
+ return parsed.protocol === "https:"
70
+ && parsed.port === ""
71
+ && parsed.username === ""
72
+ && parsed.password === ""
73
+ && TPAY_PAYMENT_HOSTS.has(parsed.hostname.toLowerCase())
74
+ ? parsed.toString()
75
+ : null;
76
+ } catch {
77
+ return null;
78
+ }
79
+ }
80
+
81
+ export function normalizeTpayTransaction(
82
+ transaction: Record<string, unknown>,
83
+ ): ProviderReconciliationStatus {
84
+ const providerStatus = readStatus(transaction);
85
+ const statusFromField = normalizeTpayStatus(providerStatus);
86
+ // This rail leaves its status field non-terminal when the bank refuses, reporting
87
+ // the reason beside it in TWO carriers instead — per attempt, and in an errors
88
+ // array — so reading only the status field left a refused charge polling forever
89
+ // (what stranded a renewal on 2026-08-12). Attempts win when both carry a code:
90
+ // being the dated carrier, they are the only one that can say which is current.
91
+ const payments = record(transaction.payments);
92
+ const refusal = statusFromField !== "succeeded"
93
+ ? lastRefusal(payments.attempts, "paymentErrorCode") ?? lastRefusal(payments.errors, "errorCode")
94
+ : null;
95
+ const status = refusal ? "failed" as const : statusFromField;
96
+ return {
97
+ status,
98
+ providerStatus,
99
+ occurredAt: refusal?.occurredAt ?? readDate(transaction),
100
+ failureReason: refusal
101
+ ? `tpay_decline_${refusal.code}`
102
+ : status === "failed" ? `tpay_${providerStatus}` : null,
103
+ amountMinor: readAmountMinor(transaction),
104
+ currency: readCurrency(transaction),
105
+ rawPayload: sanitizedTpayTransaction(transaction, providerStatus, refusal),
106
+ };
107
+ }
108
+
109
+ interface AttemptRefusal {
110
+ code: string;
111
+ occurredAt: string | null;
112
+ reading: DeclineCodeReading;
113
+ }
114
+
115
+ /**
116
+ * The last row carrying a refusal code, dated and read. The scan is shared with
117
+ * the execution path, which needs the same "which code does this stand on"
118
+ * answer the instant the provider refuses; only the date and the reading are
119
+ * added. Undated rows yield no instant, so the caller uses the charge's own date.
120
+ */
121
+ function lastRefusal(rows: unknown, codeKey: string): AttemptRefusal | null {
122
+ const refusal = lastRefusalRow(rows, codeKey);
123
+ if (!refusal) return null;
124
+ return {
125
+ code: refusal.code,
126
+ occurredAt: parseAttemptDate(refusal.row.date),
127
+ reading: declineCodeReading(refusal.code),
128
+ };
129
+ }
130
+
131
+ /**
132
+ * This rail labels no timestamp with a zone, on either carrier, and both are its
133
+ * own local wall clock. Reading them as UTC moved every instant this adapter
134
+ * produced one or two hours into the future: on 2026-08-15 a decline that landed
135
+ * 14:46Z was recorded as 16:46Z, later than the cron run that wrote it. The zone
136
+ * belongs to the rail, not to the merchant, so it is named here — beside the
137
+ * carriers it explains — and passed to the generic conversion.
138
+ */
139
+ const REPORTING_TIME_ZONE = "Europe/Warsaw";
140
+
141
+ /** Attempt timestamps arrive as `DD.MM.YYYY HH:mm`, which `Date.parse` misreads. */
142
+ function parseAttemptDate(value: unknown): string | null {
143
+ if (typeof value !== "string") return null;
144
+ const match = /^(\d{2})\.(\d{2})\.(\d{4})[ T](\d{2}):(\d{2})/.exec(value.trim());
145
+ if (!match) return parseDate(value);
146
+ const [, day, month, year, hour, minute] = match;
147
+ return wallClockToUtc(`${year}-${month}-${day}T${hour}:${minute}:00`, REPORTING_TIME_ZONE);
148
+ }
149
+
150
+ function normalizeTpayStatus(status: string): ProviderReconciliationStatus["status"] {
151
+ const normalized = status.trim().toLowerCase();
152
+ if (["correct", "paid", "true", "success", "settled"].includes(normalized)) return "succeeded";
153
+ if ([
154
+ "failed",
155
+ "failure",
156
+ "error",
157
+ "false",
158
+ "declined",
159
+ "rejected",
160
+ "cancelled",
161
+ "canceled",
162
+ "expired",
163
+ ].includes(normalized)) {
164
+ return "failed";
165
+ }
166
+ if ([
167
+ "new",
168
+ "pending",
169
+ "created",
170
+ "processing",
171
+ "in_progress",
172
+ "authorized",
173
+ "requires_action",
174
+ "pending_confirmation",
175
+ ].includes(normalized)) {
176
+ return "pending";
177
+ }
178
+ return "unknown";
179
+ }
180
+
181
+ function readStatus(transaction: Record<string, unknown>): string {
182
+ for (const key of ["status", "tr_status", "transactionStatus", "state"]) {
183
+ const raw = transaction[key];
184
+ if (typeof raw === "string" && raw.trim()) return raw.trim();
185
+ if (typeof raw === "boolean") return raw ? "true" : "false";
186
+ }
187
+ return "unknown";
188
+ }
189
+
190
+ function readDate(transaction: Record<string, unknown>): string | null {
191
+ const nestedDate = record(transaction.date);
192
+ for (const key of ["realization", "creation"]) {
193
+ const parsed = parseDate(nestedDate[key]);
194
+ if (parsed) return parsed;
195
+ }
196
+ for (const key of ["date", "updatedAt", "updated_at", "createdAt", "created_at"]) {
197
+ const parsed = parseDate(transaction[key]);
198
+ if (parsed) return parsed;
199
+ }
200
+ return null;
201
+ }
202
+
203
+ /** The transaction carrier sends `YYYY-MM-DD HH:mm:ss` — zone-less, so read in the rail's zone. */
204
+ function parseDate(value: unknown): string | null {
205
+ return parseInstant(value, REPORTING_TIME_ZONE);
206
+ }
207
+
208
+ function readAmountMinor(transaction: Record<string, unknown>): number | null {
209
+ return readAmountMinorFrom(
210
+ transaction,
211
+ ["amountMinor", "paidAmountMinor", "tr_amount_minor", "tr_paid_minor"],
212
+ ["paidAmount", "amount", "tr_paid", "tr_amount"],
213
+ );
214
+ }
215
+
216
+ function readConfiguredAmountMinor(transaction: Record<string, unknown>): number | null {
217
+ return readAmountMinorFrom(transaction, ["amountMinor", "tr_amount_minor"], ["amount", "tr_amount"]);
218
+ }
219
+
220
+ function readAmountMinorFrom(
221
+ transaction: Record<string, unknown>,
222
+ minorKeys: readonly string[],
223
+ majorKeys: readonly string[],
224
+ ): number | null {
225
+ for (const key of minorKeys) {
226
+ const raw = transaction[key];
227
+ if (typeof raw === "number" && Number.isFinite(raw)) return Math.round(raw);
228
+ if (typeof raw === "string" && /^\d+$/.test(raw.trim())) return Number(raw);
229
+ }
230
+ for (const key of majorKeys) {
231
+ const raw = transaction[key];
232
+ const value = typeof raw === "number" ? raw : typeof raw === "string" ? Number(raw.replace(",", ".")) : NaN;
233
+ if (Number.isFinite(value)) return Math.round(value * 100);
234
+ }
235
+ return null;
236
+ }
237
+
238
+ function readCurrency(transaction: Record<string, unknown>): string | null {
239
+ for (const key of ["currency", "tr_currency"]) {
240
+ const raw = transaction[key];
241
+ if (typeof raw === "string" && raw.trim().length === 3) return raw.trim().toUpperCase();
242
+ }
243
+ return null;
244
+ }
245
+
246
+ /**
247
+ * Classification goes through the shared `classifyDecline` seam, the same one
248
+ * the synchronous path and the card rail use, so there is exactly one place a
249
+ * refusal becomes a class. Only neutral evidence crosses: this provider's code
250
+ * vocabulary is translated here and travels no further. Since PR 2724 the class
251
+ * reaches the case and becomes the payer's cause sentence; it is no cadence input.
252
+ */
253
+
254
+ function sanitizedTpayTransaction(
255
+ transaction: Record<string, unknown>,
256
+ providerStatus: string,
257
+ refusal: AttemptRefusal | null = null,
258
+ ): Record<string, unknown> {
259
+ return {
260
+ // Before this, the only record of why a charge failed was discarded on
261
+ // every poll.
262
+ ...(refusal ? refusalCapture(refusal, `tpay_decline_${refusal.code}`) : {}),
263
+ ...failureEvidencePayload(refusal || ["failed", "failure", "error", "false", "declined", "rejected"].includes(providerStatus.trim().toLowerCase()) ? tpayFailureEvidence({
264
+ source: "readback", providerPaymentId: readOptionalString(transaction, "transactionId"),
265
+ refusalVerified: true, flow: null,
266
+ observation: refusal ? { code: refusal.code, disposition: "present" } : attemptDeclineObservation(transaction), reading: refusal?.reading,
267
+ }) : null),
268
+ provider: "tpay",
269
+ transactionId: readOptionalString(transaction, "transactionId") ?? readOptionalString(transaction, "id"),
270
+ title: readOptionalString(transaction, "title") ?? readOptionalString(transaction, "tr_id"),
271
+ status: providerStatus,
272
+ requestIdPresent: Boolean(readOptionalString(transaction, "requestId")),
273
+ amountMinorPresent: readAmountMinor(transaction) !== null,
274
+ currency: readCurrency(transaction),
275
+ };
276
+ }
277
+
278
+ function readOptionalString(value: Record<string, unknown>, key: string): string | null {
279
+ const raw = value[key];
280
+ return typeof raw === "string" && raw.trim() ? raw.trim() : null;
281
+ }
282
+
283
+ function record(value: unknown): Record<string, unknown> {
284
+ return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
285
+ }