@openlup/platform 0.24.0 → 0.26.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 +127 -432
- package/README.md +46 -11
- package/dist/communications/contracts/subscriptionPaymentExpired.d.ts +3 -2
- package/dist/communications/contracts/subscriptionPaymentExpired.js +1 -1
- package/dist/communications/contracts/subscriptionPaymentFailed.d.ts +4 -3
- package/dist/communications/contracts/subscriptionPaymentFailed.js +4 -5
- package/dist/composition/index.d.ts +1 -0
- package/dist/composition/index.js +1 -0
- package/dist/composition/subscriptionContribution.d.ts +18 -0
- package/dist/composition/subscriptionContribution.js +19 -0
- package/dist/inventory/index.d.ts +2 -0
- package/dist/inventory/index.js +1 -0
- package/dist/inventory/postgresStockAvailability.d.ts +24 -0
- package/dist/inventory/postgresStockAvailability.js +48 -0
- package/dist/subscriptions/commandExamples.d.ts +6 -0
- package/dist/subscriptions/commandExamples.js +20 -0
- package/dist/subscriptions/contracts/commands.d.ts +8 -1
- package/dist/subscriptions/contracts/index.d.ts +2 -2
- package/dist/subscriptions/contracts/renewal.d.ts +2 -0
- package/dist/subscriptions/contracts/stockWait.d.ts +12 -1
- package/dist/subscriptions/index.d.ts +22 -4
- package/dist/subscriptions/index.js +22 -3
- package/dist/subscriptions/lifecycleMessages.d.ts +9 -0
- package/dist/subscriptions/lifecycleMessages.js +38 -0
- package/dist/subscriptions/paymentOutcomes.js +1 -1
- package/dist/subscriptions/postgresCommandStore.d.ts +44 -0
- package/dist/subscriptions/postgresCommandStore.js +172 -0
- package/dist/subscriptions/postgresRetryStore.d.ts +6 -0
- package/dist/subscriptions/postgresRetryStore.js +11 -0
- package/dist/subscriptions/postgresStockWaitStore.d.ts +62 -0
- package/dist/subscriptions/postgresStockWaitStore.js +174 -0
- package/dist/subscriptions/renewalCycleFailureMechanism.js +3 -2
- package/dist/subscriptions/services.d.ts +31 -0
- package/dist/subscriptions/services.js +15 -0
- package/package.json +2 -2
- package/release-gates.json +2 -2
- package/sql/20261024170000_subscription_command_lifecycle.sql +404 -0
- package/sql/20261024180000_subscription_stock_wait.sql +330 -0
- package/sql/20261024183000_inventory_available_to_promise.sql +70 -0
- package/sql/20261024184000_subscription_renewal_reserve_uses_seam.sql +147 -0
- package/sql/20261024184500_subscription_retry_late_positive_stock.sql +59 -0
- package/sql/20261024184600_subscription_retry_candidate_payment_filter.sql +22 -0
- package/src/communications/contracts/subscriptionPaymentExpired.ts +4 -3
- package/src/communications/contracts/subscriptionPaymentFailed.ts +8 -8
- package/src/composition/index.ts +1 -0
- package/src/composition/subscriptionContribution.ts +25 -0
- package/src/inventory/index.ts +2 -0
- package/src/inventory/postgresStockAvailability.ts +54 -0
- package/src/subscriptions/commandExamples.ts +19 -0
- package/src/subscriptions/contracts/commands.ts +35 -2
- package/src/subscriptions/contracts/index.ts +2 -2
- package/src/subscriptions/contracts/renewal.ts +2 -1
- package/src/subscriptions/contracts/stockWait.ts +14 -2
- package/src/subscriptions/index.ts +30 -3
- package/src/subscriptions/lifecycleMessages.ts +37 -0
- package/src/subscriptions/paymentOutcomes.ts +1 -1
- package/src/subscriptions/postgresCommandStore.ts +183 -0
- package/src/subscriptions/postgresRetryStore.ts +11 -0
- package/src/subscriptions/postgresStockWaitStore.ts +170 -0
- package/src/subscriptions/renewalCycleFailureMechanism.ts +3 -1
- package/src/subscriptions/services.ts +21 -0
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
-- Frozen stock waits use native M06 availability and reservations. Resumed means
|
|
2
|
+
-- paid realization admitted, never parcel arrival. No background consumer,
|
|
3
|
+
-- payment executor, pause, replacement confirmation or automatic delivery fact.
|
|
4
|
+
-- Disable the wait driver before rollback and retain captured obligations.
|
|
5
|
+
-- Donor: supabase/migrations/20261003123000_subscription_stock_wait.sql (87-126,
|
|
6
|
+
-- 260-298, 628-650), 20261003142320_subscription_stock_wait_unpaid_discovery.sql
|
|
7
|
+
-- (20-23, 116-195). Uncaptured selection rotates on operation.updated_at.
|
|
8
|
+
-- Native frozen recovery is pre-cycle only: operation snapshots
|
|
9
|
+
-- replace the managed unpaid cycle/order/payment graph; provider-owned graphs refuse.
|
|
10
|
+
CREATE TABLE public.subscription_stock_wait_cases (
|
|
11
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
12
|
+
subscription_id uuid NOT NULL REFERENCES public.subscriptions(id),
|
|
13
|
+
client_id uuid NOT NULL REFERENCES public.clients(id),
|
|
14
|
+
renewal_operation_id uuid NOT NULL UNIQUE REFERENCES public.subscription_renewal_operations(id),
|
|
15
|
+
scheduled_at timestamptz NOT NULL,
|
|
16
|
+
cycle_number integer NOT NULL CHECK (cycle_number>0),
|
|
17
|
+
state text NOT NULL CHECK (state IN ('waiting','stock_secured','resumed','closed')),
|
|
18
|
+
due_snapshot jsonb NOT NULL,
|
|
19
|
+
cycle_snapshot jsonb NOT NULL,
|
|
20
|
+
fingerprint text NOT NULL CHECK (fingerprint ~ '^[0-9a-f]{64}$'),
|
|
21
|
+
identity_key text NOT NULL,
|
|
22
|
+
deficits jsonb NOT NULL CHECK (jsonb_typeof(deficits)='array'),
|
|
23
|
+
notice_messages jsonb NOT NULL DEFAULT '{}',
|
|
24
|
+
refusal_observations jsonb NOT NULL DEFAULT '[]',
|
|
25
|
+
cycle_id uuid REFERENCES public.subscription_cycles(id),
|
|
26
|
+
order_id uuid REFERENCES public.commerce_orders(id),
|
|
27
|
+
fulfillment_id uuid REFERENCES public.fulfillment_shipments(id),
|
|
28
|
+
opened_at timestamptz NOT NULL DEFAULT now(),
|
|
29
|
+
last_checked_at timestamptz NOT NULL DEFAULT now(),
|
|
30
|
+
last_selected_at timestamptz,
|
|
31
|
+
stock_secured_at timestamptz,
|
|
32
|
+
resumed_at timestamptz,
|
|
33
|
+
closed_at timestamptz,
|
|
34
|
+
block_reason text,
|
|
35
|
+
CONSTRAINT subscription_stock_wait_schedule_key UNIQUE(subscription_id,scheduled_at)
|
|
36
|
+
);
|
|
37
|
+
-- squawk-ignore require-concurrent-index-creation
|
|
38
|
+
CREATE INDEX subscription_stock_wait_candidates_idx ON public.subscription_stock_wait_cases(state,last_selected_at,id);
|
|
39
|
+
REVOKE ALL ON TABLE public.subscription_stock_wait_cases FROM PUBLIC, anon, authenticated;
|
|
40
|
+
CREATE FUNCTION public.subscription_stock_wait_projection(p_case public.subscription_stock_wait_cases)
|
|
41
|
+
RETURNS jsonb LANGUAGE sql IMMUTABLE SET search_path = pg_catalog AS $$
|
|
42
|
+
SELECT to_jsonb(p_case)-'due_snapshot'-'cycle_snapshot'-'notice_messages';
|
|
43
|
+
$$;
|
|
44
|
+
CREATE FUNCTION public.subscription_stock_wait_read(p_case_id uuid)
|
|
45
|
+
RETURNS jsonb LANGUAGE sql STABLE SET search_path = pg_catalog AS $$
|
|
46
|
+
SELECT public.subscription_stock_wait_projection(c)||jsonb_build_object('waitingNoticeRelevant',s.status='active' AND s.next_cycle_at=c.scheduled_at)
|
|
47
|
+
FROM public.subscription_stock_wait_cases c JOIN public.subscriptions s ON s.id=c.subscription_id WHERE c.id=p_case_id;
|
|
48
|
+
$$;
|
|
49
|
+
CREATE FUNCTION public.subscription_stock_wait_frozen(p_subscription_id uuid,p_scheduled_at timestamptz)
|
|
50
|
+
RETURNS jsonb LANGUAGE sql STABLE SET search_path = pg_catalog AS $$
|
|
51
|
+
SELECT jsonb_build_object('caseId',c.id,'operationId',op.id,'identityKey',op.identity_key,
|
|
52
|
+
'operationFingerprint',op.operation_fingerprint,'snapshot',op.cycle_snapshot,'cycleNumber',op.cycle_number,
|
|
53
|
+
'binding',jsonb_build_object('caseId',c.id,'cycleId',cycle.id,'orderId',ord.id,'paymentId',intent.id,'engineIdempotencyKey',op.identity_key))
|
|
54
|
+
FROM public.subscription_renewal_operations op
|
|
55
|
+
LEFT JOIN public.subscription_stock_wait_cases c ON c.renewal_operation_id=op.id
|
|
56
|
+
LEFT JOIN public.subscription_cycles cycle ON cycle.renewal_operation_id=op.id
|
|
57
|
+
LEFT JOIN public.commerce_orders ord ON ord.renewal_operation_id=op.id
|
|
58
|
+
LEFT JOIN public.commerce_settlement_intents intent ON intent.renewal_operation_id=op.id
|
|
59
|
+
WHERE op.subscription_id=p_subscription_id AND op.scheduled_at=p_scheduled_at
|
|
60
|
+
AND EXISTS(SELECT 1 FROM public.subscriptions s WHERE s.id=op.subscription_id
|
|
61
|
+
AND s.status='active' AND s.next_cycle_at=p_scheduled_at)
|
|
62
|
+
AND op.attempt_prepared_at IS NULL AND op.external_attempt_ref IS NULL
|
|
63
|
+
AND cycle.id IS NULL AND ord.id IS NULL AND intent.id IS NULL
|
|
64
|
+
AND (c.id IS NULL OR c.state IN ('waiting','stock_secured'))
|
|
65
|
+
AND NOT EXISTS(SELECT 1 FROM public.subscription_renewal_outcomes outcome WHERE outcome.renewal_operation_id=op.id
|
|
66
|
+
AND (outcome.outcome<>'refused' OR outcome.reason NOT IN ('inventory_unavailable','reservation_expired') OR outcome.external_ref IS NOT NULL));
|
|
67
|
+
$$;
|
|
68
|
+
|
|
69
|
+
-- Credit the operation's live commitment exactly once, subtract every other
|
|
70
|
+
-- commitment, and preserve the complete frozen basket in the wait ledger.
|
|
71
|
+
CREATE FUNCTION public.subscription_stock_wait_deficits(p_snapshot jsonb,p_operation_id uuid,p_now timestamptz)
|
|
72
|
+
RETURNS jsonb LANGUAGE plpgsql SET search_path = pg_catalog AS $$
|
|
73
|
+
DECLARE line record; stock public.fulfillment_stock_current; required bigint; own_qty bigint;
|
|
74
|
+
available bigint; promise jsonb; result jsonb:='[]'; source text:=p_snapshot->>'stockSourceKey';
|
|
75
|
+
BEGIN
|
|
76
|
+
IF jsonb_typeof(p_snapshot->'lines') IS DISTINCT FROM 'array' OR jsonb_array_length(p_snapshot->'lines')=0 OR source IS NULL THEN
|
|
77
|
+
RAISE EXCEPTION 'stock_wait_invalid_basket' USING ERRCODE='22023'; END IF;
|
|
78
|
+
PERFORM s.sku FROM public.fulfillment_stock_current s JOIN jsonb_array_elements(p_snapshot->'lines') l ON l->>'sku'=s.sku
|
|
79
|
+
WHERE s.source_key=source ORDER BY s.sku FOR UPDATE OF s;
|
|
80
|
+
FOR line IN SELECT l->>'sku' sku,sum((l->>'quantity')::integer) quantity FROM jsonb_array_elements(p_snapshot->'lines') l GROUP BY l->>'sku' ORDER BY l->>'sku' LOOP
|
|
81
|
+
required:=line.quantity;
|
|
82
|
+
IF required IS NULL OR required<1 OR btrim(COALESCE(line.sku,''))='' THEN RAISE EXCEPTION 'stock_wait_invalid_quantity'; END IF;
|
|
83
|
+
SELECT * INTO stock FROM public.fulfillment_stock_current WHERE source_key=source AND sku=line.sku;
|
|
84
|
+
IF stock.sku IS NULL THEN RAISE EXCEPTION 'stock_wait_stock_missing'; END IF;
|
|
85
|
+
IF stock.stale_after<=p_now THEN RAISE EXCEPTION 'stock_wait_stock_stale'; END IF;
|
|
86
|
+
SELECT COALESCE(sum(l.quantity),0) INTO own_qty
|
|
87
|
+
FROM public.fulfillment_inventory_reservations r JOIN public.fulfillment_inventory_reservation_lines l ON l.reservation_id=r.id
|
|
88
|
+
WHERE r.source_key=source AND l.sku=line.sku AND r.renewal_operation_id=p_operation_id
|
|
89
|
+
AND (r.status='committed' OR (r.status='held' AND r.expires_at>p_now));
|
|
90
|
+
promise:=public.inventory_available_to_promise(source,line.sku,p_now,true);
|
|
91
|
+
available:=CASE WHEN stock.inventory_class='sellable' THEN (promise->>'available_quantity')::bigint ELSE 0 END;
|
|
92
|
+
IF own_qty+available<required THEN
|
|
93
|
+
result:=result||jsonb_build_array(jsonb_build_object('sku',line.sku,'requiredUnits',required,'reservedUnits',own_qty,
|
|
94
|
+
'availableUnits',available,'missingUnits',required-own_qty-available));
|
|
95
|
+
END IF;
|
|
96
|
+
END LOOP;
|
|
97
|
+
RETURN result;
|
|
98
|
+
END;
|
|
99
|
+
$$;
|
|
100
|
+
CREATE FUNCTION public.subscription_stock_wait_enqueue(p_case public.subscription_stock_wait_cases,p_kind text)
|
|
101
|
+
RETURNS void LANGUAGE plpgsql SET search_path = pg_catalog AS $$
|
|
102
|
+
BEGIN
|
|
103
|
+
IF p_kind NOT IN ('started','resumed') THEN RAISE EXCEPTION 'stock_wait_event_invalid'; END IF;
|
|
104
|
+
INSERT INTO public.outbox_events(aggregate_type,aggregate_id,event_type,idempotency_key,payload,metadata)
|
|
105
|
+
VALUES('subscription_stock_wait',p_case.id,'subscription_stock_wait.'||p_kind,'subscription_stock_wait:'||p_case.id::text||':'||p_kind,
|
|
106
|
+
jsonb_build_object('caseId',p_case.id,'clientId',p_case.client_id,'subscriptionId',p_case.subscription_id,
|
|
107
|
+
'scheduledAt',p_case.scheduled_at,'deficits',p_case.deficits,'orderId',p_case.order_id),jsonb_build_object('source','subscription_stock_wait'))
|
|
108
|
+
ON CONFLICT(event_type,idempotency_key) DO NOTHING;
|
|
109
|
+
END;
|
|
110
|
+
$$;
|
|
111
|
+
|
|
112
|
+
-- This is a bounded stock-only reset. Stock-refusal observations are not payment
|
|
113
|
+
-- outcomes; any other outcome or business artifact refuses before mutation.
|
|
114
|
+
CREATE FUNCTION public.subscription_stock_wait_recover_refusal(p_operation_id uuid,p_fingerprint text,p_worker text,p_now timestamptz DEFAULT now())
|
|
115
|
+
RETURNS jsonb LANGUAGE plpgsql SET search_path = pg_catalog AS $$
|
|
116
|
+
DECLARE s public.subscriptions; op public.subscription_renewal_operations; c public.subscription_stock_wait_cases;
|
|
117
|
+
r public.fulfillment_inventory_reservations; deficits jsonb; token uuid:=gen_random_uuid();
|
|
118
|
+
BEGIN
|
|
119
|
+
SELECT * INTO s FROM public.subscriptions WHERE id=(SELECT subscription_id FROM public.subscription_renewal_operations WHERE id=p_operation_id) FOR UPDATE;
|
|
120
|
+
SELECT * INTO op FROM public.subscription_renewal_operations WHERE id=p_operation_id FOR UPDATE;
|
|
121
|
+
SELECT * INTO c FROM public.subscription_stock_wait_cases WHERE renewal_operation_id=p_operation_id FOR UPDATE;
|
|
122
|
+
SELECT * INTO r FROM public.fulfillment_inventory_reservations WHERE renewal_operation_id=p_operation_id FOR UPDATE;
|
|
123
|
+
IF op.id IS NULL OR s.status<>'active' OR s.next_cycle_at IS DISTINCT FROM op.scheduled_at OR
|
|
124
|
+
op.operation_fingerprint IS DISTINCT FROM p_fingerprint OR btrim(COALESCE(p_worker,''))='' OR
|
|
125
|
+
(c.id IS NOT NULL AND (c.fingerprint<>op.operation_fingerprint OR c.cycle_snapshot<>op.cycle_snapshot OR c.identity_key<>op.identity_key OR c.state='closed')) THEN
|
|
126
|
+
RAISE EXCEPTION 'stock_wait_binding_changed' USING ERRCODE='22023'; END IF;
|
|
127
|
+
IF c.state='stock_secured' AND op.state='reserved' AND r.status='held' AND r.expires_at>p_now AND op.lease_expires_at>p_now THEN
|
|
128
|
+
RETURN public.subscription_renewal_operation_response(op,false,true,'stock_secured'); END IF;
|
|
129
|
+
IF NOT COALESCE(((op.state='refused' AND op.terminal_reason IN ('inventory_unavailable','reservation_expired')) OR
|
|
130
|
+
(op.state='reserved' AND c.state='stock_secured' AND r.status='held')),false) OR op.lease_expires_at>p_now OR
|
|
131
|
+
op.attempt_prepared_at IS NOT NULL OR op.external_attempt_ref IS NOT NULL OR op.external_attempt_acknowledged_at IS NOT NULL OR
|
|
132
|
+
EXISTS(SELECT 1 FROM public.subscription_cycles WHERE renewal_operation_id=op.id) OR
|
|
133
|
+
EXISTS(SELECT 1 FROM public.commerce_orders WHERE renewal_operation_id=op.id) OR
|
|
134
|
+
EXISTS(SELECT 1 FROM public.commerce_settlement_intents WHERE renewal_operation_id=op.id) OR
|
|
135
|
+
EXISTS(SELECT 1 FROM public.subscription_renewal_outcomes WHERE renewal_operation_id=op.id AND
|
|
136
|
+
(outcome<>'refused' OR reason IS NULL OR reason NOT IN ('inventory_unavailable','reservation_expired') OR external_ref IS NOT NULL
|
|
137
|
+
OR operation_fingerprint<>op.operation_fingerprint OR event_key NOT IN (op.identity_key||':inventory',op.identity_key||':reservation-expired'))) OR
|
|
138
|
+
(r.id IS NOT NULL AND (r.operation_fingerprint<>p_fingerprint OR r.source_key IS DISTINCT FROM op.cycle_snapshot->>'stockSourceKey' OR (r.status NOT IN ('expired','released') AND NOT (r.status='held' AND c.state='stock_secured')))) THEN
|
|
139
|
+
RAISE EXCEPTION 'stock_wait_recovery_provider_owned_or_not_eligible' USING ERRCODE='22023'; END IF;
|
|
140
|
+
deficits:=public.subscription_stock_wait_deficits(op.cycle_snapshot,op.id,p_now);
|
|
141
|
+
INSERT INTO public.subscription_stock_wait_cases(subscription_id,client_id,renewal_operation_id,scheduled_at,cycle_number,state,due_snapshot,cycle_snapshot,fingerprint,identity_key,deficits,opened_at,last_checked_at)
|
|
142
|
+
VALUES(s.id,s.client_id,op.id,op.scheduled_at,op.cycle_number,'waiting',to_jsonb(op),op.cycle_snapshot,op.operation_fingerprint,op.identity_key,deficits,p_now,p_now)
|
|
143
|
+
ON CONFLICT(subscription_id,scheduled_at) DO UPDATE SET deficits=EXCLUDED.deficits,last_checked_at=p_now RETURNING * INTO c;
|
|
144
|
+
IF jsonb_array_length(deficits)>0 THEN
|
|
145
|
+
UPDATE public.subscription_stock_wait_cases SET state='waiting',block_reason='inventory_unavailable' WHERE id=c.id RETURNING * INTO c;
|
|
146
|
+
PERFORM public.subscription_stock_wait_enqueue(c,'started');
|
|
147
|
+
RETURN jsonb_build_object('waiting',true,'caseId',c.id,'deficits',deficits);
|
|
148
|
+
END IF;
|
|
149
|
+
-- All effects inside this block roll back on a stock shortage. The wait row
|
|
150
|
+
-- remains outside it; prior commitments are never released to try another SKU.
|
|
151
|
+
BEGIN
|
|
152
|
+
-- The base stock rail uses one local refusal key per schedule/reason. Archive
|
|
153
|
+
-- those exact non-payment receipts before clearing them for this bounded
|
|
154
|
+
-- reset, so a later expiry can reuse the original key without a unique-key
|
|
155
|
+
-- failure. Provider/money outcomes are rejected above and never removed.
|
|
156
|
+
UPDATE public.subscription_stock_wait_cases SET refusal_observations=refusal_observations||
|
|
157
|
+
COALESCE((SELECT jsonb_agg(to_jsonb(observation) ORDER BY occurred_at,id) FROM public.subscription_renewal_outcomes observation WHERE renewal_operation_id=op.id),'[]'::jsonb)
|
|
158
|
+
WHERE id=c.id;
|
|
159
|
+
DELETE FROM public.subscription_renewal_outcomes WHERE renewal_operation_id=op.id;
|
|
160
|
+
UPDATE public.subscription_renewal_operations SET state='claimed',terminal_outcome=NULL,terminal_reason=NULL,terminal_at=NULL,
|
|
161
|
+
claim_token=token,claim_generation=claim_generation+1,claimed_by=p_worker,claimed_at=p_now,lease_expires_at=p_now+interval '5 minutes',updated_at=p_now WHERE id=op.id;
|
|
162
|
+
IF r.id IS NULL THEN
|
|
163
|
+
IF NOT (public.fulfillment_reserve_subscription_renewal(op.id,token,900,p_now)->>'reserved')::boolean THEN
|
|
164
|
+
RAISE EXCEPTION 'stock_wait_atomic_shortage' USING ERRCODE='23514'; END IF;
|
|
165
|
+
ELSE
|
|
166
|
+
-- Restore exactly the expired reservation's original frozen lines.
|
|
167
|
+
IF (SELECT jsonb_agg(jsonb_build_object('sku',sku,'quantity',quantity) ORDER BY sku) FROM public.fulfillment_inventory_reservation_lines WHERE reservation_id=r.id)
|
|
168
|
+
IS DISTINCT FROM (SELECT jsonb_agg(jsonb_build_object('sku',l->>'sku','quantity',(l->>'quantity')::integer) ORDER BY l->>'sku') FROM jsonb_array_elements(op.cycle_snapshot->'lines') l) THEN
|
|
169
|
+
RAISE EXCEPTION 'stock_wait_reservation_snapshot_mismatch'; END IF;
|
|
170
|
+
UPDATE public.fulfillment_inventory_reservations SET status='held',expires_at=p_now+interval '15 minutes',terminal_reason=NULL,updated_at=p_now WHERE id=r.id;
|
|
171
|
+
UPDATE public.subscription_renewal_operations SET state='reserved' WHERE id=op.id;
|
|
172
|
+
END IF;
|
|
173
|
+
EXCEPTION WHEN check_violation THEN
|
|
174
|
+
IF SQLERRM<>'stock_wait_atomic_shortage' THEN RAISE; END IF;
|
|
175
|
+
UPDATE public.subscription_stock_wait_cases SET block_reason='inventory_unavailable',state='waiting' WHERE id=c.id;
|
|
176
|
+
RETURN jsonb_build_object('waiting',true,'caseId',c.id);
|
|
177
|
+
END;
|
|
178
|
+
UPDATE public.subscription_stock_wait_cases SET state='stock_secured',deficits='[]',block_reason=NULL,
|
|
179
|
+
stock_secured_at=COALESCE(stock_secured_at,p_now),last_checked_at=p_now WHERE id=c.id;
|
|
180
|
+
SELECT * INTO op FROM public.subscription_renewal_operations WHERE id=op.id;
|
|
181
|
+
RETURN public.subscription_renewal_operation_response(op,true,false,'stock_secured')||jsonb_build_object('waiting',false,'caseId',c.id);
|
|
182
|
+
END;
|
|
183
|
+
$$;
|
|
184
|
+
CREATE FUNCTION public.subscription_stock_wait_admit(p_input jsonb,p_due jsonb,p_now timestamptz DEFAULT now())
|
|
185
|
+
RETURNS jsonb LANGUAGE plpgsql SET search_path = pg_catalog AS $$
|
|
186
|
+
DECLARE frozen jsonb; op public.subscription_renewal_operations; result jsonb; c public.subscription_stock_wait_cases;
|
|
187
|
+
BEGIN
|
|
188
|
+
SELECT * INTO op FROM public.subscription_renewal_operations WHERE id=(p_input->>'operationId')::uuid;
|
|
189
|
+
frozen:=public.subscription_stock_wait_frozen(op.subscription_id,op.scheduled_at);
|
|
190
|
+
IF frozen IS NULL OR frozen->'snapshot' IS DISTINCT FROM p_input->'snapshot' OR
|
|
191
|
+
frozen->>'operationFingerprint' IS DISTINCT FROM p_input->>'operationFingerprint' OR
|
|
192
|
+
frozen->>'identityKey' IS DISTINCT FROM p_input->>'identityKey' OR
|
|
193
|
+
(p_due ? 'stockWaitRecovery' AND frozen->'binding' IS DISTINCT FROM p_due->'stockWaitRecovery') THEN
|
|
194
|
+
RAISE EXCEPTION 'stock_wait_frozen_binding_changed' USING ERRCODE='23505'; END IF;
|
|
195
|
+
IF op.state='refused' OR (op.state='reserved' AND op.lease_expires_at<=p_now) THEN
|
|
196
|
+
RETURN public.subscription_stock_wait_recover_refusal(op.id,op.operation_fingerprint,p_input->>'worker',p_now);
|
|
197
|
+
END IF;
|
|
198
|
+
IF op.state='reserved' THEN RETURN public.subscription_renewal_operation_response(op,false,true,'stock_secured'); END IF;
|
|
199
|
+
result:=public.fulfillment_reserve_subscription_renewal(op.id,(p_input->>'claimToken')::uuid,900,p_now);
|
|
200
|
+
IF (result->>'reserved')::boolean THEN RETURN result||jsonb_build_object('waiting',false); END IF;
|
|
201
|
+
-- Record the wait immediately; lease expiry is still required before reset.
|
|
202
|
+
INSERT INTO public.subscription_stock_wait_cases(subscription_id,client_id,renewal_operation_id,scheduled_at,cycle_number,state,due_snapshot,cycle_snapshot,fingerprint,identity_key,deficits,opened_at,last_checked_at)
|
|
203
|
+
SELECT op.subscription_id,s.client_id,op.id,op.scheduled_at,op.cycle_number,'waiting',p_due,op.cycle_snapshot,
|
|
204
|
+
op.operation_fingerprint,op.identity_key,public.subscription_stock_wait_deficits(op.cycle_snapshot,op.id,p_now),p_now,p_now
|
|
205
|
+
FROM public.subscriptions s WHERE s.id=op.subscription_id
|
|
206
|
+
ON CONFLICT(subscription_id,scheduled_at) DO UPDATE SET last_checked_at=p_now RETURNING * INTO c;
|
|
207
|
+
PERFORM public.subscription_stock_wait_enqueue(c,'started');
|
|
208
|
+
RETURN result||jsonb_build_object('waiting',true,'caseId',c.id);
|
|
209
|
+
END;
|
|
210
|
+
$$;
|
|
211
|
+
-- Discover refused schedules even without a prior wait row. Oldest selected
|
|
212
|
+
-- waits rotate independently of the normal due cohort, preserving fairness.
|
|
213
|
+
CREATE FUNCTION public.subscription_stock_wait_candidates(p_limit integer DEFAULT 50,p_now timestamptz DEFAULT now())
|
|
214
|
+
RETURNS jsonb LANGUAGE plpgsql SET search_path = pg_catalog AS $$
|
|
215
|
+
DECLARE result jsonb; row record;
|
|
216
|
+
BEGIN
|
|
217
|
+
result:='[]';
|
|
218
|
+
FOR row IN SELECT op.id,op.subscription_id,op.scheduled_at,c.id case_id FROM public.subscription_renewal_operations op
|
|
219
|
+
JOIN public.subscriptions s ON s.id=op.subscription_id LEFT JOIN public.subscription_stock_wait_cases c ON c.renewal_operation_id=op.id
|
|
220
|
+
WHERE s.status='active' AND s.next_cycle_at=op.scheduled_at AND op.lease_expires_at<=p_now
|
|
221
|
+
AND ((op.state='refused' AND op.terminal_reason IN ('inventory_unavailable','reservation_expired')) OR (op.state='reserved' AND c.state='stock_secured'))
|
|
222
|
+
AND (c.id IS NULL OR c.state IN ('waiting','stock_secured'))
|
|
223
|
+
AND public.subscription_stock_wait_frozen(op.subscription_id,op.scheduled_at) IS NOT NULL
|
|
224
|
+
ORDER BY CASE WHEN c.id IS NULL THEN op.updated_at ELSE c.last_selected_at END NULLS FIRST,op.scheduled_at,op.id LIMIT LEAST(GREATEST(p_limit,1),500)
|
|
225
|
+
LOOP
|
|
226
|
+
IF public.subscription_stock_wait_frozen(row.subscription_id,row.scheduled_at) IS NOT NULL THEN
|
|
227
|
+
IF row.case_id IS NOT NULL THEN
|
|
228
|
+
UPDATE public.subscription_stock_wait_cases SET last_selected_at=p_now WHERE id=row.case_id;
|
|
229
|
+
ELSE
|
|
230
|
+
PERFORM 1 FROM public.subscription_renewal_operations WHERE id=row.id FOR UPDATE SKIP LOCKED;
|
|
231
|
+
IF NOT FOUND THEN CONTINUE; END IF;
|
|
232
|
+
UPDATE public.subscription_renewal_operations SET updated_at=p_now WHERE id=row.id;
|
|
233
|
+
END IF;
|
|
234
|
+
result:=result||jsonb_build_array(public.subscription_stock_wait_frozen(row.subscription_id,row.scheduled_at));
|
|
235
|
+
END IF;
|
|
236
|
+
END LOOP;
|
|
237
|
+
-- Superseded/cancelled unpaid waits close; provider-owned/paid cases never do.
|
|
238
|
+
UPDATE public.subscription_stock_wait_cases c SET state='closed',closed_at=p_now,block_reason='schedule_superseded'
|
|
239
|
+
FROM public.subscriptions s,public.subscription_renewal_operations op WHERE s.id=c.subscription_id AND op.id=c.renewal_operation_id
|
|
240
|
+
AND c.state IN ('waiting','stock_secured') AND (s.status='cancelled' OR s.next_cycle_at IS DISTINCT FROM c.scheduled_at)
|
|
241
|
+
AND op.attempt_prepared_at IS NULL AND op.external_attempt_ref IS NULL
|
|
242
|
+
AND NOT EXISTS(SELECT 1 FROM public.subscription_cycles WHERE renewal_operation_id=op.id);
|
|
243
|
+
RETURN result;
|
|
244
|
+
END;
|
|
245
|
+
$$;
|
|
246
|
+
CREATE FUNCTION public.subscription_stock_wait_normal_due(p_limit integer DEFAULT 50,p_now timestamptz DEFAULT now())
|
|
247
|
+
RETURNS jsonb LANGUAGE sql STABLE SET search_path = pg_catalog AS $$
|
|
248
|
+
SELECT COALESCE(jsonb_agg(to_jsonb(d) ORDER BY d.scheduled_at,d.subscription_id),'[]'::jsonb) FROM (
|
|
249
|
+
SELECT subscription.id AS subscription_id, subscription.client_id, subscription.next_cycle_at AS scheduled_at,
|
|
250
|
+
subscription.cadence_days, terms.next_cycle_number AS cycle_number, terms.currency_code,
|
|
251
|
+
terms.stock_source_key, terms.settlement_channel_key, terms.authorization_ref,
|
|
252
|
+
terms.payer_account_ref, terms.unattended_charge_authorized_at, terms.delivery_admission,
|
|
253
|
+
terms.delivery_block_reason
|
|
254
|
+
FROM public.subscriptions subscription
|
|
255
|
+
JOIN public.subscription_renewal_terms terms ON terms.subscription_id=subscription.id
|
|
256
|
+
WHERE subscription.status='active' AND subscription.next_cycle_at IS NOT NULL
|
|
257
|
+
AND subscription.next_cycle_at<=COALESCE(p_now,now())
|
|
258
|
+
AND NOT EXISTS(SELECT 1 FROM public.subscription_renewal_operations operation
|
|
259
|
+
WHERE operation.subscription_id=subscription.id AND operation.scheduled_at=subscription.next_cycle_at
|
|
260
|
+
AND (operation.state IN ('succeeded','refused') OR operation.lease_expires_at>COALESCE(p_now,now())))
|
|
261
|
+
AND NOT EXISTS(SELECT 1 FROM public.subscription_stock_wait_cases c
|
|
262
|
+
WHERE c.subscription_id=subscription.id AND c.scheduled_at=subscription.next_cycle_at AND c.state IN ('waiting','stock_secured'))
|
|
263
|
+
ORDER BY subscription.next_cycle_at,subscription.id LIMIT LEAST(GREATEST(COALESCE(p_limit,50),1),500)
|
|
264
|
+
) d;
|
|
265
|
+
$$;
|
|
266
|
+
CREATE FUNCTION public.subscription_stock_wait_mark_resumed(p_order_id uuid)
|
|
267
|
+
RETURNS jsonb LANGUAGE plpgsql SET search_path = pg_catalog AS $$
|
|
268
|
+
DECLARE c public.subscription_stock_wait_cases; shipment public.fulfillment_shipments;
|
|
269
|
+
BEGIN
|
|
270
|
+
PERFORM 1 FROM public.subscriptions WHERE id=(SELECT subscription_id FROM public.subscription_stock_wait_cases WHERE renewal_operation_id=(SELECT renewal_operation_id FROM public.commerce_orders WHERE id=p_order_id)) FOR UPDATE;
|
|
271
|
+
SELECT * INTO c FROM public.subscription_stock_wait_cases WHERE renewal_operation_id=(SELECT renewal_operation_id FROM public.commerce_orders WHERE id=p_order_id) FOR UPDATE;
|
|
272
|
+
IF c.id IS NULL THEN RETURN NULL; END IF;
|
|
273
|
+
SELECT * INTO shipment FROM public.fulfillment_shipments WHERE order_id=p_order_id AND replaces_shipment_id IS NULL ORDER BY id LIMIT 1 FOR UPDATE;
|
|
274
|
+
IF c.state='closed' OR EXISTS(SELECT 1 FROM public.commerce_orders WHERE id=p_order_id AND status='pending_payment') THEN RETURN NULL; END IF;
|
|
275
|
+
IF NOT EXISTS(SELECT 1 FROM public.commerce_orders WHERE id=p_order_id AND status IN ('paid','fulfillment_pending','fulfilled')) OR
|
|
276
|
+
NOT EXISTS(SELECT 1 FROM public.commerce_settlement_intents WHERE order_id=p_order_id AND status='settled') OR
|
|
277
|
+
NOT EXISTS(SELECT 1 FROM public.subscription_cycles WHERE renewal_operation_id=c.renewal_operation_id AND status='paid') OR
|
|
278
|
+
NOT EXISTS(SELECT 1 FROM public.fulfillment_inventory_reservations WHERE renewal_operation_id=c.renewal_operation_id AND status='consumed') OR
|
|
279
|
+
shipment.id IS NULL OR shipment.status IN ('cancelled','exception') OR
|
|
280
|
+
EXISTS(SELECT 1 FROM public.commerce_order_holds WHERE order_id=p_order_id AND status='active') THEN
|
|
281
|
+
RAISE EXCEPTION 'stock_wait_realization_admission_missing' USING ERRCODE='22023'; END IF;
|
|
282
|
+
IF c.state='resumed' THEN
|
|
283
|
+
IF c.fulfillment_id IS DISTINCT FROM shipment.id THEN RAISE EXCEPTION 'stock_wait_fulfillment_replay_mismatch'; END IF;
|
|
284
|
+
RETURN public.subscription_stock_wait_projection(c);
|
|
285
|
+
END IF;
|
|
286
|
+
IF c.state<>'stock_secured' THEN RAISE EXCEPTION 'stock_wait_stock_not_secured'; END IF;
|
|
287
|
+
UPDATE public.subscription_stock_wait_cases SET state='resumed',resumed_at=now(),cycle_id=(SELECT subscription_cycle_id FROM public.commerce_orders WHERE id=p_order_id),
|
|
288
|
+
order_id=p_order_id,fulfillment_id=shipment.id WHERE id=c.id RETURNING * INTO c;
|
|
289
|
+
PERFORM public.subscription_stock_wait_enqueue(c,'resumed');
|
|
290
|
+
RETURN public.subscription_stock_wait_projection(c);
|
|
291
|
+
END;
|
|
292
|
+
$$;
|
|
293
|
+
CREATE FUNCTION public.subscription_stock_wait_summary(p_state text)
|
|
294
|
+
RETURNS jsonb LANGUAGE plpgsql STABLE SET search_path = pg_catalog AS $$
|
|
295
|
+
BEGIN
|
|
296
|
+
IF p_state NOT IN ('all','waiting','stock_secured','resumed','closed') THEN RAISE EXCEPTION 'stock_wait_summary_state_invalid'; END IF;
|
|
297
|
+
RETURN jsonb_build_object('totalCount',(SELECT count(*) FROM public.subscription_stock_wait_cases WHERE p_state='all' OR state=p_state),
|
|
298
|
+
'productTotals',COALESCE((SELECT jsonb_agg(to_jsonb(t)) FROM (SELECT l->>'sku' sku,sum((l->>'missingUnits')::bigint) missing_units,
|
|
299
|
+
count(DISTINCT c.id) affected_cases FROM public.subscription_stock_wait_cases c CROSS JOIN LATERAL jsonb_array_elements(c.deficits) l
|
|
300
|
+
WHERE p_state='all' OR c.state=p_state GROUP BY l->>'sku') t),'[]'::jsonb));
|
|
301
|
+
END;
|
|
302
|
+
$$;
|
|
303
|
+
CREATE FUNCTION public.subscription_stock_wait_bind_notice(p_case_id uuid,p_event_id uuid,p_message jsonb)
|
|
304
|
+
RETURNS jsonb LANGUAGE plpgsql SET search_path = pg_catalog AS $$
|
|
305
|
+
DECLARE c public.subscription_stock_wait_cases; existing jsonb;
|
|
306
|
+
BEGIN
|
|
307
|
+
SELECT * INTO c FROM public.subscription_stock_wait_cases WHERE id=p_case_id FOR UPDATE;
|
|
308
|
+
IF c.id IS NULL OR NOT EXISTS(SELECT 1 FROM public.outbox_events WHERE id=p_event_id AND aggregate_id=c.id
|
|
309
|
+
AND aggregate_type='subscription_stock_wait' AND event_type IN ('subscription_stock_wait.started','subscription_stock_wait.resumed')) THEN
|
|
310
|
+
RAISE EXCEPTION 'stock_wait_notice_binding_invalid'; END IF;
|
|
311
|
+
existing:=c.notice_messages->p_event_id::text;
|
|
312
|
+
IF existing IS NOT NULL THEN RETURN existing; END IF;
|
|
313
|
+
IF jsonb_typeof(p_message) IS DISTINCT FROM 'object' OR EXISTS(SELECT 1 FROM unnest(ARRAY['from','to','subject','html']) k WHERE btrim(COALESCE(p_message->>k,''))='') THEN
|
|
314
|
+
RAISE EXCEPTION 'stock_wait_notice_message_invalid'; END IF;
|
|
315
|
+
UPDATE public.subscription_stock_wait_cases SET notice_messages=jsonb_set(notice_messages,ARRAY[p_event_id::text],p_message) WHERE id=c.id;
|
|
316
|
+
RETURN p_message;
|
|
317
|
+
END;
|
|
318
|
+
$$;
|
|
319
|
+
REVOKE ALL ON FUNCTION public.subscription_stock_wait_projection(public.subscription_stock_wait_cases) FROM PUBLIC, anon, authenticated;
|
|
320
|
+
REVOKE ALL ON FUNCTION public.subscription_stock_wait_read(uuid) FROM PUBLIC, anon, authenticated;
|
|
321
|
+
REVOKE ALL ON FUNCTION public.subscription_stock_wait_frozen(uuid,timestamptz) FROM PUBLIC, anon, authenticated;
|
|
322
|
+
REVOKE ALL ON FUNCTION public.subscription_stock_wait_deficits(jsonb,uuid,timestamptz) FROM PUBLIC, anon, authenticated;
|
|
323
|
+
REVOKE ALL ON FUNCTION public.subscription_stock_wait_enqueue(public.subscription_stock_wait_cases,text) FROM PUBLIC, anon, authenticated;
|
|
324
|
+
REVOKE ALL ON FUNCTION public.subscription_stock_wait_admit(jsonb,jsonb,timestamptz) FROM PUBLIC, anon, authenticated;
|
|
325
|
+
REVOKE ALL ON FUNCTION public.subscription_stock_wait_candidates(integer,timestamptz) FROM PUBLIC, anon, authenticated;
|
|
326
|
+
REVOKE ALL ON FUNCTION public.subscription_stock_wait_normal_due(integer,timestamptz) FROM PUBLIC, anon, authenticated;
|
|
327
|
+
REVOKE ALL ON FUNCTION public.subscription_stock_wait_mark_resumed(uuid) FROM PUBLIC, anon, authenticated;
|
|
328
|
+
REVOKE ALL ON FUNCTION public.subscription_stock_wait_summary(text) FROM PUBLIC, anon, authenticated;
|
|
329
|
+
REVOKE ALL ON FUNCTION public.subscription_stock_wait_bind_notice(uuid,uuid,jsonb) FROM PUBLIC, anon, authenticated;
|
|
330
|
+
REVOKE ALL ON FUNCTION public.subscription_stock_wait_recover_refusal(uuid,text,text,timestamptz) FROM PUBLIC, anon, authenticated;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
-- Donor: supabase/migrations/20260928170000_new_purchase_stock_availability.sql (26-76).
|
|
2
|
+
-- Native provider-stock subset: no lots/safety/excluded order; channel exposure is deferred.
|
|
3
|
+
-- M06 observed-stock ATP. Native stock has no separate safety balance.
|
|
4
|
+
-- Admission callers keep their stock-row locks; this is a read, not a hold.
|
|
5
|
+
-- Only actual native renewal allocation skips the fixed 14-day soft priority.
|
|
6
|
+
CREATE FUNCTION public.inventory_channel_exposure_quantity(p_source_key text, p_sku text)
|
|
7
|
+
RETURNS jsonb LANGUAGE sql STABLE SET search_path = pg_catalog AS $$
|
|
8
|
+
SELECT '0'::jsonb;
|
|
9
|
+
$$;
|
|
10
|
+
REVOKE ALL ON FUNCTION public.inventory_channel_exposure_quantity(text,text) FROM PUBLIC, anon, authenticated;
|
|
11
|
+
|
|
12
|
+
CREATE FUNCTION public.inventory_available_to_promise(
|
|
13
|
+
p_source_key text, p_sku text, p_as_of timestamptz, p_for_subscription_renewal boolean
|
|
14
|
+
)
|
|
15
|
+
RETURNS jsonb LANGUAGE sql STABLE SET search_path = pg_catalog AS $$
|
|
16
|
+
WITH live_holds AS (
|
|
17
|
+
SELECT r.renewal_operation_id,l.quantity
|
|
18
|
+
FROM public.fulfillment_inventory_reservations r
|
|
19
|
+
JOIN public.fulfillment_inventory_reservation_lines l ON l.reservation_id=r.id
|
|
20
|
+
WHERE r.source_key=p_source_key AND l.sku=p_sku
|
|
21
|
+
AND (r.status='committed' OR (r.status='held' AND r.expires_at>p_as_of))
|
|
22
|
+
), components AS (
|
|
23
|
+
SELECT COALESCE((SELECT stock.for_sale_quantity FROM public.fulfillment_stock_current stock
|
|
24
|
+
WHERE stock.source_key=p_source_key AND stock.sku=p_sku
|
|
25
|
+
AND stock.inventory_class='sellable' AND stock.stale_after>p_as_of),0)::bigint for_sale,
|
|
26
|
+
(SELECT COALESCE(sum(quantity),0) FROM live_holds)::bigint hard_holds,
|
|
27
|
+
(SELECT COALESCE(sum(GREATEST(0,due.quantity-credited.quantity)),0)::bigint
|
|
28
|
+
FROM (
|
|
29
|
+
SELECT s.id,s.next_cycle_at,sum(l.quantity) quantity
|
|
30
|
+
FROM public.subscriptions s
|
|
31
|
+
JOIN public.subscription_renewal_terms t ON t.subscription_id=s.id
|
|
32
|
+
JOIN public.subscription_renewal_term_lines l ON l.subscription_id=s.id
|
|
33
|
+
WHERE s.status='active' AND s.next_cycle_at<=p_as_of+interval '14 days'
|
|
34
|
+
AND t.stock_source_key=p_source_key AND l.sku=p_sku
|
|
35
|
+
GROUP BY s.id,s.next_cycle_at
|
|
36
|
+
) due CROSS JOIN LATERAL (
|
|
37
|
+
SELECT COALESCE(sum(h.quantity),0) quantity FROM live_holds h
|
|
38
|
+
JOIN public.subscription_renewal_operations op ON op.id=h.renewal_operation_id
|
|
39
|
+
WHERE op.subscription_id=due.id AND op.scheduled_at=due.next_cycle_at
|
|
40
|
+
AND NOT EXISTS(SELECT 1 FROM public.subscription_cycles c
|
|
41
|
+
WHERE c.renewal_operation_id=op.id AND c.status IN ('paid','skipped','cancelled'))
|
|
42
|
+
) credited) soft_demand,
|
|
43
|
+
(public.inventory_channel_exposure_quantity(p_source_key,p_sku)#>>'{}')::bigint exposure
|
|
44
|
+
)
|
|
45
|
+
SELECT jsonb_build_object('available_quantity',GREATEST(0,for_sale-hard_holds
|
|
46
|
+
-CASE WHEN p_for_subscription_renewal IS TRUE THEN 0 ELSE soft_demand END-exposure),
|
|
47
|
+
'for_sale_quantity',for_sale,'hard_hold_quantity',hard_holds,'safety_quantity',0,
|
|
48
|
+
'soft_subscription_demand_quantity',soft_demand,'channel_exposure_quantity',exposure)
|
|
49
|
+
FROM components;
|
|
50
|
+
$$;
|
|
51
|
+
REVOKE ALL ON FUNCTION public.inventory_available_to_promise(text,text,timestamptz,boolean) FROM PUBLIC, anon, authenticated;
|
|
52
|
+
|
|
53
|
+
-- M06 parity completion. Both availability fields protect subscription demand;
|
|
54
|
+
-- this one-time-purchase read never accepts a caller-supplied renewal exemption.
|
|
55
|
+
CREATE FUNCTION public.inventory_new_purchase_stock_read(p_source_key text, p_skus jsonb, p_as_of timestamptz)
|
|
56
|
+
RETURNS jsonb LANGUAGE sql STABLE SET search_path = pg_catalog AS $$
|
|
57
|
+
SELECT COALESCE(jsonb_agg(jsonb_build_object('sku',requested.sku,'catalogSkuId',sku.id,
|
|
58
|
+
'source',CASE WHEN stock.sku IS NULL THEN 'missing' ELSE 'provider' END,
|
|
59
|
+
'providerForSale',stock.for_sale_quantity,'activeHolds',atp->'hard_hold_quantity',
|
|
60
|
+
'safetyStock',atp->'safety_quantity','available',atp->'available_quantity',
|
|
61
|
+
'stale',stock.sku IS NOT NULL AND stock.stale_after<=p_as_of,
|
|
62
|
+
'renewalDemand',atp->'soft_subscription_demand_quantity',
|
|
63
|
+
'availableForNewPurchases',atp->'available_quantity','excludedOrderHolds',0,
|
|
64
|
+
'channelExposure',atp->'channel_exposure_quantity') ORDER BY requested.sku),'[]'::jsonb)
|
|
65
|
+
FROM (SELECT DISTINCT value sku FROM jsonb_array_elements_text(p_skus)) requested
|
|
66
|
+
LEFT JOIN public.catalog_skus sku ON sku.sku=requested.sku
|
|
67
|
+
LEFT JOIN public.fulfillment_stock_current stock ON stock.source_key=p_source_key AND stock.sku=requested.sku
|
|
68
|
+
CROSS JOIN LATERAL (SELECT public.inventory_available_to_promise(p_source_key,requested.sku,p_as_of,false) atp) promise;
|
|
69
|
+
$$;
|
|
70
|
+
REVOKE ALL ON FUNCTION public.inventory_new_purchase_stock_read(text,jsonb,timestamptz) FROM PUBLIC, anon, authenticated;
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
-- Donor: supabase/migrations/20260710131002_no_cron_inventory_reservation_leases.sql
|
|
2
|
+
-- (inventory_reserve_order provider branch); native operation/claim receipts replace managed order holds.
|
|
3
|
+
CREATE OR REPLACE FUNCTION public.fulfillment_reserve_subscription_renewal(
|
|
4
|
+
p_operation_id uuid,
|
|
5
|
+
p_claim_token uuid,
|
|
6
|
+
p_hold_seconds integer DEFAULT 900,
|
|
7
|
+
p_now timestamptz DEFAULT now()
|
|
8
|
+
)
|
|
9
|
+
RETURNS jsonb
|
|
10
|
+
LANGUAGE plpgsql
|
|
11
|
+
SET search_path = pg_catalog
|
|
12
|
+
AS $$
|
|
13
|
+
DECLARE
|
|
14
|
+
v_operation public.subscription_renewal_operations%ROWTYPE;
|
|
15
|
+
v_reservation public.fulfillment_inventory_reservations%ROWTYPE;
|
|
16
|
+
v_line jsonb;
|
|
17
|
+
v_source text;
|
|
18
|
+
v_sku text;
|
|
19
|
+
v_quantity integer;
|
|
20
|
+
v_available integer;
|
|
21
|
+
BEGIN
|
|
22
|
+
IF p_operation_id IS NULL OR p_claim_token IS NULL
|
|
23
|
+
OR p_hold_seconds IS NULL OR p_hold_seconds NOT BETWEEN 60 AND 86400
|
|
24
|
+
THEN
|
|
25
|
+
RAISE EXCEPTION 'fulfillment_renewal_reservation_invalid_input' USING ERRCODE = '22023';
|
|
26
|
+
END IF;
|
|
27
|
+
|
|
28
|
+
SELECT * INTO v_operation FROM public.subscription_renewal_operations
|
|
29
|
+
WHERE id = p_operation_id FOR UPDATE;
|
|
30
|
+
IF NOT FOUND THEN
|
|
31
|
+
RAISE EXCEPTION 'fulfillment_renewal_reservation_operation_not_found' USING ERRCODE = '22023';
|
|
32
|
+
END IF;
|
|
33
|
+
IF v_operation.claim_token <> p_claim_token
|
|
34
|
+
OR v_operation.lease_expires_at <= COALESCE(p_now, now())
|
|
35
|
+
THEN
|
|
36
|
+
RAISE EXCEPTION 'fulfillment_renewal_reservation_stale_claim' USING ERRCODE = '40001';
|
|
37
|
+
END IF;
|
|
38
|
+
|
|
39
|
+
SELECT * INTO v_reservation FROM public.fulfillment_inventory_reservations
|
|
40
|
+
WHERE renewal_operation_id = p_operation_id FOR UPDATE;
|
|
41
|
+
IF FOUND THEN
|
|
42
|
+
IF v_reservation.operation_fingerprint <> v_operation.operation_fingerprint THEN
|
|
43
|
+
RAISE EXCEPTION 'fulfillment_renewal_reservation_fingerprint_conflict' USING ERRCODE = '23505';
|
|
44
|
+
END IF;
|
|
45
|
+
IF v_reservation.status = 'held'
|
|
46
|
+
AND v_reservation.expires_at <= COALESCE(p_now, now())
|
|
47
|
+
AND v_operation.state = 'reserved'
|
|
48
|
+
THEN
|
|
49
|
+
UPDATE public.fulfillment_inventory_reservations
|
|
50
|
+
SET status = 'expired', expires_at = NULL,
|
|
51
|
+
terminal_reason = 'reservation_expired', updated_at = COALESCE(p_now, now())
|
|
52
|
+
WHERE id = v_reservation.id
|
|
53
|
+
RETURNING * INTO v_reservation;
|
|
54
|
+
UPDATE public.subscription_renewal_operations
|
|
55
|
+
SET state = 'refused', terminal_outcome = 'refused',
|
|
56
|
+
terminal_reason = 'reservation_expired', terminal_at = COALESCE(p_now, now()),
|
|
57
|
+
updated_at = COALESCE(p_now, now())
|
|
58
|
+
WHERE id = p_operation_id RETURNING * INTO v_operation;
|
|
59
|
+
INSERT INTO public.subscription_renewal_outcomes (
|
|
60
|
+
renewal_operation_id, operation_fingerprint, event_key, outcome, reason, occurred_at
|
|
61
|
+
) VALUES (
|
|
62
|
+
p_operation_id, v_operation.operation_fingerprint,
|
|
63
|
+
v_operation.identity_key || ':reservation-expired', 'refused',
|
|
64
|
+
'reservation_expired', COALESCE(p_now, now()));
|
|
65
|
+
RETURN jsonb_build_object(
|
|
66
|
+
'reserved', false, 'replayed', true, 'reservationId', v_reservation.id,
|
|
67
|
+
'status', 'expired', 'reason', 'reservation_expired');
|
|
68
|
+
END IF;
|
|
69
|
+
RETURN jsonb_build_object(
|
|
70
|
+
'reserved', v_reservation.status IN ('held', 'committed', 'consumed'),
|
|
71
|
+
'replayed', true, 'reservationId', v_reservation.id,
|
|
72
|
+
'status', v_reservation.status, 'reason', v_reservation.terminal_reason);
|
|
73
|
+
END IF;
|
|
74
|
+
|
|
75
|
+
IF v_operation.state <> 'claimed' THEN
|
|
76
|
+
RAISE EXCEPTION 'fulfillment_renewal_reservation_invalid_state' USING ERRCODE = '22023';
|
|
77
|
+
END IF;
|
|
78
|
+
v_source := v_operation.cycle_snapshot->>'stockSourceKey';
|
|
79
|
+
IF v_source IS NULL OR v_source !~ '^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$' THEN
|
|
80
|
+
RAISE EXCEPTION 'fulfillment_renewal_reservation_invalid_snapshot' USING ERRCODE = '22023';
|
|
81
|
+
END IF;
|
|
82
|
+
|
|
83
|
+
-- Lock all stock rows in a deterministic order before computing ATP, so two different
|
|
84
|
+
-- operations containing the same SKUs cannot both spend the same observed availability.
|
|
85
|
+
PERFORM stock.sku
|
|
86
|
+
FROM public.fulfillment_stock_current stock
|
|
87
|
+
JOIN jsonb_array_elements(v_operation.cycle_snapshot->'lines') line
|
|
88
|
+
ON stock.source_key = v_source AND stock.sku = line->>'sku'
|
|
89
|
+
ORDER BY stock.sku
|
|
90
|
+
FOR UPDATE OF stock;
|
|
91
|
+
|
|
92
|
+
FOR v_line IN SELECT value FROM jsonb_array_elements(v_operation.cycle_snapshot->'lines')
|
|
93
|
+
LOOP
|
|
94
|
+
v_sku := v_line->>'sku';
|
|
95
|
+
BEGIN v_quantity := (v_line->>'quantity')::integer;
|
|
96
|
+
EXCEPTION WHEN invalid_text_representation OR numeric_value_out_of_range THEN
|
|
97
|
+
RAISE EXCEPTION 'fulfillment_renewal_reservation_invalid_snapshot' USING ERRCODE = '22023';
|
|
98
|
+
END;
|
|
99
|
+
IF btrim(COALESCE(v_sku, '')) = '' OR v_quantity < 1 THEN
|
|
100
|
+
RAISE EXCEPTION 'fulfillment_renewal_reservation_invalid_snapshot' USING ERRCODE = '22023';
|
|
101
|
+
END IF;
|
|
102
|
+
|
|
103
|
+
SELECT (public.inventory_available_to_promise(v_source,v_sku,COALESCE(p_now,now()),true)
|
|
104
|
+
->>'available_quantity')::integer INTO v_available;
|
|
105
|
+
IF v_available < v_quantity THEN
|
|
106
|
+
UPDATE public.subscription_renewal_operations
|
|
107
|
+
SET state = 'refused', terminal_outcome = 'refused',
|
|
108
|
+
terminal_reason = 'inventory_unavailable', terminal_at = COALESCE(p_now, now()),
|
|
109
|
+
updated_at = COALESCE(p_now, now())
|
|
110
|
+
WHERE id = p_operation_id RETURNING * INTO v_operation;
|
|
111
|
+
INSERT INTO public.subscription_renewal_outcomes (
|
|
112
|
+
renewal_operation_id, operation_fingerprint, event_key, outcome, reason, occurred_at
|
|
113
|
+
) VALUES (
|
|
114
|
+
p_operation_id, v_operation.operation_fingerprint,
|
|
115
|
+
v_operation.identity_key || ':inventory', 'refused', 'inventory_unavailable',
|
|
116
|
+
COALESCE(p_now, now()));
|
|
117
|
+
RETURN jsonb_build_object(
|
|
118
|
+
'reserved', false, 'replayed', false, 'reservationId', NULL,
|
|
119
|
+
'status', 'refused', 'reason', 'inventory_unavailable');
|
|
120
|
+
END IF;
|
|
121
|
+
END LOOP;
|
|
122
|
+
|
|
123
|
+
INSERT INTO public.fulfillment_inventory_reservations (
|
|
124
|
+
renewal_operation_id, operation_fingerprint, source_key, expires_at, created_at, updated_at
|
|
125
|
+
) VALUES (
|
|
126
|
+
p_operation_id, v_operation.operation_fingerprint, v_source,
|
|
127
|
+
COALESCE(p_now, now()) + make_interval(secs => p_hold_seconds),
|
|
128
|
+
COALESCE(p_now, now()), COALESCE(p_now, now())
|
|
129
|
+
) RETURNING * INTO v_reservation;
|
|
130
|
+
|
|
131
|
+
FOR v_line IN SELECT value FROM jsonb_array_elements(v_operation.cycle_snapshot->'lines')
|
|
132
|
+
LOOP
|
|
133
|
+
INSERT INTO public.fulfillment_inventory_reservation_lines (
|
|
134
|
+
reservation_id, operation_fingerprint, sku, quantity
|
|
135
|
+
) VALUES (
|
|
136
|
+
v_reservation.id, v_operation.operation_fingerprint,
|
|
137
|
+
v_line->>'sku', (v_line->>'quantity')::integer);
|
|
138
|
+
END LOOP;
|
|
139
|
+
|
|
140
|
+
UPDATE public.subscription_renewal_operations
|
|
141
|
+
SET state = 'reserved', updated_at = COALESCE(p_now, now())
|
|
142
|
+
WHERE id = p_operation_id;
|
|
143
|
+
RETURN jsonb_build_object(
|
|
144
|
+
'reserved', true, 'replayed', false, 'reservationId', v_reservation.id,
|
|
145
|
+
'status', 'held', 'reason', NULL);
|
|
146
|
+
END;
|
|
147
|
+
$$;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
-- Donor: supabase/migrations/20261009110000_subscription_dunning_active_repair.sql
|
|
2
|
+
-- (payment-result recovery). Native released-to-committed ATP accounting has no managed row-state twin.
|
|
3
|
+
-- Native CTL-2 X: verified waiting-state late payment restores the original
|
|
4
|
+
-- released reservation as an ATP-counted committed hold, without a new charge.
|
|
5
|
+
-- Requires the native N1/N2/N3, 20261024183000 ATP seam and 20261024184000 allocator prefix.
|
|
6
|
+
-- Body-only replacement: original signature, invoker rights, search_path and
|
|
7
|
+
-- effective ACLs remain unchanged; no grants, tables, seeds or driver activation.
|
|
8
|
+
-- Prepared/committed recovery retains its existing consumed transition. A restored
|
|
9
|
+
-- hold remains counted until an explicit fulfillment/inventory reconciliation.
|
|
10
|
+
-- Native only; no managed twin is installed. Keep this forward on code rollback.
|
|
11
|
+
CREATE OR REPLACE FUNCTION public.subscription_retry_record_recovery(p_operation_id uuid,p_key text,p_input jsonb)
|
|
12
|
+
RETURNS jsonb LANGUAGE plpgsql SET search_path = pg_catalog AS $$
|
|
13
|
+
DECLARE o public.subscription_retry_obligations; r public.subscription_retry_receipts; prep public.subscription_retry_receipts;
|
|
14
|
+
i public.commerce_settlement_intents; result jsonb; at_time timestamptz:=(p_input->>'occurredAt')::timestamptz;
|
|
15
|
+
BEGIN
|
|
16
|
+
PERFORM 1 FROM public.subscriptions WHERE id=(SELECT subscription_id FROM public.subscription_renewal_operations WHERE id=p_operation_id) FOR UPDATE;
|
|
17
|
+
SELECT * INTO o FROM public.subscription_retry_obligations WHERE renewal_operation_id=p_operation_id FOR UPDATE;
|
|
18
|
+
SELECT * INTO r FROM public.subscription_retry_receipts WHERE receipt_key=p_key;
|
|
19
|
+
IF r.id IS NOT NULL THEN
|
|
20
|
+
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;
|
|
21
|
+
RETURN r.result;
|
|
22
|
+
END IF;
|
|
23
|
+
SELECT * INTO i FROM public.commerce_settlement_intents WHERE id=o.settlement_intent_id FOR UPDATE;
|
|
24
|
+
SELECT * INTO prep FROM public.subscription_retry_receipts WHERE obligation_id=o.id AND kind='prepare' AND attempt_sequence=o.attempt_sequence;
|
|
25
|
+
IF o.id IS NULL OR o.state='recovered' OR at_time IS NULL OR (p_input->>'firstRecordedAt')::timestamptz IS NULL OR
|
|
26
|
+
btrim(COALESCE(p_input->>'evidenceKey',''))='' OR btrim(COALESCE(p_input->>'externalRef',''))='' OR
|
|
27
|
+
(p_input->>'amountMinor')::bigint IS DISTINCT FROM i.amount_minor OR p_input->>'currency' IS DISTINCT FROM i.currency_code OR
|
|
28
|
+
(o.state='prepared' AND prep.receipt_key IS DISTINCT FROM p_input->>'prepareKey') THEN
|
|
29
|
+
RAISE EXCEPTION 'subscription_retry_positive_evidence_required' USING ERRCODE='22023'; END IF;
|
|
30
|
+
PERFORM public.payment_truth_ingest_event(p_input->>'evidenceKey',
|
|
31
|
+
encode(sha256(convert_to(jsonb_build_object('operationId',p_operation_id,'input',p_input)::text,'UTF8')),'hex'),
|
|
32
|
+
p_key,i.id,'captured',i.amount_minor,i.currency_code,at_time,NULL,NULL,
|
|
33
|
+
p_input||jsonb_build_object('renewalOperationId',p_operation_id,'operationFingerprint',o.operation_fingerprint),
|
|
34
|
+
NULL,NULL,p_input->>'template');
|
|
35
|
+
UPDATE public.subscription_renewal_terms SET next_cycle_number=GREATEST(next_cycle_number,
|
|
36
|
+
(SELECT cycle_number+1 FROM public.subscription_renewal_operations WHERE id=p_operation_id)),updated_at=at_time WHERE subscription_id=o.subscription_id;
|
|
37
|
+
-- A waiting-state refusal released this obligation's stock. Paid lines must
|
|
38
|
+
-- re-enter the existing ATP hard holds; consumed rows are not counted there.
|
|
39
|
+
-- Serialize with the native allocator using its deterministic stock-row locks.
|
|
40
|
+
IF o.state='waiting' THEN
|
|
41
|
+
PERFORM stock.sku FROM public.fulfillment_stock_current stock
|
|
42
|
+
JOIN public.fulfillment_inventory_reservations reservation ON reservation.source_key=stock.source_key
|
|
43
|
+
JOIN public.fulfillment_inventory_reservation_lines line ON line.reservation_id=reservation.id AND line.sku=stock.sku
|
|
44
|
+
WHERE reservation.renewal_operation_id=p_operation_id AND reservation.status='released'
|
|
45
|
+
ORDER BY stock.sku FOR UPDATE OF stock;
|
|
46
|
+
END IF;
|
|
47
|
+
UPDATE public.fulfillment_inventory_reservations
|
|
48
|
+
SET status=CASE WHEN status='released' THEN 'committed' ELSE 'consumed' END,
|
|
49
|
+
expires_at=CASE WHEN status='released' THEN NULL ELSE expires_at END,terminal_reason=NULL,updated_at=at_time
|
|
50
|
+
WHERE renewal_operation_id=p_operation_id AND
|
|
51
|
+
(status='committed' OR (o.state='waiting' AND status='released'));
|
|
52
|
+
IF o.case_id IS NOT NULL THEN PERFORM public.dunning_lifecycle_record_recovery(o.case_id,at_time,p_input->>'template'); END IF;
|
|
53
|
+
UPDATE public.subscription_retry_obligations SET state='recovered',next_retry_at=NULL,updated_at=at_time WHERE id=o.id RETURNING * INTO o;
|
|
54
|
+
result:=to_jsonb(o);
|
|
55
|
+
INSERT INTO public.subscription_retry_receipts(obligation_id,receipt_key,kind,attempt_sequence,input,result)
|
|
56
|
+
VALUES(o.id,p_key,'recovery',o.attempt_sequence,p_input,result);
|
|
57
|
+
RETURN result;
|
|
58
|
+
END;
|
|
59
|
+
$$;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
-- Donor: supabase/migrations/20261009110000_subscription_dunning_active_repair.sql
|
|
2
|
+
-- (due-reader paid exclusion); 20260912164500_retry_admission_observed_provider_success.sql.
|
|
3
|
+
-- Native waiting-obligation selection translates managed unpaid-cycle/intent admission.
|
|
4
|
+
-- Native CTL-2 Y: omit paid cycles and settled intents before retry LIMIT so
|
|
5
|
+
-- stale waiting obligations cannot starve genuinely due declines.
|
|
6
|
+
-- Requires the native prefix through CTL-2 X (20261024184500). This read-only body
|
|
7
|
+
-- replacement retains the exact arguments/defaults, STABLE volatility, invoker
|
|
8
|
+
-- rights, search_path, ordering, limit clamp and effective ACLs. No close route,
|
|
9
|
+
-- state rewrite, grant, seed, subscription pause or driver activation is added.
|
|
10
|
+
-- Native only; no managed twin is installed. Keep this forward on code rollback.
|
|
11
|
+
CREATE OR REPLACE FUNCTION public.subscription_retry_candidates(p_limit integer DEFAULT 50,p_now timestamptz DEFAULT now())
|
|
12
|
+
RETURNS jsonb LANGUAGE sql STABLE SET search_path = pg_catalog AS $$
|
|
13
|
+
SELECT COALESCE(jsonb_agg(to_jsonb(c)), '[]'::jsonb) FROM (
|
|
14
|
+
SELECT o.* FROM public.subscription_retry_obligations o JOIN public.subscriptions s ON s.id=o.subscription_id
|
|
15
|
+
WHERE s.status='active' AND o.state='waiting' AND o.next_retry_at<=p_now
|
|
16
|
+
AND EXISTS(SELECT 1 FROM public.subscription_cycles cycle
|
|
17
|
+
WHERE cycle.id=o.cycle_id AND cycle.status<>'paid')
|
|
18
|
+
AND EXISTS(SELECT 1 FROM public.commerce_settlement_intents intent
|
|
19
|
+
WHERE intent.id=o.settlement_intent_id AND intent.status<>'settled')
|
|
20
|
+
ORDER BY o.next_retry_at,o.id LIMIT LEAST(GREATEST(p_limit,1),500)
|
|
21
|
+
) c;
|
|
22
|
+
$$;
|