@openlup/platform 0.27.0 → 0.29.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 (232) hide show
  1. package/CHANGELOG.md +386 -127
  2. package/README.md +251 -9
  3. package/dist/account-support/accountReadFacade.d.ts +8 -0
  4. package/dist/account-support/accountReadFacade.js +10 -0
  5. package/dist/account-support/accountReadModels.d.ts +69 -0
  6. package/dist/account-support/accountReadModels.js +272 -0
  7. package/dist/account-support/bindings.d.ts +23 -0
  8. package/dist/account-support/bindings.js +16 -0
  9. package/dist/account-support/contracts/account.d.ts +281 -0
  10. package/dist/account-support/contracts/account.js +1 -0
  11. package/dist/account-support/contracts/capabilities.d.ts +44 -0
  12. package/dist/account-support/contracts/capabilities.js +1 -0
  13. package/dist/account-support/contracts/commands.d.ts +307 -0
  14. package/dist/account-support/contracts/commands.js +1 -0
  15. package/dist/account-support/contracts/customer360.d.ts +154 -0
  16. package/dist/account-support/contracts/customer360.js +1 -0
  17. package/dist/account-support/contracts/index.d.ts +10 -0
  18. package/dist/account-support/contracts/index.js +1 -0
  19. package/dist/account-support/contracts/invoice.d.ts +79 -0
  20. package/dist/account-support/contracts/invoice.js +1 -0
  21. package/dist/account-support/contracts/operator.d.ts +289 -0
  22. package/dist/account-support/contracts/operator.js +1 -0
  23. package/dist/account-support/contribution.d.ts +24 -0
  24. package/dist/account-support/contribution.js +22 -0
  25. package/dist/account-support/customer360.d.ts +106 -0
  26. package/dist/account-support/customer360.js +163 -0
  27. package/dist/account-support/customerAccountReadDiagnostics.d.ts +11 -0
  28. package/dist/account-support/customerAccountReadDiagnostics.js +71 -0
  29. package/dist/account-support/index.d.ts +26 -0
  30. package/dist/account-support/index.js +15 -0
  31. package/dist/account-support/invoiceAccess.d.ts +57 -0
  32. package/dist/account-support/invoiceAccess.js +40 -0
  33. package/dist/account-support/migrations.d.ts +14 -0
  34. package/dist/account-support/migrations.js +7 -0
  35. package/dist/account-support/operatorAuthority.d.ts +67 -0
  36. package/dist/account-support/operatorAuthority.js +379 -0
  37. package/dist/account-support/operatorRecovery.d.ts +66 -0
  38. package/dist/account-support/operatorRecovery.js +129 -0
  39. package/dist/account-support/orderHistory.d.ts +50 -0
  40. package/dist/account-support/orderHistory.js +188 -0
  41. package/dist/account-support/paymentMethodComposition.d.ts +5 -0
  42. package/dist/account-support/paymentMethodComposition.js +4 -0
  43. package/dist/account-support/postgresOperatorAuthority.d.ts +16 -0
  44. package/dist/account-support/postgresOperatorAuthority.js +93 -0
  45. package/dist/account-support/selfService.d.ts +6 -0
  46. package/dist/account-support/selfService.js +81 -0
  47. package/dist/account-support/subscriptionCommandCodec.d.ts +10 -0
  48. package/dist/account-support/subscriptionCommandCodec.js +38 -0
  49. package/dist/catalog/draftSchemas.d.ts +1 -1
  50. package/dist/catalog/foundationSchemas.d.ts +3 -3
  51. package/dist/catalog/resolvedSkuProjection.d.ts +2 -2
  52. package/dist/catalog/sellableSchemas.d.ts +2 -2
  53. package/dist/commerce/checkoutCommandSchemas.d.ts +2 -2
  54. package/dist/commerce/checkoutResumeSchemas.d.ts +6 -6
  55. package/dist/commerce/checkoutSchemas.d.ts +12 -12
  56. package/dist/commerce/commerceReturnsPort.d.ts +2 -2
  57. package/dist/commerce/configuratorIntentSchemas.d.ts +2 -2
  58. package/dist/commerce/contractPrimitives.d.ts +8 -8
  59. package/dist/commerce/customerDefaultsSnapshotSchemas.d.ts +4 -4
  60. package/dist/commerce/model/omsReadModelHelpers.d.ts +4 -4
  61. package/dist/commerce/offerAvailabilitySchemas.d.ts +1 -1
  62. package/dist/commerce/oms/omsContractBase.d.ts +15 -15
  63. package/dist/commerce/oms/omsContractReadAuxSchemas.d.ts +11 -11
  64. package/dist/commerce/oms/omsContractReadSchemas.d.ts +121 -121
  65. package/dist/commerce/oms/omsContractRequests.d.ts +13 -13
  66. package/dist/commerce/oms/omsControlPlane.d.ts +22 -22
  67. package/dist/commerce/oms/omsFulfillmentContractSchemas.d.ts +12 -12
  68. package/dist/commerce/oms/omsFulfillmentDebugSchemas.d.ts +12 -12
  69. package/dist/commerce/orderReviewContracts.d.ts +33 -33
  70. package/dist/commerce/outboxEventSchemas.d.ts +4 -4
  71. package/dist/commerce/schemas.d.ts +21 -21
  72. package/dist/commerce/server/orderReviewHandlers.d.ts +10 -10
  73. package/dist/communications/consentPurposeRegistry.d.ts +1 -1
  74. package/dist/communications/newsletterIntegrationContracts.d.ts +8 -8
  75. package/dist/composition/accountSupportContribution.d.ts +18 -0
  76. package/dist/composition/accountSupportContribution.js +21 -0
  77. package/dist/composition/index.d.ts +2 -0
  78. package/dist/composition/index.js +2 -0
  79. package/dist/composition/marketingContribution.d.ts +21 -0
  80. package/dist/composition/marketingContribution.js +29 -0
  81. package/dist/fulfillment/handlerSchemas.d.ts +40 -40
  82. package/dist/identity/customerContracts.d.ts +2 -2
  83. package/dist/invoicing/snapshot.d.ts +1 -1
  84. package/dist/marketing/acquisitionCaseSchemas.d.ts +103 -0
  85. package/dist/marketing/acquisitionCaseSchemas.js +53 -0
  86. package/dist/marketing/acquisitionEvidenceSchemas.d.ts +105 -0
  87. package/dist/marketing/acquisitionEvidenceSchemas.js +55 -0
  88. package/dist/marketing/analytics/dataLayer.d.ts +18 -0
  89. package/dist/marketing/analytics/dataLayer.js +111 -0
  90. package/dist/marketing/analytics/noop.d.ts +9 -0
  91. package/dist/marketing/analytics/noop.js +8 -0
  92. package/dist/marketing/analytics/purchaseFacts.d.ts +4 -0
  93. package/dist/marketing/analytics/purchaseFacts.js +69 -0
  94. package/dist/marketing/analytics/routePolicy.d.ts +10 -0
  95. package/dist/marketing/analytics/routePolicy.js +59 -0
  96. package/dist/marketing/consentEvidence.d.ts +8 -0
  97. package/dist/marketing/consentEvidence.js +32 -0
  98. package/dist/marketing/contactLegal.d.ts +14 -0
  99. package/dist/marketing/contactLegal.js +19 -0
  100. package/dist/marketing/contracts/acquisition.d.ts +88 -0
  101. package/dist/marketing/contracts/acquisition.js +1 -0
  102. package/dist/marketing/contracts/acquisitionPorts.d.ts +49 -0
  103. package/dist/marketing/contracts/acquisitionPorts.js +1 -0
  104. package/dist/marketing/contracts/analytics.d.ts +144 -0
  105. package/dist/marketing/contracts/analytics.js +1 -0
  106. package/dist/marketing/contracts/contactLegal.d.ts +53 -0
  107. package/dist/marketing/contracts/contactLegal.js +1 -0
  108. package/dist/marketing/contracts/index.d.ts +6 -0
  109. package/dist/marketing/contracts/index.js +1 -0
  110. package/dist/marketing/contracts/prelaunch.d.ts +39 -0
  111. package/dist/marketing/contracts/prelaunch.js +1 -0
  112. package/dist/marketing/index.d.ts +16 -0
  113. package/dist/marketing/index.js +14 -0
  114. package/dist/marketing/prelaunchAcquisitionSchemas.d.ts +116 -0
  115. package/dist/marketing/prelaunchAcquisitionSchemas.js +51 -0
  116. package/dist/marketing/seo/jsonLd.d.ts +157 -0
  117. package/dist/marketing/seo/jsonLd.js +127 -0
  118. package/dist/marketing/seo/metadata.d.ts +41 -0
  119. package/dist/marketing/seo/metadata.js +36 -0
  120. package/dist/marketing/seo/serializeJsonLd.d.ts +1 -0
  121. package/dist/marketing/seo/serializeJsonLd.js +6 -0
  122. package/dist/marketing/seo/urls.d.ts +14 -0
  123. package/dist/marketing/seo/urls.js +23 -0
  124. package/dist/marketing/server/acquisitionCaseDirect.d.ts +4 -0
  125. package/dist/marketing/server/acquisitionCaseDirect.js +88 -0
  126. package/dist/marketing/server/acquisitionCaseHandlers.d.ts +28 -0
  127. package/dist/marketing/server/acquisitionCaseHandlers.js +40 -0
  128. package/dist/marketing/server/acquisitionCasePorts.d.ts +42 -0
  129. package/dist/marketing/server/acquisitionCasePorts.js +1 -0
  130. package/dist/marketing/server/acquisitionEvidenceDirect.d.ts +5 -0
  131. package/dist/marketing/server/acquisitionEvidenceDirect.js +100 -0
  132. package/dist/marketing/server/analyticsContext.d.ts +3 -0
  133. package/dist/marketing/server/analyticsContext.js +65 -0
  134. package/dist/marketing/server/analyticsContextPorts.d.ts +31 -0
  135. package/dist/marketing/server/analyticsContextPorts.js +1 -0
  136. package/dist/marketing/server/bindings.d.ts +14 -0
  137. package/dist/marketing/server/bindings.js +18 -0
  138. package/dist/marketing/server/directPorts.d.ts +26 -0
  139. package/dist/marketing/server/directPorts.js +1 -0
  140. package/dist/marketing/server/index.d.ts +14 -0
  141. package/dist/marketing/server/index.js +11 -0
  142. package/dist/marketing/server/postgres/acquisitionCaseRoutines.d.ts +10 -0
  143. package/dist/marketing/server/postgres/acquisitionCaseRoutines.js +204 -0
  144. package/dist/marketing/server/postgres/acquisitionEvidenceRoutines.d.ts +10 -0
  145. package/dist/marketing/server/postgres/acquisitionEvidenceRoutines.js +107 -0
  146. package/dist/marketing/server/postgres/index.d.ts +3 -0
  147. package/dist/marketing/server/postgres/index.js +3 -0
  148. package/dist/marketing/server/prelaunchAcquisitionDirect.d.ts +4 -0
  149. package/dist/marketing/server/prelaunchAcquisitionDirect.js +122 -0
  150. package/dist/marketing/server/privacyContributor.d.ts +66 -0
  151. package/dist/marketing/server/privacyContributor.js +87 -0
  152. package/dist/marketing/server/research/adminSurveyResponsesHandler.d.ts +9 -0
  153. package/dist/marketing/server/research/adminSurveyResponsesHandler.js +53 -0
  154. package/dist/marketing/server/research/managedSurveyResponseSubmit.d.ts +39 -0
  155. package/dist/marketing/server/research/managedSurveyResponseSubmit.js +102 -0
  156. package/dist/marketing/server/research/surveyResponseSubmitHandler.d.ts +8 -0
  157. package/dist/marketing/server/research/surveyResponseSubmitHandler.js +41 -0
  158. package/dist/marketing/validation.d.ts +2 -0
  159. package/dist/marketing/validation.js +3 -0
  160. package/dist/pricing/acquisitionQuoteBinding.d.ts +1 -1
  161. package/dist/pricing/catalogFactsSchemas.d.ts +1 -1
  162. package/dist/pricing/quotePromotionEvidence.d.ts +1 -1
  163. package/dist/pricing/quoteSchemas.d.ts +56 -56
  164. package/dist/promotions/adminPromotionCodesContracts.d.ts +30 -30
  165. package/dist/promotions/adminPromotionsContracts.d.ts +14 -14
  166. package/package.json +32 -2
  167. package/release-gates.json +99 -4
  168. package/sql/20261026120000_account_support_operator_authority.sql +693 -0
  169. package/src/account-support/accountReadFacade.ts +15 -0
  170. package/src/account-support/accountReadModels.ts +371 -0
  171. package/src/account-support/bindings.ts +28 -0
  172. package/src/account-support/contracts/account.ts +306 -0
  173. package/src/account-support/contracts/capabilities.ts +30 -0
  174. package/src/account-support/contracts/commands.ts +243 -0
  175. package/src/account-support/contracts/customer360.ts +150 -0
  176. package/src/account-support/contracts/index.ts +141 -0
  177. package/src/account-support/contracts/invoice.ts +44 -0
  178. package/src/account-support/contracts/operator.ts +123 -0
  179. package/src/account-support/contribution.ts +35 -0
  180. package/src/account-support/customer360.ts +268 -0
  181. package/src/account-support/customerAccountReadDiagnostics.ts +83 -0
  182. package/src/account-support/index.ts +72 -0
  183. package/src/account-support/invoiceAccess.ts +63 -0
  184. package/src/account-support/migrations.ts +7 -0
  185. package/src/account-support/operatorAuthority.ts +393 -0
  186. package/src/account-support/operatorRecovery.ts +136 -0
  187. package/src/account-support/orderHistory.ts +309 -0
  188. package/src/account-support/paymentMethodComposition.ts +8 -0
  189. package/src/account-support/postgresOperatorAuthority.ts +92 -0
  190. package/src/account-support/selfService.ts +83 -0
  191. package/src/account-support/subscriptionCommandCodec.ts +48 -0
  192. package/src/composition/accountSupportContribution.ts +28 -0
  193. package/src/composition/index.ts +2 -0
  194. package/src/composition/marketingContribution.ts +37 -0
  195. package/src/marketing/acquisitionCaseSchemas.ts +75 -0
  196. package/src/marketing/acquisitionEvidenceSchemas.ts +72 -0
  197. package/src/marketing/analytics/dataLayer.ts +100 -0
  198. package/src/marketing/analytics/noop.ts +14 -0
  199. package/src/marketing/analytics/purchaseFacts.ts +63 -0
  200. package/src/marketing/analytics/routePolicy.ts +55 -0
  201. package/src/marketing/consentEvidence.ts +36 -0
  202. package/src/marketing/contactLegal.ts +22 -0
  203. package/src/marketing/contracts/acquisition.ts +50 -0
  204. package/src/marketing/contracts/acquisitionPorts.ts +48 -0
  205. package/src/marketing/contracts/analytics.ts +88 -0
  206. package/src/marketing/contracts/contactLegal.ts +41 -0
  207. package/src/marketing/contracts/index.ts +17 -0
  208. package/src/marketing/contracts/prelaunch.ts +15 -0
  209. package/src/marketing/index.ts +16 -0
  210. package/src/marketing/prelaunchAcquisitionSchemas.ts +64 -0
  211. package/src/marketing/seo/jsonLd.ts +153 -0
  212. package/src/marketing/seo/metadata.ts +62 -0
  213. package/src/marketing/seo/serializeJsonLd.ts +6 -0
  214. package/src/marketing/seo/urls.ts +34 -0
  215. package/src/marketing/server/acquisitionCaseDirect.ts +67 -0
  216. package/src/marketing/server/acquisitionCaseHandlers.ts +57 -0
  217. package/src/marketing/server/acquisitionCasePorts.ts +42 -0
  218. package/src/marketing/server/acquisitionEvidenceDirect.ts +76 -0
  219. package/src/marketing/server/analyticsContext.ts +55 -0
  220. package/src/marketing/server/analyticsContextPorts.ts +28 -0
  221. package/src/marketing/server/bindings.ts +27 -0
  222. package/src/marketing/server/directPorts.ts +27 -0
  223. package/src/marketing/server/index.ts +14 -0
  224. package/src/marketing/server/postgres/acquisitionCaseRoutines.ts +218 -0
  225. package/src/marketing/server/postgres/acquisitionEvidenceRoutines.ts +103 -0
  226. package/src/marketing/server/postgres/index.ts +3 -0
  227. package/src/marketing/server/prelaunchAcquisitionDirect.ts +121 -0
  228. package/src/marketing/server/privacyContributor.ts +123 -0
  229. package/src/marketing/server/research/adminSurveyResponsesHandler.ts +78 -0
  230. package/src/marketing/server/research/managedSurveyResponseSubmit.ts +158 -0
  231. package/src/marketing/server/research/surveyResponseSubmitHandler.ts +57 -0
  232. package/src/marketing/validation.ts +3 -0
