@openlup/platform 0.22.0 → 0.24.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 (241) hide show
  1. package/CHANGELOG.md +440 -158
  2. package/README.md +141 -589
  3. package/dist/fulfillment/adapterConformance.js +1 -1
  4. package/dist/fulfillment/bindings.js +1 -1
  5. package/dist/fulfillment/contracts/conformance.d.ts +3 -1
  6. package/dist/fulfillment/contracts/dispatch.d.ts +45 -3
  7. package/dist/fulfillment/contracts/index.d.ts +3 -3
  8. package/dist/fulfillment/contracts/observation.d.ts +21 -1
  9. package/dist/fulfillment/contracts/readback.d.ts +21 -0
  10. package/dist/fulfillment/contracts/store.d.ts +3 -0
  11. package/dist/fulfillment/contracts/sync.d.ts +58 -2
  12. package/dist/fulfillment/dispatchProjection.d.ts +20 -1
  13. package/dist/fulfillment/dispatchProjection.js +97 -6
  14. package/dist/fulfillment/index.d.ts +3 -2
  15. package/dist/fulfillment/index.js +2 -2
  16. package/dist/fulfillment/migrations.d.ts +5 -0
  17. package/dist/fulfillment/migrations.js +1 -0
  18. package/dist/fulfillment/observationProjection.d.ts +24 -3
  19. package/dist/fulfillment/observationProjection.js +65 -12
  20. package/dist/fulfillment/omnipack/client.d.ts +3 -1
  21. package/dist/fulfillment/omnipack/client.js +31 -13
  22. package/dist/fulfillment/omnipack/index.d.ts +4 -1
  23. package/dist/fulfillment/omnipack/index.js +107 -45
  24. package/dist/fulfillment/omnipack/mappers.d.ts +7 -1
  25. package/dist/fulfillment/omnipack/mappers.js +24 -2
  26. package/dist/fulfillment/omnipack/observation.d.ts +11 -4
  27. package/dist/fulfillment/omnipack/observation.js +44 -29
  28. package/dist/fulfillment/omnipack/reconciliation.d.ts +15 -0
  29. package/dist/fulfillment/omnipack/reconciliation.js +55 -0
  30. package/dist/fulfillment/postgresDispatchStore.js +1 -1
  31. package/dist/fulfillment/postgresShipmentSpine.d.ts +8 -1
  32. package/dist/fulfillment/postgresShipmentSpine.js +17 -3
  33. package/dist/fulfillment/postgresStockSyncPort.js +6 -3
  34. package/dist/fulfillment/providerDispatchAcceptance.d.ts +1 -0
  35. package/dist/fulfillment/providerDispatchAcceptance.js +8 -2
  36. package/dist/fulfillment/providerDispatchWorker.d.ts +51 -0
  37. package/dist/fulfillment/providerDispatchWorker.js +65 -20
  38. package/dist/fulfillment/providerReconciliationWorker.d.ts +27 -19
  39. package/dist/fulfillment/providerReconciliationWorker.js +75 -19
  40. package/dist/fulfillment/providerStockSyncWorker.d.ts +5 -1
  41. package/dist/fulfillment/providerStockSyncWorker.js +229 -120
  42. package/dist/fulfillment/providerTrackingReferences.d.ts +3 -2
  43. package/dist/fulfillment/verifiedIngress.d.ts +38 -11
  44. package/dist/fulfillment/verifiedIngress.js +36 -8
  45. package/dist/invoicing/accountingCanonicalProviderLines.d.ts +5 -0
  46. package/dist/invoicing/accountingCanonicalProviderLines.js +104 -0
  47. package/dist/invoicing/accountingLineAllocation.d.ts +2 -0
  48. package/dist/invoicing/accountingLineAllocation.js +69 -0
  49. package/dist/invoicing/adapterConformance.d.ts +42 -0
  50. package/dist/invoicing/adapterConformance.js +296 -0
  51. package/dist/invoicing/artifacts.d.ts +7 -0
  52. package/dist/invoicing/artifacts.js +20 -0
  53. package/dist/invoicing/bindings.d.ts +46 -0
  54. package/dist/invoicing/bindings.js +69 -0
  55. package/dist/invoicing/capabilities.d.ts +11 -0
  56. package/dist/invoicing/capabilities.js +10 -0
  57. package/dist/invoicing/channelPolicy.d.ts +28 -0
  58. package/dist/invoicing/channelPolicy.js +27 -0
  59. package/dist/invoicing/continuity.d.ts +12 -0
  60. package/dist/invoicing/continuity.js +18 -0
  61. package/dist/invoicing/contracts/capabilities.d.ts +40 -0
  62. package/dist/invoicing/contracts/capabilities.js +1 -0
  63. package/dist/invoicing/contracts/companyLookup.d.ts +49 -0
  64. package/dist/invoicing/contracts/companyLookup.js +1 -0
  65. package/dist/invoicing/contracts/delivery.d.ts +74 -0
  66. package/dist/invoicing/contracts/delivery.js +1 -0
  67. package/dist/invoicing/contracts/facts.d.ts +110 -0
  68. package/dist/invoicing/contracts/facts.js +1 -0
  69. package/dist/invoicing/contracts/host.d.ts +245 -0
  70. package/dist/invoicing/contracts/host.js +1 -0
  71. package/dist/invoicing/contracts/index.d.ts +16 -0
  72. package/dist/invoicing/contracts/index.js +1 -0
  73. package/dist/invoicing/contracts/provider.d.ts +126 -0
  74. package/dist/invoicing/contracts/provider.js +1 -0
  75. package/dist/invoicing/contracts/settlement.d.ts +66 -0
  76. package/dist/invoicing/contracts/settlement.js +1 -0
  77. package/dist/invoicing/contracts/store.d.ts +277 -0
  78. package/dist/invoicing/contracts/store.js +1 -0
  79. package/dist/invoicing/contribution.d.ts +21 -0
  80. package/dist/invoicing/contribution.js +19 -0
  81. package/dist/invoicing/correction.d.ts +42 -0
  82. package/dist/invoicing/correction.js +44 -0
  83. package/dist/invoicing/delivery.d.ts +85 -0
  84. package/dist/invoicing/delivery.js +79 -0
  85. package/dist/invoicing/documentHistory.d.ts +50 -0
  86. package/dist/invoicing/documentHistory.js +213 -0
  87. package/dist/invoicing/documentSync.d.ts +24 -0
  88. package/dist/invoicing/documentSync.js +26 -0
  89. package/dist/invoicing/dutyCollector.d.ts +15 -0
  90. package/dist/invoicing/dutyCollector.js +35 -0
  91. package/dist/invoicing/fulfillmentTrigger.d.ts +56 -0
  92. package/dist/invoicing/fulfillmentTrigger.js +90 -0
  93. package/dist/invoicing/handoff.d.ts +37 -0
  94. package/dist/invoicing/handoff.js +62 -0
  95. package/dist/invoicing/index.d.ts +39 -0
  96. package/dist/invoicing/index.js +37 -0
  97. package/dist/invoicing/issue.d.ts +39 -0
  98. package/dist/invoicing/issue.js +55 -0
  99. package/dist/invoicing/migrations.d.ts +48 -0
  100. package/dist/invoicing/migrations.js +25 -0
  101. package/dist/invoicing/nativeUnsupported.d.ts +9 -0
  102. package/dist/invoicing/nativeUnsupported.js +22 -0
  103. package/dist/invoicing/paymentReadback.d.ts +20 -0
  104. package/dist/invoicing/paymentReadback.js +47 -0
  105. package/dist/invoicing/postgresInvoiceStore.d.ts +55 -0
  106. package/dist/invoicing/postgresInvoiceStore.js +344 -0
  107. package/dist/invoicing/readModel.d.ts +74 -0
  108. package/dist/invoicing/readModel.js +90 -0
  109. package/dist/invoicing/readback.d.ts +4 -0
  110. package/dist/invoicing/readback.js +30 -0
  111. package/dist/invoicing/reversal.d.ts +37 -0
  112. package/dist/invoicing/reversal.js +28 -0
  113. package/dist/invoicing/sellerRevision.d.ts +19 -0
  114. package/dist/invoicing/sellerRevision.js +14 -0
  115. package/dist/invoicing/settlementSync.d.ts +18 -0
  116. package/dist/invoicing/settlementSync.js +32 -0
  117. package/dist/invoicing/snapshot.d.ts +52 -0
  118. package/dist/invoicing/snapshot.js +141 -0
  119. package/dist/subscriptions/automaticRenewal.d.ts +44 -0
  120. package/dist/subscriptions/automaticRenewal.js +81 -0
  121. package/dist/subscriptions/automaticRenewalExecution.d.ts +21 -0
  122. package/dist/subscriptions/automaticRenewalExecution.js +44 -0
  123. package/dist/subscriptions/contracts/commands.d.ts +115 -0
  124. package/dist/subscriptions/contracts/commands.js +1 -0
  125. package/dist/subscriptions/contracts/dunning.d.ts +133 -0
  126. package/dist/subscriptions/contracts/dunning.js +1 -0
  127. package/dist/subscriptions/contracts/index.d.ts +6 -0
  128. package/dist/subscriptions/contracts/index.js +1 -0
  129. package/dist/subscriptions/contracts/messages.d.ts +43 -0
  130. package/dist/subscriptions/contracts/messages.js +1 -0
  131. package/dist/subscriptions/contracts/renewal.d.ts +533 -0
  132. package/dist/subscriptions/contracts/renewal.js +1 -0
  133. package/dist/subscriptions/contracts/stockWait.d.ts +73 -0
  134. package/dist/subscriptions/contracts/stockWait.js +1 -0
  135. package/dist/subscriptions/index.d.ts +39 -0
  136. package/dist/subscriptions/index.js +17 -0
  137. package/dist/subscriptions/paymentOutcomes.d.ts +8 -0
  138. package/dist/subscriptions/paymentOutcomes.js +27 -0
  139. package/dist/subscriptions/postgresRenewalStore.d.ts +20 -0
  140. package/dist/subscriptions/postgresRenewalStore.js +61 -0
  141. package/dist/subscriptions/postgresRetryStore.d.ts +78 -0
  142. package/dist/subscriptions/postgresRetryStore.js +142 -0
  143. package/dist/subscriptions/renewalCycleFailureMechanism.d.ts +34 -0
  144. package/dist/subscriptions/renewalCycleFailureMechanism.js +79 -0
  145. package/dist/subscriptions/renewalCycleMechanism.d.ts +46 -0
  146. package/dist/subscriptions/renewalCycleMechanism.js +157 -0
  147. package/dist/subscriptions/renewalCycleRefusalMechanism.d.ts +41 -0
  148. package/dist/subscriptions/renewalCycleRefusalMechanism.js +160 -0
  149. package/dist/subscriptions/renewalIdentity.d.ts +3 -0
  150. package/dist/subscriptions/renewalIdentity.js +4 -0
  151. package/package.json +23 -3
  152. package/release-gates.json +74 -7
  153. package/sql/20260809210000_accounting_document_rail.sql +889 -0
  154. package/sql/20260811150000_accounting_payout_rail.sql +333 -0
  155. package/sql/20260812130000_accounting_document_lifecycle_rail.sql +460 -0
  156. package/sql/20261022160000_subscription_retry_lifecycle.sql +328 -0
  157. package/sql/20261022165000_fulfillment_stock_evidence_replay_reopen.sql +30 -0
  158. package/sql/20261022190000_invoicing_durable_duties.sql +494 -0
  159. package/sql/20261022191000_invoicing_document_effects.sql +249 -0
  160. package/sql/20261022192000_invoicing_payment_decoupling.sql +144 -0
  161. package/sql/20261022195000_invoicing_seller_revision.sql +65 -0
  162. package/src/fulfillment/adapterConformance.ts +1 -1
  163. package/src/fulfillment/bindings.ts +1 -1
  164. package/src/fulfillment/contracts/conformance.ts +3 -1
  165. package/src/fulfillment/contracts/dispatch.ts +24 -5
  166. package/src/fulfillment/contracts/index.ts +3 -3
  167. package/src/fulfillment/contracts/observation.ts +11 -3
  168. package/src/fulfillment/contracts/readback.ts +13 -0
  169. package/src/fulfillment/contracts/store.ts +1 -0
  170. package/src/fulfillment/contracts/sync.ts +13 -2
  171. package/src/fulfillment/dispatchProjection.ts +94 -7
  172. package/src/fulfillment/index.ts +3 -2
  173. package/src/fulfillment/migrations.ts +1 -0
  174. package/src/fulfillment/observationProjection.ts +64 -13
  175. package/src/fulfillment/omnipack/client.ts +32 -13
  176. package/src/fulfillment/omnipack/index.ts +99 -39
  177. package/src/fulfillment/omnipack/mappers.ts +23 -2
  178. package/src/fulfillment/omnipack/observation.ts +45 -27
  179. package/src/fulfillment/omnipack/reconciliation.ts +56 -0
  180. package/src/fulfillment/postgresDispatchStore.ts +1 -1
  181. package/src/fulfillment/postgresShipmentSpine.ts +21 -4
  182. package/src/fulfillment/postgresStockSyncPort.ts +6 -3
  183. package/src/fulfillment/providerDispatchAcceptance.ts +9 -3
  184. package/src/fulfillment/providerDispatchWorker.ts +61 -20
  185. package/src/fulfillment/providerReconciliationWorker.ts +81 -25
  186. package/src/fulfillment/providerStockSyncWorker.ts +222 -126
  187. package/src/fulfillment/providerTrackingReferences.ts +3 -2
  188. package/src/fulfillment/verifiedIngress.ts +44 -11
  189. package/src/invoicing/accountingCanonicalProviderLines.ts +134 -0
  190. package/src/invoicing/accountingLineAllocation.ts +69 -0
  191. package/src/invoicing/adapterConformance.ts +249 -0
  192. package/src/invoicing/artifacts.ts +19 -0
  193. package/src/invoicing/bindings.ts +80 -0
  194. package/src/invoicing/capabilities.ts +9 -0
  195. package/src/invoicing/channelPolicy.ts +50 -0
  196. package/src/invoicing/continuity.ts +15 -0
  197. package/src/invoicing/contracts/capabilities.ts +15 -0
  198. package/src/invoicing/contracts/companyLookup.ts +13 -0
  199. package/src/invoicing/contracts/delivery.ts +24 -0
  200. package/src/invoicing/contracts/facts.ts +71 -0
  201. package/src/invoicing/contracts/host.ts +84 -0
  202. package/src/invoicing/contracts/index.ts +16 -0
  203. package/src/invoicing/contracts/provider.ts +78 -0
  204. package/src/invoicing/contracts/settlement.ts +39 -0
  205. package/src/invoicing/contracts/store.ts +102 -0
  206. package/src/invoicing/contribution.ts +28 -0
  207. package/src/invoicing/correction.ts +56 -0
  208. package/src/invoicing/delivery.ts +122 -0
  209. package/src/invoicing/documentHistory.ts +301 -0
  210. package/src/invoicing/documentSync.ts +51 -0
  211. package/src/invoicing/dutyCollector.ts +31 -0
  212. package/src/invoicing/fulfillmentTrigger.ts +144 -0
  213. package/src/invoicing/handoff.ts +71 -0
  214. package/src/invoicing/index.ts +39 -0
  215. package/src/invoicing/issue.ts +64 -0
  216. package/src/invoicing/migrations.ts +25 -0
  217. package/src/invoicing/nativeUnsupported.ts +24 -0
  218. package/src/invoicing/paymentReadback.ts +70 -0
  219. package/src/invoicing/postgresInvoiceStore.ts +334 -0
  220. package/src/invoicing/readModel.ts +80 -0
  221. package/src/invoicing/readback.ts +26 -0
  222. package/src/invoicing/reversal.ts +34 -0
  223. package/src/invoicing/sellerRevision.ts +20 -0
  224. package/src/invoicing/settlementSync.ts +37 -0
  225. package/src/invoicing/snapshot.ts +192 -0
  226. package/src/subscriptions/automaticRenewal.ts +87 -0
  227. package/src/subscriptions/automaticRenewalExecution.ts +39 -0
  228. package/src/subscriptions/contracts/commands.ts +46 -0
  229. package/src/subscriptions/contracts/dunning.ts +48 -0
  230. package/src/subscriptions/contracts/index.ts +6 -0
  231. package/src/subscriptions/contracts/messages.ts +18 -0
  232. package/src/subscriptions/contracts/renewal.ts +325 -0
  233. package/src/subscriptions/contracts/stockWait.ts +34 -0
  234. package/src/subscriptions/index.ts +24 -0
  235. package/src/subscriptions/paymentOutcomes.ts +23 -0
  236. package/src/subscriptions/postgresRenewalStore.ts +76 -0
  237. package/src/subscriptions/postgresRetryStore.ts +174 -0
  238. package/src/subscriptions/renewalCycleFailureMechanism.ts +119 -0
  239. package/src/subscriptions/renewalCycleMechanism.ts +225 -0
  240. package/src/subscriptions/renewalCycleRefusalMechanism.ts +215 -0
  241. package/src/subscriptions/renewalIdentity.ts +5 -0
