@openlup/platform 0.23.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.
- package/CHANGELOG.md +218 -59
- package/README.md +93 -11
- package/dist/fulfillment/contracts/dispatch.d.ts +4 -0
- package/dist/fulfillment/dispatchProjection.d.ts +1 -1
- package/dist/fulfillment/dispatchProjection.js +4 -2
- package/dist/fulfillment/omnipack/client.js +14 -6
- package/dist/fulfillment/omnipack/index.js +5 -2
- package/dist/fulfillment/providerDispatchWorker.d.ts +38 -0
- package/dist/fulfillment/providerDispatchWorker.js +19 -1
- package/dist/invoicing/accountingCanonicalProviderLines.d.ts +5 -0
- package/dist/invoicing/accountingCanonicalProviderLines.js +104 -0
- package/dist/invoicing/accountingLineAllocation.d.ts +2 -0
- package/dist/invoicing/accountingLineAllocation.js +69 -0
- package/dist/invoicing/adapterConformance.d.ts +42 -0
- package/dist/invoicing/adapterConformance.js +296 -0
- package/dist/invoicing/artifacts.d.ts +7 -0
- package/dist/invoicing/artifacts.js +20 -0
- package/dist/invoicing/bindings.d.ts +46 -0
- package/dist/invoicing/bindings.js +69 -0
- package/dist/invoicing/channelPolicy.d.ts +28 -0
- package/dist/invoicing/channelPolicy.js +27 -0
- package/dist/invoicing/continuity.d.ts +12 -0
- package/dist/invoicing/continuity.js +18 -0
- package/dist/invoicing/contracts/facts.d.ts +11 -1
- package/dist/invoicing/contracts/host.d.ts +3 -4
- package/dist/invoicing/contracts/index.d.ts +1 -1
- package/dist/invoicing/contracts/store.d.ts +15 -5
- package/dist/invoicing/contribution.d.ts +21 -0
- package/dist/invoicing/contribution.js +19 -0
- package/dist/invoicing/correction.d.ts +42 -0
- package/dist/invoicing/correction.js +44 -0
- package/dist/invoicing/delivery.d.ts +85 -0
- package/dist/invoicing/delivery.js +79 -0
- package/dist/invoicing/documentHistory.d.ts +50 -0
- package/dist/invoicing/documentHistory.js +213 -0
- package/dist/invoicing/documentSync.d.ts +24 -0
- package/dist/invoicing/documentSync.js +26 -0
- package/dist/invoicing/dutyCollector.d.ts +15 -0
- package/dist/invoicing/dutyCollector.js +35 -0
- package/dist/invoicing/fulfillmentTrigger.d.ts +56 -0
- package/dist/invoicing/fulfillmentTrigger.js +90 -0
- package/dist/invoicing/handoff.d.ts +37 -0
- package/dist/invoicing/handoff.js +62 -0
- package/dist/invoicing/index.d.ts +38 -0
- package/dist/invoicing/index.js +36 -0
- package/dist/invoicing/issue.d.ts +39 -0
- package/dist/invoicing/issue.js +55 -0
- package/dist/invoicing/migrations.d.ts +48 -0
- package/dist/invoicing/migrations.js +25 -0
- package/dist/invoicing/nativeUnsupported.d.ts +9 -0
- package/dist/invoicing/nativeUnsupported.js +22 -0
- package/dist/invoicing/paymentReadback.d.ts +20 -0
- package/dist/invoicing/paymentReadback.js +47 -0
- package/dist/invoicing/postgresInvoiceStore.d.ts +55 -0
- package/dist/invoicing/postgresInvoiceStore.js +344 -0
- package/dist/invoicing/readModel.d.ts +74 -0
- package/dist/invoicing/readModel.js +90 -0
- package/dist/invoicing/readback.d.ts +4 -0
- package/dist/invoicing/readback.js +30 -0
- package/dist/invoicing/reversal.d.ts +37 -0
- package/dist/invoicing/reversal.js +28 -0
- package/dist/invoicing/sellerRevision.d.ts +19 -0
- package/dist/invoicing/sellerRevision.js +14 -0
- package/dist/invoicing/settlementSync.d.ts +18 -0
- package/dist/invoicing/settlementSync.js +32 -0
- package/dist/invoicing/snapshot.d.ts +52 -0
- package/dist/invoicing/snapshot.js +141 -0
- package/package.json +3 -3
- package/release-gates.json +21 -7
- package/sql/20260809210000_accounting_document_rail.sql +889 -0
- package/sql/20260811150000_accounting_payout_rail.sql +333 -0
- package/sql/20260812130000_accounting_document_lifecycle_rail.sql +460 -0
- package/sql/20261022190000_invoicing_durable_duties.sql +494 -0
- package/sql/20261022191000_invoicing_document_effects.sql +249 -0
- package/sql/20261022192000_invoicing_payment_decoupling.sql +144 -0
- package/sql/20261022195000_invoicing_seller_revision.sql +65 -0
- package/src/fulfillment/contracts/dispatch.ts +4 -0
- package/src/fulfillment/dispatchProjection.ts +4 -2
- package/src/fulfillment/omnipack/client.ts +13 -6
- package/src/fulfillment/omnipack/index.ts +5 -2
- package/src/fulfillment/providerDispatchWorker.ts +14 -1
- package/src/invoicing/accountingCanonicalProviderLines.ts +134 -0
- package/src/invoicing/accountingLineAllocation.ts +69 -0
- package/src/invoicing/adapterConformance.ts +249 -0
- package/src/invoicing/artifacts.ts +19 -0
- package/src/invoicing/bindings.ts +80 -0
- package/src/invoicing/channelPolicy.ts +50 -0
- package/src/invoicing/continuity.ts +15 -0
- package/src/invoicing/contracts/facts.ts +12 -1
- package/src/invoicing/contracts/host.ts +3 -4
- package/src/invoicing/contracts/index.ts +1 -1
- package/src/invoicing/contracts/store.ts +11 -7
- package/src/invoicing/contribution.ts +28 -0
- package/src/invoicing/correction.ts +56 -0
- package/src/invoicing/delivery.ts +122 -0
- package/src/invoicing/documentHistory.ts +301 -0
- package/src/invoicing/documentSync.ts +51 -0
- package/src/invoicing/dutyCollector.ts +31 -0
- package/src/invoicing/fulfillmentTrigger.ts +144 -0
- package/src/invoicing/handoff.ts +71 -0
- package/src/invoicing/index.ts +38 -0
- package/src/invoicing/issue.ts +64 -0
- package/src/invoicing/migrations.ts +25 -0
- package/src/invoicing/nativeUnsupported.ts +24 -0
- package/src/invoicing/paymentReadback.ts +70 -0
- package/src/invoicing/postgresInvoiceStore.ts +334 -0
- package/src/invoicing/readModel.ts +80 -0
- package/src/invoicing/readback.ts +26 -0
- package/src/invoicing/reversal.ts +34 -0
- package/src/invoicing/sellerRevision.ts +20 -0
- package/src/invoicing/settlementSync.ts +37 -0
- package/src/invoicing/snapshot.ts +192 -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
|
+
$$;
|