@openlup/platform 0.23.0 → 0.25.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 (165) hide show
  1. package/CHANGELOG.md +327 -269
  2. package/README.md +123 -12
  3. package/dist/communications/contracts/subscriptionPaymentExpired.d.ts +3 -2
  4. package/dist/communications/contracts/subscriptionPaymentExpired.js +1 -1
  5. package/dist/communications/contracts/subscriptionPaymentFailed.d.ts +4 -3
  6. package/dist/communications/contracts/subscriptionPaymentFailed.js +4 -5
  7. package/dist/composition/index.d.ts +1 -0
  8. package/dist/composition/index.js +1 -0
  9. package/dist/composition/subscriptionContribution.d.ts +18 -0
  10. package/dist/composition/subscriptionContribution.js +19 -0
  11. package/dist/fulfillment/contracts/dispatch.d.ts +4 -0
  12. package/dist/fulfillment/dispatchProjection.d.ts +1 -1
  13. package/dist/fulfillment/dispatchProjection.js +4 -2
  14. package/dist/fulfillment/omnipack/client.js +14 -6
  15. package/dist/fulfillment/omnipack/index.js +5 -2
  16. package/dist/fulfillment/providerDispatchWorker.d.ts +38 -0
  17. package/dist/fulfillment/providerDispatchWorker.js +19 -1
  18. package/dist/inventory/index.d.ts +2 -0
  19. package/dist/inventory/index.js +1 -0
  20. package/dist/inventory/postgresStockAvailability.d.ts +24 -0
  21. package/dist/inventory/postgresStockAvailability.js +48 -0
  22. package/dist/invoicing/accountingCanonicalProviderLines.d.ts +5 -0
  23. package/dist/invoicing/accountingCanonicalProviderLines.js +104 -0
  24. package/dist/invoicing/accountingLineAllocation.d.ts +2 -0
  25. package/dist/invoicing/accountingLineAllocation.js +69 -0
  26. package/dist/invoicing/adapterConformance.d.ts +42 -0
  27. package/dist/invoicing/adapterConformance.js +296 -0
  28. package/dist/invoicing/artifacts.d.ts +7 -0
  29. package/dist/invoicing/artifacts.js +20 -0
  30. package/dist/invoicing/bindings.d.ts +46 -0
  31. package/dist/invoicing/bindings.js +69 -0
  32. package/dist/invoicing/channelPolicy.d.ts +28 -0
  33. package/dist/invoicing/channelPolicy.js +27 -0
  34. package/dist/invoicing/continuity.d.ts +12 -0
  35. package/dist/invoicing/continuity.js +18 -0
  36. package/dist/invoicing/contracts/facts.d.ts +11 -1
  37. package/dist/invoicing/contracts/host.d.ts +3 -4
  38. package/dist/invoicing/contracts/index.d.ts +1 -1
  39. package/dist/invoicing/contracts/store.d.ts +15 -5
  40. package/dist/invoicing/contribution.d.ts +21 -0
  41. package/dist/invoicing/contribution.js +19 -0
  42. package/dist/invoicing/correction.d.ts +42 -0
  43. package/dist/invoicing/correction.js +44 -0
  44. package/dist/invoicing/delivery.d.ts +85 -0
  45. package/dist/invoicing/delivery.js +79 -0
  46. package/dist/invoicing/documentHistory.d.ts +50 -0
  47. package/dist/invoicing/documentHistory.js +213 -0
  48. package/dist/invoicing/documentSync.d.ts +24 -0
  49. package/dist/invoicing/documentSync.js +26 -0
  50. package/dist/invoicing/dutyCollector.d.ts +15 -0
  51. package/dist/invoicing/dutyCollector.js +35 -0
  52. package/dist/invoicing/fulfillmentTrigger.d.ts +56 -0
  53. package/dist/invoicing/fulfillmentTrigger.js +90 -0
  54. package/dist/invoicing/handoff.d.ts +37 -0
  55. package/dist/invoicing/handoff.js +62 -0
  56. package/dist/invoicing/index.d.ts +38 -0
  57. package/dist/invoicing/index.js +36 -0
  58. package/dist/invoicing/issue.d.ts +39 -0
  59. package/dist/invoicing/issue.js +55 -0
  60. package/dist/invoicing/migrations.d.ts +48 -0
  61. package/dist/invoicing/migrations.js +25 -0
  62. package/dist/invoicing/nativeUnsupported.d.ts +9 -0
  63. package/dist/invoicing/nativeUnsupported.js +22 -0
  64. package/dist/invoicing/paymentReadback.d.ts +20 -0
  65. package/dist/invoicing/paymentReadback.js +47 -0
  66. package/dist/invoicing/postgresInvoiceStore.d.ts +55 -0
  67. package/dist/invoicing/postgresInvoiceStore.js +344 -0
  68. package/dist/invoicing/readModel.d.ts +74 -0
  69. package/dist/invoicing/readModel.js +90 -0
  70. package/dist/invoicing/readback.d.ts +4 -0
  71. package/dist/invoicing/readback.js +30 -0
  72. package/dist/invoicing/reversal.d.ts +37 -0
  73. package/dist/invoicing/reversal.js +28 -0
  74. package/dist/invoicing/sellerRevision.d.ts +19 -0
  75. package/dist/invoicing/sellerRevision.js +14 -0
  76. package/dist/invoicing/settlementSync.d.ts +18 -0
  77. package/dist/invoicing/settlementSync.js +32 -0
  78. package/dist/invoicing/snapshot.d.ts +52 -0
  79. package/dist/invoicing/snapshot.js +141 -0
  80. package/dist/subscriptions/commandExamples.d.ts +6 -0
  81. package/dist/subscriptions/commandExamples.js +20 -0
  82. package/dist/subscriptions/contracts/commands.d.ts +8 -1
  83. package/dist/subscriptions/contracts/index.d.ts +2 -2
  84. package/dist/subscriptions/contracts/stockWait.d.ts +12 -1
  85. package/dist/subscriptions/index.d.ts +22 -4
  86. package/dist/subscriptions/index.js +22 -3
  87. package/dist/subscriptions/lifecycleMessages.d.ts +9 -0
  88. package/dist/subscriptions/lifecycleMessages.js +38 -0
  89. package/dist/subscriptions/paymentOutcomes.js +1 -1
  90. package/dist/subscriptions/postgresCommandStore.d.ts +44 -0
  91. package/dist/subscriptions/postgresCommandStore.js +172 -0
  92. package/dist/subscriptions/postgresRetryStore.d.ts +6 -0
  93. package/dist/subscriptions/postgresRetryStore.js +11 -0
  94. package/dist/subscriptions/postgresStockWaitStore.d.ts +62 -0
  95. package/dist/subscriptions/postgresStockWaitStore.js +174 -0
  96. package/dist/subscriptions/services.d.ts +31 -0
  97. package/dist/subscriptions/services.js +15 -0
  98. package/package.json +3 -3
  99. package/release-gates.json +21 -7
  100. package/sql/20260809210000_accounting_document_rail.sql +889 -0
  101. package/sql/20260811150000_accounting_payout_rail.sql +333 -0
  102. package/sql/20260812130000_accounting_document_lifecycle_rail.sql +460 -0
  103. package/sql/20261022190000_invoicing_durable_duties.sql +494 -0
  104. package/sql/20261022191000_invoicing_document_effects.sql +249 -0
  105. package/sql/20261022192000_invoicing_payment_decoupling.sql +144 -0
  106. package/sql/20261022195000_invoicing_seller_revision.sql +65 -0
  107. package/sql/20261024170000_subscription_command_lifecycle.sql +404 -0
  108. package/sql/20261024180000_subscription_stock_wait.sql +330 -0
  109. package/sql/20261024183000_inventory_available_to_promise.sql +70 -0
  110. package/sql/20261024184000_subscription_renewal_reserve_uses_seam.sql +147 -0
  111. package/sql/20261024184500_subscription_retry_late_positive_stock.sql +59 -0
  112. package/sql/20261024184600_subscription_retry_candidate_payment_filter.sql +22 -0
  113. package/src/communications/contracts/subscriptionPaymentExpired.ts +4 -3
  114. package/src/communications/contracts/subscriptionPaymentFailed.ts +8 -8
  115. package/src/composition/index.ts +1 -0
  116. package/src/composition/subscriptionContribution.ts +25 -0
  117. package/src/fulfillment/contracts/dispatch.ts +4 -0
  118. package/src/fulfillment/dispatchProjection.ts +4 -2
  119. package/src/fulfillment/omnipack/client.ts +13 -6
  120. package/src/fulfillment/omnipack/index.ts +5 -2
  121. package/src/fulfillment/providerDispatchWorker.ts +14 -1
  122. package/src/inventory/index.ts +2 -0
  123. package/src/inventory/postgresStockAvailability.ts +54 -0
  124. package/src/invoicing/accountingCanonicalProviderLines.ts +134 -0
  125. package/src/invoicing/accountingLineAllocation.ts +69 -0
  126. package/src/invoicing/adapterConformance.ts +249 -0
  127. package/src/invoicing/artifacts.ts +19 -0
  128. package/src/invoicing/bindings.ts +80 -0
  129. package/src/invoicing/channelPolicy.ts +50 -0
  130. package/src/invoicing/continuity.ts +15 -0
  131. package/src/invoicing/contracts/facts.ts +12 -1
  132. package/src/invoicing/contracts/host.ts +3 -4
  133. package/src/invoicing/contracts/index.ts +1 -1
  134. package/src/invoicing/contracts/store.ts +11 -7
  135. package/src/invoicing/contribution.ts +28 -0
  136. package/src/invoicing/correction.ts +56 -0
  137. package/src/invoicing/delivery.ts +122 -0
  138. package/src/invoicing/documentHistory.ts +301 -0
  139. package/src/invoicing/documentSync.ts +51 -0
  140. package/src/invoicing/dutyCollector.ts +31 -0
  141. package/src/invoicing/fulfillmentTrigger.ts +144 -0
  142. package/src/invoicing/handoff.ts +71 -0
  143. package/src/invoicing/index.ts +38 -0
  144. package/src/invoicing/issue.ts +64 -0
  145. package/src/invoicing/migrations.ts +25 -0
  146. package/src/invoicing/nativeUnsupported.ts +24 -0
  147. package/src/invoicing/paymentReadback.ts +70 -0
  148. package/src/invoicing/postgresInvoiceStore.ts +334 -0
  149. package/src/invoicing/readModel.ts +80 -0
  150. package/src/invoicing/readback.ts +26 -0
  151. package/src/invoicing/reversal.ts +34 -0
  152. package/src/invoicing/sellerRevision.ts +20 -0
  153. package/src/invoicing/settlementSync.ts +37 -0
  154. package/src/invoicing/snapshot.ts +192 -0
  155. package/src/subscriptions/commandExamples.ts +19 -0
  156. package/src/subscriptions/contracts/commands.ts +35 -2
  157. package/src/subscriptions/contracts/index.ts +2 -2
  158. package/src/subscriptions/contracts/stockWait.ts +14 -2
  159. package/src/subscriptions/index.ts +30 -3
  160. package/src/subscriptions/lifecycleMessages.ts +37 -0
  161. package/src/subscriptions/paymentOutcomes.ts +1 -1
  162. package/src/subscriptions/postgresCommandStore.ts +183 -0
  163. package/src/subscriptions/postgresRetryStore.ts +11 -0
  164. package/src/subscriptions/postgresStockWaitStore.ts +170 -0
  165. package/src/subscriptions/services.ts +21 -0
