@openlup/platform 0.21.0 → 0.23.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 (349) hide show
  1. package/CHANGELOG.md +432 -146
  2. package/README.md +82 -589
  3. package/dist/composition/composeBundle.d.ts +7 -0
  4. package/dist/composition/composeBundle.js +2 -0
  5. package/dist/composition/fulfillmentContribution.d.ts +21 -0
  6. package/dist/composition/fulfillmentContribution.js +19 -0
  7. package/dist/composition/index.d.ts +1 -0
  8. package/dist/composition/index.js +1 -0
  9. package/dist/fulfillment/adapterConformance.d.ts +3 -0
  10. package/dist/fulfillment/adapterConformance.js +223 -0
  11. package/dist/fulfillment/bindings.d.ts +3 -0
  12. package/dist/fulfillment/bindings.js +29 -0
  13. package/dist/fulfillment/commerceFulfillmentHandlers.d.ts +29 -0
  14. package/dist/fulfillment/commerceFulfillmentHandlers.js +184 -0
  15. package/dist/fulfillment/commerceFulfillmentReadModel.d.ts +86 -0
  16. package/dist/fulfillment/commerceFulfillmentReadModel.js +131 -0
  17. package/dist/fulfillment/contracts/alignment.d.ts +25 -0
  18. package/dist/fulfillment/contracts/alignment.js +1 -0
  19. package/dist/fulfillment/contracts/bindings.d.ts +51 -0
  20. package/dist/fulfillment/contracts/bindings.js +1 -0
  21. package/dist/fulfillment/contracts/capabilities.d.ts +29 -0
  22. package/dist/fulfillment/contracts/capabilities.js +1 -0
  23. package/dist/fulfillment/contracts/conformance.d.ts +95 -0
  24. package/dist/fulfillment/contracts/conformance.js +1 -0
  25. package/dist/fulfillment/contracts/customerAlignment.d.ts +6 -0
  26. package/dist/fulfillment/contracts/customerAlignment.js +1 -0
  27. package/dist/fulfillment/contracts/dispatch.d.ts +143 -0
  28. package/dist/fulfillment/contracts/dispatch.js +1 -0
  29. package/dist/fulfillment/contracts/index.d.ts +18 -0
  30. package/dist/fulfillment/contracts/index.js +1 -0
  31. package/dist/fulfillment/contracts/label.d.ts +35 -0
  32. package/dist/fulfillment/contracts/label.js +1 -0
  33. package/dist/fulfillment/contracts/nativeUnsupported.d.ts +6 -0
  34. package/dist/fulfillment/contracts/nativeUnsupported.js +1 -0
  35. package/dist/fulfillment/contracts/observation.d.ts +72 -0
  36. package/dist/fulfillment/contracts/observation.js +1 -0
  37. package/dist/fulfillment/contracts/operator.d.ts +64 -0
  38. package/dist/fulfillment/contracts/operator.js +1 -0
  39. package/dist/fulfillment/contracts/read.d.ts +110 -0
  40. package/dist/fulfillment/contracts/read.js +1 -0
  41. package/dist/fulfillment/contracts/readback.d.ts +59 -0
  42. package/dist/fulfillment/contracts/readback.js +1 -0
  43. package/dist/fulfillment/contracts/replacement.d.ts +34 -0
  44. package/dist/fulfillment/contracts/replacement.js +1 -0
  45. package/dist/fulfillment/contracts/store.d.ts +89 -0
  46. package/dist/fulfillment/contracts/store.js +1 -0
  47. package/dist/fulfillment/contracts/sync.d.ts +226 -0
  48. package/dist/fulfillment/contracts/sync.js +1 -0
  49. package/dist/fulfillment/currentFulfillmentParcel.d.ts +2 -0
  50. package/dist/fulfillment/currentFulfillmentParcel.js +1 -0
  51. package/dist/fulfillment/delayReadModel.d.ts +11 -0
  52. package/dist/fulfillment/delayReadModel.js +45 -0
  53. package/dist/fulfillment/deliveryAlignmentReadModel.d.ts +4 -0
  54. package/dist/fulfillment/deliveryAlignmentReadModel.js +12 -0
  55. package/dist/fulfillment/dispatchProjection.d.ts +26 -0
  56. package/dist/fulfillment/dispatchProjection.js +107 -0
  57. package/dist/fulfillment/fulfillmentObservationEffects.d.ts +66 -0
  58. package/dist/fulfillment/fulfillmentObservationEffects.js +36 -0
  59. package/dist/fulfillment/handlerSchemas.d.ts +647 -0
  60. package/dist/fulfillment/handlerSchemas.js +213 -0
  61. package/dist/fulfillment/handlerSupport.d.ts +33 -0
  62. package/dist/fulfillment/handlerSupport.js +25 -0
  63. package/dist/fulfillment/index.d.ts +38 -0
  64. package/dist/fulfillment/index.js +37 -0
  65. package/dist/fulfillment/labelLifecycle.d.ts +24 -0
  66. package/dist/fulfillment/labelLifecycle.js +9 -0
  67. package/dist/fulfillment/lowStockEvidenceHandler.d.ts +36 -0
  68. package/dist/fulfillment/lowStockEvidenceHandler.js +46 -0
  69. package/dist/fulfillment/migrations.d.ts +32 -0
  70. package/dist/fulfillment/migrations.js +9 -0
  71. package/dist/fulfillment/nativeUnsupported.d.ts +10 -0
  72. package/dist/fulfillment/nativeUnsupported.js +18 -0
  73. package/dist/fulfillment/normalizedFulfillmentFacts.d.ts +2 -0
  74. package/dist/fulfillment/normalizedFulfillmentFacts.js +1 -0
  75. package/dist/fulfillment/observationProjection.d.ts +32 -0
  76. package/dist/fulfillment/observationProjection.js +90 -0
  77. package/dist/fulfillment/omnipack/capabilities.d.ts +3 -0
  78. package/dist/fulfillment/omnipack/capabilities.js +16 -0
  79. package/dist/fulfillment/omnipack/client.d.ts +39 -0
  80. package/dist/fulfillment/omnipack/client.js +172 -0
  81. package/dist/fulfillment/omnipack/index.d.ts +55 -0
  82. package/dist/fulfillment/omnipack/index.js +408 -0
  83. package/dist/fulfillment/omnipack/mappers.d.ts +79 -0
  84. package/dist/fulfillment/omnipack/mappers.js +199 -0
  85. package/dist/fulfillment/omnipack/observation.d.ts +28 -0
  86. package/dist/fulfillment/omnipack/observation.js +72 -0
  87. package/dist/fulfillment/omnipack/omnipackDispatchPayload.d.ts +36 -0
  88. package/dist/fulfillment/omnipack/omnipackDispatchPayload.js +63 -0
  89. package/dist/fulfillment/omnipack/omnipackOutboundOrderPayload.d.ts +83 -0
  90. package/dist/fulfillment/omnipack/omnipackOutboundOrderPayload.js +113 -0
  91. package/dist/fulfillment/omnipack/omnipackProductPackPayload.d.ts +11 -0
  92. package/dist/fulfillment/omnipack/omnipackProductPackPayload.js +38 -0
  93. package/dist/fulfillment/omnipack/omnipackProductPayload.d.ts +18 -0
  94. package/dist/fulfillment/omnipack/omnipackProductPayload.js +51 -0
  95. package/dist/fulfillment/omnipack/omnipackReconciliationPayload.d.ts +1 -0
  96. package/dist/fulfillment/omnipack/omnipackReconciliationPayload.js +3 -0
  97. package/dist/fulfillment/omnipack/omnipackStatusVocabulary.d.ts +4 -0
  98. package/dist/fulfillment/omnipack/omnipackStatusVocabulary.js +87 -0
  99. package/dist/fulfillment/omnipack/omnipackWebhookPayload.d.ts +9 -0
  100. package/dist/fulfillment/omnipack/omnipackWebhookPayload.js +76 -0
  101. package/dist/fulfillment/omnipack/productSyncMappers.d.ts +18 -0
  102. package/dist/fulfillment/omnipack/productSyncMappers.js +43 -0
  103. package/dist/fulfillment/omnipack/providerError.d.ts +25 -0
  104. package/dist/fulfillment/omnipack/providerError.js +47 -0
  105. package/dist/fulfillment/omnipack/reconciliation.d.ts +15 -0
  106. package/dist/fulfillment/omnipack/reconciliation.js +55 -0
  107. package/dist/fulfillment/omnipack/trackingReferences.d.ts +16 -0
  108. package/dist/fulfillment/omnipack/trackingReferences.js +52 -0
  109. package/dist/fulfillment/paidFulfillmentRecovery.d.ts +67 -0
  110. package/dist/fulfillment/paidFulfillmentRecovery.js +154 -0
  111. package/dist/fulfillment/paidHandoff.d.ts +27 -0
  112. package/dist/fulfillment/paidHandoff.js +86 -0
  113. package/dist/fulfillment/paidHandoffPorts.d.ts +24 -0
  114. package/dist/fulfillment/paidHandoffPorts.js +3 -0
  115. package/dist/fulfillment/postgresDeliveryAlignment.d.ts +4 -0
  116. package/dist/fulfillment/postgresDeliveryAlignment.js +12 -0
  117. package/dist/fulfillment/postgresDispatchStore.d.ts +16 -0
  118. package/dist/fulfillment/postgresDispatchStore.js +44 -0
  119. package/dist/fulfillment/postgresEvidenceReadPort.d.ts +25 -0
  120. package/dist/fulfillment/postgresEvidenceReadPort.js +45 -0
  121. package/dist/fulfillment/postgresPaidFulfillmentRecovery.d.ts +25 -0
  122. package/dist/fulfillment/postgresPaidFulfillmentRecovery.js +26 -0
  123. package/dist/fulfillment/postgresPaidHandoffStore.d.ts +4 -0
  124. package/dist/fulfillment/postgresPaidHandoffStore.js +10 -0
  125. package/dist/fulfillment/postgresShipmentSpine.d.ts +70 -0
  126. package/dist/fulfillment/postgresShipmentSpine.js +246 -0
  127. package/dist/fulfillment/postgresStockSyncPort.d.ts +8 -0
  128. package/dist/fulfillment/postgresStockSyncPort.js +66 -0
  129. package/dist/fulfillment/productReconciliationEvidenceHandler.d.ts +36 -0
  130. package/dist/fulfillment/productReconciliationEvidenceHandler.js +50 -0
  131. package/dist/fulfillment/providerCapabilities.d.ts +9 -0
  132. package/dist/fulfillment/providerCapabilities.js +17 -0
  133. package/dist/fulfillment/providerCommandLedger.d.ts +143 -0
  134. package/dist/fulfillment/providerCommandLedger.js +14 -0
  135. package/dist/fulfillment/providerDispatchAcceptance.d.ts +27 -0
  136. package/dist/fulfillment/providerDispatchAcceptance.js +29 -0
  137. package/dist/fulfillment/providerDispatchError.d.ts +6 -0
  138. package/dist/fulfillment/providerDispatchError.js +5 -0
  139. package/dist/fulfillment/providerDispatchWorker.d.ts +44 -0
  140. package/dist/fulfillment/providerDispatchWorker.js +199 -0
  141. package/dist/fulfillment/providerProductSyncWorker.d.ts +28 -0
  142. package/dist/fulfillment/providerProductSyncWorker.js +122 -0
  143. package/dist/fulfillment/providerReconciliationError.d.ts +9 -0
  144. package/dist/fulfillment/providerReconciliationError.js +17 -0
  145. package/dist/fulfillment/providerReconciliationTerminalSkip.d.ts +2 -0
  146. package/dist/fulfillment/providerReconciliationTerminalSkip.js +6 -0
  147. package/dist/fulfillment/providerReconciliationTimestamp.d.ts +1 -0
  148. package/dist/fulfillment/providerReconciliationTimestamp.js +27 -0
  149. package/dist/fulfillment/providerReconciliationWorker.d.ts +90 -0
  150. package/dist/fulfillment/providerReconciliationWorker.js +207 -0
  151. package/dist/fulfillment/providerStockAvailability.d.ts +13 -0
  152. package/dist/fulfillment/providerStockAvailability.js +51 -0
  153. package/dist/fulfillment/providerStockSyncWorker.d.ts +41 -0
  154. package/dist/fulfillment/providerStockSyncWorker.js +295 -0
  155. package/dist/fulfillment/providerTrackingReferences.d.ts +16 -0
  156. package/dist/fulfillment/providerTrackingReferences.js +23 -0
  157. package/dist/fulfillment/replacementRefusals.d.ts +5 -0
  158. package/dist/fulfillment/replacementRefusals.js +38 -0
  159. package/dist/fulfillment/replacementShipmentHandler.d.ts +5 -0
  160. package/dist/fulfillment/replacementShipmentHandler.js +46 -0
  161. package/dist/fulfillment/schemaCapabilities.d.ts +13 -0
  162. package/dist/fulfillment/schemaCapabilities.js +7 -0
  163. package/dist/fulfillment/shipmentsOverviewHandler.d.ts +16 -0
  164. package/dist/fulfillment/shipmentsOverviewHandler.js +54 -0
  165. package/dist/fulfillment/statusMap.d.ts +2 -0
  166. package/dist/fulfillment/statusMap.js +1 -0
  167. package/dist/fulfillment/trackingProgress.d.ts +1 -0
  168. package/dist/fulfillment/trackingProgress.js +1 -0
  169. package/dist/fulfillment/verifiedIngress.d.ts +79 -0
  170. package/dist/fulfillment/verifiedIngress.js +94 -0
  171. package/dist/invoicing/capabilities.d.ts +11 -0
  172. package/dist/invoicing/capabilities.js +10 -0
  173. package/dist/invoicing/contracts/capabilities.d.ts +40 -0
  174. package/dist/invoicing/contracts/capabilities.js +1 -0
  175. package/dist/invoicing/contracts/companyLookup.d.ts +49 -0
  176. package/dist/invoicing/contracts/companyLookup.js +1 -0
  177. package/dist/invoicing/contracts/delivery.d.ts +74 -0
  178. package/dist/invoicing/contracts/delivery.js +1 -0
  179. package/dist/invoicing/contracts/facts.d.ts +100 -0
  180. package/dist/invoicing/contracts/facts.js +1 -0
  181. package/dist/invoicing/contracts/host.d.ts +246 -0
  182. package/dist/invoicing/contracts/host.js +1 -0
  183. package/dist/invoicing/contracts/index.d.ts +16 -0
  184. package/dist/invoicing/contracts/index.js +1 -0
  185. package/dist/invoicing/contracts/provider.d.ts +126 -0
  186. package/dist/invoicing/contracts/provider.js +1 -0
  187. package/dist/invoicing/contracts/settlement.d.ts +66 -0
  188. package/dist/invoicing/contracts/settlement.js +1 -0
  189. package/dist/invoicing/contracts/store.d.ts +267 -0
  190. package/dist/invoicing/contracts/store.js +1 -0
  191. package/dist/invoicing/index.d.ts +1 -0
  192. package/dist/invoicing/index.js +1 -0
  193. package/dist/payments/stripe/index.d.ts +25 -0
  194. package/dist/payments/stripe/index.js +25 -0
  195. package/dist/payments/tpay/index.d.ts +30 -0
  196. package/dist/payments/tpay/index.js +30 -0
  197. package/dist/subscriptions/automaticRenewal.d.ts +44 -0
  198. package/dist/subscriptions/automaticRenewal.js +81 -0
  199. package/dist/subscriptions/automaticRenewalExecution.d.ts +21 -0
  200. package/dist/subscriptions/automaticRenewalExecution.js +44 -0
  201. package/dist/subscriptions/contracts/commands.d.ts +115 -0
  202. package/dist/subscriptions/contracts/commands.js +1 -0
  203. package/dist/subscriptions/contracts/dunning.d.ts +133 -0
  204. package/dist/subscriptions/contracts/dunning.js +1 -0
  205. package/dist/subscriptions/contracts/index.d.ts +6 -0
  206. package/dist/subscriptions/contracts/index.js +1 -0
  207. package/dist/subscriptions/contracts/messages.d.ts +43 -0
  208. package/dist/subscriptions/contracts/messages.js +1 -0
  209. package/dist/subscriptions/contracts/renewal.d.ts +533 -0
  210. package/dist/subscriptions/contracts/renewal.js +1 -0
  211. package/dist/subscriptions/contracts/stockWait.d.ts +73 -0
  212. package/dist/subscriptions/contracts/stockWait.js +1 -0
  213. package/dist/subscriptions/index.d.ts +39 -0
  214. package/dist/subscriptions/index.js +17 -0
  215. package/dist/subscriptions/paymentOutcomes.d.ts +8 -0
  216. package/dist/subscriptions/paymentOutcomes.js +27 -0
  217. package/dist/subscriptions/postgresRenewalStore.d.ts +20 -0
  218. package/dist/subscriptions/postgresRenewalStore.js +61 -0
  219. package/dist/subscriptions/postgresRetryStore.d.ts +78 -0
  220. package/dist/subscriptions/postgresRetryStore.js +142 -0
  221. package/dist/subscriptions/renewalCycleFailureMechanism.d.ts +34 -0
  222. package/dist/subscriptions/renewalCycleFailureMechanism.js +79 -0
  223. package/dist/subscriptions/renewalCycleMechanism.d.ts +46 -0
  224. package/dist/subscriptions/renewalCycleMechanism.js +157 -0
  225. package/dist/subscriptions/renewalCycleRefusalMechanism.d.ts +41 -0
  226. package/dist/subscriptions/renewalCycleRefusalMechanism.js +160 -0
  227. package/dist/subscriptions/renewalIdentity.d.ts +3 -0
  228. package/dist/subscriptions/renewalIdentity.js +4 -0
  229. package/package.json +37 -2
  230. package/release-gates.json +97 -3
  231. package/sql/20260808210000_fulfillment_shipment_rail.sql +810 -0
  232. package/sql/20260819150000_fulfillment_shipment_sequence.sql +98 -0
  233. package/sql/20261022080000_fulfillment_command_ledger.sql +786 -0
  234. package/sql/20261022090000_fulfillment_delivery_alignment.sql +1106 -0
  235. package/sql/20261022160000_subscription_retry_lifecycle.sql +328 -0
  236. package/sql/20261022165000_fulfillment_stock_evidence_replay_reopen.sql +30 -0
  237. package/src/composition/composeBundle.ts +4 -0
  238. package/src/composition/fulfillmentContribution.ts +28 -0
  239. package/src/composition/index.ts +1 -0
  240. package/src/fulfillment/adapterConformance.ts +195 -0
  241. package/src/fulfillment/bindings.ts +26 -0
  242. package/src/fulfillment/commerceFulfillmentHandlers.ts +263 -0
  243. package/src/fulfillment/commerceFulfillmentReadModel.ts +257 -0
  244. package/src/fulfillment/contracts/alignment.ts +13 -0
  245. package/src/fulfillment/contracts/bindings.ts +30 -0
  246. package/src/fulfillment/contracts/capabilities.ts +15 -0
  247. package/src/fulfillment/contracts/conformance.ts +63 -0
  248. package/src/fulfillment/contracts/customerAlignment.ts +4 -0
  249. package/src/fulfillment/contracts/dispatch.ts +73 -0
  250. package/src/fulfillment/contracts/index.ts +18 -0
  251. package/src/fulfillment/contracts/label.ts +9 -0
  252. package/src/fulfillment/contracts/nativeUnsupported.ts +6 -0
  253. package/src/fulfillment/contracts/observation.ts +26 -0
  254. package/src/fulfillment/contracts/operator.ts +31 -0
  255. package/src/fulfillment/contracts/read.ts +42 -0
  256. package/src/fulfillment/contracts/readback.ts +24 -0
  257. package/src/fulfillment/contracts/replacement.ts +15 -0
  258. package/src/fulfillment/contracts/store.ts +27 -0
  259. package/src/fulfillment/contracts/sync.ts +72 -0
  260. package/src/fulfillment/currentFulfillmentParcel.ts +2 -0
  261. package/src/fulfillment/delayReadModel.ts +60 -0
  262. package/src/fulfillment/deliveryAlignmentReadModel.ts +15 -0
  263. package/src/fulfillment/dispatchProjection.ts +105 -0
  264. package/src/fulfillment/fulfillmentObservationEffects.ts +86 -0
  265. package/src/fulfillment/handlerSchemas.ts +255 -0
  266. package/src/fulfillment/handlerSupport.ts +25 -0
  267. package/src/fulfillment/index.ts +39 -0
  268. package/src/fulfillment/labelLifecycle.ts +11 -0
  269. package/src/fulfillment/lowStockEvidenceHandler.ts +60 -0
  270. package/src/fulfillment/migrations.ts +9 -0
  271. package/src/fulfillment/nativeUnsupported.ts +23 -0
  272. package/src/fulfillment/normalizedFulfillmentFacts.ts +2 -0
  273. package/src/fulfillment/observationProjection.ts +92 -0
  274. package/src/fulfillment/omnipack/capabilities.ts +18 -0
  275. package/src/fulfillment/omnipack/client.ts +270 -0
  276. package/src/fulfillment/omnipack/index.ts +369 -0
  277. package/src/fulfillment/omnipack/mappers.ts +282 -0
  278. package/src/fulfillment/omnipack/observation.ts +79 -0
  279. package/src/fulfillment/omnipack/omnipackDispatchPayload.ts +93 -0
  280. package/src/fulfillment/omnipack/omnipackOutboundOrderPayload.ts +194 -0
  281. package/src/fulfillment/omnipack/omnipackProductPackPayload.ts +47 -0
  282. package/src/fulfillment/omnipack/omnipackProductPayload.ts +67 -0
  283. package/src/fulfillment/omnipack/omnipackReconciliationPayload.ts +7 -0
  284. package/src/fulfillment/omnipack/omnipackStatusVocabulary.ts +103 -0
  285. package/src/fulfillment/omnipack/omnipackWebhookPayload.ts +73 -0
  286. package/src/fulfillment/omnipack/productSyncMappers.ts +74 -0
  287. package/src/fulfillment/omnipack/providerError.ts +64 -0
  288. package/src/fulfillment/omnipack/reconciliation.ts +56 -0
  289. package/src/fulfillment/omnipack/trackingReferences.ts +77 -0
  290. package/src/fulfillment/paidFulfillmentRecovery.ts +252 -0
  291. package/src/fulfillment/paidHandoff.ts +122 -0
  292. package/src/fulfillment/paidHandoffPorts.ts +21 -0
  293. package/src/fulfillment/postgresDeliveryAlignment.ts +14 -0
  294. package/src/fulfillment/postgresDispatchStore.ts +55 -0
  295. package/src/fulfillment/postgresEvidenceReadPort.ts +59 -0
  296. package/src/fulfillment/postgresPaidFulfillmentRecovery.ts +29 -0
  297. package/src/fulfillment/postgresPaidHandoffStore.ts +11 -0
  298. package/src/fulfillment/postgresShipmentSpine.ts +338 -0
  299. package/src/fulfillment/postgresStockSyncPort.ts +68 -0
  300. package/src/fulfillment/productReconciliationEvidenceHandler.ts +64 -0
  301. package/src/fulfillment/providerCapabilities.ts +18 -0
  302. package/src/fulfillment/providerCommandLedger.ts +53 -0
  303. package/src/fulfillment/providerDispatchAcceptance.ts +37 -0
  304. package/src/fulfillment/providerDispatchError.ts +7 -0
  305. package/src/fulfillment/providerDispatchWorker.ts +159 -0
  306. package/src/fulfillment/providerProductSyncWorker.ts +154 -0
  307. package/src/fulfillment/providerReconciliationError.ts +20 -0
  308. package/src/fulfillment/providerReconciliationTerminalSkip.ts +8 -0
  309. package/src/fulfillment/providerReconciliationTimestamp.ts +34 -0
  310. package/src/fulfillment/providerReconciliationWorker.ts +206 -0
  311. package/src/fulfillment/providerStockAvailability.ts +60 -0
  312. package/src/fulfillment/providerStockSyncWorker.ts +318 -0
  313. package/src/fulfillment/providerTrackingReferences.ts +32 -0
  314. package/src/fulfillment/replacementRefusals.ts +43 -0
  315. package/src/fulfillment/replacementShipmentHandler.ts +47 -0
  316. package/src/fulfillment/schemaCapabilities.ts +7 -0
  317. package/src/fulfillment/shipmentsOverviewHandler.ts +73 -0
  318. package/src/fulfillment/statusMap.ts +2 -0
  319. package/src/fulfillment/trackingProgress.ts +1 -0
  320. package/src/fulfillment/verifiedIngress.ts +113 -0
  321. package/src/invoicing/capabilities.ts +9 -0
  322. package/src/invoicing/contracts/capabilities.ts +15 -0
  323. package/src/invoicing/contracts/companyLookup.ts +13 -0
  324. package/src/invoicing/contracts/delivery.ts +24 -0
  325. package/src/invoicing/contracts/facts.ts +60 -0
  326. package/src/invoicing/contracts/host.ts +85 -0
  327. package/src/invoicing/contracts/index.ts +16 -0
  328. package/src/invoicing/contracts/provider.ts +78 -0
  329. package/src/invoicing/contracts/settlement.ts +39 -0
  330. package/src/invoicing/contracts/store.ts +98 -0
  331. package/src/invoicing/index.ts +1 -0
  332. package/src/payments/stripe/index.ts +30 -0
  333. package/src/payments/tpay/index.ts +36 -0
  334. package/src/subscriptions/automaticRenewal.ts +87 -0
  335. package/src/subscriptions/automaticRenewalExecution.ts +39 -0
  336. package/src/subscriptions/contracts/commands.ts +46 -0
  337. package/src/subscriptions/contracts/dunning.ts +48 -0
  338. package/src/subscriptions/contracts/index.ts +6 -0
  339. package/src/subscriptions/contracts/messages.ts +18 -0
  340. package/src/subscriptions/contracts/renewal.ts +325 -0
  341. package/src/subscriptions/contracts/stockWait.ts +34 -0
  342. package/src/subscriptions/index.ts +24 -0
  343. package/src/subscriptions/paymentOutcomes.ts +23 -0
  344. package/src/subscriptions/postgresRenewalStore.ts +76 -0
  345. package/src/subscriptions/postgresRetryStore.ts +174 -0
  346. package/src/subscriptions/renewalCycleFailureMechanism.ts +119 -0
  347. package/src/subscriptions/renewalCycleMechanism.ts +225 -0
  348. package/src/subscriptions/renewalCycleRefusalMechanism.ts +215 -0
  349. package/src/subscriptions/renewalIdentity.ts +5 -0
