@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,404 @@
|
|
|
1
|
+
-- Native command boundary. The invoker host authenticates/authorizes the trusted
|
|
2
|
+
-- actor and client (including operator scope); no browser role can execute it.
|
|
3
|
+
-- The existing five-action lifecycle rail remains unchanged. Generic bundle
|
|
4
|
+
-- commands use the native edit-quote/catalogue contract, with command-bound
|
|
5
|
+
-- acceptance and atomic future renewal terms. Prices lock until the next edit;
|
|
6
|
+
-- proration is none. No operation/cycle/order/payment snapshot is rewritten.
|
|
7
|
+
-- Raw preview replay extracts the stored payload before subtracting acceptedQuoteHash.
|
|
8
|
+
-- Donor semantics: generic_bundle_actions (20260711151000), native line/cadence
|
|
9
|
+
-- no-op truth (20260711170020), slide no-op truth (20260713110000), locked renewal
|
|
10
|
+
-- lane (20260826160000). Portion mode/factor, introductory and address policy
|
|
11
|
+
-- stay host-owned; native resize compares lines/cadence, not a stored mode.
|
|
12
|
+
-- Donor files: supabase/migrations/20260711151000_subscription_generic_bundle_actions.sql
|
|
13
|
+
-- (194-207, 645-782), 20260711170020_subscription_package_changed_noop_truth.sql
|
|
14
|
+
-- (163-205), 20260713110000_subscription_slide_next_cycle_noop_truth.sql (100-118).
|
|
15
|
+
-- Native quotes expand omitted plan coreLines from stored lines; donor apply receives
|
|
16
|
+
-- already expanded recipeLines. Composition metadata has no native storage.
|
|
17
|
+
-- Preview-only no-op parity; direct apply keeps native lifecycle guards/receipts.
|
|
18
|
+
|
|
19
|
+
CREATE TABLE public.subscription_command_receipts (
|
|
20
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
21
|
+
actor_id uuid NOT NULL,
|
|
22
|
+
idempotency_key text NOT NULL CHECK (char_length(btrim(idempotency_key)) BETWEEN 8 AND 180),
|
|
23
|
+
subscription_id uuid NOT NULL REFERENCES public.subscriptions(id),
|
|
24
|
+
client_id uuid NOT NULL REFERENCES public.clients(id),
|
|
25
|
+
command_fingerprint text NOT NULL CHECK (command_fingerprint ~ '^[0-9a-f]{64}$'),
|
|
26
|
+
command jsonb NOT NULL CHECK (jsonb_typeof(command) = 'object'),
|
|
27
|
+
quote_hash text,
|
|
28
|
+
result jsonb NOT NULL,
|
|
29
|
+
status text NOT NULL CHECK (status IN ('previewed','applied','noop','refused')),
|
|
30
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
31
|
+
CONSTRAINT subscription_command_actor_key UNIQUE (actor_id,idempotency_key)
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
-- Preview normalizes the generic command into the native bounded whole-bundle
|
|
35
|
+
-- quote. The stored command excludes only the subsequently accepted quote hash;
|
|
36
|
+
-- its action and every other supplied field remain part of the fingerprint.
|
|
37
|
+
CREATE FUNCTION public.subscription_command_preview(
|
|
38
|
+
p_actor_id uuid,p_client_id uuid,p_key text,p_subscription_id uuid,p_action text,
|
|
39
|
+
p_payload jsonb,p_now timestamptz DEFAULT now()
|
|
40
|
+
) RETURNS jsonb LANGUAGE plpgsql SET search_path = pg_catalog AS $$
|
|
41
|
+
DECLARE
|
|
42
|
+
s public.subscriptions%ROWTYPE; r public.subscription_command_receipts%ROWTYPE;
|
|
43
|
+
q public.subscription_edit_quotes%ROWTYPE; command jsonb; fingerprint text;
|
|
44
|
+
core jsonb; addons jsonb; requested jsonb; lines jsonb; cadence integer; version integer;
|
|
45
|
+
total bigint; current_total bigint; quote_key text; quote_hash text; answer jsonb; reason text;
|
|
46
|
+
v_noop boolean:=false; core_matches boolean; addon_matches boolean;
|
|
47
|
+
BEGIN
|
|
48
|
+
IF p_actor_id IS NULL OR p_client_id IS NULL OR p_now IS NULL OR NOT isfinite(p_now)
|
|
49
|
+
OR p_key IS NULL OR p_key<>btrim(p_key) OR char_length(p_key) NOT BETWEEN 8 AND 180
|
|
50
|
+
OR p_payload IS NULL OR jsonb_typeof(p_payload)<>'object' OR p_payload ? 'acceptedQuoteHash' OR p_action IS NULL THEN
|
|
51
|
+
RAISE EXCEPTION 'subscription_command_invalid_input' USING ERRCODE='22023';
|
|
52
|
+
END IF;
|
|
53
|
+
command:=jsonb_build_object('clientId',p_client_id,'subscriptionId',p_subscription_id,'action',p_action,'payload',p_payload-'acceptedQuoteHash');
|
|
54
|
+
fingerprint:=encode(sha256(convert_to(command::text,'UTF8')),'hex');
|
|
55
|
+
-- Serialize the actor/key even when competing commands target different rows.
|
|
56
|
+
PERFORM pg_advisory_xact_lock(hashtextextended(p_actor_id::text||':'||p_key,0));
|
|
57
|
+
SELECT * INTO s FROM public.subscriptions WHERE id=p_subscription_id AND client_id=p_client_id FOR UPDATE;
|
|
58
|
+
IF NOT FOUND THEN RAISE EXCEPTION 'subscription_command_forbidden' USING ERRCODE='22023'; END IF;
|
|
59
|
+
SELECT * INTO r FROM public.subscription_command_receipts WHERE actor_id=p_actor_id AND idempotency_key=p_key;
|
|
60
|
+
IF FOUND THEN
|
|
61
|
+
IF jsonb_set(r.command,'{payload}',(r.command->'payload')-'acceptedQuoteHash')<>command THEN
|
|
62
|
+
RETURN jsonb_build_object('status','refused','reason','subscription_command_idempotency_conflict');
|
|
63
|
+
END IF;
|
|
64
|
+
RETURN r.result;
|
|
65
|
+
END IF;
|
|
66
|
+
<<preview_command>>
|
|
67
|
+
BEGIN
|
|
68
|
+
IF p_action='slide_next_cycle' THEN
|
|
69
|
+
BEGIN
|
|
70
|
+
v_noop:=s.status='active' AND s.next_cycle_at IS NOT NULL
|
|
71
|
+
AND floor(extract(epoch FROM NULLIF(p_payload->>'newNextCycleAt','')::timestamptz)/86400)
|
|
72
|
+
=floor(extract(epoch FROM s.next_cycle_at)/86400);
|
|
73
|
+
EXCEPTION WHEN invalid_datetime_format OR datetime_field_overflow THEN
|
|
74
|
+
RAISE EXCEPTION 'subscription_command_preview_unsupported' USING ERRCODE='22023';
|
|
75
|
+
END;
|
|
76
|
+
IF v_noop IS NOT TRUE THEN
|
|
77
|
+
RAISE EXCEPTION 'subscription_command_preview_unsupported' USING ERRCODE='22023';
|
|
78
|
+
END IF;
|
|
79
|
+
ELSE
|
|
80
|
+
IF p_action NOT IN ('update_bundle','resize_bundle','update_package_template','update_plan_length',
|
|
81
|
+
'add_addon','remove_addon','update_addon_quantity') THEN
|
|
82
|
+
RAISE EXCEPTION 'subscription_command_preview_unsupported' USING ERRCODE='22023';
|
|
83
|
+
END IF;
|
|
84
|
+
IF s.status<>'active' THEN RAISE EXCEPTION 'subscription_command_not_active' USING ERRCODE='22023'; END IF;
|
|
85
|
+
IF p_action<>'cancel' AND EXISTS(SELECT 1 FROM public.subscription_dunning_cases WHERE subscription_id=s.id AND status='open') THEN
|
|
86
|
+
RAISE EXCEPTION 'subscription_command_payment_blocked' USING ERRCODE='22023';
|
|
87
|
+
END IF;
|
|
88
|
+
IF s.next_cycle_at IS NULL OR p_now>=s.next_cycle_at-make_interval(hours=>s.edit_window_hours) THEN
|
|
89
|
+
RAISE EXCEPTION 'subscription_edit_window_closed' USING ERRCODE='22023';
|
|
90
|
+
END IF;
|
|
91
|
+
PERFORM public.subscription_lifecycle_assert_unlocked_cycle(s.id,s.next_cycle_at);
|
|
92
|
+
IF NOT EXISTS(SELECT 1 FROM public.subscription_method_states WHERE subscription_id=s.id AND health_state='healthy') THEN
|
|
93
|
+
RAISE EXCEPTION 'subscription_command_payment_blocked' USING ERRCODE='22023';
|
|
94
|
+
END IF;
|
|
95
|
+
version:=(p_payload->>'expectedTemplateVersion')::integer;
|
|
96
|
+
IF version IS NULL OR version<>s.template_version THEN
|
|
97
|
+
RAISE EXCEPTION 'subscription_edit_stale_template' USING ERRCODE='22023';
|
|
98
|
+
END IF;
|
|
99
|
+
cadence:=CASE WHEN p_action IN ('update_bundle','resize_bundle','update_package_template','update_plan_length')
|
|
100
|
+
THEN COALESCE((p_payload->>'cadenceDays')::integer,(p_payload->>'planDays')::integer,s.cadence_days) ELSE s.cadence_days END;
|
|
101
|
+
IF cadence NOT BETWEEN 1 AND 90 OR s.price_list_id IS NULL OR s.currency_code IS NULL THEN
|
|
102
|
+
RAISE EXCEPTION 'subscription_edit_invalid_request' USING ERRCODE='22023';
|
|
103
|
+
END IF;
|
|
104
|
+
SELECT COALESCE(jsonb_agg(jsonb_build_object('variantId',variant_id,'qty',quantity) ORDER BY sort_order,id)
|
|
105
|
+
FILTER(WHERE NOT is_addon),'[]'),COALESCE(jsonb_agg(jsonb_build_object('variantId',variant_id,'qty',quantity)
|
|
106
|
+
ORDER BY sort_order,id) FILTER(WHERE is_addon),'[]'),COALESCE(sum(quantity*unit_price_minor),0)
|
|
107
|
+
INTO core,addons,current_total FROM public.subscription_lines WHERE subscription_id=s.id;
|
|
108
|
+
IF p_action IN ('update_bundle','resize_bundle','update_package_template') THEN
|
|
109
|
+
IF p_payload->'compositionConstraint' IS NULL OR jsonb_typeof(p_payload->'compositionConstraint')<>'object'
|
|
110
|
+
OR (p_payload->'compositionConstraint') ?| ARRAY['version','data'] THEN
|
|
111
|
+
RAISE EXCEPTION 'subscription_command_invalid_composition_constraint' USING ERRCODE='22023';
|
|
112
|
+
END IF;
|
|
113
|
+
END IF;
|
|
114
|
+
-- Compare donor line sets by UUID and summed quantity, independently of order/prices.
|
|
115
|
+
SELECT bool_and(matches) FILTER(WHERE NOT is_addon),bool_and(matches) FILTER(WHERE is_addon)
|
|
116
|
+
INTO core_matches,addon_matches FROM (
|
|
117
|
+
SELECT is_addon,jsonb_typeof(wanted)='array' AND
|
|
118
|
+
(SELECT COALESCE(jsonb_object_agg(variant,qty),'{}') FROM (
|
|
119
|
+
SELECT (v->>'variantId')::uuid variant,sum((v->>'qty')::integer) qty
|
|
120
|
+
FROM jsonb_array_elements(CASE WHEN jsonb_typeof(wanted)='array' THEN wanted ELSE '[]' END) v
|
|
121
|
+
WHERE COALESCE(v->>'variantId','') ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
|
|
122
|
+
AND COALESCE(v->>'qty','') ~ '^[1-9][0-9]{0,5}$'
|
|
123
|
+
GROUP BY (v->>'variantId')::uuid) desired)
|
|
124
|
+
=(SELECT COALESCE(jsonb_object_agg(variant_id,qty),'{}') FROM (
|
|
125
|
+
SELECT variant_id,sum(quantity) qty FROM public.subscription_lines
|
|
126
|
+
WHERE subscription_id=s.id AND is_addon=inputs.is_addon GROUP BY variant_id) stored)
|
|
127
|
+
AND NOT EXISTS(SELECT 1 FROM jsonb_array_elements(
|
|
128
|
+
CASE WHEN jsonb_typeof(wanted)='array' THEN wanted ELSE '[]' END) v
|
|
129
|
+
WHERE COALESCE(v->>'variantId','') !~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
|
|
130
|
+
OR COALESCE(v->>'qty','') !~ '^[1-9][0-9]{0,5}$') matches
|
|
131
|
+
FROM (VALUES(false,p_payload->'coreLines'),(true,COALESCE(p_payload->'addonLines','[]'))) inputs(is_addon,wanted)
|
|
132
|
+
) line_sets;
|
|
133
|
+
IF p_action IN ('add_addon','update_addon_quantity') THEN
|
|
134
|
+
v_noop:=EXISTS(SELECT 1 FROM public.subscription_lines WHERE subscription_id=s.id
|
|
135
|
+
AND variant_id=NULLIF(p_payload->>'variantId','')::uuid AND is_addon
|
|
136
|
+
AND quantity=CASE WHEN p_action='add_addon' THEN GREATEST(1,COALESCE((p_payload->>'qty')::integer,1))
|
|
137
|
+
ELSE (p_payload->>'qty')::integer END);
|
|
138
|
+
ELSIF p_action='update_bundle' THEN
|
|
139
|
+
v_noop:=core_matches AND cadence=s.cadence_days AND (NOT p_payload ? 'addonLines' OR addon_matches);
|
|
140
|
+
ELSIF p_action='resize_bundle' THEN
|
|
141
|
+
v_noop:=core_matches AND cadence=s.cadence_days;
|
|
142
|
+
ELSIF p_action='update_plan_length' THEN
|
|
143
|
+
v_noop:=cadence=s.cadence_days AND core_matches;
|
|
144
|
+
ELSIF p_action='update_package_template' THEN
|
|
145
|
+
v_noop:=cadence=s.cadence_days AND core_matches AND addon_matches;
|
|
146
|
+
END IF;
|
|
147
|
+
END IF;
|
|
148
|
+
IF v_noop THEN
|
|
149
|
+
answer:=jsonb_build_object('status','noop','subscriptionId',s.id,'action',p_action,'subscriptionStatus',s.status,
|
|
150
|
+
'nextCycleAt',s.next_cycle_at,'templateVersion',s.template_version,'eventId',NULL);
|
|
151
|
+
EXIT preview_command;
|
|
152
|
+
END IF;
|
|
153
|
+
IF p_action IN ('update_bundle','resize_bundle','update_package_template') THEN
|
|
154
|
+
core:=p_payload->'coreLines';
|
|
155
|
+
IF p_action='update_package_template' OR (p_action='update_bundle' AND p_payload ? 'addonLines') THEN
|
|
156
|
+
addons:=COALESCE(p_payload->'addonLines','[]');
|
|
157
|
+
END IF;
|
|
158
|
+
IF p_action='update_bundle' AND cadence=s.cadence_days AND
|
|
159
|
+
(SELECT COALESCE(sum((v->>'qty')::integer),0) FROM jsonb_array_elements(core) v)<>
|
|
160
|
+
(SELECT COALESCE(sum(quantity),0) FROM public.subscription_lines WHERE subscription_id=s.id AND NOT is_addon) THEN
|
|
161
|
+
RAISE EXCEPTION 'subscription_command_bundle_total_mismatch' USING ERRCODE='22023';
|
|
162
|
+
END IF;
|
|
163
|
+
ELSIF p_action='update_plan_length' THEN
|
|
164
|
+
core:=COALESCE(p_payload->'coreLines',core);
|
|
165
|
+
ELSIF p_action='add_addon' THEN
|
|
166
|
+
addons:=addons||jsonb_build_array(jsonb_build_object('variantId',p_payload->>'variantId','qty',GREATEST(1,COALESCE((p_payload->>'qty')::integer,1))));
|
|
167
|
+
ELSIF p_action IN ('remove_addon','update_addon_quantity') THEN
|
|
168
|
+
IF NOT EXISTS(SELECT 1 FROM jsonb_array_elements(addons) v WHERE v->>'variantId'=lower(p_payload->>'variantId')) THEN
|
|
169
|
+
RAISE EXCEPTION 'subscription_command_addon_not_found' USING ERRCODE='22023';
|
|
170
|
+
END IF;
|
|
171
|
+
SELECT COALESCE(jsonb_agg(CASE WHEN v->>'variantId'=lower(p_payload->>'variantId') THEN
|
|
172
|
+
jsonb_set(v,'{qty}',p_payload->'qty') ELSE v END),'[]') INTO addons
|
|
173
|
+
FROM jsonb_array_elements(addons) v WHERE p_action<>'remove_addon' OR v->>'variantId'<>lower(p_payload->>'variantId');
|
|
174
|
+
END IF;
|
|
175
|
+
IF core IS NULL OR jsonb_typeof(core)<>'array' OR jsonb_array_length(core) NOT BETWEEN 1 AND 6
|
|
176
|
+
OR addons IS NULL OR jsonb_typeof(addons)<>'array' OR jsonb_array_length(addons)>12 THEN
|
|
177
|
+
RAISE EXCEPTION 'subscription_edit_invalid_lines' USING ERRCODE='22023';
|
|
178
|
+
END IF;
|
|
179
|
+
requested:=core||addons;
|
|
180
|
+
IF EXISTS(SELECT 1 FROM jsonb_array_elements(requested) v WHERE jsonb_typeof(v)<>'object'
|
|
181
|
+
OR COALESCE(v->>'variantId','') !~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
|
|
182
|
+
OR COALESCE(v->>'qty','') !~ '^[0-9]{1,2}$' OR (v->>'qty')::integer NOT BETWEEN 1 AND 99)
|
|
183
|
+
OR EXISTS(SELECT 1 FROM jsonb_array_elements(requested) v GROUP BY lower(v->>'variantId') HAVING count(*)>1) THEN
|
|
184
|
+
RAISE EXCEPTION 'subscription_edit_invalid_lines' USING ERRCODE='22023';
|
|
185
|
+
END IF;
|
|
186
|
+
-- Same catalogue price selection as the existing customer bundle preview.
|
|
187
|
+
SELECT jsonb_agg(jsonb_build_object('variantId',sku.id,'quantity',(v->>'qty')::integer,
|
|
188
|
+
'isAddon',ord>jsonb_array_length(core),'sortOrder',ord-1,'unitPriceMinor',price.unit_price_minor,'currency',s.currency_code)
|
|
189
|
+
ORDER BY ord),sum(price.unit_price_minor*(v->>'qty')::integer)
|
|
190
|
+
INTO lines,total FROM jsonb_array_elements(requested) WITH ORDINALITY x(v,ord)
|
|
191
|
+
JOIN public.catalog_skus sku ON sku.id=(v->>'variantId')::uuid AND sku.status='active' AND sku.sellable_in_subscription
|
|
192
|
+
LEFT JOIN LATERAL (SELECT unit_price_minor FROM public.price_entries pe WHERE pe.variant_id=sku.id
|
|
193
|
+
AND pe.price_list_id=s.price_list_id AND pe.mode IN ('subscription','any') AND pe.active
|
|
194
|
+
AND pe.min_qty<=(v->>'qty')::integer AND pe.valid_from<=p_now AND (pe.valid_to IS NULL OR pe.valid_to>p_now)
|
|
195
|
+
ORDER BY CASE pe.mode WHEN 'subscription' THEN 0 ELSE 1 END,pe.min_qty DESC,pe.valid_from DESC,pe.id LIMIT 1) price ON true;
|
|
196
|
+
IF lines IS NULL OR jsonb_array_length(lines)<>jsonb_array_length(requested)
|
|
197
|
+
OR EXISTS(SELECT 1 FROM jsonb_array_elements(lines) v WHERE v->'unitPriceMinor'='null') THEN
|
|
198
|
+
RAISE EXCEPTION 'subscription_edit_catalog_unavailable' USING ERRCODE='22023';
|
|
199
|
+
END IF;
|
|
200
|
+
quote_key:='command:'||encode(sha256(convert_to(p_actor_id::text||':'||p_key,'UTF8')),'hex');
|
|
201
|
+
quote_hash:=encode(sha256(convert_to(quote_key||':'||fingerprint||':'||lines::text||':'||(p_now+interval '15 minutes')::text,'UTF8')),'hex');
|
|
202
|
+
INSERT INTO public.subscription_edit_quotes(subscription_id,idempotency_key,request_fingerprint,quote_hash,
|
|
203
|
+
expected_template_version,proposed_lines,proposed_cadence_days,current_total_minor,new_total_minor,currency_code,expires_at)
|
|
204
|
+
VALUES(s.id,quote_key,fingerprint,quote_hash,version,lines,cadence,current_total,total,s.currency_code,p_now+interval '15 minutes') RETURNING * INTO q;
|
|
205
|
+
answer:=jsonb_build_object('status','previewed','canApply',true,'quoteHash',q.quote_hash,'quoteExpiresAt',q.expires_at,
|
|
206
|
+
'templateVersion',version,'lines',lines,'currentTotalMinor',current_total,'newTotalMinor',total,
|
|
207
|
+
'priceAgreementPolicy',jsonb_build_object('kind','lock_until_edit','proration','none','cycleEffect','future_unlocked_cycles_only'));
|
|
208
|
+
EXCEPTION WHEN SQLSTATE '22023' OR invalid_text_representation OR numeric_value_out_of_range THEN
|
|
209
|
+
GET STACKED DIAGNOSTICS reason=MESSAGE_TEXT;
|
|
210
|
+
answer:=jsonb_build_object('status','refused','canApply',false,'reason',reason);
|
|
211
|
+
END;
|
|
212
|
+
INSERT INTO public.subscription_command_receipts(actor_id,idempotency_key,subscription_id,client_id,command_fingerprint,command,quote_hash,result,status)
|
|
213
|
+
VALUES(p_actor_id,p_key,s.id,p_client_id,fingerprint,command,q.quote_hash,answer,answer->>'status');
|
|
214
|
+
RETURN answer;
|
|
215
|
+
END;
|
|
216
|
+
$$;
|
|
217
|
+
|
|
218
|
+
CREATE FUNCTION public.subscription_command_apply(
|
|
219
|
+
p_actor_id uuid,p_client_id uuid,p_key text,p_subscription_id uuid,p_action text,
|
|
220
|
+
p_payload jsonb,p_now timestamptz DEFAULT now()
|
|
221
|
+
) RETURNS jsonb LANGUAGE plpgsql SET search_path = pg_catalog AS $$
|
|
222
|
+
<<apply_command>>
|
|
223
|
+
DECLARE
|
|
224
|
+
s public.subscriptions%ROWTYPE; r public.subscription_command_receipts%ROWTYPE;
|
|
225
|
+
q public.subscription_edit_quotes%ROWTYPE; command jsonb; fingerprint text; quote_fingerprint text; answer jsonb;
|
|
226
|
+
reason text; event_id uuid; event_key text; line jsonb; target timestamptz; outcome text:='applied';
|
|
227
|
+
BEGIN
|
|
228
|
+
IF p_actor_id IS NULL OR p_client_id IS NULL OR p_now IS NULL OR NOT isfinite(p_now)
|
|
229
|
+
OR p_key IS NULL OR p_key<>btrim(p_key) OR char_length(p_key) NOT BETWEEN 8 AND 180
|
|
230
|
+
OR p_payload IS NULL OR jsonb_typeof(p_payload)<>'object' OR p_action IS NULL THEN
|
|
231
|
+
RAISE EXCEPTION 'subscription_command_invalid_input' USING ERRCODE='22023';
|
|
232
|
+
END IF;
|
|
233
|
+
command:=jsonb_build_object('clientId',p_client_id,'subscriptionId',p_subscription_id,'action',p_action,'payload',p_payload);
|
|
234
|
+
fingerprint:=encode(sha256(convert_to(command::text,'UTF8')),'hex');
|
|
235
|
+
quote_fingerprint:=encode(sha256(convert_to(jsonb_set(command,'{payload}',p_payload-'acceptedQuoteHash')::text,'UTF8')),'hex');
|
|
236
|
+
PERFORM pg_advisory_xact_lock(hashtextextended(p_actor_id::text||':'||p_key,0));
|
|
237
|
+
SELECT * INTO s FROM public.subscriptions WHERE id=p_subscription_id AND client_id=p_client_id FOR UPDATE;
|
|
238
|
+
IF NOT FOUND THEN RAISE EXCEPTION 'subscription_command_forbidden' USING ERRCODE='22023'; END IF;
|
|
239
|
+
SELECT * INTO r FROM public.subscription_command_receipts WHERE actor_id=p_actor_id AND idempotency_key=p_key FOR UPDATE;
|
|
240
|
+
IF FOUND THEN
|
|
241
|
+
IF r.command_fingerprint<>(CASE WHEN r.status='previewed' THEN quote_fingerprint ELSE fingerprint END)
|
|
242
|
+
OR r.quote_hash IS DISTINCT FROM (p_payload->>'acceptedQuoteHash') THEN
|
|
243
|
+
RETURN jsonb_build_object('status','refused','reason','subscription_command_idempotency_conflict');
|
|
244
|
+
END IF;
|
|
245
|
+
IF r.status<>'previewed' THEN
|
|
246
|
+
IF r.status='refused' THEN RETURN r.result; END IF;
|
|
247
|
+
RETURN r.result||jsonb_build_object('status','replayed','originalStatus',r.status);
|
|
248
|
+
END IF;
|
|
249
|
+
END IF;
|
|
250
|
+
event_key:='command:'||encode(sha256(convert_to(p_actor_id::text||':'||p_key,'UTF8')),'hex');
|
|
251
|
+
BEGIN
|
|
252
|
+
IF p_action<>'cancel' AND EXISTS(SELECT 1 FROM public.subscription_dunning_cases WHERE subscription_id=s.id AND status='open') THEN
|
|
253
|
+
RAISE EXCEPTION 'subscription_command_payment_blocked' USING ERRCODE='22023';
|
|
254
|
+
END IF;
|
|
255
|
+
-- Take the same subscription lock as claim first, then the future
|
|
256
|
+
-- terms and every line. A previously deferred operation keeps its snapshot.
|
|
257
|
+
PERFORM 1 FROM public.subscription_renewal_terms WHERE subscription_id=s.id FOR UPDATE;
|
|
258
|
+
PERFORM 1 FROM public.subscription_renewal_term_lines WHERE subscription_id=s.id ORDER BY line_ordinal FOR UPDATE;
|
|
259
|
+
-- Existing preparation owns the operation before it inserts a cycle. Do
|
|
260
|
+
-- not wait in the reverse lock order: refuse a busy operation, or a live
|
|
261
|
+
-- pre-payment lease whose worker could still prepare after this command.
|
|
262
|
+
IF p_action IN ('cancel','resume','skip_next_cycle','slide_next_cycle','order_now','reactivate')
|
|
263
|
+
AND NOT (p_action='slide_next_cycle' AND floor(extract(epoch FROM (p_payload->>'newNextCycleAt')::timestamptz)/86400)
|
|
264
|
+
=floor(extract(epoch FROM s.next_cycle_at)/86400)) THEN
|
|
265
|
+
BEGIN
|
|
266
|
+
PERFORM 1 FROM public.subscription_renewal_operations WHERE subscription_id=s.id ORDER BY id FOR UPDATE NOWAIT;
|
|
267
|
+
EXCEPTION WHEN lock_not_available THEN
|
|
268
|
+
RAISE EXCEPTION 'subscription_command_operation_busy' USING ERRCODE='22023';
|
|
269
|
+
END;
|
|
270
|
+
IF EXISTS(SELECT 1 FROM public.subscription_renewal_operations WHERE subscription_id=s.id
|
|
271
|
+
AND (state='attempt_prepared' OR (state IN ('claimed','reserved') AND lease_expires_at>p_now))) THEN
|
|
272
|
+
RAISE EXCEPTION 'subscription_command_payment_blocked' USING ERRCODE='22023';
|
|
273
|
+
END IF;
|
|
274
|
+
END IF;
|
|
275
|
+
IF p_action IN ('update_bundle','resize_bundle','update_package_template','update_plan_length','add_addon','remove_addon','update_addon_quantity') THEN
|
|
276
|
+
IF NOT EXISTS(SELECT 1 FROM public.subscription_method_states WHERE subscription_id=s.id AND health_state='healthy') THEN
|
|
277
|
+
RAISE EXCEPTION 'subscription_command_payment_blocked' USING ERRCODE='22023';
|
|
278
|
+
END IF;
|
|
279
|
+
SELECT * INTO q FROM public.subscription_edit_quotes WHERE subscription_id=s.id AND idempotency_key=event_key FOR UPDATE;
|
|
280
|
+
IF NOT FOUND OR q.quote_hash IS DISTINCT FROM (p_payload->>'acceptedQuoteHash') THEN
|
|
281
|
+
RAISE EXCEPTION 'subscription_edit_quote_required' USING ERRCODE='22023';
|
|
282
|
+
END IF;
|
|
283
|
+
IF q.request_fingerprint<>quote_fingerprint OR s.template_version<>q.expected_template_version THEN
|
|
284
|
+
RAISE EXCEPTION 'subscription_edit_stale_template' USING ERRCODE='22023';
|
|
285
|
+
END IF;
|
|
286
|
+
IF q.status<>'previewed' OR q.expires_at<=p_now THEN RAISE EXCEPTION 'subscription_edit_quote_expired' USING ERRCODE='22023'; END IF;
|
|
287
|
+
IF s.status<>'active' OR s.next_cycle_at IS NULL OR p_now>=s.next_cycle_at-make_interval(hours=>s.edit_window_hours) THEN
|
|
288
|
+
RAISE EXCEPTION 'subscription_edit_window_closed' USING ERRCODE='22023';
|
|
289
|
+
END IF;
|
|
290
|
+
PERFORM public.subscription_lifecycle_assert_unlocked_cycle(s.id,s.next_cycle_at);
|
|
291
|
+
IF NOT EXISTS(SELECT 1 FROM public.subscription_renewal_terms WHERE subscription_id=s.id AND currency_code=q.currency_code) THEN
|
|
292
|
+
RAISE EXCEPTION 'subscription_command_renewal_terms_missing' USING ERRCODE='22023';
|
|
293
|
+
END IF;
|
|
294
|
+
IF (SELECT jsonb_agg(jsonb_build_object('lineOrdinal',(v->>'sortOrder')::integer+1,'sku',sku.sku,
|
|
295
|
+
'quantity',(v->>'quantity')::integer,'unitAmountMinor',(v->>'unitPriceMinor')::bigint) ORDER BY (v->>'sortOrder')::integer)
|
|
296
|
+
FROM jsonb_array_elements(q.proposed_lines) v JOIN public.catalog_skus sku ON sku.id=(v->>'variantId')::uuid)
|
|
297
|
+
=(SELECT jsonb_agg(jsonb_build_object('lineOrdinal',line_ordinal,'sku',sku,'quantity',quantity,'unitAmountMinor',unit_amount_minor)
|
|
298
|
+
ORDER BY line_ordinal) FROM public.subscription_renewal_term_lines WHERE subscription_id=s.id)
|
|
299
|
+
AND s.cadence_days=q.proposed_cadence_days AND q.proposed_lines=(SELECT COALESCE(jsonb_agg(jsonb_build_object(
|
|
300
|
+
'variantId',variant_id,'quantity',quantity,'isAddon',is_addon,'sortOrder',sort_order,'unitPriceMinor',unit_price_minor,'currency',currency_code)
|
|
301
|
+
ORDER BY sort_order,id),'[]') FROM public.subscription_lines WHERE subscription_id=s.id) THEN
|
|
302
|
+
outcome:='noop';
|
|
303
|
+
ELSE
|
|
304
|
+
DELETE FROM public.subscription_lines WHERE subscription_id=s.id;
|
|
305
|
+
FOR line IN SELECT value FROM jsonb_array_elements(q.proposed_lines) LOOP
|
|
306
|
+
INSERT INTO public.subscription_lines(subscription_id,variant_id,quantity,sort_order,is_addon,unit_price_minor,currency_code)
|
|
307
|
+
VALUES(s.id,(line->>'variantId')::uuid,(line->>'quantity')::integer,(line->>'sortOrder')::integer,
|
|
308
|
+
(line->>'isAddon')::boolean,(line->>'unitPriceMinor')::bigint,q.currency_code);
|
|
309
|
+
END LOOP;
|
|
310
|
+
DELETE FROM public.subscription_renewal_term_lines WHERE subscription_id=s.id;
|
|
311
|
+
INSERT INTO public.subscription_renewal_term_lines(subscription_id,line_ordinal,sku,quantity,unit_amount_minor)
|
|
312
|
+
SELECT s.id,(v->>'sortOrder')::integer+1,sku.sku,(v->>'quantity')::integer,(v->>'unitPriceMinor')::bigint
|
|
313
|
+
FROM jsonb_array_elements(q.proposed_lines) v JOIN public.catalog_skus sku ON sku.id=(v->>'variantId')::uuid;
|
|
314
|
+
UPDATE public.subscription_renewal_terms SET updated_at=p_now WHERE subscription_id=s.id;
|
|
315
|
+
UPDATE public.subscriptions SET cadence_days=q.proposed_cadence_days,template_version=template_version+1,updated_at=p_now
|
|
316
|
+
WHERE id=s.id RETURNING * INTO s;
|
|
317
|
+
END IF;
|
|
318
|
+
ELSIF p_action IN ('order_now','reactivate') THEN
|
|
319
|
+
IF p_payload->'confirmedChargeTiming' IS DISTINCT FROM 'true'::jsonb THEN
|
|
320
|
+
RAISE EXCEPTION 'subscription_command_charge_timing_not_confirmed' USING ERRCODE='22023';
|
|
321
|
+
END IF;
|
|
322
|
+
IF NOT EXISTS(SELECT 1 FROM public.subscription_method_states WHERE subscription_id=s.id AND health_state='healthy')
|
|
323
|
+
OR NOT EXISTS(SELECT 1 FROM public.subscription_renewal_terms WHERE subscription_id=s.id) THEN
|
|
324
|
+
RAISE EXCEPTION 'subscription_command_payment_blocked' USING ERRCODE='22023';
|
|
325
|
+
END IF;
|
|
326
|
+
IF (p_action='order_now' AND (s.status<>'active' OR s.next_cycle_at IS NULL))
|
|
327
|
+
OR (p_action='reactivate' AND s.status<>'cancelled') THEN
|
|
328
|
+
RAISE EXCEPTION 'subscription_lifecycle_invalid_transition' USING ERRCODE='22023';
|
|
329
|
+
END IF;
|
|
330
|
+
PERFORM public.subscription_lifecycle_assert_unlocked_cycle(s.id,s.next_cycle_at);
|
|
331
|
+
target:=p_now+CASE p_action WHEN 'order_now' THEN interval '5 minutes' ELSE interval '2 days' END;
|
|
332
|
+
UPDATE public.subscriptions SET status='active',next_cycle_at=target,ended_at=NULL,cancellation_reason=NULL,
|
|
333
|
+
template_version=template_version+CASE WHEN p_action='reactivate' THEN 1 ELSE 0 END,updated_at=p_now WHERE id=s.id RETURNING * INTO s;
|
|
334
|
+
ELSIF p_action IN ('pause','resume','skip_next_cycle','slide_next_cycle','cancel') THEN
|
|
335
|
+
IF p_action='cancel' THEN
|
|
336
|
+
IF EXISTS(SELECT 1 FROM public.subscription_renewal_operations WHERE subscription_id=s.id
|
|
337
|
+
AND attempt_prepared_at IS NOT NULL AND state NOT IN ('succeeded','refused'))
|
|
338
|
+
OR EXISTS(SELECT 1 FROM public.subscription_retry_obligations WHERE subscription_id=s.id AND state='prepared') THEN
|
|
339
|
+
RAISE EXCEPTION 'subscription_command_payment_blocked' USING ERRCODE='22023';
|
|
340
|
+
END IF;
|
|
341
|
+
UPDATE public.subscription_dunning_notifications SET status='skipped',recovery_url_path=NULL,
|
|
342
|
+
error='subscription_cancelled',updated_at=p_now WHERE status='queued' AND case_id IN
|
|
343
|
+
(SELECT id FROM public.subscription_dunning_cases WHERE subscription_id=s.id AND status='open');
|
|
344
|
+
UPDATE public.commerce_checkout_recovery_tokens SET revoked_at=p_now WHERE revoked_at IS NULL AND order_id IN
|
|
345
|
+
(SELECT orders.id FROM public.commerce_orders orders JOIN public.subscription_dunning_cases cases
|
|
346
|
+
ON cases.cycle_id=orders.subscription_cycle_id WHERE cases.subscription_id=s.id AND cases.status='open');
|
|
347
|
+
UPDATE public.subscription_dunning_cases SET status='cancelled',next_retry_at=NULL,updated_at=p_now
|
|
348
|
+
WHERE subscription_id=s.id AND status='open';
|
|
349
|
+
END IF;
|
|
350
|
+
IF p_action IN ('skip_next_cycle','slide_next_cycle') AND NOT EXISTS(
|
|
351
|
+
SELECT 1 FROM public.subscription_method_states WHERE subscription_id=s.id AND health_state='healthy') THEN
|
|
352
|
+
RAISE EXCEPTION 'subscription_command_payment_blocked' USING ERRCODE='22023';
|
|
353
|
+
END IF;
|
|
354
|
+
-- Do not move away from a current payment obligation on resume.
|
|
355
|
+
IF p_action='resume' THEN PERFORM public.subscription_lifecycle_assert_unlocked_cycle(s.id,s.next_cycle_at); END IF;
|
|
356
|
+
IF p_action='cancel' THEN
|
|
357
|
+
-- Operation locks above fence every worker; only elapsed, never-prepared
|
|
358
|
+
-- obligations may be cancelled here. Paid/ambiguous commitments stay owned.
|
|
359
|
+
UPDATE public.fulfillment_inventory_reservations stock_hold SET status='released',expires_at=NULL,
|
|
360
|
+
terminal_reason='subscription_cancelled',updated_at=p_now
|
|
361
|
+
FROM public.subscription_renewal_operations op WHERE op.subscription_id=s.id
|
|
362
|
+
AND stock_hold.renewal_operation_id=op.id AND stock_hold.status='held'
|
|
363
|
+
AND op.state IN ('claimed','reserved') AND op.attempt_prepared_at IS NULL
|
|
364
|
+
AND op.external_attempt_ref IS NULL;
|
|
365
|
+
UPDATE public.subscription_renewal_operations SET state='refused',terminal_outcome='refused',
|
|
366
|
+
terminal_reason='subscription_cancelled',terminal_at=p_now,updated_at=p_now
|
|
367
|
+
WHERE subscription_id=s.id AND state IN ('claimed','reserved') AND attempt_prepared_at IS NULL
|
|
368
|
+
AND external_attempt_ref IS NULL;
|
|
369
|
+
END IF;
|
|
370
|
+
answer:=public.subscription_apply_lifecycle_action(p_client_id,event_key,s.id,p_action,p_payload,p_now);
|
|
371
|
+
outcome:=answer#>>'{subscriptionAction,status}';
|
|
372
|
+
event_id:=(answer#>>'{subscriptionAction,eventId}')::uuid;
|
|
373
|
+
SELECT * INTO s FROM public.subscriptions WHERE id=s.id;
|
|
374
|
+
ELSE
|
|
375
|
+
RAISE EXCEPTION 'subscription_command_unsupported_action' USING ERRCODE='22023';
|
|
376
|
+
END IF;
|
|
377
|
+
IF answer IS NULL AND outcome='applied' THEN
|
|
378
|
+
INSERT INTO public.subscription_events(subscription_id,event_type,idempotency_key,payload,occurred_at)
|
|
379
|
+
VALUES(s.id,'subscription.customer_self_service.'||p_action,event_key,jsonb_build_object('action',p_action,'payload',p_payload,
|
|
380
|
+
'priceAgreementPolicy',jsonb_build_object('kind','lock_until_edit','proration','none','cycleEffect','future_unlocked_cycles_only')),p_now)
|
|
381
|
+
RETURNING id INTO event_id;
|
|
382
|
+
END IF;
|
|
383
|
+
IF q.id IS NOT NULL AND outcome='applied' THEN
|
|
384
|
+
UPDATE public.subscription_edit_quotes SET status='accepted',accepted_event_id=event_id,accepted_at=p_now WHERE id=q.id;
|
|
385
|
+
END IF;
|
|
386
|
+
answer:=jsonb_build_object('status',outcome,'subscriptionId',s.id,'action',p_action,'subscriptionStatus',s.status,
|
|
387
|
+
'nextCycleAt',s.next_cycle_at,'templateVersion',s.template_version,'eventId',event_id);
|
|
388
|
+
EXCEPTION WHEN SQLSTATE '22023' OR invalid_text_representation OR numeric_value_out_of_range THEN
|
|
389
|
+
GET STACKED DIAGNOSTICS reason=MESSAGE_TEXT;
|
|
390
|
+
answer:=jsonb_build_object('status','refused','reason',reason);
|
|
391
|
+
END;
|
|
392
|
+
IF r.id IS NULL THEN
|
|
393
|
+
INSERT INTO public.subscription_command_receipts(actor_id,idempotency_key,subscription_id,client_id,command_fingerprint,command,quote_hash,result,status)
|
|
394
|
+
VALUES(p_actor_id,p_key,s.id,p_client_id,fingerprint,command,p_payload->>'acceptedQuoteHash',answer,answer->>'status');
|
|
395
|
+
ELSE
|
|
396
|
+
UPDATE public.subscription_command_receipts SET command_fingerprint=fingerprint,command=apply_command.command,result=answer,status=answer->>'status'
|
|
397
|
+
WHERE id=r.id;
|
|
398
|
+
END IF;
|
|
399
|
+
RETURN answer;
|
|
400
|
+
END;
|
|
401
|
+
$$;
|
|
402
|
+
REVOKE ALL ON public.subscription_command_receipts FROM PUBLIC,anon,authenticated;
|
|
403
|
+
REVOKE ALL ON FUNCTION public.subscription_command_preview(uuid,uuid,text,uuid,text,jsonb,timestamptz) FROM PUBLIC,anon,authenticated;
|
|
404
|
+
REVOKE ALL ON FUNCTION public.subscription_command_apply(uuid,uuid,text,uuid,text,jsonb,timestamptz) FROM PUBLIC,anon,authenticated;
|