@@ -0,0 +1,249 @@
1
+ -- Fenced document effects. No payment, subscription, shipment or outbox trigger.
2
+ CREATE TABLE public.invoicing_document_observations (
3
+ event_key text PRIMARY KEY,
4
+ document_id uuid NOT NULL REFERENCES public.accounting_documents(id),
5
+ observation jsonb NOT NULL,
6
+ observed_at timestamptz NOT NULL
7
+ );
8
+ CREATE TABLE public.invoicing_delivery_receipts (
9
+ delivery_id uuid PRIMARY KEY REFERENCES public.accounting_document_deliveries(id),
10
+ generation integer NOT NULL DEFAULT 0,
11
+ claim_token uuid,
12
+ lease_until timestamptz,
13
+ state text NOT NULL DEFAULT 'ready' CHECK (state IN ('ready','processing','sent','uncertain','failed')),
14
+ result jsonb
15
+ );
16
+ CREATE FUNCTION public.invoicing_request_reversal(p_key text,p_order uuid,p_reason text,p_evidence jsonb)
17
+ RETURNS jsonb LANGUAGE plpgsql SECURITY INVOKER SET search_path = pg_catalog AS $$
18
+ DECLARE
19
+ v_base public.invoicing_duties%ROWTYPE;
20
+ v_child public.invoicing_duties%ROWTYPE;
21
+ v_document public.accounting_documents%ROWTYPE;
22
+ v_reversal jsonb;
23
+ v_result jsonb;
24
+ v_facts jsonb;
25
+ v_prior public.invoicing_effect_receipts%ROWTYPE;
26
+ v_existing_document uuid;
27
+ v_ready uuid;
28
+ v_safe_void boolean;
29
+ BEGIN
30
+ IF nullif(btrim(p_key),'') IS NULL OR nullif(btrim(p_reason),'') IS NULL OR jsonb_typeof(p_evidence) IS DISTINCT FROM 'object' THEN
31
+ RAISE EXCEPTION 'invoicing_reversal_invalid' USING ERRCODE='22023'; END IF;
32
+ IF NOT pg_try_advisory_xact_lock(hashtextextended('invoicing:'||p_order::text,0)) THEN RAISE EXCEPTION 'invoicing_capture_busy' USING ERRCODE='55P03'; END IF;
33
+ PERFORM 1 FROM public.accounting_document_requests WHERE id=coalesce(
34
+ (SELECT request_id FROM public.invoicing_duties WHERE order_id=p_order AND parent_duty_id IS NULL),
35
+ (SELECT r.id FROM public.accounting_document_requests r JOIN public.accounting_document_operations o ON o.document_id=r.document_id
36
+ JOIN public.invoicing_duties d ON o.idempotency_key='invoicing:request:'||d.id::text
37
+ WHERE d.order_id=p_order AND d.parent_duty_id IS NULL AND r.request_kind='issue')) FOR UPDATE;
38
+ SELECT * INTO v_base FROM public.invoicing_duties WHERE order_id=p_order AND parent_duty_id IS NULL FOR UPDATE;
39
+ IF NOT FOUND THEN RETURN jsonb_build_object('kind','missing'); END IF;
40
+ v_reversal:=jsonb_build_object('key',p_key,'reason',p_reason,'evidence',p_evidence);
41
+ SELECT * INTO v_prior FROM public.invoicing_effect_receipts WHERE receipt_key='reversal-key:'||p_key;
42
+ IF FOUND AND (v_prior.duty_id IS DISTINCT FROM v_base.id OR v_prior.payload IS DISTINCT FROM v_reversal) THEN
43
+ RAISE EXCEPTION 'invoicing_reversal_key_conflict' USING ERRCODE='23505'; END IF;
44
+ INSERT INTO public.invoicing_effect_receipts(receipt_key,duty_id,payload) VALUES('reversal-key:'||p_key,v_base.id,v_reversal) ON CONFLICT DO NOTHING;
45
+ SELECT * INTO v_prior FROM public.invoicing_effect_receipts WHERE receipt_key='reversal-key:'||p_key;
46
+ IF v_prior.duty_id IS DISTINCT FROM v_base.id OR v_prior.payload IS DISTINCT FROM v_reversal THEN
47
+ RAISE EXCEPTION 'invoicing_reversal_key_conflict' USING ERRCODE='23505'; END IF;
48
+ -- Once a compensation intent exists, later aliases cannot change its reason/evidence.
49
+ IF v_base.reversal IS NOT NULL AND (v_base.reversal-'key') IS DISTINCT FROM (v_reversal-'key') THEN
50
+ RAISE EXCEPTION 'invoicing_reversal_conflict' USING ERRCODE='23505'; END IF;
51
+ UPDATE public.invoicing_duties SET reversal=coalesce(reversal,v_reversal) WHERE id=v_base.id;
52
+ IF v_base.status='voided' THEN RETURN jsonb_build_object('kind','voided','replayed',true); END IF;
53
+ IF v_base.status='blocked' THEN RETURN jsonb_build_object('kind','unsupported'); END IF;
54
+ IF v_base.status IN ('processing','unknown') OR v_base.claim_token IS NOT NULL OR v_base.lease_until IS NOT NULL THEN RETURN jsonb_build_object('kind','pending_readback'); END IF;
55
+ IF v_base.document_id IS NULL THEN
56
+ SELECT o.document_id,r.id INTO v_base.document_id,v_base.request_id FROM public.accounting_document_operations o
57
+ JOIN public.accounting_document_requests r ON r.document_id=o.document_id AND r.request_kind='issue'
58
+ WHERE o.idempotency_key='invoicing:request:'||v_base.id::text;
59
+ END IF;
60
+ -- A live unlinked local document is still an outstanding obligation.
61
+ IF EXISTS (SELECT 1 FROM public.accounting_documents a WHERE a.order_id=p_order
62
+ AND a.correction_of_document_id IS NULL AND a.status<>'voided'
63
+ AND a.id IS DISTINCT FROM v_base.document_id) THEN RETURN jsonb_build_object('kind','unsupported'); END IF;
64
+ v_safe_void:=coalesce(
65
+ (v_base.last_result->>'kind'='retryable' AND v_base.last_result->'evidence'->>'phase'='before_send')
66
+ OR (v_base.last_result->>'kind'='absent' AND v_base.last_result->'evidence'->>'kind'='authoritative_absence'
67
+ AND v_base.last_result->'evidence'->>'providerCreateIdentity'='invoicing:'||v_base.id::text),false);
68
+ IF v_base.status<>'issued' AND EXISTS (SELECT 1 FROM public.accounting_document_requests
69
+ WHERE id=v_base.request_id AND (status='processing' OR claim_token IS NOT NULL
70
+ OR (attempts>0 AND NOT v_safe_void))) THEN RETURN jsonb_build_object('kind','pending_readback'); END IF;
71
+ IF v_base.document_id IS NULL OR (v_base.status<>'issued' AND (v_base.generation=0 OR v_safe_void)) THEN
72
+ IF v_base.document_id IS NOT NULL THEN
73
+ PERFORM public.accounting_document_advance('invoicing:void:'||v_base.id::text,v_base.document_id,'voided');
74
+ UPDATE public.accounting_document_requests SET status='discarded',claim_token=NULL,updated_at=now() WHERE id=v_base.request_id;
75
+ END IF;
76
+ UPDATE public.invoicing_duties SET status='voided' WHERE id=v_base.id;
77
+ RETURN jsonb_build_object('kind','voided','replayed',false);
78
+ END IF;
79
+ SELECT * INTO v_child FROM public.invoicing_duties WHERE parent_duty_id=v_base.id;
80
+ IF FOUND THEN RETURN jsonb_build_object('kind','correction_requested','dutyId',v_child.id,'replayed',true); END IF;
81
+ SELECT * INTO v_document FROM public.accounting_documents WHERE id=v_base.document_id FOR UPDATE;
82
+ IF v_document.provider_reference IS NULL THEN RETURN jsonb_build_object('kind','pending_readback'); END IF;
83
+ SELECT id INTO v_existing_document FROM public.accounting_documents WHERE correction_of_document_id=v_base.document_id;
84
+ IF FOUND THEN
85
+ -- An old native child/request is an existing obligation, even when its M09
86
+ -- sidecar is new. Do not call the constructor again or refreeze its seller.
87
+ v_result:=jsonb_build_object('document',jsonb_build_object('id',v_existing_document));
88
+ ELSE
89
+ v_result:=public.accounting_document_request_correction('invoicing:correction:'||v_base.id::text,v_base.document_id,p_reason,p_evidence);
90
+ END IF;
91
+ INSERT INTO public.invoicing_duties(order_id,parent_duty_id,document_id,request_id,original_key,status,seller_policy_version)
92
+ SELECT p_order,v_base.id,(v_result->'document'->>'id')::uuid,r.id,p_key,CASE WHEN doc.provider_reference IS NOT NULL THEN 'issued' WHEN doc.status='voided' THEN 'voided'
93
+ WHEN r.attempts>0 OR r.status<>'pending' OR r.claim_token IS NOT NULL THEN 'unsupported' ELSE 'ready' END,
94
+ CASE WHEN v_existing_document IS NULL AND p_evidence->>'sellerPolicyVersion'='invoicing.seller.v1' THEN 'invoicing.seller.v1' END
95
+ FROM public.accounting_document_requests r JOIN public.accounting_documents doc ON doc.id=r.document_id WHERE r.document_id=(v_result->'document'->>'id')::uuid AND r.request_kind='correction'
96
+ RETURNING * INTO v_child;
97
+ UPDATE public.accounting_document_requests SET available_at='infinity'
98
+ WHERE id=v_child.request_id AND v_child.status='ready' AND status='pending' AND attempts=0 AND claim_token IS NULL
99
+ RETURNING id INTO v_ready;
100
+ IF v_child.status='ready' AND v_ready IS NULL THEN
101
+ UPDATE public.invoicing_duties SET status='unsupported' WHERE id=v_child.id RETURNING * INTO v_child;
102
+ END IF;
103
+ SELECT facts INTO v_facts FROM public.invoicing_fact_revisions WHERE duty_id=v_base.id;
104
+ INSERT INTO public.invoicing_fact_revisions(duty_id,revision_ref,facts) VALUES(v_child.id,'correction:'||v_base.id::text,
105
+ v_facts||jsonb_build_object('before',v_facts,'after',jsonb_build_object('totalGrossMinor',0,'totalNetMinor',0,'totalTaxMinor',0),
106
+ 'parentDocumentId',v_base.document_id,'parentExternalId',v_document.provider_reference,'reason',p_reason));
107
+ RETURN jsonb_build_object('kind',CASE WHEN v_child.status='unsupported' THEN 'unsupported' ELSE 'correction_requested' END,'dutyId',v_child.id,'replayed',false);
108
+ END;
109
+ $$;
110
+ CREATE FUNCTION public.invoicing_finish_correction(p_duty uuid,p_token uuid,p_generation integer,p_external text,p_number text,p_proof jsonb)
111
+ RETURNS jsonb LANGUAGE plpgsql SECURITY INVOKER SET search_path = pg_catalog AS $$
112
+ DECLARE v_duty public.invoicing_duties%ROWTYPE; v_base public.invoicing_duties%ROWTYPE; v_payload jsonb; v_prior jsonb; v_parent_external text;
113
+ BEGIN
114
+ -- Parent request precedes child request, then the corresponding duties.
115
+ SELECT * INTO v_base FROM public.invoicing_duties WHERE id=(SELECT parent_duty_id FROM public.invoicing_duties WHERE id=p_duty);
116
+ PERFORM 1 FROM public.accounting_document_requests WHERE id=v_base.request_id FOR UPDATE;
117
+ PERFORM 1 FROM public.accounting_document_requests WHERE id=(SELECT request_id FROM public.invoicing_duties WHERE id=p_duty) FOR UPDATE;
118
+ PERFORM 1 FROM public.invoicing_duties WHERE id=v_base.id FOR UPDATE;
119
+ SELECT * INTO v_duty FROM public.invoicing_duties WHERE id=p_duty FOR UPDATE;
120
+ IF NOT FOUND OR v_duty.parent_duty_id IS NULL THEN RAISE EXCEPTION 'invoicing_correction_missing' USING ERRCODE='22023'; END IF;
121
+ v_payload:=jsonb_build_object('externalId',p_external,'number',p_number,'proof',p_proof);
122
+ SELECT payload INTO v_prior FROM public.invoicing_effect_receipts WHERE receipt_key='correction:'||p_duty::text;
123
+ IF FOUND THEN
124
+ IF v_prior->>'externalId' IS DISTINCT FROM p_external OR v_prior->>'number' IS DISTINCT FROM p_number
125
+ OR v_prior->'proof'->>'parentExternalId' IS DISTINCT FROM p_proof->>'parentExternalId' THEN RAISE EXCEPTION 'invoicing_correction_identity_conflict' USING ERRCODE='23505'; END IF;
126
+ RETURN jsonb_build_object('kind','issued','replayed',true);
127
+ END IF;
128
+ IF v_duty.status<>'processing' OR v_duty.claim_token IS DISTINCT FROM p_token OR v_duty.generation IS DISTINCT FROM p_generation OR v_duty.lease_until<=now()
129
+ THEN RETURN jsonb_build_object('kind','stale'); END IF;
130
+ SELECT provider_reference INTO v_parent_external FROM public.accounting_documents WHERE id=v_base.document_id;
131
+ IF nullif(btrim(p_external),'') IS NULL OR p_external=v_parent_external OR p_proof->>'kind' IS NULL OR p_proof->>'kind' NOT IN ('issued','readback_found')
132
+ OR p_proof->>'externalId' IS DISTINCT FROM p_external OR p_proof->>'parentExternalId' IS DISTINCT FROM v_parent_external
133
+ OR p_proof->>'providerCreateIdentity' IS DISTINCT FROM 'invoicing:'||p_duty::text
134
+ OR (p_proof->>'kind'='issued' AND (v_duty.preflight_generation IS DISTINCT FROM p_generation OR v_duty.last_result->>'action'<>'create'))
135
+ THEN RAISE EXCEPTION 'invoicing_correction_proof_invalid' USING ERRCODE='22023'; END IF;
136
+ PERFORM public.accounting_document_advance('invoicing:correction-request:'||p_duty::text,v_duty.document_id,'issue_requested');
137
+ PERFORM public.accounting_document_advance('invoicing:correction-issue:'||p_duty::text,v_duty.document_id,'issued',p_external,jsonb_build_object('number',p_number));
138
+ PERFORM public.accounting_document_advance('invoicing:corrected:'||p_duty::text,v_base.document_id,'corrected');
139
+ UPDATE public.accounting_document_requests SET status='succeeded',claim_token=NULL,error=NULL,updated_at=now() WHERE id=v_duty.request_id;
140
+ UPDATE public.invoicing_duties SET status='issued',claim_token=NULL,lease_until=NULL,last_result=v_payload WHERE id=p_duty;
141
+ INSERT INTO public.invoicing_effect_receipts(receipt_key,duty_id,payload) VALUES('correction:'||p_duty::text,p_duty,v_payload);
142
+ RETURN jsonb_build_object('kind','issued','replayed',false);
143
+ END;
144
+ $$;
145
+ CREATE FUNCTION public.invoicing_record_observation(p_key text,p_document uuid,p_observation jsonb)
146
+ RETURNS jsonb LANGUAGE plpgsql SECURITY INVOKER SET search_path = pg_catalog AS $$
147
+ DECLARE v_prior public.invoicing_document_observations%ROWTYPE; v_state text;
148
+ BEGIN
149
+ IF nullif(btrim(p_key),'') IS NULL OR p_observation->>'observedAt' IS NULL OR p_observation->>'kind' IS NULL
150
+ OR p_observation->>'kind' NOT IN ('clearance','artifacts','unsupported') THEN
151
+ RAISE EXCEPTION 'invoicing_observation_invalid' USING ERRCODE='22023'; END IF;
152
+ PERFORM 1 FROM public.accounting_documents WHERE id=p_document FOR UPDATE;
153
+ SELECT * INTO v_prior FROM public.invoicing_document_observations WHERE event_key=p_key;
154
+ IF FOUND THEN
155
+ IF v_prior.document_id IS DISTINCT FROM p_document OR v_prior.observation IS DISTINCT FROM p_observation THEN
156
+ RAISE EXCEPTION 'invoicing_observation_conflict' USING ERRCODE='23505'; END IF;
157
+ RETURN jsonb_build_object('replayed',true);
158
+ END IF;
159
+ IF p_observation->>'kind'='clearance' THEN
160
+ v_state:=p_observation->>'state';
161
+ IF v_state IS NULL OR v_state NOT IN ('not_submitted','pending','accepted','rejected','not_required')
162
+ OR (v_state='accepted' AND nullif(btrim(p_observation->>'reference'),'') IS NULL) THEN
163
+ RAISE EXCEPTION 'invoicing_clearance_invalid' USING ERRCODE='22023'; END IF;
164
+ -- Older observations remain evidence without replacing newer projection.
165
+ IF NOT EXISTS(SELECT 1 FROM public.invoicing_document_observations WHERE document_id=p_document
166
+ AND observation->>'kind'='clearance' AND observed_at>(p_observation->>'observedAt')::timestamptz) THEN
167
+ PERFORM public.accounting_document_record_submission(p_document,v_state,p_observation->>'reference',(p_observation->>'observedAt')::timestamptz);
168
+ END IF;
169
+ END IF;
170
+ INSERT INTO public.invoicing_document_observations VALUES(p_key,p_document,p_observation,(p_observation->>'observedAt')::timestamptz);
171
+ RETURN jsonb_build_object('replayed',false,'kind',p_observation->>'kind');
172
+ END;
173
+ $$;
174
+ CREATE FUNCTION public.invoicing_claim_deliveries(p_limit integer,p_visibility integer,p_require_clearance boolean,p_order_id uuid DEFAULT NULL)
175
+ RETURNS jsonb LANGUAGE plpgsql SECURITY INVOKER SET search_path = pg_catalog AS $$
176
+ DECLARE v_delivery public.accounting_document_deliveries%ROWTYPE; v_receipt public.invoicing_delivery_receipts%ROWTYPE; v_rows jsonb:='[]';
177
+ BEGIN
178
+ UPDATE public.invoicing_delivery_receipts SET state='uncertain',claim_token=NULL,lease_until=NULL,
179
+ result=jsonb_build_object('kind','uncertain','receipt',NULL,'evidence',jsonb_build_object('code','lease_expired'))
180
+ WHERE state='processing' AND lease_until<=clock_timestamp();
181
+ FOR v_delivery IN SELECT d.* FROM public.accounting_document_deliveries d
182
+ JOIN public.accounting_documents doc ON doc.id=d.document_id
183
+ WHERE (p_order_id IS NULL OR doc.order_id=p_order_id)
184
+ AND d.status IN ('pending','failed') AND d.available_at<=now() AND doc.provider_reference IS NOT NULL
185
+ AND doc.status IN ('issued','accepted','corrected')
186
+ AND (p_require_clearance IS NOT TRUE OR doc.submission_required IS NOT TRUE OR doc.submission_state='accepted')
187
+ AND NOT EXISTS (SELECT 1 FROM public.invoicing_delivery_receipts s WHERE s.delivery_id=d.id AND s.state IN ('processing','sent','uncertain'))
188
+ ORDER BY d.created_at,d.id LIMIT least(greatest(coalesce(p_limit,10),1),100) FOR UPDATE OF d, doc SKIP LOCKED LOOP
189
+ INSERT INTO public.invoicing_delivery_receipts(delivery_id,generation) VALUES(v_delivery.id,v_delivery.attempts) ON CONFLICT DO NOTHING;
190
+ SELECT * INTO v_receipt FROM public.invoicing_delivery_receipts WHERE delivery_id=v_delivery.id FOR UPDATE;
191
+ IF v_receipt.state IN ('processing','sent','uncertain') THEN CONTINUE; END IF;
192
+ UPDATE public.invoicing_delivery_receipts SET generation=greatest(generation,v_delivery.attempts)+1,claim_token=gen_random_uuid(),state='processing',
193
+ lease_until=now()+make_interval(secs=>least(greatest(coalesce(p_visibility,300),1),3600)) WHERE delivery_id=v_delivery.id RETURNING * INTO v_receipt;
194
+ UPDATE public.accounting_document_deliveries SET status='processing',attempts=v_receipt.generation,lease_until='infinity',updated_at=now() WHERE id=v_delivery.id;
195
+ v_rows:=v_rows||jsonb_build_array(to_jsonb(v_delivery)||to_jsonb(v_receipt));
196
+ END LOOP;
197
+ RETURN v_rows;
198
+ END;
199
+ $$;
200
+ CREATE FUNCTION public.invoicing_settle_delivery(p_delivery uuid,p_token uuid,p_generation integer,p_kind text,p_receipt text,p_evidence jsonb)
201
+ RETURNS jsonb LANGUAGE plpgsql SECURITY INVOKER SET search_path = pg_catalog AS $$
202
+ DECLARE v_row public.invoicing_delivery_receipts%ROWTYPE; v_payload jsonb;
203
+ BEGIN
204
+ PERFORM 1 FROM public.accounting_document_deliveries WHERE id=p_delivery FOR UPDATE;
205
+ SELECT * INTO v_row FROM public.invoicing_delivery_receipts WHERE delivery_id=p_delivery FOR UPDATE;
206
+ v_payload:=jsonb_build_object('kind',p_kind,'receipt',p_receipt,'evidence',p_evidence);
207
+ IF v_row.state='sent' THEN
208
+ IF v_row.result IS DISTINCT FROM v_payload THEN RAISE EXCEPTION 'invoicing_delivery_conflict' USING ERRCODE='23505'; END IF;
209
+ RETURN jsonb_build_object('kind','sent','replayed',true);
210
+ END IF;
211
+ IF v_row.delivery_id IS NULL OR v_row.state<>'processing' OR v_row.claim_token IS DISTINCT FROM p_token OR v_row.generation IS DISTINCT FROM p_generation
212
+ OR v_row.lease_until IS NULL OR v_row.lease_until<=clock_timestamp()
213
+ THEN RETURN jsonb_build_object('kind','stale'); END IF;
214
+ IF p_kind IS NULL OR p_kind NOT IN ('sent','uncertain','failed_before_send') OR jsonb_typeof(p_evidence) IS DISTINCT FROM 'object'
215
+ OR (p_kind='sent' AND nullif(btrim(p_receipt),'') IS NULL)
216
+ OR (p_kind<>'sent' AND p_receipt IS NOT NULL)
217
+ THEN RAISE EXCEPTION 'invoicing_delivery_evidence_invalid' USING ERRCODE='22023'; END IF;
218
+ UPDATE public.invoicing_delivery_receipts SET state=CASE WHEN p_kind='failed_before_send' THEN 'failed' ELSE p_kind END,
219
+ result=v_payload,claim_token=NULL,lease_until=NULL WHERE delivery_id=p_delivery;
220
+ IF p_kind='sent' THEN
221
+ UPDATE public.accounting_document_deliveries SET status='succeeded',receipt_reference=p_receipt,delivered_at=now(),lease_until=NULL,updated_at=now() WHERE id=p_delivery;
222
+ INSERT INTO public.accounting_document_delivery_attempts(delivery_id,attempt_ordinal,outcome,receipt_reference) VALUES(p_delivery,p_generation,'delivered',p_receipt);
223
+ ELSIF p_kind='failed_before_send' THEN
224
+ UPDATE public.accounting_document_deliveries SET status='failed',lease_until=NULL,
225
+ available_at=CASE WHEN p_evidence->'retryable'='false'::jsonb THEN 'infinity'::timestamptz
226
+ ELSE now()+make_interval(secs=>CASE WHEN jsonb_typeof(p_evidence->'retrySeconds')='number' THEN
227
+ CASE WHEN (p_evidence->>'retrySeconds')::numeric=trunc((p_evidence->>'retrySeconds')::numeric)
228
+ THEN least(greatest((p_evidence->>'retrySeconds')::numeric,60),86400)::integer ELSE 900 END
229
+ ELSE 900 END) END,
230
+ last_error=p_evidence,updated_at=now() WHERE id=p_delivery;
231
+ INSERT INTO public.accounting_document_delivery_attempts(delivery_id,attempt_ordinal,outcome,error) VALUES(p_delivery,p_generation,'failed',p_evidence);
232
+ END IF;
233
+ RETURN jsonb_build_object('kind',p_kind,'replayed',false);
234
+ END;
235
+ $$;
236
+
237
+ REVOKE ALL ON TABLE public.invoicing_document_observations FROM PUBLIC, anon, authenticated;
238
+
239
+ REVOKE ALL ON TABLE public.invoicing_delivery_receipts FROM PUBLIC, anon, authenticated;
240
+
241
+ REVOKE ALL ON FUNCTION public.invoicing_request_reversal(text,uuid,text,jsonb) FROM PUBLIC, anon, authenticated;
242
+
243
+ REVOKE ALL ON FUNCTION public.invoicing_finish_correction(uuid,uuid,integer,text,text,jsonb) FROM PUBLIC, anon, authenticated;
244
+
245
+ REVOKE ALL ON FUNCTION public.invoicing_record_observation(text,uuid,jsonb) FROM PUBLIC, anon, authenticated;
246
+
247
+ REVOKE ALL ON FUNCTION public.invoicing_claim_deliveries(integer,integer,boolean,uuid) FROM PUBLIC, anon, authenticated;
248
+
249
+ REVOKE ALL ON FUNCTION public.invoicing_settle_delivery(uuid,uuid,integer,text,text,jsonb) FROM PUBLIC, anon, authenticated;
@@ -0,0 +1,144 @@
1
+ CREATE OR REPLACE FUNCTION public.payment_truth_apply_terminal(
2
+ p_event_id uuid,
3
+ p_idempotency_key text,
4
+ p_outcome text,
5
+ p_occurred_at timestamptz,
6
+ p_failure_class text,
7
+ p_failure_reason text,
8
+ p_source_evidence jsonb,
9
+ p_dunning_template_slug text,
10
+ p_recovery_url_path text,
11
+ p_recovery_template_slug text
12
+ )
13
+ RETURNS void
14
+ LANGUAGE plpgsql
15
+ SET search_path = public, pg_catalog
16
+ AS $$
17
+ DECLARE
18
+ v_event public.payment_truth_events%ROWTYPE;
19
+ v_intent public.commerce_settlement_intents%ROWTYPE;
20
+ v_order public.commerce_orders%ROWTYPE;
21
+ v_cycle public.subscription_cycles%ROWTYPE;
22
+ v_subscription public.subscriptions%ROWTYPE;
23
+ v_settlement jsonb;
24
+ v_dunning jsonb;
25
+ v_accounting jsonb;
26
+ v_positions jsonb;
27
+ v_open_case_id uuid;
28
+ v_transition_id uuid;
29
+ v_dunning_case_id uuid;
30
+ v_accounting_document_id uuid;
31
+ BEGIN
32
+ SELECT * INTO v_event FROM public.payment_truth_events WHERE id = p_event_id FOR UPDATE;
33
+ IF NOT FOUND THEN RAISE EXCEPTION 'payment_truth_event_not_found' USING ERRCODE = '22023'; END IF;
34
+ IF v_event.state = 'settled' THEN RETURN; END IF;
35
+ IF p_outcome NOT IN ('captured', 'refused') THEN
36
+ RAISE EXCEPTION 'payment_truth_terminal_outcome_invalid' USING ERRCODE = '22023';
37
+ END IF;
38
+ IF p_outcome = 'refused' AND p_failure_class IS NULL THEN
39
+ RAISE EXCEPTION 'payment_truth_refusal_classification_required' USING ERRCODE = '22023';
40
+ END IF;
41
+ IF p_failure_class IS NOT NULL AND p_failure_class NOT IN (
42
+ 'authorization_refused', 'insufficient_funds', 'method_unavailable',
43
+ 'risk_refused', 'unknown_refusal'
44
+ ) THEN
45
+ RAISE EXCEPTION 'payment_truth_failure_class_invalid' USING ERRCODE = '22023';
46
+ END IF;
47
+
48
+ SELECT * INTO v_intent
49
+ FROM public.commerce_settlement_intents WHERE id = v_event.settlement_intent_id;
50
+ SELECT * INTO v_order FROM public.commerce_orders WHERE id = v_intent.order_id;
51
+ IF v_intent.amount_minor IS DISTINCT FROM v_event.amount_minor
52
+ OR v_intent.currency_code IS DISTINCT FROM v_event.currency_code
53
+ THEN
54
+ RAISE EXCEPTION 'payment_truth_settlement_money_mismatch' USING ERRCODE = '22023';
55
+ END IF;
56
+
57
+ v_settlement := public.commerce_record_settlement(
58
+ p_idempotency_key || ':settlement',
59
+ v_event.settlement_intent_id,
60
+ CASE WHEN p_outcome = 'captured' THEN 'succeeded' ELSE 'failed' END,
61
+ NULL,
62
+ jsonb_build_object(
63
+ 'paymentTruthEventId', v_event.id,
64
+ 'sourceEvidence', COALESCE(p_source_evidence, '{}'::jsonb),
65
+ 'reason', p_failure_reason,
66
+ 'failureClass', p_failure_class
67
+ ),
68
+ p_occurred_at
69
+ );
70
+ v_transition_id := (v_settlement->>'transitionId')::uuid;
71
+
72
+ IF p_outcome = 'captured' THEN
73
+ -- Capture owed work only; no document construction or accounting call here.
74
+ DECLARE v_capture_timeout text := current_setting('lock_timeout');
75
+ BEGIN
76
+ PERFORM set_config('lock_timeout', '100ms', true);
77
+ PERFORM public.invoicing_capture_duty(
78
+ p_idempotency_key || ':accounting', 'paid', v_transition_id, v_order.id,
79
+ jsonb_build_object('settlementIntentId', v_intent.id, 'amountMinor', v_intent.amount_minor, 'currency', v_intent.currency_code),
80
+ p_occurred_at
81
+ );
82
+ PERFORM set_config('lock_timeout', v_capture_timeout, true);
83
+ EXCEPTION WHEN OTHERS OR query_canceled THEN
84
+ -- query_canceled during capture is absorbed so settlement/recovery commit.
85
+ -- Append-only, no FK locks or conflicting unique key. The retained paid
86
+ -- transition is also durable retry evidence if host ACLs deny this marker.
87
+ BEGIN
88
+ PERFORM set_config('lock_timeout','100ms',true);
89
+ INSERT INTO public.invoicing_capture_faults(order_id,source_kind,source_id,attempts,state,available_at)
90
+ VALUES(v_order.id,'paid',v_transition_id,1,'retryable',now());
91
+ PERFORM set_config('lock_timeout',v_capture_timeout,true);
92
+ EXCEPTION WHEN OTHERS OR query_canceled THEN NULL;
93
+ END;
94
+ END;
95
+
96
+ IF v_order.subscription_cycle_id IS NOT NULL THEN
97
+ SELECT id INTO v_open_case_id
98
+ FROM public.subscription_dunning_cases
99
+ WHERE cycle_id = v_order.subscription_cycle_id AND status = 'open'
100
+ FOR UPDATE;
101
+ IF v_open_case_id IS NOT NULL THEN
102
+ PERFORM public.dunning_lifecycle_record_recovery(
103
+ v_open_case_id, p_occurred_at, p_recovery_template_slug
104
+ );
105
+ END IF;
106
+ END IF;
107
+ ELSE
108
+ IF v_order.subscription_cycle_id IS NOT NULL THEN
109
+ SELECT * INTO v_cycle
110
+ FROM public.subscription_cycles WHERE id = v_order.subscription_cycle_id;
111
+ SELECT * INTO v_subscription
112
+ FROM public.subscriptions WHERE id = v_cycle.subscription_id;
113
+ v_dunning := public.dunning_lifecycle_open_case(
114
+ 'payment-truth:dunning:' || v_cycle.id::text,
115
+ v_subscription.id,
116
+ v_cycle.id,
117
+ v_subscription.client_id,
118
+ 1,
119
+ NULL,
120
+ p_failure_class,
121
+ p_failure_reason,
122
+ p_occurred_at,
123
+ v_event.amount_minor,
124
+ v_event.currency_code,
125
+ p_dunning_template_slug,
126
+ p_recovery_url_path
127
+ );
128
+ v_dunning_case_id := (v_dunning->>'caseId')::uuid;
129
+ END IF;
130
+ END IF;
131
+
132
+ UPDATE public.payment_truth_events
133
+ SET state = 'settled',
134
+ outcome = p_outcome,
135
+ failure_class = CASE WHEN p_outcome = 'refused' THEN p_failure_class ELSE NULL END,
136
+ failure_reason = CASE WHEN p_outcome = 'refused' THEN p_failure_reason ELSE NULL END,
137
+ settlement_transition_id = v_transition_id,
138
+ dunning_case_id = v_dunning_case_id,
139
+ accounting_document_id = v_accounting_document_id,
140
+ settled_at = p_occurred_at,
141
+ updated_at = p_occurred_at
142
+ WHERE id = v_event.id;
143
+ END;
144
+ $$;
@@ -0,0 +1,65 @@
1
+ -- Eligibility is a creation marker, never a wall-clock inference or historical backfill.
2
+ CREATE TABLE public.invoicing_seller_revisions (
3
+ duty_id uuid PRIMARY KEY REFERENCES public.invoicing_duties(id),
4
+ eligibility_version text NOT NULL CHECK (eligibility_version='invoicing.seller.v1'),
5
+ revision_ref text,
6
+ seller_facts jsonb,
7
+ captured_at timestamptz,
8
+ capture_generation integer,
9
+ CHECK ((seller_facts IS NULL AND revision_ref IS NULL AND captured_at IS NULL AND capture_generation IS NULL)
10
+ OR (jsonb_typeof(seller_facts)='object' AND revision_ref IS NOT NULL AND captured_at IS NOT NULL AND capture_generation IS NOT NULL))
11
+ );
12
+ CREATE FUNCTION public.invoicing_request_with_seller_policy(p_key text,p_source_kind text,p_source_id uuid,p_order_id uuid,p_evidence jsonb,p_occurred_at timestamptz)
13
+ RETURNS jsonb LANGUAGE plpgsql SECURITY INVOKER SET search_path = pg_catalog AS $$
14
+ DECLARE v_result jsonb; v_existing boolean; v_prior_duty boolean; v_duty uuid; v_timeout text:=current_setting('lock_timeout');
15
+ BEGIN
16
+ IF p_source_kind='correction' THEN
17
+ RETURN public.invoicing_request_reversal(p_key,p_order_id,p_evidence->>'reason',p_evidence||jsonb_build_object('sellerPolicyVersion','invoicing.seller.v1'));
18
+ END IF;
19
+ IF NOT pg_try_advisory_xact_lock(hashtextextended('invoicing:'||p_order_id::text,0)) THEN RAISE EXCEPTION 'invoicing_capture_busy' USING ERRCODE='55P03'; END IF;
20
+ PERFORM set_config('lock_timeout','100ms',true);
21
+ PERFORM 1 FROM public.commerce_orders WHERE id=p_order_id FOR UPDATE;
22
+ -- A new sidecar around an older document/request is still a legacy obligation.
23
+ v_prior_duty:=EXISTS(SELECT 1 FROM public.invoicing_duties WHERE order_id=p_order_id AND parent_duty_id IS NULL);
24
+ v_existing:=EXISTS(SELECT 1 FROM public.invoicing_duties WHERE order_id=p_order_id AND parent_duty_id IS NULL)
25
+ OR EXISTS(SELECT 1 FROM public.accounting_documents WHERE order_id=p_order_id AND correction_of_document_id IS NULL);
26
+ v_result:=public.invoicing_capture_duty(p_key,p_source_kind,p_source_id,p_order_id,p_evidence,p_occurred_at);
27
+ v_duty:=(v_result->>'dutyId')::uuid;
28
+ IF v_existing AND NOT v_prior_duty THEN UPDATE public.invoicing_duties SET seller_policy_version=NULL WHERE id=v_duty; END IF;
29
+ IF NOT v_existing THEN INSERT INTO public.invoicing_seller_revisions(duty_id,eligibility_version) VALUES(v_duty,'invoicing.seller.v1') ON CONFLICT DO NOTHING; END IF;
30
+ PERFORM set_config('lock_timeout',v_timeout,true);
31
+ RETURN v_result;
32
+ END;
33
+ $$;
34
+ CREATE FUNCTION public.invoicing_claim_with_seller(p_limit integer,p_visibility integer,p_provider text,p_seller jsonb,p_revision text,p_order_id uuid DEFAULT NULL)
35
+ RETURNS jsonb LANGUAGE plpgsql SECURITY INVOKER SET search_path = pg_catalog AS $$
36
+ DECLARE v_claim jsonb; v_seller public.invoicing_seller_revisions%ROWTYPE; v_rows jsonb:='[]';
37
+ BEGIN
38
+ IF jsonb_typeof(p_seller) IS DISTINCT FROM 'object' OR nullif(btrim(p_revision),'') IS NULL THEN
39
+ RAISE EXCEPTION 'invoicing_seller_required' USING ERRCODE='22023'; END IF;
40
+ FOR v_claim IN SELECT value FROM jsonb_array_elements(public.invoicing_claim_duties(p_limit,p_visibility,p_provider,p_order_id)) LOOP
41
+ -- Generation advances only in invoicing_claim_duties: 1 is the first-ever claim.
42
+ -- A pre-wrapper claim must never acquire a seller revision on retry.
43
+ INSERT INTO public.invoicing_seller_revisions(duty_id,eligibility_version)
44
+ SELECT id,'invoicing.seller.v1' FROM public.invoicing_duties WHERE id=(v_claim->>'id')::uuid AND seller_policy_version='invoicing.seller.v1' AND (v_claim->>'generation')::integer=1
45
+ ON CONFLICT DO NOTHING;
46
+ SELECT * INTO v_seller FROM public.invoicing_seller_revisions WHERE duty_id=(v_claim->>'id')::uuid FOR UPDATE;
47
+ IF FOUND AND (v_seller.seller_facts IS NOT NULL OR (v_claim->>'generation')::integer=1) THEN
48
+ UPDATE public.invoicing_seller_revisions SET seller_facts=coalesce(seller_facts,p_seller),revision_ref=coalesce(revision_ref,p_revision),
49
+ captured_at=coalesce(captured_at,now()),capture_generation=coalesce(capture_generation,(v_claim->>'generation')::integer)
50
+ WHERE duty_id=v_seller.duty_id RETURNING * INTO v_seller;
51
+ v_claim:=v_claim||jsonb_build_object('seller',v_seller.seller_facts,'sellerRevision',v_seller.revision_ref,'sellerMode','frozen');
52
+ ELSE
53
+ v_claim:=v_claim||jsonb_build_object('seller',p_seller,'sellerRevision',p_revision,'sellerMode','legacy_fallback');
54
+ END IF;
55
+ v_rows:=v_rows||jsonb_build_array(v_claim);
56
+ END LOOP;
57
+ RETURN v_rows;
58
+ END;
59
+ $$;
60
+
61
+ REVOKE ALL ON TABLE public.invoicing_seller_revisions FROM PUBLIC, anon, authenticated;
62
+
63
+ REVOKE ALL ON FUNCTION public.invoicing_request_with_seller_policy(text,text,uuid,uuid,jsonb,timestamptz) FROM PUBLIC, anon, authenticated;
64
+
65
+ REVOKE ALL ON FUNCTION public.invoicing_claim_with_seller(integer,integer,text,jsonb,text,uuid) FROM PUBLIC, anon, authenticated;
@@ -80,7 +80,7 @@ export async function runFulfillmentAdapterConformance(subjects: readonly Fulfil
80
80
  requireSame(result, fixture.expected, "observation fixture"); assertDurable(result, subject);
81
81
  if (!caps.operations.verifyObservation.supported) requireFact(result.kind === "refused" && result.refusal.reason === "unsupported", "unsupported observation");
82
82
  if (result.kind === "verified") {
83
- requireSame(result.observation, projectObservation(result.observation), "observation allowlist");
83
+ requireSame(result.observation, projectObservation(result.observation, subject.approveTrackingUrl, subject.observationCodecs), "observation allowlist");
84
84
  const observation = result.observation;
85
85
  const identity = stable({ provider: observation.fact.providerKind, reference: observation.fact.fulfillmentReference,
86
86
  kind: observation.eventKind, outcome: observation.fact.canonicalStatus, occurredAt: observation.fact.occurredAt });
@@ -12,7 +12,7 @@ export function createFulfillmentServices(options: FulfillmentServicesOptions):
12
12
  const selected = readFulfillmentCapabilities(capability ? [capability] : [], selection);
13
13
  if (selected.kind === 'refused') return { ready: false, refusal: selected.refusal };
14
14
  if (!selected.descriptor.operations.dispatch.supported) return { ready: false, refusal: { reason: 'unsupported', phase: 'selection', retryable: false } };
15
- if (!store || !provider) return { ready: false, refusal: FULFILLMENT_NATIVE_UNSUPPORTED };
15
+ if (!store || (config.mode !== "shadow" && !provider)) return { ready: false, refusal: FULFILLMENT_NATIVE_UNSUPPORTED };
16
16
  return observe();
17
17
  };
18
18
  return {
@@ -1,7 +1,7 @@
1
1
  import type { FulfillmentCapabilityDescriptor } from "./capabilities.js";
2
2
  import type { FulfillmentDispatchPort, FulfillmentDispatchRequest } from "./dispatch.js";
3
3
  import type { FulfillmentLabelPort } from "./label.js";
4
- import type { FulfillmentObservationPort } from "./observation.js";
4
+ import type { FulfillmentObservationPort, FulfillmentObservationCodecs } from "./observation.js";
5
5
  import type { FulfillmentReadbackPort } from "./readback.js";
6
6
  import type { FulfillmentStockSyncProvider, FulfillmentProductSyncProvider } from "./sync.js";
7
7
  import type { FulfillmentStorePort } from "./store.js";
@@ -14,6 +14,8 @@ type SyncFixture<Port> = { [K in keyof Port]: Port[K] extends (...args: never[])
14
14
 
15
15
  /** Offline adapter-owned inputs and exact expected outputs; never real customer data. */
16
16
  export interface FulfillmentAdapterConformanceSubject {
17
+ approveTrackingUrl?(url: string): boolean;
18
+ observationCodecs?: FulfillmentObservationCodecs;
17
19
  subjectId: string;
18
20
  adapter: {
19
21
  capabilities: FulfillmentCapabilityDescriptor; dispatch: FulfillmentDispatchPort;
@@ -12,7 +12,7 @@ export interface FulfillmentRefusal {
12
12
  phase: "selection" | "prepare" | "claim" | "begin" | "dispatch" | "acknowledge" | "verify" | "normalize" | "apply" | "readback" | "sync";
13
13
  retryable: boolean;
14
14
  }
15
- /** Frozen domain input, never copied into command evidence. Credentials stay in adapter closures. */
15
+ /** Frozen domain input. Contact PII stays transient; only allowlisted mapped items enter evidence. Credentials stay in adapter closures. */
16
16
  export interface FulfillmentDispatchRequest {
17
17
  requestKey: string;
18
18
  fulfillmentRef: string;
@@ -21,21 +21,40 @@ export interface FulfillmentDispatchRequest {
21
21
  parcelRef: string | null;
22
22
  contactRevision: { schemaVersion: number; source: string; revision: number };
23
23
  recipient: { name: string | null; email: string | null; phone: string | null; line1: string; line2: string | null; city: string; postalCode: string; country: string; deliveryInstructions: string | null; courierInstructions: string | null };
24
- lines: readonly { skuRef: string; title: string | null; quantity: number; productRef: string | null; packRef: string | null }[];
24
+ lines: readonly { skuRef: string; title: string | null; quantity: number; productRef: string | null; packRef: string | null; lotRef?: string | null; expirationDate?: string | null }[];
25
25
  selection: FulfillmentSelection;
26
26
  }
27
27
  /** Allowlisted durable evidence; fingerprint commits to actual adapter command bytes. */
28
28
  export interface DispatchEvidence {
29
+ redactedFieldCount?: number;
29
30
  commandFingerprint: string;
30
31
  contactRevision: FulfillmentDispatchRequest["contactRevision"];
31
32
  providerRef: string;
32
33
  carrierRef: string | null;
33
34
  serviceRef: string | null;
35
+ transcript?: {
36
+ itemCount: number;
37
+ items: readonly { skuRef: string; quantity: number; lotRef: string | null; expirationDate: string | null }[];
38
+ deliveryKind: DeliveryKind; pickupPointRef: string | null;
39
+ /** Count of populated reference/date fields redacted by the durable projection. */
40
+ redactedFieldCount?: number;
41
+ };
42
+ mapping?: { source: "dispatch_worker"; mode: "shadow" | "stage" | "live"; shadowMode: boolean };
34
43
  }
35
- export type FulfillmentDispatchResult =
36
- | { kind: "accepted"; externalRef: string; evidence: DispatchEvidence; providerAttemptKey: string; labelKey: string }
44
+ export interface FulfillmentOperationError {
45
+ redactedFieldCount?: number;
46
+ code: string; status: number | null; retryable: boolean; mayHaveSucceeded: boolean;
47
+ /** Whether provider submission was attempted; absent means unknown, never proven false. */
48
+ submissionAttempted?: boolean;
49
+ /** Whether the HTTP response body was read, including empty or malformed JSON; absent means no body-read evidence. */
50
+ responseBodyRead?: boolean;
51
+ /** Machine cause only; never an arbitrary exception message. */
52
+ cause?: string;
53
+ }
54
+ export type FulfillmentDispatchResult = ({ error?: FulfillmentOperationError; redactedFieldCount?: number } & (
55
+ | { kind: "accepted"; externalRef: string; evidence: DispatchEvidence; providerAttemptKey: string; labelKey: string; recovery?: { source: "persisted_acceptance"; proof: "persisted_external_ref"; noProviderPost: true } }
37
56
  | { kind: "not_dispatched"; effectMayHaveSucceeded: false; refusal: FulfillmentRefusal }
38
- | { kind: "unknown"; effectMayHaveSucceeded: true; refusal: FulfillmentRefusal };
57
+ | { kind: "unknown"; effectMayHaveSucceeded: true; refusal: FulfillmentRefusal }));
39
58
  export type FulfillmentDispatchPrepareResult =
40
59
  | { kind: "prepared"; evidence: DispatchEvidence }
41
60
  | { kind: "refused"; refusal: FulfillmentRefusal };
@@ -1,8 +1,8 @@
1
- export type { DispatchEvidence, FulfillmentDispatchPrepareResult, FulfillmentSelection, FulfillmentDispatchRequest, FulfillmentDispatchResult, FulfillmentDispatchPort, FulfillmentRefusal } from "./dispatch.js";
1
+ export type { FulfillmentOperationError, DispatchEvidence, FulfillmentDispatchPrepareResult, FulfillmentSelection, FulfillmentDispatchRequest, FulfillmentDispatchResult, FulfillmentDispatchPort, FulfillmentRefusal } from "./dispatch.js";
2
2
  export type { LabelDocument, FulfillmentLabelPort } from "./label.js";
3
3
  /** G17 local reads/ingress persistence are inline runtime worker inputs; this provider envelope is unchanged. */
4
- export type { FulfillmentObservation, FulfillmentObservationPort, FulfillmentTrackingReference } from "./observation.js";
5
- export type { FulfillmentReadbackResult, FulfillmentReadbackPort } from "./readback.js";
4
+ export type { FulfillmentObservation, FulfillmentObservationCodecs, FulfillmentObservationPort, FulfillmentTrackingReference } from "./observation.js";
5
+ export type { FulfillmentReadbackResult, FulfillmentReadbackPort, FulfillmentReconciliationObservation, FulfillmentObservationPagePort } from "./readback.js";
6
6
  export type { SyncCursor, StockSnapshot, StockMovement, LocalStock, FulfillmentStockSyncProvider, FulfillmentStockSyncPort, FulfillmentProductSyncProvider, FulfillmentProductSyncPort } from "./sync.js";
7
7
  export type { FulfillmentCapabilityDescriptor } from "./capabilities.js";
8
8
  export type { DispatchRecord, FulfillmentStorePort, FulfillmentPaidHandoffPort } from "./store.js";
@@ -1,7 +1,13 @@
1
1
  import type { NormalizedFulfillmentFact } from "@openlup/core/fulfillment";
2
2
  import type { CanonicalLocalFulfillmentStatus } from "./index.js";
3
3
  import type { FulfillmentRefusal } from "./dispatch.js";
4
- export interface FulfillmentTrackingReference { trackingNumber: string; carrierRef: string | null; serviceRef: string | null; publicUrl: string | null }
4
+ /** Explicit adopter policy, forwarded through each durable observation projection. */
5
+ export interface FulfillmentObservationCodecs {
6
+ approveReference?(value: string): boolean;
7
+ /** Overrides the query/fragment default; HTTPS, no credentials and URL approval still apply. */
8
+ trackingUrl?(url: string): string | null;
9
+ }
10
+ export interface FulfillmentTrackingReference { trackingNumber: string; /** Normalized carrier kind, never a raw provider code. */ carrierRef: string | null; serviceRef: string | null; publicUrl: string | null }
5
11
  /** Durable normalized envelope. eventKey, not the receivedAt-bearing fact fingerprint, deduplicates retries. */
6
12
  export interface FulfillmentObservation {
7
13
  fact: NormalizedFulfillmentFact<CanonicalLocalFulfillmentStatus>;
@@ -10,9 +16,11 @@ export interface FulfillmentObservation {
10
16
  firstReceivedAt: string;
11
17
  correlation: { orderRef: string | null; fulfillmentRef: string | null; externalRef: string | null; orderNumber: string | null; providerFulfillmentRef: string | null; parcelRef: string | null };
12
18
  trackingRefs: readonly FulfillmentTrackingReference[];
13
- diagnostics?: { refusal?: FulfillmentRefusal; stockContention?: { sqlState: "55P03"; phase: "provider_stock_consumed"; retryable: true }; annotation?: "recorded" | "failed"; recoveredPickedReceipt?: { receiptRef: string; closed: boolean } };
19
+ /** Original provider status tokens; canonical status does not identify an occurrence. */
20
+ providerStatus?: { primary: string | null; subStatus: string | null };
21
+ diagnostics?: { redactedFieldCount?: number; admission?: { rejectedTrackingRefs: number; rejectedTrackingUrls: number }; refusal?: FulfillmentRefusal; stockContention?: { sqlState: "55P03"; phase: "provider_stock_consumed"; retryable: true }; annotation?: "recorded" | "failed"; recoveredPickedReceipt?: { receiptRef: string; closed: boolean } };
14
22
  }
15
23
  export interface FulfillmentObservationPort {
16
24
  /** Authenticate untouched bytes before parsing. Inputs live only during verification. */
17
- verifyAndNormalize(input: { rawBody: Uint8Array; headers: Readonly<Record<string, string>>; receivedAt: string }): Promise<{ kind: "verified"; observation: FulfillmentObservation } | { kind: "refused"; refusal: FulfillmentRefusal }>;
25
+ verifyAndNormalize(input: { rawBody: Uint8Array; headers: Readonly<Record<string, string>>; receivedAt: string }): Promise<{ kind: "verified"; observation: FulfillmentObservation } | { kind: "refused"; refusal: FulfillmentRefusal; diagnostics?: { invalidTimestamp?: true; rejectedCorrelationRefs?: number } }>;
18
26
  }
@@ -9,3 +9,16 @@ export interface FulfillmentReadbackPort {
9
9
  /** Empty pages and missing local rows cannot certify external absence. */
10
10
  read(input: { providerRef: string; requestKey: string; fulfillmentRef: string; externalRef: string | null; mode: "command" | "parcel" }): Promise<FulfillmentReadbackResult>;
11
11
  }
12
+
13
+ /** Collection observation is separate from authoritative exact lookup. */
14
+ export type FulfillmentReconciliationObservation = FulfillmentReadbackResult | {
15
+ kind: "unmapped"; externalRef: string | null;
16
+ evidence: Pick<FulfillmentObservation, "correlation" | "eventKey" | "providerStatus" | "diagnostics">;
17
+ };
18
+ export interface FulfillmentObservationPagePort {
19
+ readPage(input: { page: number; size: number }): Promise<
20
+ | { kind: "page"; rows: readonly FulfillmentReconciliationObservation[] }
21
+ | { kind: "page_exhausted" }
22
+ | { kind: "failed"; refusal: FulfillmentRefusal; error?: import("./dispatch.js").FulfillmentOperationError }
23
+ >;
24
+ }
@@ -4,6 +4,7 @@ export interface DispatchRecord {
4
4
  externalRef: string | null; mode: "shadow" | "stage" | "live";
5
5
  status: "draft" | "submitting" | "created" | "uncertain" | "failed" | "cancel_requested" | "cancelled";
6
6
  evidence: DispatchEvidence; replayed: boolean;
7
+ acceptance?: Extract<FulfillmentDispatchResult, { kind: "accepted" }> | null;
7
8
  }
8
9
  export interface FulfillmentStorePort {
9
10
  read(requestKey: string): Promise<DispatchRecord | null>;