@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,494 @@
|
|
|
1
|
+
-- Independent document obligations. Capture touches only the neutral duty/receipt rail;
|
|
2
|
+
-- accounting locks, facts and provider work belong to the sweep/executor transaction.
|
|
3
|
+
-- Native execution requires the host's explicit owner/least-privilege bootstrap.
|
|
4
|
+
-- Capture the install boundary by source identity. These rails have no
|
|
5
|
+
-- insertion timestamp, and occurred_at is host evidence that can arrive late.
|
|
6
|
+
CREATE TABLE public.invoicing_cutover (
|
|
7
|
+
source_kind text NOT NULL CHECK (source_kind IN ('paid','handoff')),
|
|
8
|
+
source_id uuid NOT NULL,
|
|
9
|
+
PRIMARY KEY (source_kind,source_id)
|
|
10
|
+
);
|
|
11
|
+
INSERT INTO public.invoicing_cutover(source_kind,source_id)
|
|
12
|
+
SELECT 'paid',id FROM public.commerce_settlement_transitions
|
|
13
|
+
UNION ALL SELECT 'handoff',id FROM public.fulfillment_shipment_operations
|
|
14
|
+
ON CONFLICT DO NOTHING;
|
|
15
|
+
CREATE TABLE public.invoicing_duties (
|
|
16
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
17
|
+
order_id uuid NOT NULL REFERENCES public.commerce_orders(id),
|
|
18
|
+
parent_duty_id uuid REFERENCES public.invoicing_duties(id),
|
|
19
|
+
document_id uuid UNIQUE REFERENCES public.accounting_documents(id),
|
|
20
|
+
request_id uuid UNIQUE REFERENCES public.accounting_document_requests(id),
|
|
21
|
+
original_key text NOT NULL,
|
|
22
|
+
status text NOT NULL DEFAULT 'owed' CHECK (status IN ('owed','blocked','ready','processing','unknown','issued','voided','unsupported','faulted')),
|
|
23
|
+
provider text,
|
|
24
|
+
seller_policy_version text,
|
|
25
|
+
generation integer NOT NULL DEFAULT 0 CHECK (generation >= 0),
|
|
26
|
+
claim_token uuid,
|
|
27
|
+
lease_until timestamptz,
|
|
28
|
+
available_at timestamptz NOT NULL DEFAULT now(),
|
|
29
|
+
fact_attempts integer NOT NULL DEFAULT 0,
|
|
30
|
+
fact_token uuid,
|
|
31
|
+
fact_lease_until timestamptz,
|
|
32
|
+
preflight_generation integer,
|
|
33
|
+
reversal jsonb,
|
|
34
|
+
last_result jsonb,
|
|
35
|
+
created_at timestamptz NOT NULL DEFAULT now()
|
|
36
|
+
);
|
|
37
|
+
CREATE UNIQUE INDEX invoicing_duties_one_issue ON public.invoicing_duties(order_id) WHERE parent_duty_id IS NULL;
|
|
38
|
+
CREATE UNIQUE INDEX invoicing_duties_one_correction ON public.invoicing_duties(parent_duty_id) WHERE parent_duty_id IS NOT NULL;
|
|
39
|
+
CREATE TABLE public.invoicing_fact_revisions (
|
|
40
|
+
duty_id uuid PRIMARY KEY REFERENCES public.invoicing_duties(id),
|
|
41
|
+
revision_ref text NOT NULL CHECK (btrim(revision_ref) <> ''),
|
|
42
|
+
facts jsonb NOT NULL CHECK (jsonb_typeof(facts) = 'object'),
|
|
43
|
+
installed_at timestamptz NOT NULL DEFAULT now()
|
|
44
|
+
);
|
|
45
|
+
-- Source identities, exact original host keys and terminal effects share this immutable
|
|
46
|
+
-- receipt rail; different source/key aliases point to the same physical duty.
|
|
47
|
+
CREATE TABLE public.invoicing_effect_receipts (
|
|
48
|
+
receipt_key text PRIMARY KEY,
|
|
49
|
+
duty_id uuid NOT NULL REFERENCES public.invoicing_duties(id),
|
|
50
|
+
payload jsonb NOT NULL,
|
|
51
|
+
recorded_at timestamptz NOT NULL DEFAULT now()
|
|
52
|
+
);
|
|
53
|
+
-- No order/duty FK: recording a capture fault must not reacquire money locks.
|
|
54
|
+
CREATE TABLE public.invoicing_capture_faults (
|
|
55
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
56
|
+
order_id uuid NOT NULL,
|
|
57
|
+
source_kind text NOT NULL,
|
|
58
|
+
source_id uuid NOT NULL,
|
|
59
|
+
attempts integer NOT NULL,
|
|
60
|
+
state text NOT NULL CHECK (state IN ('retryable','faulted')),
|
|
61
|
+
available_at timestamptz NOT NULL,
|
|
62
|
+
recorded_at timestamptz NOT NULL DEFAULT clock_timestamp()
|
|
63
|
+
);
|
|
64
|
+
CREATE INDEX invoicing_capture_faults_source ON public.invoicing_capture_faults(source_kind,source_id);
|
|
65
|
+
CREATE INDEX invoicing_capture_faults_order ON public.invoicing_capture_faults(order_id);
|
|
66
|
+
CREATE INDEX invoicing_duties_available ON public.invoicing_duties(status,available_at);
|
|
67
|
+
CREATE VIEW public.invoicing_due_sources WITH (security_invoker=true) AS
|
|
68
|
+
SELECT 'paid'::text AS source_kind, t.id AS source_id, i.order_id,
|
|
69
|
+
t.idempotency_key AS source_key, t.occurred_at,
|
|
70
|
+
jsonb_build_object('settlementIntentId',i.id,'amountMinor',i.amount_minor,'currency',i.currency_code) AS evidence
|
|
71
|
+
FROM public.commerce_settlement_transitions t
|
|
72
|
+
JOIN public.commerce_settlement_intents i ON i.id=t.intent_id
|
|
73
|
+
WHERE t.outcome='succeeded'
|
|
74
|
+
AND NOT EXISTS (SELECT 1 FROM public.invoicing_cutover c WHERE c.source_kind='paid' AND c.source_id=t.id)
|
|
75
|
+
AND NOT EXISTS (SELECT 1 FROM public.accounting_documents a WHERE a.order_id=i.order_id AND a.status<>'voided')
|
|
76
|
+
AND NOT EXISTS (SELECT 1 FROM public.invoicing_capture_faults f WHERE f.source_kind='paid' AND f.source_id=t.id AND (f.state='faulted' OR f.available_at>now()))
|
|
77
|
+
AND NOT EXISTS (
|
|
78
|
+
SELECT 1 FROM public.invoicing_effect_receipts r WHERE r.receipt_key='source:paid:' || t.id::text)
|
|
79
|
+
UNION ALL
|
|
80
|
+
SELECT 'handoff'::text, o.id, o.order_id, o.idempotency_key, o.occurred_at,
|
|
81
|
+
jsonb_build_object('shipmentId',s.id,'handedOverAt',to_char(s.handed_over_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS.US"Z"'))
|
|
82
|
+
FROM public.fulfillment_shipment_operations o
|
|
83
|
+
JOIN public.fulfillment_shipments s ON s.id=o.shipment_id
|
|
84
|
+
WHERE o.payload->>'toStatus'='handed_over' AND s.handed_over_at IS NOT NULL
|
|
85
|
+
AND NOT EXISTS (SELECT 1 FROM public.invoicing_cutover c WHERE c.source_kind='handoff' AND c.source_id=o.id)
|
|
86
|
+
AND NOT EXISTS (SELECT 1 FROM public.accounting_documents a WHERE a.order_id=o.order_id AND a.status<>'voided')
|
|
87
|
+
AND NOT EXISTS (SELECT 1 FROM public.invoicing_capture_faults f WHERE f.source_kind='handoff' AND f.source_id=o.id AND (f.state='faulted' OR f.available_at>now()))
|
|
88
|
+
AND NOT EXISTS (
|
|
89
|
+
SELECT 1 FROM public.invoicing_effect_receipts r WHERE r.receipt_key='source:handoff:' || o.id::text);
|
|
90
|
+
|
|
91
|
+
CREATE FUNCTION public.invoicing_capture_duty(
|
|
92
|
+
p_key text, p_source_kind text, p_source_id uuid, p_order_id uuid, p_evidence jsonb, p_occurred_at timestamptz
|
|
93
|
+
) RETURNS jsonb LANGUAGE plpgsql SECURITY INVOKER SET search_path = pg_catalog AS $$
|
|
94
|
+
DECLARE
|
|
95
|
+
v_duty public.invoicing_duties%ROWTYPE;
|
|
96
|
+
v_receipt public.invoicing_effect_receipts%ROWTYPE;
|
|
97
|
+
v_payload jsonb;
|
|
98
|
+
v_key text;
|
|
99
|
+
BEGIN
|
|
100
|
+
IF p_key IS NULL OR btrim(p_key)='' OR p_source_kind NOT IN ('paid','handoff') OR p_source_kind IS NULL
|
|
101
|
+
OR p_source_id IS NULL OR p_order_id IS NULL OR p_occurred_at IS NULL OR jsonb_typeof(p_evidence) IS DISTINCT FROM 'object'
|
|
102
|
+
THEN RAISE EXCEPTION 'invoicing_capture_invalid' USING ERRCODE='22023'; END IF;
|
|
103
|
+
-- Serialize only aliases for this order, without consulting accounting objects.
|
|
104
|
+
IF NOT pg_try_advisory_xact_lock(hashtextextended('invoicing:' || p_order_id::text,0)) THEN
|
|
105
|
+
RAISE EXCEPTION 'invoicing_capture_busy' USING ERRCODE='55P03';
|
|
106
|
+
END IF;
|
|
107
|
+
INSERT INTO public.invoicing_duties(order_id,original_key,seller_policy_version) VALUES(p_order_id,p_key,
|
|
108
|
+
'invoicing.seller.v1')
|
|
109
|
+
ON CONFLICT (order_id) WHERE parent_duty_id IS NULL DO NOTHING RETURNING * INTO v_duty;
|
|
110
|
+
IF v_duty.id IS NULL THEN
|
|
111
|
+
SELECT * INTO v_duty FROM public.invoicing_duties WHERE order_id=p_order_id AND parent_duty_id IS NULL;
|
|
112
|
+
END IF;
|
|
113
|
+
v_payload:=jsonb_build_object('sourceKind',p_source_kind,'sourceId',p_source_id,'orderId',p_order_id,'evidence',p_evidence,'occurredAt',to_char(p_occurred_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS.US"Z"'));
|
|
114
|
+
FOREACH v_key IN ARRAY ARRAY['source:' || p_source_kind || ':' || p_source_id::text,'key:' || p_source_kind || ':' || coalesce(p_evidence->>'settlementIntentId',p_source_id::text) || ':' || p_key] LOOP
|
|
115
|
+
INSERT INTO public.invoicing_effect_receipts(receipt_key,duty_id,payload) VALUES(v_key,v_duty.id,v_payload)
|
|
116
|
+
ON CONFLICT DO NOTHING;
|
|
117
|
+
SELECT * INTO v_receipt FROM public.invoicing_effect_receipts WHERE receipt_key=v_key;
|
|
118
|
+
IF v_receipt.duty_id IS DISTINCT FROM v_duty.id OR v_receipt.payload IS DISTINCT FROM v_payload THEN
|
|
119
|
+
RAISE EXCEPTION 'invoicing_source_or_key_conflict' USING ERRCODE='23505';
|
|
120
|
+
END IF;
|
|
121
|
+
END LOOP;
|
|
122
|
+
RETURN jsonb_build_object('dutyId',v_duty.id,'status',v_duty.status);
|
|
123
|
+
END;
|
|
124
|
+
$$;
|
|
125
|
+
|
|
126
|
+
CREATE FUNCTION public.invoicing_install_facts(p_duty uuid,p_facts jsonb,p_revision text)
|
|
127
|
+
RETURNS jsonb LANGUAGE plpgsql SECURITY INVOKER SET search_path = pg_catalog AS $$
|
|
128
|
+
DECLARE v_duty public.invoicing_duties%ROWTYPE; v_prior public.invoicing_fact_revisions%ROWTYPE;
|
|
129
|
+
v_line jsonb; v_sum numeric:=0; v_net numeric:=0; v_tax numeric:=0;
|
|
130
|
+
BEGIN
|
|
131
|
+
SELECT * INTO v_duty FROM public.invoicing_duties WHERE id=p_duty FOR UPDATE;
|
|
132
|
+
IF NOT FOUND THEN RAISE EXCEPTION 'invoicing_duty_missing' USING ERRCODE='22023'; END IF;
|
|
133
|
+
SELECT * INTO v_prior FROM public.invoicing_fact_revisions WHERE duty_id=p_duty;
|
|
134
|
+
IF FOUND THEN
|
|
135
|
+
IF v_prior.facts IS DISTINCT FROM p_facts OR v_prior.revision_ref IS DISTINCT FROM p_revision THEN
|
|
136
|
+
RAISE EXCEPTION 'invoicing_facts_immutable' USING ERRCODE='23505'; END IF;
|
|
137
|
+
RETURN jsonb_build_object('dutyId',p_duty,'status',v_duty.status,'replayed',true);
|
|
138
|
+
END IF;
|
|
139
|
+
IF v_duty.status NOT IN ('owed','blocked') THEN RAISE EXCEPTION 'invoicing_facts_inactive' USING ERRCODE='22023'; END IF;
|
|
140
|
+
IF p_revision IS NULL OR btrim(p_revision)='' OR jsonb_typeof(p_facts) IS DISTINCT FROM 'object'
|
|
141
|
+
OR jsonb_typeof(p_facts->'buyer') IS DISTINCT FROM 'object'
|
|
142
|
+
OR jsonb_typeof(p_facts->'lines') IS DISTINCT FROM 'array'
|
|
143
|
+
OR jsonb_typeof(p_facts->'dates') IS DISTINCT FROM 'object'
|
|
144
|
+
OR jsonb_typeof(p_facts->'refs') IS DISTINCT FROM 'object'
|
|
145
|
+
OR nullif(p_facts->>'policyVersion','') IS NULL OR p_facts->>'currency' IS NULL
|
|
146
|
+
OR length(p_facts->>'currency')<>3 OR jsonb_array_length(p_facts->'lines')=0
|
|
147
|
+
THEN
|
|
148
|
+
UPDATE public.invoicing_duties SET status='blocked',last_result='{"kind":"missing_facts"}' WHERE id=p_duty;
|
|
149
|
+
RETURN jsonb_build_object('dutyId',p_duty,'status','blocked');
|
|
150
|
+
END IF;
|
|
151
|
+
-- Minor amounts must be integer and safe before a host converts them to JS numbers.
|
|
152
|
+
FOR v_line IN SELECT value FROM jsonb_array_elements(p_facts->'lines') LOOP
|
|
153
|
+
IF jsonb_typeof(v_line->'quantity') IS DISTINCT FROM 'number' OR jsonb_typeof(v_line->'taxRateBps') IS DISTINCT FROM 'number'
|
|
154
|
+
OR nullif(btrim(v_line->>'description'),'') IS NULL OR NOT (v_line ?& ARRAY['quantity','unitGrossMinor','totalGrossMinor','totalNetMinor','totalTaxMinor','taxRateBps','description'])
|
|
155
|
+
OR (v_line->>'quantity')::numeric NOT BETWEEN 1 AND 2147483647
|
|
156
|
+
OR trunc((v_line->>'quantity')::numeric)<>(v_line->>'quantity')::numeric
|
|
157
|
+
OR (v_line->>'taxRateBps')::numeric NOT BETWEEN 0 AND 10000
|
|
158
|
+
OR trunc((v_line->>'taxRateBps')::numeric)<>(v_line->>'taxRateBps')::numeric
|
|
159
|
+
OR (v_line->>'totalNetMinor')::numeric+(v_line->>'totalTaxMinor')::numeric<>(v_line->>'totalGrossMinor')::numeric
|
|
160
|
+
OR EXISTS (SELECT 1 FROM jsonb_each(v_line) a WHERE a.key IN ('unitGrossMinor','totalGrossMinor','totalNetMinor','totalTaxMinor')
|
|
161
|
+
AND (jsonb_typeof(a.value)<>'number' OR abs(a.value::text::numeric)>9007199254740991 OR trunc(a.value::text::numeric)<>a.value::text::numeric))
|
|
162
|
+
THEN RAISE EXCEPTION 'invoicing_facts_money_unsupported' USING ERRCODE='22023'; END IF;
|
|
163
|
+
v_sum:=v_sum+(v_line->>'totalGrossMinor')::numeric;
|
|
164
|
+
v_net:=v_net+(v_line->>'totalNetMinor')::numeric;
|
|
165
|
+
v_tax:=v_tax+(v_line->>'totalTaxMinor')::numeric;
|
|
166
|
+
END LOOP;
|
|
167
|
+
IF v_sum IS DISTINCT FROM (p_facts->>'totalGrossMinor')::numeric
|
|
168
|
+
OR v_net IS DISTINCT FROM (p_facts->>'totalNetMinor')::numeric OR v_tax IS DISTINCT FROM (p_facts->>'totalTaxMinor')::numeric
|
|
169
|
+
OR v_sum<0 OR v_sum>9007199254740991 OR v_net<0 OR v_tax<0
|
|
170
|
+
THEN RAISE EXCEPTION 'invoicing_facts_total_mismatch' USING ERRCODE='22023'; END IF;
|
|
171
|
+
IF NOT EXISTS (SELECT 1 FROM public.commerce_orders WHERE id=v_duty.order_id AND currency_code=p_facts->>'currency'
|
|
172
|
+
AND total_amount_minor=v_sum AND paid_at IS NOT NULL) THEN
|
|
173
|
+
RAISE EXCEPTION 'invoicing_order_money_mismatch' USING ERRCODE='22023';
|
|
174
|
+
END IF;
|
|
175
|
+
INSERT INTO public.invoicing_fact_revisions(duty_id,revision_ref,facts) VALUES(p_duty,p_revision,p_facts) ON CONFLICT DO NOTHING;
|
|
176
|
+
RETURN jsonb_build_object('dutyId',p_duty,'status','owed');
|
|
177
|
+
END;
|
|
178
|
+
$$;
|
|
179
|
+
|
|
180
|
+
-- Outbox-style claim/token/visibility; source capture and fact preparation
|
|
181
|
+
-- each stop at eight attempts. Provider duty claims have no attempt ceiling.
|
|
182
|
+
-- Commit before invoking accounting work. Capture never installs.
|
|
183
|
+
CREATE FUNCTION public.invoicing_sweep_duties(p_limit integer)
|
|
184
|
+
RETURNS jsonb LANGUAGE plpgsql SECURITY INVOKER SET search_path = pg_catalog AS $$
|
|
185
|
+
DECLARE v_source record; v_duty record; v_seen integer:=0; v_attempt integer; v_claims jsonb:='[]'; v_timeout text:=current_setting('lock_timeout');
|
|
186
|
+
BEGIN
|
|
187
|
+
FOR v_source IN SELECT * FROM public.invoicing_due_sources ORDER BY occurred_at,source_id LIMIT least(greatest(coalesce(p_limit,10),1),100) LOOP
|
|
188
|
+
BEGIN
|
|
189
|
+
PERFORM set_config('lock_timeout','100ms',true);
|
|
190
|
+
PERFORM public.invoicing_capture_duty(v_source.source_key,v_source.source_kind,v_source.source_id,v_source.order_id,v_source.evidence,v_source.occurred_at);
|
|
191
|
+
PERFORM set_config('lock_timeout',v_timeout,true);
|
|
192
|
+
v_seen:=v_seen+1;
|
|
193
|
+
EXCEPTION WHEN OTHERS OR query_canceled THEN
|
|
194
|
+
SELECT coalesce(max(attempts),0)+1 INTO v_attempt FROM public.invoicing_capture_faults WHERE source_kind=v_source.source_kind AND source_id=v_source.source_id;
|
|
195
|
+
INSERT INTO public.invoicing_capture_faults(order_id,source_kind,source_id,attempts,state,available_at)
|
|
196
|
+
VALUES(v_source.order_id,v_source.source_kind,v_source.source_id,v_attempt,CASE WHEN v_attempt>=8 THEN 'faulted' ELSE 'retryable' END,
|
|
197
|
+
now()+make_interval(secs=>least(3600,60*power(2,least(v_attempt-1,6)))::integer));
|
|
198
|
+
END;
|
|
199
|
+
END LOOP;
|
|
200
|
+
FOR v_duty IN SELECT d.* FROM public.invoicing_duties d
|
|
201
|
+
JOIN public.invoicing_fact_revisions f ON f.duty_id=d.id
|
|
202
|
+
WHERE d.request_id IS NULL AND d.status IN ('owed','blocked') AND d.available_at<=now()
|
|
203
|
+
AND d.reversal IS NULL
|
|
204
|
+
AND (d.fact_token IS NULL OR d.fact_lease_until<=now())
|
|
205
|
+
ORDER BY d.created_at,d.id LIMIT least(greatest(coalesce(p_limit,10),1),100) FOR UPDATE OF d SKIP LOCKED LOOP
|
|
206
|
+
IF v_duty.fact_attempts>=8 THEN
|
|
207
|
+
UPDATE public.invoicing_duties SET status='faulted',fact_token=NULL,fact_lease_until=NULL,last_result='{"kind":"faulted"}' WHERE id=v_duty.id;
|
|
208
|
+
CONTINUE;
|
|
209
|
+
END IF;
|
|
210
|
+
UPDATE public.invoicing_duties SET fact_attempts=fact_attempts+1,fact_token=gen_random_uuid(),fact_lease_until=now()+interval '5 minutes'
|
|
211
|
+
WHERE id=v_duty.id RETURNING * INTO v_duty;
|
|
212
|
+
v_claims:=v_claims||jsonb_build_array(jsonb_build_object('dutyId',v_duty.id,'token',v_duty.fact_token));
|
|
213
|
+
END LOOP;
|
|
214
|
+
RETURN jsonb_build_object('collected',v_seen,'claims',v_claims);
|
|
215
|
+
END;
|
|
216
|
+
$$;
|
|
217
|
+
|
|
218
|
+
-- Accounting transaction: no order FOR UPDATE and no external I/O.
|
|
219
|
+
-- The final duty fence uses NOWAIT so it cannot delay order work.
|
|
220
|
+
-- Deterministic document_ref preserves the incumbent one-document identity.
|
|
221
|
+
-- Bound unavoidable FK/unique waits. Commit before the lease-guarded mark RPC.
|
|
222
|
+
CREATE FUNCTION public.invoicing_prepare_facts(p_duty uuid,p_token uuid)
|
|
223
|
+
RETURNS jsonb LANGUAGE plpgsql SECURITY INVOKER SET search_path = pg_catalog AS $$
|
|
224
|
+
DECLARE v_duty public.invoicing_duties%ROWTYPE; v_document public.accounting_documents%ROWTYPE;
|
|
225
|
+
v_facts jsonb; v_request uuid; v_ready uuid; v_status text; v_timeout text;
|
|
226
|
+
BEGIN
|
|
227
|
+
IF NOT pg_try_advisory_xact_lock(hashtextextended('invoicing:'||(SELECT order_id::text FROM public.invoicing_duties WHERE id=p_duty),0)) THEN RETURN '{"status":"owed","kind":"retryable"}'; END IF;
|
|
228
|
+
SELECT * INTO v_duty FROM public.invoicing_duties WHERE id=p_duty;
|
|
229
|
+
IF NOT FOUND OR p_token IS NULL OR v_duty.fact_token IS NULL OR v_duty.fact_lease_until IS NULL OR v_duty.fact_token IS DISTINCT FROM p_token OR v_duty.fact_lease_until<=now() OR v_duty.request_id IS NOT NULL OR v_duty.status NOT IN ('owed','blocked') OR v_duty.reversal IS NOT NULL THEN RETURN '{"status":"stale"}'; END IF;
|
|
230
|
+
SELECT facts INTO v_facts FROM public.invoicing_fact_revisions WHERE duty_id=p_duty;
|
|
231
|
+
v_timeout:=current_setting('lock_timeout');
|
|
232
|
+
BEGIN
|
|
233
|
+
PERFORM set_config('lock_timeout','100ms',true);
|
|
234
|
+
SELECT * INTO v_document FROM public.accounting_documents WHERE order_id=v_duty.order_id AND correction_of_document_id IS NULL FOR UPDATE;
|
|
235
|
+
IF FOUND AND (v_document.metadata->'neutralFacts' IS DISTINCT FROM v_facts OR v_document.total_gross_minor<>(v_facts->>'totalGrossMinor')::bigint) THEN
|
|
236
|
+
PERFORM set_config('lock_timeout',v_timeout,true);
|
|
237
|
+
RETURN '{"status":"unsupported","kind":"legacy_facts_unproved"}';
|
|
238
|
+
END IF;
|
|
239
|
+
IF v_document.id IS NULL THEN
|
|
240
|
+
INSERT INTO public.accounting_documents(order_id,document_ref,status,total_gross_minor,requested_at,metadata)
|
|
241
|
+
VALUES(v_duty.order_id,'order:'||v_duty.order_id::text||':base','issue_requested',(v_facts->>'totalGrossMinor')::bigint,now(),
|
|
242
|
+
jsonb_build_object('neutralFacts',v_facts,'currency',v_facts->>'currency','requestIdempotencyKey',v_duty.original_key))
|
|
243
|
+
ON CONFLICT ON CONSTRAINT accounting_documents_document_ref_key DO NOTHING RETURNING * INTO v_document;
|
|
244
|
+
IF v_document.id IS NULL THEN
|
|
245
|
+
-- A rival may have installed incompatible facts since the read. Retry
|
|
246
|
+
-- through the same immutable-facts comparison, never adopt blindly.
|
|
247
|
+
RAISE EXCEPTION 'invoicing_install_raced' USING ERRCODE='40001';
|
|
248
|
+
END IF;
|
|
249
|
+
PERFORM public.accounting_document_write_positions(v_document.id,v_facts->'lines');
|
|
250
|
+
INSERT INTO public.accounting_document_requests(document_id,request_kind,available_at) VALUES(v_document.id,'issue','infinity');
|
|
251
|
+
INSERT INTO public.accounting_document_operations(document_id,order_id,operation_type,idempotency_key,payload,occurred_at)
|
|
252
|
+
VALUES(v_document.id,v_duty.order_id,'document_requested','invoicing:request:'||v_duty.id::text,jsonb_build_object('totalGrossMinor',(v_facts->>'totalGrossMinor')::bigint),now());
|
|
253
|
+
END IF;
|
|
254
|
+
SELECT id INTO v_request FROM public.accounting_document_requests WHERE document_id=v_document.id AND request_kind='issue';
|
|
255
|
+
UPDATE public.accounting_document_requests SET available_at='infinity'
|
|
256
|
+
WHERE id=v_request AND status='pending' AND attempts=0 AND claim_token IS NULL RETURNING id INTO v_ready;
|
|
257
|
+
v_status:=CASE WHEN v_document.provider_reference IS NOT NULL THEN 'issued' WHEN v_document.status='voided' THEN 'voided'
|
|
258
|
+
WHEN v_ready IS NULL THEN 'unsupported' ELSE 'ready' END;
|
|
259
|
+
PERFORM 1 FROM public.invoicing_duties WHERE id=p_duty AND fact_token=p_token
|
|
260
|
+
AND fact_lease_until>clock_timestamp() AND request_id IS NULL
|
|
261
|
+
AND status IN ('owed','blocked') AND reversal IS NULL FOR UPDATE NOWAIT;
|
|
262
|
+
IF NOT FOUND THEN RAISE EXCEPTION 'invoicing_fact_lease_stale' USING ERRCODE='40001'; END IF;
|
|
263
|
+
PERFORM set_config('lock_timeout',v_timeout,true);
|
|
264
|
+
RETURN jsonb_build_object('status',v_status,'documentId',v_document.id,'requestId',v_request);
|
|
265
|
+
EXCEPTION WHEN OTHERS OR query_canceled THEN
|
|
266
|
+
RETURN '{"status":"owed","kind":"retryable"}';
|
|
267
|
+
END;
|
|
268
|
+
END;
|
|
269
|
+
$$;
|
|
270
|
+
CREATE FUNCTION public.invoicing_finish_facts(p_duty uuid,p_token uuid,p_result jsonb)
|
|
271
|
+
RETURNS jsonb LANGUAGE plpgsql SECURITY INVOKER SET search_path = pg_catalog AS $$
|
|
272
|
+
DECLARE v_duty public.invoicing_duties%ROWTYPE; v_document record;
|
|
273
|
+
v_request record; v_document_id uuid; v_request_id uuid; v_status text; v_owned boolean; v_facts jsonb; v_kind text;
|
|
274
|
+
BEGIN
|
|
275
|
+
-- Validate host identity before taking its request lock; retain request-before-duty order.
|
|
276
|
+
IF p_result->>'requestId' IS NOT NULL THEN
|
|
277
|
+
PERFORM 1 FROM public.accounting_document_requests r
|
|
278
|
+
JOIN public.accounting_documents a ON a.id=r.document_id
|
|
279
|
+
JOIN public.invoicing_duties d ON d.order_id=a.order_id
|
|
280
|
+
WHERE d.id=p_duty AND r.id=(p_result->>'requestId')::uuid FOR UPDATE OF r;
|
|
281
|
+
IF NOT FOUND THEN
|
|
282
|
+
RAISE EXCEPTION 'invoicing_fact_result_mismatch' USING ERRCODE='22023'; END IF;
|
|
283
|
+
END IF;
|
|
284
|
+
SELECT * INTO v_duty FROM public.invoicing_duties WHERE id=p_duty FOR UPDATE;
|
|
285
|
+
IF NOT FOUND OR p_token IS NULL OR v_duty.fact_token IS NULL OR v_duty.fact_lease_until IS NULL OR v_duty.fact_token IS DISTINCT FROM p_token OR v_duty.fact_lease_until<=now() OR v_duty.request_id IS NOT NULL OR v_duty.status NOT IN ('owed','blocked') OR v_duty.reversal IS NOT NULL THEN RETURN '{"status":"stale"}'; END IF;
|
|
286
|
+
SELECT facts INTO v_facts FROM public.invoicing_fact_revisions WHERE duty_id=p_duty;
|
|
287
|
+
v_status:=p_result->>'status';
|
|
288
|
+
v_kind:=p_result->>'kind';
|
|
289
|
+
IF v_status IS NULL OR v_status NOT IN ('owed','ready','issued','voided','unsupported') THEN RAISE EXCEPTION 'invoicing_fact_result_invalid' USING ERRCODE='22023'; END IF;
|
|
290
|
+
IF p_result->>'documentId' IS NOT NULL THEN
|
|
291
|
+
SELECT * INTO v_document FROM public.accounting_documents WHERE id=(p_result->>'documentId')::uuid;
|
|
292
|
+
SELECT * INTO v_request FROM public.accounting_document_requests WHERE id=(p_result->>'requestId')::uuid;
|
|
293
|
+
IF v_document.id IS NULL OR v_document.order_id IS DISTINCT FROM v_duty.order_id OR v_document.correction_of_document_id IS NOT NULL
|
|
294
|
+
OR v_request.id IS NULL OR v_request.document_id IS DISTINCT FROM v_document.id OR v_request.request_kind<>'issue'
|
|
295
|
+
THEN RAISE EXCEPTION 'invoicing_fact_result_mismatch' USING ERRCODE='22023'; END IF;
|
|
296
|
+
v_status:=CASE WHEN v_document.provider_reference IS NOT NULL THEN 'issued' WHEN v_document.status='voided' THEN 'voided'
|
|
297
|
+
WHEN v_request.attempts>0 OR v_request.status<>'pending' OR v_request.claim_token IS NOT NULL
|
|
298
|
+
OR v_request.available_at<>'infinity'::timestamptz THEN 'unsupported' ELSE 'ready' END;
|
|
299
|
+
IF v_status IS DISTINCT FROM p_result->>'status' THEN RAISE EXCEPTION 'invoicing_fact_result_mismatch' USING ERRCODE='22023'; END IF;
|
|
300
|
+
v_document_id:=v_document.id; v_request_id:=v_request.id;
|
|
301
|
+
v_owned:=EXISTS(SELECT 1 FROM public.accounting_document_operations WHERE document_id=v_document.id AND idempotency_key='invoicing:request:'||p_duty::text);
|
|
302
|
+
IF NOT v_owned AND (v_facts IS NULL OR v_document.metadata->'neutralFacts' IS DISTINCT FROM v_facts
|
|
303
|
+
OR v_document.total_gross_minor IS DISTINCT FROM (v_facts->>'totalGrossMinor')::bigint) THEN
|
|
304
|
+
RAISE EXCEPTION 'invoicing_fact_result_mismatch' USING ERRCODE='22023'; END IF;
|
|
305
|
+
IF v_status='unsupported' THEN v_kind:='unsupported'; END IF;
|
|
306
|
+
ELSIF p_result->>'requestId' IS NOT NULL OR v_status NOT IN ('owed','unsupported') THEN
|
|
307
|
+
RAISE EXCEPTION 'invoicing_fact_result_mismatch' USING ERRCODE='22023';
|
|
308
|
+
ELSIF v_status='unsupported' THEN
|
|
309
|
+
IF v_facts IS NULL OR NOT EXISTS (SELECT 1 FROM public.accounting_documents a
|
|
310
|
+
WHERE a.order_id=v_duty.order_id AND a.correction_of_document_id IS NULL
|
|
311
|
+
AND (a.metadata->'neutralFacts' IS DISTINCT FROM v_facts
|
|
312
|
+
OR a.total_gross_minor IS DISTINCT FROM (v_facts->>'totalGrossMinor')::bigint)) THEN
|
|
313
|
+
RAISE EXCEPTION 'invoicing_fact_result_mismatch' USING ERRCODE='22023'; END IF;
|
|
314
|
+
v_kind:='legacy_facts_unproved';
|
|
315
|
+
END IF;
|
|
316
|
+
IF v_status='owed' AND v_duty.fact_attempts>=8 THEN v_status:='faulted'; END IF;
|
|
317
|
+
UPDATE public.invoicing_duties SET status=v_status,fact_token=NULL,fact_lease_until=NULL,
|
|
318
|
+
document_id=v_document_id,request_id=v_request_id,
|
|
319
|
+
seller_policy_version=CASE WHEN v_document_id IS NOT NULL AND NOT v_owned THEN NULL ELSE seller_policy_version END,
|
|
320
|
+
available_at=CASE WHEN v_status='owed' THEN now()+make_interval(secs=>least(3600,60*power(2,least(fact_attempts-1,6)))::integer) ELSE now() END,
|
|
321
|
+
last_result=CASE WHEN v_status='faulted' THEN '{"kind":"faulted"}'::jsonb WHEN v_kind IS NOT NULL THEN jsonb_build_object('kind',v_kind) END
|
|
322
|
+
WHERE id=p_duty;
|
|
323
|
+
RETURN jsonb_build_object('dutyId',p_duty,'status',v_status);
|
|
324
|
+
END;
|
|
325
|
+
$$;
|
|
326
|
+
|
|
327
|
+
-- Trusted host readback is independent of payment truth and accounting tables.
|
|
328
|
+
-- Expose only closed neutral diagnostic codes, never raw SQLSTATE/provider evidence.
|
|
329
|
+
-- Ready remains owed until issue; a live processing obligation is claimed.
|
|
330
|
+
CREATE FUNCTION public.invoicing_duty_readback(p_order_id uuid)
|
|
331
|
+
RETURNS jsonb LANGUAGE sql STABLE SECURITY INVOKER SET search_path = pg_catalog AS $$
|
|
332
|
+
SELECT jsonb_build_object('orderId',p_order_id,'captureFaults',
|
|
333
|
+
(SELECT coalesce(jsonb_agg(jsonb_build_object('sourceId',f.source_id,'state',CASE WHEN EXISTS(SELECT 1 FROM public.invoicing_effect_receipts r WHERE r.receipt_key='source:'||f.source_kind||':'||f.source_id::text) THEN 'resolved' ELSE f.state END,'attempts',f.attempts,'availableAt',f.available_at) ORDER BY f.recorded_at,f.id),'[]'::jsonb) FROM (SELECT DISTINCT ON (source_kind,source_id) * FROM public.invoicing_capture_faults WHERE order_id=p_order_id ORDER BY source_kind,source_id,recorded_at DESC,id DESC) f),'duties',coalesce(jsonb_agg(
|
|
334
|
+
jsonb_build_object('dutyId',d.id,'parentDutyId',d.parent_duty_id,'documentId',d.document_id,
|
|
335
|
+
'state',CASE d.status WHEN 'ready' THEN 'owed' WHEN 'processing' THEN 'claimed' ELSE d.status END,
|
|
336
|
+
'attempts',d.generation,'factAttempts',d.fact_attempts,
|
|
337
|
+
'diagnosticCode',CASE WHEN d.last_result->>'kind' IN
|
|
338
|
+
('faulted','missing_facts','legacy_facts_unproved','retryable','blocked','unsupported','unknown','absent')
|
|
339
|
+
THEN d.last_result->>'kind' ELSE NULL END,
|
|
340
|
+
'createdAt',d.created_at,'availableAt',d.available_at,'leaseUntil',d.lease_until)
|
|
341
|
+
ORDER BY d.created_at,d.id),'[]'::jsonb))
|
|
342
|
+
FROM public.invoicing_duties d WHERE d.order_id=p_order_id;
|
|
343
|
+
$$;
|
|
344
|
+
|
|
345
|
+
CREATE FUNCTION public.invoicing_claim_duties(p_limit integer,p_visibility integer,p_provider text,p_order_id uuid DEFAULT NULL)
|
|
346
|
+
RETURNS jsonb LANGUAGE plpgsql SECURITY INVOKER SET search_path = pg_catalog AS $$
|
|
347
|
+
DECLARE
|
|
348
|
+
v_request public.accounting_document_requests%ROWTYPE;
|
|
349
|
+
v_duty public.invoicing_duties%ROWTYPE;
|
|
350
|
+
v_rows jsonb:='[]';
|
|
351
|
+
v_facts jsonb;
|
|
352
|
+
BEGIN
|
|
353
|
+
IF nullif(btrim(p_provider),'') IS NULL THEN RAISE EXCEPTION 'invoicing_provider_required' USING ERRCODE='22023'; END IF;
|
|
354
|
+
FOR v_request IN SELECT r.* FROM public.accounting_document_requests r
|
|
355
|
+
JOIN public.invoicing_duties d ON d.request_id=r.id
|
|
356
|
+
WHERE (p_order_id IS NULL OR d.order_id=p_order_id)
|
|
357
|
+
AND d.status IN ('ready','processing','unknown') AND d.available_at<=now()
|
|
358
|
+
AND (d.status<>'processing' OR d.lease_until<=now())
|
|
359
|
+
AND (d.provider IS NULL OR d.provider=p_provider)
|
|
360
|
+
ORDER BY d.available_at,r.created_at,r.id LIMIT least(greatest(coalesce(p_limit,10),1),100) FOR UPDATE OF r, d SKIP LOCKED LOOP
|
|
361
|
+
SELECT * INTO v_duty FROM public.invoicing_duties WHERE request_id=v_request.id FOR UPDATE SKIP LOCKED;
|
|
362
|
+
IF NOT FOUND THEN CONTINUE; END IF;
|
|
363
|
+
IF v_request.attempts IS DISTINCT FROM v_duty.generation OR v_request.claim_token IS DISTINCT FROM v_duty.claim_token THEN
|
|
364
|
+
UPDATE public.invoicing_duties SET status='unsupported',last_result='{"kind":"unsupported"}' WHERE id=v_duty.id;
|
|
365
|
+
CONTINUE;
|
|
366
|
+
END IF;
|
|
367
|
+
SELECT facts INTO v_facts FROM public.invoicing_fact_revisions WHERE duty_id=v_duty.id;
|
|
368
|
+
IF NOT FOUND THEN CONTINUE; END IF;
|
|
369
|
+
-- A crashed create may have crossed the remote boundary. Reclaims are readback only.
|
|
370
|
+
v_duty.last_result:=jsonb_build_object('action',CASE WHEN v_duty.status IN ('processing','unknown') THEN 'readback' ELSE 'create' END);
|
|
371
|
+
UPDATE public.invoicing_duties SET status='processing',generation=generation+1,claim_token=gen_random_uuid(),
|
|
372
|
+
lease_until=now()+make_interval(secs=>least(greatest(coalesce(p_visibility,300),1),3600)),provider=coalesce(provider,p_provider),
|
|
373
|
+
preflight_generation=NULL,last_result=v_duty.last_result WHERE id=v_duty.id RETURNING * INTO v_duty;
|
|
374
|
+
UPDATE public.accounting_document_requests SET status='processing',attempts=v_duty.generation,claim_token=v_duty.claim_token,
|
|
375
|
+
available_at='infinity',updated_at=now() WHERE id=v_request.id;
|
|
376
|
+
v_rows:=v_rows || jsonb_build_array(to_jsonb(v_duty)||jsonb_build_object('facts',v_facts,'providerCreateIdentity','invoicing:'||v_duty.id::text));
|
|
377
|
+
END LOOP;
|
|
378
|
+
RETURN v_rows;
|
|
379
|
+
END;
|
|
380
|
+
$$;
|
|
381
|
+
|
|
382
|
+
CREATE FUNCTION public.invoicing_preflight_duty(p_duty uuid,p_token uuid,p_generation integer,p_evidence jsonb)
|
|
383
|
+
RETURNS jsonb LANGUAGE plpgsql SECURITY INVOKER SET search_path = pg_catalog AS $$
|
|
384
|
+
DECLARE v_duty public.invoicing_duties%ROWTYPE; v_facts jsonb; v_result text;
|
|
385
|
+
BEGIN
|
|
386
|
+
PERFORM 1 FROM public.accounting_document_requests WHERE id=(SELECT request_id FROM public.invoicing_duties WHERE id=p_duty) FOR UPDATE;
|
|
387
|
+
SELECT * INTO v_duty FROM public.invoicing_duties WHERE id=p_duty FOR UPDATE;
|
|
388
|
+
IF NOT FOUND OR v_duty.status<>'processing' OR v_duty.claim_token IS DISTINCT FROM p_token OR v_duty.generation IS DISTINCT FROM p_generation OR v_duty.lease_until<=now()
|
|
389
|
+
THEN RETURN jsonb_build_object('kind','stale'); END IF;
|
|
390
|
+
SELECT facts INTO v_facts FROM public.invoicing_fact_revisions WHERE duty_id=p_duty;
|
|
391
|
+
v_result:=CASE WHEN p_evidence->>'kind' IS DISTINCT FROM 'observed' THEN 'unavailable'
|
|
392
|
+
WHEN p_evidence->>'currency' IS DISTINCT FROM v_facts->>'currency'
|
|
393
|
+
OR (p_evidence->>'amountMinor')::numeric IS DISTINCT FROM (v_facts->>'totalGrossMinor')::numeric THEN 'mismatch'
|
|
394
|
+
WHEN p_evidence->>'outcome' IS DISTINCT FROM 'captured' THEN 'blocked' ELSE 'ready' END;
|
|
395
|
+
UPDATE public.invoicing_duties SET preflight_generation=CASE WHEN v_result='ready' THEN p_generation END,
|
|
396
|
+
last_result=last_result || jsonb_build_object('preflight',v_result) WHERE id=p_duty;
|
|
397
|
+
RETURN jsonb_build_object('kind',v_result);
|
|
398
|
+
END;
|
|
399
|
+
$$;
|
|
400
|
+
|
|
401
|
+
CREATE FUNCTION public.invoicing_finish_issue(p_duty uuid,p_token uuid,p_generation integer,p_external text,p_number text,p_proof jsonb)
|
|
402
|
+
RETURNS jsonb LANGUAGE plpgsql SECURITY INVOKER SET search_path = pg_catalog AS $$
|
|
403
|
+
DECLARE v_duty public.invoicing_duties%ROWTYPE; v_payload jsonb; v_prior jsonb;
|
|
404
|
+
BEGIN
|
|
405
|
+
PERFORM 1 FROM public.accounting_document_requests WHERE id=(SELECT request_id FROM public.invoicing_duties WHERE id=p_duty) FOR UPDATE;
|
|
406
|
+
SELECT * INTO v_duty FROM public.invoicing_duties WHERE id=p_duty FOR UPDATE;
|
|
407
|
+
IF NOT FOUND THEN RAISE EXCEPTION 'invoicing_duty_missing' USING ERRCODE='22023'; END IF;
|
|
408
|
+
v_payload:=jsonb_build_object('externalId',p_external,'number',p_number,'proof',p_proof);
|
|
409
|
+
SELECT payload INTO v_prior FROM public.invoicing_effect_receipts WHERE receipt_key='issue:'||p_duty::text;
|
|
410
|
+
IF FOUND THEN
|
|
411
|
+
IF v_prior->>'externalId' IS DISTINCT FROM p_external OR v_prior->>'number' IS DISTINCT FROM p_number THEN RAISE EXCEPTION 'invoicing_issue_identity_conflict' USING ERRCODE='23505'; END IF;
|
|
412
|
+
RETURN jsonb_build_object('kind','issued','replayed',true);
|
|
413
|
+
END IF;
|
|
414
|
+
IF v_duty.parent_duty_id IS NOT NULL OR v_duty.status<>'processing' OR v_duty.claim_token IS DISTINCT FROM p_token
|
|
415
|
+
OR v_duty.generation IS DISTINCT FROM p_generation OR v_duty.lease_until<=now()
|
|
416
|
+
THEN RETURN jsonb_build_object('kind','stale'); END IF;
|
|
417
|
+
IF nullif(btrim(p_external),'') IS NULL OR p_proof->>'kind' IS NULL OR p_proof->>'kind' NOT IN ('issued','readback_found')
|
|
418
|
+
OR p_proof->>'externalId' IS DISTINCT FROM p_external
|
|
419
|
+
OR p_proof->>'providerCreateIdentity' IS DISTINCT FROM 'invoicing:'||p_duty::text
|
|
420
|
+
OR (p_proof->>'kind'='issued' AND (v_duty.preflight_generation IS DISTINCT FROM p_generation OR v_duty.last_result->>'action'<>'create'))
|
|
421
|
+
THEN RAISE EXCEPTION 'invoicing_issue_proof_invalid' USING ERRCODE='22023'; END IF;
|
|
422
|
+
PERFORM public.accounting_document_advance('invoicing:issue:'||p_duty::text,v_duty.document_id,'issued',p_external,jsonb_build_object('number',p_number));
|
|
423
|
+
UPDATE public.accounting_document_requests SET status='succeeded',claim_token=NULL,error=NULL,updated_at=now() WHERE id=v_duty.request_id;
|
|
424
|
+
UPDATE public.invoicing_duties SET status='issued',claim_token=NULL,lease_until=NULL,last_result=v_payload WHERE id=p_duty;
|
|
425
|
+
INSERT INTO public.invoicing_effect_receipts(receipt_key,duty_id,payload) VALUES('issue:'||p_duty::text,p_duty,v_payload);
|
|
426
|
+
-- Reversal requested while remote outcome was unknown: record identity FIRST,
|
|
427
|
+
-- then compensate atomically. A local error rolls the entire finalization back.
|
|
428
|
+
IF v_duty.reversal IS NOT NULL THEN
|
|
429
|
+
PERFORM public.invoicing_request_reversal(v_duty.reversal->>'key',v_duty.order_id,v_duty.reversal->>'reason',v_duty.reversal->'evidence');
|
|
430
|
+
END IF;
|
|
431
|
+
RETURN jsonb_build_object('kind','issued','replayed',false);
|
|
432
|
+
END;
|
|
433
|
+
$$;
|
|
434
|
+
|
|
435
|
+
CREATE FUNCTION public.invoicing_fail_duty(p_duty uuid,p_token uuid,p_generation integer,p_kind text,p_evidence jsonb,p_delay integer)
|
|
436
|
+
RETURNS jsonb LANGUAGE plpgsql SECURITY INVOKER SET search_path = pg_catalog AS $$
|
|
437
|
+
DECLARE v_duty public.invoicing_duties%ROWTYPE;
|
|
438
|
+
BEGIN
|
|
439
|
+
PERFORM 1 FROM public.accounting_document_requests WHERE id=(SELECT request_id FROM public.invoicing_duties WHERE id=p_duty) FOR UPDATE;
|
|
440
|
+
SELECT * INTO v_duty FROM public.invoicing_duties WHERE id=p_duty FOR UPDATE;
|
|
441
|
+
IF NOT FOUND OR v_duty.status<>'processing' OR v_duty.claim_token IS DISTINCT FROM p_token OR v_duty.generation IS DISTINCT FROM p_generation OR v_duty.lease_until<=now()
|
|
442
|
+
THEN RETURN jsonb_build_object('kind','stale'); END IF;
|
|
443
|
+
IF p_kind IS NULL OR p_kind NOT IN ('retryable','blocked','unsupported','unknown','absent') OR jsonb_typeof(p_evidence) IS DISTINCT FROM 'object'
|
|
444
|
+
OR (p_kind='retryable' AND p_evidence->>'phase' IS DISTINCT FROM 'before_send')
|
|
445
|
+
OR (p_kind='absent' AND (p_evidence->>'kind' IS DISTINCT FROM 'authoritative_absence'
|
|
446
|
+
OR p_evidence->>'providerCreateIdentity' IS DISTINCT FROM 'invoicing:'||p_duty::text))
|
|
447
|
+
THEN RAISE EXCEPTION 'invoicing_failure_evidence_invalid' USING ERRCODE='22023'; END IF;
|
|
448
|
+
IF v_duty.reversal IS NOT NULL AND (p_kind='absent'
|
|
449
|
+
OR (p_kind='retryable' AND v_duty.last_result->>'action'='create')) THEN
|
|
450
|
+
PERFORM public.accounting_document_advance('invoicing:void:'||v_duty.id::text,v_duty.document_id,'voided');
|
|
451
|
+
UPDATE public.accounting_document_requests SET status='discarded',claim_token=NULL,updated_at=now(),
|
|
452
|
+
available_at='infinity' WHERE id=v_duty.request_id;
|
|
453
|
+
UPDATE public.invoicing_duties SET status='voided',claim_token=NULL,lease_until=NULL,
|
|
454
|
+
last_result=jsonb_build_object('kind',p_kind,'evidence',p_evidence) WHERE id=p_duty;
|
|
455
|
+
RETURN jsonb_build_object('kind','voided');
|
|
456
|
+
END IF;
|
|
457
|
+
UPDATE public.invoicing_duties SET status=CASE WHEN p_kind='retryable' AND v_duty.last_result->>'action'='readback' THEN 'unknown' WHEN p_kind IN ('retryable','absent') THEN 'ready' WHEN p_kind='unknown' THEN 'unknown' ELSE p_kind END,
|
|
458
|
+
last_result=jsonb_build_object('kind',p_kind,'evidence',p_evidence),claim_token=NULL,lease_until=NULL,
|
|
459
|
+
available_at=now()+make_interval(secs=>least(greatest(coalesce(p_delay,60),0),86400)) WHERE id=p_duty;
|
|
460
|
+
UPDATE public.accounting_document_requests SET status='failed',claim_token=NULL,updated_at=now(),
|
|
461
|
+
available_at='infinity' WHERE id=v_duty.request_id;
|
|
462
|
+
RETURN jsonb_build_object('kind',p_kind);
|
|
463
|
+
END;
|
|
464
|
+
$$;
|
|
465
|
+
|
|
466
|
+
REVOKE ALL ON TABLE public.invoicing_duties FROM PUBLIC, anon, authenticated;
|
|
467
|
+
|
|
468
|
+
REVOKE ALL ON TABLE public.invoicing_fact_revisions FROM PUBLIC, anon, authenticated;
|
|
469
|
+
|
|
470
|
+
REVOKE ALL ON TABLE public.invoicing_effect_receipts FROM PUBLIC, anon, authenticated;
|
|
471
|
+
|
|
472
|
+
REVOKE ALL ON public.invoicing_due_sources FROM PUBLIC, anon, authenticated;
|
|
473
|
+
|
|
474
|
+
REVOKE ALL ON FUNCTION public.invoicing_capture_duty(text,text,uuid,uuid,jsonb,timestamptz) FROM PUBLIC, anon, authenticated;
|
|
475
|
+
|
|
476
|
+
REVOKE ALL ON FUNCTION public.invoicing_install_facts(uuid,jsonb,text) FROM PUBLIC, anon, authenticated;
|
|
477
|
+
|
|
478
|
+
REVOKE ALL ON FUNCTION public.invoicing_sweep_duties(integer) FROM PUBLIC, anon, authenticated;
|
|
479
|
+
|
|
480
|
+
REVOKE ALL ON FUNCTION public.invoicing_claim_duties(integer,integer,text,uuid) FROM PUBLIC, anon, authenticated;
|
|
481
|
+
|
|
482
|
+
REVOKE ALL ON FUNCTION public.invoicing_preflight_duty(uuid,uuid,integer,jsonb) FROM PUBLIC, anon, authenticated;
|
|
483
|
+
|
|
484
|
+
REVOKE ALL ON FUNCTION public.invoicing_finish_issue(uuid,uuid,integer,text,text,jsonb) FROM PUBLIC, anon, authenticated;
|
|
485
|
+
|
|
486
|
+
REVOKE ALL ON FUNCTION public.invoicing_fail_duty(uuid,uuid,integer,text,jsonb,integer) FROM PUBLIC, anon, authenticated;
|
|
487
|
+
|
|
488
|
+
REVOKE ALL ON FUNCTION public.invoicing_duty_readback(uuid) FROM PUBLIC, anon, authenticated;
|
|
489
|
+
|
|
490
|
+
REVOKE ALL ON TABLE public.invoicing_cutover FROM PUBLIC, anon, authenticated;
|
|
491
|
+
REVOKE ALL ON TABLE public.invoicing_capture_faults FROM PUBLIC, anon, authenticated;
|
|
492
|
+
|
|
493
|
+
REVOKE ALL ON FUNCTION public.invoicing_prepare_facts(uuid,uuid) FROM PUBLIC, anon, authenticated;
|
|
494
|
+
REVOKE ALL ON FUNCTION public.invoicing_finish_facts(uuid,uuid,jsonb) FROM PUBLIC, anon, authenticated;
|