@@ -0,0 +1,693 @@
1
+ -- Support authority delegates lifecycle effects to the existing native owner.
2
+ CREATE FUNCTION public.customer_support_apply_subscription_action_v1(
3
+ p_operator_id uuid,
4
+ p_subscription_id uuid,
5
+ p_action text,
6
+ p_payload jsonb,
7
+ p_expected_version integer,
8
+ p_idempotency_key text,
9
+ p_now timestamptz
10
+ )
11
+ RETURNS jsonb
12
+ LANGUAGE plpgsql
13
+ SECURITY INVOKER
14
+ SET search_path = pg_catalog
15
+ AS $$
16
+ DECLARE
17
+ v_existing public.customer_support_subscription_commands%ROWTYPE;
18
+ v_subscription public.subscriptions%ROWTYPE;
19
+ v_subject public.clients%ROWTYPE;
20
+ v_owner public.subscription_command_receipts%ROWTYPE;
21
+ v_payload jsonb := COALESCE(p_payload, '{}'::jsonb);
22
+ v_now timestamptz := COALESCE(p_now, now());
23
+ v_fingerprint text;
24
+ v_owner_key text;
25
+ v_slide_min_days integer := 3;
26
+ v_slide_target timestamptz;
27
+ v_refusal_code text;
28
+ v_outcome text;
29
+ v_response jsonb;
30
+ v_delegate jsonb;
31
+ v_reason text;
32
+ v_pause_enabled boolean := true;
33
+ BEGIN
34
+ PERFORM public.communications_require_active_operator(p_operator_id);
35
+ IF EXISTS (SELECT 1 FROM public.platform_control_operators WHERE principal_id = p_operator_id)
36
+ OR NOT public.platform_human_membership_is_administrator(p_operator_id) THEN
37
+ RAISE EXCEPTION 'customer_support_command_requires_human' USING ERRCODE = '42501';
38
+ END IF;
39
+ IF p_subscription_id IS NULL
40
+ OR COALESCE(p_action, '') NOT IN ('pause', 'resume', 'slide_next_cycle', 'change_shipping_address')
41
+ OR jsonb_typeof(v_payload) <> 'object'
42
+ OR p_expected_version IS NULL OR p_expected_version < 1
43
+ OR char_length(btrim(COALESCE(p_idempotency_key, ''))) NOT BETWEEN 8 AND 200
44
+ OR NOT isfinite(v_now) THEN
45
+ RAISE EXCEPTION 'customer_support_subscription_command_invalid' USING ERRCODE = '22023';
46
+ END IF;
47
+ IF v_payload ? 'slideMinDays' THEN
48
+ IF COALESCE(v_payload->>'slideMinDays', '') !~ '^[0-9]{1,2}$' THEN
49
+ RAISE EXCEPTION 'customer_support_subscription_command_invalid' USING ERRCODE = '22023';
50
+ END IF;
51
+ v_slide_min_days := (v_payload->>'slideMinDays')::integer;
52
+ IF v_slide_min_days < 1 OR v_slide_min_days > 60 THEN
53
+ RAISE EXCEPTION 'customer_support_subscription_command_invalid' USING ERRCODE = '22023';
54
+ END IF;
55
+ END IF;
56
+ v_fingerprint := encode(sha256(convert_to(
57
+ 'subscription_action|' || p_action || '|' || p_subscription_id::text || '|'
58
+ || p_expected_version::text || '|' || v_payload::text, 'utf8')), 'hex');
59
+ PERFORM pg_advisory_xact_lock(hashtextextended('customer-support-subscription:' || p_idempotency_key, 0));
60
+ SELECT * INTO v_existing FROM public.customer_support_subscription_commands
61
+ WHERE idempotency_key = p_idempotency_key;
62
+ IF FOUND THEN
63
+ IF v_existing.payload_fingerprint <> v_fingerprint
64
+ OR v_existing.operator_id <> p_operator_id
65
+ OR v_existing.target_id <> p_subscription_id
66
+ OR v_existing.command_kind <> 'subscription_action' THEN
67
+ RAISE EXCEPTION 'customer_support_idempotency_conflict' USING ERRCODE = '23505';
68
+ END IF;
69
+ IF v_existing.response->>'outcome' IN ('refused', 'conflict') THEN
70
+ RETURN v_existing.response;
71
+ END IF;
72
+ RETURN jsonb_set(v_existing.response, '{outcome}', '"replayed"'::jsonb, true);
73
+ END IF;
74
+ -- A namespaced digest maps the entire support key, including spaces, without
75
+ -- truncating its 200-character domain into the owner's 180-character domain.
76
+ v_owner_key := 'm12-support:' || encode(sha256(convert_to(p_idempotency_key, 'utf8')), 'hex');
77
+ -- Match the owner's actor/key-before-subscription lock order.
78
+ PERFORM pg_advisory_xact_lock(hashtextextended(p_operator_id::text || ':' || v_owner_key, 0));
79
+ SELECT * INTO v_subscription FROM public.subscriptions
80
+ WHERE id = p_subscription_id FOR UPDATE;
81
+ IF NOT FOUND THEN
82
+ v_refusal_code := 'subscription_not_found';
83
+ ELSE
84
+ SELECT * INTO v_subject FROM public.clients
85
+ WHERE id = v_subscription.client_id FOR SHARE;
86
+ IF NOT FOUND THEN
87
+ v_refusal_code := 'subject_not_found';
88
+ ELSIF v_subject.principal_id IS NULL THEN
89
+ v_refusal_code := 'subject_account_unlinked';
90
+ END IF;
91
+ END IF;
92
+ IF v_refusal_code IS NULL AND v_subscription.template_version <> p_expected_version THEN
93
+ v_refusal_code := 'version_conflict';
94
+ END IF;
95
+ -- No address engine is installed here; the typed native port normally refuses
96
+ -- before SQL. Direct trusted SQL callers receive the same capability refusal.
97
+ IF v_refusal_code IS NULL AND p_action = 'change_shipping_address' THEN
98
+ v_refusal_code := 'operator_subscription_authority_unavailable';
99
+ END IF;
100
+ IF v_refusal_code IS NULL AND p_action = 'slide_next_cycle' THEN
101
+ BEGIN
102
+ v_slide_target := NULLIF(v_payload->>'newNextCycleAt', '')::timestamptz;
103
+ EXCEPTION WHEN others THEN
104
+ v_slide_target := NULL;
105
+ END;
106
+ IF v_slide_target IS NULL THEN
107
+ v_refusal_code := 'slide_target_invalid';
108
+ ELSIF v_subscription.status <> 'active' OR v_subscription.next_cycle_at IS NULL THEN
109
+ v_refusal_code := 'slide_not_available';
110
+ ELSIF v_slide_target < v_now + (v_slide_min_days || ' days')::interval
111
+ OR v_slide_target > v_now + interval '60 days' THEN
112
+ v_refusal_code := 'slide_out_of_window';
113
+ ELSIF (v_slide_target AT TIME ZONE 'UTC')::date = (v_subscription.next_cycle_at AT TIME ZONE 'UTC')::date THEN
114
+ v_outcome := 'noop';
115
+ ELSIF v_slide_target < v_now + interval '3 days' THEN
116
+ v_refusal_code := 'operator_subscription_authority_unavailable';
117
+ END IF;
118
+ END IF;
119
+ -- Admission to an existing owner capability, never terminalization in M12.
120
+ -- Open dunning is delegated so its existing payment_blocked refusal survives.
121
+ IF v_refusal_code IS NULL AND p_action = 'resume' AND v_subscription.status = 'paused'
122
+ AND NOT EXISTS (SELECT 1 FROM public.subscription_dunning_cases
123
+ WHERE subscription_id = p_subscription_id AND status = 'open')
124
+ AND ((SELECT status FROM public.subscription_dunning_cases
125
+ WHERE subscription_id = p_subscription_id ORDER BY opened_at DESC, id DESC LIMIT 1) = 'expired'
126
+ OR EXISTS (SELECT 1 FROM public.subscription_cycles WHERE subscription_id = p_subscription_id
127
+ AND status = 'payment_pending')) THEN
128
+ v_refusal_code := 'operator_subscription_authority_unavailable';
129
+ END IF;
130
+ IF v_refusal_code IS NULL AND p_action = 'pause' THEN
131
+ IF NOT v_pause_enabled THEN
132
+ v_refusal_code := 'operator_subscription_authority_unavailable';
133
+ ELSIF EXISTS (SELECT 1 FROM public.subscription_renewal_operations
134
+ WHERE subscription_id = p_subscription_id AND state = 'attempt_prepared') THEN
135
+ -- The existing owner pause dispatch does not supply the donor attempt fence.
136
+ v_refusal_code := 'operator_subscription_authority_unavailable';
137
+ END IF;
138
+ END IF;
139
+ IF v_refusal_code IS NULL AND v_outcome IS DISTINCT FROM 'noop' THEN
140
+ v_delegate := public.subscription_command_apply(
141
+ p_operator_id, v_subject.id, v_owner_key, p_subscription_id, p_action,
142
+ (v_payload - 'slideMinDays') || jsonb_build_object('actorKind', 'operator', 'operatorId', p_operator_id), v_now);
143
+ SELECT * INTO v_owner FROM public.subscription_command_receipts
144
+ WHERE actor_id = p_operator_id AND idempotency_key = v_owner_key;
145
+ IF NOT FOUND THEN
146
+ RAISE EXCEPTION 'customer_support_owner_receipt_invalid' USING ERRCODE = '55000';
147
+ END IF;
148
+ IF v_delegate->>'status' = 'refused' THEN
149
+ v_reason := v_delegate->>'reason';
150
+ v_refusal_code := CASE v_reason
151
+ WHEN 'subscription_command_payment_blocked' THEN 'payment_blocked'
152
+ WHEN 'subscription_command_operation_busy' THEN 'payment_blocked'
153
+ WHEN 'subscription_lifecycle_payment_blocked' THEN 'payment_blocked'
154
+ WHEN 'subscription_lifecycle_invalid_transition' THEN 'invalid_transition'
155
+ WHEN 'subscription_lifecycle_invalid_slide' THEN 'slide_out_of_window'
156
+ WHEN 'subscription_command_idempotency_conflict' THEN 'idempotency_conflict'
157
+ ELSE NULL END;
158
+ IF v_refusal_code = 'idempotency_conflict' THEN
159
+ RAISE EXCEPTION 'customer_support_idempotency_conflict' USING ERRCODE = '23505';
160
+ END IF;
161
+ IF v_refusal_code IS NULL THEN
162
+ RAISE EXCEPTION 'customer_support_owner_receipt_invalid' USING ERRCODE = '55000';
163
+ END IF;
164
+ ELSIF v_delegate->>'status' IS NULL OR v_delegate->>'status' NOT IN ('applied', 'noop', 'replayed') THEN
165
+ RAISE EXCEPTION 'customer_support_owner_receipt_invalid' USING ERRCODE = '55000';
166
+ ELSE
167
+ v_outcome := v_delegate->>'status';
168
+ v_response := jsonb_build_object('outcome', v_outcome, 'action', p_action,
169
+ 'subscriptionId', p_subscription_id, 'subscriptionStatus', v_delegate->'subscriptionStatus',
170
+ 'nextCycleAt', v_delegate->'nextCycleAt', 'templateVersion', v_delegate->'templateVersion',
171
+ 'eventId', v_delegate->'eventId', 'appliedBy', NULL);
172
+ END IF;
173
+ END IF;
174
+ IF v_refusal_code IS NOT NULL THEN
175
+ v_outcome := CASE WHEN v_refusal_code = 'version_conflict' THEN 'conflict' ELSE 'refused' END;
176
+ v_response := jsonb_build_object('outcome', v_outcome, 'action', p_action,
177
+ 'subscriptionId', p_subscription_id, 'refusalCode', v_refusal_code);
178
+ ELSIF v_outcome = 'noop' AND v_response IS NULL THEN
179
+ v_response := jsonb_build_object('outcome', 'noop', 'action', p_action,
180
+ 'subscriptionId', p_subscription_id, 'subscriptionStatus', v_subscription.status,
181
+ 'nextCycleAt', v_subscription.next_cycle_at, 'templateVersion', v_subscription.template_version);
182
+ END IF;
183
+ IF v_owner.id IS NOT NULL THEN
184
+ v_response := v_response || jsonb_build_object('ownerReceipt', jsonb_build_object(
185
+ 'id', v_owner.id, 'actorId', v_owner.actor_id, 'idempotencyKey', v_owner.idempotency_key,
186
+ 'commandFingerprint', v_owner.command_fingerprint, 'result', v_owner.result));
187
+ END IF;
188
+ INSERT INTO public.customer_support_subscription_commands
189
+ (idempotency_key, payload_fingerprint, operator_id, subject_id, target_id, command_kind, response, created_at)
190
+ VALUES (p_idempotency_key, v_fingerprint, p_operator_id, COALESCE(v_subject.id, v_subscription.client_id),
191
+ p_subscription_id, 'subscription_action', v_response, v_now);
192
+ INSERT INTO public.customer_support_subscription_audit_events
193
+ (operator_id, subject_id, target_id, command_kind, requested_action, idempotency_key,
194
+ outcome, refusal_code, value_before, value_after, occurred_at)
195
+ VALUES (p_operator_id, COALESCE(v_subject.id, v_subscription.client_id), p_subscription_id,
196
+ 'subscription_action', p_action, p_idempotency_key, v_outcome, v_refusal_code,
197
+ v_payload->>'newNextCycleAt', v_response->>'nextCycleAt', v_now);
198
+ RETURN v_response;
199
+ END;
200
+ $$;
201
+
202
+ REVOKE ALL ON FUNCTION public.customer_support_apply_subscription_action_v1(uuid,uuid,text,jsonb,integer,text,timestamptz) FROM PUBLIC, anon, authenticated;
203
+
204
+ CREATE OR REPLACE FUNCTION public.customer_support_issue_recovery(
205
+ p_operator_id uuid,
206
+ p_client_id uuid,
207
+ p_case_id uuid,
208
+ p_idempotency_key text,
209
+ p_command_fingerprint text,
210
+ p_token_hash text,
211
+ p_expires_at timestamptz,
212
+ p_recovery_url_path text
213
+ )
214
+ RETURNS jsonb
215
+ LANGUAGE plpgsql
216
+ SET search_path = pg_catalog
217
+ AS $$
218
+ DECLARE
219
+ v_existing public.customer_recovery_commands%ROWTYPE;
220
+ v_client public.clients%ROWTYPE;
221
+ v_case public.subscription_dunning_cases%ROWTYPE;
222
+ v_order public.commerce_orders%ROWTYPE;
223
+ v_issue_result jsonb;
224
+ v_notice_attempt integer;
225
+ v_notice_id uuid;
226
+ v_audit_id uuid := gen_random_uuid();
227
+ v_recorded_at timestamptz := clock_timestamp();
228
+ v_response jsonb;
229
+ v_refusal_code text;
230
+ BEGIN
231
+ PERFORM public.communications_require_active_operator(p_operator_id);
232
+ IF EXISTS (SELECT 1 FROM public.platform_control_operators WHERE principal_id = p_operator_id) THEN
233
+ RAISE EXCEPTION 'customer_support_command_requires_human' USING ERRCODE = '42501';
234
+ END IF;
235
+ IF p_client_id IS NULL OR p_case_id IS NULL
236
+ OR char_length(btrim(COALESCE(p_idempotency_key, ''))) NOT BETWEEN 8 AND 200
237
+ OR COALESCE(p_command_fingerprint, '') !~ '^[0-9a-f]{64}$'
238
+ OR COALESCE(p_token_hash, '') !~ '^[0-9a-f]{64}$'
239
+ OR p_expires_at IS NULL OR p_expires_at <= now()
240
+ OR p_recovery_url_path IS NULL OR p_recovery_url_path !~ '^/[^[:cntrl:]]+$'
241
+ THEN
242
+ RAISE EXCEPTION 'customer_support_recovery_command_invalid' USING ERRCODE = '22023';
243
+ END IF;
244
+
245
+ PERFORM pg_catalog.pg_advisory_xact_lock(
246
+ pg_catalog.hashtextextended('customer-support-recovery:' || p_idempotency_key, 0)
247
+ );
248
+ SELECT * INTO v_existing
249
+ FROM public.customer_recovery_commands AS command_row
250
+ WHERE command_row.idempotency_key = p_idempotency_key
251
+ FOR UPDATE;
252
+ IF FOUND THEN
253
+ IF v_existing.command_fingerprint <> p_command_fingerprint
254
+ OR v_existing.operator_id <> p_operator_id
255
+ OR v_existing.client_id <> p_client_id
256
+ OR v_existing.case_id <> p_case_id
257
+ THEN
258
+ RAISE EXCEPTION 'customer_support_idempotency_conflict' USING ERRCODE = '23505';
259
+ END IF;
260
+ IF v_existing.response->>'outcome' = 'refused' THEN
261
+ RETURN v_existing.response;
262
+ END IF;
263
+ RETURN jsonb_set(v_existing.response, '{outcome}', '"replayed"'::jsonb, true);
264
+ END IF;
265
+
266
+ SELECT * INTO v_client FROM public.clients AS client_row
267
+ WHERE client_row.id = p_client_id;
268
+ IF NOT FOUND THEN
269
+ v_refusal_code := 'subject_not_found';
270
+ ELSIF v_client.lifecycle_stage <> 'customer' THEN
271
+ v_refusal_code := 'lifecycle_not_eligible';
272
+ END IF;
273
+
274
+ IF v_refusal_code IS NULL THEN
275
+ SELECT * INTO v_case FROM public.subscription_dunning_cases AS case_row
276
+ WHERE case_row.id = p_case_id
277
+ FOR UPDATE;
278
+ IF NOT FOUND OR v_case.client_id <> p_client_id THEN
279
+ v_refusal_code := 'case_not_found';
280
+ ELSIF v_case.status <> 'open' THEN
281
+ v_refusal_code := 'case_not_open';
282
+ END IF;
283
+ END IF;
284
+
285
+ IF v_refusal_code IS NULL THEN
286
+ SELECT * INTO v_order FROM public.commerce_orders AS order_row
287
+ WHERE order_row.subscription_cycle_id = v_case.cycle_id
288
+ AND order_row.client_id = p_client_id
289
+ AND order_row.status = 'pending_payment'
290
+ ORDER BY order_row.created_at DESC, order_row.id DESC
291
+ LIMIT 1
292
+ FOR UPDATE;
293
+ IF NOT FOUND THEN
294
+ v_refusal_code := 'recoverable_order_unavailable';
295
+ END IF;
296
+ END IF;
297
+
298
+ IF v_refusal_code IS NULL AND NOT EXISTS (
299
+ SELECT 1 FROM public.subscription_dunning_notifications AS notice_row
300
+ WHERE notice_row.case_id = p_case_id
301
+ AND notice_row.recipient_kind = 'customer'
302
+ AND notice_row.notification_kind = 'payment_failed'
303
+ ) THEN
304
+ v_refusal_code := 'dunning_authority_unavailable';
305
+ END IF;
306
+
307
+ IF v_refusal_code IS NOT NULL THEN
308
+ INSERT INTO public.customer_support_audit_events (
309
+ id, operator_id, client_id, case_id, event_type, outcome, command_key, occurred_at
310
+ ) VALUES (
311
+ v_audit_id, p_operator_id, p_client_id, p_case_id,
312
+ 'issue_recovery', 'refused', p_idempotency_key, v_recorded_at
313
+ );
314
+ v_response := jsonb_build_object(
315
+ 'outcome', 'refused', 'refusalCode', v_refusal_code, 'auditEventId', v_audit_id
316
+ );
317
+ INSERT INTO public.customer_recovery_commands (
318
+ idempotency_key, command_fingerprint, operator_id, client_id, case_id,
319
+ response, created_at
320
+ ) VALUES (
321
+ p_idempotency_key, p_command_fingerprint, p_operator_id, p_client_id, p_case_id,
322
+ v_response, v_recorded_at
323
+ );
324
+ RETURN v_response;
325
+ END IF;
326
+
327
+ v_issue_result := public.dunning_lifecycle_issue_recovery_token(
328
+ p_case_id, p_token_hash, p_expires_at
329
+ );
330
+ IF COALESCE((v_issue_result->>'issued')::boolean, false) IS NOT TRUE THEN
331
+ INSERT INTO public.customer_support_audit_events (
332
+ id, operator_id, client_id, case_id, event_type, outcome, command_key, occurred_at
333
+ ) VALUES (
334
+ v_audit_id, p_operator_id, p_client_id, p_case_id,
335
+ 'issue_recovery', 'refused', p_idempotency_key, v_recorded_at
336
+ );
337
+ v_response := jsonb_build_object(
338
+ 'outcome', 'refused', 'refusalCode', 'recoverable_order_unavailable',
339
+ 'auditEventId', v_audit_id
340
+ );
341
+ INSERT INTO public.customer_recovery_commands (
342
+ idempotency_key, command_fingerprint, operator_id, client_id, case_id,
343
+ response, created_at
344
+ ) VALUES (
345
+ p_idempotency_key, p_command_fingerprint, p_operator_id, p_client_id, p_case_id,
346
+ v_response, v_recorded_at
347
+ );
348
+ RETURN v_response;
349
+ END IF;
350
+
351
+ -- D15 predates this command fence and reports issued even when its token-hash
352
+ -- conflict branch inserts nothing. Verify the same active capability exists
353
+ -- for the locked order; failure aborts the transaction and restores any token
354
+ -- D15 revoked, so no notice can carry an unusable link.
355
+ IF NOT EXISTS (
356
+ SELECT 1 FROM public.commerce_checkout_recovery_tokens AS recovery_token
357
+ WHERE recovery_token.order_id = v_order.id
358
+ AND recovery_token.client_id = p_client_id
359
+ AND recovery_token.token_hash = p_token_hash
360
+ AND recovery_token.revoked_at IS NULL
361
+ AND recovery_token.expires_at = p_expires_at
362
+ ) THEN
363
+ RAISE EXCEPTION 'customer_support_recovery_token_not_persisted' USING ERRCODE = '55000';
364
+ END IF;
365
+
366
+ SELECT COALESCE(max(notice_row.retry_attempt), 0) + 1 INTO v_notice_attempt
367
+ FROM public.subscription_dunning_notifications AS notice_row
368
+ WHERE notice_row.case_id = p_case_id
369
+ AND notice_row.notification_kind = 'payment_recovery';
370
+ v_notice_id := public.dunning_lifecycle_queue_notice(
371
+ p_case_id, 'payment_recovery', 'payment_recovery', v_notice_attempt,
372
+ p_recovery_url_path
373
+ );
374
+
375
+ INSERT INTO public.customer_support_audit_events (
376
+ id, operator_id, client_id, case_id, event_type, outcome, command_key, occurred_at
377
+ ) VALUES (
378
+ v_audit_id, p_operator_id, p_client_id, p_case_id,
379
+ 'issue_recovery', 'issued', p_idempotency_key, v_recorded_at
380
+ );
381
+
382
+ v_response := jsonb_build_object(
383
+ 'outcome', 'issued',
384
+ 'deliveryStatus', 'queued',
385
+ 'auditEventId', v_audit_id
386
+ );
387
+ INSERT INTO public.customer_recovery_commands (
388
+ idempotency_key, command_fingerprint, operator_id, client_id, case_id,
389
+ response, created_at
390
+ ) VALUES (
391
+ p_idempotency_key, p_command_fingerprint, p_operator_id, p_client_id, p_case_id,
392
+ v_response, v_recorded_at
393
+ );
394
+
395
+ RETURN v_response;
396
+ END;
397
+ $$;
398
+
399
+ REVOKE ALL ON FUNCTION public.customer_support_issue_recovery(uuid,uuid,uuid,text,text,text,timestamptz,text) FROM PUBLIC, anon, authenticated;
400
+
401
+ CREATE OR REPLACE FUNCTION public.customer_support_absorb_lead_v1(
402
+ p_operator_id uuid,
403
+ p_customer_id uuid,
404
+ p_lead_id uuid,
405
+ p_expected_lead_email text,
406
+ p_idempotency_key text,
407
+ p_now timestamptz
408
+ )
409
+ RETURNS jsonb
410
+ LANGUAGE plpgsql
411
+ SET search_path = public, pg_catalog
412
+ AS $$
413
+ DECLARE
414
+ v_existing public.customer_support_subscription_commands%ROWTYPE;
415
+ v_lead public.clients%ROWTYPE;
416
+ v_customer public.clients%ROWTYPE;
417
+ v_now timestamptz := COALESCE(p_now, now());
418
+ v_expected text := lower(btrim(COALESCE(p_expected_lead_email, '')));
419
+ v_fingerprint text;
420
+ v_refusal_code text;
421
+ v_outcome text;
422
+ v_response jsonb;
423
+ v_referencing record;
424
+ v_blocking text[] := ARRAY[]::text[];
425
+ v_unclassified text[] := ARRAY[]::text[];
426
+ v_named text[] := ARRAY[]::text[];
427
+ v_present bigint;
428
+ v_moved bigint;
429
+ v_carried jsonb := jsonb_build_object(
430
+ 'personalization', 0, 'consents', 0, 'sourceLinks', 0, 'deliveries', 0);
431
+ v_carried_by_table jsonb := '{}'::jsonb;
432
+ v_lead_email text;
433
+ v_tombstone text;
434
+ v_subject_id uuid;
435
+ BEGIN
436
+ -- The gate answers first, before a single row of customer state is read.
437
+ PERFORM public.communications_require_active_operator(p_operator_id);
438
+ IF EXISTS (SELECT 1 FROM public.platform_control_operators WHERE principal_id = p_operator_id) THEN
439
+ RAISE EXCEPTION 'customer_support_command_requires_human' USING ERRCODE = '42501';
440
+ END IF;
441
+
442
+ IF p_customer_id IS NULL
443
+ OR p_lead_id IS NULL
444
+ OR p_customer_id = p_lead_id
445
+ OR char_length(btrim(COALESCE(p_idempotency_key, ''))) NOT BETWEEN 8 AND 200
446
+ OR char_length(v_expected) NOT BETWEEN 3 AND 320
447
+ THEN
448
+ RAISE EXCEPTION 'customer_support_absorb_lead_invalid' USING ERRCODE = '22023';
449
+ END IF;
450
+
451
+ v_fingerprint := encode(
452
+ pg_catalog.sha256(pg_catalog.convert_to(
453
+ 'lead_absorption|' || p_customer_id::text || '|' || p_lead_id::text || '|' || v_expected, 'utf8')),
454
+ 'hex');
455
+
456
+ PERFORM pg_catalog.pg_advisory_xact_lock(
457
+ pg_catalog.hashtextextended('customer-support-subscription:' || p_idempotency_key, 0)
458
+ );
459
+ -- Both subjects, in a fixed id order, so two absorptions naming the same pair
460
+ -- from opposite directions serialize instead of deadlocking. Proving the lead
461
+ -- is empty and then emptying it has to be one indivisible act: a concurrent
462
+ -- insert between the two is the only way this routine could destroy data.
463
+ PERFORM pg_catalog.pg_advisory_xact_lock(
464
+ pg_catalog.hashtextextended('customer-support-subject:'
465
+ || LEAST(p_customer_id, p_lead_id)::text, 0));
466
+ PERFORM pg_catalog.pg_advisory_xact_lock(
467
+ pg_catalog.hashtextextended('customer-support-subject:'
468
+ || GREATEST(p_customer_id, p_lead_id)::text, 0));
469
+
470
+ SELECT * INTO v_existing
471
+ FROM public.customer_support_subscription_commands AS command_row
472
+ WHERE command_row.idempotency_key = p_idempotency_key;
473
+
474
+ IF FOUND THEN
475
+ IF v_existing.payload_fingerprint <> v_fingerprint
476
+ OR v_existing.operator_id <> p_operator_id
477
+ OR v_existing.target_id <> p_lead_id
478
+ OR v_existing.command_kind <> 'lead_absorption'
479
+ THEN
480
+ RAISE EXCEPTION 'customer_support_idempotency_conflict' USING ERRCODE = '23505';
481
+ END IF;
482
+ IF v_existing.response->>'outcome' IN ('refused', 'conflict') THEN
483
+ RETURN v_existing.response;
484
+ END IF;
485
+ RETURN jsonb_set(v_existing.response, '{outcome}', '"replayed"'::jsonb, true);
486
+ END IF;
487
+
488
+ SELECT * INTO v_lead
489
+ FROM public.clients AS lead_row
490
+ WHERE lead_row.id = p_lead_id
491
+ FOR UPDATE;
492
+ IF NOT FOUND THEN
493
+ v_refusal_code := 'lead_not_found';
494
+ END IF;
495
+
496
+ IF v_refusal_code IS NULL THEN
497
+ SELECT * INTO v_customer
498
+ FROM public.clients AS customer_row
499
+ WHERE customer_row.id = p_customer_id
500
+ FOR UPDATE;
501
+ IF NOT FOUND THEN
502
+ v_refusal_code := 'customer_not_found';
503
+ ELSE
504
+ v_subject_id := p_customer_id;
505
+ END IF;
506
+ END IF;
507
+
508
+ v_lead_email := v_lead.email;
509
+
510
+ -- An already-absorbed pair is a settled fact, not a stale expectation: the
511
+ -- address it is asked about is precisely the one this routine moved away.
512
+ IF v_refusal_code IS NULL
513
+ AND v_lead.metadata #>> '{absorption,absorbedIntoClientId}' = p_customer_id::text
514
+ THEN
515
+ v_outcome := 'noop';
516
+ ELSIF v_refusal_code IS NULL
517
+ AND lower(btrim(COALESCE(v_lead.email, ''))) IS DISTINCT FROM v_expected
518
+ THEN
519
+ v_refusal_code := 'lead_email_expectation_conflict';
520
+ END IF;
521
+
522
+ -- This lane names the authorization link `principal_id`; the rule it enforces
523
+ -- is the managed lane's rule exactly.
524
+ IF v_refusal_code IS NULL AND v_outcome IS NULL AND v_lead.principal_id IS NOT NULL THEN
525
+ v_refusal_code := 'lead_has_identity';
526
+ END IF;
527
+
528
+ IF v_refusal_code IS NULL AND v_outcome IS NULL THEN
529
+ FOR v_referencing IN
530
+ SELECT referencing.relname::text AS table_name,
531
+ local_column.attname::text AS column_name,
532
+ policy_row.disposition AS disposition
533
+ FROM pg_catalog.pg_constraint AS fk
534
+ JOIN pg_catalog.pg_class AS referencing ON referencing.oid = fk.conrelid
535
+ JOIN pg_catalog.pg_namespace AS referencing_schema
536
+ ON referencing_schema.oid = referencing.relnamespace
537
+ CROSS JOIN LATERAL unnest(fk.conkey, fk.confkey) AS key_pair(local_attnum, remote_attnum)
538
+ JOIN pg_catalog.pg_attribute AS local_column
539
+ ON local_column.attrelid = fk.conrelid AND local_column.attnum = key_pair.local_attnum
540
+ JOIN pg_catalog.pg_attribute AS remote_column
541
+ ON remote_column.attrelid = fk.confrelid AND remote_column.attnum = key_pair.remote_attnum
542
+ LEFT JOIN public.client_absorption_policy AS policy_row
543
+ ON policy_row.table_name = referencing.relname
544
+ WHERE fk.contype = 'f'
545
+ AND fk.confrelid = 'public.clients'::regclass
546
+ AND referencing_schema.nspname = 'public'
547
+ AND remote_column.attname = 'id'
548
+ ORDER BY 1, 2
549
+ LOOP
550
+ IF v_referencing.disposition IS NULL THEN
551
+ IF NOT (v_referencing.table_name = ANY (v_unclassified)) THEN
552
+ v_unclassified := v_unclassified || v_referencing.table_name;
553
+ END IF;
554
+ ELSIF v_referencing.disposition = 'block' THEN
555
+ EXECUTE format(
556
+ 'SELECT count(*) FROM public.%I AS blocking_row WHERE blocking_row.%I = $1',
557
+ v_referencing.table_name, v_referencing.column_name)
558
+ INTO v_present USING p_lead_id;
559
+ IF v_present > 0 AND NOT (v_referencing.table_name = ANY (v_blocking)) THEN
560
+ v_blocking := v_blocking || v_referencing.table_name;
561
+ END IF;
562
+ END IF;
563
+ END LOOP;
564
+
565
+ IF array_length(v_unclassified, 1) IS NOT NULL THEN
566
+ v_refusal_code := 'unclassified_referencing_table';
567
+ v_named := v_unclassified;
568
+ ELSIF array_length(v_blocking, 1) IS NOT NULL THEN
569
+ v_refusal_code := 'lead_has_commercial_footprint';
570
+ v_named := v_blocking;
571
+ END IF;
572
+ END IF;
573
+
574
+ IF v_refusal_code IS NULL AND v_outcome IS NULL THEN
575
+ FOR v_referencing IN
576
+ SELECT referencing.relname::text AS table_name,
577
+ local_column.attname::text AS column_name
578
+ FROM pg_catalog.pg_constraint AS fk
579
+ JOIN pg_catalog.pg_class AS referencing ON referencing.oid = fk.conrelid
580
+ JOIN pg_catalog.pg_namespace AS referencing_schema
581
+ ON referencing_schema.oid = referencing.relnamespace
582
+ CROSS JOIN LATERAL unnest(fk.conkey, fk.confkey) AS key_pair(local_attnum, remote_attnum)
583
+ JOIN pg_catalog.pg_attribute AS local_column
584
+ ON local_column.attrelid = fk.conrelid AND local_column.attnum = key_pair.local_attnum
585
+ JOIN pg_catalog.pg_attribute AS remote_column
586
+ ON remote_column.attrelid = fk.confrelid AND remote_column.attnum = key_pair.remote_attnum
587
+ JOIN public.client_absorption_policy AS policy_row
588
+ ON policy_row.table_name = referencing.relname
589
+ WHERE fk.contype = 'f'
590
+ AND fk.confrelid = 'public.clients'::regclass
591
+ AND referencing_schema.nspname = 'public'
592
+ AND remote_column.attname = 'id'
593
+ AND policy_row.disposition = 'carry'
594
+ ORDER BY 1, 2
595
+ LOOP
596
+ BEGIN
597
+ EXECUTE format(
598
+ 'UPDATE public.%I AS carried_row SET %I = $1 WHERE carried_row.%I = $2',
599
+ v_referencing.table_name, v_referencing.column_name, v_referencing.column_name)
600
+ USING p_customer_id, p_lead_id;
601
+ GET DIAGNOSTICS v_moved = ROW_COUNT;
602
+ EXCEPTION WHEN unique_violation THEN
603
+ -- The customer already occupies this table's own key. Theirs is kept
604
+ -- untouched and the lead's stays on the archived record; overwriting
605
+ -- would be the one destructive act this routine must never perform.
606
+ v_moved := 0;
607
+ INSERT INTO public.customer_support_subscription_audit_events (
608
+ operator_id, subject_id, target_id, command_kind, requested_action,
609
+ idempotency_key, outcome, refusal_code, value_before, value_after, occurred_at
610
+ ) VALUES (
611
+ p_operator_id, v_subject_id, p_lead_id, 'lead_absorption',
612
+ 'absorb_lead_carry_conflict', p_idempotency_key, 'noop', NULL,
613
+ v_referencing.table_name, 'retained_by_customer', v_now
614
+ );
615
+ END;
616
+ v_carried_by_table := v_carried_by_table
617
+ || jsonb_build_object(v_referencing.table_name, v_moved);
618
+ END LOOP;
619
+
620
+ -- The four named counters are the contract the callers read. This lane does
621
+ -- not carry those four tables yet, so they answer zero honestly rather than
622
+ -- reporting whatever else moved; `absorption.carried` on the archived row
623
+ -- below is the complete per-table record.
624
+ v_carried := jsonb_build_object(
625
+ 'personalization', COALESCE((v_carried_by_table->>'customer_personalization')::int, 0),
626
+ 'consents', COALESCE((v_carried_by_table->>'client_consents')::int, 0),
627
+ 'sourceLinks', COALESCE((v_carried_by_table->>'client_source_links')::int, 0),
628
+ 'deliveries', COALESCE((v_carried_by_table->>'communication_email_deliveries')::int, 0));
629
+
630
+ -- The reserved .invalid namespace can never resolve, so the archived row
631
+ -- can hold an address forever without anyone ever mailing it -- and the
632
+ -- real one is now free for the correction that was refused.
633
+ v_tombstone := 'absorbed+' || replace(p_lead_id::text, '-', '') || '@absorbed.invalid';
634
+ UPDATE public.clients AS lead_row
635
+ SET email = v_tombstone,
636
+ metadata = lead_row.metadata || jsonb_build_object(
637
+ 'absorption', jsonb_build_object(
638
+ 'absorbedIntoClientId', p_customer_id,
639
+ 'absorbedAt', v_now,
640
+ 'originalEmail', v_lead_email,
641
+ 'idempotencyKey', p_idempotency_key,
642
+ 'carried', v_carried_by_table)),
643
+ updated_at = v_now
644
+ WHERE lead_row.id = p_lead_id;
645
+ END IF;
646
+
647
+ IF v_refusal_code IS NOT NULL THEN
648
+ v_outcome := CASE
649
+ WHEN v_refusal_code = 'lead_email_expectation_conflict' THEN 'conflict'
650
+ ELSE 'refused' END;
651
+ v_response := jsonb_build_object(
652
+ 'outcome', v_outcome,
653
+ 'action', 'absorb_lead',
654
+ 'leadId', p_lead_id,
655
+ 'refusalCode', v_refusal_code
656
+ );
657
+ IF v_refusal_code IN ('lead_has_commercial_footprint', 'unclassified_referencing_table') THEN
658
+ v_response := v_response || jsonb_build_object('blockingTables', to_jsonb(v_named));
659
+ END IF;
660
+ ELSE
661
+ v_response := jsonb_build_object(
662
+ 'outcome', COALESCE(v_outcome, 'applied'),
663
+ 'action', 'absorb_lead',
664
+ 'leadId', p_lead_id,
665
+ 'customerId', p_customer_id,
666
+ 'carried', v_carried
667
+ );
668
+ END IF;
669
+
670
+ INSERT INTO public.customer_support_subscription_commands (
671
+ idempotency_key, payload_fingerprint, operator_id, subject_id, target_id,
672
+ command_kind, response, created_at
673
+ ) VALUES (
674
+ p_idempotency_key, v_fingerprint, p_operator_id, v_subject_id, p_lead_id,
675
+ 'lead_absorption', v_response, v_now
676
+ );
677
+
678
+ INSERT INTO public.customer_support_subscription_audit_events (
679
+ operator_id, subject_id, target_id, command_kind, requested_action,
680
+ idempotency_key, outcome, refusal_code, value_before, value_after, occurred_at
681
+ ) VALUES (
682
+ p_operator_id, v_subject_id, p_lead_id, 'lead_absorption', 'absorb_lead',
683
+ p_idempotency_key, v_response->>'outcome', v_refusal_code,
684
+ v_lead_email,
685
+ CASE WHEN v_refusal_code IS NULL AND v_outcome IS NULL THEN v_tombstone ELSE NULL END,
686
+ v_now
687
+ );
688
+
689
+ RETURN v_response;
690
+ END;
691
+ $$;
692
+
693
+ REVOKE ALL ON FUNCTION public.customer_support_absorb_lead_v1(uuid,uuid,uuid,text,text,timestamptz) FROM PUBLIC, anon, authenticated;