211 lines
7.2 KiB
PL/PgSQL
211 lines
7.2 KiB
PL/PgSQL
-- ============================================================================
|
|
-- 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;
|