200 lines
7.7 KiB
PL/PgSQL
200 lines
7.7 KiB
PL/PgSQL
\set ON_ERROR_STOP on
|
|
|
|
-- Regression: back-office subscription edits must not rewrite the billing terms
|
|
-- of a provider-owned (Payple / store) subscription, and admin delete must
|
|
-- release provider ownership so the customer can purchase again.
|
|
-- Before 20260928190000 a manager PATCH of tier='pro_plus' on an auto-renewing
|
|
-- Payple Pro row was accepted, and payple-renew then charged the pro_plus price.
|
|
|
|
BEGIN;
|
|
|
|
CREATE OR REPLACE FUNCTION pg_temp.assert_true(condition boolean, message text)
|
|
RETURNS void
|
|
LANGUAGE plpgsql
|
|
AS $$
|
|
BEGIN
|
|
IF condition IS NOT TRUE THEN
|
|
RAISE EXCEPTION 'assertion_failed: %', message;
|
|
END IF;
|
|
END;
|
|
$$;
|
|
|
|
INSERT INTO auth.users (
|
|
id, aud, role, email, encrypted_password, email_confirmed_at,
|
|
raw_app_meta_data, raw_user_meta_data, created_at, updated_at
|
|
) VALUES
|
|
(
|
|
'41900000-0000-4000-8000-000000000001', 'authenticated', 'authenticated',
|
|
'guard-manager@example.invalid', crypt('fixture-password', gen_salt('bf')), now(),
|
|
'{"provider":"email","providers":["email"]}'::jsonb, '{}'::jsonb, now(), now()
|
|
),
|
|
(
|
|
'41900000-0000-4000-8000-000000000002', 'authenticated', 'authenticated',
|
|
'guard-admin@example.invalid', crypt('fixture-password', gen_salt('bf')), now(),
|
|
'{"provider":"email","providers":["email"]}'::jsonb, '{}'::jsonb, now(), now()
|
|
),
|
|
(
|
|
'41900000-0000-4000-8000-000000000003', 'authenticated', 'authenticated',
|
|
'guard-payple-customer@example.invalid', crypt('fixture-password', gen_salt('bf')), now(),
|
|
'{"provider":"email","providers":["email"]}'::jsonb, '{}'::jsonb, now(), now()
|
|
),
|
|
(
|
|
'41900000-0000-4000-8000-000000000004', 'authenticated', 'authenticated',
|
|
'guard-comp-customer@example.invalid', crypt('fixture-password', gen_salt('bf')), now(),
|
|
'{"provider":"email","providers":["email"]}'::jsonb, '{}'::jsonb, now(), now()
|
|
);
|
|
|
|
UPDATE public.profiles SET role = 'manager' WHERE id = '41900000-0000-4000-8000-000000000001';
|
|
UPDATE public.profiles SET role = 'admin' WHERE id = '41900000-0000-4000-8000-000000000002';
|
|
|
|
INSERT INTO public.subscriptions (user_id, tier, status, provider, payment_provider)
|
|
VALUES
|
|
('41900000-0000-4000-8000-000000000003', 'free', 'active', 'none', 'none'),
|
|
('41900000-0000-4000-8000-000000000004', 'free', 'active', 'none', 'none')
|
|
ON CONFLICT (user_id) DO NOTHING;
|
|
|
|
UPDATE public.subscriptions
|
|
SET tier = 'pro',
|
|
status = 'active',
|
|
provider = 'payple',
|
|
provider_resource_id = 'payple-guard-fixture-resource',
|
|
payple_payer_id = 'payple-guard-fixture-payer',
|
|
auto_renewing = true,
|
|
current_period_start = '2026-09-15T03:12:45Z',
|
|
current_period_end = '2026-10-15T03:12:45Z'
|
|
WHERE user_id = '41900000-0000-4000-8000-000000000003';
|
|
|
|
UPDATE public.subscriptions
|
|
SET tier = 'free', status = 'active', provider = 'none', auto_renewing = NULL
|
|
WHERE user_id = '41900000-0000-4000-8000-000000000004';
|
|
|
|
-- A rejected mutation must raise provider_managed_subscription and change nothing.
|
|
CREATE OR REPLACE FUNCTION pg_temp.expect_provider_guard(
|
|
p_label text,
|
|
p_tier text,
|
|
p_status text,
|
|
p_period_end timestamptz
|
|
)
|
|
RETURNS void
|
|
LANGUAGE plpgsql
|
|
AS $$
|
|
DECLARE
|
|
v_rejected boolean := false;
|
|
v_row public.subscriptions%ROWTYPE;
|
|
BEGIN
|
|
BEGIN
|
|
PERFORM public.admin_mutate_subscription_v1(
|
|
'guard-manager@example.invalid', gen_random_uuid(), 'update',
|
|
'41900000-0000-4000-8000-000000000003',
|
|
p_tier, p_status, p_period_end, NULL, NULL, 'guard regression ' || p_label
|
|
);
|
|
EXCEPTION
|
|
WHEN check_violation THEN
|
|
PERFORM pg_temp.assert_true(
|
|
SQLERRM = 'provider_managed_subscription',
|
|
format('%s rejected with provider_managed_subscription, got %s', p_label, SQLERRM)
|
|
);
|
|
v_rejected := true;
|
|
END;
|
|
PERFORM pg_temp.assert_true(v_rejected, format('%s must be rejected on a Payple-owned row', p_label));
|
|
|
|
SELECT * INTO v_row FROM public.subscriptions
|
|
WHERE user_id = '41900000-0000-4000-8000-000000000003';
|
|
PERFORM pg_temp.assert_true(
|
|
v_row.tier = 'pro'
|
|
AND v_row.status = 'active'
|
|
AND v_row.provider = 'payple'
|
|
AND v_row.auto_renewing
|
|
AND v_row.current_period_end = '2026-10-15T03:12:45Z'::timestamptz,
|
|
format('%s left the Payple row unchanged: %s/%s/%s/%s', p_label,
|
|
v_row.tier, v_row.status, v_row.provider, v_row.current_period_end)
|
|
);
|
|
END;
|
|
$$;
|
|
|
|
SELECT pg_temp.expect_provider_guard('manager tier comp', 'pro_plus', NULL, NULL);
|
|
SELECT pg_temp.expect_provider_guard('manager tier downgrade', 'free', NULL, NULL);
|
|
SELECT pg_temp.expect_provider_guard('manager status change', NULL, 'expired', NULL);
|
|
SELECT pg_temp.expect_provider_guard('manager period shift', NULL, NULL, '2026-10-01T00:00:00Z');
|
|
SELECT pg_temp.expect_provider_guard('form resend with new tier', 'pro_plus', 'active', '2026-10-15T00:00:00Z');
|
|
|
|
-- The admin form resends tier/status and a day-granular period end on every
|
|
-- save. Unchanged values must pass so notes and credits stay editable, and the
|
|
-- stored period-end instant must not be truncated to midnight.
|
|
DO $$
|
|
DECLARE
|
|
v_result jsonb;
|
|
v_row public.subscriptions%ROWTYPE;
|
|
BEGIN
|
|
v_result := public.admin_mutate_subscription_v1(
|
|
'guard-manager@example.invalid', gen_random_uuid(), 'update',
|
|
'41900000-0000-4000-8000-000000000003',
|
|
'pro', 'active', '2026-10-15T00:00:00Z', 250, 'support note', 'note-only edit'
|
|
);
|
|
PERFORM pg_temp.assert_true((v_result->>'success')::boolean, 'note-only edit succeeds');
|
|
|
|
SELECT * INTO v_row FROM public.subscriptions
|
|
WHERE user_id = '41900000-0000-4000-8000-000000000003';
|
|
PERFORM pg_temp.assert_true(
|
|
v_row.tier = 'pro'
|
|
AND v_row.provider = 'payple'
|
|
AND v_row.current_period_end = '2026-10-15T03:12:45Z'::timestamptz
|
|
AND v_row.overage_credits = 250
|
|
AND v_row.admin_note = 'support note',
|
|
format('note-only edit keeps billing terms: %s/%s/%s/%s/%s',
|
|
v_row.tier, v_row.provider, v_row.current_period_end, v_row.overage_credits, v_row.admin_note)
|
|
);
|
|
END;
|
|
$$;
|
|
|
|
-- Admin-managed rows keep the existing manager edit behavior.
|
|
DO $$
|
|
DECLARE
|
|
v_row public.subscriptions%ROWTYPE;
|
|
BEGIN
|
|
PERFORM public.admin_mutate_subscription_v1(
|
|
'guard-manager@example.invalid', gen_random_uuid(), 'update',
|
|
'41900000-0000-4000-8000-000000000004',
|
|
'pro_plus', 'active', '2026-12-01T00:00:00Z', NULL, NULL, 'comp grant'
|
|
);
|
|
SELECT * INTO v_row FROM public.subscriptions
|
|
WHERE user_id = '41900000-0000-4000-8000-000000000004';
|
|
PERFORM pg_temp.assert_true(
|
|
v_row.tier = 'pro_plus'
|
|
AND v_row.provider = 'none'
|
|
AND v_row.current_period_end = '2026-12-01T00:00:00Z'::timestamptz,
|
|
format('comp row accepts tier/period edits: %s/%s/%s', v_row.tier, v_row.provider, v_row.current_period_end)
|
|
);
|
|
END;
|
|
$$;
|
|
|
|
-- Admin delete releases provider ownership: renewals stop and a new purchase is allowed.
|
|
DO $$
|
|
DECLARE
|
|
v_row public.subscriptions%ROWTYPE;
|
|
BEGIN
|
|
PERFORM public.admin_mutate_subscription_v1(
|
|
'guard-admin@example.invalid', gen_random_uuid(), 'delete',
|
|
'41900000-0000-4000-8000-000000000003',
|
|
NULL, NULL, NULL, NULL, NULL, 'refund and revoke'
|
|
);
|
|
SELECT * INTO v_row FROM public.subscriptions
|
|
WHERE user_id = '41900000-0000-4000-8000-000000000003';
|
|
PERFORM pg_temp.assert_true(
|
|
v_row.tier = 'free'
|
|
AND v_row.status = 'expired'
|
|
AND v_row.provider = 'none'
|
|
AND v_row.payment_provider = 'none'
|
|
AND v_row.provider_resource_id IS NULL
|
|
AND v_row.auto_renewing IS FALSE,
|
|
format('delete releases provider ownership: %s/%s/%s/%s/%s/%s', v_row.tier, v_row.status,
|
|
v_row.provider, v_row.payment_provider, v_row.provider_resource_id, v_row.auto_renewing)
|
|
);
|
|
PERFORM pg_temp.assert_true(
|
|
(SELECT tier FROM public.profiles WHERE id = '41900000-0000-4000-8000-000000000003') = 'free',
|
|
'profile tier follows delete'
|
|
);
|
|
END;
|
|
$$;
|
|
|
|
ROLLBACK;
|