diff --git a/server/supabase/functions/admin-subscriptions/index.ts b/server/supabase/functions/admin-subscriptions/index.ts index ad2feb8..18c9f0c 100644 --- a/server/supabase/functions/admin-subscriptions/index.ts +++ b/server/supabase/functions/admin-subscriptions/index.ts @@ -8,7 +8,6 @@ import { actorEmail, adminErrorResponse, adminJsonResponse, - adminRpcError, parsePagination, parseSubscriptionMutationRequest, readAdminJson, @@ -19,6 +18,7 @@ import { validateQueryKeys, validateSubscriptionRpcResult, } from '../_shared/admin-contract.ts' +import { subscriptionRpcError } from './rpc-error.ts' const SUBSCRIPTION_COLUMNS = 'id, user_id, tier, status, provider, payment_provider, current_period_start, current_period_end, overage_credits, admin_note, cancel_at, created_at, updated_at' @@ -82,7 +82,7 @@ async function mutateSubscription(req: Request, action: SubscriptionAction): Pro p_admin_note: mutation.adminNote ?? null, p_memo: mutation.memo, }) - if (error) throw adminRpcError(error) + if (error) throw subscriptionRpcError(error) const response = validateSubscriptionRpcResult(data, mutation) return adminJsonResponse(response, corsHeaders, action === 'create' ? 201 : 200) } diff --git a/server/supabase/functions/admin-subscriptions/rpc-error.test.ts b/server/supabase/functions/admin-subscriptions/rpc-error.test.ts new file mode 100644 index 0000000..831cb43 --- /dev/null +++ b/server/supabase/functions/admin-subscriptions/rpc-error.test.ts @@ -0,0 +1,18 @@ +import { PROVIDER_MANAGED_SUBSCRIPTION, subscriptionRpcError } from './rpc-error.ts' + +function assert(condition: boolean, message: string): asserts condition { + if (!condition) throw new Error(message) +} + +Deno.test('provider-owned subscription edits surface as a specific 409', () => { + const error = subscriptionRpcError({ code: '23514', message: 'provider_managed_subscription' }) + assert(error.status === 409, `status 409, got ${error.status}`) + assert(error.code === PROVIDER_MANAGED_SUBSCRIPTION, `code ${error.code}`) +}) + +Deno.test('other subscription RPC errors keep the shared admin mapping', () => { + const forbidden = subscriptionRpcError({ code: '42501', message: 'admin_role_required' }) + assert(forbidden.status === 403 && forbidden.code === 'admin_role_required', 'admin role mapping kept') + const conflict = subscriptionRpcError({ code: '23514', message: 'some_other_check' }) + assert(conflict.status === 409 && conflict.code === 'admin_operation_conflict', 'generic conflict kept') +}) diff --git a/server/supabase/functions/admin-subscriptions/rpc-error.ts b/server/supabase/functions/admin-subscriptions/rpc-error.ts new file mode 100644 index 0000000..4e96c08 --- /dev/null +++ b/server/supabase/functions/admin-subscriptions/rpc-error.ts @@ -0,0 +1,20 @@ +// server/supabase/functions/admin-subscriptions/rpc-error.ts +// admin_mutate_subscription_v1 오류를 공개 오류 코드로 바꾼다. 구독 전용 거절 사유를 +// 먼저 판별하고, 나머지는 공통 관리자 RPC 매핑(adminRpcError)에 맡긴다. + +import { AdminPublicError, adminRpcError } from '../_shared/admin-contract.ts' + +type AdminRpcErrorLike = Parameters[0] + +/** + * 결제 사업자(Payple·스토어)가 소유한 구독의 등급·상태·기간을 관리자 화면에서 바꾸려 할 때 + * SQL이 던지는 사유. 사업자가 청구 조건의 정본이므로 관리자 수정은 충돌(409)로 거절한다. + */ +export const PROVIDER_MANAGED_SUBSCRIPTION = 'provider_managed_subscription' + +export function subscriptionRpcError(error: AdminRpcErrorLike): AdminPublicError { + if ((error.message ?? '').includes(PROVIDER_MANAGED_SUBSCRIPTION)) { + return new AdminPublicError(409, PROVIDER_MANAGED_SUBSCRIPTION) + } + return adminRpcError(error) +} diff --git a/server/supabase/functions/payple-renew/index.ts b/server/supabase/functions/payple-renew/index.ts index 9075516..70b3edf 100644 --- a/server/supabase/functions/payple-renew/index.ts +++ b/server/supabase/functions/payple-renew/index.ts @@ -9,10 +9,12 @@ import { import { createPaypleRenewalGateway, createSupabaseRenewalStore } from './adapters.ts' import { normalizeRenewalCandidate, + type RenewableTier, type RenewalDeps, type RenewalResult, renewSubscription, } from './renewal.ts' +import { latestPaidTierByUser, withPaidTier } from './paid-tier.ts' type ServiceClient = ReturnType @@ -94,6 +96,32 @@ async function selectDueRenewals( return (data ?? []) as Record[] } +const PAID_TIER_QUERY_CHUNK = 100 + +/** + * 갱신 대상 사용자별로 마지막으로 반영된 Payple 결제 등급을 읽는다. + * 청구 금액의 정본은 이 원장이다 — subscriptions.tier는 청구 근거로 쓰지 않는다. + */ +async function loadLastPaidTiers( + serviceClient: ServiceClient, + userIds: readonly string[], +): Promise> { + const rows: Record[] = [] + for (let offset = 0; offset < userIds.length; offset += PAID_TIER_QUERY_CHUNK) { + const { data, error } = await serviceClient + .from('payment_provider_operations') + .select('user_id, requested_tier, updated_at, created_at') + .eq('provider', 'payple') + .eq('state', 'applied') + .in('operation_type', ['checkout', 'renewal']) + .not('requested_tier', 'is', null) + .in('user_id', userIds.slice(offset, offset + PAID_TIER_QUERY_CHUNK)) + if (error) throw new Error('renewal_paid_tier_query_failed') + rows.push(...((data ?? []) as Record[])) + } + return latestPaidTierByUser(rows) +} + export async function paypleRenewHandler(req: Request): Promise { if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders }) if (req.method !== 'POST') return jsonResponse({ error: 'method_not_allowed' }, 405) @@ -109,6 +137,11 @@ export async function paypleRenewHandler(req: Request): Promise { return jsonResponse({ renewed: 0, failed: 0, expired, results }) } + const userIds = dueRenewals + .map((row) => row.user_id) + .filter((userId): userId is string => typeof userId === 'string') + const paidTiers = await loadLastPaidTiers(serviceClient, userIds) + const deps: RenewalDeps = { store: createSupabaseRenewalStore(serviceClient), gateway: await createPaypleRenewalGateway(getPaypleConfig()), @@ -117,7 +150,7 @@ export async function paypleRenewHandler(req: Request): Promise { } for (const row of dueRenewals) { - const candidate = normalizeRenewalCandidate(row) + const candidate = normalizeRenewalCandidate(withPaidTier(row, paidTiers)) if (!candidate) { results.push({ userId: typeof row.user_id === 'string' ? row.user_id : '', diff --git a/server/supabase/functions/payple-renew/paid-tier.test.ts b/server/supabase/functions/payple-renew/paid-tier.test.ts new file mode 100644 index 0000000..2773a9a --- /dev/null +++ b/server/supabase/functions/payple-renew/paid-tier.test.ts @@ -0,0 +1,111 @@ +import { latestPaidTierByUser, withPaidTier } from './paid-tier.ts' +import { + type ChargeRequest, + normalizeRenewalCandidate, + type RenewalDeps, + type RenewalPaymentApplication, + renewSubscription, +} from './renewal.ts' +import { TIER_PRICE } from '../_shared/payple.ts' + +function assert(condition: boolean, message: string): asserts condition { + if (!condition) throw new Error(message) +} + +const USER = 'user0019-0000-0000-0000-000000000000' +const OTHER_USER = 'user0020-0000-0000-0000-000000000000' +const ORDER = 'D3RO-20260928100000-user0019-cccccccc' + +/** A Payple Pro subscriber whose row tier a back-office edit rewrote to pro_plus. */ +const tamperedDueRow: Record = { + user_id: USER, + tier: 'pro_plus', + payple_payer_id: 'payer-19', + provider_resource_id: 'payer-19', + current_period_end: '2026-09-27T00:00:00.000Z', + renewal_failures: 0, +} + +const appliedOperations: Record[] = [ + { user_id: USER, requested_tier: 'pro', updated_at: '2026-08-27T00:00:05.000Z', created_at: '2026-08-27T00:00:00.000Z' }, + { user_id: OTHER_USER, requested_tier: 'pro_plus', updated_at: '2026-09-01T00:00:00.000Z', created_at: '2026-09-01T00:00:00.000Z' }, +] + +function chargingHarness(): { deps: RenewalDeps; charges: ChargeRequest[]; applied: RenewalPaymentApplication[] } { + const charges: ChargeRequest[] = [] + const applied: RenewalPaymentApplication[] = [] + const deps: RenewalDeps = { + now: () => new Date('2026-09-28T01:00:00.000Z'), + newOrderId: () => ORDER, + store: { + reserveRenewal: () => Promise.resolve({ kind: 'created', operationId: 'op-19' }), + getOperation: () => Promise.resolve(null), + markOperation: () => Promise.resolve(true), + recordRenewalFailure: () => Promise.resolve(true), + applyRenewalPayment: (application) => { + applied.push(application) + return Promise.resolve({ applied: true, duplicate: false }) + }, + }, + gateway: { + charge: (request) => { + charges.push(request) + return Promise.resolve({ orderId: request.orderId, total: String(request.amount) }) + }, + lookup: () => Promise.resolve({ kind: 'unknown' }), + }, + } + return { deps, charges, applied } +} + +// ── 회귀: 관리자 등급 수정이 다음 갱신 청구 금액을 바꾸던 버그 ───────── + +Deno.test('renewal charges the tier the customer last paid for, not an edited row tier', async () => { + const candidate = normalizeRenewalCandidate( + withPaidTier(tamperedDueRow, latestPaidTierByUser(appliedOperations)), + ) + assert(candidate !== null, 'candidate is valid') + const { deps, charges, applied } = chargingHarness() + + const result = await renewSubscription(deps, candidate) + + assert(result.success, `renewal succeeds: ${JSON.stringify(result)}`) + assert(charges.length === 1, 'charged once') + assert(charges[0].amount === TIER_PRICE.pro, `charged the pro price, got ${charges[0].amount}`) + assert(applied.length === 1 && applied[0].candidate.tier === 'pro', 'entitlement renewed at the paid tier') +}) + +Deno.test('legacy subscriptions without a payment ledger entry keep their row tier', () => { + const row = { ...tamperedDueRow, user_id: 'legacy-user', tier: 'pro' } + const adjusted = withPaidTier(row, latestPaidTierByUser(appliedOperations)) + assert(adjusted === row, 'row is returned unchanged') + assert(normalizeRenewalCandidate(adjusted)?.tier === 'pro', 'legacy tier kept') +}) + +Deno.test('latest applied payment operation wins per user', () => { + const tiers = latestPaidTierByUser([ + { user_id: USER, requested_tier: 'pro', updated_at: '2026-07-01T00:00:00.000Z', created_at: '2026-07-01T00:00:00.000Z' }, + { user_id: USER, requested_tier: 'pro_plus', updated_at: '2026-09-01T00:00:00.000Z', created_at: '2026-09-01T00:00:00.000Z' }, + { user_id: USER, requested_tier: 'pro', updated_at: '2026-08-01T00:00:00.000Z', created_at: '2026-08-01T00:00:00.000Z' }, + ]) + assert(tiers.get(USER) === 'pro_plus', 'most recently applied upgrade is charged') +}) + +Deno.test('same applied time falls back to the later created operation', () => { + const tiers = latestPaidTierByUser([ + { user_id: USER, requested_tier: 'pro_plus', updated_at: '2026-09-01T00:00:00.000Z', created_at: '2026-08-31T00:00:00.000Z' }, + { user_id: USER, requested_tier: 'pro', updated_at: '2026-09-01T00:00:00.000Z', created_at: '2026-09-01T00:00:00.000Z' }, + ]) + assert(tiers.get(USER) === 'pro', 'later-created operation wins the tie') +}) + +Deno.test('malformed ledger rows are ignored', () => { + const tiers = latestPaidTierByUser([ + { user_id: USER, requested_tier: 'free', updated_at: '2026-09-01T00:00:00.000Z' }, + { user_id: USER, requested_tier: null, updated_at: '2026-09-02T00:00:00.000Z' }, + { user_id: USER, requested_tier: 'pro_plus', updated_at: 'not-a-date' }, + { user_id: 42, requested_tier: 'pro', updated_at: '2026-09-03T00:00:00.000Z' }, + { user_id: USER, requested_tier: 'pro', updated_at: '2026-08-01T00:00:00.000Z' }, + ]) + assert(tiers.size === 1 && tiers.get(USER) === 'pro', 'only the valid row counts') +}) diff --git a/server/supabase/functions/payple-renew/paid-tier.ts b/server/supabase/functions/payple-renew/paid-tier.ts new file mode 100644 index 0000000..8e27fb4 --- /dev/null +++ b/server/supabase/functions/payple-renew/paid-tier.ts @@ -0,0 +1,58 @@ +// server/supabase/functions/payple-renew/paid-tier.ts +// 갱신 청구 등급 정책 — 청구 금액은 고객이 실제로 결제한 등급에서 정한다. +// +// subscriptions.tier는 관리자 화면·동기화 트리거 등 여러 경로에서 바뀔 수 있는 엔타이틀먼트 +// 값이다. 저장된 빌링키로 청구할 금액을 이 값에서 읽으면, 등급 수정 한 번으로 고객이 동의하지 +// 않은 금액이 청구된다. 그래서 갱신은 마지막으로 반영(applied)된 Payple 결제 작업의 +// requested_tier를 정본으로 삼는다. 결제 작업 원장이 생기기 전의 레거시 구독처럼 기록이 +// 없을 때만 행의 tier를 그대로 쓴다. + +import type { RenewableTier } from './renewal.ts' + +function renewableTier(value: unknown): RenewableTier | null { + return value === 'pro' || value === 'pro_plus' ? value : null +} + +function timestampOf(value: unknown): number { + if (typeof value !== 'string') return Number.NaN + return new Date(value).getTime() +} + +/** + * payment_provider_operations 행(state='applied', 결제 유형)에서 사용자별로 가장 나중에 + * 반영된 작업의 등급을 고른다. 반영 시각은 updated_at(반영 후에는 더 바뀌지 않는다), + * 같으면 created_at으로 가린다. 형식이 맞지 않는 행은 무시한다. + */ +export function latestPaidTierByUser( + rows: readonly Record[], +): Map { + const latest = new Map() + for (const row of rows) { + const userId = typeof row.user_id === 'string' ? row.user_id : '' + const tier = renewableTier(row.requested_tier) + const appliedAt = timestampOf(row.updated_at) + if (!userId || !tier || !Number.isFinite(appliedAt)) continue + const createdAt = timestampOf(row.created_at) + const current = latest.get(userId) + const newer = !current + || appliedAt > current.appliedAt + || (appliedAt === current.appliedAt + && Number.isFinite(createdAt) + && (!Number.isFinite(current.createdAt) || createdAt > current.createdAt)) + if (newer) latest.set(userId, { tier, appliedAt, createdAt }) + } + return new Map([...latest].map(([userId, entry]) => [userId, entry.tier])) +} + +/** + * 갱신 후보 행의 tier를 고객이 마지막으로 결제한 등급으로 맞춘다. + * 결제 기록이 없으면 행을 그대로 돌려준다(레거시 호환). + */ +export function withPaidTier( + row: Record, + paidTiers: ReadonlyMap, +): Record { + const userId = typeof row.user_id === 'string' ? row.user_id : '' + const paidTier = userId ? paidTiers.get(userId) : undefined + return paidTier ? { ...row, tier: paidTier } : row +} diff --git a/server/supabase/migrations/20260928190000_admin_subscription_provider_guard.sql b/server/supabase/migrations/20260928190000_admin_subscription_provider_guard.sql new file mode 100644 index 0000000..7917690 --- /dev/null +++ b/server/supabase/migrations/20260928190000_admin_subscription_provider_guard.sql @@ -0,0 +1,196 @@ +-- Back-office subscription edits must not rewrite provider-owned billing terms. +-- +-- admin_mutate_subscription_v1 (20260821000023) let a manager PATCH the tier, +-- status and period end of a subscription that a payment provider owns. For a +-- Payple subscriber the row kept provider='payple', auto_renewing=true and its +-- billing key, so the next payple-renew run charged the stored card the price +-- of the edited tier. Admin 'delete' likewise left provider='payple' (or +-- google_play), which blocks the customer from buying again on the web. +-- +-- This redefinition keeps the signature, idempotency contract and audit shape, +-- and changes two branches: +-- * update: on a provider-owned row (provider not in 'none'/'admin') a change +-- of tier, status or period end is rejected with +-- 'provider_managed_subscription'. Notes and overage credits stay editable. +-- The admin form edits the period end at day granularity, so a value on +-- the same UTC date as the stored one is treated as unchanged and the +-- stored instant is kept. +-- * delete: releases provider ownership the same way a non-entitled provider +-- event does (provider 'none', no resource, not auto-renewing, no store +-- product), so renewals stop and the customer can purchase again. + +BEGIN; + +CREATE OR REPLACE FUNCTION public.admin_mutate_subscription_v1( + p_actor_email text, + p_idempotency_key uuid, + p_action text, + p_user_id uuid, + p_tier text DEFAULT NULL, + p_status text DEFAULT NULL, + p_current_period_end timestamptz DEFAULT NULL, + p_overage_credits integer DEFAULT NULL, + p_admin_note text DEFAULT NULL, + p_memo text DEFAULT NULL +) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, public, auth, extensions +AS $$ +DECLARE + actor record; + before_subscription public.subscriptions%ROWTYPE; + after_subscription public.subscriptions%ROWTYPE; + request_payload jsonb; + request_digest text; + existing_request public.admin_operation_requests%ROWTYPE; + inserted boolean; + result jsonb; + before_snapshot jsonb; + after_snapshot jsonb; + provider_managed boolean; + next_period_end timestamptz; +BEGIN + SELECT * INTO actor FROM public.resolve_external_admin_actor_v1(p_actor_email, 'manager'); + IF p_action NOT IN ('create', 'update', 'delete') OR p_idempotency_key IS NULL OR p_user_id IS NULL THEN + RAISE EXCEPTION 'invalid_subscription_operation' USING ERRCODE = '22023'; + END IF; + IF p_action IN ('create', 'delete') AND actor.actor_role = 'manager' THEN + RAISE EXCEPTION 'admin_role_required' USING ERRCODE = '42501'; + END IF; + IF p_memo IS NULL OR char_length(trim(p_memo)) NOT BETWEEN 3 AND 1000 THEN + RAISE EXCEPTION 'memo_must_be_3_to_1000_characters' USING ERRCODE = '22023'; + END IF; + IF p_tier IS NOT NULL AND p_tier NOT IN ('free', 'pro', 'pro_plus') THEN + RAISE EXCEPTION 'invalid_subscription_tier' USING ERRCODE = '22023'; + END IF; + IF p_status IS NOT NULL AND p_status NOT IN ('active', 'canceled', 'past_due', 'expired') THEN + RAISE EXCEPTION 'invalid_subscription_status' USING ERRCODE = '22023'; + END IF; + IF p_overage_credits IS NOT NULL AND (p_overage_credits < 0 OR p_overage_credits > 1000000) THEN + RAISE EXCEPTION 'invalid_overage_credits' USING ERRCODE = '22023'; + END IF; + IF p_admin_note IS NOT NULL AND char_length(p_admin_note) > 2000 THEN + RAISE EXCEPTION 'admin_note_too_long' USING ERRCODE = '22023'; + END IF; + IF NOT EXISTS (SELECT 1 FROM public.profiles WHERE id = p_user_id) THEN + RAISE EXCEPTION 'target_user_not_found' USING ERRCODE = 'P0002'; + END IF; + + request_payload := jsonb_build_object( + 'action', p_action, 'user', p_user_id, 'tier', p_tier, 'status', p_status, + 'periodEnd', p_current_period_end, 'credits', p_overage_credits, + 'note', p_admin_note, 'memo', trim(p_memo) + ); + request_digest := encode(extensions.digest(request_payload::text, 'sha256'), 'hex'); + INSERT INTO public.admin_operation_requests(actor_id, idempotency_key, operation, request_hash) + VALUES (actor.actor_id, p_idempotency_key, 'subscription.' || p_action, request_digest) + ON CONFLICT DO NOTHING + RETURNING true INTO inserted; + IF NOT coalesce(inserted, false) THEN + SELECT * INTO existing_request FROM public.admin_operation_requests + WHERE actor_id = actor.actor_id AND idempotency_key = p_idempotency_key; + IF existing_request.operation <> ('subscription.' || p_action) OR existing_request.request_hash <> request_digest THEN + RAISE EXCEPTION 'idempotency_key_reused_with_different_request' USING ERRCODE = '22023'; + END IF; + IF existing_request.response_data IS NULL THEN + RAISE EXCEPTION 'operation_in_progress' USING ERRCODE = '55P03'; + END IF; + RETURN existing_request.response_data; + END IF; + + SELECT * INTO before_subscription FROM public.subscriptions WHERE user_id = p_user_id FOR UPDATE; + IF p_action = 'create' THEN + IF before_subscription.id IS NOT NULL THEN + RAISE EXCEPTION 'subscription_already_exists' USING ERRCODE = '23505'; + END IF; + IF p_tier IS NULL THEN RAISE EXCEPTION 'tier_required' USING ERRCODE = '22023'; END IF; + INSERT INTO public.subscriptions(user_id, tier, status, provider, payment_provider, + current_period_start, current_period_end, overage_credits, admin_note, created_at, updated_at) + VALUES (p_user_id, p_tier, coalesce(p_status, 'active'), 'none', 'none', now(), + p_current_period_end, coalesce(p_overage_credits, 0), p_admin_note, now(), now()) + RETURNING * INTO after_subscription; + ELSIF p_action = 'update' THEN + IF before_subscription.id IS NULL THEN RAISE EXCEPTION 'subscription_not_found' USING ERRCODE = 'P0002'; END IF; + provider_managed := coalesce(before_subscription.provider, 'none') NOT IN ('none', 'admin'); + IF provider_managed THEN + IF (p_tier IS NOT NULL AND p_tier IS DISTINCT FROM before_subscription.tier) + OR (p_status IS NOT NULL AND p_status IS DISTINCT FROM before_subscription.status) + OR (p_current_period_end IS NOT NULL AND ( + before_subscription.current_period_end IS NULL + OR (p_current_period_end AT TIME ZONE 'UTC')::date + <> (before_subscription.current_period_end AT TIME ZONE 'UTC')::date + )) THEN + RAISE EXCEPTION 'provider_managed_subscription' USING ERRCODE = '23514'; + END IF; + next_period_end := before_subscription.current_period_end; + ELSE + next_period_end := coalesce(p_current_period_end, before_subscription.current_period_end); + END IF; + UPDATE public.subscriptions SET + tier = coalesce(p_tier, tier), + status = coalesce(p_status, status), + current_period_end = next_period_end, + overage_credits = coalesce(p_overage_credits, overage_credits), + admin_note = coalesce(p_admin_note, admin_note), + updated_at = now() + WHERE user_id = p_user_id RETURNING * INTO after_subscription; + ELSE + IF before_subscription.id IS NULL THEN RAISE EXCEPTION 'subscription_not_found' USING ERRCODE = 'P0002'; END IF; + UPDATE public.subscriptions SET tier = 'free', status = 'expired', cancel_at = now(), + provider = 'none', + provider_resource_id = NULL, + auto_renewing = false, + store_product_id = NULL, + store_purchase_id = NULL, + admin_note = '[DELETED] ' || trim(p_memo), updated_at = now() + WHERE user_id = p_user_id RETURNING * INTO after_subscription; + END IF; + + UPDATE public.profiles SET tier = after_subscription.tier, updated_at = now() WHERE id = p_user_id; + before_snapshot := CASE WHEN before_subscription.id IS NULL THEN NULL ELSE jsonb_build_object( + 'id', before_subscription.id, + 'user_id', before_subscription.user_id, + 'tier', before_subscription.tier, + 'status', before_subscription.status, + 'provider', before_subscription.provider, + 'payment_provider', before_subscription.payment_provider, + 'current_period_start', before_subscription.current_period_start, + 'current_period_end', before_subscription.current_period_end, + 'overage_credits', before_subscription.overage_credits, + 'admin_note', before_subscription.admin_note, + 'cancel_at', before_subscription.cancel_at, + 'created_at', before_subscription.created_at, + 'updated_at', before_subscription.updated_at + ) END; + after_snapshot := jsonb_build_object( + 'id', after_subscription.id, + 'user_id', after_subscription.user_id, + 'tier', after_subscription.tier, + 'status', after_subscription.status, + 'provider', after_subscription.provider, + 'payment_provider', after_subscription.payment_provider, + 'current_period_start', after_subscription.current_period_start, + 'current_period_end', after_subscription.current_period_end, + 'overage_credits', after_subscription.overage_credits, + 'admin_note', after_subscription.admin_note, + 'cancel_at', after_subscription.cancel_at, + 'created_at', after_subscription.created_at, + 'updated_at', after_subscription.updated_at + ); + INSERT INTO public.audit_log(admin_id, action, target_type, target_id, before_data, after_data, memo) + VALUES (actor.actor_id, 'subscription.' || p_action, 'subscription', p_user_id, + before_snapshot, after_snapshot, trim(p_memo)); + + result := jsonb_build_object('success', true, 'subscription', after_snapshot); + UPDATE public.admin_operation_requests SET response_data = result, completed_at = now() + WHERE actor_id = actor.actor_id AND idempotency_key = p_idempotency_key; + RETURN result; +END; +$$; + +REVOKE ALL ON FUNCTION public.admin_mutate_subscription_v1(text, uuid, text, uuid, text, text, timestamptz, integer, text, text) FROM PUBLIC, anon, authenticated; +GRANT EXECUTE ON FUNCTION public.admin_mutate_subscription_v1(text, uuid, text, uuid, text, text, timestamptz, integer, text, text) TO service_role; + +COMMIT; diff --git a/server/supabase/tests/admin-subscription-provider-guard.integration.sql b/server/supabase/tests/admin-subscription-provider-guard.integration.sql new file mode 100644 index 0000000..806c1a5 --- /dev/null +++ b/server/supabase/tests/admin-subscription-provider-guard.integration.sql @@ -0,0 +1,200 @@ +\set ON_ERROR_STOP on + +-- Regression: back-office subscription edits must not rewrite the billing terms +-- of a provider-owned (Payple / store) subscription, and admin delete must +-- release provider ownership so the customer can purchase again. +-- Before 20260928190000 a manager PATCH of tier='pro_plus' on an auto-renewing +-- Payple Pro row was accepted, and payple-renew then charged the pro_plus price. + +BEGIN; + +CREATE OR REPLACE FUNCTION pg_temp.assert_true(condition boolean, message text) +RETURNS void +LANGUAGE plpgsql +AS $$ +BEGIN + IF condition IS NOT TRUE THEN + RAISE EXCEPTION 'assertion_failed: %', message; + END IF; +END; +$$; + +INSERT INTO auth.users ( + id, aud, role, email, encrypted_password, email_confirmed_at, + raw_app_meta_data, raw_user_meta_data, created_at, updated_at +) VALUES + ( + '41900000-0000-4000-8000-000000000001', 'authenticated', 'authenticated', + 'guard-manager@example.invalid', crypt('fixture-password', gen_salt('bf')), now(), + '{"provider":"email","providers":["email"]}'::jsonb, '{}'::jsonb, now(), now() + ), + ( + '41900000-0000-4000-8000-000000000002', 'authenticated', 'authenticated', + 'guard-admin@example.invalid', crypt('fixture-password', gen_salt('bf')), now(), + '{"provider":"email","providers":["email"]}'::jsonb, '{}'::jsonb, now(), now() + ), + ( + '41900000-0000-4000-8000-000000000003', 'authenticated', 'authenticated', + 'guard-payple-customer@example.invalid', crypt('fixture-password', gen_salt('bf')), now(), + '{"provider":"email","providers":["email"]}'::jsonb, '{}'::jsonb, now(), now() + ), + ( + '41900000-0000-4000-8000-000000000004', 'authenticated', 'authenticated', + 'guard-comp-customer@example.invalid', crypt('fixture-password', gen_salt('bf')), now(), + '{"provider":"email","providers":["email"]}'::jsonb, '{}'::jsonb, now(), now() + ); + +UPDATE public.profiles SET role = 'manager' WHERE id = '41900000-0000-4000-8000-000000000001'; +UPDATE public.profiles SET role = 'admin' WHERE id = '41900000-0000-4000-8000-000000000002'; + +INSERT INTO public.subscriptions (user_id, tier, status, provider, payment_provider) +VALUES + ('41900000-0000-4000-8000-000000000003', 'free', 'active', 'none', 'none'), + ('41900000-0000-4000-8000-000000000004', 'free', 'active', 'none', 'none') +ON CONFLICT (user_id) DO NOTHING; + +UPDATE public.subscriptions + SET tier = 'pro', + status = 'active', + provider = 'payple', + provider_resource_id = 'payple-guard-fixture-resource', + payple_payer_id = 'payple-guard-fixture-payer', + auto_renewing = true, + current_period_start = '2026-09-15T03:12:45Z', + current_period_end = '2026-10-15T03:12:45Z' + WHERE user_id = '41900000-0000-4000-8000-000000000003'; + +UPDATE public.subscriptions + SET tier = 'free', status = 'active', provider = 'none', auto_renewing = NULL + WHERE user_id = '41900000-0000-4000-8000-000000000004'; + +-- A rejected mutation must raise provider_managed_subscription and change nothing. +CREATE OR REPLACE FUNCTION pg_temp.expect_provider_guard( + p_label text, + p_tier text, + p_status text, + p_period_end timestamptz +) +RETURNS void +LANGUAGE plpgsql +AS $$ +DECLARE + v_rejected boolean := false; + v_row public.subscriptions%ROWTYPE; +BEGIN + BEGIN + PERFORM public.admin_mutate_subscription_v1( + 'guard-manager@example.invalid', gen_random_uuid(), 'update', + '41900000-0000-4000-8000-000000000003', + p_tier, p_status, p_period_end, NULL, NULL, 'guard regression ' || p_label + ); + EXCEPTION + WHEN check_violation THEN + PERFORM pg_temp.assert_true( + SQLERRM = 'provider_managed_subscription', + format('%s rejected with provider_managed_subscription, got %s', p_label, SQLERRM) + ); + v_rejected := true; + END; + PERFORM pg_temp.assert_true(v_rejected, format('%s must be rejected on a Payple-owned row', p_label)); + + SELECT * INTO v_row FROM public.subscriptions + WHERE user_id = '41900000-0000-4000-8000-000000000003'; + PERFORM pg_temp.assert_true( + v_row.tier = 'pro' + AND v_row.status = 'active' + AND v_row.provider = 'payple' + AND v_row.auto_renewing + AND v_row.current_period_end = '2026-10-15T03:12:45Z'::timestamptz, + format('%s left the Payple row unchanged: %s/%s/%s/%s', p_label, + v_row.tier, v_row.status, v_row.provider, v_row.current_period_end) + ); +END; +$$; + +SELECT pg_temp.expect_provider_guard('manager tier comp', 'pro_plus', NULL, NULL); +SELECT pg_temp.expect_provider_guard('manager tier downgrade', 'free', NULL, NULL); +SELECT pg_temp.expect_provider_guard('manager status change', NULL, 'expired', NULL); +SELECT pg_temp.expect_provider_guard('manager period shift', NULL, NULL, '2026-10-01T00:00:00Z'); +SELECT pg_temp.expect_provider_guard('form resend with new tier', 'pro_plus', 'active', '2026-10-15T00:00:00Z'); + +-- The admin form resends tier/status and a day-granular period end on every +-- save. Unchanged values must pass so notes and credits stay editable, and the +-- stored period-end instant must not be truncated to midnight. +DO $$ +DECLARE + v_result jsonb; + v_row public.subscriptions%ROWTYPE; +BEGIN + v_result := public.admin_mutate_subscription_v1( + 'guard-manager@example.invalid', gen_random_uuid(), 'update', + '41900000-0000-4000-8000-000000000003', + 'pro', 'active', '2026-10-15T00:00:00Z', 250, 'support note', 'note-only edit' + ); + PERFORM pg_temp.assert_true((v_result->>'success')::boolean, 'note-only edit succeeds'); + + SELECT * INTO v_row FROM public.subscriptions + WHERE user_id = '41900000-0000-4000-8000-000000000003'; + PERFORM pg_temp.assert_true( + v_row.tier = 'pro' + AND v_row.provider = 'payple' + AND v_row.current_period_end = '2026-10-15T03:12:45Z'::timestamptz + AND v_row.overage_credits = 250 + AND v_row.admin_note = 'support note', + format('note-only edit keeps billing terms: %s/%s/%s/%s/%s', + v_row.tier, v_row.provider, v_row.current_period_end, v_row.overage_credits, v_row.admin_note) + ); +END; +$$; + +-- Admin-managed rows keep the existing manager edit behavior. +DO $$ +DECLARE + v_row public.subscriptions%ROWTYPE; +BEGIN + PERFORM public.admin_mutate_subscription_v1( + 'guard-manager@example.invalid', gen_random_uuid(), 'update', + '41900000-0000-4000-8000-000000000004', + 'pro_plus', 'active', '2026-12-01T00:00:00Z', NULL, NULL, 'comp grant' + ); + SELECT * INTO v_row FROM public.subscriptions + WHERE user_id = '41900000-0000-4000-8000-000000000004'; + PERFORM pg_temp.assert_true( + v_row.tier = 'pro_plus' + AND v_row.provider = 'none' + AND v_row.current_period_end = '2026-12-01T00:00:00Z'::timestamptz, + format('comp row accepts tier/period edits: %s/%s/%s', v_row.tier, v_row.provider, v_row.current_period_end) + ); +END; +$$; + +-- Admin delete releases provider ownership: renewals stop and a new purchase is allowed. +DO $$ +DECLARE + v_row public.subscriptions%ROWTYPE; +BEGIN + PERFORM public.admin_mutate_subscription_v1( + 'guard-admin@example.invalid', gen_random_uuid(), 'delete', + '41900000-0000-4000-8000-000000000003', + NULL, NULL, NULL, NULL, NULL, 'refund and revoke' + ); + SELECT * INTO v_row FROM public.subscriptions + WHERE user_id = '41900000-0000-4000-8000-000000000003'; + PERFORM pg_temp.assert_true( + v_row.tier = 'free' + AND v_row.status = 'expired' + AND v_row.provider = 'none' + AND v_row.payment_provider = 'none' + AND v_row.provider_resource_id IS NULL + AND v_row.auto_renewing IS FALSE, + format('delete releases provider ownership: %s/%s/%s/%s/%s/%s', v_row.tier, v_row.status, + v_row.provider, v_row.payment_provider, v_row.provider_resource_id, v_row.auto_renewing) + ); + PERFORM pg_temp.assert_true( + (SELECT tier FROM public.profiles WHERE id = '41900000-0000-4000-8000-000000000003') = 'free', + 'profile tier follows delete' + ); +END; +$$; + +ROLLBACK;