@openlup/platform 0.23.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/CHANGELOG.md +327 -269
  2. package/README.md +123 -12
  3. package/dist/communications/contracts/subscriptionPaymentExpired.d.ts +3 -2
  4. package/dist/communications/contracts/subscriptionPaymentExpired.js +1 -1
  5. package/dist/communications/contracts/subscriptionPaymentFailed.d.ts +4 -3
  6. package/dist/communications/contracts/subscriptionPaymentFailed.js +4 -5
  7. package/dist/composition/index.d.ts +1 -0
  8. package/dist/composition/index.js +1 -0
  9. package/dist/composition/subscriptionContribution.d.ts +18 -0
  10. package/dist/composition/subscriptionContribution.js +19 -0
  11. package/dist/fulfillment/contracts/dispatch.d.ts +4 -0
  12. package/dist/fulfillment/dispatchProjection.d.ts +1 -1
  13. package/dist/fulfillment/dispatchProjection.js +4 -2
  14. package/dist/fulfillment/omnipack/client.js +14 -6
  15. package/dist/fulfillment/omnipack/index.js +5 -2
  16. package/dist/fulfillment/providerDispatchWorker.d.ts +38 -0
  17. package/dist/fulfillment/providerDispatchWorker.js +19 -1
  18. package/dist/inventory/index.d.ts +2 -0
  19. package/dist/inventory/index.js +1 -0
  20. package/dist/inventory/postgresStockAvailability.d.ts +24 -0
  21. package/dist/inventory/postgresStockAvailability.js +48 -0
  22. package/dist/invoicing/accountingCanonicalProviderLines.d.ts +5 -0
  23. package/dist/invoicing/accountingCanonicalProviderLines.js +104 -0
  24. package/dist/invoicing/accountingLineAllocation.d.ts +2 -0
  25. package/dist/invoicing/accountingLineAllocation.js +69 -0
  26. package/dist/invoicing/adapterConformance.d.ts +42 -0
  27. package/dist/invoicing/adapterConformance.js +296 -0
  28. package/dist/invoicing/artifacts.d.ts +7 -0
  29. package/dist/invoicing/artifacts.js +20 -0
  30. package/dist/invoicing/bindings.d.ts +46 -0
  31. package/dist/invoicing/bindings.js +69 -0
  32. package/dist/invoicing/channelPolicy.d.ts +28 -0
  33. package/dist/invoicing/channelPolicy.js +27 -0
  34. package/dist/invoicing/continuity.d.ts +12 -0
  35. package/dist/invoicing/continuity.js +18 -0
  36. package/dist/invoicing/contracts/facts.d.ts +11 -1
  37. package/dist/invoicing/contracts/host.d.ts +3 -4
  38. package/dist/invoicing/contracts/index.d.ts +1 -1
  39. package/dist/invoicing/contracts/store.d.ts +15 -5
  40. package/dist/invoicing/contribution.d.ts +21 -0
  41. package/dist/invoicing/contribution.js +19 -0
  42. package/dist/invoicing/correction.d.ts +42 -0
  43. package/dist/invoicing/correction.js +44 -0
  44. package/dist/invoicing/delivery.d.ts +85 -0
  45. package/dist/invoicing/delivery.js +79 -0
  46. package/dist/invoicing/documentHistory.d.ts +50 -0
  47. package/dist/invoicing/documentHistory.js +213 -0
  48. package/dist/invoicing/documentSync.d.ts +24 -0
  49. package/dist/invoicing/documentSync.js +26 -0
  50. package/dist/invoicing/dutyCollector.d.ts +15 -0
  51. package/dist/invoicing/dutyCollector.js +35 -0
  52. package/dist/invoicing/fulfillmentTrigger.d.ts +56 -0
  53. package/dist/invoicing/fulfillmentTrigger.js +90 -0
  54. package/dist/invoicing/handoff.d.ts +37 -0
  55. package/dist/invoicing/handoff.js +62 -0
  56. package/dist/invoicing/index.d.ts +38 -0
  57. package/dist/invoicing/index.js +36 -0
  58. package/dist/invoicing/issue.d.ts +39 -0
  59. package/dist/invoicing/issue.js +55 -0
  60. package/dist/invoicing/migrations.d.ts +48 -0
  61. package/dist/invoicing/migrations.js +25 -0
  62. package/dist/invoicing/nativeUnsupported.d.ts +9 -0
  63. package/dist/invoicing/nativeUnsupported.js +22 -0
  64. package/dist/invoicing/paymentReadback.d.ts +20 -0
  65. package/dist/invoicing/paymentReadback.js +47 -0
  66. package/dist/invoicing/postgresInvoiceStore.d.ts +55 -0
  67. package/dist/invoicing/postgresInvoiceStore.js +344 -0
  68. package/dist/invoicing/readModel.d.ts +74 -0
  69. package/dist/invoicing/readModel.js +90 -0
  70. package/dist/invoicing/readback.d.ts +4 -0
  71. package/dist/invoicing/readback.js +30 -0
  72. package/dist/invoicing/reversal.d.ts +37 -0
  73. package/dist/invoicing/reversal.js +28 -0
  74. package/dist/invoicing/sellerRevision.d.ts +19 -0
  75. package/dist/invoicing/sellerRevision.js +14 -0
  76. package/dist/invoicing/settlementSync.d.ts +18 -0
  77. package/dist/invoicing/settlementSync.js +32 -0
  78. package/dist/invoicing/snapshot.d.ts +52 -0
  79. package/dist/invoicing/snapshot.js +141 -0
  80. package/dist/subscriptions/commandExamples.d.ts +6 -0
  81. package/dist/subscriptions/commandExamples.js +20 -0
  82. package/dist/subscriptions/contracts/commands.d.ts +8 -1
  83. package/dist/subscriptions/contracts/index.d.ts +2 -2
  84. package/dist/subscriptions/contracts/stockWait.d.ts +12 -1
  85. package/dist/subscriptions/index.d.ts +22 -4
  86. package/dist/subscriptions/index.js +22 -3
  87. package/dist/subscriptions/lifecycleMessages.d.ts +9 -0
  88. package/dist/subscriptions/lifecycleMessages.js +38 -0
  89. package/dist/subscriptions/paymentOutcomes.js +1 -1
  90. package/dist/subscriptions/postgresCommandStore.d.ts +44 -0
  91. package/dist/subscriptions/postgresCommandStore.js +172 -0
  92. package/dist/subscriptions/postgresRetryStore.d.ts +6 -0
  93. package/dist/subscriptions/postgresRetryStore.js +11 -0
  94. package/dist/subscriptions/postgresStockWaitStore.d.ts +62 -0
  95. package/dist/subscriptions/postgresStockWaitStore.js +174 -0
  96. package/dist/subscriptions/services.d.ts +31 -0
  97. package/dist/subscriptions/services.js +15 -0
  98. package/package.json +3 -3
  99. package/release-gates.json +21 -7
  100. package/sql/20260809210000_accounting_document_rail.sql +889 -0
  101. package/sql/20260811150000_accounting_payout_rail.sql +333 -0
  102. package/sql/20260812130000_accounting_document_lifecycle_rail.sql +460 -0
  103. package/sql/20261022190000_invoicing_durable_duties.sql +494 -0
  104. package/sql/20261022191000_invoicing_document_effects.sql +249 -0
  105. package/sql/20261022192000_invoicing_payment_decoupling.sql +144 -0
  106. package/sql/20261022195000_invoicing_seller_revision.sql +65 -0
  107. package/sql/20261024170000_subscription_command_lifecycle.sql +404 -0
  108. package/sql/20261024180000_subscription_stock_wait.sql +330 -0
  109. package/sql/20261024183000_inventory_available_to_promise.sql +70 -0
  110. package/sql/20261024184000_subscription_renewal_reserve_uses_seam.sql +147 -0
  111. package/sql/20261024184500_subscription_retry_late_positive_stock.sql +59 -0
  112. package/sql/20261024184600_subscription_retry_candidate_payment_filter.sql +22 -0
  113. package/src/communications/contracts/subscriptionPaymentExpired.ts +4 -3
  114. package/src/communications/contracts/subscriptionPaymentFailed.ts +8 -8
  115. package/src/composition/index.ts +1 -0
  116. package/src/composition/subscriptionContribution.ts +25 -0
  117. package/src/fulfillment/contracts/dispatch.ts +4 -0
  118. package/src/fulfillment/dispatchProjection.ts +4 -2
  119. package/src/fulfillment/omnipack/client.ts +13 -6
  120. package/src/fulfillment/omnipack/index.ts +5 -2
  121. package/src/fulfillment/providerDispatchWorker.ts +14 -1
  122. package/src/inventory/index.ts +2 -0
  123. package/src/inventory/postgresStockAvailability.ts +54 -0
  124. package/src/invoicing/accountingCanonicalProviderLines.ts +134 -0
  125. package/src/invoicing/accountingLineAllocation.ts +69 -0
  126. package/src/invoicing/adapterConformance.ts +249 -0
  127. package/src/invoicing/artifacts.ts +19 -0
  128. package/src/invoicing/bindings.ts +80 -0
  129. package/src/invoicing/channelPolicy.ts +50 -0
  130. package/src/invoicing/continuity.ts +15 -0
  131. package/src/invoicing/contracts/facts.ts +12 -1
  132. package/src/invoicing/contracts/host.ts +3 -4
  133. package/src/invoicing/contracts/index.ts +1 -1
  134. package/src/invoicing/contracts/store.ts +11 -7
  135. package/src/invoicing/contribution.ts +28 -0
  136. package/src/invoicing/correction.ts +56 -0
  137. package/src/invoicing/delivery.ts +122 -0
  138. package/src/invoicing/documentHistory.ts +301 -0
  139. package/src/invoicing/documentSync.ts +51 -0
  140. package/src/invoicing/dutyCollector.ts +31 -0
  141. package/src/invoicing/fulfillmentTrigger.ts +144 -0
  142. package/src/invoicing/handoff.ts +71 -0
  143. package/src/invoicing/index.ts +38 -0
  144. package/src/invoicing/issue.ts +64 -0
  145. package/src/invoicing/migrations.ts +25 -0
  146. package/src/invoicing/nativeUnsupported.ts +24 -0
  147. package/src/invoicing/paymentReadback.ts +70 -0
  148. package/src/invoicing/postgresInvoiceStore.ts +334 -0
  149. package/src/invoicing/readModel.ts +80 -0
  150. package/src/invoicing/readback.ts +26 -0
  151. package/src/invoicing/reversal.ts +34 -0
  152. package/src/invoicing/sellerRevision.ts +20 -0
  153. package/src/invoicing/settlementSync.ts +37 -0
  154. package/src/invoicing/snapshot.ts +192 -0
  155. package/src/subscriptions/commandExamples.ts +19 -0
  156. package/src/subscriptions/contracts/commands.ts +35 -2
  157. package/src/subscriptions/contracts/index.ts +2 -2
  158. package/src/subscriptions/contracts/stockWait.ts +14 -2
  159. package/src/subscriptions/index.ts +30 -3
  160. package/src/subscriptions/lifecycleMessages.ts +37 -0
  161. package/src/subscriptions/paymentOutcomes.ts +1 -1
  162. package/src/subscriptions/postgresCommandStore.ts +183 -0
  163. package/src/subscriptions/postgresRetryStore.ts +11 -0
  164. package/src/subscriptions/postgresStockWaitStore.ts +170 -0
  165. 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;