fix(payple): reject checkout while a paid Payple period is still active

This commit is contained in:
Yun Chan 2026-09-28 02:16:21 +09:00
parent f456d737c4
commit e69fe0335d
5 changed files with 717 additions and 71 deletions

View file

@ -0,0 +1,211 @@
-- ============================================================================
-- Payple checkout: reject a second checkout while a paid period is running
--
-- reserve_payment_provider_operation previously allowed a checkout whenever
-- subscriptions.provider was 'none' or the requesting provider. An active
-- Payple subscriber on a stale tab (or a direct API call with a fresh
-- idempotency key) could therefore reserve a new checkout, get charged the
-- full price again, and apply_payment_provider_event then restarted the paid
-- period at now()..now()+1 month, dropping the unused remainder.
--
-- The reservation is the serialization point (per-user advisory lock), so the
-- guard lives here: a same-provider checkout is rejected with
-- 'subscription_already_active' while the row is paid (tier <> 'free') and its
-- current period has not ended. Upgrades are not modelled as checkouts; an
-- upgrade must be a separate prorating/extending operation.
--
-- Only the checkout branch changes; renewal and cancellation keep their
-- existing ownership checks. The body is otherwise identical to
-- 20260821000003_payment_provider_serialization.sql.
-- ============================================================================
BEGIN;
CREATE OR REPLACE FUNCTION public.reserve_payment_provider_operation(
p_user_id uuid,
p_provider text,
p_operation_type text,
p_requested_tier text,
p_idempotency_key text,
p_provider_order_id text DEFAULT NULL,
p_provider_resource_id text DEFAULT NULL
) RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public, pg_temp
AS $$
DECLARE
v_existing public.payment_provider_operations%ROWTYPE;
v_subscription public.subscriptions%ROWTYPE;
v_operation public.payment_provider_operations%ROWTYPE;
BEGIN
IF p_user_id IS NULL OR NOT EXISTS (SELECT 1 FROM auth.users WHERE id = p_user_id) THEN
RAISE EXCEPTION 'unknown_user';
END IF;
IF p_provider NOT IN ('stripe', 'payple', 'google_play', 'app_store') THEN
RAISE EXCEPTION 'invalid_provider';
END IF;
IF p_operation_type NOT IN ('checkout', 'renewal', 'cancellation') THEN
RAISE EXCEPTION 'invalid_operation_type';
END IF;
IF p_operation_type IN ('checkout', 'renewal') AND p_requested_tier NOT IN ('pro', 'pro_plus') THEN
RAISE EXCEPTION 'invalid_tier';
END IF;
IF p_operation_type = 'cancellation' AND p_requested_tier IS NOT NULL THEN
RAISE EXCEPTION 'cancellation_tier_must_be_null';
END IF;
IF p_idempotency_key IS NULL
OR p_idempotency_key !~ '^[A-Za-z0-9._:-]{12,160}$' THEN
RAISE EXCEPTION 'invalid_idempotency_key';
END IF;
IF p_provider_order_id IS NOT NULL
AND p_provider_order_id !~ '^[A-Za-z0-9._-]{8,64}$' THEN
RAISE EXCEPTION 'invalid_provider_order_id';
END IF;
IF p_provider_resource_id IS NOT NULL
AND length(trim(p_provider_resource_id)) NOT BETWEEN 1 AND 255 THEN
RAISE EXCEPTION 'invalid_provider_resource_id';
END IF;
PERFORM pg_advisory_xact_lock(hashtextextended(p_user_id::text, 73031));
SELECT *
INTO v_existing
FROM public.payment_provider_operations
WHERE user_id = p_user_id
AND provider = p_provider
AND idempotency_key = p_idempotency_key
FOR UPDATE;
IF v_existing.id IS NOT NULL THEN
IF v_existing.operation_type <> p_operation_type
OR v_existing.requested_tier IS DISTINCT FROM p_requested_tier
OR v_existing.provider_resource_id IS DISTINCT FROM p_provider_resource_id THEN
RAISE EXCEPTION 'idempotency_key_payload_mismatch';
END IF;
RETURN jsonb_build_object(
'created', false,
'operation_id', v_existing.id,
'state', v_existing.state,
'provider_order_id', v_existing.provider_order_id,
'external_reference', v_existing.external_reference,
'reason', 'idempotent_replay'
);
END IF;
UPDATE public.payment_provider_operations
SET state = 'failed',
error_code = 'operation_lease_expired',
updated_at = now()
WHERE user_id = p_user_id
AND state IN ('reserved', 'external_created', 'charged')
AND expires_at <= now();
SELECT *
INTO v_subscription
FROM public.subscriptions
WHERE user_id = p_user_id
FOR UPDATE;
IF v_subscription.id IS NULL THEN
INSERT INTO public.subscriptions (user_id, tier, status, provider, payment_provider)
VALUES (p_user_id, 'free', 'active', 'none', 'none')
RETURNING * INTO v_subscription;
END IF;
IF p_operation_type = 'checkout' THEN
IF v_subscription.provider NOT IN ('none', p_provider) THEN
RETURN jsonb_build_object(
'created', false,
'state', 'rejected',
'reason', 'active_subscription_other_provider',
'owner_provider', v_subscription.provider
);
END IF;
-- Same provider, still paid and inside its period: a new checkout would
-- charge again and restart the period. A missing period end on a paid
-- row is treated as still running.
IF v_subscription.provider = p_provider
AND v_subscription.tier <> 'free'
AND coalesce(v_subscription.current_period_end, 'infinity'::timestamptz) > now() THEN
RETURN jsonb_build_object(
'created', false,
'state', 'rejected',
'reason', 'subscription_already_active',
'owner_provider', v_subscription.provider,
'tier', v_subscription.tier,
'current_period_end', v_subscription.current_period_end
);
END IF;
ELSE
IF v_subscription.provider <> p_provider THEN
RETURN jsonb_build_object(
'created', false,
'state', 'rejected',
'reason', 'provider_not_owner',
'owner_provider', v_subscription.provider
);
END IF;
IF p_provider_resource_id IS NOT NULL
AND v_subscription.provider_resource_id IS DISTINCT FROM p_provider_resource_id THEN
RETURN jsonb_build_object(
'created', false,
'state', 'rejected',
'reason', 'provider_resource_not_owner'
);
END IF;
END IF;
IF EXISTS (
SELECT 1
FROM public.payment_provider_operations
WHERE user_id = p_user_id
AND state IN ('reserved', 'external_created', 'charged')
AND expires_at > now()
) THEN
RETURN jsonb_build_object(
'created', false,
'state', 'rejected',
'reason', 'payment_operation_in_progress'
);
END IF;
INSERT INTO public.payment_provider_operations (
user_id,
provider,
operation_type,
idempotency_key,
requested_tier,
provider_order_id,
provider_resource_id,
expires_at
) VALUES (
p_user_id,
p_provider,
p_operation_type,
p_idempotency_key,
p_requested_tier,
nullif(trim(p_provider_order_id), ''),
nullif(trim(p_provider_resource_id), ''),
now() + interval '15 minutes'
)
RETURNING * INTO v_operation;
RETURN jsonb_build_object(
'created', true,
'operation_id', v_operation.id,
'state', v_operation.state,
'provider_order_id', v_operation.provider_order_id,
'expires_at', v_operation.expires_at
);
END;
$$;
REVOKE ALL ON FUNCTION public.reserve_payment_provider_operation(
uuid, text, text, text, text, text, text
) FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.reserve_payment_provider_operation(
uuid, text, text, text, text, text, text
) TO service_role;
COMMIT;