@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,96 @@
1
+ /**
2
+ * Per-field maxima Tpay enforces on `payer.*`, read from the provider's own SDK
3
+ * rather than inferred. In `tpay-com/tpay-openapi-php`,
4
+ * `src/Model/Objects/Transactions/Payer.php` binds every accepted wire key to a
5
+ * field model, and each model declares the `$maxLength` the API applies:
6
+ *
7
+ * email `src/Model/Fields/Person/Email.php` 255
8
+ * name `src/Model/Fields/Payer/Name.php` 255
9
+ * ip `src/Model/Fields/Payer/IP.php` 255
10
+ * userAgent `src/Model/Fields/Payer/UserAgent.php` 255
11
+ *
12
+ * ⚠️ The unit is BYTES, not characters: `src/Model/Fields/FieldValidator.php`
13
+ * measures with PHP `strlen()`, which counts bytes. A 255-CHARACTER name in an
14
+ * accented or non-Latin script can be far more than 255 bytes in UTF-8 and would
15
+ * still be refused, so the clamp below counts bytes.
16
+ *
17
+ * An over-long field is refused synchronously with HTTP 400 `not_valid`. On BLIK
18
+ * the code then never reaches the bank and the buyer sees no prompt at all: the
19
+ * Facebook iOS in-app WebView user agent is 271 characters, which cost four
20
+ * orders and two cancelled subscriptions from 2026-08-28.
21
+ */
22
+ const TPAY_PAYER_MAX_BYTES = {
23
+ email: 255,
24
+ name: 255,
25
+ ip: 255,
26
+ userAgent: 255,
27
+ };
28
+ /**
29
+ * Fits one payer field inside the provider's documented maximum.
30
+ *
31
+ * ⛔ Truncate, never omit, and for every one of the four keys. `Field.php` runs
32
+ * a `pattern` check only for models that declare one and none of these four
33
+ * does, so a truncated value stays structurally valid; `getRequiredFields()`
34
+ * makes `email` and `name` mandatory, so dropping either only buys a different
35
+ * 400; and `ts:request:no_ip` is a real provider error, so dropping `ip` trades
36
+ * one refusal for another. Truncation also keeps the prefix that a fraud review
37
+ * or a support ticket can still read.
38
+ *
39
+ * Lives here, in the single function that serialises the wire body, so no payer
40
+ * producer can bypass it.
41
+ */
42
+ function payerFieldWithinLimit(key, value) {
43
+ const maxBytes = TPAY_PAYER_MAX_BYTES[key];
44
+ const bytes = new TextEncoder().encode(value);
45
+ if (bytes.length <= maxBytes)
46
+ return value;
47
+ let end = maxBytes;
48
+ // Never cut inside a code point: UTF-8 continuation bytes are 0b10xxxxxx, so
49
+ // walk back off them rather than emitting a replacement character.
50
+ while (end > 0 && (bytes[end] & 0xc0) === 0x80)
51
+ end -= 1;
52
+ return new TextDecoder().decode(bytes.subarray(0, end));
53
+ }
54
+ /**
55
+ * Builds the `POST /transactions` wire payload.
56
+ *
57
+ * Every BLIK-specific field lives under `pay.blikPaymentData`; `aliases` carries
58
+ * the mandate. `refuseNoPayId` sits beside `aliases` (not inside it), while
59
+ * `recommendedAuthLevel` and `noDelay` sit inside the alias object as siblings of
60
+ * `autopayment` — matching Tpay's SDK object model and verified against
61
+ * production on 2026-07-20.
62
+ */
63
+ export function buildTpayTransactionPayload(input) {
64
+ const pay = {};
65
+ if (input.groupId)
66
+ pay.groupId = input.groupId;
67
+ if (input.channelId)
68
+ pay.channelId = input.channelId;
69
+ if (input.blikToken || input.alias) {
70
+ pay.blikPaymentData = {
71
+ ...(input.blikPaymentType ? { type: input.blikPaymentType } : {}),
72
+ ...(input.blikToken ? { blikToken: input.blikToken } : {}),
73
+ ...(input.refuseNoPayId ? { refuseNoPayId: true } : {}),
74
+ ...(input.alias ? { aliases: input.alias } : {}),
75
+ };
76
+ }
77
+ return {
78
+ amount: input.amount,
79
+ currency: input.currency,
80
+ description: input.description,
81
+ hiddenDescription: input.hiddenDescription,
82
+ payer: {
83
+ email: payerFieldWithinLimit("email", input.payer.email),
84
+ name: payerFieldWithinLimit("name", input.payer.name),
85
+ ...(input.payer.ip ? { ip: payerFieldWithinLimit("ip", input.payer.ip) } : {}),
86
+ ...(input.payer.userAgent
87
+ ? { userAgent: payerFieldWithinLimit("userAgent", input.payer.userAgent) }
88
+ : {}),
89
+ },
90
+ ...(Object.keys(pay).length > 0 ? { pay } : {}),
91
+ callbacks: {
92
+ notification: { url: input.notificationUrl },
93
+ payerUrls: { success: input.successUrl, error: input.errorUrl },
94
+ },
95
+ };
96
+ }
@@ -0,0 +1,31 @@
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
+ * The UTC instant of a zone-less `YYYY-MM-DDTHH:mm[:ss]` read in `timeZone`.
15
+ *
16
+ * Two passes, because the offset is a function of the answer being computed:
17
+ * the first prices the naive reading, the second re-prices it at the instant
18
+ * that produced. That is what makes the conversion hold on both sides of a DST
19
+ * transition rather than only in the half-year it was written in — a single
20
+ * pass reads the hour after a spring-forward an hour early. A local time the
21
+ * transition skips or repeats resolves through the offset in force at the first
22
+ * pass; no wall clock can separate those two readings.
23
+ */
24
+ export declare function wallClockToUtc(wallClock: string, timeZone: string): string | null;
25
+ /**
26
+ * An ISO UTC instant for `value`, or `null` when it does not carry one. A
27
+ * zone-less value is read as a wall clock in `timeZone`; a value stating its
28
+ * own offset keeps it. Never invents an instant — the absence of a readable
29
+ * timestamp must stay distinguishable from a timestamp.
30
+ */
31
+ export declare function parseInstant(value: unknown, timeZone: string): string | null;
@@ -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
+ /** A date-time with no `Z` and no `±HH:mm`, i.e. a bare wall-clock label. */
14
+ const ZONELESS_DATE_TIME = /^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?)$/;
15
+ /** Milliseconds `timeZone` is ahead of UTC at `at` (negative when west of it). */
16
+ function zoneOffsetMs(at, timeZone) {
17
+ const zone = new Intl.DateTimeFormat("en", { timeZone, timeZoneName: "longOffset" })
18
+ .formatToParts(at).find((part) => part.type === "timeZoneName")?.value;
19
+ const match = /^GMT([+-])(\d{1,2}):(\d{2})$/.exec(zone ?? "");
20
+ if (!match)
21
+ return 0;
22
+ const [, sign, hours, minutes] = match;
23
+ return (sign === "-" ? -1 : 1) * (Number(hours) * 60 + Number(minutes)) * 60_000;
24
+ }
25
+ /**
26
+ * The UTC instant of a zone-less `YYYY-MM-DDTHH:mm[:ss]` read in `timeZone`.
27
+ *
28
+ * Two passes, because the offset is a function of the answer being computed:
29
+ * the first prices the naive reading, the second re-prices it at the instant
30
+ * that produced. That is what makes the conversion hold on both sides of a DST
31
+ * transition rather than only in the half-year it was written in — a single
32
+ * pass reads the hour after a spring-forward an hour early. A local time the
33
+ * transition skips or repeats resolves through the offset in force at the first
34
+ * pass; no wall clock can separate those two readings.
35
+ */
36
+ export function wallClockToUtc(wallClock, timeZone) {
37
+ const asIfUtc = Date.parse(`${wallClock}Z`);
38
+ if (!Number.isFinite(asIfUtc))
39
+ return null;
40
+ const firstPass = asIfUtc - zoneOffsetMs(new Date(asIfUtc), timeZone);
41
+ return new Date(asIfUtc - zoneOffsetMs(new Date(firstPass), timeZone)).toISOString();
42
+ }
43
+ /**
44
+ * An ISO UTC instant for `value`, or `null` when it does not carry one. A
45
+ * zone-less value is read as a wall clock in `timeZone`; a value stating its
46
+ * own offset keeps it. Never invents an instant — the absence of a readable
47
+ * timestamp must stay distinguishable from a timestamp.
48
+ */
49
+ export function parseInstant(value, timeZone) {
50
+ if (typeof value !== "string" || !value.trim())
51
+ return null;
52
+ const trimmed = value.trim();
53
+ const zoneless = ZONELESS_DATE_TIME.exec(trimmed);
54
+ if (zoneless)
55
+ return wallClockToUtc(`${zoneless[1]}T${zoneless[2]}`, timeZone);
56
+ return Number.isFinite(Date.parse(trimmed)) ? new Date(trimmed).toISOString() : null;
57
+ }
@@ -0,0 +1,17 @@
1
+ export interface TpayJwsCertificateResolver {
2
+ (url: string): Promise<string>;
3
+ }
4
+ export interface TpayJwsVerificationInput {
5
+ rawBody: string;
6
+ signatureHeader: string | undefined;
7
+ resolveCertificate: TpayJwsCertificateResolver;
8
+ rootCertificatePem: string;
9
+ allowedX5uPrefix?: string;
10
+ verifyCertificateChain?: (signingCertificatePem: string, rootCertificatePem: string) => boolean;
11
+ verifyDetachedSignature?: (input: {
12
+ signingInput: string;
13
+ signature: Buffer;
14
+ signingCertificatePem: string;
15
+ }) => boolean;
16
+ }
17
+ export declare function verifyTpayJwsSignature({ rawBody, signatureHeader, resolveCertificate, rootCertificatePem, allowedX5uPrefix, verifyCertificateChain: verifyChain, verifyDetachedSignature: verifyDetached, }: TpayJwsVerificationInput): Promise<boolean>;
@@ -0,0 +1,51 @@
1
+ import { verify as verifySignature, X509Certificate } from "node:crypto";
2
+ export async function verifyTpayJwsSignature({ rawBody, signatureHeader, resolveCertificate, rootCertificatePem, allowedX5uPrefix = "https://secure.sandbox.tpay.com/", verifyCertificateChain: verifyChain = verifyCertificateChain, verifyDetachedSignature: verifyDetached = defaultVerifyDetachedSignature, }) {
3
+ if (!signatureHeader || !rootCertificatePem)
4
+ return false;
5
+ const parts = signatureHeader.split(".");
6
+ if (parts.length < 3 || !parts[0] || !parts[2])
7
+ return false;
8
+ const header = parseJwsHeader(parts[0]);
9
+ if (!header?.x5u || !header.x5u.startsWith(allowedX5uPrefix))
10
+ return false;
11
+ const signingCertificatePem = await resolveCertificate(header.x5u);
12
+ if (!verifyChain(signingCertificatePem, rootCertificatePem))
13
+ return false;
14
+ const signedPayload = `${parts[0]}.${base64Url(Buffer.from(rawBody, "utf8"))}`;
15
+ const signature = base64UrlDecode(parts[2]);
16
+ return verifyDetached({ signingInput: signedPayload, signature, signingCertificatePem });
17
+ }
18
+ function parseJwsHeader(encoded) {
19
+ try {
20
+ const parsed = JSON.parse(base64UrlDecode(encoded).toString("utf8"));
21
+ if (!parsed || typeof parsed !== "object")
22
+ return null;
23
+ const x5u = parsed.x5u;
24
+ return typeof x5u === "string" ? { x5u } : null;
25
+ }
26
+ catch {
27
+ return null;
28
+ }
29
+ }
30
+ function verifyCertificateChain(signingCertificatePem, rootCertificatePem) {
31
+ try {
32
+ const signing = new X509Certificate(signingCertificatePem);
33
+ const root = new X509Certificate(rootCertificatePem);
34
+ return signing.verify(root.publicKey);
35
+ }
36
+ catch {
37
+ return false;
38
+ }
39
+ }
40
+ function defaultVerifyDetachedSignature(input) {
41
+ const publicKey = new X509Certificate(input.signingCertificatePem).publicKey;
42
+ return verifySignature("RSA-SHA256", Buffer.from(input.signingInput), publicKey, input.signature);
43
+ }
44
+ function base64Url(value) {
45
+ return value.toString("base64").replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_");
46
+ }
47
+ function base64UrlDecode(value) {
48
+ const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
49
+ const padded = normalized.padEnd(normalized.length + ((4 - normalized.length % 4) % 4), "=");
50
+ return Buffer.from(padded, "base64");
51
+ }
@@ -0,0 +1,54 @@
1
+ import type { PaymentMethodLifecycleEvent, PaymentMethodLifecycleEventKind } from "@openlup/core/payment";
2
+ declare const PROVIDER_KIND = "tpay";
3
+ type TpayCanonicalPaymentEvent = {
4
+ provider_event_id: string;
5
+ event_type: "payment.succeeded" | "payment.failed" | "payment.refunded" | "payment.requires_action" | "payment.disputed" | "setup.succeeded" | "setup.failed" | "setup.requires_action";
6
+ payment_provider_id: string;
7
+ payment_intent_id?: string | null;
8
+ amount_minor?: number;
9
+ raw_payload: Record<string, unknown>;
10
+ };
11
+ export interface NormalizedTpayMethodRef {
12
+ clientId: string | null;
13
+ subscriptionId?: string | null;
14
+ providerMethodRef: string;
15
+ methodKind: "blik_payid";
16
+ status: "active" | "inactive";
17
+ consentSnapshot: Record<string, unknown>;
18
+ }
19
+ export interface NormalizedTpayWebhook {
20
+ event: TpayCanonicalPaymentEvent;
21
+ methodRef: NormalizedTpayMethodRef | null;
22
+ /**
23
+ * The neutral transition this delivery reports about the stored mandate. The
24
+ * instant it happened is not known at this layer, so the full neutral event
25
+ * is assembled by the provider-webhook mapper below.
26
+ */
27
+ methodLifecycleKind: PaymentMethodLifecycleEventKind | null;
28
+ responseBody: "TRUE";
29
+ }
30
+ type NormalizedTpayProviderWebhook = {
31
+ provider: typeof PROVIDER_KIND;
32
+ providerEventId: string;
33
+ eventType: TpayCanonicalPaymentEvent["event_type"];
34
+ providerPaymentId: string;
35
+ paymentIntentId?: string | null;
36
+ amountMinor: number | null;
37
+ currency: string | null;
38
+ occurredAt: string;
39
+ rawPayload: Record<string, unknown>;
40
+ reusableMethod: {
41
+ clientId: string;
42
+ subscriptionId: string | null;
43
+ providerCustomerRef: null;
44
+ providerMethodRef: string;
45
+ providerMandateRef: null;
46
+ methodKind: "blik_payid";
47
+ status: "active" | "inactive";
48
+ consentSnapshot: Record<string, unknown>;
49
+ } | null;
50
+ methodLifecycle: PaymentMethodLifecycleEvent | null;
51
+ };
52
+ export declare function normalizeTpayWebhook(rawBody: string): NormalizedTpayWebhook;
53
+ export declare function toNormalizedProviderTpayWebhook(webhook: NormalizedTpayWebhook, occurredAt: string): NormalizedTpayProviderWebhook;
54
+ export {};
@@ -0,0 +1,200 @@
1
+ const PROVIDER_KIND = "tpay";
2
+ /**
3
+ * Alias-rail event names, mapped to the neutral lifecycle vocabulary.
4
+ *
5
+ * ⛔ `ALIAS_UPDATE` is a ROTATION, not a death: the payer's standing consent is
6
+ * unchanged and only the alias behind it moves. Classifying it with the two
7
+ * terminal names would strip a healthy subscription of the mandate it renews
8
+ * on. That is why the `active` flag below already treats register and update
9
+ * alike, and why this table must keep them apart from unregister and expiry.
10
+ */
11
+ const ALIAS_LIFECYCLE_KINDS = {
12
+ ALIAS_REGISTER: "method_registered",
13
+ ALIAS_UPDATE: "method_updated",
14
+ ALIAS_UNREGISTER: "method_revoked",
15
+ ALIAS_EXPIRED: "method_expired",
16
+ };
17
+ export function normalizeTpayWebhook(rawBody) {
18
+ const payload = parsePayload(rawBody);
19
+ const aliasEvent = payload.event;
20
+ if (aliasEvent) {
21
+ return normalizeAliasWebhook(payload);
22
+ }
23
+ return normalizeTransactionWebhook(payload);
24
+ }
25
+ export function toNormalizedProviderTpayWebhook(webhook, occurredAt) {
26
+ return {
27
+ provider: PROVIDER_KIND,
28
+ providerEventId: webhook.event.provider_event_id,
29
+ eventType: webhook.event.event_type,
30
+ providerPaymentId: webhook.event.payment_provider_id,
31
+ paymentIntentId: webhook.event.payment_intent_id ?? null,
32
+ amountMinor: webhook.event.amount_minor ?? null,
33
+ currency: readCurrency(webhook.event.raw_payload),
34
+ occurredAt,
35
+ rawPayload: webhook.event.raw_payload,
36
+ reusableMethod: toReusableMethod(webhook.methodRef),
37
+ // The alias value IS the stored method reference on this rail. The rail
38
+ // publishes no scheme label or digits with an alias transition, and its
39
+ // expiry field is deliberately not read here (see the wave plan): a
40
+ // transition on this rail is a state change, not a facts refresh.
41
+ methodLifecycle: webhook.methodLifecycleKind
42
+ ? {
43
+ kind: webhook.methodLifecycleKind,
44
+ providerKind: PROVIDER_KIND,
45
+ providerMethodRef: webhook.event.payment_provider_id,
46
+ providerEventId: webhook.event.provider_event_id,
47
+ occurredAt,
48
+ replacement: null,
49
+ }
50
+ : null,
51
+ };
52
+ }
53
+ function normalizeTransactionWebhook(payload) {
54
+ const transactionTitle = required(payload, "tr_id");
55
+ const rawStatus = required(payload, "tr_status");
56
+ // Tpay production sends `tr_status` UPPERCASE ("TRUE"/"CHARGEBACK"); a
57
+ // case-sensitive compare mislabels every real success as payment.failed
58
+ // (caught live 2026-07-13 via a production payment-link). Classify
59
+ // case-insensitively; keep the raw value in raw_payload for observability.
60
+ const status = rawStatus.toLowerCase();
61
+ const amountMinor = amountToMinor(payload.tr_paid || payload.tr_amount);
62
+ const paymentIntentId = parseUuid(payload.tr_crc);
63
+ return {
64
+ event: {
65
+ // Only chargebacks split identity; every other status keeps its legacy keys.
66
+ provider_event_id: status === "chargeback" ? `${transactionTitle}:chargeback` : transactionTitle,
67
+ event_type: status === "chargeback" ? "payment.refunded" : status === "true" ? "payment.succeeded" : "payment.failed",
68
+ payment_provider_id: transactionTitle,
69
+ payment_intent_id: paymentIntentId,
70
+ amount_minor: amountMinor,
71
+ raw_payload: {
72
+ provider: PROVIDER_KIND,
73
+ eventKind: "transaction",
74
+ transactionTitle,
75
+ hiddenDescription: payload.tr_crc ?? null,
76
+ transactionAmount: payload.tr_amount ?? null,
77
+ paidAmount: payload.tr_paid ?? null,
78
+ // Null, not the settlement default: this records what the provider SAID,
79
+ // and a currency it did not send is unknown. The whole path was already
80
+ // built for that - `NormalizedTpayProviderWebhook.currency` is typed
81
+ // `string | null` and `readCurrency` below returns null for an absent or
82
+ // blank field - so the old `?? "PLN"` was manufacturing an operand rather
83
+ // than filling a gap.
84
+ //
85
+ // It does not weaken the SQL cross-check either. `commerce_payment_control_
86
+ // apply_result` guards `v_event.currency IS NOT NULL AND v_event.currency <>
87
+ // v_intent.currency`; a fabricated code cannot detect a real mismatch, it can
88
+ // only compare equal by luck in a PLN deployment or raise a *false*
89
+ // `payment_control_result_currency_mismatch` in one that settles elsewhere and
90
+ // refuse a legitimate payment. Skipping a comparison nobody can make beats
91
+ // making one against an invented value.
92
+ currency: payload.tr_currency ?? null,
93
+ status: rawStatus,
94
+ testMode: payload.test_mode ?? null,
95
+ },
96
+ },
97
+ methodRef: null,
98
+ methodLifecycleKind: null,
99
+ responseBody: "TRUE",
100
+ };
101
+ }
102
+ function normalizeAliasWebhook(payload) {
103
+ const aliasValue = required(payload, "msg_value[value]");
104
+ // Tpay sends alias event names UPPERCASE; normalize defensively so the
105
+ // classification can never be defeated by casing (see tr_status above).
106
+ const event = required(payload, "event").toUpperCase();
107
+ const active = event === "ALIAS_REGISTER" || event === "ALIAS_UPDATE";
108
+ const paymentIntentId = paymentIntentIdFromAlias(aliasValue);
109
+ return {
110
+ event: {
111
+ provider_event_id: `${event}:${aliasValue}`,
112
+ event_type: active ? "setup.succeeded" : "setup.failed",
113
+ payment_provider_id: aliasValue,
114
+ payment_intent_id: paymentIntentId,
115
+ raw_payload: {
116
+ provider: PROVIDER_KIND,
117
+ eventKind: "blik_alias",
118
+ event,
119
+ paymentIntentId,
120
+ aliasValuePresent: true,
121
+ aliasType: payload["msg_value[type]"] ?? null,
122
+ aliasExpirationDatePresent: Boolean(payload["msg_value[expirationDate]"]),
123
+ },
124
+ },
125
+ methodRef: {
126
+ clientId: payload.client_id ?? null,
127
+ subscriptionId: payload.subscription_id ?? null,
128
+ providerMethodRef: aliasValue,
129
+ methodKind: "blik_payid",
130
+ status: active ? "active" : "inactive",
131
+ consentSnapshot: { source: "tpay.alias.webhook", event },
132
+ },
133
+ methodLifecycleKind: ALIAS_LIFECYCLE_KINDS[event] ?? null,
134
+ responseBody: "TRUE",
135
+ };
136
+ }
137
+ function toReusableMethod(methodRef) {
138
+ if (!methodRef?.clientId)
139
+ return null;
140
+ return {
141
+ clientId: methodRef.clientId,
142
+ subscriptionId: methodRef.subscriptionId ?? null,
143
+ providerCustomerRef: null,
144
+ providerMethodRef: methodRef.providerMethodRef,
145
+ providerMandateRef: null,
146
+ methodKind: methodRef.methodKind,
147
+ status: methodRef.status,
148
+ consentSnapshot: methodRef.consentSnapshot,
149
+ };
150
+ }
151
+ function readCurrency(payload) {
152
+ const currency = payload.currency;
153
+ return typeof currency === "string" && currency.trim() ? currency : null;
154
+ }
155
+ function parsePayload(rawBody) {
156
+ const trimmed = rawBody.trim();
157
+ if (trimmed.startsWith("{")) {
158
+ const parsed = JSON.parse(trimmed);
159
+ return flattenJson(parsed);
160
+ }
161
+ const params = new URLSearchParams(rawBody);
162
+ return Object.fromEntries(Array.from(params.entries()));
163
+ }
164
+ function flattenJson(value, prefix = "") {
165
+ const out = {};
166
+ for (const [key, raw] of Object.entries(value)) {
167
+ const name = prefix ? `${prefix}[${key}]` : key;
168
+ if (raw && typeof raw === "object" && !Array.isArray(raw)) {
169
+ Object.assign(out, flattenJson(raw, name));
170
+ }
171
+ else if (raw !== undefined && raw !== null) {
172
+ out[name] = String(raw);
173
+ }
174
+ }
175
+ return out;
176
+ }
177
+ function required(payload, key) {
178
+ const value = payload[key];
179
+ if (!value)
180
+ throw new Error(`tpay_webhook_missing_${key}`);
181
+ return value;
182
+ }
183
+ function amountToMinor(value) {
184
+ if (!value)
185
+ return undefined;
186
+ const numeric = Number(value.replace(",", "."));
187
+ return Number.isFinite(numeric) ? Math.round(numeric * 100) : undefined;
188
+ }
189
+ function paymentIntentIdFromAlias(value) {
190
+ 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);
191
+ return match ? match[1].toLowerCase() : null;
192
+ }
193
+ function parseUuid(value) {
194
+ if (!value)
195
+ return null;
196
+ const trimmed = value.trim();
197
+ 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)
198
+ ? trimmed.toLowerCase()
199
+ : null;
200
+ }
@@ -0,0 +1,12 @@
1
+ import type { PaymentVerifyNormalizePort, RefundEvidencePolicy, VerifiedMethodEvidence, VerifiedPaymentEvidence } from "./contracts/evidence.js";
2
+ import type { ProviderWebhookWithMethodLifecycle } from "./paymentMethodLifecycle.js";
3
+ /** New boundary module: verified adapter envelopes into the unchanged donor carrier.
4
+ * Original bytes go only to verification. Store adapter-redacted audit facts plus
5
+ * nested normalized evidence; typed facts override overlapping reader keys.
6
+ * Neither audit shape nor receipt time changes adapter identity/fingerprint.
7
+ * Incomplete refunds are ingested and acknowledged, but require-evidence prevents apply.
8
+ */
9
+ export declare function projectVerifiedEvidence(evidence: VerifiedPaymentEvidence | VerifiedMethodEvidence, input: Parameters<PaymentVerifyNormalizePort["verifyAndNormalize"]>[0], policy: RefundEvidencePolicy, methodOwner?: {
10
+ clientId: string;
11
+ subscriptionId?: string | null;
12
+ }): ProviderWebhookWithMethodLifecycle;