fix(billing): stop back-office edits from changing Payple renewal charges

This commit is contained in:
Yun Chan 2026-09-28 02:16:21 +09:00
parent 957e136789
commit f456d737c4
8 changed files with 639 additions and 3 deletions

View file

@ -0,0 +1,196 @@
-- Back-office subscription edits must not rewrite provider-owned billing terms.
--
-- admin_mutate_subscription_v1 (20260821000023) let a manager PATCH the tier,
-- status and period end of a subscription that a payment provider owns. For a
-- Payple subscriber the row kept provider='payple', auto_renewing=true and its
-- billing key, so the next payple-renew run charged the stored card the price
-- of the edited tier. Admin 'delete' likewise left provider='payple' (or
-- google_play), which blocks the customer from buying again on the web.
--
-- This redefinition keeps the signature, idempotency contract and audit shape,
-- and changes two branches:
-- * update: on a provider-owned row (provider not in 'none'/'admin') a change
-- of tier, status or period end is rejected with
-- 'provider_managed_subscription'. Notes and overage credits stay editable.
-- The admin form edits the period end at day granularity, so a value on
-- the same UTC date as the stored one is treated as unchanged and the
-- stored instant is kept.
-- * delete: releases provider ownership the same way a non-entitled provider
-- event does (provider 'none', no resource, not auto-renewing, no store
-- product), so renewals stop and the customer can purchase again.
BEGIN;
CREATE OR REPLACE FUNCTION public.admin_mutate_subscription_v1(
p_actor_email text,
p_idempotency_key uuid,
p_action text,
p_user_id uuid,
p_tier text DEFAULT NULL,
p_status text DEFAULT NULL,
p_current_period_end timestamptz DEFAULT NULL,
p_overage_credits integer DEFAULT NULL,
p_admin_note text DEFAULT NULL,
p_memo text DEFAULT NULL
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = pg_catalog, public, auth, extensions
AS $$
DECLARE
actor record;
before_subscription public.subscriptions%ROWTYPE;
after_subscription public.subscriptions%ROWTYPE;
request_payload jsonb;
request_digest text;
existing_request public.admin_operation_requests%ROWTYPE;
inserted boolean;
result jsonb;
before_snapshot jsonb;
after_snapshot jsonb;
provider_managed boolean;
next_period_end timestamptz;
BEGIN
SELECT * INTO actor FROM public.resolve_external_admin_actor_v1(p_actor_email, 'manager');
IF p_action NOT IN ('create', 'update', 'delete') OR p_idempotency_key IS NULL OR p_user_id IS NULL THEN
RAISE EXCEPTION 'invalid_subscription_operation' USING ERRCODE = '22023';
END IF;
IF p_action IN ('create', 'delete') AND actor.actor_role = 'manager' THEN
RAISE EXCEPTION 'admin_role_required' USING ERRCODE = '42501';
END IF;
IF p_memo IS NULL OR char_length(trim(p_memo)) NOT BETWEEN 3 AND 1000 THEN
RAISE EXCEPTION 'memo_must_be_3_to_1000_characters' USING ERRCODE = '22023';
END IF;
IF p_tier IS NOT NULL AND p_tier NOT IN ('free', 'pro', 'pro_plus') THEN
RAISE EXCEPTION 'invalid_subscription_tier' USING ERRCODE = '22023';
END IF;
IF p_status IS NOT NULL AND p_status NOT IN ('active', 'canceled', 'past_due', 'expired') THEN
RAISE EXCEPTION 'invalid_subscription_status' USING ERRCODE = '22023';
END IF;
IF p_overage_credits IS NOT NULL AND (p_overage_credits < 0 OR p_overage_credits > 1000000) THEN
RAISE EXCEPTION 'invalid_overage_credits' USING ERRCODE = '22023';
END IF;
IF p_admin_note IS NOT NULL AND char_length(p_admin_note) > 2000 THEN
RAISE EXCEPTION 'admin_note_too_long' USING ERRCODE = '22023';
END IF;
IF NOT EXISTS (SELECT 1 FROM public.profiles WHERE id = p_user_id) THEN
RAISE EXCEPTION 'target_user_not_found' USING ERRCODE = 'P0002';
END IF;
request_payload := jsonb_build_object(
'action', p_action, 'user', p_user_id, 'tier', p_tier, 'status', p_status,
'periodEnd', p_current_period_end, 'credits', p_overage_credits,
'note', p_admin_note, 'memo', trim(p_memo)
);
request_digest := encode(extensions.digest(request_payload::text, 'sha256'), 'hex');
INSERT INTO public.admin_operation_requests(actor_id, idempotency_key, operation, request_hash)
VALUES (actor.actor_id, p_idempotency_key, 'subscription.' || p_action, request_digest)
ON CONFLICT DO NOTHING
RETURNING true INTO inserted;
IF NOT coalesce(inserted, false) THEN
SELECT * INTO existing_request FROM public.admin_operation_requests
WHERE actor_id = actor.actor_id AND idempotency_key = p_idempotency_key;
IF existing_request.operation <> ('subscription.' || p_action) OR existing_request.request_hash <> request_digest THEN
RAISE EXCEPTION 'idempotency_key_reused_with_different_request' USING ERRCODE = '22023';
END IF;
IF existing_request.response_data IS NULL THEN
RAISE EXCEPTION 'operation_in_progress' USING ERRCODE = '55P03';
END IF;
RETURN existing_request.response_data;
END IF;
SELECT * INTO before_subscription FROM public.subscriptions WHERE user_id = p_user_id FOR UPDATE;
IF p_action = 'create' THEN
IF before_subscription.id IS NOT NULL THEN
RAISE EXCEPTION 'subscription_already_exists' USING ERRCODE = '23505';
END IF;
IF p_tier IS NULL THEN RAISE EXCEPTION 'tier_required' USING ERRCODE = '22023'; END IF;
INSERT INTO public.subscriptions(user_id, tier, status, provider, payment_provider,
current_period_start, current_period_end, overage_credits, admin_note, created_at, updated_at)
VALUES (p_user_id, p_tier, coalesce(p_status, 'active'), 'none', 'none', now(),
p_current_period_end, coalesce(p_overage_credits, 0), p_admin_note, now(), now())
RETURNING * INTO after_subscription;
ELSIF p_action = 'update' THEN
IF before_subscription.id IS NULL THEN RAISE EXCEPTION 'subscription_not_found' USING ERRCODE = 'P0002'; END IF;
provider_managed := coalesce(before_subscription.provider, 'none') NOT IN ('none', 'admin');
IF provider_managed THEN
IF (p_tier IS NOT NULL AND p_tier IS DISTINCT FROM before_subscription.tier)
OR (p_status IS NOT NULL AND p_status IS DISTINCT FROM before_subscription.status)
OR (p_current_period_end IS NOT NULL AND (
before_subscription.current_period_end IS NULL
OR (p_current_period_end AT TIME ZONE 'UTC')::date
<> (before_subscription.current_period_end AT TIME ZONE 'UTC')::date
)) THEN
RAISE EXCEPTION 'provider_managed_subscription' USING ERRCODE = '23514';
END IF;
next_period_end := before_subscription.current_period_end;
ELSE
next_period_end := coalesce(p_current_period_end, before_subscription.current_period_end);
END IF;
UPDATE public.subscriptions SET
tier = coalesce(p_tier, tier),
status = coalesce(p_status, status),
current_period_end = next_period_end,
overage_credits = coalesce(p_overage_credits, overage_credits),
admin_note = coalesce(p_admin_note, admin_note),
updated_at = now()
WHERE user_id = p_user_id RETURNING * INTO after_subscription;
ELSE
IF before_subscription.id IS NULL THEN RAISE EXCEPTION 'subscription_not_found' USING ERRCODE = 'P0002'; END IF;
UPDATE public.subscriptions SET tier = 'free', status = 'expired', cancel_at = now(),
provider = 'none',
provider_resource_id = NULL,
auto_renewing = false,
store_product_id = NULL,
store_purchase_id = NULL,
admin_note = '[DELETED] ' || trim(p_memo), updated_at = now()
WHERE user_id = p_user_id RETURNING * INTO after_subscription;
END IF;
UPDATE public.profiles SET tier = after_subscription.tier, updated_at = now() WHERE id = p_user_id;
before_snapshot := CASE WHEN before_subscription.id IS NULL THEN NULL ELSE jsonb_build_object(
'id', before_subscription.id,
'user_id', before_subscription.user_id,
'tier', before_subscription.tier,
'status', before_subscription.status,
'provider', before_subscription.provider,
'payment_provider', before_subscription.payment_provider,
'current_period_start', before_subscription.current_period_start,
'current_period_end', before_subscription.current_period_end,
'overage_credits', before_subscription.overage_credits,
'admin_note', before_subscription.admin_note,
'cancel_at', before_subscription.cancel_at,
'created_at', before_subscription.created_at,
'updated_at', before_subscription.updated_at
) END;
after_snapshot := jsonb_build_object(
'id', after_subscription.id,
'user_id', after_subscription.user_id,
'tier', after_subscription.tier,
'status', after_subscription.status,
'provider', after_subscription.provider,
'payment_provider', after_subscription.payment_provider,
'current_period_start', after_subscription.current_period_start,
'current_period_end', after_subscription.current_period_end,
'overage_credits', after_subscription.overage_credits,
'admin_note', after_subscription.admin_note,
'cancel_at', after_subscription.cancel_at,
'created_at', after_subscription.created_at,
'updated_at', after_subscription.updated_at
);
INSERT INTO public.audit_log(admin_id, action, target_type, target_id, before_data, after_data, memo)
VALUES (actor.actor_id, 'subscription.' || p_action, 'subscription', p_user_id,
before_snapshot, after_snapshot, trim(p_memo));
result := jsonb_build_object('success', true, 'subscription', after_snapshot);
UPDATE public.admin_operation_requests SET response_data = result, completed_at = now()
WHERE actor_id = actor.actor_id AND idempotency_key = p_idempotency_key;
RETURN result;
END;
$$;
REVOKE ALL ON FUNCTION public.admin_mutate_subscription_v1(text, uuid, text, uuid, text, text, timestamptz, integer, text, text) FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.admin_mutate_subscription_v1(text, uuid, text, uuid, text, text, timestamptz, integer, text, text) TO service_role;
COMMIT;