-- ============================================================================ -- 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;