@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
@@ -355,6 +355,66 @@
355
355
  ],
356
356
  "entrypoint": "dist/shipping/contracts/index.d.ts",
357
357
  "snapshot": "api/shipping-contracts.api.md"
358
+ },
359
+ "./payments": {
360
+ "role": "capability",
361
+ "maturity": "experimental",
362
+ "compatibilityPolicy": "internal-candidate",
363
+ "packageSmokeEvidence": [
364
+ "test/packageBoundary.test.ts",
365
+ "test/payments/preparedAttempt.test.ts",
366
+ "test/payments/verifiedIngress.test.ts",
367
+ "test/payments/refundEvidence.test.ts",
368
+ "test/payments/truth.test.ts",
369
+ "test/payments/reconciliation.test.ts",
370
+ "test/payments/adapterConformance.test.ts",
371
+ "test/payments/postgresPaymentTruth.test.ts"
372
+ ],
373
+ "entrypoint": "dist/payments/index.d.ts",
374
+ "snapshot": "api/payments.api.md"
375
+ },
376
+ "./payments/contracts": {
377
+ "role": "capability",
378
+ "maturity": "experimental",
379
+ "compatibilityPolicy": "internal-candidate",
380
+ "packageSmokeEvidence": [
381
+ "test/packageBoundary.test.ts",
382
+ "test/payments/contracts.test.ts",
383
+ "test/payments/verifiedIngress.test.ts",
384
+ "test/payments/refundEvidence.test.ts",
385
+ "test/payments/truth.test.ts",
386
+ "test/payments/reconciliation.test.ts",
387
+ "test/payments/adapterConformance.test.ts",
388
+ "test/payments/postgresPaymentTruth.test.ts"
389
+ ],
390
+ "entrypoint": "dist/payments/contracts/index.d.ts",
391
+ "snapshot": "api/payments-contracts.api.md"
392
+ },
393
+ "./payments/stripe": {
394
+ "role": "capability",
395
+ "maturity": "experimental",
396
+ "compatibilityPolicy": "internal-candidate",
397
+ "packageSmokeEvidence": [
398
+ "test/payments/stripe/conformance.test.ts",
399
+ "test/payments/stripe/factory.test.ts",
400
+ "test/payments/stripe/stripeWebhookNormalizer.test.ts",
401
+ "test/payments/stripe/stripeSandboxPaymentExecutionAdapter.test.ts"
402
+ ],
403
+ "entrypoint": "dist/payments/stripe/index.d.ts",
404
+ "snapshot": "api/payments-stripe.api.md"
405
+ },
406
+ "./payments/tpay": {
407
+ "role": "capability",
408
+ "maturity": "experimental",
409
+ "compatibilityPolicy": "internal-candidate",
410
+ "packageSmokeEvidence": [
411
+ "test/payments/tpay/factory.test.ts",
412
+ "test/payments/tpay/conformance.test.ts",
413
+ "test/payments/tpay/webhookNormalizer.test.ts",
414
+ "test/payments/tpay/tpayPaymentExecutionAdapter.test.ts"
415
+ ],
416
+ "entrypoint": "dist/payments/tpay/index.d.ts",
417
+ "snapshot": "api/payments-tpay.api.md"
358
418
  }
359
419
  },
360
420
  "pack": {
@@ -370,8 +430,8 @@
370
430
  "maxUnpackedBytes": 5734400,
371
431
  "maxFiles": 2100,
372
432
  "baseline": {
373
- "version": "0.18.0",
374
- "packedBytes": 779849
433
+ "version": "0.20.0",
434
+ "packedBytes": 809376
375
435
  },
376
436
  "browserEntries": {
377
437
  "./catalog/contracts": 4096,
@@ -380,7 +440,8 @@
380
440
  "./inventory/contracts": 32,
381
441
  "./pricing/contracts": 416,
382
442
  "./promotions/contracts": 32,
383
- "./shipping/contracts": 32
443
+ "./shipping/contracts": 32,
444
+ "./payments/contracts": 32
384
445
  }
385
446
  }
386
447
  }
