@openlup/platform 0.22.0 → 0.23.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 +222 -99
- package/README.md +59 -589
- package/dist/fulfillment/adapterConformance.js +1 -1
- package/dist/fulfillment/bindings.js +1 -1
- package/dist/fulfillment/contracts/conformance.d.ts +3 -1
- package/dist/fulfillment/contracts/dispatch.d.ts +41 -3
- package/dist/fulfillment/contracts/index.d.ts +3 -3
- package/dist/fulfillment/contracts/observation.d.ts +21 -1
- package/dist/fulfillment/contracts/readback.d.ts +21 -0
- package/dist/fulfillment/contracts/store.d.ts +3 -0
- package/dist/fulfillment/contracts/sync.d.ts +58 -2
- package/dist/fulfillment/dispatchProjection.d.ts +20 -1
- package/dist/fulfillment/dispatchProjection.js +95 -6
- package/dist/fulfillment/index.d.ts +3 -2
- package/dist/fulfillment/index.js +2 -2
- package/dist/fulfillment/migrations.d.ts +5 -0
- package/dist/fulfillment/migrations.js +1 -0
- package/dist/fulfillment/observationProjection.d.ts +24 -3
- package/dist/fulfillment/observationProjection.js +65 -12
- package/dist/fulfillment/omnipack/client.d.ts +3 -1
- package/dist/fulfillment/omnipack/client.js +17 -7
- package/dist/fulfillment/omnipack/index.d.ts +4 -1
- package/dist/fulfillment/omnipack/index.js +104 -45
- package/dist/fulfillment/omnipack/mappers.d.ts +7 -1
- package/dist/fulfillment/omnipack/mappers.js +24 -2
- package/dist/fulfillment/omnipack/observation.d.ts +11 -4
- package/dist/fulfillment/omnipack/observation.js +44 -29
- package/dist/fulfillment/omnipack/reconciliation.d.ts +15 -0
- package/dist/fulfillment/omnipack/reconciliation.js +55 -0
- package/dist/fulfillment/postgresDispatchStore.js +1 -1
- package/dist/fulfillment/postgresShipmentSpine.d.ts +8 -1
- package/dist/fulfillment/postgresShipmentSpine.js +17 -3
- package/dist/fulfillment/postgresStockSyncPort.js +6 -3
- package/dist/fulfillment/providerDispatchAcceptance.d.ts +1 -0
- package/dist/fulfillment/providerDispatchAcceptance.js +8 -2
- package/dist/fulfillment/providerDispatchWorker.d.ts +13 -0
- package/dist/fulfillment/providerDispatchWorker.js +47 -20
- package/dist/fulfillment/providerReconciliationWorker.d.ts +27 -19
- package/dist/fulfillment/providerReconciliationWorker.js +75 -19
- package/dist/fulfillment/providerStockSyncWorker.d.ts +5 -1
- package/dist/fulfillment/providerStockSyncWorker.js +229 -120
- package/dist/fulfillment/providerTrackingReferences.d.ts +3 -2
- package/dist/fulfillment/verifiedIngress.d.ts +38 -11
- package/dist/fulfillment/verifiedIngress.js +36 -8
- package/dist/invoicing/capabilities.d.ts +11 -0
- package/dist/invoicing/capabilities.js +10 -0
- package/dist/invoicing/contracts/capabilities.d.ts +40 -0
- package/dist/invoicing/contracts/capabilities.js +1 -0
- package/dist/invoicing/contracts/companyLookup.d.ts +49 -0
- package/dist/invoicing/contracts/companyLookup.js +1 -0
- package/dist/invoicing/contracts/delivery.d.ts +74 -0
- package/dist/invoicing/contracts/delivery.js +1 -0
- package/dist/invoicing/contracts/facts.d.ts +100 -0
- package/dist/invoicing/contracts/facts.js +1 -0
- package/dist/invoicing/contracts/host.d.ts +246 -0
- package/dist/invoicing/contracts/host.js +1 -0
- package/dist/invoicing/contracts/index.d.ts +16 -0
- package/dist/invoicing/contracts/index.js +1 -0
- package/dist/invoicing/contracts/provider.d.ts +126 -0
- package/dist/invoicing/contracts/provider.js +1 -0
- package/dist/invoicing/contracts/settlement.d.ts +66 -0
- package/dist/invoicing/contracts/settlement.js +1 -0
- package/dist/invoicing/contracts/store.d.ts +267 -0
- package/dist/invoicing/contracts/store.js +1 -0
- package/dist/invoicing/index.d.ts +1 -0
- package/dist/invoicing/index.js +1 -0
- package/dist/subscriptions/automaticRenewal.d.ts +44 -0
- package/dist/subscriptions/automaticRenewal.js +81 -0
- package/dist/subscriptions/automaticRenewalExecution.d.ts +21 -0
- package/dist/subscriptions/automaticRenewalExecution.js +44 -0
- package/dist/subscriptions/contracts/commands.d.ts +115 -0
- package/dist/subscriptions/contracts/commands.js +1 -0
- package/dist/subscriptions/contracts/dunning.d.ts +133 -0
- package/dist/subscriptions/contracts/dunning.js +1 -0
- package/dist/subscriptions/contracts/index.d.ts +6 -0
- package/dist/subscriptions/contracts/index.js +1 -0
- package/dist/subscriptions/contracts/messages.d.ts +43 -0
- package/dist/subscriptions/contracts/messages.js +1 -0
- package/dist/subscriptions/contracts/renewal.d.ts +533 -0
- package/dist/subscriptions/contracts/renewal.js +1 -0
- package/dist/subscriptions/contracts/stockWait.d.ts +73 -0
- package/dist/subscriptions/contracts/stockWait.js +1 -0
- package/dist/subscriptions/index.d.ts +39 -0
- package/dist/subscriptions/index.js +17 -0
- package/dist/subscriptions/paymentOutcomes.d.ts +8 -0
- package/dist/subscriptions/paymentOutcomes.js +27 -0
- package/dist/subscriptions/postgresRenewalStore.d.ts +20 -0
- package/dist/subscriptions/postgresRenewalStore.js +61 -0
- package/dist/subscriptions/postgresRetryStore.d.ts +78 -0
- package/dist/subscriptions/postgresRetryStore.js +142 -0
- package/dist/subscriptions/renewalCycleFailureMechanism.d.ts +34 -0
- package/dist/subscriptions/renewalCycleFailureMechanism.js +79 -0
- package/dist/subscriptions/renewalCycleMechanism.d.ts +46 -0
- package/dist/subscriptions/renewalCycleMechanism.js +157 -0
- package/dist/subscriptions/renewalCycleRefusalMechanism.d.ts +41 -0
- package/dist/subscriptions/renewalCycleRefusalMechanism.js +160 -0
- package/dist/subscriptions/renewalIdentity.d.ts +3 -0
- package/dist/subscriptions/renewalIdentity.js +4 -0
- package/package.json +22 -2
- package/release-gates.json +56 -3
- package/sql/20261022160000_subscription_retry_lifecycle.sql +328 -0
- package/sql/20261022165000_fulfillment_stock_evidence_replay_reopen.sql +30 -0
- package/src/fulfillment/adapterConformance.ts +1 -1
- package/src/fulfillment/bindings.ts +1 -1
- package/src/fulfillment/contracts/conformance.ts +3 -1
- package/src/fulfillment/contracts/dispatch.ts +20 -5
- package/src/fulfillment/contracts/index.ts +3 -3
- package/src/fulfillment/contracts/observation.ts +11 -3
- package/src/fulfillment/contracts/readback.ts +13 -0
- package/src/fulfillment/contracts/store.ts +1 -0
- package/src/fulfillment/contracts/sync.ts +13 -2
- package/src/fulfillment/dispatchProjection.ts +92 -7
- package/src/fulfillment/index.ts +3 -2
- package/src/fulfillment/migrations.ts +1 -0
- package/src/fulfillment/observationProjection.ts +64 -13
- package/src/fulfillment/omnipack/client.ts +19 -7
- package/src/fulfillment/omnipack/index.ts +96 -39
- package/src/fulfillment/omnipack/mappers.ts +23 -2
- package/src/fulfillment/omnipack/observation.ts +45 -27
- package/src/fulfillment/omnipack/reconciliation.ts +56 -0
- package/src/fulfillment/postgresDispatchStore.ts +1 -1
- package/src/fulfillment/postgresShipmentSpine.ts +21 -4
- package/src/fulfillment/postgresStockSyncPort.ts +6 -3
- package/src/fulfillment/providerDispatchAcceptance.ts +9 -3
- package/src/fulfillment/providerDispatchWorker.ts +48 -20
- package/src/fulfillment/providerReconciliationWorker.ts +81 -25
- package/src/fulfillment/providerStockSyncWorker.ts +222 -126
- package/src/fulfillment/providerTrackingReferences.ts +3 -2
- package/src/fulfillment/verifiedIngress.ts +44 -11
- package/src/invoicing/capabilities.ts +9 -0
- package/src/invoicing/contracts/capabilities.ts +15 -0
- package/src/invoicing/contracts/companyLookup.ts +13 -0
- package/src/invoicing/contracts/delivery.ts +24 -0
- package/src/invoicing/contracts/facts.ts +60 -0
- package/src/invoicing/contracts/host.ts +85 -0
- package/src/invoicing/contracts/index.ts +16 -0
- package/src/invoicing/contracts/provider.ts +78 -0
- package/src/invoicing/contracts/settlement.ts +39 -0
- package/src/invoicing/contracts/store.ts +98 -0
- package/src/invoicing/index.ts +1 -0
- package/src/subscriptions/automaticRenewal.ts +87 -0
- package/src/subscriptions/automaticRenewalExecution.ts +39 -0
- package/src/subscriptions/contracts/commands.ts +46 -0
- package/src/subscriptions/contracts/dunning.ts +48 -0
- package/src/subscriptions/contracts/index.ts +6 -0
- package/src/subscriptions/contracts/messages.ts +18 -0
- package/src/subscriptions/contracts/renewal.ts +325 -0
- package/src/subscriptions/contracts/stockWait.ts +34 -0
- package/src/subscriptions/index.ts +24 -0
- package/src/subscriptions/paymentOutcomes.ts +23 -0
- package/src/subscriptions/postgresRenewalStore.ts +76 -0
- package/src/subscriptions/postgresRetryStore.ts +174 -0
- package/src/subscriptions/renewalCycleFailureMechanism.ts +119 -0
- package/src/subscriptions/renewalCycleMechanism.ts +225 -0
- package/src/subscriptions/renewalCycleRefusalMechanism.ts +215 -0
- package/src/subscriptions/renewalIdentity.ts +5 -0
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
-- Native same-obligation retry bookkeeping. The original renewal remains terminal;
|
|
2
|
+
-- payment_pending, order, settlement, snapshot and identity never get recreated.
|
|
3
|
+
-- A prepared receipt is an irreversible dispatch fence: loss of ACK requires
|
|
4
|
+
-- readback, never another prepare. Hosts supply verified payment observations;
|
|
5
|
+
-- these invoker functions perform no payment I/O and grant no runtime authority.
|
|
6
|
+
-- Failure and case propagation retain their separate donor transaction units.
|
|
7
|
+
-- Disable the retry driver before rollback; retain these ledgers for reconciliation.
|
|
8
|
+
CREATE TABLE public.subscription_retry_obligations (
|
|
9
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
10
|
+
renewal_operation_id uuid NOT NULL UNIQUE REFERENCES public.subscription_renewal_operations(id),
|
|
11
|
+
subscription_id uuid NOT NULL REFERENCES public.subscriptions(id),
|
|
12
|
+
cycle_id uuid NOT NULL UNIQUE REFERENCES public.subscription_cycles(id),
|
|
13
|
+
order_id uuid NOT NULL UNIQUE REFERENCES public.commerce_orders(id),
|
|
14
|
+
settlement_intent_id uuid NOT NULL UNIQUE REFERENCES public.commerce_settlement_intents(id),
|
|
15
|
+
logical_cycle_number integer NOT NULL CHECK (logical_cycle_number > 0),
|
|
16
|
+
identity_key text NOT NULL UNIQUE,
|
|
17
|
+
operation_fingerprint text NOT NULL CHECK (operation_fingerprint ~ '^[0-9a-f]{64}$'),
|
|
18
|
+
scheduled_at timestamptz NOT NULL,
|
|
19
|
+
cycle_snapshot jsonb NOT NULL,
|
|
20
|
+
retry_attempt integer NOT NULL DEFAULT 0 CHECK (retry_attempt >= 0),
|
|
21
|
+
attempt_sequence integer NOT NULL DEFAULT 0 CHECK (attempt_sequence >= 0),
|
|
22
|
+
state text NOT NULL DEFAULT 'waiting' CHECK (state IN ('waiting','prepared','recovered')),
|
|
23
|
+
next_retry_at timestamptz,
|
|
24
|
+
failure_class text,
|
|
25
|
+
failure_reason text,
|
|
26
|
+
failed_at timestamptz,
|
|
27
|
+
case_id uuid REFERENCES public.subscription_dunning_cases(id),
|
|
28
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
29
|
+
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
30
|
+
UNIQUE (subscription_id, scheduled_at)
|
|
31
|
+
);
|
|
32
|
+
-- squawk-ignore require-concurrent-index-creation
|
|
33
|
+
CREATE INDEX subscription_retry_due_idx ON public.subscription_retry_obligations(state,next_retry_at,id);
|
|
34
|
+
CREATE TABLE public.subscription_retry_receipts (
|
|
35
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
36
|
+
obligation_id uuid NOT NULL REFERENCES public.subscription_retry_obligations(id),
|
|
37
|
+
receipt_key text NOT NULL UNIQUE CHECK (btrim(receipt_key) <> ''),
|
|
38
|
+
kind text NOT NULL CHECK (kind IN ('failure','prepare','recovery','schedule')),
|
|
39
|
+
attempt_sequence integer NOT NULL CHECK (attempt_sequence >= 0),
|
|
40
|
+
input jsonb NOT NULL,
|
|
41
|
+
result jsonb NOT NULL,
|
|
42
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
43
|
+
UNIQUE (obligation_id, kind, attempt_sequence)
|
|
44
|
+
);
|
|
45
|
+
CREATE TABLE public.subscription_retry_recovery_authorizations (
|
|
46
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
47
|
+
obligation_id uuid NOT NULL REFERENCES public.subscription_retry_obligations(id),
|
|
48
|
+
token_id uuid NOT NULL REFERENCES public.commerce_checkout_recovery_tokens(id),
|
|
49
|
+
retry_attempt integer NOT NULL CHECK (retry_attempt > 0),
|
|
50
|
+
method_ref text NOT NULL CHECK (btrim(method_ref) <> ''),
|
|
51
|
+
method_evidence_ref text NOT NULL CHECK (btrim(method_evidence_ref) <> ''),
|
|
52
|
+
authorized_at timestamptz NOT NULL,
|
|
53
|
+
consumed_at timestamptz,
|
|
54
|
+
UNIQUE (token_id, retry_attempt),
|
|
55
|
+
UNIQUE (obligation_id, retry_attempt)
|
|
56
|
+
);
|
|
57
|
+
REVOKE ALL ON TABLE public.subscription_retry_obligations, public.subscription_retry_receipts, public.subscription_retry_recovery_authorizations FROM PUBLIC, anon, authenticated;
|
|
58
|
+
|
|
59
|
+
CREATE FUNCTION public.subscription_retry_read(p_operation_id uuid)
|
|
60
|
+
RETURNS jsonb LANGUAGE sql STABLE SET search_path = pg_catalog AS $$
|
|
61
|
+
SELECT to_jsonb(o) FROM public.subscription_retry_obligations o WHERE renewal_operation_id=p_operation_id;
|
|
62
|
+
$$;
|
|
63
|
+
CREATE FUNCTION public.subscription_retry_candidates(p_limit integer DEFAULT 50,p_now timestamptz DEFAULT now())
|
|
64
|
+
RETURNS jsonb LANGUAGE sql STABLE SET search_path = pg_catalog AS $$
|
|
65
|
+
SELECT COALESCE(jsonb_agg(to_jsonb(c)), '[]'::jsonb) FROM (
|
|
66
|
+
SELECT o.* FROM public.subscription_retry_obligations o JOIN public.subscriptions s ON s.id=o.subscription_id
|
|
67
|
+
WHERE s.status='active' AND o.state='waiting' AND o.next_retry_at<=p_now
|
|
68
|
+
ORDER BY o.next_retry_at,o.id LIMIT LEAST(GREATEST(p_limit,1),500)
|
|
69
|
+
) c;
|
|
70
|
+
$$;
|
|
71
|
+
|
|
72
|
+
-- input binds the original logical cycle explicitly; the native renewal counter
|
|
73
|
+
-- is not evidence of activation-cycle numbering. Retry zero is the original.
|
|
74
|
+
CREATE FUNCTION public.subscription_retry_apply_failure(p_operation_id uuid,p_key text,p_input jsonb)
|
|
75
|
+
RETURNS jsonb LANGUAGE plpgsql SET search_path = pg_catalog AS $$
|
|
76
|
+
DECLARE op public.subscription_renewal_operations; o public.subscription_retry_obligations;
|
|
77
|
+
r public.subscription_retry_receipts; c public.subscription_cycles; i public.commerce_settlement_intents;
|
|
78
|
+
at_time timestamptz := (p_input->>'occurredAt')::timestamptz; seq integer := (p_input->>'attemptSequence')::integer;
|
|
79
|
+
next_time timestamptz; result jsonb;
|
|
80
|
+
BEGIN
|
|
81
|
+
PERFORM 1 FROM public.subscriptions WHERE id=(SELECT subscription_id FROM public.subscription_renewal_operations WHERE id=p_operation_id) FOR UPDATE;
|
|
82
|
+
SELECT * INTO op FROM public.subscription_renewal_operations WHERE id=p_operation_id FOR UPDATE;
|
|
83
|
+
IF op.id IS NULL OR at_time IS NULL OR seq IS NULL OR btrim(COALESCE(p_key,''))='' OR
|
|
84
|
+
btrim(COALESCE(p_input->>'evidenceKey',''))='' OR (p_input->>'firstRecordedAt')::timestamptz IS NULL OR
|
|
85
|
+
(p_input->>'logicalCycleNumber')::integer IS NULL OR (p_input->>'logicalCycleNumber')::integer<1 THEN
|
|
86
|
+
RAISE EXCEPTION 'subscription_retry_invalid_input' USING ERRCODE='22023'; END IF;
|
|
87
|
+
SELECT * INTO o FROM public.subscription_retry_obligations WHERE renewal_operation_id=op.id FOR UPDATE;
|
|
88
|
+
SELECT * INTO r FROM public.subscription_retry_receipts WHERE receipt_key=p_key;
|
|
89
|
+
IF r.id IS NOT NULL THEN
|
|
90
|
+
IF r.obligation_id IS DISTINCT FROM o.id OR r.kind<>'failure' OR r.input<>p_input THEN RAISE EXCEPTION 'subscription_retry_replay_conflict' USING ERRCODE='23505'; END IF;
|
|
91
|
+
RETURN r.result;
|
|
92
|
+
END IF;
|
|
93
|
+
SELECT * INTO c FROM public.subscription_cycles WHERE renewal_operation_id=op.id FOR UPDATE;
|
|
94
|
+
SELECT * INTO i FROM public.commerce_settlement_intents WHERE renewal_operation_id=op.id FOR UPDATE;
|
|
95
|
+
IF c.id IS NULL OR i.id IS NULL OR c.status<>'payment_pending' OR i.status='settled' THEN
|
|
96
|
+
RAISE EXCEPTION 'subscription_retry_not_unpaid' USING ERRCODE='22023'; END IF;
|
|
97
|
+
INSERT INTO public.subscription_retry_obligations(renewal_operation_id,subscription_id,cycle_id,order_id,settlement_intent_id,
|
|
98
|
+
logical_cycle_number,identity_key,operation_fingerprint,scheduled_at,cycle_snapshot)
|
|
99
|
+
VALUES(op.id,op.subscription_id,c.id,i.order_id,i.id,(p_input->>'logicalCycleNumber')::integer,
|
|
100
|
+
op.identity_key,op.operation_fingerprint,op.scheduled_at,op.cycle_snapshot)
|
|
101
|
+
ON CONFLICT (renewal_operation_id) DO NOTHING;
|
|
102
|
+
SELECT * INTO o FROM public.subscription_retry_obligations WHERE renewal_operation_id=op.id FOR UPDATE;
|
|
103
|
+
SELECT * INTO r FROM public.subscription_retry_receipts WHERE receipt_key=p_key;
|
|
104
|
+
IF r.id IS NOT NULL THEN
|
|
105
|
+
IF r.obligation_id<>o.id OR r.kind<>'failure' OR r.input<>p_input THEN RAISE EXCEPTION 'subscription_retry_replay_conflict' USING ERRCODE='23505'; END IF;
|
|
106
|
+
RETURN r.result;
|
|
107
|
+
END IF;
|
|
108
|
+
IF o.attempt_sequence<>seq OR o.logical_cycle_number<>(p_input->>'logicalCycleNumber')::integer OR o.state='recovered' OR
|
|
109
|
+
EXISTS(SELECT 1 FROM public.subscription_retry_receipts WHERE obligation_id=o.id AND kind='failure' AND
|
|
110
|
+
(attempt_sequence=seq OR input->>'evidenceKey'=p_input->>'evidenceKey')) THEN
|
|
111
|
+
RAISE EXCEPTION 'subscription_retry_attempt_conflict' USING ERRCODE='23505'; END IF;
|
|
112
|
+
IF seq=0 THEN
|
|
113
|
+
IF op.state='attempt_prepared' THEN
|
|
114
|
+
PERFORM public.commerce_record_subscription_renewal_outcome(op.id,p_key,op.operation_fingerprint,'refused',
|
|
115
|
+
op.external_attempt_ref,p_input->>'reason',at_time);
|
|
116
|
+
ELSIF op.state<>'refused' OR NOT EXISTS(SELECT 1 FROM public.subscription_renewal_outcomes
|
|
117
|
+
WHERE renewal_operation_id=op.id AND outcome='refused') OR op.attempt_prepared_at IS NULL THEN
|
|
118
|
+
RAISE EXCEPTION 'subscription_retry_definite_decline_required' USING ERRCODE='22023';
|
|
119
|
+
ELSE
|
|
120
|
+
at_time:=op.terminal_at;
|
|
121
|
+
END IF;
|
|
122
|
+
ELSE
|
|
123
|
+
IF o.state<>'prepared' OR btrim(COALESCE(p_input->>'externalRef',''))='' OR
|
|
124
|
+
btrim(COALESCE(p_input->>'evidenceKey',''))='' OR (p_input->>'firstRecordedAt')::timestamptz IS NULL THEN RAISE EXCEPTION 'subscription_retry_not_prepared' USING ERRCODE='22023'; END IF;
|
|
125
|
+
-- As on the M07 truth rail, attempt evidence does not replace the
|
|
126
|
+
-- aggregate's write-once external handle.
|
|
127
|
+
PERFORM public.commerce_record_settlement(p_key||':settlement',i.id,'failed',NULL,p_input,at_time);
|
|
128
|
+
UPDATE public.fulfillment_inventory_reservations SET status='released',terminal_reason='settlement_refused',updated_at=at_time
|
|
129
|
+
WHERE renewal_operation_id=op.id AND status='committed';
|
|
130
|
+
END IF;
|
|
131
|
+
next_time := CASE WHEN p_input->>'failureClass'='hard_do_not_retry' THEN NULL
|
|
132
|
+
WHEN o.retry_attempt=0 THEN at_time+interval '24 hours'
|
|
133
|
+
WHEN o.retry_attempt=1 THEN at_time+interval '72 hours'
|
|
134
|
+
WHEN o.retry_attempt=2 THEN at_time+interval '168 hours' ELSE NULL END;
|
|
135
|
+
UPDATE public.subscription_retry_obligations SET retry_attempt=retry_attempt+1,state='waiting',next_retry_at=next_time,
|
|
136
|
+
failure_class=p_input->>'failureClass',failure_reason=p_input->>'reason',failed_at=at_time,updated_at=at_time WHERE id=o.id RETURNING * INTO o;
|
|
137
|
+
result:=to_jsonb(o);
|
|
138
|
+
INSERT INTO public.subscription_retry_receipts(obligation_id,receipt_key,kind,attempt_sequence,input,result)
|
|
139
|
+
VALUES(o.id,p_key,'failure',seq,p_input,result);
|
|
140
|
+
RETURN result;
|
|
141
|
+
END;
|
|
142
|
+
$$;
|
|
143
|
+
|
|
144
|
+
CREATE FUNCTION public.subscription_retry_open_case(p_operation_id uuid,p_template text,p_recovery_path text)
|
|
145
|
+
RETURNS jsonb LANGUAGE plpgsql SET search_path = pg_catalog AS $$
|
|
146
|
+
DECLARE o public.subscription_retry_obligations; result jsonb; client uuid; i public.commerce_settlement_intents;
|
|
147
|
+
BEGIN
|
|
148
|
+
PERFORM 1 FROM public.subscriptions WHERE id=(SELECT subscription_id FROM public.subscription_renewal_operations WHERE id=p_operation_id) FOR UPDATE;
|
|
149
|
+
SELECT * INTO o FROM public.subscription_retry_obligations WHERE renewal_operation_id=p_operation_id FOR UPDATE;
|
|
150
|
+
IF o.id IS NULL OR o.retry_attempt=0 OR o.state='recovered' THEN RAISE EXCEPTION 'subscription_retry_failure_required'; END IF;
|
|
151
|
+
SELECT client_id INTO client FROM public.subscriptions WHERE id=o.subscription_id;
|
|
152
|
+
SELECT * INTO i FROM public.commerce_settlement_intents WHERE id=o.settlement_intent_id;
|
|
153
|
+
IF o.case_id IS NULL THEN
|
|
154
|
+
result:=public.dunning_lifecycle_open_case(o.identity_key||':retry-case',o.subscription_id,o.cycle_id,client,
|
|
155
|
+
o.retry_attempt,o.next_retry_at,o.failure_class,o.failure_reason,o.failed_at,i.amount_minor,i.currency_code,p_template,p_recovery_path);
|
|
156
|
+
UPDATE public.subscription_retry_obligations SET case_id=(result->>'caseId')::uuid WHERE id=o.id RETURNING * INTO o;
|
|
157
|
+
END IF;
|
|
158
|
+
RETURN public.dunning_lifecycle_handle_failure(o.case_id,o.failed_at,o.failure_class,o.failure_reason,
|
|
159
|
+
o.retry_attempt,o.next_retry_at,p_template,p_recovery_path);
|
|
160
|
+
END;
|
|
161
|
+
$$;
|
|
162
|
+
|
|
163
|
+
-- Token consumption and durable usable-method evidence precede scheduling. A
|
|
164
|
+
-- method webhook alone cannot reopen a class stop or an exhausted ladder.
|
|
165
|
+
CREATE FUNCTION public.subscription_retry_authorize_recovery(p_operation_id uuid,p_token_id uuid,p_method text,p_evidence text,p_now timestamptz DEFAULT now())
|
|
166
|
+
RETURNS jsonb LANGUAGE plpgsql SET search_path = pg_catalog AS $$
|
|
167
|
+
DECLARE o public.subscription_retry_obligations; t public.commerce_checkout_recovery_tokens;
|
|
168
|
+
a public.subscription_retry_recovery_authorizations;
|
|
169
|
+
BEGIN
|
|
170
|
+
PERFORM 1 FROM public.subscriptions WHERE id=(SELECT subscription_id FROM public.subscription_renewal_operations WHERE id=p_operation_id) FOR UPDATE;
|
|
171
|
+
SELECT * INTO o FROM public.subscription_retry_obligations WHERE renewal_operation_id=p_operation_id FOR UPDATE;
|
|
172
|
+
SELECT * INTO a FROM public.subscription_retry_recovery_authorizations WHERE obligation_id=o.id AND retry_attempt=o.retry_attempt;
|
|
173
|
+
IF a.id IS NOT NULL THEN
|
|
174
|
+
IF a.token_id<>p_token_id OR a.method_ref IS DISTINCT FROM p_method OR a.method_evidence_ref IS DISTINCT FROM p_evidence THEN
|
|
175
|
+
RAISE EXCEPTION 'subscription_retry_authorization_conflict' USING ERRCODE='23505'; END IF;
|
|
176
|
+
RETURN to_jsonb(a);
|
|
177
|
+
END IF;
|
|
178
|
+
SELECT * INTO t FROM public.commerce_checkout_recovery_tokens WHERE id=p_token_id FOR UPDATE;
|
|
179
|
+
IF o.id IS NULL OR o.case_id IS NULL OR o.state<>'waiting' OR t.id IS NULL OR t.order_id<>o.order_id OR
|
|
180
|
+
t.client_id IS DISTINCT FROM (SELECT client_id FROM public.subscriptions WHERE id=o.subscription_id) OR
|
|
181
|
+
t.revoked_at IS NOT NULL OR t.expires_at<=p_now OR
|
|
182
|
+
NOT EXISTS(SELECT 1 FROM public.subscription_method_states WHERE subscription_id=o.subscription_id AND health_state='healthy') OR btrim(COALESCE(p_method,''))='' OR btrim(COALESCE(p_evidence,''))='' OR
|
|
183
|
+
NOT EXISTS(SELECT 1 FROM public.subscription_dunning_cases WHERE id=o.case_id AND status='open') THEN
|
|
184
|
+
RAISE EXCEPTION 'subscription_retry_recovery_not_authorized' USING ERRCODE='22023'; END IF;
|
|
185
|
+
INSERT INTO public.subscription_retry_recovery_authorizations(obligation_id,token_id,retry_attempt,method_ref,method_evidence_ref,authorized_at)
|
|
186
|
+
VALUES(o.id,t.id,o.retry_attempt,p_method,p_evidence,p_now) RETURNING * INTO a;
|
|
187
|
+
UPDATE public.commerce_checkout_recovery_tokens SET revoked_at=p_now WHERE id=t.id;
|
|
188
|
+
RETURN to_jsonb(a);
|
|
189
|
+
END;
|
|
190
|
+
$$;
|
|
191
|
+
CREATE FUNCTION public.subscription_retry_schedule_recovery(p_operation_id uuid,p_key text,p_authorization_id uuid,p_now timestamptz DEFAULT now())
|
|
192
|
+
RETURNS jsonb LANGUAGE plpgsql SET search_path = pg_catalog AS $$
|
|
193
|
+
DECLARE o public.subscription_retry_obligations; a public.subscription_retry_recovery_authorizations; r public.subscription_retry_receipts; input jsonb;
|
|
194
|
+
BEGIN
|
|
195
|
+
PERFORM 1 FROM public.subscriptions WHERE id=(SELECT subscription_id FROM public.subscription_renewal_operations WHERE id=p_operation_id) FOR UPDATE;
|
|
196
|
+
SELECT * INTO o FROM public.subscription_retry_obligations WHERE renewal_operation_id=p_operation_id FOR UPDATE;
|
|
197
|
+
input:=jsonb_build_object('authorizationId',p_authorization_id,'now',p_now);
|
|
198
|
+
SELECT * INTO r FROM public.subscription_retry_receipts WHERE receipt_key=p_key;
|
|
199
|
+
IF r.id IS NOT NULL THEN
|
|
200
|
+
IF r.obligation_id<>o.id OR r.kind<>'schedule' OR r.input<>input THEN RAISE EXCEPTION 'subscription_retry_replay_conflict' USING ERRCODE='23505'; END IF;
|
|
201
|
+
RETURN r.result;
|
|
202
|
+
END IF;
|
|
203
|
+
SELECT * INTO a FROM public.subscription_retry_recovery_authorizations WHERE id=p_authorization_id FOR UPDATE;
|
|
204
|
+
IF o.id IS NULL OR o.state<>'waiting' OR a.obligation_id IS DISTINCT FROM o.id OR a.retry_attempt<>o.retry_attempt OR a.consumed_at IS NOT NULL OR
|
|
205
|
+
NOT EXISTS(SELECT 1 FROM public.subscription_method_states WHERE subscription_id=o.subscription_id AND health_state='healthy') THEN
|
|
206
|
+
RAISE EXCEPTION 'subscription_retry_recovery_not_authorized' USING ERRCODE='22023'; END IF;
|
|
207
|
+
-- Receipt first: a failed schedule write rolls the receipt back too.
|
|
208
|
+
INSERT INTO public.subscription_retry_receipts(obligation_id,receipt_key,kind,attempt_sequence,input,result)
|
|
209
|
+
VALUES(o.id,p_key,'schedule',o.attempt_sequence,input,jsonb_build_object('scheduled',true,'obligationId',o.id));
|
|
210
|
+
UPDATE public.subscription_retry_obligations SET next_retry_at=p_now,updated_at=p_now WHERE id=o.id;
|
|
211
|
+
UPDATE public.subscription_dunning_cases SET next_retry_at=p_now,updated_at=p_now WHERE id=o.case_id AND status='open';
|
|
212
|
+
RETURN jsonb_build_object('scheduled',true,'obligationId',o.id);
|
|
213
|
+
END;
|
|
214
|
+
$$;
|
|
215
|
+
|
|
216
|
+
-- Native M06 stock is locked in SKU order; all lines are checked before any
|
|
217
|
+
-- released reservation is recommitted. Existing unrelated commitments survive.
|
|
218
|
+
CREATE FUNCTION public.subscription_retry_prepare(p_operation_id uuid,p_key text,p_input jsonb,p_now timestamptz DEFAULT now())
|
|
219
|
+
RETURNS jsonb LANGUAGE plpgsql SET search_path = pg_catalog AS $$
|
|
220
|
+
DECLARE o public.subscription_retry_obligations; op public.subscription_renewal_operations; r public.subscription_retry_receipts;
|
|
221
|
+
a public.subscription_retry_recovery_authorizations; reservation public.fulfillment_inventory_reservations;
|
|
222
|
+
line record; available bigint; committed bigint; result jsonb;
|
|
223
|
+
BEGIN
|
|
224
|
+
PERFORM 1 FROM public.subscriptions WHERE id=(SELECT subscription_id FROM public.subscription_renewal_operations WHERE id=p_operation_id) FOR UPDATE;
|
|
225
|
+
SELECT * INTO op FROM public.subscription_renewal_operations WHERE id=p_operation_id FOR UPDATE;
|
|
226
|
+
SELECT * INTO o FROM public.subscription_retry_obligations WHERE renewal_operation_id=op.id FOR UPDATE;
|
|
227
|
+
SELECT * INTO r FROM public.subscription_retry_receipts WHERE receipt_key=p_key;
|
|
228
|
+
IF r.id IS NOT NULL THEN
|
|
229
|
+
IF r.obligation_id<>o.id OR r.kind<>'prepare' OR r.input<>p_input THEN RAISE EXCEPTION 'subscription_retry_replay_conflict' USING ERRCODE='23505'; END IF;
|
|
230
|
+
RETURN r.result||jsonb_build_object('execute',false,'replayed',true);
|
|
231
|
+
END IF;
|
|
232
|
+
IF o.id IS NULL OR o.state<>'waiting' OR o.next_retry_at IS NULL OR o.next_retry_at>p_now OR
|
|
233
|
+
NOT EXISTS(SELECT 1 FROM public.subscriptions WHERE id=o.subscription_id AND status='active') OR
|
|
234
|
+
NOT EXISTS(SELECT 1 FROM public.subscription_cycles WHERE id=o.cycle_id AND status='payment_pending') OR
|
|
235
|
+
NOT EXISTS(SELECT 1 FROM public.commerce_settlement_intents WHERE id=o.settlement_intent_id AND status='failed') OR
|
|
236
|
+
btrim(COALESCE(p_input->>'idempotencyKey',''))='' OR btrim(COALESCE(p_input->>'providerIdempotencyKey',''))='' OR
|
|
237
|
+
COALESCE(p_input->>'providerRequestFingerprint','') !~ '^[0-9a-f]{64}$' OR
|
|
238
|
+
p_input->>'paymentIntentId' IS DISTINCT FROM o.settlement_intent_id::text OR
|
|
239
|
+
p_input->>'orderRef' IS DISTINCT FROM o.order_id::text OR
|
|
240
|
+
(p_input->>'amountMinor')::bigint IS DISTINCT FROM (o.cycle_snapshot->>'totalAmountMinor')::bigint OR
|
|
241
|
+
p_input->>'currency' IS DISTINCT FROM o.cycle_snapshot->>'currency' OR
|
|
242
|
+
EXISTS(SELECT 1 FROM public.subscription_retry_receipts WHERE kind='prepare' AND (input->>'idempotencyKey'=p_input->>'idempotencyKey' OR input->>'providerIdempotencyKey'=p_input->>'providerIdempotencyKey')) OR btrim(COALESCE(p_input->>'paymentMethodRef',''))='' THEN
|
|
243
|
+
RAISE EXCEPTION 'subscription_retry_not_due_or_ambiguous' USING ERRCODE='22023'; END IF;
|
|
244
|
+
SELECT * INTO a FROM public.subscription_retry_recovery_authorizations WHERE obligation_id=o.id AND retry_attempt=o.retry_attempt FOR UPDATE;
|
|
245
|
+
IF (o.retry_attempt>=4 OR o.failure_class='hard_do_not_retry') AND (a.id IS NULL OR a.consumed_at IS NOT NULL) THEN
|
|
246
|
+
RAISE EXCEPTION 'subscription_retry_recovery_not_authorized' USING ERRCODE='22023'; END IF;
|
|
247
|
+
IF a.id IS NOT NULL AND (a.method_ref IS DISTINCT FROM p_input->>'paymentMethodRef' OR
|
|
248
|
+
NOT EXISTS(SELECT 1 FROM public.subscription_method_states WHERE subscription_id=o.subscription_id AND health_state='healthy')) THEN RAISE EXCEPTION 'subscription_retry_method_mismatch'; END IF;
|
|
249
|
+
SELECT * INTO reservation FROM public.fulfillment_inventory_reservations WHERE renewal_operation_id=op.id FOR UPDATE;
|
|
250
|
+
IF reservation.id IS NULL OR reservation.operation_fingerprint<>o.operation_fingerprint THEN RAISE EXCEPTION 'subscription_retry_stock_binding_missing'; END IF;
|
|
251
|
+
PERFORM stock.sku FROM public.fulfillment_stock_current stock JOIN public.fulfillment_inventory_reservation_lines l ON l.sku=stock.sku
|
|
252
|
+
WHERE l.reservation_id=reservation.id AND stock.source_key=reservation.source_key ORDER BY stock.sku FOR UPDATE OF stock;
|
|
253
|
+
FOR line IN SELECT * FROM public.fulfillment_inventory_reservation_lines WHERE reservation_id=reservation.id ORDER BY sku LOOP
|
|
254
|
+
SELECT for_sale_quantity INTO available FROM public.fulfillment_stock_current WHERE source_key=reservation.source_key AND sku=line.sku
|
|
255
|
+
AND inventory_class='sellable' AND stale_after>p_now;
|
|
256
|
+
SELECT COALESCE(sum(l.quantity),0) INTO committed FROM public.fulfillment_inventory_reservation_lines l
|
|
257
|
+
JOIN public.fulfillment_inventory_reservations rr ON rr.id=l.reservation_id WHERE rr.source_key=reservation.source_key AND l.sku=line.sku
|
|
258
|
+
AND rr.id<>reservation.id AND (rr.status='committed' OR (rr.status='held' AND rr.expires_at>p_now));
|
|
259
|
+
IF available IS NULL OR available-committed<line.quantity THEN RAISE EXCEPTION 'subscription_retry_inventory_unavailable' USING ERRCODE='23514'; END IF;
|
|
260
|
+
END LOOP;
|
|
261
|
+
UPDATE public.fulfillment_inventory_reservations SET status='committed',expires_at=NULL,terminal_reason=NULL,updated_at=p_now WHERE id=reservation.id;
|
|
262
|
+
UPDATE public.subscription_retry_obligations SET state='prepared',attempt_sequence=attempt_sequence+1,next_retry_at=NULL,updated_at=p_now WHERE id=o.id RETURNING * INTO o;
|
|
263
|
+
UPDATE public.subscription_retry_recovery_authorizations SET consumed_at=p_now WHERE id=a.id;
|
|
264
|
+
result:=jsonb_build_object('execute',true,'replayed',false,'obligationId',o.id,'attemptSequence',o.attempt_sequence,
|
|
265
|
+
'cycleId',o.cycle_id,'orderId',o.order_id,'settlementIntentId',o.settlement_intent_id,'identityKey',o.identity_key,
|
|
266
|
+
'operationFingerprint',o.operation_fingerprint,'snapshot',o.cycle_snapshot,'request',p_input);
|
|
267
|
+
INSERT INTO public.subscription_retry_receipts(obligation_id,receipt_key,kind,attempt_sequence,input,result)
|
|
268
|
+
VALUES(o.id,p_key,'prepare',o.attempt_sequence,p_input,result);
|
|
269
|
+
RETURN result;
|
|
270
|
+
END;
|
|
271
|
+
$$;
|
|
272
|
+
|
|
273
|
+
-- The M07 external settlement reference remains write-once across attempts.
|
|
274
|
+
-- M07 truth ingestion retains that aggregate handle while recording distinct
|
|
275
|
+
-- attempt evidence. Never clear or substitute the settlement reference.
|
|
276
|
+
-- Verified positive evidence can settle an original decline without resetting its
|
|
277
|
+
-- operation. An outstanding retry must be reconciled under its own prepare key.
|
|
278
|
+
CREATE FUNCTION public.subscription_retry_record_recovery(p_operation_id uuid,p_key text,p_input jsonb)
|
|
279
|
+
RETURNS jsonb LANGUAGE plpgsql SET search_path = pg_catalog AS $$
|
|
280
|
+
DECLARE o public.subscription_retry_obligations; r public.subscription_retry_receipts; prep public.subscription_retry_receipts;
|
|
281
|
+
i public.commerce_settlement_intents; result jsonb; at_time timestamptz:=(p_input->>'occurredAt')::timestamptz;
|
|
282
|
+
BEGIN
|
|
283
|
+
PERFORM 1 FROM public.subscriptions WHERE id=(SELECT subscription_id FROM public.subscription_renewal_operations WHERE id=p_operation_id) FOR UPDATE;
|
|
284
|
+
SELECT * INTO o FROM public.subscription_retry_obligations WHERE renewal_operation_id=p_operation_id FOR UPDATE;
|
|
285
|
+
SELECT * INTO r FROM public.subscription_retry_receipts WHERE receipt_key=p_key;
|
|
286
|
+
IF r.id IS NOT NULL THEN
|
|
287
|
+
IF r.obligation_id<>o.id OR r.kind<>'recovery' OR r.input<>p_input THEN RAISE EXCEPTION 'subscription_retry_replay_conflict' USING ERRCODE='23505'; END IF;
|
|
288
|
+
RETURN r.result;
|
|
289
|
+
END IF;
|
|
290
|
+
SELECT * INTO i FROM public.commerce_settlement_intents WHERE id=o.settlement_intent_id FOR UPDATE;
|
|
291
|
+
SELECT * INTO prep FROM public.subscription_retry_receipts WHERE obligation_id=o.id AND kind='prepare' AND attempt_sequence=o.attempt_sequence;
|
|
292
|
+
IF o.id IS NULL OR o.state='recovered' OR at_time IS NULL OR (p_input->>'firstRecordedAt')::timestamptz IS NULL OR
|
|
293
|
+
btrim(COALESCE(p_input->>'evidenceKey',''))='' OR btrim(COALESCE(p_input->>'externalRef',''))='' OR
|
|
294
|
+
(p_input->>'amountMinor')::bigint IS DISTINCT FROM i.amount_minor OR p_input->>'currency' IS DISTINCT FROM i.currency_code OR
|
|
295
|
+
(o.state='prepared' AND prep.receipt_key IS DISTINCT FROM p_input->>'prepareKey') THEN
|
|
296
|
+
RAISE EXCEPTION 'subscription_retry_positive_evidence_required' USING ERRCODE='22023'; END IF;
|
|
297
|
+
PERFORM public.payment_truth_ingest_event(p_input->>'evidenceKey',
|
|
298
|
+
encode(sha256(convert_to(jsonb_build_object('operationId',p_operation_id,'input',p_input)::text,'UTF8')),'hex'),
|
|
299
|
+
p_key,i.id,'captured',i.amount_minor,i.currency_code,at_time,NULL,NULL,
|
|
300
|
+
p_input||jsonb_build_object('renewalOperationId',p_operation_id,'operationFingerprint',o.operation_fingerprint),
|
|
301
|
+
NULL,NULL,p_input->>'template');
|
|
302
|
+
UPDATE public.subscription_renewal_terms SET next_cycle_number=GREATEST(next_cycle_number,
|
|
303
|
+
(SELECT cycle_number+1 FROM public.subscription_renewal_operations WHERE id=p_operation_id)),updated_at=at_time WHERE subscription_id=o.subscription_id;
|
|
304
|
+
UPDATE public.fulfillment_inventory_reservations SET status='consumed',terminal_reason=NULL,updated_at=at_time
|
|
305
|
+
WHERE renewal_operation_id=p_operation_id AND status='committed';
|
|
306
|
+
IF o.case_id IS NOT NULL THEN PERFORM public.dunning_lifecycle_record_recovery(o.case_id,at_time,p_input->>'template'); END IF;
|
|
307
|
+
UPDATE public.subscription_retry_obligations SET state='recovered',next_retry_at=NULL,updated_at=at_time WHERE id=o.id RETURNING * INTO o;
|
|
308
|
+
result:=to_jsonb(o);
|
|
309
|
+
INSERT INTO public.subscription_retry_receipts(obligation_id,receipt_key,kind,attempt_sequence,input,result)
|
|
310
|
+
VALUES(o.id,p_key,'recovery',o.attempt_sequence,p_input,result);
|
|
311
|
+
RETURN result;
|
|
312
|
+
END;
|
|
313
|
+
$$;
|
|
314
|
+
CREATE FUNCTION public.subscription_retry_note_row_outcome(p_operation_id uuid,p_key text,p_input jsonb)
|
|
315
|
+
RETURNS jsonb LANGUAGE sql SET search_path = pg_catalog AS $$
|
|
316
|
+
SELECT CASE WHEN p_input->>'outcome'='succeeded' THEN public.subscription_retry_record_recovery(p_operation_id,p_key,p_input)
|
|
317
|
+
WHEN p_input->>'outcome'='refused' THEN public.subscription_retry_apply_failure(p_operation_id,p_key,p_input)
|
|
318
|
+
ELSE jsonb_build_object('awaitingProvider',true,'execute',false) END;
|
|
319
|
+
$$;
|
|
320
|
+
REVOKE ALL ON FUNCTION public.subscription_retry_read(uuid) FROM PUBLIC, anon, authenticated;
|
|
321
|
+
REVOKE ALL ON FUNCTION public.subscription_retry_candidates(integer,timestamptz) FROM PUBLIC, anon, authenticated;
|
|
322
|
+
REVOKE ALL ON FUNCTION public.subscription_retry_apply_failure(uuid,text,jsonb) FROM PUBLIC, anon, authenticated;
|
|
323
|
+
REVOKE ALL ON FUNCTION public.subscription_retry_open_case(uuid,text,text) FROM PUBLIC, anon, authenticated;
|
|
324
|
+
REVOKE ALL ON FUNCTION public.subscription_retry_authorize_recovery(uuid,uuid,text,text,timestamptz) FROM PUBLIC, anon, authenticated;
|
|
325
|
+
REVOKE ALL ON FUNCTION public.subscription_retry_schedule_recovery(uuid,text,uuid,timestamptz) FROM PUBLIC, anon, authenticated;
|
|
326
|
+
REVOKE ALL ON FUNCTION public.subscription_retry_prepare(uuid,text,jsonb,timestamptz) FROM PUBLIC, anon, authenticated;
|
|
327
|
+
REVOKE ALL ON FUNCTION public.subscription_retry_record_recovery(uuid,text,jsonb) FROM PUBLIC, anon, authenticated;
|
|
328
|
+
REVOKE ALL ON FUNCTION public.subscription_retry_note_row_outcome(uuid,text,jsonb) FROM PUBLIC, anon, authenticated;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
CREATE OR REPLACE FUNCTION public.fulfillment_record_stock_evidence(
|
|
2
|
+
p_source_key text, p_idempotency_key text, p_fingerprint text, p_sku text,
|
|
3
|
+
p_threshold_kind text, p_severity text, p_provider_for_sale integer,
|
|
4
|
+
p_local_available integer
|
|
5
|
+
) RETURNS jsonb LANGUAGE plpgsql SET search_path = pg_catalog, public AS $$
|
|
6
|
+
DECLARE v_existing public.fulfillment_stock_evidence%ROWTYPE;
|
|
7
|
+
BEGIN
|
|
8
|
+
SELECT * INTO v_existing FROM public.fulfillment_stock_evidence
|
|
9
|
+
WHERE idempotency_key = p_idempotency_key FOR UPDATE;
|
|
10
|
+
IF FOUND THEN
|
|
11
|
+
IF v_existing.operation_fingerprint <> p_fingerprint THEN
|
|
12
|
+
RAISE EXCEPTION 'fulfillment_stock_evidence_idempotency_conflict' USING ERRCODE = '23505';
|
|
13
|
+
END IF;
|
|
14
|
+
UPDATE public.fulfillment_stock_evidence SET
|
|
15
|
+
status = 'open', severity = p_severity,
|
|
16
|
+
provider_for_sale_quantity = p_provider_for_sale,
|
|
17
|
+
local_available_quantity = p_local_available,
|
|
18
|
+
last_seen_at = clock_timestamp(), resolved_at = NULL
|
|
19
|
+
WHERE idempotency_key = p_idempotency_key;
|
|
20
|
+
RETURN jsonb_build_object('replayed', true);
|
|
21
|
+
END IF;
|
|
22
|
+
INSERT INTO public.fulfillment_stock_evidence(
|
|
23
|
+
idempotency_key, operation_fingerprint, source_key, sku, threshold_kind,
|
|
24
|
+
severity, provider_for_sale_quantity, local_available_quantity
|
|
25
|
+
) VALUES (
|
|
26
|
+
p_idempotency_key, p_fingerprint, p_source_key, p_sku, p_threshold_kind,
|
|
27
|
+
p_severity, p_provider_for_sale, p_local_available
|
|
28
|
+
);
|
|
29
|
+
RETURN jsonb_build_object('replayed', false);
|
|
30
|
+
END $$;
|
|
@@ -80,7 +80,7 @@ export async function runFulfillmentAdapterConformance(subjects: readonly Fulfil
|
|
|
80
80
|
requireSame(result, fixture.expected, "observation fixture"); assertDurable(result, subject);
|
|
81
81
|
if (!caps.operations.verifyObservation.supported) requireFact(result.kind === "refused" && result.refusal.reason === "unsupported", "unsupported observation");
|
|
82
82
|
if (result.kind === "verified") {
|
|
83
|
-
requireSame(result.observation, projectObservation(result.observation), "observation allowlist");
|
|
83
|
+
requireSame(result.observation, projectObservation(result.observation, subject.approveTrackingUrl, subject.observationCodecs), "observation allowlist");
|
|
84
84
|
const observation = result.observation;
|
|
85
85
|
const identity = stable({ provider: observation.fact.providerKind, reference: observation.fact.fulfillmentReference,
|
|
86
86
|
kind: observation.eventKind, outcome: observation.fact.canonicalStatus, occurredAt: observation.fact.occurredAt });
|
|
@@ -12,7 +12,7 @@ export function createFulfillmentServices(options: FulfillmentServicesOptions):
|
|
|
12
12
|
const selected = readFulfillmentCapabilities(capability ? [capability] : [], selection);
|
|
13
13
|
if (selected.kind === 'refused') return { ready: false, refusal: selected.refusal };
|
|
14
14
|
if (!selected.descriptor.operations.dispatch.supported) return { ready: false, refusal: { reason: 'unsupported', phase: 'selection', retryable: false } };
|
|
15
|
-
if (!store || !provider) return { ready: false, refusal: FULFILLMENT_NATIVE_UNSUPPORTED };
|
|
15
|
+
if (!store || (config.mode !== "shadow" && !provider)) return { ready: false, refusal: FULFILLMENT_NATIVE_UNSUPPORTED };
|
|
16
16
|
return observe();
|
|
17
17
|
};
|
|
18
18
|
return {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { FulfillmentCapabilityDescriptor } from "./capabilities.js";
|
|
2
2
|
import type { FulfillmentDispatchPort, FulfillmentDispatchRequest } from "./dispatch.js";
|
|
3
3
|
import type { FulfillmentLabelPort } from "./label.js";
|
|
4
|
-
import type { FulfillmentObservationPort } from "./observation.js";
|
|
4
|
+
import type { FulfillmentObservationPort, FulfillmentObservationCodecs } from "./observation.js";
|
|
5
5
|
import type { FulfillmentReadbackPort } from "./readback.js";
|
|
6
6
|
import type { FulfillmentStockSyncProvider, FulfillmentProductSyncProvider } from "./sync.js";
|
|
7
7
|
import type { FulfillmentStorePort } from "./store.js";
|
|
@@ -14,6 +14,8 @@ type SyncFixture<Port> = { [K in keyof Port]: Port[K] extends (...args: never[])
|
|
|
14
14
|
|
|
15
15
|
/** Offline adapter-owned inputs and exact expected outputs; never real customer data. */
|
|
16
16
|
export interface FulfillmentAdapterConformanceSubject {
|
|
17
|
+
approveTrackingUrl?(url: string): boolean;
|
|
18
|
+
observationCodecs?: FulfillmentObservationCodecs;
|
|
17
19
|
subjectId: string;
|
|
18
20
|
adapter: {
|
|
19
21
|
capabilities: FulfillmentCapabilityDescriptor; dispatch: FulfillmentDispatchPort;
|
|
@@ -12,7 +12,7 @@ export interface FulfillmentRefusal {
|
|
|
12
12
|
phase: "selection" | "prepare" | "claim" | "begin" | "dispatch" | "acknowledge" | "verify" | "normalize" | "apply" | "readback" | "sync";
|
|
13
13
|
retryable: boolean;
|
|
14
14
|
}
|
|
15
|
-
/** Frozen domain input
|
|
15
|
+
/** Frozen domain input. Contact PII stays transient; only allowlisted mapped items enter evidence. Credentials stay in adapter closures. */
|
|
16
16
|
export interface FulfillmentDispatchRequest {
|
|
17
17
|
requestKey: string;
|
|
18
18
|
fulfillmentRef: string;
|
|
@@ -21,21 +21,36 @@ export interface FulfillmentDispatchRequest {
|
|
|
21
21
|
parcelRef: string | null;
|
|
22
22
|
contactRevision: { schemaVersion: number; source: string; revision: number };
|
|
23
23
|
recipient: { name: string | null; email: string | null; phone: string | null; line1: string; line2: string | null; city: string; postalCode: string; country: string; deliveryInstructions: string | null; courierInstructions: string | null };
|
|
24
|
-
lines: readonly { skuRef: string; title: string | null; quantity: number; productRef: string | null; packRef: string | null }[];
|
|
24
|
+
lines: readonly { skuRef: string; title: string | null; quantity: number; productRef: string | null; packRef: string | null; lotRef?: string | null; expirationDate?: string | null }[];
|
|
25
25
|
selection: FulfillmentSelection;
|
|
26
26
|
}
|
|
27
27
|
/** Allowlisted durable evidence; fingerprint commits to actual adapter command bytes. */
|
|
28
28
|
export interface DispatchEvidence {
|
|
29
|
+
redactedFieldCount?: number;
|
|
29
30
|
commandFingerprint: string;
|
|
30
31
|
contactRevision: FulfillmentDispatchRequest["contactRevision"];
|
|
31
32
|
providerRef: string;
|
|
32
33
|
carrierRef: string | null;
|
|
33
34
|
serviceRef: string | null;
|
|
35
|
+
transcript?: {
|
|
36
|
+
itemCount: number;
|
|
37
|
+
items: readonly { skuRef: string; quantity: number; lotRef: string | null; expirationDate: string | null }[];
|
|
38
|
+
deliveryKind: DeliveryKind; pickupPointRef: string | null;
|
|
39
|
+
/** Count of populated reference/date fields redacted by the durable projection. */
|
|
40
|
+
redactedFieldCount?: number;
|
|
41
|
+
};
|
|
42
|
+
mapping?: { source: "dispatch_worker"; mode: "shadow" | "stage" | "live"; shadowMode: boolean };
|
|
34
43
|
}
|
|
35
|
-
export
|
|
36
|
-
|
|
44
|
+
export interface FulfillmentOperationError {
|
|
45
|
+
redactedFieldCount?: number;
|
|
46
|
+
code: string; status: number | null; retryable: boolean; mayHaveSucceeded: boolean;
|
|
47
|
+
/** Machine cause only; never an arbitrary exception message. */
|
|
48
|
+
cause?: string;
|
|
49
|
+
}
|
|
50
|
+
export type FulfillmentDispatchResult = ({ error?: FulfillmentOperationError; redactedFieldCount?: number } & (
|
|
51
|
+
| { kind: "accepted"; externalRef: string; evidence: DispatchEvidence; providerAttemptKey: string; labelKey: string; recovery?: { source: "persisted_acceptance"; proof: "persisted_external_ref"; noProviderPost: true } }
|
|
37
52
|
| { kind: "not_dispatched"; effectMayHaveSucceeded: false; refusal: FulfillmentRefusal }
|
|
38
|
-
| { kind: "unknown"; effectMayHaveSucceeded: true; refusal: FulfillmentRefusal };
|
|
53
|
+
| { kind: "unknown"; effectMayHaveSucceeded: true; refusal: FulfillmentRefusal }));
|
|
39
54
|
export type FulfillmentDispatchPrepareResult =
|
|
40
55
|
| { kind: "prepared"; evidence: DispatchEvidence }
|
|
41
56
|
| { kind: "refused"; refusal: FulfillmentRefusal };
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
export type { DispatchEvidence, FulfillmentDispatchPrepareResult, FulfillmentSelection, FulfillmentDispatchRequest, FulfillmentDispatchResult, FulfillmentDispatchPort, FulfillmentRefusal } from "./dispatch.js";
|
|
1
|
+
export type { FulfillmentOperationError, DispatchEvidence, FulfillmentDispatchPrepareResult, FulfillmentSelection, FulfillmentDispatchRequest, FulfillmentDispatchResult, FulfillmentDispatchPort, FulfillmentRefusal } from "./dispatch.js";
|
|
2
2
|
export type { LabelDocument, FulfillmentLabelPort } from "./label.js";
|
|
3
3
|
/** G17 local reads/ingress persistence are inline runtime worker inputs; this provider envelope is unchanged. */
|
|
4
|
-
export type { FulfillmentObservation, FulfillmentObservationPort, FulfillmentTrackingReference } from "./observation.js";
|
|
5
|
-
export type { FulfillmentReadbackResult, FulfillmentReadbackPort } from "./readback.js";
|
|
4
|
+
export type { FulfillmentObservation, FulfillmentObservationCodecs, FulfillmentObservationPort, FulfillmentTrackingReference } from "./observation.js";
|
|
5
|
+
export type { FulfillmentReadbackResult, FulfillmentReadbackPort, FulfillmentReconciliationObservation, FulfillmentObservationPagePort } from "./readback.js";
|
|
6
6
|
export type { SyncCursor, StockSnapshot, StockMovement, LocalStock, FulfillmentStockSyncProvider, FulfillmentStockSyncPort, FulfillmentProductSyncProvider, FulfillmentProductSyncPort } from "./sync.js";
|
|
7
7
|
export type { FulfillmentCapabilityDescriptor } from "./capabilities.js";
|
|
8
8
|
export type { DispatchRecord, FulfillmentStorePort, FulfillmentPaidHandoffPort } from "./store.js";
|
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import type { NormalizedFulfillmentFact } from "@openlup/core/fulfillment";
|
|
2
2
|
import type { CanonicalLocalFulfillmentStatus } from "./index.js";
|
|
3
3
|
import type { FulfillmentRefusal } from "./dispatch.js";
|
|
4
|
-
|
|
4
|
+
/** Explicit adopter policy, forwarded through each durable observation projection. */
|
|
5
|
+
export interface FulfillmentObservationCodecs {
|
|
6
|
+
approveReference?(value: string): boolean;
|
|
7
|
+
/** Overrides the query/fragment default; HTTPS, no credentials and URL approval still apply. */
|
|
8
|
+
trackingUrl?(url: string): string | null;
|
|
9
|
+
}
|
|
10
|
+
export interface FulfillmentTrackingReference { trackingNumber: string; /** Normalized carrier kind, never a raw provider code. */ carrierRef: string | null; serviceRef: string | null; publicUrl: string | null }
|
|
5
11
|
/** Durable normalized envelope. eventKey, not the receivedAt-bearing fact fingerprint, deduplicates retries. */
|
|
6
12
|
export interface FulfillmentObservation {
|
|
7
13
|
fact: NormalizedFulfillmentFact<CanonicalLocalFulfillmentStatus>;
|
|
@@ -10,9 +16,11 @@ export interface FulfillmentObservation {
|
|
|
10
16
|
firstReceivedAt: string;
|
|
11
17
|
correlation: { orderRef: string | null; fulfillmentRef: string | null; externalRef: string | null; orderNumber: string | null; providerFulfillmentRef: string | null; parcelRef: string | null };
|
|
12
18
|
trackingRefs: readonly FulfillmentTrackingReference[];
|
|
13
|
-
|
|
19
|
+
/** Original provider status tokens; canonical status does not identify an occurrence. */
|
|
20
|
+
providerStatus?: { primary: string | null; subStatus: string | null };
|
|
21
|
+
diagnostics?: { redactedFieldCount?: number; admission?: { rejectedTrackingRefs: number; rejectedTrackingUrls: number }; refusal?: FulfillmentRefusal; stockContention?: { sqlState: "55P03"; phase: "provider_stock_consumed"; retryable: true }; annotation?: "recorded" | "failed"; recoveredPickedReceipt?: { receiptRef: string; closed: boolean } };
|
|
14
22
|
}
|
|
15
23
|
export interface FulfillmentObservationPort {
|
|
16
24
|
/** Authenticate untouched bytes before parsing. Inputs live only during verification. */
|
|
17
|
-
verifyAndNormalize(input: { rawBody: Uint8Array; headers: Readonly<Record<string, string>>; receivedAt: string }): Promise<{ kind: "verified"; observation: FulfillmentObservation } | { kind: "refused"; refusal: FulfillmentRefusal }>;
|
|
25
|
+
verifyAndNormalize(input: { rawBody: Uint8Array; headers: Readonly<Record<string, string>>; receivedAt: string }): Promise<{ kind: "verified"; observation: FulfillmentObservation } | { kind: "refused"; refusal: FulfillmentRefusal; diagnostics?: { invalidTimestamp?: true; rejectedCorrelationRefs?: number } }>;
|
|
18
26
|
}
|
|
@@ -9,3 +9,16 @@ export interface FulfillmentReadbackPort {
|
|
|
9
9
|
/** Empty pages and missing local rows cannot certify external absence. */
|
|
10
10
|
read(input: { providerRef: string; requestKey: string; fulfillmentRef: string; externalRef: string | null; mode: "command" | "parcel" }): Promise<FulfillmentReadbackResult>;
|
|
11
11
|
}
|
|
12
|
+
|
|
13
|
+
/** Collection observation is separate from authoritative exact lookup. */
|
|
14
|
+
export type FulfillmentReconciliationObservation = FulfillmentReadbackResult | {
|
|
15
|
+
kind: "unmapped"; externalRef: string | null;
|
|
16
|
+
evidence: Pick<FulfillmentObservation, "correlation" | "eventKey" | "providerStatus" | "diagnostics">;
|
|
17
|
+
};
|
|
18
|
+
export interface FulfillmentObservationPagePort {
|
|
19
|
+
readPage(input: { page: number; size: number }): Promise<
|
|
20
|
+
| { kind: "page"; rows: readonly FulfillmentReconciliationObservation[] }
|
|
21
|
+
| { kind: "page_exhausted" }
|
|
22
|
+
| { kind: "failed"; refusal: FulfillmentRefusal; error?: import("./dispatch.js").FulfillmentOperationError }
|
|
23
|
+
>;
|
|
24
|
+
}
|
|
@@ -4,6 +4,7 @@ export interface DispatchRecord {
|
|
|
4
4
|
externalRef: string | null; mode: "shadow" | "stage" | "live";
|
|
5
5
|
status: "draft" | "submitting" | "created" | "uncertain" | "failed" | "cancel_requested" | "cancelled";
|
|
6
6
|
evidence: DispatchEvidence; replayed: boolean;
|
|
7
|
+
acceptance?: Extract<FulfillmentDispatchResult, { kind: "accepted" }> | null;
|
|
7
8
|
}
|
|
8
9
|
export interface FulfillmentStorePort {
|
|
9
10
|
read(requestKey: string): Promise<DispatchRecord | null>;
|
|
@@ -19,13 +19,24 @@ export interface SyncCursor {
|
|
|
19
19
|
/** G15: successful sellable coverage gaps; legacy absent values read as []. */
|
|
20
20
|
coverageGapSkus?: readonly string[];
|
|
21
21
|
refusal: FulfillmentRefusal | null;
|
|
22
|
+
/** Per-entry bounded diagnostics and run evidence in the existing cursor JSON, never raw errors. */
|
|
23
|
+
syncRunId?: string;
|
|
24
|
+
counts?: { checked: number; updated: number; movements: number; rejected: number; failures: number; providerCalls: number; readBacks: number };
|
|
25
|
+
items?: readonly { kind?: "stock" | "movement"; itemIndex: number; skuRef: string | null; status: "recorded" | "failed" | "rejected"; reason?: string;
|
|
26
|
+
activeReserved?: number; sellableNow?: number | null; coverageGapQuantity?: number }[];
|
|
27
|
+
alerts?: readonly { skuRef: string; thresholdKind: Exclude<Threshold, "forecast">; severity: "info" | "warning" | "critical";
|
|
28
|
+
providerForSaleQuantity: number; localAvailableQuantity: number; detectedAt: string;
|
|
29
|
+
activeReserved?: number; coverageGapQuantity?: number; gapHours?: number; thresholdHours?: number }[];
|
|
30
|
+
staleSync?: { gapHours: number; thresholdHours: number; detectedAt: string };
|
|
31
|
+
error?: { reason: string; providerError?: { operation: "get_stock" | "get_stock_movements"; status: number | null; code: string; retryable: boolean } } | null;
|
|
22
32
|
}
|
|
23
33
|
export interface LocalStock { skuRef: string; onHand: number; reserved: number; unavailable: number; safetyStock: number }
|
|
24
34
|
type Threshold = "safety_stock" | "reservation_coverage" | "forecast" | "stale_sync";
|
|
25
35
|
type Conflict = "unknown_sku" | "ean_conflict" | "kind_quantity_conflict";
|
|
26
36
|
export interface FulfillmentStockSyncProvider {
|
|
27
|
-
|
|
28
|
-
|
|
37
|
+
/** Rejected rows carry only their input index and a closed reason, never an unsafe reference. */
|
|
38
|
+
getStock(): Promise<(StockSnapshot | { kind: "rejected"; itemIndex: number; reason: "invalid_sku_ref" })[]>;
|
|
39
|
+
getStockMovements(input?: { startLocalDate?: string; endLocalDate?: string; size?: number }): Promise<(StockMovement | { kind: "rejected"; itemIndex: number; reason: "invalid_sku_ref" })[]>;
|
|
29
40
|
}
|
|
30
41
|
export interface FulfillmentStockSyncPort {
|
|
31
42
|
readCursor(): Promise<SyncCursor | null>;
|