From e69fe0335d76614752c34eb5488a9187d2122e3a Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Mon, 28 Sep 2026 02:16:21 +0900 Subject: [PATCH] fix(payple): reject checkout while a paid Payple period is still active --- .../payple-checkout/checkout-policy.test.ts | 172 ++++++++++++++ .../payple-checkout/checkout-policy.ts | 145 ++++++++++++ .../functions/payple-checkout/index.ts | 114 ++++------ ...ple_checkout_active_subscription_guard.sql | 211 ++++++++++++++++++ ...eckout-active-subscription.integration.sql | 146 ++++++++++++ 5 files changed, 717 insertions(+), 71 deletions(-) create mode 100644 server/supabase/functions/payple-checkout/checkout-policy.test.ts create mode 100644 server/supabase/functions/payple-checkout/checkout-policy.ts create mode 100644 server/supabase/migrations/20260928200000_payple_checkout_active_subscription_guard.sql create mode 100644 server/supabase/tests/payple-checkout-active-subscription.integration.sql diff --git a/server/supabase/functions/payple-checkout/checkout-policy.test.ts b/server/supabase/functions/payple-checkout/checkout-policy.test.ts new file mode 100644 index 0000000..7ecd018 --- /dev/null +++ b/server/supabase/functions/payple-checkout/checkout-policy.test.ts @@ -0,0 +1,172 @@ +import { + chargeMatchesOrder, + decideReservation, + isBillingKeyOwnedBy, + isValidIdempotencyKey, + parseCheckoutRequest, + planCheckoutFailure, +} from './checkout-policy.ts' + +function assert(condition: boolean, message: string): asserts condition { + if (!condition) throw new Error(message) +} + +function assertEquals(actual: unknown, expected: unknown, message: string): void { + const a = JSON.stringify(actual) + const e = JSON.stringify(expected) + if (a !== e) throw new Error(`${message}: expected ${e}, got ${a}`) +} + +Deno.test('parseCheckoutRequest accepts a valid request with or without idempotency key', () => { + assertEquals( + parseCheckoutRequest({ payer_id: 'payer-12345', tier: 'pro' }), + { payerId: 'payer-12345', tier: 'pro', idempotencyKey: undefined }, + 'without key', + ) + assertEquals( + parseCheckoutRequest({ + payer_id: 'payer-12345', + tier: 'pro_plus', + idempotency_key: 'payple-checkout:abc-123', + }), + { payerId: 'payer-12345', tier: 'pro_plus', idempotencyKey: 'payple-checkout:abc-123' }, + 'with key', + ) +}) + +Deno.test('parseCheckoutRequest rejects malformed bodies', () => { + const invalid: unknown[] = [ + null, + 'string', + {}, + { payer_id: 'short', tier: 'pro' }, + { payer_id: 'x'.repeat(256), tier: 'pro' }, + { payer_id: 'payer-12345', tier: 'free' }, + { payer_id: 12345678, tier: 'pro' }, + { payer_id: 'payer-12345', tier: 'pro', idempotency_key: 'short' }, + { payer_id: 'payer-12345', tier: 'pro', idempotency_key: 'has spaces in it!' }, + { payer_id: 'payer-12345', tier: 'pro', idempotency_key: null }, + ] + for (const body of invalid) { + assert(parseCheckoutRequest(body) === null, `must reject ${JSON.stringify(body)}`) + } +}) + +Deno.test('isValidIdempotencyKey enforces the database key format', () => { + assert(isValidIdempotencyKey('payple-checkout:0123'), 'valid key') + assert(!isValidIdempotencyKey('a'.repeat(11)), 'too short') + assert(!isValidIdempotencyKey('a'.repeat(161)), 'too long') + assert(!isValidIdempotencyKey(42), 'not a string') +}) + +Deno.test('decideReservation proceeds only for a freshly created operation', () => { + assertEquals( + decideReservation({ created: true, operation_id: 'op-1', state: 'reserved' }), + { kind: 'proceed', operationId: 'op-1' }, + 'created', + ) + assertEquals( + decideReservation({ created: false, operation_id: 'op-1', state: 'applied', reason: 'idempotent_replay' }), + { kind: 'reject', status: 409, body: { error: 'idempotent_replay', state: 'applied' } }, + 'replay never charges again', + ) + assertEquals( + decideReservation(null), + { kind: 'reject', status: 409, body: { error: 'payment_operation_in_progress', state: 'rejected' } }, + 'missing reservation', + ) + assertEquals( + decideReservation({ created: true }), + { kind: 'reject', status: 409, body: { error: 'payment_operation_in_progress', state: 'rejected' } }, + 'created without operation id', + ) +}) + +Deno.test('decideReservation surfaces an active paid period without charging (stale-tab double checkout)', () => { + const decision = decideReservation({ + created: false, + state: 'rejected', + reason: 'subscription_already_active', + owner_provider: 'payple', + tier: 'pro', + current_period_end: '2026-10-28T00:00:00.000Z', + }) + assertEquals( + decision, + { kind: 'reject', status: 409, body: { error: 'subscription_already_active', state: 'rejected' } }, + 'active subscriber is rejected before Payple is contacted', + ) +}) + +Deno.test('isBillingKeyOwnedBy requires a live key registered to the caller', () => { + const owners = ['user-1', 'hashed-user-1'] + assert( + isBillingKeyOwnedBy({ PCD_PAY_RST: 'success', PCD_PAYER_ID: 'payer-1', PCD_PAYER_NO: 'hashed-user-1' }, 'payer-1', owners), + 'owner accepted', + ) + assert( + !isBillingKeyOwnedBy({ PCD_PAY_RST: 'error', PCD_PAYER_ID: 'payer-1', PCD_PAYER_NO: 'user-1' }, 'payer-1', owners), + 'failed lookup rejected', + ) + assert( + !isBillingKeyOwnedBy({ PCD_PAY_RST: 'success', PCD_PAYER_ID: 'payer-2', PCD_PAYER_NO: 'user-1' }, 'payer-1', owners), + 'different billing key rejected', + ) + assert( + !isBillingKeyOwnedBy({ PCD_PAY_RST: 'success', PCD_PAYER_ID: 'payer-1', PCD_PAYER_NO: '' }, 'payer-1', owners), + 'missing payer number rejected', + ) + assert( + !isBillingKeyOwnedBy({ PCD_PAY_RST: 'success', PCD_PAYER_ID: 'payer-1', PCD_PAYER_NO: 'user-2' }, 'payer-1', owners), + 'other user rejected', + ) +}) + +Deno.test('chargeMatchesOrder binds the charge to the reserved order', () => { + const expected = { orderId: 'D3RO-1', amount: 9900, payerId: 'payer-1' } + assert(chargeMatchesOrder({ PCD_PAY_OID: 'D3RO-1', PCD_PAY_TOTAL: '9900' }, expected), 'no payer echo') + assert( + chargeMatchesOrder({ PCD_PAY_OID: 'D3RO-1', PCD_PAY_TOTAL: '9900', PCD_PAYER_ID: 'payer-1' }, expected), + 'matching payer echo', + ) + assert(!chargeMatchesOrder({ PCD_PAY_OID: 'D3RO-2', PCD_PAY_TOTAL: '9900' }, expected), 'order mismatch') + assert(!chargeMatchesOrder({ PCD_PAY_OID: 'D3RO-1', PCD_PAY_TOTAL: '100' }, expected), 'amount mismatch') + assert( + !chargeMatchesOrder({ PCD_PAY_OID: 'D3RO-1', PCD_PAY_TOTAL: '9900', PCD_PAYER_ID: 'payer-x' }, expected), + 'payer mismatch', + ) +}) + +Deno.test('planCheckoutFailure keeps unknown charges reconcilable and releases safe failures', () => { + const base = { + hasOperation: true, + externalChargeCompleted: false, + chargeOutcomeUnknown: false, + configurationError: false, + } + assertEquals( + planCheckoutFailure({ ...base, chargeOutcomeUnknown: true }, 'payple_not_configured'), + { mark: { state: 'external_created' }, status: 409, error: 'payment_requires_reconciliation' }, + 'ambiguous charge', + ) + assertEquals( + planCheckoutFailure(base, 'payple_not_configured'), + { mark: { state: 'failed', errorCode: 'payple_checkout_failed' }, status: 502, error: 'payple_checkout_failed' }, + 'failure before charge', + ) + assertEquals( + planCheckoutFailure({ ...base, externalChargeCompleted: true }, 'payple_not_configured'), + { mark: null, status: 409, error: 'payment_requires_reconciliation' }, + 'failure after charge leaves the operation charged', + ) + assertEquals( + planCheckoutFailure({ ...base, hasOperation: false, configurationError: true }, 'payple_not_configured'), + { mark: null, status: 503, error: 'payple_not_configured' }, + 'configuration error', + ) + assertEquals( + planCheckoutFailure({ ...base, configurationError: true }, 'payple_not_configured'), + { mark: { state: 'failed', errorCode: 'payple_not_configured' }, status: 503, error: 'payple_not_configured' }, + 'configuration error with operation', + ) +}) diff --git a/server/supabase/functions/payple-checkout/checkout-policy.ts b/server/supabase/functions/payple-checkout/checkout-policy.ts new file mode 100644 index 0000000..cecdb19 --- /dev/null +++ b/server/supabase/functions/payple-checkout/checkout-policy.ts @@ -0,0 +1,145 @@ +// Pure decision rules for the Payple checkout edge function. No IO lives +// here: index.ts owns Supabase/Payple calls and asks these functions what to +// do with each result, so the rules are unit-testable without a network. + +export type CheckoutTier = 'pro' | 'pro_plus' + +export interface CheckoutInput { + payerId: string + tier: CheckoutTier + idempotencyKey: string | undefined +} + +export function isValidIdempotencyKey(value: unknown): value is string { + return typeof value === 'string' && /^[A-Za-z0-9._:-]{12,160}$/.test(value) +} + +/** Returns null when the request body is not a valid checkout request. */ +export function parseCheckoutRequest(body: unknown): CheckoutInput | null { + if (!body || typeof body !== 'object') return null + const record = body as Record + const payerId = record.payer_id + const tier = record.tier + const idempotencyKey = record.idempotency_key + if ( + typeof payerId !== 'string' + || payerId.length < 8 + || payerId.length > 255 + || (tier !== 'pro' && tier !== 'pro_plus') + || (idempotencyKey !== undefined && !isValidIdempotencyKey(idempotencyKey)) + ) { + return null + } + return { payerId, tier, idempotencyKey } +} + +export type ReservationDecision = + | { kind: 'proceed'; operationId: string } + | { kind: 'reject'; status: 409; body: { error: string; state: string } } + +/** + * Interprets the reserve_payment_provider_operation result. Only a freshly + * created operation may lead to an external charge; every other outcome + * (idempotent replay, in-flight operation, other-provider ownership, or an + * already active paid period — 'subscription_already_active') is a 409 with + * the database reason passed through unchanged. + */ +export function decideReservation(data: unknown): ReservationDecision { + const reservation = data && typeof data === 'object' + ? data as Record + : null + if (reservation?.created === true && typeof reservation.operation_id === 'string') { + return { kind: 'proceed', operationId: reservation.operation_id } + } + return { + kind: 'reject', + status: 409, + body: { + error: typeof reservation?.reason === 'string' + ? reservation.reason + : 'payment_operation_in_progress', + state: typeof reservation?.state === 'string' ? reservation.state : 'rejected', + }, + } +} + +export interface BillingKeyOwnerEvidence { + PCD_PAY_RST: string + PCD_PAYER_ID?: string + PCD_PAYER_NO?: string +} + +/** The billing key must be live and registered to the authenticated user. */ +export function isBillingKeyOwnedBy( + billingKey: BillingKeyOwnerEvidence, + payerId: string, + expectedPayerNumbers: readonly string[], +): boolean { + return billingKey.PCD_PAY_RST === 'success' + && billingKey.PCD_PAYER_ID === payerId + && typeof billingKey.PCD_PAYER_NO === 'string' + && billingKey.PCD_PAYER_NO.length > 0 + && expectedPayerNumbers.includes(billingKey.PCD_PAYER_NO) +} + +export interface ChargeEvidence { + PCD_PAY_OID: string + PCD_PAY_TOTAL: string + PCD_PAYER_ID?: string +} + +/** The synchronous charge response must describe exactly the reserved order. */ +export function chargeMatchesOrder( + charge: ChargeEvidence, + expected: { orderId: string; amount: number; payerId: string }, +): boolean { + return charge.PCD_PAY_OID === expected.orderId + && charge.PCD_PAY_TOTAL === String(expected.amount) + && (!charge.PCD_PAYER_ID || charge.PCD_PAYER_ID === expected.payerId) +} + +export interface CheckoutFailureFacts { + /** A reservation exists for this request. */ + hasOperation: boolean + /** Payple accepted the charge before the failure. */ + externalChargeCompleted: boolean + /** The charge call failed in a way that does not prove it was not taken. */ + chargeOutcomeUnknown: boolean + /** Payple is not configured on this deployment. */ + configurationError: boolean +} + +export interface CheckoutFailurePlan { + /** How to mark the reserved operation, or null to leave it untouched. */ + mark: + | { state: 'external_created' } + | { state: 'failed'; errorCode: 'payple_not_configured' | 'payple_checkout_failed' } + | null + /** HTTP status and error code returned to the caller (auth errors excepted). */ + status: 409 | 502 | 503 + error: string +} + +export function planCheckoutFailure( + facts: CheckoutFailureFacts, + configurationErrorCode: string, +): CheckoutFailurePlan { + let mark: CheckoutFailurePlan['mark'] = null + if (facts.hasOperation && facts.chargeOutcomeUnknown) { + mark = { state: 'external_created' } + } else if (facts.hasOperation && !facts.externalChargeCompleted) { + mark = { + state: 'failed', + errorCode: facts.configurationError ? 'payple_not_configured' : 'payple_checkout_failed', + } + } + if (facts.configurationError) { + return { mark, status: 503, error: configurationErrorCode } + } + const needsReconciliation = facts.externalChargeCompleted || facts.chargeOutcomeUnknown + return { + mark, + status: needsReconciliation ? 409 : 502, + error: needsReconciliation ? 'payment_requires_reconciliation' : 'payple_checkout_failed', + } +} diff --git a/server/supabase/functions/payple-checkout/index.ts b/server/supabase/functions/payple-checkout/index.ts index c4710b0..c5b9451 100644 --- a/server/supabase/functions/payple-checkout/index.ts +++ b/server/supabase/functions/payple-checkout/index.ts @@ -17,19 +17,13 @@ import { TIER_GOODS_NAME, TIER_PRICE, } from '../_shared/payple.ts' - -interface CheckoutRequest { - payer_id?: unknown - tier?: unknown - idempotency_key?: unknown -} - -interface OperationReservation { - created?: boolean - operation_id?: string - state?: string - reason?: string -} +import { + chargeMatchesOrder, + decideReservation, + isBillingKeyOwnedBy, + parseCheckoutRequest, + planCheckoutFailure, +} from './checkout-policy.ts' interface ApplyResult { applied?: boolean @@ -44,10 +38,6 @@ function jsonResponse(body: Record, status = 200): Response { }) } -function validIdempotencyKey(value: unknown): value is string { - return typeof value === 'string' && /^[A-Za-z0-9._:-]{12,160}$/.test(value) -} - Deno.serve(async (req: Request) => { const preflight = handleCorsPreflightRequest(req) if (preflight) return preflight @@ -60,21 +50,13 @@ Deno.serve(async (req: Request) => { try { const user = await requireUser(req) - const body = await req.json() as CheckoutRequest - if ( - typeof body.payer_id !== 'string' - || body.payer_id.length < 8 - || body.payer_id.length > 255 - || (body.tier !== 'pro' && body.tier !== 'pro_plus') - || (body.idempotency_key !== undefined && !validIdempotencyKey(body.idempotency_key)) - ) { - return jsonResponse({ error: 'invalid_request' }, 400) - } + const input = parseCheckoutRequest(await req.json()) + if (!input) return jsonResponse({ error: 'invalid_request' }, 400) // Configuration is validated before reserving state or contacting Payple. // There are deliberately no bundled/test credential fallbacks. const config = getPaypleConfig() - const idempotencyKey = body.idempotency_key + const idempotencyKey = input.idempotencyKey ?? `payple-checkout:${crypto.randomUUID()}` const orderId = generateOrderId(user.id) providerOrderId = orderId @@ -84,40 +66,35 @@ Deno.serve(async (req: Request) => { p_user_id: user.id, p_provider: 'payple', p_operation_type: 'checkout', - p_requested_tier: body.tier, + p_requested_tier: input.tier, p_idempotency_key: idempotencyKey, p_provider_order_id: orderId, // Bind the reserved order to this billing key before any charge. The // PUSERINFO lookup below still verifies its authenticated user owner. - p_provider_resource_id: body.payer_id, + p_provider_resource_id: input.payerId, }, ) if (reservationError) throw new Error('payment_reservation_failed') - const reservation = reservationData as OperationReservation | null - if (!reservation?.created || typeof reservation.operation_id !== 'string') { - return jsonResponse({ - error: reservation?.reason ?? 'payment_operation_in_progress', - state: reservation?.state ?? 'rejected', - }, 409) + // The reservation is the single serialization point: it rejects an + // in-flight operation, another provider's ownership, and an active paid + // Payple period ('subscription_already_active') before any charge. + const reservation = decideReservation(reservationData) + if (reservation.kind === 'reject') { + return jsonResponse(reservation.body, reservation.status) } - operationId = reservation.operation_id + operationId = reservation.operationId - const price = TIER_PRICE[body.tier] - const goodsName = TIER_GOODS_NAME[body.tier] + const price = TIER_PRICE[input.tier] + const goodsName = TIER_GOODS_NAME[input.tier] const billingKeyAuth = await paypleAuth(config, { payWork: 'PUSERINFO' }) - const billingKey = await paypleLookupBillingKey(config, billingKeyAuth, body.payer_id) + const billingKey = await paypleLookupBillingKey(config, billingKeyAuth, input.payerId) const expectedPayerNumbers = [user.id, await payplePayerNumber(user.id)] - if ( - billingKey.PCD_PAY_RST !== 'success' - || billingKey.PCD_PAYER_ID !== body.payer_id - || !billingKey.PCD_PAYER_NO - || !expectedPayerNumbers.includes(billingKey.PCD_PAYER_NO) - ) { + if (!isBillingKeyOwnedBy(billingKey, input.payerId, expectedPayerNumbers)) { throw new Error('payple_billing_key_owner_mismatch') } const auth = await paypleAuth(config, { simpleFlag: true }) const billingResult = await paypleBilling(config, auth, { - payerId: body.payer_id, + payerId: input.payerId, amount: price, orderId, goodsName, @@ -132,11 +109,7 @@ Deno.serve(async (req: Request) => { }) if (chargedError) throw new Error('payment_operation_update_failed') - if ( - billingResult.PCD_PAY_OID !== orderId - || billingResult.PCD_PAY_TOTAL !== String(price) - || (billingResult.PCD_PAYER_ID && billingResult.PCD_PAYER_ID !== body.payer_id) - ) { + if (!chargeMatchesOrder(billingResult, { orderId, amount: price, payerId: input.payerId })) { throw new Error('payple_charge_response_mismatch') } @@ -155,19 +128,19 @@ Deno.serve(async (req: Request) => { p_event_type: 'payment.completed', p_payload_digest: await payplePaymentEventDigest({ orderId, - payerId: body.payer_id, + payerId: input.payerId, payType: 'card', amount: price, }), - p_provider_resource_id: body.payer_id, - p_tier: body.tier, + p_provider_resource_id: input.payerId, + p_tier: input.tier, p_status: 'active', p_entitled: true, p_current_period_start: start, p_current_period_end: end, p_cancel_at: null, p_auto_renewing: true, - p_provider_customer_id: body.payer_id, + p_provider_customer_id: input.payerId, p_provider_order_id: orderId, p_store_product_id: null, p_store_purchase_id: null, @@ -186,39 +159,38 @@ Deno.serve(async (req: Request) => { return jsonResponse({ success: true, - tier: body.tier, + tier: input.tier, order_id: orderId, amount: price, }) } catch (error) { - const chargeOutcomeUnknown = error instanceof PaypleBillingError && !error.definitive - if (operationId && chargeOutcomeUnknown) { + const plan = planCheckoutFailure( + { + hasOperation: operationId !== null, + externalChargeCompleted, + chargeOutcomeUnknown: error instanceof PaypleBillingError && !error.definitive, + configurationError: error instanceof PaypleConfigurationError, + }, + error instanceof PaypleConfigurationError ? error.code : 'payple_not_configured', + ) + if (operationId && plan.mark?.state === 'external_created') { await serviceClient.rpc('mark_payment_provider_operation', { p_operation_id: operationId, p_state: 'external_created', p_external_reference: providerOrderId, p_error_code: null, }) - } else if (operationId && !externalChargeCompleted) { + } else if (operationId && plan.mark?.state === 'failed') { await serviceClient.rpc('mark_payment_provider_operation', { p_operation_id: operationId, p_state: 'failed', p_external_reference: null, - p_error_code: error instanceof PaypleConfigurationError - ? 'payple_not_configured' - : 'payple_checkout_failed', + p_error_code: plan.mark.errorCode, }) } if (error && typeof error === 'object' && 'status' in error && 'message' in error) { return authErrorResponse(error as AuthError, corsHeaders) } - if (error instanceof PaypleConfigurationError) { - return jsonResponse({ error: error.code }, 503) - } - return jsonResponse({ - error: externalChargeCompleted || chargeOutcomeUnknown - ? 'payment_requires_reconciliation' - : 'payple_checkout_failed', - }, externalChargeCompleted || chargeOutcomeUnknown ? 409 : 502) + return jsonResponse({ error: plan.error }, plan.status) } }) diff --git a/server/supabase/migrations/20260928200000_payple_checkout_active_subscription_guard.sql b/server/supabase/migrations/20260928200000_payple_checkout_active_subscription_guard.sql new file mode 100644 index 0000000..13c7e1f --- /dev/null +++ b/server/supabase/migrations/20260928200000_payple_checkout_active_subscription_guard.sql @@ -0,0 +1,211 @@ +-- ============================================================================ +-- Payple checkout: reject a second checkout while a paid period is running +-- +-- reserve_payment_provider_operation previously allowed a checkout whenever +-- subscriptions.provider was 'none' or the requesting provider. An active +-- Payple subscriber on a stale tab (or a direct API call with a fresh +-- idempotency key) could therefore reserve a new checkout, get charged the +-- full price again, and apply_payment_provider_event then restarted the paid +-- period at now()..now()+1 month, dropping the unused remainder. +-- +-- The reservation is the serialization point (per-user advisory lock), so the +-- guard lives here: a same-provider checkout is rejected with +-- 'subscription_already_active' while the row is paid (tier <> 'free') and its +-- current period has not ended. Upgrades are not modelled as checkouts; an +-- upgrade must be a separate prorating/extending operation. +-- +-- Only the checkout branch changes; renewal and cancellation keep their +-- existing ownership checks. The body is otherwise identical to +-- 20260821000003_payment_provider_serialization.sql. +-- ============================================================================ + +BEGIN; + +CREATE OR REPLACE FUNCTION public.reserve_payment_provider_operation( + p_user_id uuid, + p_provider text, + p_operation_type text, + p_requested_tier text, + p_idempotency_key text, + p_provider_order_id text DEFAULT NULL, + p_provider_resource_id text DEFAULT NULL +) RETURNS jsonb +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public, pg_temp +AS $$ +DECLARE + v_existing public.payment_provider_operations%ROWTYPE; + v_subscription public.subscriptions%ROWTYPE; + v_operation public.payment_provider_operations%ROWTYPE; +BEGIN + IF p_user_id IS NULL OR NOT EXISTS (SELECT 1 FROM auth.users WHERE id = p_user_id) THEN + RAISE EXCEPTION 'unknown_user'; + END IF; + IF p_provider NOT IN ('stripe', 'payple', 'google_play', 'app_store') THEN + RAISE EXCEPTION 'invalid_provider'; + END IF; + IF p_operation_type NOT IN ('checkout', 'renewal', 'cancellation') THEN + RAISE EXCEPTION 'invalid_operation_type'; + END IF; + IF p_operation_type IN ('checkout', 'renewal') AND p_requested_tier NOT IN ('pro', 'pro_plus') THEN + RAISE EXCEPTION 'invalid_tier'; + END IF; + IF p_operation_type = 'cancellation' AND p_requested_tier IS NOT NULL THEN + RAISE EXCEPTION 'cancellation_tier_must_be_null'; + END IF; + IF p_idempotency_key IS NULL + OR p_idempotency_key !~ '^[A-Za-z0-9._:-]{12,160}$' THEN + RAISE EXCEPTION 'invalid_idempotency_key'; + END IF; + IF p_provider_order_id IS NOT NULL + AND p_provider_order_id !~ '^[A-Za-z0-9._-]{8,64}$' THEN + RAISE EXCEPTION 'invalid_provider_order_id'; + END IF; + IF p_provider_resource_id IS NOT NULL + AND length(trim(p_provider_resource_id)) NOT BETWEEN 1 AND 255 THEN + RAISE EXCEPTION 'invalid_provider_resource_id'; + END IF; + + PERFORM pg_advisory_xact_lock(hashtextextended(p_user_id::text, 73031)); + + SELECT * + INTO v_existing + FROM public.payment_provider_operations + WHERE user_id = p_user_id + AND provider = p_provider + AND idempotency_key = p_idempotency_key + FOR UPDATE; + + IF v_existing.id IS NOT NULL THEN + IF v_existing.operation_type <> p_operation_type + OR v_existing.requested_tier IS DISTINCT FROM p_requested_tier + OR v_existing.provider_resource_id IS DISTINCT FROM p_provider_resource_id THEN + RAISE EXCEPTION 'idempotency_key_payload_mismatch'; + END IF; + RETURN jsonb_build_object( + 'created', false, + 'operation_id', v_existing.id, + 'state', v_existing.state, + 'provider_order_id', v_existing.provider_order_id, + 'external_reference', v_existing.external_reference, + 'reason', 'idempotent_replay' + ); + END IF; + + UPDATE public.payment_provider_operations + SET state = 'failed', + error_code = 'operation_lease_expired', + updated_at = now() + WHERE user_id = p_user_id + AND state IN ('reserved', 'external_created', 'charged') + AND expires_at <= now(); + + SELECT * + INTO v_subscription + FROM public.subscriptions + WHERE user_id = p_user_id + FOR UPDATE; + + IF v_subscription.id IS NULL THEN + INSERT INTO public.subscriptions (user_id, tier, status, provider, payment_provider) + VALUES (p_user_id, 'free', 'active', 'none', 'none') + RETURNING * INTO v_subscription; + END IF; + + IF p_operation_type = 'checkout' THEN + IF v_subscription.provider NOT IN ('none', p_provider) THEN + RETURN jsonb_build_object( + 'created', false, + 'state', 'rejected', + 'reason', 'active_subscription_other_provider', + 'owner_provider', v_subscription.provider + ); + END IF; + -- Same provider, still paid and inside its period: a new checkout would + -- charge again and restart the period. A missing period end on a paid + -- row is treated as still running. + IF v_subscription.provider = p_provider + AND v_subscription.tier <> 'free' + AND coalesce(v_subscription.current_period_end, 'infinity'::timestamptz) > now() THEN + RETURN jsonb_build_object( + 'created', false, + 'state', 'rejected', + 'reason', 'subscription_already_active', + 'owner_provider', v_subscription.provider, + 'tier', v_subscription.tier, + 'current_period_end', v_subscription.current_period_end + ); + END IF; + ELSE + IF v_subscription.provider <> p_provider THEN + RETURN jsonb_build_object( + 'created', false, + 'state', 'rejected', + 'reason', 'provider_not_owner', + 'owner_provider', v_subscription.provider + ); + END IF; + IF p_provider_resource_id IS NOT NULL + AND v_subscription.provider_resource_id IS DISTINCT FROM p_provider_resource_id THEN + RETURN jsonb_build_object( + 'created', false, + 'state', 'rejected', + 'reason', 'provider_resource_not_owner' + ); + END IF; + END IF; + + IF EXISTS ( + SELECT 1 + FROM public.payment_provider_operations + WHERE user_id = p_user_id + AND state IN ('reserved', 'external_created', 'charged') + AND expires_at > now() + ) THEN + RETURN jsonb_build_object( + 'created', false, + 'state', 'rejected', + 'reason', 'payment_operation_in_progress' + ); + END IF; + + INSERT INTO public.payment_provider_operations ( + user_id, + provider, + operation_type, + idempotency_key, + requested_tier, + provider_order_id, + provider_resource_id, + expires_at + ) VALUES ( + p_user_id, + p_provider, + p_operation_type, + p_idempotency_key, + p_requested_tier, + nullif(trim(p_provider_order_id), ''), + nullif(trim(p_provider_resource_id), ''), + now() + interval '15 minutes' + ) + RETURNING * INTO v_operation; + + RETURN jsonb_build_object( + 'created', true, + 'operation_id', v_operation.id, + 'state', v_operation.state, + 'provider_order_id', v_operation.provider_order_id, + 'expires_at', v_operation.expires_at + ); +END; +$$; + +REVOKE ALL ON FUNCTION public.reserve_payment_provider_operation( + uuid, text, text, text, text, text, text +) FROM PUBLIC, anon, authenticated; +GRANT EXECUTE ON FUNCTION public.reserve_payment_provider_operation( + uuid, text, text, text, text, text, text +) TO service_role; + +COMMIT; diff --git a/server/supabase/tests/payple-checkout-active-subscription.integration.sql b/server/supabase/tests/payple-checkout-active-subscription.integration.sql new file mode 100644 index 0000000..9a4cdd6 --- /dev/null +++ b/server/supabase/tests/payple-checkout-active-subscription.integration.sql @@ -0,0 +1,146 @@ +\set ON_ERROR_STOP on + +-- Regression: a second Payple checkout (stale tab, direct API call) must not +-- charge an active Payple subscriber again and restart the paid period. +-- Covers reserve_payment_provider_operation from +-- 20260928200000_payple_checkout_active_subscription_guard.sql. + +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 ( + '20000000-0000-4000-8000-000000000020', 'authenticated', 'authenticated', + 'payple-active-guard@example.invalid', crypt('fixture-password', gen_salt('bf')), now(), + '{"provider":"email","providers":["email"]}'::jsonb, '{}'::jsonb, now(), now() +); + +DO $$ +DECLARE + c_user constant uuid := '20000000-0000-4000-8000-000000000020'; + v_first jsonb; + v_apply jsonb; + v_second jsonb; + v_upgrade jsonb; + v_cancel jsonb; + v_lapsed jsonb; + v_period_end timestamptz; + v_before public.subscriptions%ROWTYPE; + v_after public.subscriptions%ROWTYPE; + v_operations integer; +BEGIN + -- First tab: a free user subscribes to Pro through Payple. + v_first := public.reserve_payment_provider_operation( + c_user, 'payple', 'checkout', 'pro', + 'payple-checkout:active-guard-first', 'D3RO-20260928030000-guard01', + 'payer-active-guard' + ); + PERFORM pg_temp.assert_true((v_first->>'created')::boolean, 'free user can reserve checkout'); + + v_period_end := now() + interval '1 month'; + v_apply := public.apply_payment_provider_event( + p_user_id => c_user, + p_provider => 'payple', + p_event_id => 'payple-payment:active-guard-first', + p_event_created_at => now() - interval '1 minute', + p_event_type => 'payment.completed', + p_payload_digest => repeat('a', 64), + p_provider_resource_id => 'payer-active-guard', + p_tier => 'pro', + p_status => 'active', + p_entitled => true, + p_current_period_start => now() - interval '1 minute', + p_current_period_end => v_period_end, + p_cancel_at => NULL, + p_auto_renewing => true, + p_provider_customer_id => 'payer-active-guard', + p_provider_order_id => 'D3RO-20260928030000-guard01', + p_operation_id => (v_first->>'operation_id')::uuid + ); + PERFORM pg_temp.assert_true((v_apply->>'applied')::boolean, 'first checkout applied'); + + SELECT * INTO v_before FROM public.subscriptions WHERE user_id = c_user; + + -- Second (stale) tab: fresh idempotency key, same provider, same tier. + v_second := public.reserve_payment_provider_operation( + c_user, 'payple', 'checkout', 'pro', + 'payple-checkout:active-guard-second', 'D3RO-20260928030500-guard02', + 'payer-active-guard' + ); + PERFORM pg_temp.assert_true( + NOT coalesce((v_second->>'created')::boolean, false), + 'active Payple subscriber must not reserve a second checkout' + ); + PERFORM pg_temp.assert_true( + v_second->>'reason' = 'subscription_already_active', + 'second checkout is rejected as subscription_already_active, got ' + || coalesce(v_second->>'reason', '') + ); + + -- An unprorated full-price "upgrade" through checkout is rejected too. + v_upgrade := public.reserve_payment_provider_operation( + c_user, 'payple', 'checkout', 'pro_plus', + 'payple-checkout:active-guard-upgrade', 'D3RO-20260928031000-guard03', + 'payer-active-guard' + ); + PERFORM pg_temp.assert_true( + v_upgrade->>'reason' = 'subscription_already_active', + 'checkout cannot be used as an unprorated upgrade' + ); + + SELECT count(*) INTO v_operations + FROM public.payment_provider_operations + WHERE user_id = c_user AND operation_type = 'checkout'; + PERFORM pg_temp.assert_true(v_operations = 1, 'rejected checkouts create no operation'); + + SELECT * INTO v_after FROM public.subscriptions WHERE user_id = c_user; + PERFORM pg_temp.assert_true( + v_after.current_period_start = v_before.current_period_start + AND v_after.current_period_end = v_before.current_period_end + AND v_after.current_period_end = v_period_end + AND v_after.tier = 'pro', + 'paid period is preserved after the rejected checkout' + ); + + -- Cancellation of the live entitlement is still possible. + v_cancel := public.reserve_payment_provider_operation( + c_user, 'payple', 'cancellation', NULL, + 'payple-manage:active-guard-cancel', NULL, + 'payer-active-guard' + ); + PERFORM pg_temp.assert_true((v_cancel->>'created')::boolean, 'active subscriber can still cancel'); + PERFORM public.mark_payment_provider_operation( + (v_cancel->>'operation_id')::uuid, 'failed', NULL, 'fixture_released' + ); + + -- Once the paid period has lapsed, a new checkout is allowed again. + UPDATE public.subscriptions + SET current_period_end = now() - interval '1 second' + WHERE user_id = c_user; + v_lapsed := public.reserve_payment_provider_operation( + c_user, 'payple', 'checkout', 'pro', + 'payple-checkout:active-guard-lapsed', 'D3RO-20260928031500-guard04', + 'payer-active-guard' + ); + PERFORM pg_temp.assert_true( + (v_lapsed->>'created')::boolean, + 'checkout is allowed after the paid Payple period ended' + ); +END; +$$; + +ROLLBACK; + +SELECT 'payple_checkout_active_subscription_ok' AS result;