@@ -0,0 +1,889 @@
1
+ -- Public platform accounting rail: the neutral document machine. Four relations, one
2
+ -- arithmetic invariant, the lifecycle a document walks, correction by reversal, a private
3
+ -- request queue, the fifth emitter, and the two ledger delegations the shipped accounting
4
+ -- runtime already calls.
5
+ --
6
+ -- WHAT THIS FORWARD SHIPS, AND WHAT IT REFUSES TO KNOW. The second bundle can already say
7
+ -- that an order was paid, cancelled or refunded, that a parcel was handed over, and that a
8
+ -- scheduled job holds a lease. What it has never had is a way to say "a document is owed for
9
+ -- this order, these are its positions, and here is what happened to it". That is the whole of
10
+ -- this forward. It is deliberately NOT an accounting system: there is no ledger of accounts,
11
+ -- no tax engine, no numbering, and no notion of who the buyer is. Fiscal numbering, storage,
12
+ -- tax rules and general-ledger competence stay in whatever document provider a deployment
13
+ -- configures -- a boundary the domain that owns this capability already states in prose, and
14
+ -- which this forward is the first executable statement of.
15
+ --
16
+ -- THE KERNEL OWNS THE DOCUMENT, NOT THE MONEY, AND THAT IS THE LOAD-BEARING DECISION.
17
+ -- The managed chain derives a document's positions from an order-item relation inside a
18
+ -- BEFORE-write trigger that reads six money columns, a shipping pair and a tax rate, and
19
+ -- refuses on eight distinct arithmetic mismatches. This kernel has none of those columns: the
20
+ -- order rail gave it the WHEN of money -- paid, cancelled, refunded -- and none of the HOW
21
+ -- MUCH. Authoring them here would be a commerce surface written inside an accounting slice,
22
+ -- so the request below takes the positions and the declared total AS ARGUMENTS, and the only
23
+ -- thing this kernel enforces about money is the one invariant the product already declares:
24
+ -- the positions must equal the charged total. That invariant is integer arithmetic. It is
25
+ -- currency-agnostic, tax-rate-agnostic (rates are basis points this kernel stores and never
26
+ -- interprets), and it names no country.
27
+ --
28
+ -- MINOR UNITS, NOT CENTS. `cents` is the subdivision of two particular currencies. Amounts
29
+ -- here are `*_minor`, which is also the vocabulary the shipped application types already use.
30
+ --
31
+ -- WHERE THE SEMANTICS COME FROM, AND EVERY DEPARTURE THIS KERNEL TAKES FROM THEM. The behaviour
32
+ -- below was derived from the managed chain by measuring it live -- the request from a paid
33
+ -- order, its replay, the refusal of an unpaid order, the refusal of positions that miss the
34
+ -- declared total by one minor unit, the three correction refusals and the correction itself --
35
+ -- inside one transaction that was rolled back. Nine departures, each named, because a silent
36
+ -- one is how a kernel and its origin quietly stop being the same machine:
37
+ --
38
+ -- 1. SEVEN LIFECYCLE VALUES, NOT TEN. The chain's document CHECK carries ten today; it
39
+ -- carried seven when the ledger was authored, and the three it grew since are the three
40
+ -- this kernel may not own: one is a verdict about the validity of a national tax
41
+ -- identifier, and two are states of a submission to a national tax authority. A
42
+ -- deployment that has neither still needs every one of the seven below.
43
+ -- 2. THE INVARIANT IS A DEFERRED CONSTRAINT TRIGGER, NOT AN ARGUMENT CHECK. A check inside
44
+ -- the request function would be satisfied once and then bypassed by any later write to
45
+ -- the position ledger. The constraint fires at the end of the transaction that touched
46
+ -- either side, so a document and its positions can only ever be committed in agreement.
47
+ -- 3. A CORRECTION IS A NEW DOCUMENT, NEVER A MUTATION. Upstream a correction flips a status
48
+ -- on the original row and records a reason. Here the correction is a second document
49
+ -- that references the original through a self-referencing key, carrying the original's
50
+ -- positions negated, so the same invariant holds over it by construction and the
51
+ -- original's own fiscal facts are never rewritten. One correction per document, as a
52
+ -- partial unique index rather than a procedural check, because two concurrent callers
53
+ -- cannot both pass a read and then both insert.
54
+ -- 4. THE LIFECYCLE IS A NAMED TRANSITION ALLOWLIST -- the device the shipment rail settled
55
+ -- on -- so an illegal move is one refusal with one name instead of a condition per verb.
56
+ -- 5. THE QUEUE IS THIS CAPABILITY'S OWN. The standing rule of these rails is that a
57
+ -- capability which owns a notification owns its own enqueue; the corollary is that it
58
+ -- does not put its work items in another capability's queue. Upstream says the same
59
+ -- thing out loud about its own delivery queue, and that property is preserved here.
60
+ -- 6. THE DOCUMENT REFERENCE IS DERIVED, NOT COUNTED. There is no sequence and no counter in
61
+ -- this forward, on purpose. A gapless fiscal number is a legal obligation discharged by
62
+ -- the configured provider; what this kernel needs is a stable local handle, so it builds
63
+ -- one from the order it belongs to. Uniqueness is a constraint, not a lock.
64
+ -- 7. THE TWO `_v3` LEDGER VERBS ARE THIN DELEGATIONS onto the bodies the job-run forward
65
+ -- already shipped, and they exist because the accounting runtime is the only consumer in
66
+ -- the application that calls them -- so on this bundle every accounting job fails at
67
+ -- claim until they exist. They need no new column: the job-run forward's own header
68
+ -- records that a concrete adapter label survives losslessly in caller-supplied metadata,
69
+ -- and that is exactly where the invocation source is folded. Two behavioural departures
70
+ -- come with them, and both are real: upstream REFUSES a job whose control row was not
71
+ -- configured in advance, because that row carries an array of permitted trigger kinds
72
+ -- this kernel does not have, whereas here the claim creates the row it needs on first
73
+ -- contact; and upstream answers `trigger_kind_not_allowed` where this kernel answers
74
+ -- `inactive_driver`, which is the same refusal expressed in the vocabulary each side
75
+ -- actually has.
76
+ -- 8. NO `SECURITY DEFINER`, NO `GRANT`/`REVOKE`, NO ROW-LEVEL SECURITY, matching every
77
+ -- forward this catalogue carries -- upstream every function below is `SECURITY DEFINER`.
78
+ -- This kernel creates zero roles. Said out loud rather than assumed: ON THIS KERNEL THE
79
+ -- FUNCTIONS BELOW ARE NOT A SECURITY BOUNDARY. The host application is what
80
+ -- authenticates, and a caller that can reach these functions can already reach the
81
+ -- tables. `SET search_path` is kept, because that one is a correctness property and not
82
+ -- a role property. Every constraint is named rather than left to a generated identifier,
83
+ -- and `CREATE` is unconditional, because a manifest-ordered forward runs exactly once
84
+ -- against a known prefix and `IF NOT EXISTS` there would hide the drift the migration
85
+ -- ledger exists to catch.
86
+ -- 9. THE FIFTH EMITTER FIRES ON ANOTHER SLICE'S TABLE, DELIBERATELY. Hand-over is the
87
+ -- moment a document becomes owed, so the notification belongs to this capability even
88
+ -- though the row that changes belongs to the shipment rail -- exactly as that rail emits
89
+ -- from the hold table the order rail owns. The rule is about who owns the NOTIFICATION.
90
+ -- Upstream agrees: its version of this emitter lives in a migration named for the
91
+ -- handoff-to-document duty, not in the shipment migration.
92
+ --
93
+ -- WHAT THIS FORWARD DELIBERATELY DOES NOT SHIP, enumerated so nobody has to infer it:
94
+ -- * no order money. No currency, no order totals, no item relation, no tax rate on an
95
+ -- order. The request is given a snapshot and checks its arithmetic;
96
+ -- * no buyer. No name, no address, no tax identifier, no consumer-versus-business kind.
97
+ -- Who a document is addressed to is a composition input, and none of it is platform data;
98
+ -- * no provider, no transport and no submission protocol. A document may carry ONE opaque
99
+ -- provider reference, written once, and this kernel never parses it;
100
+ -- * no numbering, no sequence, no counter;
101
+ -- * no runtime binding. Not one line of the application is rewired onto these relations by
102
+ -- this forward; the falsifier is its own cross-bundle parity harness;
103
+ -- * no consumer for the emitted notification. The row lands in the queue the outbox forward
104
+ -- authored and is drained by its claim/ack protocol, unchanged.
105
+
106
+ CREATE TABLE public.accounting_documents (
107
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
108
+ order_id uuid NOT NULL REFERENCES public.commerce_orders(id) ON DELETE RESTRICT,
109
+ document_ref text NOT NULL,
110
+ status text NOT NULL DEFAULT 'draft',
111
+ correction_status text NOT NULL DEFAULT 'none',
112
+ correction_of_document_id uuid REFERENCES public.accounting_documents(id) ON DELETE RESTRICT,
113
+ total_gross_minor bigint NOT NULL,
114
+ provider_reference text,
115
+ requested_at timestamptz,
116
+ issued_at timestamptz,
117
+ accepted_at timestamptz,
118
+ corrected_at timestamptz,
119
+ voided_at timestamptz,
120
+ created_at timestamptz NOT NULL DEFAULT now(),
121
+ updated_at timestamptz NOT NULL DEFAULT now(),
122
+ metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
123
+ CONSTRAINT accounting_documents_document_ref_key UNIQUE (document_ref),
124
+ CONSTRAINT accounting_documents_document_ref_nonempty_check CHECK (btrim(document_ref) <> ''),
125
+ -- The seven values a document walks. Departure 1 explains the three upstream has and this
126
+ -- kernel does not.
127
+ CONSTRAINT accounting_documents_status_check CHECK (status IN (
128
+ 'draft', 'issue_requested', 'issued', 'accepted', 'correction_requested', 'corrected', 'voided'
129
+ )),
130
+ CONSTRAINT accounting_documents_correction_status_check CHECK (correction_status IN (
131
+ 'none', 'required', 'requested', 'issued', 'accepted'
132
+ )),
133
+ CONSTRAINT accounting_documents_correction_self_check
134
+ CHECK (correction_of_document_id IS DISTINCT FROM id),
135
+ -- A correction reverses; a base document does not. The sign is the only thing that has to
136
+ -- be true of the two totals without reading the positions.
137
+ CONSTRAINT accounting_documents_correction_sign_check CHECK (
138
+ (correction_of_document_id IS NULL AND total_gross_minor >= 0)
139
+ OR (correction_of_document_id IS NOT NULL AND total_gross_minor <= 0)
140
+ ),
141
+ CONSTRAINT accounting_documents_provider_reference_nonempty_check
142
+ CHECK (provider_reference IS NULL OR btrim(provider_reference) <> '')
143
+ );
144
+
145
+ -- One base document per order, and one correction per document. Both are partial unique
146
+ -- indexes rather than procedural checks, for the reason departure 3 gives.
147
+ CREATE UNIQUE INDEX idx_accounting_documents_one_base_per_order
148
+ ON public.accounting_documents (order_id)
149
+ WHERE correction_of_document_id IS NULL;
150
+
151
+ CREATE UNIQUE INDEX idx_accounting_documents_one_correction
152
+ ON public.accounting_documents (correction_of_document_id)
153
+ WHERE correction_of_document_id IS NOT NULL;
154
+
155
+ CREATE INDEX idx_accounting_documents_status
156
+ ON public.accounting_documents (status, created_at DESC);
157
+
158
+ -- What the document says it is for. `description` is caller-supplied text this kernel stores
159
+ -- and never parses; `tax_rate_bps` is a basis-point rate it stores and never applies. There is
160
+ -- deliberately no per-line arithmetic constraint tying the total to unit times quantity: a
161
+ -- discount allocated to a line makes that identity false in every real order, and a constraint
162
+ -- that is false in the normal case is a constraint callers learn to route around.
163
+ CREATE TABLE public.accounting_document_positions (
164
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
165
+ document_id uuid NOT NULL REFERENCES public.accounting_documents(id) ON DELETE CASCADE,
166
+ position_ordinal integer NOT NULL,
167
+ description text NOT NULL,
168
+ quantity integer NOT NULL,
169
+ unit_gross_minor bigint NOT NULL,
170
+ total_gross_minor bigint NOT NULL,
171
+ tax_rate_bps integer NOT NULL DEFAULT 0,
172
+ metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
173
+ created_at timestamptz NOT NULL DEFAULT now(),
174
+ CONSTRAINT accounting_document_positions_ordinal_check CHECK (position_ordinal > 0),
175
+ CONSTRAINT accounting_document_positions_quantity_check CHECK (quantity > 0),
176
+ CONSTRAINT accounting_document_positions_tax_rate_bps_check
177
+ CHECK (tax_rate_bps >= 0 AND tax_rate_bps <= 100000),
178
+ CONSTRAINT accounting_document_positions_description_nonempty_check CHECK (btrim(description) <> ''),
179
+ CONSTRAINT accounting_document_positions_document_ordinal_key UNIQUE (document_id, position_ordinal)
180
+ );
181
+
182
+ CREATE INDEX idx_accounting_document_positions_document
183
+ ON public.accounting_document_positions (document_id, position_ordinal);
184
+
185
+ -- This capability's own work queue, per departure 5. A row is one outstanding obligation
186
+ -- towards whatever document provider a deployment configured; the visibility lease and the
187
+ -- claim token are the same devices the shared notification queue uses, because they are the
188
+ -- devices that make a crashed consumer recoverable.
189
+ CREATE TABLE public.accounting_document_requests (
190
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
191
+ document_id uuid NOT NULL REFERENCES public.accounting_documents(id) ON DELETE CASCADE,
192
+ request_kind text NOT NULL,
193
+ status text NOT NULL DEFAULT 'pending',
194
+ attempts integer NOT NULL DEFAULT 0,
195
+ available_at timestamptz NOT NULL DEFAULT now(),
196
+ claim_token uuid,
197
+ error text,
198
+ created_at timestamptz NOT NULL DEFAULT now(),
199
+ updated_at timestamptz NOT NULL DEFAULT now(),
200
+ metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
201
+ CONSTRAINT accounting_document_requests_request_kind_check
202
+ CHECK (request_kind IN ('issue', 'correction')),
203
+ CONSTRAINT accounting_document_requests_status_check
204
+ CHECK (status IN ('pending', 'processing', 'succeeded', 'failed', 'discarded')),
205
+ CONSTRAINT accounting_document_requests_attempts_check CHECK (attempts >= 0),
206
+ CONSTRAINT accounting_document_requests_document_kind_key UNIQUE (document_id, request_kind)
207
+ );
208
+
209
+ CREATE INDEX idx_accounting_document_requests_claimable
210
+ ON public.accounting_document_requests (status, available_at);
211
+
212
+ -- The audit ledger AND the idempotency ledger, in one table -- the device the order rail
213
+ -- settled on and the shipment rail inherited. A second generic key table would bring back the
214
+ -- defect it has upstream: its request fingerprint hashes the actor, so for a machine actor
215
+ -- deduplication silently stops.
216
+ CREATE TABLE public.accounting_document_operations (
217
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
218
+ document_id uuid REFERENCES public.accounting_documents(id) ON DELETE CASCADE,
219
+ order_id uuid NOT NULL REFERENCES public.commerce_orders(id) ON DELETE CASCADE,
220
+ operation_type text NOT NULL,
221
+ source text NOT NULL DEFAULT 'platform.accounting.v1',
222
+ idempotency_key text NOT NULL,
223
+ payload jsonb NOT NULL DEFAULT '{}'::jsonb,
224
+ occurred_at timestamptz NOT NULL DEFAULT now(),
225
+ CONSTRAINT accounting_document_operations_operation_type_check CHECK (operation_type IN (
226
+ 'document_requested', 'document_advanced', 'correction_requested'
227
+ )),
228
+ CONSTRAINT accounting_document_operations_idempotency_key_nonempty_check
229
+ CHECK (btrim(idempotency_key) <> ''),
230
+ CONSTRAINT accounting_document_operations_idempotency_key_key UNIQUE (idempotency_key)
231
+ );
232
+
233
+ CREATE INDEX idx_accounting_document_operations_order
234
+ ON public.accounting_document_operations (order_id, occurred_at DESC);
235
+
236
+ COMMENT ON TABLE public.accounting_documents IS
237
+ 'One row per document owed for an order, plus one row per correction of such a document.';
238
+ COMMENT ON COLUMN public.accounting_documents.provider_reference IS
239
+ 'Opaque handle returned by whatever document provider a deployment configured. Never parsed here.';
240
+ COMMENT ON COLUMN public.accounting_document_positions.tax_rate_bps IS
241
+ 'Basis-point rate carried with the position. This kernel stores it and never applies it.';
242
+
243
+ -- THE INVARIANT. Positions must equal the charged total, in minor units, exactly.
244
+ --
245
+ -- It is a deferred constraint trigger on BOTH sides of the relationship for the reason
246
+ -- departure 2 gives: a check that only ran when the document was written would be satisfied
247
+ -- once and then bypassed by the next write to the position ledger. Deferring it to the end of
248
+ -- the transaction is what lets a document and its positions be created by one call and still
249
+ -- be checked as a pair; nothing can commit them in disagreement.
250
+ CREATE FUNCTION public.accounting_document_assert_position_sum()
251
+ RETURNS trigger
252
+ LANGUAGE plpgsql
253
+ SET search_path = public, pg_catalog
254
+ AS $$
255
+ DECLARE
256
+ v_document_id uuid;
257
+ v_declared bigint;
258
+ v_positions integer;
259
+ v_sum bigint;
260
+ BEGIN
261
+ -- Written as branching statements rather than one CASE expression on purpose: the procedural
262
+ -- language compiles an expression into a single SQL query, so every record field named in any arm
263
+ -- must exist on every row type the trigger can see, and the two row types here differ.
264
+ IF TG_TABLE_NAME = 'accounting_documents' THEN
265
+ v_document_id := NEW.id;
266
+ ELSIF TG_OP = 'DELETE' THEN
267
+ v_document_id := OLD.document_id;
268
+ ELSE
269
+ v_document_id := NEW.document_id;
270
+ END IF;
271
+
272
+ SELECT total_gross_minor INTO v_declared
273
+ FROM public.accounting_documents
274
+ WHERE id = v_document_id;
275
+ -- The document is gone: a cascade took it, and there is nothing left to be consistent with.
276
+ IF NOT FOUND THEN
277
+ RETURN NULL;
278
+ END IF;
279
+
280
+ SELECT count(*)::integer, COALESCE(sum(total_gross_minor), 0)
281
+ INTO v_positions, v_sum
282
+ FROM public.accounting_document_positions
283
+ WHERE document_id = v_document_id;
284
+
285
+ IF v_positions = 0 THEN
286
+ RAISE EXCEPTION 'accounting_document_positions_required' USING ERRCODE = '23514',
287
+ DETAIL = format('document %s carries no position', v_document_id);
288
+ END IF;
289
+
290
+ IF v_sum IS DISTINCT FROM v_declared THEN
291
+ RAISE EXCEPTION 'accounting_document_position_sum_mismatch' USING ERRCODE = '23514',
292
+ DETAIL = format('document %s declares %s and its positions sum to %s',
293
+ v_document_id, v_declared, v_sum);
294
+ END IF;
295
+
296
+ RETURN NULL;
297
+ END;
298
+ $$;
299
+
300
+ CREATE CONSTRAINT TRIGGER trg_accounting_documents_position_sum
301
+ AFTER INSERT OR UPDATE ON public.accounting_documents
302
+ DEFERRABLE INITIALLY DEFERRED
303
+ FOR EACH ROW
304
+ EXECUTE FUNCTION public.accounting_document_assert_position_sum();
305
+
306
+ CREATE CONSTRAINT TRIGGER trg_accounting_document_positions_sum
307
+ AFTER INSERT OR UPDATE OR DELETE ON public.accounting_document_positions
308
+ DEFERRABLE INITIALLY DEFERRED
309
+ FOR EACH ROW
310
+ EXECUTE FUNCTION public.accounting_document_assert_position_sum();
311
+
312
+ -- One response shape for every entry point, so an applied call and a replayed one are answered
313
+ -- by the same builder and cannot drift apart. The document reference is deliberately absent:
314
+ -- it is derived from the order, and a caller that has the order has it.
315
+ CREATE FUNCTION public.accounting_document_response(
316
+ p_document public.accounting_documents,
317
+ p_outcome text,
318
+ p_replayed boolean
319
+ )
320
+ RETURNS jsonb
321
+ LANGUAGE sql
322
+ IMMUTABLE
323
+ SET search_path = public, pg_catalog
324
+ AS $$
325
+ SELECT jsonb_build_object(
326
+ 'contractVersion', 'platform.accounting.document.v1',
327
+ 'outcome', p_outcome,
328
+ 'replayed', p_replayed,
329
+ 'document', jsonb_build_object(
330
+ 'id', p_document.id,
331
+ 'orderId', p_document.order_id,
332
+ 'status', p_document.status,
333
+ 'correctionStatus', p_document.correction_status,
334
+ 'correctionOfDocumentId', p_document.correction_of_document_id,
335
+ 'totalGrossMinor', p_document.total_gross_minor));
336
+ $$;
337
+
338
+ -- Turn a caller-supplied position array into rows. Written once, because the request and the
339
+ -- correction both need it and two copies of an argument shape drift.
340
+ CREATE FUNCTION public.accounting_document_write_positions(
341
+ p_document_id uuid,
342
+ p_positions jsonb
343
+ )
344
+ RETURNS integer
345
+ LANGUAGE plpgsql
346
+ SET search_path = public, pg_catalog
347
+ AS $$
348
+ DECLARE
349
+ v_written integer;
350
+ BEGIN
351
+ IF p_positions IS NULL OR jsonb_typeof(p_positions) <> 'array' OR jsonb_array_length(p_positions) = 0 THEN
352
+ RAISE EXCEPTION 'accounting_document_positions_required' USING ERRCODE = '22023';
353
+ END IF;
354
+
355
+ INSERT INTO public.accounting_document_positions (
356
+ document_id, position_ordinal, description, quantity,
357
+ unit_gross_minor, total_gross_minor, tax_rate_bps, metadata
358
+ )
359
+ SELECT
360
+ p_document_id,
361
+ entry.ordinality::integer,
362
+ COALESCE(NULLIF(btrim(entry.value->>'description'), ''), 'position ' || entry.ordinality::text),
363
+ COALESCE((entry.value->>'quantity')::integer, 1),
364
+ COALESCE((entry.value->>'unitGrossMinor')::bigint, (entry.value->>'totalGrossMinor')::bigint),
365
+ (entry.value->>'totalGrossMinor')::bigint,
366
+ COALESCE((entry.value->>'taxRateBps')::integer, 0),
367
+ COALESCE(entry.value->'metadata', '{}'::jsonb)
368
+ FROM jsonb_array_elements(p_positions) WITH ORDINALITY AS entry(value, ordinality);
369
+
370
+ GET DIAGNOSTICS v_written = ROW_COUNT;
371
+ RETURN v_written;
372
+ END;
373
+ $$;
374
+
375
+ -- A document is owed once an order is paid, and the paid instant is the whole of the gate.
376
+ -- Nothing else about the order is read: not its total, not its currency, not its lines, not
377
+ -- who placed it -- because this kernel stores none of those and an invoice about money it
378
+ -- cannot see would be a document about nothing. The caller supplies the snapshot; the
379
+ -- invariant checks it.
380
+ CREATE FUNCTION public.accounting_document_request_from_paid_order(
381
+ p_idempotency_key text,
382
+ p_order_id uuid,
383
+ p_positions jsonb,
384
+ p_total_gross_minor bigint,
385
+ p_metadata jsonb DEFAULT '{}'::jsonb,
386
+ p_requested_at timestamptz DEFAULT now()
387
+ )
388
+ RETURNS jsonb
389
+ LANGUAGE plpgsql
390
+ SET search_path = public, pg_catalog
391
+ AS $$
392
+ DECLARE
393
+ v_order public.commerce_orders%ROWTYPE;
394
+ v_document public.accounting_documents%ROWTYPE;
395
+ v_document_ref text;
396
+ BEGIN
397
+ IF p_idempotency_key IS NULL OR length(btrim(p_idempotency_key)) < 8
398
+ OR p_order_id IS NULL OR p_total_gross_minor IS NULL THEN
399
+ RAISE EXCEPTION 'accounting_document_request_invalid_input' USING ERRCODE = '22023';
400
+ END IF;
401
+
402
+ SELECT * INTO v_order FROM public.commerce_orders WHERE id = p_order_id FOR UPDATE;
403
+ IF NOT FOUND THEN
404
+ RAISE EXCEPTION 'accounting_document_request_order_not_found' USING ERRCODE = '22023';
405
+ END IF;
406
+
407
+ IF v_order.paid_at IS NULL THEN
408
+ RAISE EXCEPTION 'accounting_document_request_order_not_paid' USING ERRCODE = '22023';
409
+ END IF;
410
+
411
+ -- Derived, never counted. Departure 6.
412
+ v_document_ref := 'order:' || p_order_id::text || ':base';
413
+
414
+ INSERT INTO public.accounting_documents (
415
+ order_id, document_ref, status, total_gross_minor, requested_at, metadata
416
+ )
417
+ VALUES (
418
+ p_order_id, v_document_ref, 'issue_requested', p_total_gross_minor, p_requested_at,
419
+ COALESCE(p_metadata, '{}'::jsonb) || jsonb_build_object('requestIdempotencyKey', p_idempotency_key)
420
+ )
421
+ ON CONFLICT ON CONSTRAINT accounting_documents_document_ref_key DO NOTHING
422
+ RETURNING * INTO v_document;
423
+
424
+ IF v_document.id IS NULL THEN
425
+ -- Idempotency is a constraint, not a timestamp comparison: the second caller reads the row
426
+ -- the first one wrote and is told so.
427
+ SELECT * INTO v_document FROM public.accounting_documents WHERE document_ref = v_document_ref;
428
+ RETURN public.accounting_document_response(v_document, 'requested', true);
429
+ END IF;
430
+
431
+ PERFORM public.accounting_document_write_positions(v_document.id, p_positions);
432
+
433
+ INSERT INTO public.accounting_document_requests (document_id, request_kind, available_at)
434
+ VALUES (v_document.id, 'issue', p_requested_at);
435
+
436
+ INSERT INTO public.accounting_document_operations
437
+ (document_id, order_id, operation_type, idempotency_key, payload, occurred_at)
438
+ VALUES (v_document.id, p_order_id, 'document_requested', p_idempotency_key,
439
+ jsonb_build_object('totalGrossMinor', p_total_gross_minor), p_requested_at);
440
+
441
+ RETURN public.accounting_document_response(v_document, 'requested', false);
442
+ END;
443
+ $$;
444
+
445
+ -- The lifecycle, as one allowlist. Every legal move is a row in the list below and every other
446
+ -- move is one refusal with one name. A provider reference may be written exactly once and may
447
+ -- never be rewritten afterwards, because it is the only thing on this row that a second system
448
+ -- also believes.
449
+ CREATE FUNCTION public.accounting_document_advance(
450
+ p_idempotency_key text,
451
+ p_document_id uuid,
452
+ p_to_status text,
453
+ p_provider_reference text DEFAULT NULL,
454
+ p_metadata jsonb DEFAULT '{}'::jsonb,
455
+ p_requested_at timestamptz DEFAULT now()
456
+ )
457
+ RETURNS jsonb
458
+ LANGUAGE plpgsql
459
+ SET search_path = public, pg_catalog
460
+ AS $$
461
+ DECLARE
462
+ v_document public.accounting_documents%ROWTYPE;
463
+ v_allowed boolean;
464
+ BEGIN
465
+ IF p_idempotency_key IS NULL OR length(btrim(p_idempotency_key)) < 8 OR p_document_id IS NULL THEN
466
+ RAISE EXCEPTION 'accounting_document_advance_invalid_input' USING ERRCODE = '22023';
467
+ END IF;
468
+
469
+ SELECT * INTO v_document FROM public.accounting_documents WHERE id = p_document_id FOR UPDATE;
470
+ IF NOT FOUND THEN
471
+ RAISE EXCEPTION 'accounting_document_advance_document_not_found' USING ERRCODE = '22023';
472
+ END IF;
473
+
474
+ IF EXISTS (SELECT 1 FROM public.accounting_document_operations
475
+ WHERE idempotency_key = p_idempotency_key)
476
+ OR v_document.status = p_to_status THEN
477
+ RETURN public.accounting_document_response(v_document, 'advanced', true);
478
+ END IF;
479
+
480
+ SELECT EXISTS (
481
+ SELECT 1 FROM (VALUES
482
+ ('draft', 'issue_requested'),
483
+ ('draft', 'voided'),
484
+ ('issue_requested', 'issued'),
485
+ ('issue_requested', 'voided'),
486
+ ('issued', 'accepted'),
487
+ ('issued', 'correction_requested'),
488
+ ('accepted', 'correction_requested'),
489
+ ('correction_requested', 'corrected')
490
+ ) AS move(from_status, to_status)
491
+ WHERE move.from_status = v_document.status AND move.to_status = p_to_status
492
+ ) INTO v_allowed;
493
+
494
+ IF NOT v_allowed THEN
495
+ RAISE EXCEPTION 'accounting_document_advance_invalid_transition' USING ERRCODE = '22023',
496
+ DETAIL = format('document %s is %s and cannot become %s',
497
+ p_document_id, v_document.status, p_to_status);
498
+ END IF;
499
+
500
+ IF p_provider_reference IS NOT NULL
501
+ AND v_document.provider_reference IS NOT NULL
502
+ AND v_document.provider_reference IS DISTINCT FROM p_provider_reference THEN
503
+ RAISE EXCEPTION 'accounting_document_provider_reference_immutable' USING ERRCODE = '22023';
504
+ END IF;
505
+
506
+ UPDATE public.accounting_documents
507
+ SET status = p_to_status,
508
+ correction_status = CASE
509
+ WHEN p_to_status = 'correction_requested' THEN 'requested'
510
+ WHEN p_to_status = 'corrected' THEN 'issued'
511
+ ELSE correction_status
512
+ END,
513
+ provider_reference = COALESCE(p_provider_reference, provider_reference),
514
+ issued_at = CASE WHEN p_to_status = 'issued' THEN p_requested_at ELSE issued_at END,
515
+ accepted_at = CASE WHEN p_to_status = 'accepted' THEN p_requested_at ELSE accepted_at END,
516
+ corrected_at = CASE WHEN p_to_status = 'corrected' THEN p_requested_at ELSE corrected_at END,
517
+ voided_at = CASE WHEN p_to_status = 'voided' THEN p_requested_at ELSE voided_at END,
518
+ updated_at = p_requested_at,
519
+ metadata = metadata || COALESCE(p_metadata, '{}'::jsonb)
520
+ WHERE id = p_document_id
521
+ RETURNING * INTO v_document;
522
+
523
+ INSERT INTO public.accounting_document_operations
524
+ (document_id, order_id, operation_type, idempotency_key, payload, occurred_at)
525
+ VALUES (p_document_id, v_document.order_id, 'document_advanced', p_idempotency_key,
526
+ jsonb_build_object('toStatus', p_to_status), p_requested_at);
527
+
528
+ RETURN public.accounting_document_response(v_document, 'advanced', false);
529
+ END;
530
+ $$;
531
+
532
+ -- Correction by reversal. The original is never rewritten: a second document is created that
533
+ -- points at it and carries its positions negated, so the two together sum to what the customer
534
+ -- is actually owed and the invariant holds over each of them separately.
535
+ CREATE FUNCTION public.accounting_document_request_correction(
536
+ p_idempotency_key text,
537
+ p_document_id uuid,
538
+ p_reason text,
539
+ p_metadata jsonb DEFAULT '{}'::jsonb,
540
+ p_requested_at timestamptz DEFAULT now()
541
+ )
542
+ RETURNS jsonb
543
+ LANGUAGE plpgsql
544
+ SET search_path = public, pg_catalog
545
+ AS $$
546
+ DECLARE
547
+ v_original public.accounting_documents%ROWTYPE;
548
+ v_correction public.accounting_documents%ROWTYPE;
549
+ BEGIN
550
+ IF p_idempotency_key IS NULL OR length(btrim(p_idempotency_key)) < 8 OR p_document_id IS NULL THEN
551
+ RAISE EXCEPTION 'accounting_document_correction_invalid_input' USING ERRCODE = '22023';
552
+ END IF;
553
+
554
+ SELECT * INTO v_original FROM public.accounting_documents WHERE id = p_document_id FOR UPDATE;
555
+ IF NOT FOUND THEN
556
+ RAISE EXCEPTION 'accounting_document_correction_document_not_found' USING ERRCODE = '22023';
557
+ END IF;
558
+
559
+ IF v_original.correction_of_document_id IS NOT NULL THEN
560
+ RAISE EXCEPTION 'accounting_document_correction_of_a_correction_forbidden' USING ERRCODE = '22023';
561
+ END IF;
562
+
563
+ -- A document nobody has issued yet is withdrawn, not corrected. Refusing here is what keeps
564
+ -- `voided` and `corrected` from becoming two names for the same thing.
565
+ IF v_original.status NOT IN ('issued', 'accepted') THEN
566
+ RAISE EXCEPTION 'accounting_document_correction_status_invalid' USING ERRCODE = '22023',
567
+ DETAIL = format('document %s is %s', p_document_id, v_original.status);
568
+ END IF;
569
+
570
+ SELECT * INTO v_correction
571
+ FROM public.accounting_documents
572
+ WHERE correction_of_document_id = p_document_id;
573
+ IF FOUND THEN
574
+ RETURN public.accounting_document_response(v_correction, 'correction_requested', true);
575
+ END IF;
576
+
577
+ INSERT INTO public.accounting_documents (
578
+ order_id, document_ref, status, correction_of_document_id,
579
+ total_gross_minor, requested_at, metadata
580
+ )
581
+ VALUES (
582
+ v_original.order_id, v_original.document_ref || ':correction', 'draft', p_document_id,
583
+ -v_original.total_gross_minor, p_requested_at,
584
+ COALESCE(p_metadata, '{}'::jsonb) || jsonb_build_object('correctionReason', p_reason)
585
+ )
586
+ RETURNING * INTO v_correction;
587
+
588
+ INSERT INTO public.accounting_document_positions (
589
+ document_id, position_ordinal, description, quantity,
590
+ unit_gross_minor, total_gross_minor, tax_rate_bps, metadata
591
+ )
592
+ SELECT v_correction.id, position_ordinal, description, quantity,
593
+ -unit_gross_minor, -total_gross_minor, tax_rate_bps, metadata
594
+ FROM public.accounting_document_positions
595
+ WHERE document_id = p_document_id;
596
+
597
+ UPDATE public.accounting_documents
598
+ SET status = 'correction_requested',
599
+ correction_status = 'requested',
600
+ updated_at = p_requested_at
601
+ WHERE id = p_document_id;
602
+
603
+ INSERT INTO public.accounting_document_requests (document_id, request_kind, available_at)
604
+ VALUES (v_correction.id, 'correction', p_requested_at);
605
+
606
+ INSERT INTO public.accounting_document_operations
607
+ (document_id, order_id, operation_type, idempotency_key, payload, occurred_at)
608
+ VALUES (v_correction.id, v_original.order_id, 'correction_requested', p_idempotency_key,
609
+ jsonb_build_object('correctsDocumentId', p_document_id, 'reason', p_reason), p_requested_at);
610
+
611
+ RETURN public.accounting_document_response(v_correction, 'correction_requested', false);
612
+ END;
613
+ $$;
614
+
615
+ -- The order rail's cancelled and refunded instants, turned into the correction they imply. The
616
+ -- gate is the platform-observable fact and nothing else: an order that was reversed, and a
617
+ -- document that exists for it.
618
+ CREATE FUNCTION public.accounting_document_request_reversal_from_order(
619
+ p_idempotency_key text,
620
+ p_order_id uuid,
621
+ p_reason text,
622
+ p_metadata jsonb DEFAULT '{}'::jsonb,
623
+ p_requested_at timestamptz DEFAULT now()
624
+ )
625
+ RETURNS jsonb
626
+ LANGUAGE plpgsql
627
+ SET search_path = public, pg_catalog
628
+ AS $$
629
+ DECLARE
630
+ v_order public.commerce_orders%ROWTYPE;
631
+ v_document_id uuid;
632
+ BEGIN
633
+ SELECT * INTO v_order FROM public.commerce_orders WHERE id = p_order_id FOR UPDATE;
634
+ IF NOT FOUND THEN
635
+ RAISE EXCEPTION 'accounting_document_reversal_order_not_found' USING ERRCODE = '22023';
636
+ END IF;
637
+
638
+ IF v_order.cancelled_at IS NULL AND v_order.refunded_at IS NULL THEN
639
+ RAISE EXCEPTION 'accounting_document_reversal_order_not_reversed' USING ERRCODE = '22023';
640
+ END IF;
641
+
642
+ SELECT id INTO v_document_id
643
+ FROM public.accounting_documents
644
+ WHERE order_id = p_order_id AND correction_of_document_id IS NULL;
645
+ IF NOT FOUND THEN
646
+ RAISE EXCEPTION 'accounting_document_reversal_document_not_found' USING ERRCODE = '22023';
647
+ END IF;
648
+
649
+ RETURN public.accounting_document_request_correction(
650
+ p_idempotency_key, v_document_id, p_reason,
651
+ COALESCE(p_metadata, '{}'::jsonb) || jsonb_build_object(
652
+ 'reversalCause', CASE WHEN v_order.refunded_at IS NOT NULL THEN 'refunded' ELSE 'cancelled' END),
653
+ p_requested_at);
654
+ END;
655
+ $$;
656
+
657
+ -- Claim a batch of outstanding obligations. An empty allowlist is refused rather than widened,
658
+ -- for the reason the notification queue gives: a caller that forgot its filter must not drain
659
+ -- every kind of work on the instance. The claim token written onto the row is the
660
+ -- authorization for every later acknowledgement, so knowing the row id is not enough.
661
+ CREATE FUNCTION public.accounting_document_queue_claim(
662
+ p_request_kinds text[],
663
+ p_batch_size integer DEFAULT 10,
664
+ p_visibility_seconds integer DEFAULT 300,
665
+ p_max_attempts integer DEFAULT 8
666
+ )
667
+ RETURNS SETOF public.accounting_document_requests
668
+ LANGUAGE plpgsql
669
+ SET search_path = public, pg_catalog
670
+ AS $$
671
+ DECLARE
672
+ v_batch integer := LEAST(GREATEST(COALESCE(p_batch_size, 10), 1), 100);
673
+ v_visibility integer := LEAST(GREATEST(COALESCE(p_visibility_seconds, 300), 30), 3600);
674
+ v_max integer := LEAST(GREATEST(COALESCE(p_max_attempts, 8), 1), 20);
675
+ BEGIN
676
+ IF p_request_kinds IS NULL OR cardinality(p_request_kinds) = 0 THEN
677
+ RAISE EXCEPTION 'accounting_document_queue_allowlist_required' USING ERRCODE = '22023';
678
+ END IF;
679
+
680
+ RETURN QUERY
681
+ WITH candidates AS (
682
+ SELECT request.id
683
+ FROM public.accounting_document_requests request
684
+ WHERE request.request_kind = ANY (p_request_kinds)
685
+ AND request.status IN ('pending', 'processing', 'failed')
686
+ AND request.available_at <= now()
687
+ AND request.attempts < v_max
688
+ ORDER BY request.created_at, request.id
689
+ LIMIT v_batch
690
+ FOR UPDATE SKIP LOCKED
691
+ )
692
+ UPDATE public.accounting_document_requests request
693
+ SET status = 'processing',
694
+ attempts = request.attempts + 1,
695
+ available_at = now() + make_interval(secs => v_visibility),
696
+ claim_token = gen_random_uuid(),
697
+ updated_at = now()
698
+ FROM candidates
699
+ WHERE request.id = candidates.id
700
+ RETURNING request.*;
701
+ END;
702
+ $$;
703
+
704
+ -- Acknowledge success. Returns false rather than raising when the row is no longer the
705
+ -- caller's: a lease that expired and was re-claimed elsewhere is an ordinary race.
706
+ CREATE FUNCTION public.accounting_document_queue_succeed(
707
+ p_request_id uuid,
708
+ p_claim_token uuid,
709
+ p_metadata jsonb DEFAULT '{}'::jsonb
710
+ )
711
+ RETURNS boolean
712
+ LANGUAGE sql
713
+ SET search_path = public, pg_catalog
714
+ AS $$
715
+ WITH settled AS (
716
+ UPDATE public.accounting_document_requests
717
+ SET status = 'succeeded',
718
+ error = NULL,
719
+ claim_token = NULL,
720
+ updated_at = now(),
721
+ metadata = metadata || COALESCE(p_metadata, '{}'::jsonb)
722
+ WHERE id = p_request_id
723
+ AND status = 'processing'
724
+ AND claim_token = p_claim_token
725
+ RETURNING id
726
+ )
727
+ SELECT EXISTS (SELECT 1 FROM settled);
728
+ $$;
729
+
730
+ -- Acknowledge failure. The outcome the caller asks for is a request, not a verdict: a retry on
731
+ -- a row that has already spent its attempts becomes a discard, so the ceiling is enforced in
732
+ -- one place instead of in every caller.
733
+ CREATE FUNCTION public.accounting_document_queue_fail(
734
+ p_request_id uuid,
735
+ p_claim_token uuid,
736
+ p_error text,
737
+ p_outcome text DEFAULT 'retry',
738
+ p_base_delay_seconds integer DEFAULT 60,
739
+ p_max_attempts integer DEFAULT 8
740
+ )
741
+ RETURNS text
742
+ LANGUAGE plpgsql
743
+ SET search_path = public, pg_catalog
744
+ AS $$
745
+ DECLARE
746
+ v_row public.accounting_document_requests%ROWTYPE;
747
+ v_base integer := LEAST(GREATEST(COALESCE(p_base_delay_seconds, 60), 5), 3600);
748
+ v_max integer := LEAST(GREATEST(COALESCE(p_max_attempts, 8), 1), 20);
749
+ BEGIN
750
+ IF p_outcome IS NULL OR p_outcome NOT IN ('retry', 'discard') THEN
751
+ RAISE EXCEPTION 'accounting_document_queue_invalid_outcome' USING ERRCODE = '22023';
752
+ END IF;
753
+
754
+ SELECT * INTO v_row
755
+ FROM public.accounting_document_requests
756
+ WHERE id = p_request_id AND status = 'processing' AND claim_token = p_claim_token
757
+ FOR UPDATE;
758
+
759
+ IF NOT FOUND THEN
760
+ RETURN 'missed';
761
+ END IF;
762
+
763
+ IF p_outcome = 'discard' OR v_row.attempts >= v_max THEN
764
+ UPDATE public.accounting_document_requests
765
+ SET status = 'discarded', error = left(p_error, 2000), claim_token = NULL, updated_at = now()
766
+ WHERE id = v_row.id;
767
+ RETURN 'discarded';
768
+ END IF;
769
+
770
+ UPDATE public.accounting_document_requests
771
+ SET status = 'failed',
772
+ error = left(p_error, 2000),
773
+ claim_token = NULL,
774
+ available_at = now() + make_interval(
775
+ secs => v_base * power(2, LEAST(GREATEST(v_row.attempts - 1, 0), 8))),
776
+ updated_at = now()
777
+ WHERE id = v_row.id;
778
+ RETURN 'failed';
779
+ END;
780
+ $$;
781
+
782
+ -- THE FIFTH EMITTER, and departure 9 says why it lives here. A parcel leaving our hands is the
783
+ -- moment a document becomes owed, so the notification is this capability's even though the row
784
+ -- belongs to the shipment rail.
785
+ --
786
+ -- The barrier lives in the trigger's WHEN clause rather than in an early RETURN inside the
787
+ -- body: a condition the planner evaluates before the function is entered cannot be reached by
788
+ -- a statement that did not change what it claims to have changed. The emitter is idempotent on
789
+ -- (event type, key) as well, so a replayed hand-over cannot double-notify.
790
+ CREATE FUNCTION public.accounting_emit_fulfillment_handed_over_notification()
791
+ RETURNS trigger
792
+ LANGUAGE plpgsql
793
+ SET search_path = public, pg_catalog
794
+ AS $$
795
+ BEGIN
796
+ INSERT INTO public.outbox_events
797
+ (aggregate_type, aggregate_id, event_type, idempotency_key, payload, metadata)
798
+ VALUES ('commerce_order', NEW.order_id, 'commerce.fulfillment.handed_over',
799
+ 'fulfillment_handed_over:' || NEW.id::text,
800
+ jsonb_build_object('orderUuid', NEW.order_id, 'shipmentUuid', NEW.id,
801
+ 'occurredAt', NEW.handed_over_at),
802
+ jsonb_build_object('source', 'accounting_emit_fulfillment_handed_over_notification'))
803
+ ON CONFLICT (event_type, idempotency_key) DO NOTHING;
804
+ RETURN NULL;
805
+ END;
806
+ $$;
807
+
808
+ CREATE TRIGGER trg_accounting_emit_fulfillment_handed_over
809
+ AFTER UPDATE OF status ON public.fulfillment_shipments
810
+ FOR EACH ROW
811
+ WHEN (NEW.status = 'handed_over' AND OLD.status IS DISTINCT FROM 'handed_over'
812
+ AND NEW.handed_over_at IS NOT NULL)
813
+ EXECUTE FUNCTION public.accounting_emit_fulfillment_handed_over_notification();
814
+
815
+ -- The two ledger delegations of departure 7. They are the reason a self-hosting adopter's
816
+ -- accounting jobs can run at all: the shipped runtime calls these two names and nothing in
817
+ -- this catalogue defined them.
818
+ --
819
+ -- The validation below is the caller's contract and is reproduced from upstream argument for
820
+ -- argument, including the length ceiling on the invocation source. What is NOT reproduced is
821
+ -- the refusal of an unconfigured control row, because the array of permitted trigger kinds it
822
+ -- reads does not exist here; the claim this delegates to creates the row it needs and adopts
823
+ -- the runner that created it, and that difference is proved rather than asserted.
824
+ CREATE FUNCTION public.platform_claim_job_run_v3(
825
+ p_job_name text,
826
+ p_trigger_kind text,
827
+ p_invocation_source text,
828
+ p_lease_seconds integer DEFAULT 900,
829
+ p_metadata jsonb DEFAULT '{}'::jsonb
830
+ )
831
+ RETURNS TABLE (
832
+ acquired boolean,
833
+ run_id uuid,
834
+ reason text,
835
+ lease_until timestamptz
836
+ )
837
+ LANGUAGE plpgsql
838
+ SET search_path = public, pg_catalog
839
+ AS $$
840
+ DECLARE
841
+ v_trigger_kind text := COALESCE(NULLIF(btrim(p_trigger_kind), ''), '');
842
+ v_invocation_source text := COALESCE(NULLIF(btrim(p_invocation_source), ''), '');
843
+ BEGIN
844
+ IF p_job_name IS NULL OR btrim(p_job_name) = '' THEN
845
+ RAISE EXCEPTION 'platform_job_name_required' USING ERRCODE = '22023';
846
+ END IF;
847
+
848
+ IF v_trigger_kind NOT IN ('worker', 'scheduler', 'operator') THEN
849
+ RAISE EXCEPTION 'platform_job_invalid_trigger_kind' USING ERRCODE = '22023';
850
+ END IF;
851
+
852
+ IF v_invocation_source = '' OR length(v_invocation_source) > 128 THEN
853
+ RAISE EXCEPTION 'platform_job_invocation_source_required' USING ERRCODE = '22023';
854
+ END IF;
855
+
856
+ -- The invocation source is evidence, never an authorization control, so it is folded into
857
+ -- the metadata this ledger stores and never interprets.
858
+ RETURN QUERY
859
+ SELECT claimed.acquired, claimed.run_id, claimed.reason, claimed.lease_until
860
+ FROM public.platform_claim_job_run(
861
+ p_job_name,
862
+ v_trigger_kind,
863
+ p_lease_seconds,
864
+ COALESCE(p_metadata, '{}'::jsonb) || jsonb_build_object(
865
+ 'triggerKind', v_trigger_kind, 'invocationSource', v_invocation_source)
866
+ ) AS claimed;
867
+ END;
868
+ $$;
869
+
870
+ -- The finish has nothing to add to the shipped body: the fence is the lease token either way,
871
+ -- and a delegation that re-implemented it would be a second copy of the one predicate that
872
+ -- must never differ.
873
+ CREATE FUNCTION public.platform_finish_job_run_v3(
874
+ p_job_name text,
875
+ p_run_id uuid,
876
+ p_status text,
877
+ p_checked integer DEFAULT NULL,
878
+ p_updated integer DEFAULT NULL,
879
+ p_error text DEFAULT NULL,
880
+ p_support_code text DEFAULT NULL,
881
+ p_metadata jsonb DEFAULT '{}'::jsonb
882
+ )
883
+ RETURNS boolean
884
+ LANGUAGE sql
885
+ SET search_path = public, pg_catalog
886
+ AS $$
887
+ SELECT public.platform_finish_job_run_v2(
888
+ p_job_name, p_run_id, p_status, p_checked, p_updated, p_error, p_support_code, p_metadata);
889
+ $$;