@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,460 @@
1
+ -- Public platform accounting rail, third forward: the rest of a document's life. Four optional
2
+ -- columns on the document, two relations, five mutations -- the way a deployment says "this
3
+ -- document has been put in front of whatever authority this market has, and here is what came
4
+ -- back, and here is the receipt for handing it to the person it is about".
5
+ --
6
+ -- WHAT THIS FORWARD SHIPS, AND WHY THE TWO HALVES ARE ONE FORWARD. The document forward gave
7
+ -- this kernel a document that an order owes, its positions, its corrections and its private
8
+ -- work queue. It stops at the moment the document exists. Everything an accountant does AFTER
9
+ -- that -- register it where the market requires registering it, and then actually deliver it --
10
+ -- was unreachable on this bundle. The two are one forward rather than two, because they are not
11
+ -- independent: whether a document may be delivered is a FUNCTION of what the authority said
12
+ -- about it. Shipping the delivery queue without the submission state would mean shipping the
13
+ -- queue with its own gate removed, and shipping the state without the queue would leave a state
14
+ -- machine with no observable consequence.
15
+ --
16
+ -- NOTHING HERE TALKS TO ANYTHING. There is no verb that submits. There is a verb that RECORDS AN
17
+ -- OUTCOME the caller was told about, and it refuses to record an acceptance that carries no
18
+ -- reference from whoever accepted it -- so a deployment cannot invent its own acceptance, and
19
+ -- the whole protocol line stays outside this kernel. Likewise nothing here delivers: the queue
20
+ -- hands out leases and takes back receipts, and what happens in between is the host
21
+ -- application's adapter, which this catalogue never names.
22
+ --
23
+ -- WHERE THE SEMANTICS COME FROM, AND EVERY DEPARTURE. The behaviour was derived from the managed
24
+ -- chain by reading three bodies end to end -- the state recorder, the queue claim with its
25
+ -- parameterised gate, and the attempt-fenced settlement. Six departures, each named, because a
26
+ -- silent one is how a kernel and its origin quietly stop being the same machine:
27
+ --
28
+ -- 1. THE MARKET RULE IS A PARAMETER, NOT A FORK. Upstream's claim predicate reads one market's
29
+ -- document kind and one market's authority column. Here the document carries a neutral
30
+ -- `submission_required` flag, the claim takes `p_require_submission_acceptance`, and the
31
+ -- rule "a document that must be registered is not delivered before it was registered" is
32
+ -- expressed without naming a market, an authority or a document kind. The rule itself is
33
+ -- kept, deliberately: deleting it would break every deployment in a market that has such an
34
+ -- authority, and a rule that can be switched off breaks nobody.
35
+ -- 2. THE STATE SET IS CLOSED AND ITS ACCEPTANCE MUST BE EVIDENCED. Upstream accepts five state
36
+ -- names and refuses the rest, which is inherited unchanged. What is added is that
37
+ -- `accepted` demands a non-empty reference: acceptance is a fact about the outside world,
38
+ -- and a kernel that lets a caller assert it with no evidence has a state machine, not a
39
+ -- record.
40
+ -- 3. RECORDING A FACT DOES NOT CREATE WORK. Upstream's state recorder INSERTS a delivery row
41
+ -- as a side effect of acceptance. Here a delivery is REQUESTED by its own verb, and
42
+ -- acceptance only opens the gate on a request that already exists. Two reasons: the side
43
+ -- effect makes a recorder responsible for guessing a recipient it does not own, and a fact
44
+ -- that silently enqueues work is the shape that made the shipped chain's delivery loop
45
+ -- impossible to reason about from the recorder alone.
46
+ -- 4. FAILURE CAN BE TERMINAL. Upstream always sets a next attempt: a document that can never
47
+ -- be delivered is retried forever, and nothing in the schema can say "stop". Here the
48
+ -- failure verb takes `p_terminal`, and a terminal failure lands in `abandoned`, which the
49
+ -- claim predicate never offers again. This is the one arm of the arc the cross-bundle
50
+ -- harness cannot compare, because upstream has no counterpart for it at all.
51
+ -- 5. EVERY ATTEMPT IS A ROW, AND REPLAY IS THEREFORE A COUNT. Upstream proves replay by
52
+ -- returning a `replayed` boolean derived from a ledger lookup. The boolean is kept, but the
53
+ -- thing that is TRUE is the attempt ledger: one delivered attempt row per delivery, ever, so
54
+ -- a replay that wrote a second one would be visible without believing a flag.
55
+ -- 6. NO `SECURITY DEFINER`, NO `GRANT`/`REVOKE`, NO ROW-LEVEL SECURITY, matching every forward
56
+ -- this catalogue carries. This kernel creates no roles and the functions below are not a
57
+ -- security boundary; the host application is what authenticates. `SET search_path` stays,
58
+ -- because that one is a correctness property. Every constraint is named, and `CREATE` is
59
+ -- unconditional, because a manifest-ordered forward runs once against a known prefix and
60
+ -- `IF NOT EXISTS` would hide the drift the migration ledger exists to catch.
61
+ --
62
+ -- WHY THE TWO ADDED CHECKS ARE `NOT VALID`. The four columns are added to a relation this
63
+ -- catalogue created empty in an earlier forward and which no application writes, so validating
64
+ -- the constraints would scan nothing, prove nothing, and take a lock for the privilege. They are
65
+ -- enforced for every row written from here on, which is every row there will ever be.
66
+ --
67
+ -- WHAT THIS FORWARD DELIBERATELY DOES NOT SHIP, enumerated so nobody has to infer it:
68
+ -- * no submission protocol, endpoint, credential, poller or schedule, and no inbound event
69
+ -- relation. Who talked to the authority, and how, is the host application's problem;
70
+ -- * no transport, no address book, no rendering, no attachment and no content of any kind.
71
+ -- `recipient_reference` and `receipt_reference` are opaque labels this kernel stores,
72
+ -- compares for equality, and never parses;
73
+ -- * no market, no document kind, no authority name, no tax treatment;
74
+ -- * no runtime binding. Not one line of the application is rewired onto these relations by
75
+ -- this forward; the falsifier is its own cross-bundle parity harness.
76
+
77
+ ALTER TABLE public.accounting_documents
78
+ ADD COLUMN submission_required boolean NOT NULL DEFAULT false,
79
+ ADD COLUMN submission_state text,
80
+ ADD COLUMN submission_reference text,
81
+ ADD COLUMN submission_updated_at timestamptz;
82
+
83
+ ALTER TABLE public.accounting_documents
84
+ ADD CONSTRAINT accounting_documents_submission_state_check CHECK (
85
+ submission_state IS NULL OR submission_state IN (
86
+ 'not_submitted', 'pending', 'accepted', 'rejected', 'not_required'
87
+ )
88
+ ) NOT VALID;
89
+
90
+ ALTER TABLE public.accounting_documents
91
+ ADD CONSTRAINT accounting_documents_submission_reference_nonempty_check CHECK (
92
+ submission_reference IS NULL OR btrim(submission_reference) <> ''
93
+ ) NOT VALID;
94
+
95
+ COMMENT ON COLUMN public.accounting_documents.submission_required IS
96
+ 'Whether this document must be registered with an authority before it may be delivered. The rule, never the market.';
97
+ COMMENT ON COLUMN public.accounting_documents.submission_reference IS
98
+ 'Opaque handle the authority answered with. Never parsed here, and required before an acceptance is recorded.';
99
+
100
+ -- One outstanding obligation to put this document in front of one recipient over one channel.
101
+ -- The lease and the attempt counter are the devices the shared notification queue uses, because
102
+ -- they are the devices that make a crashed consumer recoverable.
103
+ CREATE TABLE public.accounting_document_deliveries (
104
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
105
+ document_id uuid NOT NULL REFERENCES public.accounting_documents(id) ON DELETE CASCADE,
106
+ channel text NOT NULL,
107
+ recipient_reference text NOT NULL,
108
+ status text NOT NULL DEFAULT 'pending',
109
+ attempts integer NOT NULL DEFAULT 0,
110
+ available_at timestamptz NOT NULL DEFAULT now(),
111
+ lease_until timestamptz,
112
+ receipt_reference text,
113
+ delivered_at timestamptz,
114
+ last_error jsonb NOT NULL DEFAULT '{}'::jsonb,
115
+ created_at timestamptz NOT NULL DEFAULT now(),
116
+ updated_at timestamptz NOT NULL DEFAULT now(),
117
+ metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
118
+ CONSTRAINT accounting_document_deliveries_document_channel_key UNIQUE (document_id, channel),
119
+ CONSTRAINT accounting_document_deliveries_channel_nonempty_check CHECK (btrim(channel) <> ''),
120
+ CONSTRAINT accounting_document_deliveries_recipient_nonempty_check
121
+ CHECK (btrim(recipient_reference) <> ''),
122
+ CONSTRAINT accounting_document_deliveries_attempts_check CHECK (attempts >= 0),
123
+ -- Departure 4: `abandoned` is the state upstream cannot express.
124
+ CONSTRAINT accounting_document_deliveries_status_check CHECK (status IN (
125
+ 'pending', 'processing', 'succeeded', 'failed', 'abandoned'
126
+ )),
127
+ CONSTRAINT accounting_document_deliveries_receipt_nonempty_check
128
+ CHECK (receipt_reference IS NULL OR btrim(receipt_reference) <> ''),
129
+ -- A delivery that says it succeeded and cannot say what it holds as evidence is not a
130
+ -- delivery. The pair is the whole point of the relation.
131
+ CONSTRAINT accounting_document_deliveries_receipt_pairs_with_success_check CHECK (
132
+ (status = 'succeeded') = (receipt_reference IS NOT NULL AND delivered_at IS NOT NULL)
133
+ )
134
+ );
135
+
136
+ CREATE INDEX idx_accounting_document_deliveries_claimable
137
+ ON public.accounting_document_deliveries (status, available_at);
138
+
139
+ -- Departure 5. One row per attempt that reached an outcome, so replay is a count rather than a
140
+ -- flag, and so a delivery that failed four times before it landed still says so afterwards.
141
+ CREATE TABLE public.accounting_document_delivery_attempts (
142
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
143
+ delivery_id uuid NOT NULL REFERENCES public.accounting_document_deliveries(id) ON DELETE CASCADE,
144
+ attempt_ordinal integer NOT NULL,
145
+ outcome text NOT NULL,
146
+ receipt_reference text,
147
+ error jsonb NOT NULL DEFAULT '{}'::jsonb,
148
+ occurred_at timestamptz NOT NULL DEFAULT now(),
149
+ CONSTRAINT accounting_document_delivery_attempts_ordinal_key
150
+ UNIQUE (delivery_id, attempt_ordinal),
151
+ CONSTRAINT accounting_document_delivery_attempts_ordinal_check CHECK (attempt_ordinal > 0),
152
+ CONSTRAINT accounting_document_delivery_attempts_outcome_check CHECK (outcome IN (
153
+ 'delivered', 'failed', 'abandoned'
154
+ )),
155
+ CONSTRAINT accounting_document_delivery_attempts_receipt_check CHECK (
156
+ (outcome = 'delivered') = (receipt_reference IS NOT NULL)
157
+ )
158
+ );
159
+
160
+ COMMENT ON TABLE public.accounting_document_deliveries IS
161
+ 'One outstanding obligation to hand one document to one recipient over one channel.';
162
+ COMMENT ON TABLE public.accounting_document_delivery_attempts IS
163
+ 'One row per attempt that reached an outcome. The ledger replay is counted against.';
164
+
165
+ -- Record what the authority answered. Departure 2: the state set is closed, and an acceptance
166
+ -- must carry the reference the authority answered with.
167
+ CREATE FUNCTION public.accounting_document_record_submission(
168
+ p_document_id uuid,
169
+ p_state text,
170
+ p_reference text DEFAULT NULL,
171
+ p_recorded_at timestamptz DEFAULT now()
172
+ )
173
+ RETURNS jsonb
174
+ LANGUAGE plpgsql
175
+ SET search_path = public, pg_catalog
176
+ AS $$
177
+ DECLARE
178
+ v_document public.accounting_documents%ROWTYPE;
179
+ BEGIN
180
+ IF p_state IS NULL OR p_state NOT IN (
181
+ 'not_submitted', 'pending', 'accepted', 'rejected', 'not_required'
182
+ ) THEN
183
+ RAISE EXCEPTION 'accounting_document_submission_state_invalid' USING ERRCODE = '22023',
184
+ DETAIL = format('state %L is not one this ledger names', p_state);
185
+ END IF;
186
+
187
+ IF p_state = 'accepted' AND btrim(COALESCE(p_reference, '')) = '' THEN
188
+ RAISE EXCEPTION 'accounting_document_submission_acceptance_unreferenced' USING ERRCODE = '22023',
189
+ DETAIL = 'an acceptance is a fact about the outside world and must carry its reference';
190
+ END IF;
191
+
192
+ UPDATE public.accounting_documents
193
+ SET submission_state = p_state,
194
+ submission_reference = COALESCE(NULLIF(btrim(COALESCE(p_reference, '')), ''), submission_reference),
195
+ submission_updated_at = p_recorded_at,
196
+ -- The one document transition an authority's answer is allowed to make, and only from
197
+ -- the state that was waiting for it.
198
+ status = CASE
199
+ WHEN p_state = 'accepted' AND status = 'issued' THEN 'accepted'
200
+ ELSE status
201
+ END,
202
+ updated_at = p_recorded_at
203
+ WHERE id = p_document_id
204
+ RETURNING * INTO v_document;
205
+
206
+ IF NOT FOUND THEN
207
+ RAISE EXCEPTION 'accounting_document_submission_document_not_found' USING ERRCODE = '22023';
208
+ END IF;
209
+
210
+ RETURN jsonb_build_object(
211
+ 'documentId', v_document.id,
212
+ 'status', v_document.status,
213
+ 'submissionState', v_document.submission_state
214
+ );
215
+ END;
216
+ $$;
217
+
218
+ -- Ask for this document to be handed to a recipient. Departure 3: the request is explicit, and
219
+ -- asking twice is the same request.
220
+ CREATE FUNCTION public.accounting_document_delivery_request(
221
+ p_document_id uuid,
222
+ p_channel text,
223
+ p_recipient_reference text,
224
+ p_requested_at timestamptz DEFAULT now()
225
+ )
226
+ RETURNS jsonb
227
+ LANGUAGE plpgsql
228
+ SET search_path = public, pg_catalog
229
+ AS $$
230
+ DECLARE
231
+ v_document public.accounting_documents%ROWTYPE;
232
+ v_delivery public.accounting_document_deliveries%ROWTYPE;
233
+ v_created boolean := false;
234
+ BEGIN
235
+ IF btrim(COALESCE(p_channel, '')) = '' OR btrim(COALESCE(p_recipient_reference, '')) = '' THEN
236
+ RAISE EXCEPTION 'accounting_document_delivery_invalid_input' USING ERRCODE = '22023';
237
+ END IF;
238
+
239
+ SELECT * INTO v_document FROM public.accounting_documents WHERE id = p_document_id;
240
+ IF NOT FOUND THEN
241
+ RAISE EXCEPTION 'accounting_document_delivery_document_not_found' USING ERRCODE = '22023';
242
+ END IF;
243
+
244
+ -- A document nobody issued has nothing to hand over. This is the refusal upstream expresses
245
+ -- by simply never selecting the row, which is a silence rather than an answer.
246
+ IF v_document.status NOT IN ('issued', 'accepted', 'corrected')
247
+ OR v_document.provider_reference IS NULL THEN
248
+ RAISE EXCEPTION 'accounting_document_delivery_document_not_issued' USING ERRCODE = '22023',
249
+ DETAIL = format('document %s is %L', v_document.id, v_document.status);
250
+ END IF;
251
+
252
+ INSERT INTO public.accounting_document_deliveries (
253
+ document_id, channel, recipient_reference, available_at, created_at, updated_at
254
+ )
255
+ VALUES (
256
+ p_document_id, btrim(p_channel), btrim(p_recipient_reference),
257
+ p_requested_at, p_requested_at, p_requested_at
258
+ )
259
+ ON CONFLICT ON CONSTRAINT accounting_document_deliveries_document_channel_key DO NOTHING
260
+ RETURNING * INTO v_delivery;
261
+ v_created := FOUND;
262
+
263
+ IF NOT v_created THEN
264
+ SELECT * INTO v_delivery
265
+ FROM public.accounting_document_deliveries
266
+ WHERE document_id = p_document_id AND channel = btrim(p_channel);
267
+ END IF;
268
+
269
+ RETURN jsonb_build_object(
270
+ 'deliveryId', v_delivery.id,
271
+ 'status', v_delivery.status,
272
+ 'replayed', NOT v_created
273
+ );
274
+ END;
275
+ $$;
276
+
277
+ -- The gate, and departure 1. A document that must be registered is not offered until it was
278
+ -- registered -- unless the deployment says its market has no such authority.
279
+ CREATE FUNCTION public.accounting_document_delivery_claim(
280
+ p_limit integer DEFAULT 25,
281
+ p_lease_seconds integer DEFAULT 300,
282
+ p_require_submission_acceptance boolean DEFAULT true,
283
+ p_document_id uuid DEFAULT NULL,
284
+ p_claimed_at timestamptz DEFAULT now()
285
+ )
286
+ RETURNS jsonb
287
+ LANGUAGE plpgsql
288
+ SET search_path = public, pg_catalog
289
+ AS $$
290
+ DECLARE
291
+ v_rows jsonb;
292
+ BEGIN
293
+ WITH candidates AS (
294
+ SELECT delivery.id
295
+ FROM public.accounting_document_deliveries delivery
296
+ JOIN public.accounting_documents document ON document.id = delivery.document_id
297
+ WHERE document.status IN ('issued', 'accepted', 'corrected')
298
+ AND document.provider_reference IS NOT NULL
299
+ AND (p_document_id IS NULL OR document.id = p_document_id)
300
+ AND (
301
+ p_require_submission_acceptance IS NOT TRUE
302
+ OR document.submission_required IS NOT TRUE
303
+ OR document.submission_state = 'accepted'
304
+ )
305
+ AND (
306
+ delivery.status = 'pending'
307
+ OR (delivery.status = 'failed' AND delivery.available_at <= p_claimed_at)
308
+ OR (delivery.status = 'processing' AND delivery.lease_until <= p_claimed_at)
309
+ )
310
+ ORDER BY delivery.created_at, delivery.id
311
+ LIMIT greatest(least(COALESCE(p_limit, 25), 100), 1)
312
+ FOR UPDATE OF delivery SKIP LOCKED
313
+ ),
314
+ claimed AS (
315
+ UPDATE public.accounting_document_deliveries delivery
316
+ SET status = 'processing',
317
+ attempts = delivery.attempts + 1,
318
+ lease_until = p_claimed_at + make_interval(secs => greatest(COALESCE(p_lease_seconds, 300), 60)),
319
+ last_error = '{}'::jsonb,
320
+ updated_at = p_claimed_at
321
+ FROM candidates
322
+ WHERE delivery.id = candidates.id
323
+ RETURNING delivery.*
324
+ )
325
+ SELECT COALESCE(jsonb_agg(jsonb_build_object(
326
+ 'deliveryId', claimed.id,
327
+ 'documentId', claimed.document_id,
328
+ 'channel', claimed.channel,
329
+ 'recipientReference', claimed.recipient_reference,
330
+ 'attemptCount', claimed.attempts
331
+ ) ORDER BY claimed.created_at, claimed.id), '[]'::jsonb)
332
+ INTO v_rows
333
+ FROM claimed;
334
+
335
+ RETURN v_rows;
336
+ END;
337
+ $$;
338
+
339
+ -- The receipt. Attempt-fenced, replay-aware by ledger lookup, and refusing a second, different
340
+ -- answer to the same command.
341
+ CREATE FUNCTION public.accounting_document_delivery_settle(
342
+ p_delivery_id uuid,
343
+ p_attempt_count integer,
344
+ p_receipt_reference text,
345
+ p_settled_at timestamptz DEFAULT now()
346
+ )
347
+ RETURNS jsonb
348
+ LANGUAGE plpgsql
349
+ SET search_path = public, pg_catalog
350
+ AS $$
351
+ DECLARE
352
+ v_delivery public.accounting_document_deliveries%ROWTYPE;
353
+ v_receipt text := btrim(COALESCE(p_receipt_reference, ''));
354
+ BEGIN
355
+ IF p_delivery_id IS NULL OR COALESCE(p_attempt_count, 0) < 1 OR v_receipt = '' THEN
356
+ RAISE EXCEPTION 'accounting_document_delivery_invalid_input' USING ERRCODE = '22023';
357
+ END IF;
358
+
359
+ SELECT * INTO v_delivery
360
+ FROM public.accounting_document_deliveries WHERE id = p_delivery_id FOR UPDATE;
361
+ IF NOT FOUND THEN
362
+ RAISE EXCEPTION 'accounting_document_delivery_not_found' USING ERRCODE = '22023';
363
+ END IF;
364
+
365
+ -- Replay first, and out of the ledger rather than out of the attempt counter: the caller that
366
+ -- lost our answer is asking the same question again, not making a stale claim.
367
+ IF v_delivery.status = 'succeeded' THEN
368
+ IF v_delivery.receipt_reference IS DISTINCT FROM v_receipt THEN
369
+ RAISE EXCEPTION 'accounting_document_delivery_receipt_conflict' USING ERRCODE = '22023',
370
+ DETAIL = format('delivery %s already holds a different receipt', v_delivery.id);
371
+ END IF;
372
+ RETURN jsonb_build_object('deliveryId', v_delivery.id, 'status', v_delivery.status, 'replayed', true);
373
+ END IF;
374
+
375
+ IF v_delivery.status = 'abandoned' THEN
376
+ RAISE EXCEPTION 'accounting_document_delivery_terminal' USING ERRCODE = '22023';
377
+ END IF;
378
+
379
+ IF v_delivery.status <> 'processing' OR v_delivery.attempts <> p_attempt_count THEN
380
+ RAISE EXCEPTION 'accounting_document_delivery_stale_attempt' USING ERRCODE = '40001',
381
+ DETAIL = format('delivery %s is %L at attempt %s', v_delivery.id, v_delivery.status, v_delivery.attempts);
382
+ END IF;
383
+
384
+ UPDATE public.accounting_document_deliveries
385
+ SET status = 'succeeded',
386
+ receipt_reference = v_receipt,
387
+ delivered_at = p_settled_at,
388
+ lease_until = NULL,
389
+ last_error = '{}'::jsonb,
390
+ updated_at = p_settled_at
391
+ WHERE id = v_delivery.id;
392
+
393
+ INSERT INTO public.accounting_document_delivery_attempts (
394
+ delivery_id, attempt_ordinal, outcome, receipt_reference, occurred_at
395
+ ) VALUES (v_delivery.id, p_attempt_count, 'delivered', v_receipt, p_settled_at);
396
+
397
+ RETURN jsonb_build_object('deliveryId', v_delivery.id, 'status', 'succeeded', 'replayed', false);
398
+ END;
399
+ $$;
400
+
401
+ -- The other outcome, with departure 4: a failure may be the last one.
402
+ CREATE FUNCTION public.accounting_document_delivery_fail(
403
+ p_delivery_id uuid,
404
+ p_attempt_count integer,
405
+ p_error jsonb DEFAULT '{}'::jsonb,
406
+ p_retry_seconds integer DEFAULT 900,
407
+ p_terminal boolean DEFAULT false,
408
+ p_failed_at timestamptz DEFAULT now()
409
+ )
410
+ RETURNS jsonb
411
+ LANGUAGE plpgsql
412
+ SET search_path = public, pg_catalog
413
+ AS $$
414
+ DECLARE
415
+ v_delivery public.accounting_document_deliveries%ROWTYPE;
416
+ v_status text;
417
+ BEGIN
418
+ IF p_delivery_id IS NULL OR COALESCE(p_attempt_count, 0) < 1 THEN
419
+ RAISE EXCEPTION 'accounting_document_delivery_invalid_input' USING ERRCODE = '22023';
420
+ END IF;
421
+
422
+ SELECT * INTO v_delivery
423
+ FROM public.accounting_document_deliveries WHERE id = p_delivery_id FOR UPDATE;
424
+ IF NOT FOUND THEN
425
+ RAISE EXCEPTION 'accounting_document_delivery_not_found' USING ERRCODE = '22023';
426
+ END IF;
427
+
428
+ IF v_delivery.status IN ('succeeded', 'abandoned') THEN
429
+ RAISE EXCEPTION 'accounting_document_delivery_terminal' USING ERRCODE = '22023';
430
+ END IF;
431
+
432
+ IF v_delivery.status <> 'processing' OR v_delivery.attempts <> p_attempt_count THEN
433
+ RAISE EXCEPTION 'accounting_document_delivery_stale_attempt' USING ERRCODE = '40001';
434
+ END IF;
435
+
436
+ v_status := CASE WHEN p_terminal IS TRUE THEN 'abandoned' ELSE 'failed' END;
437
+
438
+ UPDATE public.accounting_document_deliveries
439
+ SET status = v_status,
440
+ lease_until = NULL,
441
+ available_at = CASE
442
+ WHEN v_status = 'failed'
443
+ THEN p_failed_at + make_interval(secs => greatest(COALESCE(p_retry_seconds, 900), 60))
444
+ ELSE available_at
445
+ END,
446
+ last_error = COALESCE(p_error, '{}'::jsonb),
447
+ updated_at = p_failed_at
448
+ WHERE id = v_delivery.id;
449
+
450
+ INSERT INTO public.accounting_document_delivery_attempts (
451
+ delivery_id, attempt_ordinal, outcome, error, occurred_at
452
+ ) VALUES (
453
+ v_delivery.id, p_attempt_count,
454
+ CASE WHEN v_status = 'abandoned' THEN 'abandoned' ELSE 'failed' END,
455
+ COALESCE(p_error, '{}'::jsonb), p_failed_at
456
+ );
457
+
458
+ RETURN jsonb_build_object('deliveryId', v_delivery.id, 'status', v_status, 'replayed', false);
459
+ END;
460
+ $$;