@openlup/platform 0.19.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (217) hide show
  1. package/CHANGELOG.md +161 -231
  2. package/README.md +182 -8
  3. package/dist/composition/composeBundle.d.ts +6 -0
  4. package/dist/composition/composeBundle.js +2 -0
  5. package/dist/composition/index.js +1 -0
  6. package/dist/payments/adapterConformance.d.ts +8 -0
  7. package/dist/payments/adapterConformance.js +109 -0
  8. package/dist/payments/bindings.d.ts +3 -0
  9. package/dist/payments/bindings.js +17 -0
  10. package/dist/payments/checkoutActivePaymentActionResolver.d.ts +7 -0
  11. package/dist/payments/checkoutActivePaymentActionResolver.js +77 -0
  12. package/dist/payments/checkoutRecoveryPaymentResolver.d.ts +8 -0
  13. package/dist/payments/checkoutRecoveryPaymentResolver.js +87 -0
  14. package/dist/payments/contracts/adapter.d.ts +113 -0
  15. package/dist/payments/contracts/adapter.js +1 -0
  16. package/dist/payments/contracts/conformance.d.ts +155 -0
  17. package/dist/payments/contracts/conformance.js +1 -0
  18. package/dist/payments/contracts/evidence.d.ts +157 -0
  19. package/dist/payments/contracts/evidence.js +1 -0
  20. package/dist/payments/contracts/index.d.ts +10 -0
  21. package/dist/payments/contracts/index.js +1 -0
  22. package/dist/payments/contracts/preparedAttempt.d.ts +69 -0
  23. package/dist/payments/contracts/preparedAttempt.js +1 -0
  24. package/dist/payments/contracts/reconciliation.d.ts +303 -0
  25. package/dist/payments/contracts/reconciliation.js +1 -0
  26. package/dist/payments/contracts/repair.d.ts +92 -0
  27. package/dist/payments/contracts/repair.js +1 -0
  28. package/dist/payments/contracts/truth.d.ts +92 -0
  29. package/dist/payments/contracts/truth.js +1 -0
  30. package/dist/payments/eventConformanceRunner.d.ts +12 -0
  31. package/dist/payments/eventConformanceRunner.js +125 -0
  32. package/dist/payments/index.d.ts +16 -0
  33. package/dist/payments/index.js +16 -0
  34. package/dist/payments/paymentAttemptSilenceWindow.d.ts +10 -0
  35. package/dist/payments/paymentAttemptSilenceWindow.js +19 -0
  36. package/dist/payments/paymentMethodLifecycle.d.ts +11 -0
  37. package/dist/payments/paymentMethodLifecycle.js +37 -0
  38. package/dist/payments/paymentProviderReconciliationAbsenceReopen.d.ts +7 -0
  39. package/dist/payments/paymentProviderReconciliationAbsenceReopen.js +100 -0
  40. package/dist/payments/paymentProviderReconciliationEvidence.d.ts +36 -0
  41. package/dist/payments/paymentProviderReconciliationEvidence.js +91 -0
  42. package/dist/payments/paymentProviderReconciliationWorker.d.ts +4 -0
  43. package/dist/payments/paymentProviderReconciliationWorker.js +245 -0
  44. package/dist/payments/paymentTruth.d.ts +8 -0
  45. package/dist/payments/paymentTruth.js +125 -0
  46. package/dist/payments/paymentVerifyNowHandler.d.ts +27 -0
  47. package/dist/payments/paymentVerifyNowHandler.js +70 -0
  48. package/dist/payments/paymentVerifyNowService.d.ts +23 -0
  49. package/dist/payments/paymentVerifyNowService.js +93 -0
  50. package/dist/payments/paymentWebhookPostProcessing.d.ts +22 -0
  51. package/dist/payments/paymentWebhookPostProcessing.js +15 -0
  52. package/dist/payments/postgresPaymentTruth.d.ts +10 -0
  53. package/dist/payments/postgresPaymentTruth.js +126 -0
  54. package/dist/payments/preparedAttempt.d.ts +6 -0
  55. package/dist/payments/preparedAttempt.js +70 -0
  56. package/dist/payments/preparedAttemptFinalization.d.ts +5 -0
  57. package/dist/payments/preparedAttemptFinalization.js +12 -0
  58. package/dist/payments/schemaCapabilities.d.ts +10 -0
  59. package/dist/payments/schemaCapabilities.js +10 -0
  60. package/dist/payments/sql.d.ts +20 -0
  61. package/dist/payments/sql.js +6 -0
  62. package/dist/payments/stripe/classifyDecline.d.ts +46 -0
  63. package/dist/payments/stripe/classifyDecline.js +58 -0
  64. package/dist/payments/stripe/declineFailureHints.d.ts +46 -0
  65. package/dist/payments/stripe/declineFailureHints.js +87 -0
  66. package/dist/payments/stripe/donorContracts.d.ts +120 -0
  67. package/dist/payments/stripe/donorContracts.js +1 -0
  68. package/dist/payments/stripe/executionDecline.d.ts +53 -0
  69. package/dist/payments/stripe/executionDecline.js +107 -0
  70. package/dist/payments/stripe/index.d.ts +16 -0
  71. package/dist/payments/stripe/index.js +239 -0
  72. package/dist/payments/stripe/intentMapper.d.ts +26 -0
  73. package/dist/payments/stripe/intentMapper.js +51 -0
  74. package/dist/payments/stripe/methodFacts.d.ts +10 -0
  75. package/dist/payments/stripe/methodFacts.js +12 -0
  76. package/dist/payments/stripe/paymentFailureEvidence.d.ts +26 -0
  77. package/dist/payments/stripe/paymentFailureEvidence.js +33 -0
  78. package/dist/payments/stripe/stripeFailureEvidence.d.ts +41 -0
  79. package/dist/payments/stripe/stripeFailureEvidence.js +82 -0
  80. package/dist/payments/stripe/stripeIntentConfirmationEvidence.d.ts +28 -0
  81. package/dist/payments/stripe/stripeIntentConfirmationEvidence.js +32 -0
  82. package/dist/payments/stripe/stripePaymentReconciliationProvider.d.ts +35 -0
  83. package/dist/payments/stripe/stripePaymentReconciliationProvider.js +222 -0
  84. package/dist/payments/stripe/stripeRefundEvidence.d.ts +8 -0
  85. package/dist/payments/stripe/stripeRefundEvidence.js +17 -0
  86. package/dist/payments/stripe/stripeSandboxPaymentExecutionAdapter.d.ts +45 -0
  87. package/dist/payments/stripe/stripeSandboxPaymentExecutionAdapter.js +220 -0
  88. package/dist/payments/stripe/stripeWebhookNormalizer.d.ts +14 -0
  89. package/dist/payments/stripe/stripeWebhookNormalizer.js +243 -0
  90. package/dist/payments/stripe/webhookSignature.d.ts +14 -0
  91. package/dist/payments/stripe/webhookSignature.js +43 -0
  92. package/dist/payments/tpay/blikRecurringMapper.d.ts +37 -0
  93. package/dist/payments/tpay/blikRecurringMapper.js +24 -0
  94. package/dist/payments/tpay/capabilities.d.ts +3 -0
  95. package/dist/payments/tpay/capabilities.js +18 -0
  96. package/dist/payments/tpay/classifyDecline.d.ts +46 -0
  97. package/dist/payments/tpay/classifyDecline.js +58 -0
  98. package/dist/payments/tpay/declineFailureHints.d.ts +72 -0
  99. package/dist/payments/tpay/declineFailureHints.js +37 -0
  100. package/dist/payments/tpay/donorContracts.d.ts +140 -0
  101. package/dist/payments/tpay/donorContracts.js +1 -0
  102. package/dist/payments/tpay/index.d.ts +43 -0
  103. package/dist/payments/tpay/index.js +264 -0
  104. package/dist/payments/tpay/paymentFailureEvidence.d.ts +26 -0
  105. package/dist/payments/tpay/paymentFailureEvidence.js +33 -0
  106. package/dist/payments/tpay/preparedProviderAttempt.d.ts +22 -0
  107. package/dist/payments/tpay/preparedProviderAttempt.js +24 -0
  108. package/dist/payments/tpay/providerAttemptFailureDiagnostic.d.ts +3 -0
  109. package/dist/payments/tpay/providerAttemptFailureDiagnostic.js +95 -0
  110. package/dist/payments/tpay/tpayCreateTransactionInput.d.ts +25 -0
  111. package/dist/payments/tpay/tpayCreateTransactionInput.js +128 -0
  112. package/dist/payments/tpay/tpayDeclineEvidence.d.ts +126 -0
  113. package/dist/payments/tpay/tpayDeclineEvidence.js +177 -0
  114. package/dist/payments/tpay/tpayDispatchFailure.d.ts +60 -0
  115. package/dist/payments/tpay/tpayDispatchFailure.js +121 -0
  116. package/dist/payments/tpay/tpayFailureEvidence.d.ts +20 -0
  117. package/dist/payments/tpay/tpayFailureEvidence.js +61 -0
  118. package/dist/payments/tpay/tpayHttpClient.d.ts +52 -0
  119. package/dist/payments/tpay/tpayHttpClient.js +185 -0
  120. package/dist/payments/tpay/tpayHttpFailure.d.ts +57 -0
  121. package/dist/payments/tpay/tpayHttpFailure.js +143 -0
  122. package/dist/payments/tpay/tpayPaymentExecutionAdapter.d.ts +20 -0
  123. package/dist/payments/tpay/tpayPaymentExecutionAdapter.js +249 -0
  124. package/dist/payments/tpay/tpayPaymentReconciliationProvider.d.ts +5 -0
  125. package/dist/payments/tpay/tpayPaymentReconciliationProvider.js +244 -0
  126. package/dist/payments/tpay/tpayTransactionPayload.d.ts +120 -0
  127. package/dist/payments/tpay/tpayTransactionPayload.js +96 -0
  128. package/dist/payments/tpay/wallClockTime.d.ts +31 -0
  129. package/dist/payments/tpay/wallClockTime.js +57 -0
  130. package/dist/payments/tpay/webhookJwsVerifier.d.ts +17 -0
  131. package/dist/payments/tpay/webhookJwsVerifier.js +51 -0
  132. package/dist/payments/tpay/webhookNormalizer.d.ts +54 -0
  133. package/dist/payments/tpay/webhookNormalizer.js +200 -0
  134. package/dist/payments/verifiedEvidenceProjection.d.ts +12 -0
  135. package/dist/payments/verifiedEvidenceProjection.js +166 -0
  136. package/dist/payments/verifiedIngress.d.ts +119 -0
  137. package/dist/payments/verifiedIngress.js +220 -0
  138. package/dist/shipping/bindings.d.ts +5 -0
  139. package/dist/shipping/bindings.js +12 -2
  140. package/dist/shipping/index.d.ts +14 -0
  141. package/dist/shipping/index.js +5 -0
  142. package/package.json +22 -2
  143. package/release-gates.json +64 -3
  144. package/sql/20260810180000_payment_settlement_rail.sql +574 -0
  145. package/sql/20260815150000_payment_truth_reconciliation.sql +479 -0
  146. package/sql/20260826100000_late_payment_shifts_the_subscription_cycle.sql +215 -0
  147. package/sql/20261021200000_shipping_flat_rate_seed.sql +1 -0
  148. package/src/composition/composeBundle.ts +9 -0
  149. package/src/composition/index.ts +1 -0
  150. package/src/payments/adapterConformance.ts +118 -0
  151. package/src/payments/bindings.ts +17 -0
  152. package/src/payments/checkoutActivePaymentActionResolver.ts +97 -0
  153. package/src/payments/checkoutRecoveryPaymentResolver.ts +108 -0
  154. package/src/payments/contracts/adapter.ts +96 -0
  155. package/src/payments/contracts/conformance.ts +171 -0
  156. package/src/payments/contracts/evidence.ts +142 -0
  157. package/src/payments/contracts/index.ts +54 -0
  158. package/src/payments/contracts/preparedAttempt.ts +86 -0
  159. package/src/payments/contracts/reconciliation.ts +296 -0
  160. package/src/payments/contracts/repair.ts +95 -0
  161. package/src/payments/contracts/truth.ts +108 -0
  162. package/src/payments/eventConformanceRunner.ts +149 -0
  163. package/src/payments/index.ts +21 -0
  164. package/src/payments/paymentAttemptSilenceWindow.ts +26 -0
  165. package/src/payments/paymentMethodLifecycle.ts +50 -0
  166. package/src/payments/paymentProviderReconciliationAbsenceReopen.ts +130 -0
  167. package/src/payments/paymentProviderReconciliationEvidence.ts +117 -0
  168. package/src/payments/paymentProviderReconciliationWorker.ts +291 -0
  169. package/src/payments/paymentTruth.ts +131 -0
  170. package/src/payments/paymentVerifyNowHandler.ts +94 -0
  171. package/src/payments/paymentVerifyNowService.ts +125 -0
  172. package/src/payments/paymentWebhookPostProcessing.ts +36 -0
  173. package/src/payments/postgresPaymentTruth.ts +149 -0
  174. package/src/payments/preparedAttempt.ts +90 -0
  175. package/src/payments/preparedAttemptFinalization.ts +13 -0
  176. package/src/payments/schemaCapabilities.ts +10 -0
  177. package/src/payments/sql.ts +6 -0
  178. package/src/payments/stripe/classifyDecline.ts +62 -0
  179. package/src/payments/stripe/declineFailureHints.ts +89 -0
  180. package/src/payments/stripe/donorContracts.ts +87 -0
  181. package/src/payments/stripe/executionDecline.ts +126 -0
  182. package/src/payments/stripe/index.ts +235 -0
  183. package/src/payments/stripe/intentMapper.ts +58 -0
  184. package/src/payments/stripe/methodFacts.ts +21 -0
  185. package/src/payments/stripe/paymentFailureEvidence.ts +58 -0
  186. package/src/payments/stripe/stripeFailureEvidence.ts +115 -0
  187. package/src/payments/stripe/stripeIntentConfirmationEvidence.ts +34 -0
  188. package/src/payments/stripe/stripePaymentReconciliationProvider.ts +263 -0
  189. package/src/payments/stripe/stripeRefundEvidence.ts +19 -0
  190. package/src/payments/stripe/stripeSandboxPaymentExecutionAdapter.ts +283 -0
  191. package/src/payments/stripe/stripeWebhookNormalizer.ts +277 -0
  192. package/src/payments/stripe/webhookSignature.ts +55 -0
  193. package/src/payments/tpay/blikRecurringMapper.ts +42 -0
  194. package/src/payments/tpay/capabilities.ts +21 -0
  195. package/src/payments/tpay/classifyDecline.ts +62 -0
  196. package/src/payments/tpay/declineFailureHints.ts +90 -0
  197. package/src/payments/tpay/donorContracts.ts +104 -0
  198. package/src/payments/tpay/index.ts +257 -0
  199. package/src/payments/tpay/paymentFailureEvidence.ts +58 -0
  200. package/src/payments/tpay/preparedProviderAttempt.ts +38 -0
  201. package/src/payments/tpay/providerAttemptFailureDiagnostic.ts +99 -0
  202. package/src/payments/tpay/tpayCreateTransactionInput.ts +158 -0
  203. package/src/payments/tpay/tpayDeclineEvidence.ts +233 -0
  204. package/src/payments/tpay/tpayDispatchFailure.ts +186 -0
  205. package/src/payments/tpay/tpayFailureEvidence.ts +75 -0
  206. package/src/payments/tpay/tpayHttpClient.ts +290 -0
  207. package/src/payments/tpay/tpayHttpFailure.ts +186 -0
  208. package/src/payments/tpay/tpayPaymentExecutionAdapter.ts +297 -0
  209. package/src/payments/tpay/tpayPaymentReconciliationProvider.ts +285 -0
  210. package/src/payments/tpay/tpayTransactionPayload.ts +207 -0
  211. package/src/payments/tpay/wallClockTime.ts +57 -0
  212. package/src/payments/tpay/webhookJwsVerifier.ts +82 -0
  213. package/src/payments/tpay/webhookNormalizer.ts +279 -0
  214. package/src/payments/verifiedEvidenceProjection.ts +179 -0
  215. package/src/payments/verifiedIngress.ts +317 -0
  216. package/src/shipping/bindings.ts +18 -2
  217. package/src/shipping/index.ts +6 -0
