d3ro-voice/server/supabase/migrations/20260929100011_payple_cancellation_order_scope.sql
Yun Chan ba9ef9741e fix: red-team round 3 hardening across desktop, mobile, core and server
Batch of red-team r3 fixes that were in the working tree before the
2026-09-28 design overhaul, committed as one unit with their tests.

- desktop main: STT timeouts and sidecar, voice recording store, sync
  (credentials, audio, knowledge reindex, push gates), runtime
  provisioner, update policy, AltGr keybindings, voice-command policy,
  dictionary file codec/limits, meeting transcript condensing and a
  local recording ledger so interrupted-session recovery only closes
  meetings this device recorded (a phone's live meeting is left alone).
- mobile: login CSRF via implicit token callbacks rejected, account
  deletion/retention, durable queue retention, knowledge realtime
  without unfiltered DELETE, meeting re-record failure paths, cloud STT
  client, preferences store/resync.
- core: text chunking splits long unbroken transcripts to fit, template
  field policy, dictionary limits, meeting markdown inline handling.
- server: payple webhook policy and cancellation order scope, meeting
  document generation quota, team RPC null-role guard, unified LLM
  quota in-flight accounting, knowledge chunk vector index, meeting
  re-record failure paths (migrations 20260929*).
- ci: portable/runtime feed gates, update-policy schema, Forgejo file
  delete and alias planning.

Four older tests are updated to the new contracts rather than the old
behavior: token-pair auth callbacks are rejected, knowledge realtime no
longer subscribes to DELETE, long transcript lines are split, and
meeting recovery requires the local recording ledger for empty rows.
2026-09-28 20:45:52 +09:00

380 lines
14 KiB
PL/PgSQL

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