@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.
- package/CHANGELOG.md +327 -269
- package/README.md +123 -12
- package/dist/communications/contracts/subscriptionPaymentExpired.d.ts +3 -2
- package/dist/communications/contracts/subscriptionPaymentExpired.js +1 -1
- package/dist/communications/contracts/subscriptionPaymentFailed.d.ts +4 -3
- package/dist/communications/contracts/subscriptionPaymentFailed.js +4 -5
- package/dist/composition/index.d.ts +1 -0
- package/dist/composition/index.js +1 -0
- package/dist/composition/subscriptionContribution.d.ts +18 -0
- package/dist/composition/subscriptionContribution.js +19 -0
- 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/inventory/index.d.ts +2 -0
- package/dist/inventory/index.js +1 -0
- package/dist/inventory/postgresStockAvailability.d.ts +24 -0
- package/dist/inventory/postgresStockAvailability.js +48 -0
- 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/dist/subscriptions/commandExamples.d.ts +6 -0
- package/dist/subscriptions/commandExamples.js +20 -0
- package/dist/subscriptions/contracts/commands.d.ts +8 -1
- package/dist/subscriptions/contracts/index.d.ts +2 -2
- package/dist/subscriptions/contracts/stockWait.d.ts +12 -1
- package/dist/subscriptions/index.d.ts +22 -4
- package/dist/subscriptions/index.js +22 -3
- package/dist/subscriptions/lifecycleMessages.d.ts +9 -0
- package/dist/subscriptions/lifecycleMessages.js +38 -0
- package/dist/subscriptions/paymentOutcomes.js +1 -1
- package/dist/subscriptions/postgresCommandStore.d.ts +44 -0
- package/dist/subscriptions/postgresCommandStore.js +172 -0
- package/dist/subscriptions/postgresRetryStore.d.ts +6 -0
- package/dist/subscriptions/postgresRetryStore.js +11 -0
- package/dist/subscriptions/postgresStockWaitStore.d.ts +62 -0
- package/dist/subscriptions/postgresStockWaitStore.js +174 -0
- package/dist/subscriptions/services.d.ts +31 -0
- package/dist/subscriptions/services.js +15 -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/sql/20261024170000_subscription_command_lifecycle.sql +404 -0
- package/sql/20261024180000_subscription_stock_wait.sql +330 -0
- package/sql/20261024183000_inventory_available_to_promise.sql +70 -0
- package/sql/20261024184000_subscription_renewal_reserve_uses_seam.sql +147 -0
- package/sql/20261024184500_subscription_retry_late_positive_stock.sql +59 -0
- package/sql/20261024184600_subscription_retry_candidate_payment_filter.sql +22 -0
- package/src/communications/contracts/subscriptionPaymentExpired.ts +4 -3
- package/src/communications/contracts/subscriptionPaymentFailed.ts +8 -8
- package/src/composition/index.ts +1 -0
- package/src/composition/subscriptionContribution.ts +25 -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/inventory/index.ts +2 -0
- package/src/inventory/postgresStockAvailability.ts +54 -0
- 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
- package/src/subscriptions/commandExamples.ts +19 -0
- package/src/subscriptions/contracts/commands.ts +35 -2
- package/src/subscriptions/contracts/index.ts +2 -2
- package/src/subscriptions/contracts/stockWait.ts +14 -2
- package/src/subscriptions/index.ts +30 -3
- package/src/subscriptions/lifecycleMessages.ts +37 -0
- package/src/subscriptions/paymentOutcomes.ts +1 -1
- package/src/subscriptions/postgresCommandStore.ts +183 -0
- package/src/subscriptions/postgresRetryStore.ts +11 -0
- package/src/subscriptions/postgresStockWaitStore.ts +170 -0
- 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
|
+
$$;
|