d3ro-voice/server/supabase/migrations/20260821000003_payment_provider_serialization.sql
2026-08-29 18:33:45 +09:00

1223 lines
43 KiB
PL/PgSQL

-- ============================================================================
-- Payment provider serialization and entitlement single source of truth
--
-- All Stripe, Payple, Google Play and App Store entitlement changes pass
-- through service-role-only RPCs. A per-user transaction advisory lock
-- serializes provider races, provider cursors reject out-of-order events, and
-- payment_provider is maintained only as a compatibility mirror of provider.
-- ============================================================================
BEGIN;
ALTER TABLE public.subscriptions
ADD COLUMN IF NOT EXISTS provider_resource_id text,
ADD COLUMN IF NOT EXISTS provider_event_id text,
ADD COLUMN IF NOT EXISTS provider_event_created_at timestamptz;
CREATE UNIQUE INDEX IF NOT EXISTS idx_subscriptions_provider_resource_owner
ON public.subscriptions(provider, provider_resource_id)
WHERE provider <> 'none' AND provider_resource_id IS NOT NULL;
CREATE TABLE public.payment_provider_events (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
provider text NOT NULL
CHECK (provider IN ('stripe', 'payple', 'google_play', 'app_store', 'admin')),
event_id text NOT NULL CHECK (length(event_id) BETWEEN 3 AND 255),
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
provider_resource_id text NOT NULL CHECK (length(provider_resource_id) BETWEEN 1 AND 255),
event_type text NOT NULL CHECK (length(event_type) BETWEEN 1 AND 100),
event_created_at timestamptz NOT NULL,
payload_digest text NOT NULL CHECK (payload_digest ~ '^[0-9a-f]{64}$'),
disposition text NOT NULL DEFAULT 'received'
CHECK (disposition IN ('received', 'applied', 'ignored', 'rejected', 'failed')),
result jsonb,
received_at timestamptz NOT NULL DEFAULT now(),
processed_at timestamptz,
UNIQUE (provider, event_id)
);
CREATE INDEX idx_payment_provider_events_user_created
ON public.payment_provider_events(user_id, event_created_at DESC);
ALTER TABLE public.payment_provider_events ENABLE ROW LEVEL SECURITY;
-- No authenticated policies: provider payload metadata is service-only.
CREATE TABLE public.payment_provider_cursors (
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
provider text NOT NULL
CHECK (provider IN ('stripe', 'payple', 'google_play', 'app_store', 'admin')),
last_event_created_at timestamptz NOT NULL,
last_event_id text NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, provider)
);
ALTER TABLE public.payment_provider_cursors ENABLE ROW LEVEL SECURITY;
-- No authenticated policies: cursors are service-owned serialization state.
CREATE TABLE public.payment_provider_operations (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
provider text NOT NULL
CHECK (provider IN ('stripe', 'payple', 'google_play', 'app_store')),
operation_type text NOT NULL
CHECK (operation_type IN ('checkout', 'renewal', 'cancellation')),
idempotency_key text NOT NULL CHECK (length(idempotency_key) BETWEEN 12 AND 160),
requested_tier text CHECK (requested_tier IN ('pro', 'pro_plus')),
provider_order_id text,
provider_resource_id text,
state text NOT NULL DEFAULT 'reserved'
CHECK (state IN ('reserved', 'external_created', 'charged', 'applied', 'failed')),
external_reference text,
error_code text,
expires_at timestamptz NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (user_id, provider, idempotency_key)
);
CREATE UNIQUE INDEX idx_payment_provider_operations_order
ON public.payment_provider_operations(provider, provider_order_id)
WHERE provider_order_id IS NOT NULL;
CREATE INDEX idx_payment_provider_operations_active
ON public.payment_provider_operations(user_id, expires_at)
WHERE state IN ('reserved', 'external_created', 'charged');
ALTER TABLE public.payment_provider_operations ENABLE ROW LEVEL SECURITY;
-- No authenticated policies: operations contain server-side payment state.
CREATE TRIGGER set_updated_at_payment_provider_operations
BEFORE UPDATE ON public.payment_provider_operations
FOR EACH ROW EXECUTE FUNCTION public.moddatetime();
CREATE OR REPLACE FUNCTION public.mirror_subscription_payment_provider()
RETURNS trigger
LANGUAGE plpgsql
SET search_path = public, pg_temp
AS $$
BEGIN
NEW.payment_provider := CASE
WHEN NEW.provider = 'admin' THEN 'none'
ELSE NEW.provider
END;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS mirror_subscription_payment_provider ON public.subscriptions;
CREATE TRIGGER mirror_subscription_payment_provider
BEFORE INSERT OR UPDATE OF provider, payment_provider ON public.subscriptions
FOR EACH ROW EXECUTE FUNCTION public.mirror_subscription_payment_provider();
UPDATE public.subscriptions
SET payment_provider = CASE WHEN provider = 'admin' THEN 'none' ELSE provider END
WHERE payment_provider IS DISTINCT FROM
CASE WHEN provider = 'admin' THEN 'none' ELSE provider END;
CREATE OR REPLACE FUNCTION public.sync_profile_tier_from_subscription()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public, pg_temp
AS $$
BEGIN
UPDATE public.profiles
SET tier = NEW.tier,
updated_at = now()
WHERE id = NEW.user_id
AND tier IS DISTINCT FROM NEW.tier;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS sync_profile_tier_from_subscription ON public.subscriptions;
CREATE TRIGGER sync_profile_tier_from_subscription
AFTER INSERT OR UPDATE OF tier ON public.subscriptions
FOR EACH ROW EXECUTE FUNCTION public.sync_profile_tier_from_subscription();
UPDATE public.profiles AS p
SET tier = s.tier,
updated_at = now()
FROM public.subscriptions AS s
WHERE s.user_id = p.id
AND p.tier IS DISTINCT FROM s.tier;
-- Reserve a provider operation before any external API call. A live operation
-- for the user blocks every different request, including the same provider,
-- while an exact idempotency-key replay returns the existing operation.
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;
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;
$$;
CREATE OR REPLACE FUNCTION public.mark_payment_provider_operation(
p_operation_id uuid,
p_state text,
p_external_reference text DEFAULT NULL,
p_error_code text DEFAULT NULL
) RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public, pg_temp
AS $$
DECLARE
v_operation public.payment_provider_operations%ROWTYPE;
BEGIN
IF p_state NOT IN ('external_created', 'charged', 'failed') THEN
RAISE EXCEPTION 'invalid_operation_state';
END IF;
IF p_external_reference IS NOT NULL AND length(p_external_reference) > 255 THEN
RAISE EXCEPTION 'invalid_external_reference';
END IF;
IF p_error_code IS NOT NULL AND p_error_code !~ '^[a-z0-9_:-]{1,100}$' THEN
RAISE EXCEPTION 'invalid_error_code';
END IF;
SELECT *
INTO v_operation
FROM public.payment_provider_operations
WHERE id = p_operation_id;
IF v_operation.id IS NULL THEN
RAISE EXCEPTION 'operation_not_found';
END IF;
PERFORM pg_advisory_xact_lock(hashtextextended(v_operation.user_id::text, 73031));
SELECT *
INTO v_operation
FROM public.payment_provider_operations
WHERE id = p_operation_id
FOR UPDATE;
IF v_operation.state IN ('applied', 'failed') THEN
RETURN jsonb_build_object(
'operation_id', v_operation.id,
'state', v_operation.state,
'duplicate', true
);
END IF;
IF p_state = 'charged' AND v_operation.operation_type = 'cancellation' THEN
RAISE EXCEPTION 'invalid_cancellation_state';
END IF;
UPDATE public.payment_provider_operations
SET state = p_state,
external_reference = coalesce(nullif(trim(p_external_reference), ''), external_reference),
error_code = CASE WHEN p_state = 'failed' THEN p_error_code ELSE NULL END,
updated_at = now()
WHERE id = p_operation_id
RETURNING * INTO v_operation;
RETURN jsonb_build_object(
'operation_id', v_operation.id,
'state', v_operation.state,
'duplicate', false
);
END;
$$;
-- Record a provider event that carries correlation metadata but is not itself
-- authoritative for entitlement (for example Stripe checkout.session.completed).
-- It is replay-protected without advancing the entitlement ordering cursor.
CREATE OR REPLACE FUNCTION public.record_payment_provider_observation(
p_user_id uuid,
p_provider text,
p_event_id text,
p_event_created_at timestamptz,
p_event_type text,
p_payload_digest text,
p_provider_resource_id text
) RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public, pg_temp
AS $$
DECLARE
v_event public.payment_provider_events%ROWTYPE;
v_result jsonb;
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', 'admin') THEN
RAISE EXCEPTION 'invalid_provider';
END IF;
IF p_event_id IS NULL OR length(trim(p_event_id)) NOT BETWEEN 3 AND 255
OR p_event_type IS NULL OR length(trim(p_event_type)) NOT BETWEEN 1 AND 100
OR p_provider_resource_id IS NULL
OR length(trim(p_provider_resource_id)) NOT BETWEEN 1 AND 255 THEN
RAISE EXCEPTION 'invalid_provider_observation';
END IF;
IF p_event_created_at IS NULL OR p_event_created_at > now() + interval '10 minutes' THEN
RAISE EXCEPTION 'invalid_event_created_at';
END IF;
IF p_payload_digest IS NULL OR p_payload_digest !~ '^[0-9a-f]{64}$' THEN
RAISE EXCEPTION 'invalid_payload_digest';
END IF;
PERFORM pg_advisory_xact_lock(hashtextextended(p_user_id::text, 73031));
v_result := jsonb_build_object('observed', true, 'duplicate', false);
INSERT INTO public.payment_provider_events (
provider, event_id, user_id, provider_resource_id, event_type,
event_created_at, payload_digest, disposition, result, processed_at
) VALUES (
p_provider, trim(p_event_id), p_user_id, trim(p_provider_resource_id),
trim(p_event_type), p_event_created_at, p_payload_digest,
'ignored', v_result, now()
)
ON CONFLICT (provider, event_id) DO NOTHING
RETURNING * INTO v_event;
IF v_event.id IS NULL THEN
SELECT * INTO v_event
FROM public.payment_provider_events
WHERE provider = p_provider AND event_id = trim(p_event_id)
FOR UPDATE;
IF v_event.user_id <> p_user_id
OR v_event.provider_resource_id <> trim(p_provider_resource_id)
OR v_event.event_type <> trim(p_event_type)
OR v_event.payload_digest <> p_payload_digest THEN
RAISE EXCEPTION 'provider_event_payload_mismatch';
END IF;
RETURN coalesce(v_event.result, v_result) || jsonb_build_object('duplicate', true);
END IF;
RETURN v_result;
END;
$$;
-- Apply one authoritative provider event. Provider ownership is strict: an
-- event can never overwrite another provider. A cancellation can affect only
-- the exact provider resource currently owning the entitlement.
CREATE OR REPLACE FUNCTION public.apply_payment_provider_event(
p_user_id uuid,
p_provider text,
p_event_id text,
p_event_created_at timestamptz,
p_event_type text,
p_payload_digest text,
p_provider_resource_id text,
p_tier text,
p_status text,
p_entitled boolean,
p_current_period_start timestamptz DEFAULT NULL,
p_current_period_end timestamptz DEFAULT NULL,
p_cancel_at timestamptz DEFAULT NULL,
p_auto_renewing boolean DEFAULT NULL,
p_provider_customer_id text DEFAULT NULL,
p_provider_order_id text DEFAULT NULL,
p_store_product_id text DEFAULT NULL,
p_store_purchase_id uuid DEFAULT NULL,
p_operation_id uuid DEFAULT NULL
) RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public, pg_temp
AS $$
DECLARE
v_event public.payment_provider_events%ROWTYPE;
v_cursor public.payment_provider_cursors%ROWTYPE;
v_subscription public.subscriptions%ROWTYPE;
v_result jsonb;
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', 'admin') THEN
RAISE EXCEPTION 'invalid_provider';
END IF;
IF p_event_id IS NULL OR length(trim(p_event_id)) NOT BETWEEN 3 AND 255 THEN
RAISE EXCEPTION 'invalid_event_id';
END IF;
IF p_event_created_at IS NULL OR p_event_created_at > now() + interval '10 minutes' THEN
RAISE EXCEPTION 'invalid_event_created_at';
END IF;
IF p_event_type IS NULL OR length(trim(p_event_type)) NOT BETWEEN 1 AND 100 THEN
RAISE EXCEPTION 'invalid_event_type';
END IF;
IF p_payload_digest IS NULL OR p_payload_digest !~ '^[0-9a-f]{64}$' THEN
RAISE EXCEPTION 'invalid_payload_digest';
END IF;
IF p_provider_resource_id IS NULL
OR length(trim(p_provider_resource_id)) NOT BETWEEN 1 AND 255 THEN
RAISE EXCEPTION 'invalid_provider_resource_id';
END IF;
IF p_tier NOT IN ('free', 'pro', 'pro_plus') THEN
RAISE EXCEPTION 'invalid_tier';
END IF;
IF p_entitled AND p_tier = 'free' THEN
RAISE EXCEPTION 'entitled_tier_must_be_paid';
END IF;
IF p_status IS NULL OR p_status NOT IN (
'active', 'trialing', 'past_due', 'canceled', 'unpaid', 'incomplete',
'incomplete_expired', 'paused', 'on_hold', 'expired', 'refunded', 'pending'
) THEN
RAISE EXCEPTION 'invalid_status';
END IF;
IF p_current_period_start IS NOT NULL
AND p_current_period_end IS NOT NULL
AND p_current_period_end < p_current_period_start THEN
RAISE EXCEPTION 'invalid_subscription_period';
END IF;
IF p_provider_customer_id IS NOT NULL AND length(p_provider_customer_id) > 255 THEN
RAISE EXCEPTION 'invalid_provider_customer_id';
END IF;
IF p_provider_order_id IS NOT NULL AND length(p_provider_order_id) > 255 THEN
RAISE EXCEPTION 'invalid_provider_order_id';
END IF;
PERFORM pg_advisory_xact_lock(hashtextextended(p_user_id::text, 73031));
INSERT INTO public.payment_provider_events (
provider,
event_id,
user_id,
provider_resource_id,
event_type,
event_created_at,
payload_digest
) VALUES (
p_provider,
trim(p_event_id),
p_user_id,
trim(p_provider_resource_id),
trim(p_event_type),
p_event_created_at,
p_payload_digest
)
ON CONFLICT (provider, event_id) DO NOTHING
RETURNING * INTO v_event;
IF v_event.id IS NULL THEN
SELECT *
INTO v_event
FROM public.payment_provider_events
WHERE provider = p_provider
AND event_id = trim(p_event_id)
FOR UPDATE;
IF v_event.user_id <> p_user_id
OR v_event.provider_resource_id <> trim(p_provider_resource_id)
OR v_event.event_type <> trim(p_event_type)
OR v_event.payload_digest <> p_payload_digest THEN
RAISE EXCEPTION 'provider_event_payload_mismatch';
END IF;
RETURN coalesce(
v_event.result,
jsonb_build_object(
'applied', false,
'duplicate', true,
'reason', 'event_processing_in_progress'
)
) || jsonb_build_object('duplicate', true);
END IF;
SELECT *
INTO v_cursor
FROM public.payment_provider_cursors
WHERE user_id = p_user_id
AND provider = p_provider
FOR UPDATE;
IF v_cursor.user_id IS NOT NULL AND (
v_cursor.last_event_created_at > p_event_created_at
OR (
v_cursor.last_event_created_at = p_event_created_at
AND v_cursor.last_event_id >= trim(p_event_id)
)
) THEN
v_result := jsonb_build_object(
'applied', false,
'duplicate', false,
'reason', 'stale_provider_event'
);
UPDATE public.payment_provider_events
SET disposition = 'ignored', result = v_result, processed_at = now()
WHERE id = v_event.id;
RETURN v_result;
END IF;
INSERT INTO public.payment_provider_cursors (
user_id, provider, last_event_created_at, last_event_id
) VALUES (
p_user_id, p_provider, p_event_created_at, trim(p_event_id)
)
ON CONFLICT (user_id, provider) DO UPDATE
SET last_event_created_at = EXCLUDED.last_event_created_at,
last_event_id = EXCLUDED.last_event_id,
updated_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_id IS NOT NULL AND NOT EXISTS (
SELECT 1
FROM public.payment_provider_operations
WHERE id = p_operation_id
AND user_id = p_user_id
AND provider = p_provider
) THEN
RAISE EXCEPTION 'invalid_payment_operation';
END IF;
IF p_entitled AND EXISTS (
SELECT 1
FROM public.payment_provider_operations
WHERE user_id = p_user_id
AND provider <> p_provider
AND state IN ('reserved', 'external_created', 'charged')
AND expires_at > now()
) THEN
v_result := jsonb_build_object(
'applied', false,
'duplicate', false,
'reason', 'other_provider_operation_in_progress'
);
UPDATE public.payment_provider_events
SET disposition = 'rejected', result = v_result, processed_at = now()
WHERE id = v_event.id;
RETURN v_result;
END IF;
IF p_entitled AND v_subscription.provider NOT IN ('none', p_provider) THEN
v_result := jsonb_build_object(
'applied', false,
'duplicate', false,
'reason', 'active_subscription_other_provider',
'owner_provider', v_subscription.provider
);
UPDATE public.payment_provider_events
SET disposition = 'rejected', result = v_result, processed_at = now()
WHERE id = v_event.id;
RETURN v_result;
END IF;
IF NOT p_entitled AND v_subscription.provider <> p_provider THEN
v_result := jsonb_build_object(
'applied', false,
'duplicate', false,
'reason', 'provider_not_owner',
'owner_provider', v_subscription.provider
);
UPDATE public.payment_provider_events
SET disposition = 'ignored', result = v_result, processed_at = now()
WHERE id = v_event.id;
RETURN v_result;
END IF;
IF NOT p_entitled
AND v_subscription.provider_resource_id IS DISTINCT FROM trim(p_provider_resource_id) THEN
v_result := jsonb_build_object(
'applied', false,
'duplicate', false,
'reason', 'provider_resource_not_owner'
);
UPDATE public.payment_provider_events
SET disposition = 'ignored', result = v_result, processed_at = now()
WHERE id = v_event.id;
RETURN v_result;
END IF;
IF p_entitled THEN
UPDATE public.subscriptions
SET tier = p_tier,
status = p_status,
current_period_start = p_current_period_start,
current_period_end = p_current_period_end,
cancel_at = p_cancel_at,
provider = p_provider,
provider_resource_id = trim(p_provider_resource_id),
provider_event_id = trim(p_event_id),
provider_event_created_at = p_event_created_at,
auto_renewing = p_auto_renewing,
stripe_customer_id = CASE
WHEN p_provider = 'stripe' THEN coalesce(nullif(trim(p_provider_customer_id), ''), stripe_customer_id)
ELSE stripe_customer_id
END,
stripe_subscription_id = CASE
WHEN p_provider = 'stripe' THEN trim(p_provider_resource_id)
ELSE stripe_subscription_id
END,
payple_payer_id = CASE
WHEN p_provider = 'payple' AND p_provider_customer_id = '' THEN NULL
WHEN p_provider = 'payple' AND p_provider_customer_id IS NOT NULL
THEN trim(p_provider_customer_id)
ELSE payple_payer_id
END,
payple_pay_oid = CASE
WHEN p_provider = 'payple' THEN coalesce(nullif(trim(p_provider_order_id), ''), payple_pay_oid)
ELSE payple_pay_oid
END,
store_product_id = CASE
WHEN p_provider IN ('google_play', 'app_store') THEN p_store_product_id
ELSE NULL
END,
store_purchase_id = CASE
WHEN p_provider IN ('google_play', 'app_store') THEN p_store_purchase_id
ELSE NULL
END,
renewal_failures = CASE WHEN p_provider = 'payple' THEN 0 ELSE renewal_failures END,
updated_at = now()
WHERE user_id = p_user_id;
ELSE
UPDATE public.subscriptions
SET tier = 'free',
status = p_status,
current_period_start = coalesce(p_current_period_start, current_period_start),
current_period_end = coalesce(p_current_period_end, current_period_end),
cancel_at = coalesce(p_cancel_at, p_current_period_end, now()),
provider = 'none',
provider_resource_id = NULL,
provider_event_id = trim(p_event_id),
provider_event_created_at = p_event_created_at,
auto_renewing = false,
store_product_id = NULL,
store_purchase_id = NULL,
updated_at = now()
WHERE user_id = p_user_id;
END IF;
IF p_operation_id IS NOT NULL THEN
UPDATE public.payment_provider_operations
SET state = 'applied',
external_reference = coalesce(
nullif(trim(p_provider_order_id), ''),
nullif(trim(p_provider_resource_id), ''),
external_reference
),
error_code = NULL,
updated_at = now()
WHERE id = p_operation_id;
END IF;
v_result := jsonb_build_object(
'applied', true,
'duplicate', false,
'provider', CASE WHEN p_entitled THEN p_provider ELSE 'none' END,
'tier', CASE WHEN p_entitled THEN p_tier ELSE 'free' END,
'status', p_status,
'entitled', p_entitled
);
UPDATE public.payment_provider_events
SET disposition = 'applied', result = v_result, processed_at = now()
WHERE id = v_event.id;
RETURN v_result;
END;
$$;
CREATE OR REPLACE FUNCTION public.record_payment_provider_renewal_failure(
p_user_id uuid,
p_provider text,
p_event_id text,
p_event_created_at timestamptz,
p_payload_digest text,
p_provider_resource_id text,
p_error_code text,
p_failure_threshold integer,
p_operation_id uuid DEFAULT NULL
) RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public, pg_temp
AS $$
DECLARE
v_event public.payment_provider_events%ROWTYPE;
v_cursor public.payment_provider_cursors%ROWTYPE;
v_subscription public.subscriptions%ROWTYPE;
v_failures integer;
v_revoked boolean := false;
v_result jsonb;
BEGIN
IF p_provider NOT IN ('stripe', 'payple', 'google_play', 'app_store') THEN
RAISE EXCEPTION 'invalid_provider';
END IF;
IF p_event_id IS NULL OR length(trim(p_event_id)) NOT BETWEEN 3 AND 255 THEN
RAISE EXCEPTION 'invalid_event_id';
END IF;
IF p_event_created_at IS NULL OR p_event_created_at > now() + interval '10 minutes' THEN
RAISE EXCEPTION 'invalid_event_created_at';
END IF;
IF p_payload_digest IS NULL OR p_payload_digest !~ '^[0-9a-f]{64}$' THEN
RAISE EXCEPTION 'invalid_payload_digest';
END IF;
IF p_provider_resource_id IS NULL OR length(trim(p_provider_resource_id)) NOT BETWEEN 1 AND 255 THEN
RAISE EXCEPTION 'invalid_provider_resource_id';
END IF;
IF p_error_code IS NULL OR p_error_code !~ '^[a-z0-9_:-]{1,100}$' THEN
RAISE EXCEPTION 'invalid_error_code';
END IF;
IF p_failure_threshold NOT BETWEEN 1 AND 10 THEN
RAISE EXCEPTION 'invalid_failure_threshold';
END IF;
PERFORM pg_advisory_xact_lock(hashtextextended(p_user_id::text, 73031));
INSERT INTO public.payment_provider_events (
provider, event_id, user_id, provider_resource_id, event_type,
event_created_at, payload_digest
) VALUES (
p_provider, trim(p_event_id), p_user_id, trim(p_provider_resource_id),
'renewal.failed', p_event_created_at, p_payload_digest
)
ON CONFLICT (provider, event_id) DO NOTHING
RETURNING * INTO v_event;
IF v_event.id IS NULL THEN
SELECT * INTO v_event
FROM public.payment_provider_events
WHERE provider = p_provider AND event_id = trim(p_event_id)
FOR UPDATE;
IF v_event.user_id <> p_user_id OR v_event.payload_digest <> p_payload_digest THEN
RAISE EXCEPTION 'provider_event_payload_mismatch';
END IF;
RETURN coalesce(v_event.result, jsonb_build_object(
'applied', false, 'reason', 'event_processing_in_progress'
)) || jsonb_build_object('duplicate', true);
END IF;
SELECT * INTO v_cursor
FROM public.payment_provider_cursors
WHERE user_id = p_user_id AND provider = p_provider
FOR UPDATE;
IF v_cursor.user_id IS NOT NULL AND (
v_cursor.last_event_created_at > p_event_created_at
OR (v_cursor.last_event_created_at = p_event_created_at
AND v_cursor.last_event_id >= trim(p_event_id))
) THEN
v_result := jsonb_build_object('applied', false, 'duplicate', false, 'reason', 'stale_provider_event');
UPDATE public.payment_provider_events
SET disposition = 'ignored', result = v_result, processed_at = now()
WHERE id = v_event.id;
RETURN v_result;
END IF;
INSERT INTO public.payment_provider_cursors (user_id, provider, last_event_created_at, last_event_id)
VALUES (p_user_id, p_provider, p_event_created_at, trim(p_event_id))
ON CONFLICT (user_id, provider) DO UPDATE
SET last_event_created_at = EXCLUDED.last_event_created_at,
last_event_id = EXCLUDED.last_event_id,
updated_at = now();
SELECT * INTO v_subscription
FROM public.subscriptions
WHERE user_id = p_user_id
FOR UPDATE;
IF v_subscription.id IS NULL
OR v_subscription.provider <> p_provider
OR v_subscription.provider_resource_id IS DISTINCT FROM trim(p_provider_resource_id) THEN
v_result := jsonb_build_object('applied', false, 'duplicate', false, 'reason', 'provider_resource_not_owner');
UPDATE public.payment_provider_events
SET disposition = 'ignored', result = v_result, processed_at = now()
WHERE id = v_event.id;
RETURN v_result;
END IF;
v_failures := v_subscription.renewal_failures + 1;
v_revoked := v_failures >= p_failure_threshold
AND coalesce(v_subscription.current_period_end, now()) <= now();
UPDATE public.subscriptions
SET renewal_failures = v_failures,
status = CASE WHEN v_revoked THEN 'expired' ELSE 'past_due' END,
tier = CASE WHEN v_revoked THEN 'free' ELSE tier END,
provider = CASE WHEN v_revoked THEN 'none' ELSE provider END,
provider_resource_id = CASE WHEN v_revoked THEN NULL ELSE provider_resource_id END,
auto_renewing = CASE WHEN v_revoked THEN false ELSE auto_renewing END,
provider_event_id = trim(p_event_id),
provider_event_created_at = p_event_created_at,
updated_at = now()
WHERE user_id = p_user_id;
IF p_operation_id IS NOT NULL THEN
UPDATE public.payment_provider_operations
SET state = 'failed', error_code = p_error_code, updated_at = now()
WHERE id = p_operation_id
AND user_id = p_user_id
AND provider = p_provider;
END IF;
v_result := jsonb_build_object(
'applied', true,
'duplicate', false,
'renewal_failures', v_failures,
'revoked', v_revoked,
'status', CASE WHEN v_revoked THEN 'expired' ELSE 'past_due' END
);
UPDATE public.payment_provider_events
SET disposition = 'applied', result = v_result, processed_at = now()
WHERE id = v_event.id;
RETURN v_result;
END;
$$;
-- Preserve the public signature used by iap-verify and RTDN. Store receipt
-- persistence happens under the same user lock, then the normalized store
-- entitlement is routed through apply_payment_provider_event.
CREATE OR REPLACE FUNCTION public.apply_verified_store_purchase(
p_user_id uuid,
p_platform text,
p_product_id text,
p_store_transaction_id text,
p_token_hash text,
p_purchase_token text,
p_purchase_state text,
p_purchase_at timestamptz,
p_expires_at timestamptz,
p_auto_renewing boolean,
p_acknowledged boolean,
p_tier text,
p_entitled boolean,
p_verification jsonb
) RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public, pg_temp
AS $$
DECLARE
v_purchase_id uuid;
v_existing_user_id uuid;
v_status text;
v_event_id text;
v_result jsonb;
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_platform NOT IN ('google_play', 'app_store') THEN
RAISE EXCEPTION 'invalid_platform';
END IF;
IF p_tier NOT IN ('pro', 'pro_plus') THEN
RAISE EXCEPTION 'invalid_tier';
END IF;
IF p_purchase_state NOT IN ('pending', 'purchased', 'cancelled', 'expired', 'refunded', 'on_hold', 'paused') THEN
RAISE EXCEPTION 'invalid_purchase_state';
END IF;
IF p_token_hash IS NULL OR p_token_hash !~ '^[0-9a-f]{64}$'
OR p_purchase_token IS NULL OR length(trim(p_purchase_token)) < 8 THEN
RAISE EXCEPTION 'invalid_purchase_token';
END IF;
PERFORM pg_advisory_xact_lock(hashtextextended(p_user_id::text, 73031));
SELECT user_id INTO v_existing_user_id
FROM public.iap_purchases
WHERE platform = p_platform AND token_hash = p_token_hash
FOR UPDATE;
IF v_existing_user_id IS NOT NULL AND v_existing_user_id <> p_user_id THEN
RAISE EXCEPTION 'purchase_owned_by_other_user';
END IF;
INSERT INTO public.iap_purchases (
user_id, platform, product_id, store_transaction_id, token_hash,
purchase_state, purchase_at, expires_at, auto_renewing,
acknowledged_at, verified_at
) VALUES (
p_user_id, p_platform, trim(p_product_id), nullif(trim(p_store_transaction_id), ''),
p_token_hash, p_purchase_state, p_purchase_at, p_expires_at, p_auto_renewing,
CASE WHEN p_acknowledged THEN now() ELSE NULL END, now()
)
ON CONFLICT (platform, token_hash) DO UPDATE
SET product_id = EXCLUDED.product_id,
store_transaction_id = EXCLUDED.store_transaction_id,
purchase_state = EXCLUDED.purchase_state,
purchase_at = EXCLUDED.purchase_at,
expires_at = EXCLUDED.expires_at,
auto_renewing = EXCLUDED.auto_renewing,
acknowledged_at = CASE
WHEN EXCLUDED.acknowledged_at IS NOT NULL THEN EXCLUDED.acknowledged_at
ELSE public.iap_purchases.acknowledged_at
END,
verified_at = now(),
updated_at = now()
RETURNING id INTO v_purchase_id;
INSERT INTO public.iap_purchase_receipts (purchase_id, purchase_token, verification)
VALUES (v_purchase_id, p_purchase_token, coalesce(p_verification, '{}'::jsonb))
ON CONFLICT (purchase_id) DO UPDATE
SET purchase_token = EXCLUDED.purchase_token,
verification = EXCLUDED.verification,
updated_at = now();
v_status := CASE p_purchase_state
WHEN 'purchased' THEN 'active'
WHEN 'cancelled' THEN 'canceled'
WHEN 'on_hold' THEN 'on_hold'
ELSE p_purchase_state
END;
v_event_id := concat_ws(
':', p_platform, p_token_hash, p_purchase_state,
coalesce(extract(epoch FROM p_expires_at)::bigint::text, 'none'),
CASE WHEN p_entitled THEN 'entitled' ELSE 'not-entitled' END
);
v_result := public.apply_payment_provider_event(
p_user_id => p_user_id,
p_provider => p_platform,
p_event_id => v_event_id,
p_event_created_at => clock_timestamp(),
p_event_type => 'store.purchase.' || p_purchase_state,
p_payload_digest => encode(extensions.digest(coalesce(p_verification, '{}'::jsonb)::text, 'sha256'), 'hex'),
p_provider_resource_id => v_purchase_id::text,
p_tier => CASE WHEN p_entitled THEN p_tier ELSE 'free' END,
p_status => v_status,
p_entitled => p_entitled,
p_current_period_start => p_purchase_at,
p_current_period_end => p_expires_at,
p_cancel_at => CASE WHEN p_auto_renewing THEN NULL ELSE p_expires_at END,
p_auto_renewing => p_auto_renewing,
p_provider_customer_id => NULL,
p_provider_order_id => p_store_transaction_id,
p_store_product_id => trim(p_product_id),
p_store_purchase_id => v_purchase_id,
p_operation_id => NULL
);
IF p_entitled AND v_result->>'reason' IN (
'active_subscription_other_provider',
'other_provider_operation_in_progress'
) THEN
RAISE EXCEPTION 'active_subscription_other_provider';
END IF;
RETURN v_result || jsonb_build_object(
'purchase_id', v_purchase_id,
'acknowledged', p_acknowledged
);
END;
$$;
-- Preserve the Google Play wrapper signature and linked-purchase semantics.
CREATE OR REPLACE FUNCTION public.apply_verified_google_play_purchase(
p_user_id uuid,
p_platform text,
p_product_id text,
p_store_transaction_id text,
p_token_hash text,
p_linked_token_hash text,
p_purchase_token text,
p_purchase_state text,
p_purchase_at timestamptz,
p_expires_at timestamptz,
p_auto_renewing boolean,
p_acknowledged boolean,
p_tier text,
p_entitled boolean,
p_verification jsonb
) RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public, pg_temp
AS $$
DECLARE
v_linked_purchase_id uuid;
v_linked_user_id uuid;
v_linked_revoked boolean := false;
v_result jsonb;
BEGIN
IF p_platform <> 'google_play' THEN
RAISE EXCEPTION 'invalid_platform';
END IF;
PERFORM pg_advisory_xact_lock(hashtextextended(p_user_id::text, 73031));
IF p_linked_token_hash IS NOT NULL THEN
IF p_linked_token_hash !~ '^[0-9a-f]{64}$' OR p_linked_token_hash = p_token_hash THEN
RAISE EXCEPTION 'invalid_linked_purchase_token';
END IF;
SELECT id, user_id INTO v_linked_purchase_id, v_linked_user_id
FROM public.iap_purchases
WHERE platform = 'google_play' AND token_hash = p_linked_token_hash
FOR UPDATE;
IF v_linked_user_id IS NOT NULL AND v_linked_user_id <> p_user_id THEN
RAISE EXCEPTION 'linked_purchase_owned_by_other_user';
END IF;
IF v_linked_purchase_id IS NOT NULL THEN
UPDATE public.iap_purchases
SET purchase_state = 'expired',
expires_at = least(coalesce(expires_at, now()), now()),
auto_renewing = false,
verified_at = now(),
updated_at = now()
WHERE id = v_linked_purchase_id;
v_linked_revoked := true;
IF NOT p_entitled AND EXISTS (
SELECT 1 FROM public.subscriptions
WHERE user_id = p_user_id
AND provider = 'google_play'
AND store_purchase_id = v_linked_purchase_id
) THEN
PERFORM public.apply_payment_provider_event(
p_user_id => p_user_id,
p_provider => 'google_play',
p_event_id => 'google_play:linked-revoked:' || p_linked_token_hash || ':' || p_token_hash,
p_event_created_at => clock_timestamp(),
p_event_type => 'store.purchase.linked_revoked',
p_payload_digest => encode(extensions.digest(p_linked_token_hash || ':' || p_token_hash, 'sha256'), 'hex'),
p_provider_resource_id => v_linked_purchase_id::text,
p_tier => 'free',
p_status => 'expired',
p_entitled => false,
p_current_period_start => NULL,
p_current_period_end => now(),
p_cancel_at => now(),
p_auto_renewing => false,
p_provider_customer_id => NULL,
p_provider_order_id => NULL,
p_store_product_id => NULL,
p_store_purchase_id => v_linked_purchase_id,
p_operation_id => NULL
);
END IF;
END IF;
END IF;
v_result := public.apply_verified_store_purchase(
p_user_id,
p_platform,
p_product_id,
p_store_transaction_id,
p_token_hash,
p_purchase_token,
p_purchase_state,
p_purchase_at,
p_expires_at,
p_auto_renewing,
p_acknowledged,
p_tier,
p_entitled,
p_verification
);
RETURN v_result || jsonb_build_object('linked_purchase_revoked', v_linked_revoked);
END;
$$;
REVOKE ALL ON TABLE public.payment_provider_events FROM PUBLIC, anon, authenticated;
REVOKE ALL ON TABLE public.payment_provider_cursors FROM PUBLIC, anon, authenticated;
REVOKE ALL ON TABLE public.payment_provider_operations FROM PUBLIC, anon, authenticated;
GRANT ALL ON TABLE public.payment_provider_events TO service_role;
GRANT ALL ON TABLE public.payment_provider_cursors TO service_role;
GRANT ALL ON TABLE public.payment_provider_operations TO service_role;
REVOKE ALL ON FUNCTION public.reserve_payment_provider_operation(
uuid, text, text, text, text, text, text
) FROM PUBLIC, anon, authenticated;
REVOKE ALL ON FUNCTION public.mark_payment_provider_operation(
uuid, text, text, text
) FROM PUBLIC, anon, authenticated;
REVOKE ALL ON FUNCTION public.record_payment_provider_observation(
uuid, text, text, timestamptz, text, text, text
) FROM PUBLIC, anon, authenticated;
REVOKE ALL ON FUNCTION public.apply_payment_provider_event(
uuid, text, text, timestamptz, text, text, text, text, text, boolean,
timestamptz, timestamptz, timestamptz, boolean, text, text, text, uuid, uuid
) FROM PUBLIC, anon, authenticated;
REVOKE ALL ON FUNCTION public.record_payment_provider_renewal_failure(
uuid, text, text, timestamptz, text, text, text, integer, uuid
) FROM PUBLIC, anon, authenticated;
REVOKE ALL ON FUNCTION public.apply_verified_store_purchase(
uuid, text, text, text, text, text, text, timestamptz, timestamptz,
boolean, boolean, text, boolean, jsonb
) FROM PUBLIC, anon, authenticated;
REVOKE ALL ON FUNCTION public.apply_verified_google_play_purchase(
uuid, text, text, text, text, text, text, text, timestamptz, timestamptz,
boolean, boolean, text, boolean, jsonb
) FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.reserve_payment_provider_operation(
uuid, text, text, text, text, text, text
) TO service_role;
GRANT EXECUTE ON FUNCTION public.mark_payment_provider_operation(
uuid, text, text, text
) TO service_role;
GRANT EXECUTE ON FUNCTION public.record_payment_provider_observation(
uuid, text, text, timestamptz, text, text, text
) TO service_role;
GRANT EXECUTE ON FUNCTION public.apply_payment_provider_event(
uuid, text, text, timestamptz, text, text, text, text, text, boolean,
timestamptz, timestamptz, timestamptz, boolean, text, text, text, uuid, uuid
) TO service_role;
GRANT EXECUTE ON FUNCTION public.record_payment_provider_renewal_failure(
uuid, text, text, timestamptz, text, text, text, integer, uuid
) TO service_role;
GRANT EXECUTE ON FUNCTION public.apply_verified_store_purchase(
uuid, text, text, text, text, text, text, timestamptz, timestamptz,
boolean, boolean, text, boolean, jsonb
) TO service_role;
GRANT EXECUTE ON FUNCTION public.apply_verified_google_play_purchase(
uuid, text, text, text, text, text, text, text, timestamptz, timestamptz,
boolean, boolean, text, boolean, jsonb
) TO service_role;
COMMIT;