From 0cc58b685836b64ae588ecb1ce7163b387d00f14 Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Mon, 10 Aug 2026 13:00:04 +0300 Subject: [PATCH] fix(db): exempt credit-only orgs from canceled retention Canceled orgs with active usage credits were swept as unpaid after trial end. Exclude has_usage_credits from grace eligibility and retention warnings. Co-authored-by: Cursor --- ...pt_credit_orgs_from_canceled_retention.sql | 179 ++++++++++++++++++ .../64_test_canceled_org_version_cleanup.sql | 87 +++++++-- 2 files changed, 252 insertions(+), 14 deletions(-) create mode 100644 supabase/migrations/20260810095528_exempt_credit_orgs_from_canceled_retention.sql diff --git a/supabase/migrations/20260810095528_exempt_credit_orgs_from_canceled_retention.sql b/supabase/migrations/20260810095528_exempt_credit_orgs_from_canceled_retention.sql new file mode 100644 index 0000000000..e32492614b --- /dev/null +++ b/supabase/migrations/20260810095528_exempt_credit_orgs_from_canceled_retention.sql @@ -0,0 +1,179 @@ +-- Credit-only orgs keep stripe_info.status = canceled after trial ends while +-- paying via usage credits. Retention must not treat them as unpaid abandoned +-- orgs (same exemption rationale as has_usage_credits_org for the CLI). + +CREATE OR REPLACE FUNCTION public.canceled_org_ids_past_grace(p_days integer) +RETURNS SETOF uuid +LANGUAGE sql +STABLE +SECURITY DEFINER +SET search_path = '' +AS $$ + SELECT o.id + FROM public.stripe_info AS si + JOIN public.orgs AS o ON o.customer_id = si.customer_id + WHERE si.status IN ('canceled', 'deleted') + AND NOT COALESCE(o.has_usage_credits, false) + AND GREATEST(si.canceled_at, si.subscription_anchor_end, si.trial_at) + <= pg_catalog.now() - make_interval(days => GREATEST(0, COALESCE(p_days, 0))); +$$; + +ALTER FUNCTION public.canceled_org_ids_past_grace(integer) OWNER TO postgres; +REVOKE ALL ON FUNCTION public.canceled_org_ids_past_grace(integer) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.canceled_org_ids_past_grace(integer) FROM anon; +REVOKE ALL ON FUNCTION public.canceled_org_ids_past_grace(integer) FROM authenticated; +GRANT ALL ON FUNCTION public.canceled_org_ids_past_grace(integer) TO service_role; + +COMMENT ON FUNCTION public.canceled_org_ids_past_grace(integer) IS + 'Org ids whose stripe_info is canceled/deleted, without active usage credits, ' + 'and GREATEST(canceled_at, subscription_anchor_end, trial_at) is older than p_days.'; + +COMMENT ON FUNCTION public.long_canceled_org_ids() IS + 'Org ids past 90-day canceled grace without active usage credits ' + '(see canceled_org_ids_past_grace).'; + +CREATE OR REPLACE FUNCTION public.queue_canceled_org_retention_alerts( + p_alert_type text, + p_min_days integer, + p_batch_size integer DEFAULT 500 +) +RETURNS bigint +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_batch_size integer := GREATEST(1, COALESCE(p_batch_size, 500)); + v_min_days integer := GREATEST(0, COALESCE(p_min_days, 0)); + v_max_days integer := v_min_days + 5; + v_event text; + v_queued bigint := 0; + org_record RECORD; + v_days_until integer; +BEGIN + IF p_alert_type = 'bundles_deletion_warning' THEN + v_event := 'org:bundles_will_be_deleted'; + ELSIF p_alert_type = 'app_deletion_warning' THEN + v_event := 'org:apps_will_be_deleted'; + ELSE + RAISE EXCEPTION 'unsupported retention alert type: %', p_alert_type; + END IF; + + FOR org_record IN + SELECT + o.id AS org_id, + o.name AS org_name, + o.management_email, + GREATEST(si.canceled_at, si.subscription_anchor_end, si.trial_at) AS access_end, + COALESCE( + ( + SELECT jsonb_agg(a.app_id ORDER BY a.app_id) + FROM public.apps AS a + WHERE a.owner_org = o.id + ), + '[]'::jsonb + ) AS app_ids + FROM public.stripe_info AS si + JOIN public.orgs AS o ON o.customer_id = si.customer_id + WHERE si.status IN ('canceled', 'deleted') + AND NOT COALESCE(o.has_usage_credits, false) + AND GREATEST(si.canceled_at, si.subscription_anchor_end, si.trial_at) + <= pg_catalog.now() - make_interval(days => v_min_days) + AND GREATEST(si.canceled_at, si.subscription_anchor_end, si.trial_at) + > pg_catalog.now() - make_interval(days => v_max_days) + AND ( + CASE + WHEN p_alert_type = 'bundles_deletion_warning' THEN EXISTS ( + SELECT 1 + FROM public.app_versions AS av + WHERE av.owner_org = o.id + AND av.deleted = false + AND av.name NOT IN ('builtin', 'unknown') + ) + ELSE EXISTS ( + SELECT 1 + FROM public.apps AS a + WHERE a.owner_org = o.id + ) + END + ) + AND NOT EXISTS ( + SELECT 1 + FROM public.notifications AS n + WHERE n.owner_org = o.id + AND n.event = v_event + AND n.uniq_id = ( + 'retention:' + || p_alert_type + || ':' + || to_char( + GREATEST(si.canceled_at, si.subscription_anchor_end, si.trial_at) AT TIME ZONE 'UTC', + 'YYYY-MM-DD"T"HH24:MI:SS"Z"' + ) + ) + ) + AND NOT EXISTS ( + SELECT 1 + FROM pgmq.q_canceled_org_retention_alerts AS q + WHERE q.message -> 'payload' ->> 'org_id' = o.id::text + AND q.message -> 'payload' ->> 'alert_type' = p_alert_type + ) + ORDER BY o.id + LIMIT v_batch_size + LOOP + v_days_until := GREATEST( + 0, + CEIL( + EXTRACT( + EPOCH FROM ( + org_record.access_end + + make_interval(days => v_max_days) + - pg_catalog.now() + ) + ) / 86400.0 + )::integer + ); + + PERFORM pgmq.send( + 'canceled_org_retention_alerts', + jsonb_build_object( + 'function_name', 'canceled_org_retention_alerts', + 'function_type', 'cloudflare', + 'payload', jsonb_build_object( + 'org_id', org_record.org_id, + 'org_name', org_record.org_name, + 'management_email', org_record.management_email, + 'alert_type', p_alert_type, + 'access_end', org_record.access_end, + 'days_until_deletion', v_days_until, + 'app_ids', org_record.app_ids + ) + ) + ); + v_queued := v_queued + 1; + END LOOP; + + IF v_queued > 0 THEN + RAISE NOTICE + 'queue_canceled_org_retention_alerts: type=% queued=% window=[%,%)', + p_alert_type, + v_queued, + v_min_days, + v_max_days; + END IF; + + RETURN v_queued; +END; +$$; + +ALTER FUNCTION public.queue_canceled_org_retention_alerts(text, integer, integer) OWNER TO postgres; +REVOKE ALL ON FUNCTION public.queue_canceled_org_retention_alerts(text, integer, integer) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.queue_canceled_org_retention_alerts(text, integer, integer) FROM anon; +REVOKE ALL ON FUNCTION public.queue_canceled_org_retention_alerts(text, integer, integer) FROM authenticated; +GRANT ALL ON FUNCTION public.queue_canceled_org_retention_alerts(text, integer, integer) TO service_role; + +COMMENT ON FUNCTION public.queue_canceled_org_retention_alerts(text, integer, integer) IS + 'Queues once-per-cancel-cycle Bento/tracking warnings for canceled orgs ' + 'without active usage credits in [p_min_days, p_min_days+5). Bundles require ' + 'a deletable app_versions row; apps require an apps row. Dedup uniq_id uses ' + 'access_end UTC timestamp.'; diff --git a/supabase/tests/64_test_canceled_org_version_cleanup.sql b/supabase/tests/64_test_canceled_org_version_cleanup.sql index b1e867296e..aeb96cb52d 100644 --- a/supabase/tests/64_test_canceled_org_version_cleanup.sql +++ b/supabase/tests/64_test_canceled_org_version_cleanup.sql @@ -1,6 +1,6 @@ BEGIN; -SELECT plan(39); +SELECT plan(43); -- pgmq schema is not granted to service_role; use postgres-owned helpers. CREATE OR REPLACE FUNCTION pg_temp.delete_canceled_org_retention_alerts( @@ -143,6 +143,7 @@ SELECT ok( -- long = 92d (past 90, before 95) for version soft-delete without app delete -- warn85 = 87d for bundle-deletion warning queue -- ultra = 100d (past 95) for app delete + old_apps archive +-- credits = 100d canceled + has_usage_credits (must be fully exempt) CREATE TEMP TABLE canceled_cleanup_ctx AS SELECT 'a0c1e2f3-1111-4aaa-8bbb-000000000001'::uuid AS long_canceled_org, @@ -152,6 +153,7 @@ SELECT 'a0c1e2f3-1111-4aaa-8bbb-000000000005'::uuid AS early_cancel_org, 'a0c1e2f3-1111-4aaa-8bbb-000000000006'::uuid AS warn85_org, 'a0c1e2f3-1111-4aaa-8bbb-000000000007'::uuid AS ultra_canceled_org, + 'a0c1e2f3-1111-4aaa-8bbb-000000000008'::uuid AS credits_canceled_org, 'cus_canceled_cleanup_long'::varchar AS long_customer, 'cus_canceled_cleanup_recent'::varchar AS recent_customer, 'cus_canceled_cleanup_trial'::varchar AS trial_customer, @@ -159,6 +161,7 @@ SELECT 'cus_canceled_cleanup_early'::varchar AS early_customer, 'cus_canceled_cleanup_warn85'::varchar AS warn85_customer, 'cus_canceled_cleanup_ultra'::varchar AS ultra_customer, + 'cus_canceled_cleanup_credits'::varchar AS credits_customer, 'com.test.canceled.cleanup.long'::varchar AS long_app, 'com.test.canceled.cleanup.recent'::varchar AS recent_app, 'com.test.canceled.cleanup.trial'::varchar AS trial_app, @@ -166,6 +169,7 @@ SELECT 'com.test.canceled.cleanup.early'::varchar AS early_app, 'com.test.canceled.cleanup.warn85'::varchar AS warn85_app, 'com.test.canceled.cleanup.ultra'::varchar AS ultra_app, + 'com.test.canceled.cleanup.credits'::varchar AS credits_app, '6aa76066-55ef-4238-ade6-0b32334a4097'::uuid AS user_id, 'prod_LQIregjtNduh4q'::varchar AS product_id; @@ -257,28 +261,43 @@ SELECT now() - interval '200 days', now() - interval '100 days', now() - interval '100 days' +FROM canceled_cleanup_ctx +UNION ALL +-- 100 days canceled but actively funded by usage credits. +SELECT + credits_customer, + 'canceled'::public.stripe_status, + product_id, + now() - interval '200 days', + false, + now() - interval '200 days', + now() - interval '100 days', + now() - interval '100 days' FROM canceled_cleanup_ctx; -INSERT INTO public.orgs (id, created_by, name, management_email, customer_id) -SELECT long_canceled_org, user_id, 'Long Canceled Cleanup Org', 'canceled-long@test.local', long_customer +INSERT INTO public.orgs (id, created_by, name, management_email, customer_id, has_usage_credits) +SELECT long_canceled_org, user_id, 'Long Canceled Cleanup Org', 'canceled-long@test.local', long_customer, false FROM canceled_cleanup_ctx UNION ALL -SELECT recent_canceled_org, user_id, 'Recent Canceled Cleanup Org', 'canceled-recent@test.local', recent_customer +SELECT recent_canceled_org, user_id, 'Recent Canceled Cleanup Org', 'canceled-recent@test.local', recent_customer, false FROM canceled_cleanup_ctx UNION ALL -SELECT trial_org, user_id, 'Expired Trial Cleanup Org', 'canceled-trial@test.local', trial_customer +SELECT trial_org, user_id, 'Expired Trial Cleanup Org', 'canceled-trial@test.local', trial_customer, false FROM canceled_cleanup_ctx UNION ALL -SELECT paying_org, user_id, 'Paying Cleanup Org', 'canceled-paying@test.local', paying_customer +SELECT paying_org, user_id, 'Paying Cleanup Org', 'canceled-paying@test.local', paying_customer, false FROM canceled_cleanup_ctx UNION ALL -SELECT early_cancel_org, user_id, 'Early Cancel Cleanup Org', 'canceled-early@test.local', early_customer +SELECT early_cancel_org, user_id, 'Early Cancel Cleanup Org', 'canceled-early@test.local', early_customer, false FROM canceled_cleanup_ctx UNION ALL -SELECT warn85_org, user_id, 'Warn85 Canceled Cleanup Org', 'canceled-warn85@test.local', warn85_customer +SELECT warn85_org, user_id, 'Warn85 Canceled Cleanup Org', 'canceled-warn85@test.local', warn85_customer, false FROM canceled_cleanup_ctx UNION ALL -SELECT ultra_canceled_org, user_id, 'Ultra Canceled Cleanup Org', 'canceled-ultra@test.local', ultra_customer +SELECT ultra_canceled_org, user_id, 'Ultra Canceled Cleanup Org', 'canceled-ultra@test.local', ultra_customer, false +FROM canceled_cleanup_ctx +UNION ALL +SELECT credits_canceled_org, user_id, 'Credits Canceled Cleanup Org', 'canceled-credits@test.local', credits_customer, true FROM canceled_cleanup_ctx; INSERT INTO public.apps (app_id, icon_url, owner_org, name, user_id) @@ -294,7 +313,9 @@ SELECT early_app, '', early_cancel_org, 'Early Cancel App', user_id FROM cancele UNION ALL SELECT warn85_app, '', warn85_org, 'Warn85 App', user_id FROM canceled_cleanup_ctx UNION ALL -SELECT ultra_app, '', ultra_canceled_org, 'Ultra Canceled App', user_id FROM canceled_cleanup_ctx; +SELECT ultra_app, '', ultra_canceled_org, 'Ultra Canceled App', user_id FROM canceled_cleanup_ctx +UNION ALL +SELECT credits_app, '', credits_canceled_org, 'Credits Canceled App', user_id FROM canceled_cleanup_ctx; INSERT INTO public.app_versions (id, app_id, name, storage_provider, owner_org, user_id, deleted) SELECT 970101, long_app, '1.0.0', 'r2', long_canceled_org, user_id, false FROM canceled_cleanup_ctx @@ -313,7 +334,9 @@ SELECT 970501, early_app, '1.0.0', 'r2', early_cancel_org, user_id, false FROM c UNION ALL SELECT 970601, warn85_app, '1.0.0', 'r2', warn85_org, user_id, false FROM canceled_cleanup_ctx UNION ALL -SELECT 970701, ultra_app, '1.0.0', 'r2', ultra_canceled_org, user_id, false FROM canceled_cleanup_ctx; +SELECT 970701, ultra_app, '1.0.0', 'r2', ultra_canceled_org, user_id, false FROM canceled_cleanup_ctx +UNION ALL +SELECT 970801, credits_app, '1.0.0', 'r2', credits_canceled_org, user_id, false FROM canceled_cleanup_ctx; SELECT set_config('capgo.seed_channel_targets', 'true', true); @@ -470,7 +493,8 @@ SELECT pg_temp.delete_canceled_org_retention_alerts(ARRAY[ (SELECT long_canceled_org::text FROM canceled_cleanup_ctx), (SELECT warn85_org::text FROM canceled_cleanup_ctx), (SELECT ultra_canceled_org::text FROM canceled_cleanup_ctx), - (SELECT recent_canceled_org::text FROM canceled_cleanup_ctx) + (SELECT recent_canceled_org::text FROM canceled_cleanup_ctx), + (SELECT credits_canceled_org::text FROM canceled_cleanup_ctx) ]); SELECT public.cleanup_long_canceled_org_data(); @@ -563,6 +587,30 @@ SELECT ok( '100-day org app is deleted after 95 days' ); +SELECT is( + (SELECT deleted FROM public.app_versions WHERE id = 970801), + false, + 'credit-funded canceled org versions are never soft-deleted' +); + +SELECT ok( + EXISTS ( + SELECT 1 + FROM public.apps + WHERE app_id = (SELECT credits_app FROM canceled_cleanup_ctx) + ), + 'credit-funded canceled org apps are never deleted by retention' +); + +SELECT ok( + NOT EXISTS ( + SELECT 1 + FROM public.canceled_org_ids_past_grace(0) AS oid(id) + WHERE oid.id = (SELECT credits_canceled_org FROM canceled_cleanup_ctx) + ), + 'credit-funded canceled orgs are excluded from canceled_org_ids_past_grace' +); + SELECT ok( EXISTS ( SELECT 1 @@ -580,7 +628,8 @@ SELECT is( ARRAY[ (SELECT warn85_org FROM canceled_cleanup_ctx), (SELECT long_canceled_org FROM canceled_cleanup_ctx), - (SELECT ultra_canceled_org FROM canceled_cleanup_ctx) + (SELECT ultra_canceled_org FROM canceled_cleanup_ctx), + (SELECT credits_canceled_org FROM canceled_cleanup_ctx) ] ), 1, @@ -592,7 +641,8 @@ SELECT is( 'app_deletion_warning', ARRAY[ (SELECT long_canceled_org FROM canceled_cleanup_ctx), - (SELECT ultra_canceled_org FROM canceled_cleanup_ctx) + (SELECT ultra_canceled_org FROM canceled_cleanup_ctx), + (SELECT credits_canceled_org FROM canceled_cleanup_ctx) ] ), 1, @@ -608,6 +658,15 @@ SELECT is( 'recently canceled orgs do not get retention deletion warnings' ); +SELECT is( + pg_temp.count_canceled_org_retention_alerts( + NULL, + ARRAY[(SELECT credits_canceled_org FROM canceled_cleanup_ctx)] + ), + 0, + 'credit-funded canceled orgs do not get retention deletion warnings' +); + SELECT public.cleanup_long_canceled_org_data(); SELECT is(