-- ============================================================================ -- Payple: a refund of an older order must not revoke the current period -- -- apply_payment_provider_event checked a non-entitled (revoking) event only -- against subscriptions.provider and provider_resource_id. For Payple that is -- the billing-key payer id, shared by every order charged on the key, and -- p_provider_order_id was ignored for revocations. A confirmed cancellation -- webhook for last month's order (a console refund of a duplicate or courtesy -- charge) therefore reset the whole subscription to free, dropping the period -- paid by the current order and stopping renewals. -- -- The payple-webhook edge function now ignores such cancellations itself -- (webhook-policy.ts, reason 'canceled_order_not_current'). This migration -- closes the remaining window where a renewal changes payple_pay_oid between -- the edge function's read and this function's per-user advisory lock. -- -- Only the new order-scope guard is added; the body is otherwise identical to -- 20260821000003_payment_provider_serialization.sql. CREATE OR REPLACE keeps -- the existing owner and grants (service_role only). -- ============================================================================ BEGIN; -- 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; -- A Payple revocation is order-scoped. Every order charged on one billing -- key shares provider_resource_id (the payer id), so the resource-ownership -- check below cannot tell last month's order from the current one. Only a -- cancellation of the order that funds the current period -- (subscriptions.payple_pay_oid) may revoke it. This runs before the cursor -- advances so an ignored refund of an older order cannot make a later -- legitimate event look stale. Callers that revoke without naming an order -- (payple-renew scheduled expiry, payple-manage) are unaffected. IF NOT p_entitled AND p_provider = 'payple' AND nullif(trim(p_provider_order_id), '') IS NOT NULL AND EXISTS ( SELECT 1 FROM public.subscriptions WHERE user_id = p_user_id AND provider = 'payple' AND payple_pay_oid IS DISTINCT FROM trim(p_provider_order_id) ) THEN v_result := jsonb_build_object( 'applied', false, 'duplicate', false, 'reason', 'canceled_order_not_current' ); 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; $$; COMMIT;