@@ -0,0 +1,1106 @@
1
+ -- D13 native translation of the effective managed delivery-alignment rail.
2
+ -- New invoker objects only; default off, deployment owner execution, no browser grants.
3
+ -- Existing history, payment lifecycle and managed graph remain unchanged.
4
+ -- Donor activation cycle 1 ↔ native activation ordinal 0;
5
+ -- donor renewal n+1 ↔ native renewal n (renewal operation numbers remain >= 1).
6
+ -- Activation requires no renewal operation and its active activation-order link.
7
+ -- Other unnumbered cycles remain unordered: paid ones refuse due admission for
8
+ -- manual review; any other unordered cycle prevents automatic arrival alignment.
9
+ -- D13 native translation note: only NEW cycle admission/INSERT is guarded.
10
+ -- Existing-cycle renewal/dunning retries bypass admission and one-use receipts.
11
+ -- confirm_replacement remains a note-only operator attestation. Never invoke it
12
+ -- for a replacement that did not arrive. No automatic caller is installed.
13
+ -- Roll back compatible code or turn new cases off; repair accepted effects forward.
14
+
15
+ CREATE TABLE public.subscription_delivery_alignment_control (
16
+ singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton),
17
+ mode text NOT NULL DEFAULT 'off'
18
+ CHECK (mode IN ('off', 'shadow', 'protect', 'auto_align')),
19
+ updated_at timestamptz NOT NULL DEFAULT now()
20
+ );
21
+
22
+ INSERT INTO public.subscription_delivery_alignment_control (singleton, mode)
23
+ VALUES (true, 'off')
24
+ ON CONFLICT (singleton) DO NOTHING;
25
+
26
+ COMMENT ON TABLE public.subscription_delivery_alignment_control IS
27
+ 'Singleton DB-side rollout control for new delivery-alignment cases. Default off. Existing protected cases retain their captured authority after this row changes.';
28
+
29
+ CREATE TABLE public.subscription_delivery_alignment_cases (
30
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
31
+ shipment_id uuid REFERENCES public.fulfillment_shipments(id) ON DELETE CASCADE,
32
+ predecessor_order_id uuid NOT NULL REFERENCES public.commerce_orders(id) ON DELETE CASCADE,
33
+ subscription_id uuid NOT NULL REFERENCES public.subscriptions(id) ON DELETE CASCADE,
34
+ predecessor_cycle_id uuid NOT NULL REFERENCES public.subscription_cycles(id) ON DELETE CASCADE,
35
+ predecessor_cycle_number integer CHECK (predecessor_cycle_number >= 0),
36
+ captured_mode text NOT NULL CHECK (captured_mode IN ('shadow', 'protect', 'auto_align')),
37
+ state text NOT NULL CHECK (state IN ('shadow', 'protected', 'aligned', 'released', 'manual_review')),
38
+ evidence_kind text NOT NULL CHECK (evidence_kind IN ('fulfillment_exception', 'overdue_renewal', 'missing_fulfillment')),
39
+ observed_next_cycle_at timestamptz,
40
+ delivered_at timestamptz,
41
+ aligned_next_cycle_at timestamptz,
42
+ manual_review_reason text,
43
+ created_at timestamptz NOT NULL DEFAULT now(),
44
+ updated_at timestamptz NOT NULL DEFAULT now(),
45
+ aligned_at timestamptz,
46
+ metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
47
+ CHECK (
48
+ (state = 'manual_review' AND manual_review_reason IS NOT NULL)
49
+ OR state <> 'manual_review'
50
+ ),
51
+ CHECK (
52
+ (state = 'aligned' AND aligned_next_cycle_at IS NOT NULL AND aligned_at IS NOT NULL)
53
+ OR state <> 'aligned'
54
+ )
55
+ );
56
+
57
+ -- An allowed renewal needs a durable, one-time proof that the same schedule
58
+ -- passed admission in an enforcing mode. A rejected INSERT cannot retain a new
59
+ -- protected case (the transaction rolls it back), so this receipt is the safe
60
+ -- DB boundary for callers that otherwise bypass the service preflight.
61
+ CREATE TABLE public.subscription_delivery_alignment_admission_receipts (
62
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
63
+ subscription_id uuid NOT NULL REFERENCES public.subscriptions(id) ON DELETE CASCADE,
64
+ scheduled_at timestamptz NOT NULL,
65
+ observed_next_cycle_at timestamptz NOT NULL,
66
+ decision text NOT NULL DEFAULT 'allowed' CHECK (decision = 'allowed'),
67
+ source text NOT NULL CHECK (source = 'renewal_admission'),
68
+ admitted_at timestamptz NOT NULL DEFAULT now(),
69
+ consumed_at timestamptz,
70
+ metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
71
+ UNIQUE (subscription_id, scheduled_at),
72
+ CHECK (consumed_at IS NULL OR consumed_at >= admitted_at)
73
+ );
74
+
75
+
76
+
77
+ -- The ordinary shape is exactly one case per fulfillment order. The nullable
78
+ -- fallback is intentionally only for the fail-closed "paid but no fulfillment
79
+ -- row exists" corruption/manual-review case; it is unique per predecessor order
80
+ -- and can never stand in for two physical fulfillment records.
81
+ CREATE UNIQUE INDEX subscription_delivery_alignment_case_fulfillment_key
82
+ ON public.subscription_delivery_alignment_cases (shipment_id)
83
+ WHERE shipment_id IS NOT NULL;
84
+ CREATE UNIQUE INDEX subscription_delivery_alignment_case_missing_fulfillment_key
85
+ ON public.subscription_delivery_alignment_cases (predecessor_order_id)
86
+ WHERE shipment_id IS NULL;
87
+ CREATE INDEX idx_subscription_delivery_alignment_cases_blocking
88
+ ON public.subscription_delivery_alignment_cases (subscription_id, state, predecessor_cycle_number)
89
+ WHERE state IN ('protected', 'manual_review');
90
+ CREATE INDEX idx_subscription_delivery_alignment_cases_order
91
+ ON public.subscription_delivery_alignment_cases (predecessor_order_id, shipment_id);
92
+
93
+ COMMENT ON TABLE public.subscription_delivery_alignment_cases IS
94
+ 'Durable delivery-alignment ledger. Customer mapping exposes only protected and aligned. shadow, released and manual_review remain operator/server facts.';
95
+ COMMENT ON COLUMN public.subscription_delivery_alignment_cases.delivered_at IS
96
+ 'Trusted durable fulfillment chronology copied only from fulfillment_shipments.delivered_at; status text or outbox occurredAt never qualifies.';
97
+ COMMENT ON TABLE public.subscription_delivery_alignment_admission_receipts IS
98
+ 'One-time, schedule-bound authorization consumed by a protecting-mode payment_pending cycle insert. Created only by a successful due renewal admission.';
99
+
100
+ CREATE VIEW public.subscription_delivery_alignment_cycles AS
101
+ SELECT cycle.*,
102
+ CASE
103
+ WHEN operation.cycle_number >= 1 THEN operation.cycle_number
104
+ WHEN cycle.renewal_operation_id IS NULL AND EXISTS (
105
+ SELECT 1 FROM public.subscription_activations activation
106
+ WHERE activation.state = 'active'
107
+ AND activation.subscription_id = cycle.subscription_id
108
+ AND activation.order_id = order_row.id
109
+ ) THEN 0
110
+ ELSE NULL
111
+ END AS cycle_number,
112
+ order_row.id AS order_id
113
+ FROM public.subscription_cycles cycle
114
+ LEFT JOIN public.subscription_renewal_operations operation ON operation.id = cycle.renewal_operation_id
115
+ LEFT JOIN public.commerce_orders order_row ON order_row.subscription_cycle_id = cycle.id;
116
+ CREATE FUNCTION public.subscription_delivery_alignment_get_mode()
117
+ RETURNS text
118
+ LANGUAGE sql
119
+ SECURITY INVOKER
120
+ SET search_path = pg_catalog
121
+ AS $$
122
+ SELECT mode
123
+ FROM public.subscription_delivery_alignment_control
124
+ WHERE singleton = true
125
+ $$;
126
+
127
+ -- Enforcing modes (protect, auto_align) require the host renewal path to call
128
+ -- subscription_delivery_alignment_admit_renewal before inserting a cycle.
129
+ -- The native renewal store does not yet call it: switching to an enforcing mode
130
+ -- halts every autonomous renewal with subscription_delivery_alignment_admission_required.
131
+ -- Native installs must keep mode off until that wiring lands. Before switching,
132
+ -- check for paid cycles without an activation or renewal link: enforcing modes
133
+ -- hold every due renewal on such subscriptions for manual review.
134
+ CREATE FUNCTION public.subscription_delivery_alignment_set_mode(
135
+ p_mode text
136
+ )
137
+ RETURNS jsonb
138
+ LANGUAGE plpgsql
139
+ SECURITY INVOKER
140
+ SET search_path = pg_catalog
141
+ AS $$
142
+ BEGIN
143
+ IF p_mode IS NULL OR p_mode NOT IN ('off', 'shadow', 'protect', 'auto_align') THEN
144
+ RAISE EXCEPTION 'subscription_delivery_alignment_invalid_mode' USING ERRCODE = '22023';
145
+ END IF;
146
+
147
+ UPDATE public.subscription_delivery_alignment_control
148
+ SET mode = p_mode,
149
+ updated_at = now()
150
+ WHERE singleton = true;
151
+
152
+ RETURN jsonb_build_object('mode', p_mode, 'updatedAt', now());
153
+ END;
154
+ $$;
155
+
156
+ CREATE FUNCTION public.subscription_delivery_alignment_record_allowed_receipt(
157
+ p_subscription_id uuid,
158
+ p_scheduled_at timestamptz,
159
+ p_observed_next_cycle_at timestamptz,
160
+ p_source text
161
+ )
162
+ RETURNS void
163
+ LANGUAGE plpgsql
164
+ SECURITY INVOKER
165
+ SET search_path = pg_catalog
166
+ AS $$
167
+ BEGIN
168
+ IF p_subscription_id IS NULL
169
+ OR p_scheduled_at IS NULL
170
+ OR p_observed_next_cycle_at IS NULL
171
+ OR p_source <> 'renewal_admission'
172
+ THEN
173
+ RAISE EXCEPTION 'subscription_delivery_alignment_invalid_receipt' USING ERRCODE = '22023';
174
+ END IF;
175
+
176
+ -- A subscription can hold at most one future admission token. The caller
177
+ -- already owns the subscription lock, so clearing an obsolete unconsumed
178
+ -- schedule cannot race a valid creator and bounds replay/stale buildup.
179
+ DELETE FROM public.subscription_delivery_alignment_admission_receipts
180
+ WHERE subscription_id = p_subscription_id
181
+ AND consumed_at IS NULL
182
+ AND scheduled_at <> p_scheduled_at;
183
+
184
+ INSERT INTO public.subscription_delivery_alignment_admission_receipts (
185
+ subscription_id, scheduled_at, observed_next_cycle_at, source, metadata
186
+ ) VALUES (
187
+ p_subscription_id, p_scheduled_at, p_observed_next_cycle_at, p_source,
188
+ jsonb_build_object('source', 'subscription_delivery_alignment')
189
+ )
190
+ ON CONFLICT (subscription_id, scheduled_at) DO UPDATE
191
+ SET observed_next_cycle_at = EXCLUDED.observed_next_cycle_at,
192
+ source = EXCLUDED.source,
193
+ admitted_at = now(),
194
+ metadata = EXCLUDED.metadata
195
+ WHERE public.subscription_delivery_alignment_admission_receipts.consumed_at IS NULL;
196
+ END;
197
+ $$;
198
+
199
+ CREATE FUNCTION public.subscription_delivery_alignment_upsert_case(
200
+ p_shipment_id uuid,
201
+ p_predecessor_order_id uuid,
202
+ p_subscription_id uuid,
203
+ p_predecessor_cycle_id uuid,
204
+ p_predecessor_cycle_number integer,
205
+ p_mode text,
206
+ p_evidence_kind text,
207
+ p_observed_next_cycle_at timestamptz,
208
+ p_manual_review_reason text DEFAULT NULL
209
+ )
210
+ RETURNS void
211
+ LANGUAGE plpgsql
212
+ SECURITY INVOKER
213
+ SET search_path = pg_catalog
214
+ AS $$
215
+ DECLARE
216
+ v_state text;
217
+ BEGIN
218
+ IF p_mode NOT IN ('shadow', 'protect', 'auto_align') THEN
219
+ RETURN;
220
+ END IF;
221
+
222
+ IF p_mode = 'shadow' THEN
223
+ v_state := 'shadow';
224
+ ELSE
225
+ -- An unordered non-activation cycle cannot authorize automatic alignment.
226
+ IF p_predecessor_cycle_number IS NULL THEN
227
+ p_manual_review_reason := 'missing_renewal_correlation';
228
+ END IF;
229
+ v_state := CASE
230
+ WHEN p_manual_review_reason IS NOT NULL THEN 'manual_review'
231
+ ELSE 'protected'
232
+ END;
233
+ END IF;
234
+
235
+ IF p_shipment_id IS NULL THEN
236
+ INSERT INTO public.subscription_delivery_alignment_cases (
237
+ shipment_id, predecessor_order_id, subscription_id,
238
+ predecessor_cycle_id, predecessor_cycle_number, captured_mode, state,
239
+ evidence_kind, observed_next_cycle_at, manual_review_reason, metadata
240
+ ) VALUES (
241
+ NULL, p_predecessor_order_id, p_subscription_id,
242
+ p_predecessor_cycle_id, p_predecessor_cycle_number, p_mode, v_state,
243
+ p_evidence_kind, p_observed_next_cycle_at,
244
+ p_manual_review_reason,
245
+ jsonb_build_object('source', 'subscription_delivery_alignment')
246
+ )
247
+ ON CONFLICT (predecessor_order_id) WHERE shipment_id IS NULL DO UPDATE
248
+ SET updated_at = now(),
249
+ observed_next_cycle_at = EXCLUDED.observed_next_cycle_at,
250
+ evidence_kind = EXCLUDED.evidence_kind,
251
+ state = CASE
252
+ WHEN public.subscription_delivery_alignment_cases.state IN ('aligned', 'released')
253
+ THEN public.subscription_delivery_alignment_cases.state
254
+ ELSE EXCLUDED.state
255
+ END,
256
+ captured_mode = CASE
257
+ WHEN public.subscription_delivery_alignment_cases.state IN ('aligned', 'released')
258
+ THEN public.subscription_delivery_alignment_cases.captured_mode
259
+ ELSE EXCLUDED.captured_mode
260
+ END,
261
+ manual_review_reason = COALESCE(
262
+ public.subscription_delivery_alignment_cases.manual_review_reason,
263
+ EXCLUDED.manual_review_reason
264
+ );
265
+ RETURN;
266
+ END IF;
267
+
268
+ INSERT INTO public.subscription_delivery_alignment_cases (
269
+ shipment_id, predecessor_order_id, subscription_id,
270
+ predecessor_cycle_id, predecessor_cycle_number, captured_mode, state,
271
+ evidence_kind, observed_next_cycle_at, manual_review_reason, metadata
272
+ ) VALUES (
273
+ p_shipment_id, p_predecessor_order_id, p_subscription_id,
274
+ p_predecessor_cycle_id, p_predecessor_cycle_number, p_mode, v_state,
275
+ p_evidence_kind, p_observed_next_cycle_at, p_manual_review_reason,
276
+ jsonb_build_object('source', 'subscription_delivery_alignment')
277
+ )
278
+ ON CONFLICT (shipment_id) WHERE shipment_id IS NOT NULL DO UPDATE
279
+ SET updated_at = now(),
280
+ observed_next_cycle_at = EXCLUDED.observed_next_cycle_at,
281
+ evidence_kind = EXCLUDED.evidence_kind,
282
+ state = CASE
283
+ WHEN public.subscription_delivery_alignment_cases.state IN ('aligned', 'released')
284
+ THEN public.subscription_delivery_alignment_cases.state
285
+ WHEN EXCLUDED.state = 'manual_review' THEN 'manual_review'
286
+ WHEN EXCLUDED.state = 'protected' THEN 'protected'
287
+ ELSE public.subscription_delivery_alignment_cases.state
288
+ END,
289
+ captured_mode = CASE
290
+ WHEN public.subscription_delivery_alignment_cases.state IN ('aligned', 'released')
291
+ THEN public.subscription_delivery_alignment_cases.captured_mode
292
+ WHEN EXCLUDED.state IN ('protected', 'manual_review') THEN EXCLUDED.captured_mode
293
+ ELSE public.subscription_delivery_alignment_cases.captured_mode
294
+ END,
295
+ manual_review_reason = COALESCE(
296
+ public.subscription_delivery_alignment_cases.manual_review_reason,
297
+ EXCLUDED.manual_review_reason
298
+ );
299
+ END;
300
+ $$;
301
+
302
+ -- Observe an existing active exception hold; never create a hold or an event.
303
+ -- Local cancellation remains a local state change. An undelivered, unsuperseded
304
+ -- cancelled parcel still counts as outstanding when renewal admission is enabled.
305
+ CREATE FUNCTION public.subscription_delivery_alignment_open_exception_case()
306
+ RETURNS trigger
307
+ LANGUAGE plpgsql
308
+ SECURITY INVOKER
309
+ SET search_path = pg_catalog
310
+ AS $$
311
+ DECLARE
312
+ v_mode text;
313
+ v_order public.commerce_orders%ROWTYPE;
314
+ v_cycle public.subscription_delivery_alignment_cycles%ROWTYPE;
315
+ v_subscription public.subscriptions%ROWTYPE;
316
+ v_fulfillment public.fulfillment_shipments%ROWTYPE;
317
+ v_found boolean := false;
318
+ BEGIN
319
+ IF NEW.reason <> 'fulfillment_exception' OR NEW.status <> 'active' THEN
320
+ RETURN NEW;
321
+ END IF;
322
+ IF TG_OP = 'UPDATE' AND OLD.reason = 'fulfillment_exception' AND OLD.status = 'active' THEN
323
+ RETURN NEW;
324
+ END IF;
325
+
326
+ SELECT mode INTO v_mode FROM public.subscription_delivery_alignment_control WHERE singleton = true;
327
+ IF COALESCE(v_mode, 'off') = 'off' THEN
328
+ RETURN NEW;
329
+ END IF;
330
+
331
+ SELECT * INTO v_order FROM public.commerce_orders WHERE id = NEW.order_id;
332
+ IF NOT FOUND OR v_order.subscription_cycle_id IS NULL THEN
333
+ RETURN NEW;
334
+ END IF;
335
+ SELECT * INTO v_cycle FROM public.subscription_delivery_alignment_cycles WHERE id = v_order.subscription_cycle_id;
336
+ SELECT * INTO v_subscription FROM public.subscriptions WHERE id = v_cycle.subscription_id FOR UPDATE;
337
+ IF NOT FOUND OR v_subscription.status <> 'active' OR v_cycle.status IS DISTINCT FROM 'paid' THEN
338
+ RETURN NEW;
339
+ END IF;
340
+
341
+ FOR v_fulfillment IN
342
+ SELECT * FROM public.fulfillment_shipments
343
+ WHERE order_id = v_order.id
344
+ ORDER BY created_at ASC, id ASC
345
+ LOOP
346
+ v_found := true;
347
+ PERFORM public.subscription_delivery_alignment_upsert_case(
348
+ v_fulfillment.id, v_order.id, v_subscription.id, v_cycle.id,
349
+ v_cycle.cycle_number, v_mode, 'fulfillment_exception', v_subscription.next_cycle_at
350
+ );
351
+ END LOOP;
352
+ IF NOT v_found THEN
353
+ PERFORM public.subscription_delivery_alignment_upsert_case(
354
+ NULL, v_order.id, v_subscription.id, v_cycle.id,
355
+ v_cycle.cycle_number, v_mode, 'missing_fulfillment',
356
+ v_subscription.next_cycle_at,
357
+ CASE WHEN v_mode = 'shadow' THEN NULL ELSE 'fulfillment_exception_without_fulfillment' END
358
+ );
359
+ END IF;
360
+ RETURN NEW;
361
+ END;
362
+ $$;
363
+
364
+ CREATE FUNCTION public.subscription_delivery_alignment_on_delivery()
365
+ RETURNS trigger
366
+ LANGUAGE plpgsql
367
+ SECURITY INVOKER
368
+ SET search_path = pg_catalog
369
+ AS $$
370
+ DECLARE
371
+ v_case public.subscription_delivery_alignment_cases%ROWTYPE;
372
+ v_inherited_case_id uuid;
373
+ v_order public.commerce_orders%ROWTYPE;
374
+ v_subscription public.subscriptions%ROWTYPE;
375
+ v_open_dunning boolean;
376
+ v_artifact_reason text;
377
+ v_latest_delivered_at timestamptz;
378
+ v_target timestamptz;
379
+ BEGIN
380
+ IF NEW.delivered_at IS NULL
381
+ OR (TG_OP = 'UPDATE' AND OLD.delivered_at IS NOT NULL)
382
+ THEN
383
+ RETURN NEW;
384
+ END IF;
385
+
386
+ -- Derive the aggregate from the fulfillment's order, then lock it before
387
+ -- examining its ledger. This fence is required even when there is no case
388
+ -- yet: otherwise delivery could return on an empty lookup while concurrent
389
+ -- admission creates protection from the not-yet-committed delivery.
390
+ SELECT * INTO v_order
391
+ FROM public.commerce_orders
392
+ WHERE id = NEW.order_id
393
+ AND subscription_cycle_id IS NOT NULL;
394
+ IF NOT FOUND THEN
395
+ RETURN NEW;
396
+ END IF;
397
+
398
+ SELECT * INTO v_subscription
399
+ FROM public.subscriptions
400
+ WHERE id = (SELECT subscription_id FROM public.subscription_cycles WHERE id = v_order.subscription_cycle_id)
401
+ FOR UPDATE;
402
+ IF NOT FOUND THEN
403
+ RETURN NEW;
404
+ END IF;
405
+
406
+ -- Admission or exception evidence may have changed the case while this
407
+ -- transaction waited for the aggregate, so take the case lock only now.
408
+ SELECT * INTO v_case
409
+ FROM public.subscription_delivery_alignment_cases
410
+ WHERE shipment_id = NEW.id
411
+ FOR UPDATE;
412
+
413
+ -- A replacement parcel is created after the case was already open, so it never
414
+ -- carries a case of its own. It is the arrival the customer is actually
415
+ -- waiting for, so it settles the case its predecessor opened on the same
416
+ -- order. An open case is preferred because that is the one holding the
417
+ -- renewal. Ordinary parcels never enter this branch: they replace nothing.
418
+ IF NOT FOUND AND NEW.replaces_shipment_id IS NOT NULL THEN
419
+ SELECT id INTO v_inherited_case_id
420
+ FROM public.subscription_delivery_alignment_cases
421
+ WHERE predecessor_order_id = NEW.order_id
422
+ ORDER BY (state IN ('protected', 'manual_review')) DESC, created_at ASC, id ASC
423
+ LIMIT 1;
424
+ IF v_inherited_case_id IS NOT NULL THEN
425
+ SELECT * INTO v_case
426
+ FROM public.subscription_delivery_alignment_cases
427
+ WHERE id = v_inherited_case_id
428
+ FOR UPDATE;
429
+ END IF;
430
+ END IF;
431
+ IF v_case.id IS NULL THEN
432
+ RETURN NEW;
433
+ END IF;
434
+
435
+ UPDATE public.subscription_delivery_alignment_cases
436
+ SET delivered_at = LEAST(COALESCE(delivered_at, NEW.delivered_at), NEW.delivered_at),
437
+ updated_at = now()
438
+ WHERE id = v_case.id
439
+ RETURNING * INTO v_case;
440
+
441
+ -- Outstanding means the customer is still waiting for it. A parcel that
442
+ -- another row supersedes through replaces_shipment_id has been
443
+ -- answered by that replacement, and when it was lost its delivered_at stays
444
+ -- null for ever - counting it would hold this case open for ever with it.
445
+ IF EXISTS (
446
+ SELECT 1 FROM public.fulfillment_shipments outstanding
447
+ WHERE outstanding.order_id = v_case.predecessor_order_id
448
+ AND outstanding.delivered_at IS NULL
449
+ AND NOT EXISTS (
450
+ SELECT 1 FROM public.fulfillment_shipments successor
451
+ WHERE successor.replaces_shipment_id = outstanding.id
452
+ )
453
+ ) THEN
454
+ RETURN NEW;
455
+ END IF;
456
+
457
+ IF v_subscription.status <> 'active' THEN
458
+ UPDATE public.subscription_delivery_alignment_cases
459
+ SET state = CASE WHEN state = 'protected' THEN 'released' ELSE state END,
460
+ updated_at = now()
461
+ WHERE predecessor_order_id = v_case.predecessor_order_id;
462
+ RETURN NEW;
463
+ END IF;
464
+
465
+ SELECT EXISTS (
466
+ SELECT 1 FROM public.subscription_dunning_cases
467
+ WHERE subscription_id = v_subscription.id
468
+ AND status IN ('open', 'expired')
469
+ ) INTO v_open_dunning;
470
+ IF v_open_dunning THEN
471
+ UPDATE public.subscription_delivery_alignment_cases
472
+ SET state = CASE WHEN state = 'protected' THEN 'manual_review' ELSE state END,
473
+ manual_review_reason = CASE WHEN state = 'protected' THEN 'open_dunning' ELSE manual_review_reason END,
474
+ updated_at = now()
475
+ WHERE predecessor_order_id = v_case.predecessor_order_id;
476
+ RETURN NEW;
477
+ END IF;
478
+
479
+ SELECT CASE
480
+ WHEN EXISTS (
481
+ SELECT 1 FROM public.subscription_delivery_alignment_cycles successor
482
+ JOIN public.subscription_renewal_operations operation ON operation.id = successor.renewal_operation_id
483
+ WHERE successor.subscription_id = v_subscription.id
484
+ AND (successor.cycle_number > v_case.predecessor_cycle_number OR (successor.cycle_number IS NULL AND successor.id <> v_case.predecessor_cycle_id))
485
+ AND operation.attempt_prepared_at IS NOT NULL
486
+ ) THEN 'existing_provider_attempt'
487
+ WHEN EXISTS (
488
+ SELECT 1 FROM public.subscription_delivery_alignment_cycles successor
489
+ WHERE successor.subscription_id = v_subscription.id
490
+ AND (successor.cycle_number > v_case.predecessor_cycle_number
491
+ OR (successor.cycle_number IS NULL AND successor.id <> v_case.predecessor_cycle_id))
492
+ ) THEN 'renewal_cycle_already_created'
493
+ ELSE NULL
494
+ END INTO v_artifact_reason;
495
+
496
+ IF v_artifact_reason IS NOT NULL THEN
497
+ UPDATE public.subscription_delivery_alignment_cases
498
+ SET state = CASE WHEN state = 'protected' THEN 'manual_review' ELSE state END,
499
+ manual_review_reason = CASE WHEN state = 'protected' THEN v_artifact_reason ELSE manual_review_reason END,
500
+ updated_at = now()
501
+ WHERE predecessor_order_id = v_case.predecessor_order_id;
502
+ RETURN NEW;
503
+ END IF;
504
+
505
+ IF v_case.state <> 'protected' THEN
506
+ RETURN NEW;
507
+ END IF;
508
+ IF v_case.captured_mode = 'protect' THEN
509
+ UPDATE public.subscription_delivery_alignment_cases
510
+ SET state = CASE WHEN state = 'protected' THEN 'released' ELSE state END,
511
+ updated_at = now()
512
+ WHERE predecessor_order_id = v_case.predecessor_order_id;
513
+ RETURN NEW;
514
+ END IF;
515
+ IF v_case.captured_mode <> 'auto_align' THEN
516
+ RETURN NEW;
517
+ END IF;
518
+
519
+ -- With a split shipment, delivery events can arrive in either chronology.
520
+ -- Preserve the interval from the last parcel delivered, not necessarily from
521
+ -- the final webhook processed by this transaction.
522
+ SELECT max(delivered_at) INTO v_latest_delivered_at
523
+ FROM public.fulfillment_shipments
524
+ WHERE order_id = v_case.predecessor_order_id;
525
+ v_target := v_latest_delivered_at + make_interval(days => v_subscription.cadence_days);
526
+ UPDATE public.subscriptions
527
+ SET next_cycle_at = CASE
528
+ WHEN next_cycle_at IS NULL THEN v_target
529
+ ELSE GREATEST(next_cycle_at, v_target)
530
+ END,
531
+ updated_at = now()
532
+ WHERE id = v_subscription.id
533
+ RETURNING next_cycle_at INTO v_target;
534
+
535
+ UPDATE public.subscription_delivery_alignment_cases
536
+ SET state = CASE WHEN state = 'protected' THEN 'aligned' ELSE state END,
537
+ aligned_next_cycle_at = CASE WHEN state = 'protected' THEN v_target ELSE aligned_next_cycle_at END,
538
+ aligned_at = CASE WHEN state = 'protected' THEN now() ELSE aligned_at END,
539
+ updated_at = now()
540
+ WHERE predecessor_order_id = v_case.predecessor_order_id;
541
+ RETURN NEW;
542
+ END;
543
+ $$;
544
+
545
+ CREATE FUNCTION public.subscription_delivery_alignment_admit_renewal(
546
+ p_subscription_id uuid,
547
+ p_scheduled_at timestamptz,
548
+ p_as_of timestamptz DEFAULT now()
549
+ )
550
+ RETURNS jsonb
551
+ LANGUAGE plpgsql
552
+ SECURITY INVOKER
553
+ SET search_path = pg_catalog
554
+ AS $$
555
+ DECLARE
556
+ v_subscription public.subscriptions%ROWTYPE;
557
+ v_predecessor public.subscription_delivery_alignment_cycles%ROWTYPE;
558
+ v_order public.commerce_orders%ROWTYPE;
559
+ v_mode text;
560
+ v_fulfillment public.fulfillment_shipments%ROWTYPE;
561
+ v_has_fulfillment boolean := false;
562
+ v_has_undelivered boolean := false;
563
+ v_has_paid_predecessor boolean := false;
564
+ v_existing_state text;
565
+ v_ledger_state text;
566
+ BEGIN
567
+ IF p_subscription_id IS NULL OR p_scheduled_at IS NULL OR p_as_of IS NULL THEN
568
+ RAISE EXCEPTION 'subscription_delivery_alignment_invalid_admission' USING ERRCODE = '22023';
569
+ END IF;
570
+
571
+ SELECT * INTO v_subscription
572
+ FROM public.subscriptions
573
+ WHERE id = p_subscription_id
574
+ FOR UPDATE;
575
+ IF NOT FOUND THEN
576
+ RAISE EXCEPTION 'subscription_delivery_alignment_subscription_not_found' USING ERRCODE = '22023';
577
+ END IF;
578
+
579
+ IF v_subscription.status <> 'active' THEN
580
+ RETURN jsonb_build_object('allowed', false, 'state', 'not_active', 'reason', 'subscription_not_active');
581
+ END IF;
582
+
583
+ -- D13 native translation note: this entry admits NEW cycles only.
584
+ -- Existing renewal/dunning retries bypass admission and receipts structurally.
585
+ SELECT * INTO v_predecessor
586
+ FROM public.subscription_delivery_alignment_cycles
587
+ WHERE subscription_id = p_subscription_id
588
+ AND status = 'paid'
589
+ AND cycle_number >= 0
590
+ ORDER BY cycle_number DESC NULLS LAST, scheduled_at DESC
591
+ LIMIT 1;
592
+ v_has_paid_predecessor := FOUND;
593
+
594
+ -- A later paid cycle is authoritative proof that an older collision no
595
+ -- longer controls the next renewal. Release it atomically under the same
596
+ -- subscription lock so a completed retry cannot strand future normal cycles.
597
+ IF v_has_paid_predecessor THEN
598
+ UPDATE public.subscription_delivery_alignment_cases
599
+ SET state = 'released',
600
+ updated_at = now()
601
+ WHERE subscription_id = p_subscription_id
602
+ AND state IN ('protected', 'manual_review')
603
+ AND predecessor_cycle_number < v_predecessor.cycle_number;
604
+ END IF;
605
+
606
+ SELECT state INTO v_existing_state
607
+ FROM public.subscription_delivery_alignment_cases
608
+ WHERE subscription_id = p_subscription_id
609
+ AND state IN ('protected', 'manual_review')
610
+ AND (NOT v_has_paid_predecessor OR predecessor_cycle_number >= v_predecessor.cycle_number OR predecessor_cycle_number IS NULL)
611
+ ORDER BY predecessor_cycle_number DESC, created_at DESC, id DESC
612
+ LIMIT 1;
613
+ IF v_existing_state IS NOT NULL THEN
614
+ RETURN jsonb_build_object(
615
+ 'allowed', false,
616
+ 'state', v_existing_state,
617
+ 'reason', CASE WHEN v_existing_state = 'protected'
618
+ THEN 'delivery_alignment_protected' ELSE 'delivery_alignment_manual_review' END
619
+ );
620
+ END IF;
621
+
622
+ SELECT mode INTO v_mode
623
+ FROM public.subscription_delivery_alignment_control
624
+ WHERE singleton = true;
625
+ IF COALESCE(v_mode, 'off') = 'off' THEN
626
+ RETURN jsonb_build_object('allowed', true, 'state', 'off', 'reason', 'mode_off');
627
+ END IF;
628
+ -- Shadow is observation-only. A stale caller is useful telemetry but it must
629
+ -- never turn this rollout mode into an implicit admission gate or rewrite a
630
+ -- case using a schedule we no longer own.
631
+ IF COALESCE(v_mode, 'off') = 'shadow'
632
+ AND v_subscription.next_cycle_at IS DISTINCT FROM p_scheduled_at THEN
633
+ RETURN jsonb_build_object('allowed', true, 'state', 'shadow', 'reason', 'stale_shadow');
634
+ END IF;
635
+ IF v_subscription.next_cycle_at IS DISTINCT FROM p_scheduled_at THEN
636
+ RETURN jsonb_build_object('allowed', false, 'state', 'stale', 'reason', 'stale_next_cycle_at');
637
+ END IF;
638
+ IF p_scheduled_at > p_as_of THEN
639
+ RETURN jsonb_build_object('allowed', true, 'state', 'none', 'reason', 'not_due');
640
+ END IF;
641
+
642
+ -- Unknown paid history cannot outrank a known predecessor, but ignoring it
643
+ -- could admit a charge while an uncorrelated parcel is still outstanding.
644
+ -- Refuse without a receipt; shadow remains observation-only. Do not invent
645
+ -- an ordinal from scheduled_at or silently classify it as activation.
646
+ IF EXISTS (
647
+ SELECT 1 FROM public.subscription_delivery_alignment_cycles
648
+ WHERE subscription_id = p_subscription_id
649
+ AND status = 'paid' AND cycle_number IS NULL
650
+ ) THEN
651
+ RETURN jsonb_build_object(
652
+ 'allowed', v_mode = 'shadow',
653
+ 'state', CASE WHEN v_mode = 'shadow' THEN 'shadow' ELSE 'manual_review' END,
654
+ 'reason', 'missing_renewal_correlation'
655
+ );
656
+ END IF;
657
+
658
+ IF NOT v_has_paid_predecessor OR v_predecessor.order_id IS NULL THEN
659
+ PERFORM public.subscription_delivery_alignment_record_allowed_receipt(
660
+ p_subscription_id, p_scheduled_at, v_subscription.next_cycle_at, 'renewal_admission'
661
+ );
662
+ RETURN jsonb_build_object('allowed', true, 'state', 'none', 'reason', 'no_paid_predecessor');
663
+ END IF;
664
+
665
+ SELECT * INTO v_order
666
+ FROM public.commerce_orders
667
+ WHERE id = v_predecessor.order_id;
668
+ IF NOT FOUND OR v_order.subscription_cycle_id IS DISTINCT FROM v_predecessor.id THEN
669
+ PERFORM public.subscription_delivery_alignment_record_allowed_receipt(
670
+ p_subscription_id, p_scheduled_at, v_subscription.next_cycle_at, 'renewal_admission'
671
+ );
672
+ RETURN jsonb_build_object('allowed', true, 'state', 'none', 'reason', 'predecessor_not_subscription_cycle');
673
+ END IF;
674
+
675
+ FOR v_fulfillment IN
676
+ SELECT *
677
+ FROM public.fulfillment_shipments
678
+ WHERE order_id = v_order.id
679
+ ORDER BY created_at ASC, id ASC
680
+ LOOP
681
+ v_has_fulfillment := true;
682
+ -- Superseded parcels do not count. Without this the lost original would
683
+ -- keep this admission answering `protected` long after its replacement
684
+ -- reached the customer, and the subscription would never renew again.
685
+ IF v_fulfillment.delivered_at IS NULL
686
+ AND NOT EXISTS (
687
+ SELECT 1 FROM public.fulfillment_shipments successor
688
+ WHERE successor.replaces_shipment_id = v_fulfillment.id
689
+ )
690
+ THEN
691
+ v_has_undelivered := true;
692
+ END IF;
693
+ END LOOP;
694
+
695
+ -- Do not open a delivery case merely because a normal parcel exists. It is
696
+ -- delay evidence only once the autonomous renewal is actually overdue.
697
+ IF NOT v_has_fulfillment THEN
698
+ PERFORM public.subscription_delivery_alignment_upsert_case(
699
+ NULL, v_order.id, p_subscription_id, v_predecessor.id,
700
+ v_predecessor.cycle_number, v_mode, 'missing_fulfillment',
701
+ v_subscription.next_cycle_at,
702
+ CASE WHEN v_mode = 'shadow' THEN NULL ELSE 'missing_fulfillment' END
703
+ );
704
+ IF v_mode = 'shadow' THEN
705
+ RETURN jsonb_build_object('allowed', true, 'state', 'shadow', 'reason', 'delivery_alignment_shadow');
706
+ END IF;
707
+ RETURN jsonb_build_object('allowed', false, 'state', 'manual_review', 'reason', 'delivery_alignment_manual_review');
708
+ END IF;
709
+ IF NOT v_has_undelivered THEN
710
+ PERFORM public.subscription_delivery_alignment_record_allowed_receipt(
711
+ p_subscription_id, p_scheduled_at, v_subscription.next_cycle_at, 'renewal_admission'
712
+ );
713
+ RETURN jsonb_build_object('allowed', true, 'state', 'none', 'reason', 'predecessor_delivered');
714
+ END IF;
715
+
716
+ FOR v_fulfillment IN
717
+ SELECT *
718
+ FROM public.fulfillment_shipments
719
+ WHERE order_id = v_order.id
720
+ ORDER BY created_at ASC, id ASC
721
+ LOOP
722
+ PERFORM public.subscription_delivery_alignment_upsert_case(
723
+ v_fulfillment.id, v_order.id, p_subscription_id, v_predecessor.id,
724
+ v_predecessor.cycle_number, v_mode, 'overdue_renewal',
725
+ v_subscription.next_cycle_at
726
+ );
727
+ END LOOP;
728
+
729
+ IF v_mode = 'shadow' THEN
730
+ RETURN jsonb_build_object('allowed', true, 'state', 'shadow', 'reason', 'delivery_alignment_shadow');
731
+ END IF;
732
+
733
+ -- The ledger owns this answer. The upsert above preserves a state an operator
734
+ -- or the delivery path already settled, so reading the row is the only way for
735
+ -- the response to agree with it. A blocking state still refuses; a settled one
736
+ -- is admitted and takes the same one-time receipt the other admitted paths
737
+ -- take, because the cycle-insert backstop would otherwise reject a renewal
738
+ -- this call has just declared allowed. A missing row cannot happen here (the
739
+ -- loop above wrote one for every fulfillment) and is treated as blocking.
740
+ SELECT state INTO v_ledger_state
741
+ FROM public.subscription_delivery_alignment_cases
742
+ WHERE subscription_id = p_subscription_id
743
+ AND predecessor_order_id = v_order.id
744
+ ORDER BY (state IN ('protected', 'manual_review')) DESC, updated_at DESC, id DESC
745
+ LIMIT 1;
746
+ v_ledger_state := COALESCE(v_ledger_state, 'protected');
747
+ IF v_ledger_state IN ('protected', 'manual_review') THEN
748
+ RETURN jsonb_build_object(
749
+ 'allowed', false,
750
+ 'state', v_ledger_state,
751
+ 'reason', CASE WHEN v_ledger_state = 'protected'
752
+ THEN 'delivery_alignment_protected' ELSE 'delivery_alignment_manual_review' END
753
+ );
754
+ END IF;
755
+ PERFORM public.subscription_delivery_alignment_record_allowed_receipt(
756
+ p_subscription_id, p_scheduled_at, v_subscription.next_cycle_at, 'renewal_admission'
757
+ );
758
+ RETURN jsonb_build_object(
759
+ 'allowed', true,
760
+ 'state', v_ledger_state,
761
+ 'reason', 'delivery_alignment_case_settled'
762
+ );
763
+ END;
764
+ $$;
765
+
766
+ CREATE FUNCTION public.subscription_delivery_alignment_resolve_case(
767
+ p_case_id uuid,
768
+ p_resolution text,
769
+ p_note text
770
+ )
771
+ RETURNS jsonb
772
+ LANGUAGE plpgsql
773
+ SECURITY INVOKER
774
+ SET search_path = pg_catalog
775
+ AS $$
776
+ DECLARE
777
+ v_case public.subscription_delivery_alignment_cases%ROWTYPE;
778
+ v_subscription_id uuid;
779
+ v_subscription_status text;
780
+ BEGIN
781
+ IF p_case_id IS NULL
782
+ OR p_resolution NOT IN ('operator_confirmed', 'retry_succeeded', 'false_positive')
783
+ OR NULLIF(btrim(p_note), '') IS NULL
784
+ THEN
785
+ RAISE EXCEPTION 'subscription_delivery_alignment_invalid_resolution' USING ERRCODE = '22023';
786
+ END IF;
787
+
788
+ -- Discover identity without locking the ledger first; all mutable alignment
789
+ -- paths own subscription -> ledger case to avoid delivery/admission deadlock.
790
+ SELECT subscription_id INTO v_subscription_id
791
+ FROM public.subscription_delivery_alignment_cases
792
+ WHERE id = p_case_id;
793
+ IF NOT FOUND THEN
794
+ RAISE EXCEPTION 'subscription_delivery_alignment_case_not_found' USING ERRCODE = '22023';
795
+ END IF;
796
+
797
+ SELECT status INTO v_subscription_status
798
+ FROM public.subscriptions
799
+ WHERE id = v_subscription_id
800
+ FOR UPDATE;
801
+ IF NOT FOUND THEN
802
+ RAISE EXCEPTION 'subscription_delivery_alignment_subscription_not_found' USING ERRCODE = '22023';
803
+ END IF;
804
+
805
+ SELECT * INTO v_case
806
+ FROM public.subscription_delivery_alignment_cases
807
+ WHERE id = p_case_id
808
+ FOR UPDATE;
809
+ IF v_case.state NOT IN ('protected', 'manual_review') THEN
810
+ RETURN jsonb_build_object('caseId', v_case.id, 'state', v_case.state, 'resolved', false, 'reason', 'not_open');
811
+ END IF;
812
+
813
+ -- Release is now real permission for the next autonomous charge, so it may not
814
+ -- be spent on a shipment that has not arrived. Releasing here would take money
815
+ -- for goods the customer never received and, because every open-case
816
+ -- projection keys on the open states, would take the case off the operator
817
+ -- queue, the overdue counter and the customer banner at the same time. The
818
+ -- honest exits are the durable delivery, a confirmed replacement, or ending
819
+ -- the subscription first. A case with no fulfillment row at all is untouched:
820
+ -- it has no delivery fact to wait for, and operator judgement is the only tool
821
+ -- that corruption case has.
822
+ IF v_subscription_status = 'active'
823
+ AND EXISTS (
824
+ SELECT 1
825
+ FROM public.fulfillment_shipments outstanding
826
+ WHERE outstanding.order_id = v_case.predecessor_order_id
827
+ AND outstanding.delivered_at IS NULL
828
+ AND NOT EXISTS (
829
+ SELECT 1 FROM public.fulfillment_shipments successor
830
+ WHERE successor.replaces_shipment_id = outstanding.id
831
+ )
832
+ )
833
+ THEN
834
+ RAISE EXCEPTION 'subscription_delivery_alignment_release_would_strand' USING ERRCODE = '22023';
835
+ END IF;
836
+
837
+ UPDATE public.subscription_delivery_alignment_cases
838
+ SET state = 'released',
839
+ updated_at = now(),
840
+ metadata = metadata || jsonb_build_object(
841
+ 'manualResolution',
842
+ jsonb_build_object('code', p_resolution, 'note', p_note, 'resolvedAt', now())
843
+ )
844
+ WHERE id = v_case.id
845
+ RETURNING * INTO v_case;
846
+
847
+ RETURN jsonb_build_object('caseId', v_case.id, 'state', v_case.state, 'resolved', true, 'resolution', p_resolution);
848
+ END;
849
+ $$;
850
+
851
+ CREATE FUNCTION public.subscription_delivery_alignment_confirm_replacement(
852
+ p_case_id uuid,
853
+ p_note text
854
+ )
855
+ RETURNS jsonb
856
+ LANGUAGE plpgsql
857
+ SECURITY INVOKER
858
+ SET search_path = pg_catalog
859
+ AS $$
860
+ DECLARE
861
+ v_case public.subscription_delivery_alignment_cases%ROWTYPE;
862
+ v_subscription public.subscriptions%ROWTYPE;
863
+ v_subscription_id uuid;
864
+ v_target timestamptz;
865
+ BEGIN
866
+ IF p_case_id IS NULL OR NULLIF(btrim(p_note), '') IS NULL THEN
867
+ RAISE EXCEPTION 'subscription_delivery_alignment_invalid_replacement' USING ERRCODE = '22023';
868
+ END IF;
869
+
870
+ -- Identity first, then the aggregate lock, then the case lock: the same order
871
+ -- admission, delivery and resolution use, so this cannot deadlock against them.
872
+ SELECT subscription_id INTO v_subscription_id
873
+ FROM public.subscription_delivery_alignment_cases
874
+ WHERE id = p_case_id;
875
+ IF NOT FOUND THEN
876
+ RAISE EXCEPTION 'subscription_delivery_alignment_case_not_found' USING ERRCODE = '22023';
877
+ END IF;
878
+
879
+ SELECT * INTO v_subscription
880
+ FROM public.subscriptions
881
+ WHERE id = v_subscription_id
882
+ FOR UPDATE;
883
+ IF NOT FOUND THEN
884
+ RAISE EXCEPTION 'subscription_delivery_alignment_subscription_not_found' USING ERRCODE = '22023';
885
+ END IF;
886
+
887
+ SELECT * INTO v_case
888
+ FROM public.subscription_delivery_alignment_cases
889
+ WHERE id = p_case_id
890
+ FOR UPDATE;
891
+
892
+ -- A case this call already aligned answers with its stored schedule instead of
893
+ -- shifting again. Without this the second call would add another cadence.
894
+ IF v_case.state = 'aligned' THEN
895
+ RETURN jsonb_build_object(
896
+ 'caseId', v_case.id,
897
+ 'state', v_case.state,
898
+ 'confirmed', true,
899
+ 'nextCycleAt', v_case.aligned_next_cycle_at
900
+ );
901
+ END IF;
902
+ IF v_case.state NOT IN ('protected', 'manual_review') THEN
903
+ RAISE EXCEPTION 'subscription_delivery_alignment_case_not_open' USING ERRCODE = '22023';
904
+ END IF;
905
+
906
+ -- A non-active subscription has no autonomous renewal to align. Confirming a
907
+ -- replacement there would move a schedule nobody is going to read, and would
908
+ -- hide the case from the operator queue on the strength of a schedule shift
909
+ -- that changes nothing.
910
+ IF v_subscription.status <> 'active' THEN
911
+ RAISE EXCEPTION 'subscription_delivery_alignment_replacement_requires_active_subscription' USING ERRCODE = '22023';
912
+ END IF;
913
+
914
+ -- The confirmation instant plus the subscription's own cadence, and never
915
+ -- earlier than the schedule already promised. `GREATEST` is what makes this
916
+ -- monotonic: the same one-way rule the delivery path applies.
917
+ v_target := now() + make_interval(days => v_subscription.cadence_days);
918
+ UPDATE public.subscriptions
919
+ SET next_cycle_at = CASE
920
+ WHEN next_cycle_at IS NULL THEN v_target
921
+ ELSE GREATEST(next_cycle_at, v_target)
922
+ END,
923
+ updated_at = now()
924
+ WHERE id = v_subscription.id
925
+ RETURNING next_cycle_at INTO v_target;
926
+
927
+ -- Settle every open case of the same predecessor order, not only the targeted
928
+ -- row. One replacement answers for the whole undelivered order, and a sibling
929
+ -- left open would keep the cycle-insert backstop rejecting the renewal this
930
+ -- confirmation just scheduled. Matches how the delivery path settles siblings.
931
+ UPDATE public.subscription_delivery_alignment_cases
932
+ SET state = 'aligned',
933
+ aligned_next_cycle_at = v_target,
934
+ aligned_at = now(),
935
+ updated_at = now(),
936
+ metadata = metadata || jsonb_build_object(
937
+ 'replacementConfirmation',
938
+ jsonb_build_object('note', p_note, 'confirmedAt', now(), 'nextCycleAt', v_target)
939
+ )
940
+ WHERE predecessor_order_id = v_case.predecessor_order_id
941
+ AND state IN ('protected', 'manual_review');
942
+
943
+ SELECT * INTO v_case
944
+ FROM public.subscription_delivery_alignment_cases
945
+ WHERE id = v_case.id;
946
+
947
+ RETURN jsonb_build_object(
948
+ 'caseId', v_case.id,
949
+ 'state', v_case.state,
950
+ 'confirmed', true,
951
+ 'nextCycleAt', v_case.aligned_next_cycle_at
952
+ );
953
+ END;
954
+ $$;
955
+
956
+ CREATE FUNCTION public.subscription_delivery_alignment_guard_cycle_insert()
957
+ RETURNS trigger
958
+ LANGUAGE plpgsql
959
+ SECURITY INVOKER
960
+ SET search_path = pg_catalog
961
+ AS $$
962
+ DECLARE
963
+ v_subscription public.subscriptions%ROWTYPE;
964
+ v_mode text;
965
+ v_receipt_id uuid;
966
+ BEGIN
967
+ IF NEW.status <> 'payment_pending' THEN
968
+ RETURN NEW;
969
+ END IF;
970
+
971
+ SELECT * INTO v_subscription
972
+ FROM public.subscriptions
973
+ WHERE id = NEW.subscription_id
974
+ FOR UPDATE;
975
+ IF NOT FOUND OR v_subscription.status <> 'active' THEN
976
+ RETURN NEW;
977
+ END IF;
978
+
979
+ -- Expired-dunning recovery owns its fresh cycle through a separate tokenized
980
+ -- workflow. It retains its own authority rather than becoming alignment dunning.
981
+ IF EXISTS (
982
+ SELECT 1 FROM public.subscription_dunning_cases
983
+ WHERE subscription_id = NEW.subscription_id
984
+ AND status = 'expired'
985
+ ) THEN
986
+ RETURN NEW;
987
+ END IF;
988
+
989
+ IF EXISTS (
990
+ SELECT 1 FROM public.subscription_delivery_alignment_cases
991
+ WHERE subscription_id = NEW.subscription_id
992
+ AND state IN ('protected', 'manual_review')
993
+ ) THEN
994
+ RAISE EXCEPTION 'subscription_delivery_alignment_protected' USING ERRCODE = 'P0001';
995
+ END IF;
996
+
997
+ SELECT mode INTO v_mode
998
+ FROM public.subscription_delivery_alignment_control
999
+ WHERE singleton = true;
1000
+
1001
+ -- Only enforcing modes own the stale-schedule gate. Shadow observes without
1002
+ -- blocking, and an off rollout must be behaviorally inert for new cases.
1003
+ IF COALESCE(v_mode, 'off') IN ('off', 'shadow') THEN
1004
+ -- Consuming an aligned scheduled instant is a durable lifecycle transition,
1005
+ -- even when an operator turned new-case creation off after alignment.
1006
+ UPDATE public.subscription_delivery_alignment_cases
1007
+ SET state = 'released',
1008
+ updated_at = now()
1009
+ WHERE subscription_id = NEW.subscription_id
1010
+ AND state = 'aligned'
1011
+ AND aligned_next_cycle_at = NEW.scheduled_at;
1012
+ RETURN NEW;
1013
+ END IF;
1014
+
1015
+ IF NEW.scheduled_at IS DISTINCT FROM v_subscription.next_cycle_at THEN
1016
+ RAISE EXCEPTION 'subscription_delivery_alignment_stale_schedule' USING ERRCODE = '40001';
1017
+ END IF;
1018
+
1019
+ -- Claim exactly one admission proof in the same transaction as the new
1020
+ -- cycle. A uniqueness conflict or later failure rolls this consumption back,
1021
+ -- allowing the runtime's normal idempotent retry without a second preflight.
1022
+ UPDATE public.subscription_delivery_alignment_admission_receipts
1023
+ SET consumed_at = now()
1024
+ WHERE subscription_id = NEW.subscription_id
1025
+ AND scheduled_at = NEW.scheduled_at
1026
+ AND observed_next_cycle_at = v_subscription.next_cycle_at
1027
+ AND decision = 'allowed'
1028
+ AND consumed_at IS NULL
1029
+ RETURNING id INTO v_receipt_id;
1030
+ IF v_receipt_id IS NULL THEN
1031
+ RAISE EXCEPTION 'subscription_delivery_alignment_admission_required' USING ERRCODE = 'P0001';
1032
+ END IF;
1033
+
1034
+ UPDATE public.subscription_delivery_alignment_cases
1035
+ SET state = 'released',
1036
+ updated_at = now()
1037
+ WHERE subscription_id = NEW.subscription_id
1038
+ AND state = 'aligned'
1039
+ AND aligned_next_cycle_at = NEW.scheduled_at;
1040
+
1041
+ RETURN NEW;
1042
+ END;
1043
+ $$;
1044
+ CREATE TRIGGER trg_subscription_delivery_alignment_exception_case
1045
+ AFTER INSERT OR UPDATE OF status, reason ON public.commerce_order_holds
1046
+ FOR EACH ROW EXECUTE FUNCTION public.subscription_delivery_alignment_open_exception_case();
1047
+
1048
+ CREATE TRIGGER trg_subscription_delivery_alignment_on_delivery
1049
+ AFTER INSERT OR UPDATE OF delivered_at ON public.fulfillment_shipments
1050
+ FOR EACH ROW EXECUTE FUNCTION public.subscription_delivery_alignment_on_delivery();
1051
+
1052
+ CREATE TRIGGER trg_subscription_delivery_alignment_guard_cycle_insert
1053
+ BEFORE INSERT ON public.subscription_cycles
1054
+ FOR EACH ROW EXECUTE FUNCTION public.subscription_delivery_alignment_guard_cycle_insert();
1055
+
1056
+ REVOKE ALL ON FUNCTION public.subscription_delivery_alignment_get_mode() FROM PUBLIC, anon, authenticated;
1057
+ REVOKE ALL ON FUNCTION public.subscription_delivery_alignment_set_mode(text) FROM PUBLIC, anon, authenticated;
1058
+ REVOKE ALL ON FUNCTION public.subscription_delivery_alignment_record_allowed_receipt(uuid, timestamptz, timestamptz, text) FROM PUBLIC, anon, authenticated;
1059
+ REVOKE ALL ON FUNCTION public.subscription_delivery_alignment_upsert_case(uuid, uuid, uuid, uuid, integer, text, text, timestamptz, text) FROM PUBLIC, anon, authenticated;
1060
+ REVOKE ALL ON FUNCTION public.subscription_delivery_alignment_open_exception_case() FROM PUBLIC, anon, authenticated;
1061
+ REVOKE ALL ON FUNCTION public.subscription_delivery_alignment_on_delivery() FROM PUBLIC, anon, authenticated;
1062
+ REVOKE ALL ON FUNCTION public.subscription_delivery_alignment_admit_renewal(uuid, timestamptz, timestamptz) FROM PUBLIC, anon, authenticated;
1063
+ REVOKE ALL ON FUNCTION public.subscription_delivery_alignment_resolve_case(uuid, text, text) FROM PUBLIC, anon, authenticated;
1064
+ REVOKE ALL ON FUNCTION public.subscription_delivery_alignment_confirm_replacement(uuid, text) FROM PUBLIC, anon, authenticated;
1065
+ REVOKE ALL ON FUNCTION public.subscription_delivery_alignment_guard_cycle_insert() FROM PUBLIC, anon, authenticated;
1066
+ REVOKE ALL ON public.subscription_delivery_alignment_control FROM PUBLIC, anon, authenticated;
1067
+ REVOKE ALL ON public.subscription_delivery_alignment_cases FROM PUBLIC, anon, authenticated;
1068
+ REVOKE ALL ON public.subscription_delivery_alignment_admission_receipts FROM PUBLIC, anon, authenticated;
1069
+ REVOKE ALL ON public.subscription_delivery_alignment_cycles FROM PUBLIC, anon, authenticated;
1070
+
1071
+ CREATE TABLE public.subscription_delivery_alignment_arrivals (
1072
+ request_key text PRIMARY KEY,
1073
+ shipment_id uuid NOT NULL REFERENCES public.fulfillment_shipments(id),
1074
+ delivered_at timestamptz NOT NULL,
1075
+ evidence_ref text NOT NULL CHECK (evidence_ref LIKE 'evidence:%')
1076
+ );
1077
+ CREATE FUNCTION public.fulfillment_record_alignment_arrival(p_key text,p_shipment uuid,p_arrived timestamptz,p_evidence text)
1078
+ RETURNS jsonb LANGUAGE plpgsql SECURITY INVOKER SET search_path=pg_catalog AS $$
1079
+ DECLARE v_arrival public.subscription_delivery_alignment_arrivals%ROWTYPE; v_replayed boolean; v_subscription uuid;
1080
+ BEGIN
1081
+ IF btrim(COALESCE(p_key,''))='' OR p_arrived IS NULL OR p_evidence NOT LIKE 'evidence:%' OR p_evidence IS NULL THEN RAISE EXCEPTION 'fulfillment_arrival_invalid' USING ERRCODE='22023'; END IF;
1082
+ -- Arrival commands lock subscription before shipment, then the delivery trigger
1083
+ -- locks the case. Direct shipment writes already hold the shipment lock when
1084
+ -- their AFTER trigger acquires the subscription; callers must use this command
1085
+ -- or acquire the subscription first to preserve the same lock order.
1086
+ SELECT cycle.subscription_id INTO v_subscription FROM public.fulfillment_shipments shipment
1087
+ JOIN public.commerce_orders order_row ON order_row.id=shipment.order_id
1088
+ JOIN public.subscription_cycles cycle ON cycle.id=order_row.subscription_cycle_id WHERE shipment.id=p_shipment;
1089
+ IF v_subscription IS NOT NULL THEN PERFORM id FROM public.subscriptions WHERE id=v_subscription FOR UPDATE; END IF;
1090
+ PERFORM id FROM public.fulfillment_shipments WHERE id=p_shipment FOR UPDATE;
1091
+ IF NOT FOUND THEN RAISE EXCEPTION 'fulfillment_arrival_shipment_missing' USING ERRCODE='22023'; END IF;
1092
+ SELECT * INTO v_arrival FROM public.subscription_delivery_alignment_arrivals WHERE request_key=p_key;
1093
+ v_replayed := FOUND;
1094
+ IF FOUND THEN
1095
+ IF v_arrival.shipment_id<>p_shipment OR v_arrival.delivered_at<>p_arrived OR v_arrival.evidence_ref<>p_evidence THEN RAISE EXCEPTION 'fulfillment_arrival_key_conflict' USING ERRCODE='23505'; END IF;
1096
+ ELSE
1097
+ INSERT INTO public.subscription_delivery_alignment_arrivals VALUES(p_key,p_shipment,p_arrived,p_evidence);
1098
+ -- Arrival evidence is supplied by the trusted selected binding, never inferred
1099
+ -- from status/receipt time. Preserve the first durable arrival on replay.
1100
+ UPDATE public.fulfillment_shipments SET delivered_at=p_arrived,updated_at=now() WHERE id=p_shipment AND delivered_at IS NULL;
1101
+ END IF;
1102
+ RETURN jsonb_build_object('replayed',v_replayed);
1103
+ END;
1104
+ $$;
1105
+ REVOKE ALL ON public.subscription_delivery_alignment_arrivals FROM PUBLIC, anon, authenticated;
1106
+ REVOKE ALL ON FUNCTION public.fulfillment_record_alignment_arrival(text,uuid,timestamptz,text) FROM PUBLIC, anon, authenticated;