Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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.';
87 changes: 73 additions & 14 deletions supabase/tests/64_test_canceled_org_version_cleanup.sql
Original file line number Diff line number Diff line change
@@ -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(
Expand Down Expand Up @@ -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,
Expand All @@ -152,20 +153,23 @@ 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,
'cus_canceled_cleanup_paying'::varchar AS paying_customer,
'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,
'com.test.canceled.cleanup.paying'::varchar AS paying_app,
'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;

Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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);

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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(
Expand Down
Loading