@@ -0,0 +1,574 @@
1
+ -- Public platform settlement rail: the order gets lines and a price, and the kernel gets the one
2
+ -- thing it has never had -- a way to say "this order was paid for". Three relations, two money
3
+ -- columns on the order row a previous forward published, one arithmetic invariant, two mutations,
4
+ -- one emitter, and one delegation onto the subscription rail.
5
+ --
6
+ -- WHAT THIS FORWARD SHIPS. Every rail before this one could describe an order's LIFE -- a hold, a
7
+ -- parcel, a document, a cancellation -- and none of them could describe how it was BOUGHT. The
8
+ -- order-management forward said so about itself in the open: "no payment intent, payment, payment
9
+ -- attempt or inbound provider event relation... on this kernel the order row IS where payment state
10
+ -- lands". The accounting forward said the same from the other side: "no order money. No currency,
11
+ -- no order totals, no item relation". Both named the owner of that gap, and this is it. After this
12
+ -- forward a guest can be charged on a second-bundle deployment: the order carries lines and a
13
+ -- declared total, a settlement intent carries the amount somebody is being asked for, and one
14
+ -- accepted settlement moves the order to paid and the subscription cycle it belongs to with it.
15
+ --
16
+ -- WHAT IT REFUSES TO KNOW, AND THIS IS THE LOAD-BEARING PART. There is NO payment gateway here and
17
+ -- there will not be one. Not a name, not an enum, not a branch keyed on who is charging. A
18
+ -- settlement carries ONE opaque channel key and ONE opaque external reference; this kernel writes
19
+ -- both, compares them for equality, and never parses either. The reference deployment settles
20
+ -- through a channel that does nothing at all, which is exactly why the journey needs no real
21
+ -- gateway credentials to run. Currency is a COLUMN with a checked SHAPE -- three characters -- and
22
+ -- never a value: a kernel that hard-codes one market's currency has chosen that market for every
23
+ -- adopter. The same rule covers countries, tax rules and price rounding: none appears below.
24
+ --
25
+ -- MINOR UNITS, NOT CENTS, matching the vocabulary the accounting forward settled on. `cents` is the
26
+ -- subdivision of two particular currencies; `*_minor` is the subdivision of whichever one the
27
+ -- deployment declared.
28
+ --
29
+ -- WHERE THE SEMANTICS COME FROM, AND EVERY DEPARTURE. The behaviour below was derived from the
30
+ -- managed chain by reading its payment-control plane end to end -- intent creation, attempt
31
+ -- recording, result application, and the success branch that pays a subscription cycle -- and by
32
+ -- reading the constraints its own tables carry. Ten departures, each named, because a silent one is
33
+ -- how a kernel and its origin quietly stop being the same machine:
34
+ --
35
+ -- 1. ONE INTENT AND ONE LEDGER, NOT SIX RELATIONS. Upstream splits the money into an intent, a
36
+ -- payment, an attempt, a state-transition log, an inbound event store and a generic
37
+ -- idempotency table. Five of the six exist to hold gateway facts -- session handles, request
38
+ -- fingerprints, signature verification, next-action kinds -- and this kernel has no gateway,
39
+ -- so it would be authoring five empty shapes. What survives is the pair that carries meaning
40
+ -- without one: the intent, and the append-only ledger of what happened to it.
41
+ -- 2. IDEMPOTENCY IS PER INTENT, NOT GLOBAL. Upstream keys a shared table on (scope, key) and
42
+ -- fingerprints the request into it. Here the ledger row IS the replay answer and its
43
+ -- uniqueness is (intent, key), which is the invariant that lookup actually means: two
44
+ -- unrelated intents cannot collide on a value neither of them chose.
45
+ -- 3. THE SECOND SETTLEMENT OF A SETTLED INTENT IS REFUSED. Upstream re-applies a repeated success
46
+ -- arriving under a different key: the branch it takes only refuses cancelled and refunded
47
+ -- intents, so a second delivery writes a second state transition and stamps the row again.
48
+ -- This kernel answers `..._intent_already_settled` instead. It is a deliberately STRONGER
49
+ -- promise, it is what the settlement rail exists to guarantee, and the parity harness compares
50
+ -- it as a departure rather than hiding it in the shared spine.
51
+ -- 4. A DECLINE LEAVES THE ORDER RE-PAYABLE and writes nothing else. Upstream's decline branch
52
+ -- also opens a dunning case, schedules three retries on a fixed ladder, skips queued
53
+ -- notifications and classifies the failure. Dunning, retries and failure classification are
54
+ -- capabilities of their own with their own forwards; what is portable is that a declined order
55
+ -- is still payable, and that is all this kernel promises.
56
+ -- 5. THE INVARIANT IS A DEFERRED CONSTRAINT TRIGGER over the order and its lines together, the
57
+ -- device the accounting forward settled on. An argument check would be satisfied once and then
58
+ -- bypassed by the next write to the line ledger. The predicate is one comparison and it holds
59
+ -- in BOTH directions: an order that declares no total may carry no lines, and an order that
60
+ -- declares one must carry lines summing to it exactly.
61
+ -- 6. A LINE IS QUANTITY TIMES UNIT PRICE, exactly. Upstream carries a per-line discount
62
+ -- allocation, an effective net, and a basis-point rate, and refuses on eight arithmetic
63
+ -- mismatches between them. Discounting is a pricing decision the catalogue capability owns and
64
+ -- tax is a rule this catalogue already declined to interpret; a kernel that stored either
65
+ -- without applying it would be storing a claim nobody checks.
66
+ -- 7. THE MONEY COLUMNS ARE NULLABLE AND ADDED, not required and rewritten. They are added to a
67
+ -- table two published forwards already created, so NOT NULL there would refuse to apply on any
68
+ -- instance carrying rows. A NULL total means "this order has not declared what it is worth",
69
+ -- which is exactly the state every order on this kernel was in before this forward.
70
+ -- 8. THE SUBSCRIPTION SIDE IS A DELEGATION, NOT A SECOND MACHINE. A settled order that belongs to
71
+ -- a cycle moves that cycle to `paid` and slides the subscription's next date one cadence past
72
+ -- the cycle it just paid for -- the same arithmetic the managed success branch does. It does
73
+ -- NOT terminalize, activate or cancel anything: the lifecycle verbs belong to the subscription
74
+ -- forward and are reached through it.
75
+ -- 9. NO `SECURITY DEFINER`, NO `GRANT`/`REVOKE`, NO ROW-LEVEL SECURITY, matching every forward in
76
+ -- this catalogue -- upstream every function below is `SECURITY DEFINER`. This kernel creates
77
+ -- zero roles. Said out loud rather than assumed: ON THIS KERNEL THE FUNCTIONS BELOW ARE NOT A
78
+ -- SECURITY BOUNDARY, AND NEITHER IS A SETTLEMENT AN AUTHORIZATION. A caller that can reach
79
+ -- these functions can already reach the tables; the host application is what authenticates and
80
+ -- what decides that money actually moved. `SET search_path` is kept, because that one is a
81
+ -- correctness property and not a role property.
82
+ -- 10. EVERY CONSTRAINT IS NAMED, and `CREATE` is unconditional, because a manifest-ordered forward
83
+ -- runs exactly once against a known prefix and `IF NOT EXISTS` there would hide the drift the
84
+ -- migration ledger exists to catch. Constraints ADDED to the published order table are
85
+ -- `NOT VALID`, following that table's own convention: every new write is checked from this
86
+ -- statement on, and a fresh kernel boot has nothing to validate anyway.
87
+ --
88
+ -- WHAT THIS FORWARD DELIBERATELY DOES NOT SHIP, enumerated so nobody has to infer it:
89
+ -- * no gateway, no adapter, no provider enum, no webhook ingestion, no signature verification and
90
+ -- no reconciliation sweep;
91
+ -- * no refund and no dispute. The order rail already owns the refunded transition and its
92
+ -- notification; a settlement that could also un-settle itself would be two machines;
93
+ -- * no dunning ledger, no retry schedule, no failure taxonomy, no bound payment method;
94
+ -- * no inventory reservation and no release. A settled order reserves nothing here;
95
+ -- * no shipping, no discount and no tax arithmetic anywhere;
96
+ -- * no identity. `p_*` arguments are supplied by a trusted caller; a guest order is an order
97
+ -- whose client row exists and whose caller was never asked to prove anything;
98
+ -- * no runtime binding. Not one line of the application is rewired onto these relations by this
99
+ -- forward; the falsifier is its own cross-bundle parity harness.
100
+
101
+ -- The order row two published forwards created carried a status, three instants and an owner. These
102
+ -- are the two columns that make it an order for GOODS AT A PRICE rather than a state machine with a
103
+ -- customer attached. Nullable, for departure 7.
104
+ ALTER TABLE public.commerce_orders
105
+ ADD COLUMN currency_code text,
106
+ ADD COLUMN total_amount_minor bigint;
107
+
108
+ -- Declared together or not at all: an amount without its unit is a number, and a unit without an
109
+ -- amount is a claim about nothing. The shape of the code is checked; its value never is.
110
+ ALTER TABLE public.commerce_orders
111
+ ADD CONSTRAINT commerce_orders_declared_money_check
112
+ CHECK (
113
+ (total_amount_minor IS NULL AND currency_code IS NULL)
114
+ OR (
115
+ total_amount_minor IS NOT NULL AND total_amount_minor >= 0
116
+ AND currency_code IS NOT NULL AND char_length(currency_code) = 3
117
+ )
118
+ ) NOT VALID;
119
+
120
+ COMMENT ON COLUMN public.commerce_orders.currency_code IS
121
+ 'Three-character code the deployment declared. This kernel checks its shape and never its value.';
122
+
123
+ -- One row per line of the order. The catalogue link is optional on purpose: an adopter may sell a
124
+ -- thing this kernel does not stock, and refusing the line would make the catalogue rail a
125
+ -- precondition for taking money.
126
+ CREATE TABLE public.commerce_order_items (
127
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
128
+ order_id uuid NOT NULL REFERENCES public.commerce_orders(id) ON DELETE CASCADE,
129
+ sku_id uuid REFERENCES public.catalog_skus(id) ON DELETE RESTRICT,
130
+ line_ordinal integer NOT NULL,
131
+ quantity integer NOT NULL,
132
+ unit_amount_minor bigint NOT NULL,
133
+ line_amount_minor bigint NOT NULL,
134
+ created_at timestamptz NOT NULL DEFAULT now(),
135
+ CONSTRAINT commerce_order_items_line_ordinal_check CHECK (line_ordinal > 0),
136
+ CONSTRAINT commerce_order_items_quantity_check CHECK (quantity > 0),
137
+ CONSTRAINT commerce_order_items_unit_amount_minor_check CHECK (unit_amount_minor >= 0),
138
+ -- Departure 6: the line is the multiplication, with nothing allocated onto it and nothing taken
139
+ -- out of it. Integer arithmetic, so it is exact in every currency and rounds in none.
140
+ CONSTRAINT commerce_order_items_line_amount_minor_check
141
+ CHECK (line_amount_minor = unit_amount_minor * quantity),
142
+ CONSTRAINT commerce_order_items_order_id_line_ordinal_key UNIQUE (order_id, line_ordinal)
143
+ );
144
+
145
+ CREATE INDEX idx_commerce_order_items_order
146
+ ON public.commerce_order_items (order_id, line_ordinal);
147
+
148
+ -- What somebody is being asked to pay, and where that request stands. One per order, as a unique
149
+ -- key rather than a procedural check, because two concurrent callers cannot both pass a read and
150
+ -- then both insert.
151
+ CREATE TABLE public.commerce_settlement_intents (
152
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
153
+ order_id uuid NOT NULL REFERENCES public.commerce_orders(id) ON DELETE RESTRICT,
154
+ channel_key text NOT NULL,
155
+ status text NOT NULL DEFAULT 'created',
156
+ amount_minor bigint NOT NULL,
157
+ currency_code text NOT NULL,
158
+ external_ref text,
159
+ open_idempotency_key text NOT NULL,
160
+ settled_at timestamptz,
161
+ failure_reason text,
162
+ created_at timestamptz NOT NULL DEFAULT now(),
163
+ updated_at timestamptz NOT NULL DEFAULT now(),
164
+ -- Three values, and the third is not terminal: a declined settlement can be tried again, which is
165
+ -- departure 4 expressed as a state rather than as a comment.
166
+ CONSTRAINT commerce_settlement_intents_status_check
167
+ CHECK (status IN ('created', 'settled', 'failed')),
168
+ CONSTRAINT commerce_settlement_intents_amount_minor_check CHECK (amount_minor > 0),
169
+ CONSTRAINT commerce_settlement_intents_currency_code_check CHECK (char_length(currency_code) = 3),
170
+ CONSTRAINT commerce_settlement_intents_channel_key_check
171
+ CHECK (btrim(channel_key) <> '' AND char_length(channel_key) <= 64),
172
+ CONSTRAINT commerce_settlement_intents_open_idempotency_key_check
173
+ CHECK (btrim(open_idempotency_key) <> ''),
174
+ -- The instant and the state cannot disagree, in either direction.
175
+ CONSTRAINT commerce_settlement_intents_settled_at_check
176
+ CHECK ((status = 'settled') = (settled_at IS NOT NULL)),
177
+ CONSTRAINT commerce_settlement_intents_order_id_key UNIQUE (order_id),
178
+ CONSTRAINT commerce_settlement_intents_open_idempotency_key_key UNIQUE (open_idempotency_key)
179
+ );
180
+
181
+ COMMENT ON COLUMN public.commerce_settlement_intents.channel_key IS
182
+ 'Opaque label naming whatever settles this intent in a given deployment. Never parsed here.';
183
+ COMMENT ON COLUMN public.commerce_settlement_intents.external_ref IS
184
+ 'Opaque handle handed back by that channel, written once. Never parsed here.';
185
+
186
+ -- Append-only, and the row IS the replay answer: a repeated request carrying a key this intent has
187
+ -- already seen is answered from the ledger instead of acted on twice. Uniqueness is per intent and
188
+ -- the key is mandatory, for departure 2.
189
+ CREATE TABLE public.commerce_settlement_transitions (
190
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
191
+ intent_id uuid NOT NULL REFERENCES public.commerce_settlement_intents(id) ON DELETE CASCADE,
192
+ from_status text NOT NULL,
193
+ to_status text NOT NULL,
194
+ outcome text NOT NULL,
195
+ idempotency_key text NOT NULL,
196
+ payload jsonb NOT NULL DEFAULT '{}'::jsonb,
197
+ occurred_at timestamptz NOT NULL DEFAULT now(),
198
+ CONSTRAINT commerce_settlement_transitions_outcome_check
199
+ CHECK (outcome IN ('opened', 'succeeded', 'failed')),
200
+ CONSTRAINT commerce_settlement_transitions_idempotency_key_check
201
+ CHECK (btrim(idempotency_key) <> ''),
202
+ CONSTRAINT commerce_settlement_transitions_intent_id_idempotency_key_key
203
+ UNIQUE (intent_id, idempotency_key)
204
+ );
205
+
206
+ CREATE INDEX idx_commerce_settlement_transitions_intent
207
+ ON public.commerce_settlement_transitions (intent_id, occurred_at DESC);
208
+
209
+ -- THE INVARIANT. The lines must equal the declared total, in minor units, exactly.
210
+ --
211
+ -- One predicate covers both directions, which is why it is one predicate: an order that declares
212
+ -- nothing must carry no lines (0 = 0), an order that declares an amount must carry lines summing to
213
+ -- it, and an order that carries lines without declaring anything fails on the same comparison.
214
+ -- Deferring it to the end of the transaction is what lets an order and its lines be written by one
215
+ -- call and still be checked as a pair; nothing can commit them in disagreement.
216
+ CREATE FUNCTION public.commerce_order_assert_line_sum()
217
+ RETURNS trigger
218
+ LANGUAGE plpgsql
219
+ SET search_path = public, pg_catalog
220
+ AS $$
221
+ DECLARE
222
+ v_order_id uuid;
223
+ v_declared bigint;
224
+ v_sum bigint;
225
+ BEGIN
226
+ -- Branching statements rather than one CASE expression on purpose: the procedural language
227
+ -- compiles an expression into a single query, so every record field named in any arm must exist
228
+ -- on every row type the trigger can see, and the two row types here differ.
229
+ IF TG_TABLE_NAME = 'commerce_orders' THEN
230
+ v_order_id := NEW.id;
231
+ ELSIF TG_OP = 'DELETE' THEN
232
+ v_order_id := OLD.order_id;
233
+ ELSE
234
+ v_order_id := NEW.order_id;
235
+ END IF;
236
+
237
+ SELECT total_amount_minor INTO v_declared
238
+ FROM public.commerce_orders
239
+ WHERE id = v_order_id;
240
+ -- The order is gone: a cascade took it, and there is nothing left to be consistent with.
241
+ IF NOT FOUND THEN
242
+ RETURN NULL;
243
+ END IF;
244
+
245
+ SELECT COALESCE(sum(line_amount_minor), 0) INTO v_sum
246
+ FROM public.commerce_order_items
247
+ WHERE order_id = v_order_id;
248
+
249
+ IF v_sum IS DISTINCT FROM COALESCE(v_declared, 0) THEN
250
+ RAISE EXCEPTION 'commerce_order_line_sum_mismatch' USING ERRCODE = '23514',
251
+ DETAIL = format('order %s declares %s and its lines sum to %s',
252
+ v_order_id, COALESCE(v_declared::text, 'nothing'), v_sum);
253
+ END IF;
254
+
255
+ RETURN NULL;
256
+ END;
257
+ $$;
258
+
259
+ CREATE CONSTRAINT TRIGGER trg_commerce_orders_line_sum
260
+ AFTER INSERT OR UPDATE ON public.commerce_orders
261
+ DEFERRABLE INITIALLY DEFERRED
262
+ FOR EACH ROW
263
+ EXECUTE FUNCTION public.commerce_order_assert_line_sum();
264
+
265
+ CREATE CONSTRAINT TRIGGER trg_commerce_order_items_line_sum
266
+ AFTER INSERT OR UPDATE OR DELETE ON public.commerce_order_items
267
+ DEFERRABLE INITIALLY DEFERRED
268
+ FOR EACH ROW
269
+ EXECUTE FUNCTION public.commerce_order_assert_line_sum();
270
+
271
+ -- One response shape for both entry points, so an accepted call and a replayed one are answered by
272
+ -- the same builder and cannot drift apart.
273
+ CREATE FUNCTION public.commerce_settlement_response(
274
+ p_intent public.commerce_settlement_intents,
275
+ p_transition_id uuid,
276
+ p_outcome text,
277
+ p_replayed boolean
278
+ )
279
+ RETURNS jsonb
280
+ LANGUAGE sql
281
+ IMMUTABLE
282
+ SET search_path = public, pg_catalog
283
+ AS $$
284
+ SELECT jsonb_build_object(
285
+ 'contractVersion', 'platform.commerce.settlement.v1',
286
+ 'outcome', p_outcome,
287
+ 'replayed', p_replayed,
288
+ 'transitionId', p_transition_id,
289
+ 'settlement', jsonb_build_object(
290
+ 'id', p_intent.id,
291
+ 'orderId', p_intent.order_id,
292
+ 'status', p_intent.status,
293
+ 'amountMinor', p_intent.amount_minor,
294
+ 'currencyCode', p_intent.currency_code,
295
+ 'externalRef', p_intent.external_ref,
296
+ 'failureReason', p_intent.failure_reason));
297
+ $$;
298
+
299
+ -- Ask for an order to be paid.
300
+ --
301
+ -- The ordering of the first three steps is the contract, not a style choice:
302
+ -- 1. the arguments are validated before anything is read, so a malformed request never takes a
303
+ -- lock;
304
+ -- 2. THE ORDER ROW IS LOCKED, and only then;
305
+ -- 3. the intent is looked for.
306
+ -- Two callers arriving at once serialize on the order row, and the one that loses the race re-reads
307
+ -- a table that now contains the winner's intent -- so a repeated request answers `replayed` and a
308
+ -- second, different request is refused, instead of both passing a read and both inserting.
309
+ --
310
+ -- The amount is not trusted and not derived: it is COMPARED. A caller that asks for a different
311
+ -- number than the order declares is refused, which is the only thing this kernel enforces about
312
+ -- money at the moment of asking for it.
313
+ CREATE FUNCTION public.commerce_open_settlement_intent(
314
+ p_idempotency_key text,
315
+ p_order_id uuid,
316
+ p_channel_key text,
317
+ p_amount_minor bigint,
318
+ p_currency_code text,
319
+ p_metadata jsonb DEFAULT '{}'::jsonb,
320
+ p_requested_at timestamptz DEFAULT now()
321
+ )
322
+ RETURNS jsonb
323
+ LANGUAGE plpgsql
324
+ SET search_path = public, pg_catalog
325
+ AS $$
326
+ DECLARE
327
+ v_order public.commerce_orders%ROWTYPE;
328
+ v_intent public.commerce_settlement_intents%ROWTYPE;
329
+ v_transition public.commerce_settlement_transitions%ROWTYPE;
330
+ BEGIN
331
+ -- Long enough that a caller cannot reach it by accident: an idempotency key short enough to
332
+ -- collide is worse than no key, because it silently suppresses a real second request.
333
+ IF p_idempotency_key IS NULL OR char_length(btrim(p_idempotency_key)) < 8
334
+ OR p_order_id IS NULL
335
+ OR p_channel_key IS NULL OR btrim(p_channel_key) = '' OR char_length(p_channel_key) > 64
336
+ OR p_amount_minor IS NULL OR p_amount_minor < 1
337
+ OR p_currency_code IS NULL OR char_length(p_currency_code) <> 3
338
+ THEN
339
+ RAISE EXCEPTION 'commerce_settlement_open_invalid_input' USING ERRCODE = '22023';
340
+ END IF;
341
+
342
+ SELECT * INTO v_order FROM public.commerce_orders WHERE id = p_order_id FOR UPDATE;
343
+ IF NOT FOUND THEN
344
+ RAISE EXCEPTION 'commerce_settlement_open_order_not_found' USING ERRCODE = '22023';
345
+ END IF;
346
+
347
+ SELECT * INTO v_intent
348
+ FROM public.commerce_settlement_intents WHERE order_id = p_order_id;
349
+
350
+ IF FOUND THEN
351
+ IF v_intent.open_idempotency_key IS DISTINCT FROM p_idempotency_key THEN
352
+ RAISE EXCEPTION 'commerce_settlement_open_order_already_has_intent' USING ERRCODE = '23505';
353
+ END IF;
354
+ SELECT * INTO v_transition
355
+ FROM public.commerce_settlement_transitions
356
+ WHERE intent_id = v_intent.id AND idempotency_key = p_idempotency_key;
357
+ RETURN public.commerce_settlement_response(v_intent, v_transition.id, 'opened', true);
358
+ END IF;
359
+
360
+ IF v_order.status <> 'pending_payment' THEN
361
+ RAISE EXCEPTION 'commerce_settlement_open_order_not_payable' USING ERRCODE = '22023';
362
+ END IF;
363
+
364
+ IF v_order.total_amount_minor IS DISTINCT FROM p_amount_minor
365
+ OR v_order.currency_code IS DISTINCT FROM p_currency_code
366
+ THEN
367
+ RAISE EXCEPTION 'commerce_settlement_open_amount_mismatch' USING ERRCODE = '22023';
368
+ END IF;
369
+
370
+ INSERT INTO public.commerce_settlement_intents
371
+ (order_id, channel_key, status, amount_minor, currency_code, open_idempotency_key,
372
+ created_at, updated_at)
373
+ VALUES (p_order_id, btrim(p_channel_key), 'created', p_amount_minor, p_currency_code,
374
+ p_idempotency_key, p_requested_at, p_requested_at)
375
+ RETURNING * INTO v_intent;
376
+
377
+ INSERT INTO public.commerce_settlement_transitions
378
+ (intent_id, from_status, to_status, outcome, idempotency_key, payload, occurred_at)
379
+ VALUES (v_intent.id, 'absent', 'created', 'opened', p_idempotency_key,
380
+ COALESCE(p_metadata, '{}'::jsonb), p_requested_at)
381
+ RETURNING * INTO v_transition;
382
+
383
+ RETURN public.commerce_settlement_response(v_intent, v_transition.id, 'opened', false);
384
+ END;
385
+ $$;
386
+
387
+ -- Record what the settlement channel answered.
388
+ --
389
+ -- Same discipline as above and for the same reason: validate, LOCK, then consult the ledger for a
390
+ -- replay. Consulting the ledger first would let two callers pass, which is the classic
391
+ -- read-then-write race this function is written to not have. The locks are taken ORDER FIRST and
392
+ -- intent second, in the same sequence the opening function takes them, so a caller opening an
393
+ -- intent and a caller settling one cannot deadlock against each other.
394
+ --
395
+ -- Departure 3 lives in one statement below: an intent that has already been settled refuses a
396
+ -- second settlement outright. A repeated delivery under the SAME key is a replay and is answered
397
+ -- from the ledger; a second, different request against money that has already moved is a bug in the
398
+ -- caller, and answering it would let one order be paid for twice.
399
+ CREATE FUNCTION public.commerce_record_settlement(
400
+ p_idempotency_key text,
401
+ p_intent_id uuid,
402
+ p_outcome text,
403
+ p_external_ref text DEFAULT NULL,
404
+ p_metadata jsonb DEFAULT '{}'::jsonb,
405
+ p_occurred_at timestamptz DEFAULT now()
406
+ )
407
+ RETURNS jsonb
408
+ LANGUAGE plpgsql
409
+ SET search_path = public, pg_catalog
410
+ AS $$
411
+ DECLARE
412
+ v_intent public.commerce_settlement_intents%ROWTYPE;
413
+ v_from text;
414
+ v_transition public.commerce_settlement_transitions%ROWTYPE;
415
+ v_order public.commerce_orders%ROWTYPE;
416
+ v_cycle public.subscription_cycles%ROWTYPE;
417
+ v_subscription public.subscriptions%ROWTYPE;
418
+ BEGIN
419
+ IF p_idempotency_key IS NULL OR char_length(btrim(p_idempotency_key)) < 8
420
+ OR p_intent_id IS NULL
421
+ OR p_outcome IS NULL OR p_outcome NOT IN ('succeeded', 'failed')
422
+ THEN
423
+ RAISE EXCEPTION 'commerce_settlement_record_invalid_input' USING ERRCODE = '22023';
424
+ END IF;
425
+
426
+ SELECT * INTO v_intent
427
+ FROM public.commerce_settlement_intents WHERE id = p_intent_id;
428
+ IF NOT FOUND THEN
429
+ RAISE EXCEPTION 'commerce_settlement_record_intent_not_found' USING ERRCODE = '22023';
430
+ END IF;
431
+
432
+ SELECT * INTO v_order
433
+ FROM public.commerce_orders WHERE id = v_intent.order_id FOR UPDATE;
434
+
435
+ SELECT * INTO v_intent
436
+ FROM public.commerce_settlement_intents WHERE id = p_intent_id FOR UPDATE;
437
+
438
+ SELECT * INTO v_transition
439
+ FROM public.commerce_settlement_transitions
440
+ WHERE intent_id = p_intent_id AND idempotency_key = p_idempotency_key;
441
+ IF FOUND THEN
442
+ RETURN public.commerce_settlement_response(v_intent, v_transition.id, v_transition.outcome, true);
443
+ END IF;
444
+
445
+ IF v_intent.status = 'settled' THEN
446
+ RAISE EXCEPTION 'commerce_settlement_record_intent_already_settled' USING ERRCODE = '22023';
447
+ END IF;
448
+
449
+ -- Write-once, and compared rather than overwritten: a channel that hands back a different handle
450
+ -- for the same intent is describing a different payment.
451
+ IF v_intent.external_ref IS NOT NULL AND p_external_ref IS NOT NULL
452
+ AND v_intent.external_ref IS DISTINCT FROM p_external_ref
453
+ THEN
454
+ RAISE EXCEPTION 'commerce_settlement_record_external_reference_immutable' USING ERRCODE = '22023';
455
+ END IF;
456
+
457
+ v_from := v_intent.status;
458
+
459
+ IF p_outcome = 'succeeded' THEN
460
+ -- An order somebody took off the table between the ask and the answer is not paid into. The
461
+ -- money question then belongs to whoever cancelled it, and this kernel has no refund verb.
462
+ IF v_order.status <> 'pending_payment' THEN
463
+ RAISE EXCEPTION 'commerce_settlement_record_order_not_payable' USING ERRCODE = '22023';
464
+ END IF;
465
+
466
+ UPDATE public.commerce_settlement_intents
467
+ SET status = 'settled',
468
+ settled_at = p_occurred_at,
469
+ external_ref = COALESCE(external_ref, p_external_ref),
470
+ failure_reason = NULL,
471
+ updated_at = p_occurred_at
472
+ WHERE id = p_intent_id
473
+ RETURNING * INTO v_intent;
474
+
475
+ -- The order transition is the delegation onto the order rail: that rail owns `paid`, owns the
476
+ -- notification it produces, and its emitter fires from this statement without this forward
477
+ -- restating a single line of it.
478
+ UPDATE public.commerce_orders
479
+ SET status = 'paid', paid_at = p_occurred_at, updated_at = p_occurred_at
480
+ WHERE id = v_intent.order_id
481
+ RETURNING * INTO v_order;
482
+
483
+ IF v_order.subscription_cycle_id IS NOT NULL THEN
484
+ SELECT * INTO v_cycle
485
+ FROM public.subscription_cycles WHERE id = v_order.subscription_cycle_id FOR UPDATE;
486
+ IF NOT FOUND THEN
487
+ RAISE EXCEPTION 'commerce_settlement_record_cycle_not_found' USING ERRCODE = '22023';
488
+ END IF;
489
+ IF v_cycle.status NOT IN ('planned', 'payment_pending') THEN
490
+ RAISE EXCEPTION 'commerce_settlement_record_cycle_not_payable' USING ERRCODE = '22023';
491
+ END IF;
492
+
493
+ SELECT * INTO v_subscription
494
+ FROM public.subscriptions WHERE id = v_cycle.subscription_id FOR UPDATE;
495
+
496
+ UPDATE public.subscription_cycles
497
+ SET status = 'paid', updated_at = p_occurred_at
498
+ WHERE id = v_cycle.id;
499
+
500
+ -- Departure 8: one cadence past the cycle that was just paid for, not past the moment the
501
+ -- money arrived -- whole cadences forward keep every later date on the original grid. A
502
+ -- subscription nobody is running any more is not slid: settlement truth still applies to a
503
+ -- cancelled subscription, but its schedule is not this function's to revive.
504
+ UPDATE public.subscriptions
505
+ SET next_cycle_at = v_cycle.scheduled_at + (cadence_days || ' days')::interval,
506
+ updated_at = p_occurred_at
507
+ WHERE id = v_subscription.id
508
+ AND status = 'active';
509
+
510
+ INSERT INTO public.subscription_events
511
+ (subscription_id, event_type, idempotency_key, payload, occurred_at)
512
+ VALUES (v_subscription.id, 'subscription.cycle_settled',
513
+ p_idempotency_key || ':subscription',
514
+ jsonb_build_object('cycleId', v_cycle.id, 'orderId', v_order.id,
515
+ 'settlementId', v_intent.id),
516
+ p_occurred_at)
517
+ ON CONFLICT (subscription_id, idempotency_key) DO NOTHING;
518
+ END IF;
519
+ ELSE
520
+ -- Departure 4: the order is untouched, so it stays `pending_payment` and can be settled again.
521
+ UPDATE public.commerce_settlement_intents
522
+ SET status = 'failed',
523
+ external_ref = COALESCE(external_ref, p_external_ref),
524
+ failure_reason = COALESCE(NULLIF(btrim(COALESCE(p_metadata, '{}'::jsonb)->>'reason'), ''),
525
+ 'declined'),
526
+ updated_at = p_occurred_at
527
+ WHERE id = p_intent_id
528
+ RETURNING * INTO v_intent;
529
+ END IF;
530
+
531
+ INSERT INTO public.commerce_settlement_transitions
532
+ (intent_id, from_status, to_status, outcome, idempotency_key, payload, occurred_at)
533
+ VALUES (p_intent_id, v_from, v_intent.status, p_outcome, p_idempotency_key,
534
+ COALESCE(p_metadata, '{}'::jsonb), p_occurred_at)
535
+ RETURNING * INTO v_transition;
536
+
537
+ RETURN public.commerce_settlement_response(v_intent, v_transition.id, p_outcome, false);
538
+ END;
539
+ $$;
540
+
541
+ -- THE SETTLEMENT NOTIFICATION, and it is this capability's own rather than the order rail's. The
542
+ -- order rail already says "this order is paid"; what only this rail can say is "the money for it
543
+ -- was settled through the channel that was asked", and a capability that owns a notification owns
544
+ -- its own enqueue.
545
+ --
546
+ -- The barrier lives in the trigger's WHEN clause rather than in an early RETURN inside the body: a
547
+ -- condition the planner evaluates before the function is entered cannot be reached by a statement
548
+ -- that did not change what it claims to have changed. The emitter is idempotent on
549
+ -- (event type, key) as well, so a replayed settlement cannot double-notify.
550
+ CREATE FUNCTION public.commerce_settlement_emit_settled_notification()
551
+ RETURNS trigger
552
+ LANGUAGE plpgsql
553
+ SET search_path = public, pg_catalog
554
+ AS $$
555
+ BEGIN
556
+ INSERT INTO public.outbox_events
557
+ (aggregate_type, aggregate_id, event_type, idempotency_key, payload, metadata)
558
+ VALUES ('commerce_order', NEW.order_id, 'commerce.settlement.settled',
559
+ 'settlement_settled:' || NEW.id::text,
560
+ jsonb_build_object('orderUuid', NEW.order_id, 'settlementUuid', NEW.id,
561
+ 'amountMinor', NEW.amount_minor, 'currencyCode', NEW.currency_code,
562
+ 'occurredAt', NEW.settled_at),
563
+ jsonb_build_object('source', 'commerce_settlement_emit_settled_notification'))
564
+ ON CONFLICT (event_type, idempotency_key) DO NOTHING;
565
+ RETURN NULL;
566
+ END;
567
+ $$;
568
+
569
+ CREATE TRIGGER trg_commerce_settlement_emit_settled
570
+ AFTER UPDATE OF status ON public.commerce_settlement_intents
571
+ FOR EACH ROW
572
+ WHEN (NEW.status = 'settled' AND OLD.status IS DISTINCT FROM 'settled'
573
+ AND NEW.settled_at IS NOT NULL)
574
+ EXECUTE FUNCTION public.commerce_settlement_emit_settled_notification();