@@ -0,0 +1,207 @@
1
+ export interface TpayPayerInput {
2
+ email: string;
3
+ name: string;
4
+ ip?: string | null;
5
+ userAgent?: string | null;
6
+ }
7
+
8
+ /**
9
+ * BLIK alias sent on transaction create.
10
+ *
11
+ * `UID` is merchant-scoped one-click; `PAYID` is the recurring mandate.
12
+ * `recommendedAuthLevel` is a SIBLING of `autopayment`, not a field inside it
13
+ * (Tpay SDK `Model/Objects/Transactions/Alias.php`), and Tpay documents it as
14
+ * available and required only for autopayment model `O`, where it omits the
15
+ * per-charge authorization screen.
16
+ */
17
+ export interface TpayAliasInput {
18
+ value: string;
19
+ type: "UID" | "PAYID";
20
+ label?: string;
21
+ recommendedAuthLevel?: "NOCONFREQ";
22
+ /**
23
+ * Collect immediately instead of giving the payer 72 hours to approve. Required
24
+ * on every model `O` charge; available for models `A` and `O` only.
25
+ */
26
+ noDelay?: boolean;
27
+ /**
28
+ * `frequency` is deliberately absent: Tpay requires it for model `A`, treats it
29
+ * as optional for `M`, and does not accept it for `O`. We only ever register
30
+ * variable-amount mandates, so no caller needs it.
31
+ */
32
+ autopayment?: { model: "A" | "M" | "O" };
33
+ }
34
+
35
+ export interface TpayTransactionCreateInput {
36
+ amount: number;
37
+ currency: "PLN";
38
+ description: string;
39
+ hiddenDescription: string;
40
+ payer: TpayPayerInput;
41
+ notificationUrl: string;
42
+ successUrl: string;
43
+ errorUrl: string;
44
+ groupId?: number;
45
+ channelId?: string;
46
+ blikPaymentType?: 2;
47
+ blikToken?: string;
48
+ /**
49
+ * Ask BLIK to reject the transaction outright when the payer's bank does not
50
+ * support recurring payments, instead of taking the money and silently never
51
+ * showing the mandate invitation. Only meaningful when registering a `PAYID`
52
+ * alias; never set it for a plain one-off BLIK payment.
53
+ */
54
+ refuseNoPayId?: boolean;
55
+ alias?: TpayAliasInput | null;
56
+ simulatorContext?: {
57
+ orderId?: string;
58
+ paymentIntentId: string;
59
+ clientId?: string;
60
+ /** Stable per provider attempt; simulator-only and never sent to Tpay. */
61
+ providerAttemptKey?: string;
62
+ /**
63
+ * In-account terminal path the simulator panel returns to after the buyer
64
+ * settles (account `returnContext` only). Absent for the public flow, which
65
+ * keeps the simulator page's own `/checkout/payment` return so the
66
+ * public path stays byte-identical (and client-side localized).
67
+ */
68
+ returnPath?: string;
69
+ };
70
+ }
71
+
72
+ /** One entry of Tpay's `payments.errors[]`. `errorCode` is low-cardinality. */
73
+ export interface TpayPaymentError {
74
+ errorCode: string;
75
+ errorMessage: string;
76
+ fieldName: string | null;
77
+ }
78
+
79
+ /**
80
+ * Create-transaction result.
81
+ *
82
+ * ⚠️ Tpay signals a synchronous BLIK rejection ONLY through `payments.errors[]`
83
+ * and the absence of `payments.payIdEligible`. Verified against production
84
+ * 2026-07-20: a rejected recurring BLIK still returns HTTP 200 with
85
+ * `result: "success"`, top-level `status: "pending"` AND `payments.status:
86
+ * "pending"`. Those three fields therefore prove nothing — never branch on them
87
+ * to decide success. `payments.method` is equally useless: it reads
88
+ * `"pay_by_link"` even for a settled BLIK PayID charge.
89
+ *
90
+ * This matters because Tpay has no async channel for failures — the transaction
91
+ * webhook's `tr_status` only ever carries `true` or `chargeback`, so a decline
92
+ * that is not read out of this response is never reported at all.
93
+ */
94
+ export interface TpayTransactionCreated {
95
+ transactionId: string;
96
+ title: string;
97
+ status: string;
98
+ transactionPaymentUrl: string | null;
99
+ requestId: string | null;
100
+ /**
101
+ * Whether BLIK considers the payer's bank capable of registering a PayID
102
+ * mandate. Present and `true` when eligible; absent entirely when Tpay
103
+ * rejected the transaction.
104
+ *
105
+ * ⚠️ UNDOCUMENTED — observed empirically, absent from Tpay's OpenAPI spec and
106
+ * from their official SDK. Treat as a hint that may vanish without notice and
107
+ * always keep `errors` as the fallback signal.
108
+ */
109
+ payIdEligible: boolean | null;
110
+ /** Synchronous provider errors. Empty when the request was accepted. */
111
+ errors: TpayPaymentError[];
112
+ }
113
+
114
+ /**
115
+ * Per-field maxima Tpay enforces on `payer.*`, read from the provider's own SDK
116
+ * rather than inferred. In `tpay-com/tpay-openapi-php`,
117
+ * `src/Model/Objects/Transactions/Payer.php` binds every accepted wire key to a
118
+ * field model, and each model declares the `$maxLength` the API applies:
119
+ *
120
+ * email `src/Model/Fields/Person/Email.php` 255
121
+ * name `src/Model/Fields/Payer/Name.php` 255
122
+ * ip `src/Model/Fields/Payer/IP.php` 255
123
+ * userAgent `src/Model/Fields/Payer/UserAgent.php` 255
124
+ *
125
+ * ⚠️ The unit is BYTES, not characters: `src/Model/Fields/FieldValidator.php`
126
+ * measures with PHP `strlen()`, which counts bytes. A 255-CHARACTER name in an
127
+ * accented or non-Latin script can be far more than 255 bytes in UTF-8 and would
128
+ * still be refused, so the clamp below counts bytes.
129
+ *
130
+ * An over-long field is refused synchronously with HTTP 400 `not_valid`. On BLIK
131
+ * the code then never reaches the bank and the buyer sees no prompt at all: the
132
+ * Facebook iOS in-app WebView user agent is 271 characters, which cost four
133
+ * orders and two cancelled subscriptions from 2026-08-28.
134
+ */
135
+ const TPAY_PAYER_MAX_BYTES = {
136
+ email: 255,
137
+ name: 255,
138
+ ip: 255,
139
+ userAgent: 255,
140
+ } as const;
141
+
142
+ /**
143
+ * Fits one payer field inside the provider's documented maximum.
144
+ *
145
+ * ⛔ Truncate, never omit, and for every one of the four keys. `Field.php` runs
146
+ * a `pattern` check only for models that declare one and none of these four
147
+ * does, so a truncated value stays structurally valid; `getRequiredFields()`
148
+ * makes `email` and `name` mandatory, so dropping either only buys a different
149
+ * 400; and `ts:request:no_ip` is a real provider error, so dropping `ip` trades
150
+ * one refusal for another. Truncation also keeps the prefix that a fraud review
151
+ * or a support ticket can still read.
152
+ *
153
+ * Lives here, in the single function that serialises the wire body, so no payer
154
+ * producer can bypass it.
155
+ */
156
+ function payerFieldWithinLimit(key: keyof typeof TPAY_PAYER_MAX_BYTES, value: string): string {
157
+ const maxBytes = TPAY_PAYER_MAX_BYTES[key];
158
+ const bytes = new TextEncoder().encode(value);
159
+ if (bytes.length <= maxBytes) return value;
160
+ let end = maxBytes;
161
+ // Never cut inside a code point: UTF-8 continuation bytes are 0b10xxxxxx, so
162
+ // walk back off them rather than emitting a replacement character.
163
+ while (end > 0 && (bytes[end] & 0xc0) === 0x80) end -= 1;
164
+ return new TextDecoder().decode(bytes.subarray(0, end));
165
+ }
166
+
167
+ /**
168
+ * Builds the `POST /transactions` wire payload.
169
+ *
170
+ * Every BLIK-specific field lives under `pay.blikPaymentData`; `aliases` carries
171
+ * the mandate. `refuseNoPayId` sits beside `aliases` (not inside it), while
172
+ * `recommendedAuthLevel` and `noDelay` sit inside the alias object as siblings of
173
+ * `autopayment` — matching Tpay's SDK object model and verified against
174
+ * production on 2026-07-20.
175
+ */
176
+ export function buildTpayTransactionPayload(input: TpayTransactionCreateInput): Record<string, unknown> {
177
+ const pay: Record<string, unknown> = {};
178
+ if (input.groupId) pay.groupId = input.groupId;
179
+ if (input.channelId) pay.channelId = input.channelId;
180
+ if (input.blikToken || input.alias) {
181
+ pay.blikPaymentData = {
182
+ ...(input.blikPaymentType ? { type: input.blikPaymentType } : {}),
183
+ ...(input.blikToken ? { blikToken: input.blikToken } : {}),
184
+ ...(input.refuseNoPayId ? { refuseNoPayId: true } : {}),
185
+ ...(input.alias ? { aliases: input.alias } : {}),
186
+ };
187
+ }
188
+ return {
189
+ amount: input.amount,
190
+ currency: input.currency,
191
+ description: input.description,
192
+ hiddenDescription: input.hiddenDescription,
193
+ payer: {
194
+ email: payerFieldWithinLimit("email", input.payer.email),
195
+ name: payerFieldWithinLimit("name", input.payer.name),
196
+ ...(input.payer.ip ? { ip: payerFieldWithinLimit("ip", input.payer.ip) } : {}),
197
+ ...(input.payer.userAgent
198
+ ? { userAgent: payerFieldWithinLimit("userAgent", input.payer.userAgent) }
199
+ : {}),
200
+ },
201
+ ...(Object.keys(pay).length > 0 ? { pay } : {}),
202
+ callbacks: {
203
+ notification: { url: input.notificationUrl },
204
+ payerUrls: { success: input.successUrl, error: input.errorUrl },
205
+ },
206
+ };
207
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Reading a timestamp from a rail that does not say what clock it is on.
3
+ *
4
+ * A value carrying `Z` or `±HH:mm` is already an instant and is honoured as
5
+ * written. A value without one is a WALL CLOCK — a label on someone's local
6
+ * time — and turning it into an instant needs the zone that clock runs on.
7
+ * `Date.parse` resolves such a value against the HOST zone, which is a silent
8
+ * trap: it reads correct on a laptop set to the rail's own zone and skews by
9
+ * the offset on the UTC runtime that serves production. Callers therefore pass
10
+ * the zone explicitly; there is no default, because a wrong default is exactly
11
+ * the bug this module exists to remove.
12
+ */
13
+
14
+ /** A date-time with no `Z` and no `±HH:mm`, i.e. a bare wall-clock label. */
15
+ const ZONELESS_DATE_TIME = /^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?)$/;
16
+
17
+ /** Milliseconds `timeZone` is ahead of UTC at `at` (negative when west of it). */
18
+ function zoneOffsetMs(at: Date, timeZone: string): number {
19
+ const zone = new Intl.DateTimeFormat("en", { timeZone, timeZoneName: "longOffset" })
20
+ .formatToParts(at).find((part) => part.type === "timeZoneName")?.value;
21
+ const match = /^GMT([+-])(\d{1,2}):(\d{2})$/.exec(zone ?? "");
22
+ if (!match) return 0;
23
+ const [, sign, hours, minutes] = match;
24
+ return (sign === "-" ? -1 : 1) * (Number(hours) * 60 + Number(minutes)) * 60_000;
25
+ }
26
+
27
+ /**
28
+ * The UTC instant of a zone-less `YYYY-MM-DDTHH:mm[:ss]` read in `timeZone`.
29
+ *
30
+ * Two passes, because the offset is a function of the answer being computed:
31
+ * the first prices the naive reading, the second re-prices it at the instant
32
+ * that produced. That is what makes the conversion hold on both sides of a DST
33
+ * transition rather than only in the half-year it was written in — a single
34
+ * pass reads the hour after a spring-forward an hour early. A local time the
35
+ * transition skips or repeats resolves through the offset in force at the first
36
+ * pass; no wall clock can separate those two readings.
37
+ */
38
+ export function wallClockToUtc(wallClock: string, timeZone: string): string | null {
39
+ const asIfUtc = Date.parse(`${wallClock}Z`);
40
+ if (!Number.isFinite(asIfUtc)) return null;
41
+ const firstPass = asIfUtc - zoneOffsetMs(new Date(asIfUtc), timeZone);
42
+ return new Date(asIfUtc - zoneOffsetMs(new Date(firstPass), timeZone)).toISOString();
43
+ }
44
+
45
+ /**
46
+ * An ISO UTC instant for `value`, or `null` when it does not carry one. A
47
+ * zone-less value is read as a wall clock in `timeZone`; a value stating its
48
+ * own offset keeps it. Never invents an instant — the absence of a readable
49
+ * timestamp must stay distinguishable from a timestamp.
50
+ */
51
+ export function parseInstant(value: unknown, timeZone: string): string | null {
52
+ if (typeof value !== "string" || !value.trim()) return null;
53
+ const trimmed = value.trim();
54
+ const zoneless = ZONELESS_DATE_TIME.exec(trimmed);
55
+ if (zoneless) return wallClockToUtc(`${zoneless[1]}T${zoneless[2]}`, timeZone);
56
+ return Number.isFinite(Date.parse(trimmed)) ? new Date(trimmed).toISOString() : null;
57
+ }
@@ -0,0 +1,82 @@
1
+ import { verify as verifySignature, X509Certificate } from "node:crypto";
2
+
3
+ export interface TpayJwsCertificateResolver {
4
+ (url: string): Promise<string>;
5
+ }
6
+
7
+ export interface TpayJwsVerificationInput {
8
+ rawBody: string;
9
+ signatureHeader: string | undefined;
10
+ resolveCertificate: TpayJwsCertificateResolver;
11
+ rootCertificatePem: string;
12
+ allowedX5uPrefix?: string;
13
+ verifyCertificateChain?: (signingCertificatePem: string, rootCertificatePem: string) => boolean;
14
+ verifyDetachedSignature?: (input: {
15
+ signingInput: string;
16
+ signature: Buffer;
17
+ signingCertificatePem: string;
18
+ }) => boolean;
19
+ }
20
+
21
+ export async function verifyTpayJwsSignature({
22
+ rawBody,
23
+ signatureHeader,
24
+ resolveCertificate,
25
+ rootCertificatePem,
26
+ allowedX5uPrefix = "https://secure.sandbox.tpay.com/",
27
+ verifyCertificateChain: verifyChain = verifyCertificateChain,
28
+ verifyDetachedSignature: verifyDetached = defaultVerifyDetachedSignature,
29
+ }: TpayJwsVerificationInput): Promise<boolean> {
30
+ if (!signatureHeader || !rootCertificatePem) return false;
31
+ const parts = signatureHeader.split(".");
32
+ if (parts.length < 3 || !parts[0] || !parts[2]) return false;
33
+ const header = parseJwsHeader(parts[0]);
34
+ if (!header?.x5u || !header.x5u.startsWith(allowedX5uPrefix)) return false;
35
+
36
+ const signingCertificatePem = await resolveCertificate(header.x5u);
37
+ if (!verifyChain(signingCertificatePem, rootCertificatePem)) return false;
38
+
39
+ const signedPayload = `${parts[0]}.${base64Url(Buffer.from(rawBody, "utf8"))}`;
40
+ const signature = base64UrlDecode(parts[2]);
41
+ return verifyDetached({ signingInput: signedPayload, signature, signingCertificatePem });
42
+ }
43
+
44
+ function parseJwsHeader(encoded: string): { x5u?: string } | null {
45
+ try {
46
+ const parsed = JSON.parse(base64UrlDecode(encoded).toString("utf8")) as unknown;
47
+ if (!parsed || typeof parsed !== "object") return null;
48
+ const x5u = (parsed as Record<string, unknown>).x5u;
49
+ return typeof x5u === "string" ? { x5u } : null;
50
+ } catch {
51
+ return null;
52
+ }
53
+ }
54
+
55
+ function verifyCertificateChain(signingCertificatePem: string, rootCertificatePem: string): boolean {
56
+ try {
57
+ const signing = new X509Certificate(signingCertificatePem);
58
+ const root = new X509Certificate(rootCertificatePem);
59
+ return signing.verify(root.publicKey);
60
+ } catch {
61
+ return false;
62
+ }
63
+ }
64
+
65
+ function defaultVerifyDetachedSignature(input: {
66
+ signingInput: string;
67
+ signature: Buffer;
68
+ signingCertificatePem: string;
69
+ }): boolean {
70
+ const publicKey = new X509Certificate(input.signingCertificatePem).publicKey;
71
+ return verifySignature("RSA-SHA256", Buffer.from(input.signingInput), publicKey, input.signature);
72
+ }
73
+
74
+ function base64Url(value: Buffer): string {
75
+ return value.toString("base64").replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_");
76
+ }
77
+
78
+ function base64UrlDecode(value: string): Buffer {
79
+ const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
80
+ const padded = normalized.padEnd(normalized.length + ((4 - normalized.length % 4) % 4), "=");
81
+ return Buffer.from(padded, "base64");
82
+ }
@@ -0,0 +1,279 @@
1
+ import type {
2
+ PaymentMethodLifecycleEvent,
3
+ PaymentMethodLifecycleEventKind,
4
+ } from "@openlup/core/payment";
5
+
6
+ const PROVIDER_KIND = "tpay";
7
+
8
+ /**
9
+ * Alias-rail event names, mapped to the neutral lifecycle vocabulary.
10
+ *
11
+ * ⛔ `ALIAS_UPDATE` is a ROTATION, not a death: the payer's standing consent is
12
+ * unchanged and only the alias behind it moves. Classifying it with the two
13
+ * terminal names would strip a healthy subscription of the mandate it renews
14
+ * on. That is why the `active` flag below already treats register and update
15
+ * alike, and why this table must keep them apart from unregister and expiry.
16
+ */
17
+ const ALIAS_LIFECYCLE_KINDS: Record<string, PaymentMethodLifecycleEventKind> = {
18
+ ALIAS_REGISTER: "method_registered",
19
+ ALIAS_UPDATE: "method_updated",
20
+ ALIAS_UNREGISTER: "method_revoked",
21
+ ALIAS_EXPIRED: "method_expired",
22
+ };
23
+
24
+ type TpayCanonicalPaymentEvent = {
25
+ provider_event_id: string;
26
+ event_type:
27
+ | "payment.succeeded"
28
+ | "payment.failed"
29
+ | "payment.refunded"
30
+ | "payment.requires_action"
31
+ | "payment.disputed"
32
+ | "setup.succeeded"
33
+ | "setup.failed"
34
+ | "setup.requires_action";
35
+ payment_provider_id: string;
36
+ payment_intent_id?: string | null;
37
+ amount_minor?: number;
38
+ raw_payload: Record<string, unknown>;
39
+ };
40
+
41
+ export interface NormalizedTpayMethodRef {
42
+ clientId: string | null;
43
+ subscriptionId?: string | null;
44
+ providerMethodRef: string;
45
+ methodKind: "blik_payid";
46
+ status: "active" | "inactive";
47
+ consentSnapshot: Record<string, unknown>;
48
+ }
49
+
50
+ export interface NormalizedTpayWebhook {
51
+ event: TpayCanonicalPaymentEvent;
52
+ methodRef: NormalizedTpayMethodRef | null;
53
+ /**
54
+ * The neutral transition this delivery reports about the stored mandate. The
55
+ * instant it happened is not known at this layer, so the full neutral event
56
+ * is assembled by the provider-webhook mapper below.
57
+ */
58
+ methodLifecycleKind: PaymentMethodLifecycleEventKind | null;
59
+ responseBody: "TRUE";
60
+ }
61
+
62
+ type NormalizedTpayProviderWebhook = {
63
+ provider: typeof PROVIDER_KIND;
64
+ providerEventId: string;
65
+ eventType: TpayCanonicalPaymentEvent["event_type"];
66
+ providerPaymentId: string;
67
+ paymentIntentId?: string | null;
68
+ amountMinor: number | null;
69
+ currency: string | null;
70
+ occurredAt: string;
71
+ rawPayload: Record<string, unknown>;
72
+ reusableMethod: {
73
+ clientId: string;
74
+ subscriptionId: string | null;
75
+ providerCustomerRef: null;
76
+ providerMethodRef: string;
77
+ providerMandateRef: null;
78
+ methodKind: "blik_payid";
79
+ status: "active" | "inactive";
80
+ consentSnapshot: Record<string, unknown>;
81
+ } | null;
82
+ methodLifecycle: PaymentMethodLifecycleEvent | null;
83
+ };
84
+
85
+ export function normalizeTpayWebhook(rawBody: string): NormalizedTpayWebhook {
86
+ const payload = parsePayload(rawBody);
87
+ const aliasEvent = payload.event;
88
+ if (aliasEvent) {
89
+ return normalizeAliasWebhook(payload);
90
+ }
91
+ return normalizeTransactionWebhook(payload);
92
+ }
93
+
94
+ export function toNormalizedProviderTpayWebhook(
95
+ webhook: NormalizedTpayWebhook,
96
+ occurredAt: string,
97
+ ): NormalizedTpayProviderWebhook {
98
+ return {
99
+ provider: PROVIDER_KIND,
100
+ providerEventId: webhook.event.provider_event_id,
101
+ eventType: webhook.event.event_type,
102
+ providerPaymentId: webhook.event.payment_provider_id,
103
+ paymentIntentId: webhook.event.payment_intent_id ?? null,
104
+ amountMinor: webhook.event.amount_minor ?? null,
105
+ currency: readCurrency(webhook.event.raw_payload),
106
+ occurredAt,
107
+ rawPayload: webhook.event.raw_payload,
108
+ reusableMethod: toReusableMethod(webhook.methodRef),
109
+ // The alias value IS the stored method reference on this rail. The rail
110
+ // publishes no scheme label or digits with an alias transition, and its
111
+ // expiry field is deliberately not read here (see the wave plan): a
112
+ // transition on this rail is a state change, not a facts refresh.
113
+ methodLifecycle: webhook.methodLifecycleKind
114
+ ? {
115
+ kind: webhook.methodLifecycleKind,
116
+ providerKind: PROVIDER_KIND,
117
+ providerMethodRef: webhook.event.payment_provider_id,
118
+ providerEventId: webhook.event.provider_event_id,
119
+ occurredAt,
120
+ replacement: null,
121
+ }
122
+ : null,
123
+ };
124
+ }
125
+
126
+ function normalizeTransactionWebhook(payload: Record<string, string>): NormalizedTpayWebhook {
127
+ const transactionTitle = required(payload, "tr_id");
128
+ const rawStatus = required(payload, "tr_status");
129
+ // Tpay production sends `tr_status` UPPERCASE ("TRUE"/"CHARGEBACK"); a
130
+ // case-sensitive compare mislabels every real success as payment.failed
131
+ // (caught live 2026-07-13 via a production payment-link). Classify
132
+ // case-insensitively; keep the raw value in raw_payload for observability.
133
+ const status = rawStatus.toLowerCase();
134
+ const amountMinor = amountToMinor(payload.tr_paid || payload.tr_amount);
135
+ const paymentIntentId = parseUuid(payload.tr_crc);
136
+ return {
137
+ event: {
138
+ // Only chargebacks split identity; every other status keeps its legacy keys.
139
+ provider_event_id: status === "chargeback" ? `${transactionTitle}:chargeback` : transactionTitle,
140
+ event_type: status === "chargeback" ? "payment.refunded" : status === "true" ? "payment.succeeded" : "payment.failed",
141
+ payment_provider_id: transactionTitle,
142
+ payment_intent_id: paymentIntentId,
143
+ amount_minor: amountMinor,
144
+ raw_payload: {
145
+ provider: PROVIDER_KIND,
146
+ eventKind: "transaction",
147
+ transactionTitle,
148
+ hiddenDescription: payload.tr_crc ?? null,
149
+ transactionAmount: payload.tr_amount ?? null,
150
+ paidAmount: payload.tr_paid ?? null,
151
+ // Null, not the settlement default: this records what the provider SAID,
152
+ // and a currency it did not send is unknown. The whole path was already
153
+ // built for that - `NormalizedTpayProviderWebhook.currency` is typed
154
+ // `string | null` and `readCurrency` below returns null for an absent or
155
+ // blank field - so the old `?? "PLN"` was manufacturing an operand rather
156
+ // than filling a gap.
157
+ //
158
+ // It does not weaken the SQL cross-check either. `commerce_payment_control_
159
+ // apply_result` guards `v_event.currency IS NOT NULL AND v_event.currency <>
160
+ // v_intent.currency`; a fabricated code cannot detect a real mismatch, it can
161
+ // only compare equal by luck in a PLN deployment or raise a *false*
162
+ // `payment_control_result_currency_mismatch` in one that settles elsewhere and
163
+ // refuse a legitimate payment. Skipping a comparison nobody can make beats
164
+ // making one against an invented value.
165
+ currency: payload.tr_currency ?? null,
166
+ status: rawStatus,
167
+ testMode: payload.test_mode ?? null,
168
+ },
169
+ },
170
+ methodRef: null,
171
+ methodLifecycleKind: null,
172
+ responseBody: "TRUE",
173
+ };
174
+ }
175
+
176
+ function normalizeAliasWebhook(payload: Record<string, string>): NormalizedTpayWebhook {
177
+ const aliasValue = required(payload, "msg_value[value]");
178
+ // Tpay sends alias event names UPPERCASE; normalize defensively so the
179
+ // classification can never be defeated by casing (see tr_status above).
180
+ const event = required(payload, "event").toUpperCase();
181
+ const active = event === "ALIAS_REGISTER" || event === "ALIAS_UPDATE";
182
+ const paymentIntentId = paymentIntentIdFromAlias(aliasValue);
183
+ return {
184
+ event: {
185
+ provider_event_id: `${event}:${aliasValue}`,
186
+ event_type: active ? "setup.succeeded" : "setup.failed",
187
+ payment_provider_id: aliasValue,
188
+ payment_intent_id: paymentIntentId,
189
+ raw_payload: {
190
+ provider: PROVIDER_KIND,
191
+ eventKind: "blik_alias",
192
+ event,
193
+ paymentIntentId,
194
+ aliasValuePresent: true,
195
+ aliasType: payload["msg_value[type]"] ?? null,
196
+ aliasExpirationDatePresent: Boolean(payload["msg_value[expirationDate]"]),
197
+ },
198
+ },
199
+ methodRef: {
200
+ clientId: payload.client_id ?? null,
201
+ subscriptionId: payload.subscription_id ?? null,
202
+ providerMethodRef: aliasValue,
203
+ methodKind: "blik_payid",
204
+ status: active ? "active" : "inactive",
205
+ consentSnapshot: { source: "tpay.alias.webhook", event },
206
+ },
207
+ methodLifecycleKind: ALIAS_LIFECYCLE_KINDS[event] ?? null,
208
+ responseBody: "TRUE",
209
+ };
210
+ }
211
+
212
+ function toReusableMethod(
213
+ methodRef: NormalizedTpayMethodRef | null,
214
+ ): NormalizedTpayProviderWebhook["reusableMethod"] {
215
+ if (!methodRef?.clientId) return null;
216
+ return {
217
+ clientId: methodRef.clientId,
218
+ subscriptionId: methodRef.subscriptionId ?? null,
219
+ providerCustomerRef: null,
220
+ providerMethodRef: methodRef.providerMethodRef,
221
+ providerMandateRef: null,
222
+ methodKind: methodRef.methodKind,
223
+ status: methodRef.status,
224
+ consentSnapshot: methodRef.consentSnapshot,
225
+ };
226
+ }
227
+
228
+ function readCurrency(payload: Record<string, unknown>): string | null {
229
+ const currency = payload.currency;
230
+ return typeof currency === "string" && currency.trim() ? currency : null;
231
+ }
232
+
233
+ function parsePayload(rawBody: string): Record<string, string> {
234
+ const trimmed = rawBody.trim();
235
+ if (trimmed.startsWith("{")) {
236
+ const parsed = JSON.parse(trimmed) as Record<string, unknown>;
237
+ return flattenJson(parsed);
238
+ }
239
+ const params = new URLSearchParams(rawBody);
240
+ return Object.fromEntries(Array.from(params.entries()));
241
+ }
242
+
243
+ function flattenJson(value: Record<string, unknown>, prefix = ""): Record<string, string> {
244
+ const out: Record<string, string> = {};
245
+ for (const [key, raw] of Object.entries(value)) {
246
+ const name = prefix ? `${prefix}[${key}]` : key;
247
+ if (raw && typeof raw === "object" && !Array.isArray(raw)) {
248
+ Object.assign(out, flattenJson(raw as Record<string, unknown>, name));
249
+ } else if (raw !== undefined && raw !== null) {
250
+ out[name] = String(raw);
251
+ }
252
+ }
253
+ return out;
254
+ }
255
+
256
+ function required(payload: Record<string, string>, key: string): string {
257
+ const value = payload[key];
258
+ if (!value) throw new Error(`tpay_webhook_missing_${key}`);
259
+ return value;
260
+ }
261
+
262
+ function amountToMinor(value: string | undefined): number | undefined {
263
+ if (!value) return undefined;
264
+ const numeric = Number(value.replace(",", "."));
265
+ return Number.isFinite(numeric) ? Math.round(numeric * 100) : undefined;
266
+ }
267
+
268
+ function paymentIntentIdFromAlias(value: string): string | null {
269
+ const match = /^openlup_([0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$/i.exec(value);
270
+ return match ? match[1].toLowerCase() : null;
271
+ }
272
+
273
+ function parseUuid(value: string | undefined): string | null {
274
+ if (!value) return null;
275
+ const trimmed = value.trim();
276
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(trimmed)
277
+ ? trimmed.toLowerCase()
278
+ : null;
279
+ }