@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.
- package/CHANGELOG.md +386 -127
- package/README.md +251 -9
- package/dist/account-support/accountReadFacade.d.ts +8 -0
- package/dist/account-support/accountReadFacade.js +10 -0
- package/dist/account-support/accountReadModels.d.ts +69 -0
- package/dist/account-support/accountReadModels.js +272 -0
- package/dist/account-support/bindings.d.ts +23 -0
- package/dist/account-support/bindings.js +16 -0
- package/dist/account-support/contracts/account.d.ts +281 -0
- package/dist/account-support/contracts/account.js +1 -0
- package/dist/account-support/contracts/capabilities.d.ts +44 -0
- package/dist/account-support/contracts/capabilities.js +1 -0
- package/dist/account-support/contracts/commands.d.ts +307 -0
- package/dist/account-support/contracts/commands.js +1 -0
- package/dist/account-support/contracts/customer360.d.ts +154 -0
- package/dist/account-support/contracts/customer360.js +1 -0
- package/dist/account-support/contracts/index.d.ts +10 -0
- package/dist/account-support/contracts/index.js +1 -0
- package/dist/account-support/contracts/invoice.d.ts +79 -0
- package/dist/account-support/contracts/invoice.js +1 -0
- package/dist/account-support/contracts/operator.d.ts +289 -0
- package/dist/account-support/contracts/operator.js +1 -0
- package/dist/account-support/contribution.d.ts +24 -0
- package/dist/account-support/contribution.js +22 -0
- package/dist/account-support/customer360.d.ts +106 -0
- package/dist/account-support/customer360.js +163 -0
- package/dist/account-support/customerAccountReadDiagnostics.d.ts +11 -0
- package/dist/account-support/customerAccountReadDiagnostics.js +71 -0
- package/dist/account-support/index.d.ts +26 -0
- package/dist/account-support/index.js +15 -0
- package/dist/account-support/invoiceAccess.d.ts +57 -0
- package/dist/account-support/invoiceAccess.js +40 -0
- package/dist/account-support/migrations.d.ts +14 -0
- package/dist/account-support/migrations.js +7 -0
- package/dist/account-support/operatorAuthority.d.ts +67 -0
- package/dist/account-support/operatorAuthority.js +379 -0
- package/dist/account-support/operatorRecovery.d.ts +66 -0
- package/dist/account-support/operatorRecovery.js +129 -0
- package/dist/account-support/orderHistory.d.ts +50 -0
- package/dist/account-support/orderHistory.js +188 -0
- package/dist/account-support/paymentMethodComposition.d.ts +5 -0
- package/dist/account-support/paymentMethodComposition.js +4 -0
- package/dist/account-support/postgresOperatorAuthority.d.ts +16 -0
- package/dist/account-support/postgresOperatorAuthority.js +93 -0
- package/dist/account-support/selfService.d.ts +6 -0
- package/dist/account-support/selfService.js +81 -0
- package/dist/account-support/subscriptionCommandCodec.d.ts +10 -0
- package/dist/account-support/subscriptionCommandCodec.js +38 -0
- package/dist/catalog/draftSchemas.d.ts +1 -1
- package/dist/catalog/foundationSchemas.d.ts +3 -3
- package/dist/catalog/resolvedSkuProjection.d.ts +2 -2
- package/dist/catalog/sellableSchemas.d.ts +2 -2
- package/dist/commerce/checkoutCommandSchemas.d.ts +2 -2
- package/dist/commerce/checkoutResumeSchemas.d.ts +6 -6
- package/dist/commerce/checkoutSchemas.d.ts +12 -12
- package/dist/commerce/commerceReturnsPort.d.ts +2 -2
- package/dist/commerce/configuratorIntentSchemas.d.ts +2 -2
- package/dist/commerce/contractPrimitives.d.ts +8 -8
- package/dist/commerce/customerDefaultsSnapshotSchemas.d.ts +4 -4
- package/dist/commerce/model/omsReadModelHelpers.d.ts +4 -4
- package/dist/commerce/offerAvailabilitySchemas.d.ts +1 -1
- package/dist/commerce/oms/omsContractBase.d.ts +15 -15
- package/dist/commerce/oms/omsContractReadAuxSchemas.d.ts +11 -11
- package/dist/commerce/oms/omsContractReadSchemas.d.ts +121 -121
- package/dist/commerce/oms/omsContractRequests.d.ts +13 -13
- package/dist/commerce/oms/omsControlPlane.d.ts +22 -22
- package/dist/commerce/oms/omsFulfillmentContractSchemas.d.ts +12 -12
- package/dist/commerce/oms/omsFulfillmentDebugSchemas.d.ts +12 -12
- package/dist/commerce/orderReviewContracts.d.ts +33 -33
- package/dist/commerce/outboxEventSchemas.d.ts +4 -4
- package/dist/commerce/schemas.d.ts +21 -21
- package/dist/commerce/server/orderReviewHandlers.d.ts +10 -10
- package/dist/communications/consentPurposeRegistry.d.ts +1 -1
- package/dist/communications/newsletterIntegrationContracts.d.ts +8 -8
- package/dist/composition/accountSupportContribution.d.ts +18 -0
- package/dist/composition/accountSupportContribution.js +21 -0
- package/dist/composition/index.d.ts +2 -0
- package/dist/composition/index.js +2 -0
- package/dist/composition/marketingContribution.d.ts +21 -0
- package/dist/composition/marketingContribution.js +29 -0
- package/dist/fulfillment/handlerSchemas.d.ts +40 -40
- package/dist/identity/customerContracts.d.ts +2 -2
- package/dist/invoicing/snapshot.d.ts +1 -1
- package/dist/marketing/acquisitionCaseSchemas.d.ts +103 -0
- package/dist/marketing/acquisitionCaseSchemas.js +53 -0
- package/dist/marketing/acquisitionEvidenceSchemas.d.ts +105 -0
- package/dist/marketing/acquisitionEvidenceSchemas.js +55 -0
- package/dist/marketing/analytics/dataLayer.d.ts +18 -0
- package/dist/marketing/analytics/dataLayer.js +111 -0
- package/dist/marketing/analytics/noop.d.ts +9 -0
- package/dist/marketing/analytics/noop.js +8 -0
- package/dist/marketing/analytics/purchaseFacts.d.ts +4 -0
- package/dist/marketing/analytics/purchaseFacts.js +69 -0
- package/dist/marketing/analytics/routePolicy.d.ts +10 -0
- package/dist/marketing/analytics/routePolicy.js +59 -0
- package/dist/marketing/consentEvidence.d.ts +8 -0
- package/dist/marketing/consentEvidence.js +32 -0
- package/dist/marketing/contactLegal.d.ts +14 -0
- package/dist/marketing/contactLegal.js +19 -0
- package/dist/marketing/contracts/acquisition.d.ts +88 -0
- package/dist/marketing/contracts/acquisition.js +1 -0
- package/dist/marketing/contracts/acquisitionPorts.d.ts +49 -0
- package/dist/marketing/contracts/acquisitionPorts.js +1 -0
- package/dist/marketing/contracts/analytics.d.ts +144 -0
- package/dist/marketing/contracts/analytics.js +1 -0
- package/dist/marketing/contracts/contactLegal.d.ts +53 -0
- package/dist/marketing/contracts/contactLegal.js +1 -0
- package/dist/marketing/contracts/index.d.ts +6 -0
- package/dist/marketing/contracts/index.js +1 -0
- package/dist/marketing/contracts/prelaunch.d.ts +39 -0
- package/dist/marketing/contracts/prelaunch.js +1 -0
- package/dist/marketing/index.d.ts +16 -0
- package/dist/marketing/index.js +14 -0
- package/dist/marketing/prelaunchAcquisitionSchemas.d.ts +116 -0
- package/dist/marketing/prelaunchAcquisitionSchemas.js +51 -0
- package/dist/marketing/seo/jsonLd.d.ts +157 -0
- package/dist/marketing/seo/jsonLd.js +127 -0
- package/dist/marketing/seo/metadata.d.ts +41 -0
- package/dist/marketing/seo/metadata.js +36 -0
- package/dist/marketing/seo/serializeJsonLd.d.ts +1 -0
- package/dist/marketing/seo/serializeJsonLd.js +6 -0
- package/dist/marketing/seo/urls.d.ts +14 -0
- package/dist/marketing/seo/urls.js +23 -0
- package/dist/marketing/server/acquisitionCaseDirect.d.ts +4 -0
- package/dist/marketing/server/acquisitionCaseDirect.js +88 -0
- package/dist/marketing/server/acquisitionCaseHandlers.d.ts +28 -0
- package/dist/marketing/server/acquisitionCaseHandlers.js +40 -0
- package/dist/marketing/server/acquisitionCasePorts.d.ts +42 -0
- package/dist/marketing/server/acquisitionCasePorts.js +1 -0
- package/dist/marketing/server/acquisitionEvidenceDirect.d.ts +5 -0
- package/dist/marketing/server/acquisitionEvidenceDirect.js +100 -0
- package/dist/marketing/server/analyticsContext.d.ts +3 -0
- package/dist/marketing/server/analyticsContext.js +65 -0
- package/dist/marketing/server/analyticsContextPorts.d.ts +31 -0
- package/dist/marketing/server/analyticsContextPorts.js +1 -0
- package/dist/marketing/server/bindings.d.ts +14 -0
- package/dist/marketing/server/bindings.js +18 -0
- package/dist/marketing/server/directPorts.d.ts +26 -0
- package/dist/marketing/server/directPorts.js +1 -0
- package/dist/marketing/server/index.d.ts +14 -0
- package/dist/marketing/server/index.js +11 -0
- package/dist/marketing/server/postgres/acquisitionCaseRoutines.d.ts +10 -0
- package/dist/marketing/server/postgres/acquisitionCaseRoutines.js +204 -0
- package/dist/marketing/server/postgres/acquisitionEvidenceRoutines.d.ts +10 -0
- package/dist/marketing/server/postgres/acquisitionEvidenceRoutines.js +107 -0
- package/dist/marketing/server/postgres/index.d.ts +3 -0
- package/dist/marketing/server/postgres/index.js +3 -0
- package/dist/marketing/server/prelaunchAcquisitionDirect.d.ts +4 -0
- package/dist/marketing/server/prelaunchAcquisitionDirect.js +122 -0
- package/dist/marketing/server/privacyContributor.d.ts +66 -0
- package/dist/marketing/server/privacyContributor.js +87 -0
- package/dist/marketing/server/research/adminSurveyResponsesHandler.d.ts +9 -0
- package/dist/marketing/server/research/adminSurveyResponsesHandler.js +53 -0
- package/dist/marketing/server/research/managedSurveyResponseSubmit.d.ts +39 -0
- package/dist/marketing/server/research/managedSurveyResponseSubmit.js +102 -0
- package/dist/marketing/server/research/surveyResponseSubmitHandler.d.ts +8 -0
- package/dist/marketing/server/research/surveyResponseSubmitHandler.js +41 -0
- package/dist/marketing/validation.d.ts +2 -0
- package/dist/marketing/validation.js +3 -0
- package/dist/pricing/acquisitionQuoteBinding.d.ts +1 -1
- package/dist/pricing/catalogFactsSchemas.d.ts +1 -1
- package/dist/pricing/quotePromotionEvidence.d.ts +1 -1
- package/dist/pricing/quoteSchemas.d.ts +56 -56
- package/dist/promotions/adminPromotionCodesContracts.d.ts +30 -30
- package/dist/promotions/adminPromotionsContracts.d.ts +14 -14
- package/package.json +32 -2
- package/release-gates.json +99 -4
- package/sql/20261026120000_account_support_operator_authority.sql +693 -0
- package/src/account-support/accountReadFacade.ts +15 -0
- package/src/account-support/accountReadModels.ts +371 -0
- package/src/account-support/bindings.ts +28 -0
- package/src/account-support/contracts/account.ts +306 -0
- package/src/account-support/contracts/capabilities.ts +30 -0
- package/src/account-support/contracts/commands.ts +243 -0
- package/src/account-support/contracts/customer360.ts +150 -0
- package/src/account-support/contracts/index.ts +141 -0
- package/src/account-support/contracts/invoice.ts +44 -0
- package/src/account-support/contracts/operator.ts +123 -0
- package/src/account-support/contribution.ts +35 -0
- package/src/account-support/customer360.ts +268 -0
- package/src/account-support/customerAccountReadDiagnostics.ts +83 -0
- package/src/account-support/index.ts +72 -0
- package/src/account-support/invoiceAccess.ts +63 -0
- package/src/account-support/migrations.ts +7 -0
- package/src/account-support/operatorAuthority.ts +393 -0
- package/src/account-support/operatorRecovery.ts +136 -0
- package/src/account-support/orderHistory.ts +309 -0
- package/src/account-support/paymentMethodComposition.ts +8 -0
- package/src/account-support/postgresOperatorAuthority.ts +92 -0
- package/src/account-support/selfService.ts +83 -0
- package/src/account-support/subscriptionCommandCodec.ts +48 -0
- package/src/composition/accountSupportContribution.ts +28 -0
- package/src/composition/index.ts +2 -0
- package/src/composition/marketingContribution.ts +37 -0
- package/src/marketing/acquisitionCaseSchemas.ts +75 -0
- package/src/marketing/acquisitionEvidenceSchemas.ts +72 -0
- package/src/marketing/analytics/dataLayer.ts +100 -0
- package/src/marketing/analytics/noop.ts +14 -0
- package/src/marketing/analytics/purchaseFacts.ts +63 -0
- package/src/marketing/analytics/routePolicy.ts +55 -0
- package/src/marketing/consentEvidence.ts +36 -0
- package/src/marketing/contactLegal.ts +22 -0
- package/src/marketing/contracts/acquisition.ts +50 -0
- package/src/marketing/contracts/acquisitionPorts.ts +48 -0
- package/src/marketing/contracts/analytics.ts +88 -0
- package/src/marketing/contracts/contactLegal.ts +41 -0
- package/src/marketing/contracts/index.ts +17 -0
- package/src/marketing/contracts/prelaunch.ts +15 -0
- package/src/marketing/index.ts +16 -0
- package/src/marketing/prelaunchAcquisitionSchemas.ts +64 -0
- package/src/marketing/seo/jsonLd.ts +153 -0
- package/src/marketing/seo/metadata.ts +62 -0
- package/src/marketing/seo/serializeJsonLd.ts +6 -0
- package/src/marketing/seo/urls.ts +34 -0
- package/src/marketing/server/acquisitionCaseDirect.ts +67 -0
- package/src/marketing/server/acquisitionCaseHandlers.ts +57 -0
- package/src/marketing/server/acquisitionCasePorts.ts +42 -0
- package/src/marketing/server/acquisitionEvidenceDirect.ts +76 -0
- package/src/marketing/server/analyticsContext.ts +55 -0
- package/src/marketing/server/analyticsContextPorts.ts +28 -0
- package/src/marketing/server/bindings.ts +27 -0
- package/src/marketing/server/directPorts.ts +27 -0
- package/src/marketing/server/index.ts +14 -0
- package/src/marketing/server/postgres/acquisitionCaseRoutines.ts +218 -0
- package/src/marketing/server/postgres/acquisitionEvidenceRoutines.ts +103 -0
- package/src/marketing/server/postgres/index.ts +3 -0
- package/src/marketing/server/prelaunchAcquisitionDirect.ts +121 -0
- package/src/marketing/server/privacyContributor.ts +123 -0
- package/src/marketing/server/research/adminSurveyResponsesHandler.ts +78 -0
- package/src/marketing/server/research/managedSurveyResponseSubmit.ts +158 -0
- package/src/marketing/server/research/surveyResponseSubmitHandler.ts +57 -0
- 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;
|