import { corsHeaders } from '../_shared/cors.ts' import { createServiceRoleClient } from '../_shared/quota.ts' import { calcSubscriptionPeriod, generateOrderId, getPaypleConfig, parsePaypleTimestamp, paypleAuth, paypleBilling, PaypleBillingError, PaypleConfigurationError, payplePaymentEventDigest, payplePaymentEventId, sha256Text, TIER_GOODS_NAME, TIER_PRICE, } from '../_shared/payple.ts' const MAX_RENEWAL_FAILURES = 3 interface RenewalResult { userId: string tier: string success: boolean orderId?: string error?: string } interface OperationReservation { created?: boolean operation_id?: string reason?: string } function jsonResponse(body: Record, status = 200): Response { return new Response(JSON.stringify(body), { status, headers: { ...corsHeaders, 'Content-Type': 'application/json' }, }) } Deno.serve(async (req: Request) => { if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders }) if (req.method !== 'POST') return jsonResponse({ error: 'method_not_allowed' }, 405) const token = (req.headers.get('authorization') ?? '').replace(/^Bearer\s+/i, '') const cronSecret = Deno.env.get('CRON_SECRET')?.trim() ?? '' if (!cronSecret || token.length !== cronSecret.length) { return jsonResponse({ error: 'unauthorized' }, 401) } let mismatch = 0 for (let index = 0; index < token.length; index += 1) { mismatch |= token.charCodeAt(index) ^ cronSecret.charCodeAt(index) } if (mismatch !== 0) return jsonResponse({ error: 'unauthorized' }, 401) const serviceClient = createServiceRoleClient() const now = new Date() const results: RenewalResult[] = [] try { // End-of-term cancellations are revoked through the same serialized RPC. const { data: endingSubscriptions, error: endingError } = await serviceClient .from('subscriptions') .select('user_id, tier, provider_resource_id, current_period_start, current_period_end') .eq('provider', 'payple') .eq('auto_renewing', false) .not('provider_resource_id', 'is', null) .lte('current_period_end', now.toISOString()) if (endingError) throw new Error('ending_subscription_query_failed') for (const subscription of endingSubscriptions ?? []) { if ( typeof subscription.user_id !== 'string' || typeof subscription.provider_resource_id !== 'string' ) continue const eventId = `scheduled-expire:${subscription.provider_resource_id}:${subscription.current_period_end}` const { error: expireError } = await serviceClient.rpc('apply_payment_provider_event', { p_user_id: subscription.user_id, p_provider: 'payple', p_event_id: eventId.slice(0, 255), p_event_created_at: now.toISOString(), p_event_type: 'subscription.scheduled_expiry', p_payload_digest: await sha256Text(eventId), p_provider_resource_id: subscription.provider_resource_id, p_tier: 'free', p_status: 'expired', p_entitled: false, p_current_period_start: subscription.current_period_start, p_current_period_end: subscription.current_period_end, p_cancel_at: subscription.current_period_end, p_auto_renewing: false, p_provider_customer_id: null, p_provider_order_id: null, p_store_product_id: null, p_store_purchase_id: null, p_operation_id: null, }) if (expireError) throw new Error('scheduled_expiry_failed') } const { data: expiredSubscriptions, error: queryError } = await serviceClient .from('subscriptions') .select( 'user_id, tier, payple_payer_id, provider_resource_id, current_period_end, renewal_failures', ) .eq('provider', 'payple') .eq('auto_renewing', true) .in('status', ['active', 'past_due']) .not('payple_payer_id', 'is', null) .lte('current_period_end', now.toISOString()) if (queryError) throw new Error('renewal_query_failed') if (!expiredSubscriptions?.length) { return jsonResponse({ renewed: 0, failed: 0, expired: endingSubscriptions?.length ?? 0, results }) } const config = getPaypleConfig() const auth = await paypleAuth(config, { simpleFlag: true }) for (const subscription of expiredSubscriptions) { const userId = typeof subscription.user_id === 'string' ? subscription.user_id : '' const tier = subscription.tier === 'pro' || subscription.tier === 'pro_plus' ? subscription.tier : null const payerId = typeof subscription.payple_payer_id === 'string' ? subscription.payple_payer_id : null const resourceId = typeof subscription.provider_resource_id === 'string' ? subscription.provider_resource_id : null if (!userId || !tier || !payerId || !resourceId) { results.push({ userId, tier: String(subscription.tier), success: false, error: 'invalid_subscription' }) continue } const attempt = Number(subscription.renewal_failures ?? 0) + 1 const periodKey = new Date(subscription.current_period_end as string).getTime() const idempotencyKey = `payple-renew:${userId}:${periodKey}:attempt${attempt}` const orderId = generateOrderId(userId) let operationId: string | null = null let charged = false try { const { data: reservationData, error: reservationError } = await serviceClient.rpc( 'reserve_payment_provider_operation', { p_user_id: userId, p_provider: 'payple', p_operation_type: 'renewal', p_requested_tier: tier, p_idempotency_key: idempotencyKey, p_provider_order_id: orderId, p_provider_resource_id: resourceId, }, ) if (reservationError) throw new Error('renewal_reservation_failed') const reservation = reservationData as OperationReservation | null if (!reservation?.created || typeof reservation.operation_id !== 'string') { results.push({ userId, tier, success: false, error: reservation?.reason ?? 'renewal_operation_in_progress', }) continue } operationId = reservation.operation_id const billing = await paypleBilling(config, auth, { payerId, amount: TIER_PRICE[tier], orderId, goodsName: TIER_GOODS_NAME[tier], }) charged = true const { error: chargedError } = await serviceClient.rpc('mark_payment_provider_operation', { p_operation_id: operationId, p_state: 'charged', p_external_reference: billing.PCD_PAY_OID || orderId, p_error_code: null, }) if (chargedError) throw new Error('renewal_operation_update_failed') if ( billing.PCD_PAY_OID !== orderId || billing.PCD_PAY_TOTAL !== String(TIER_PRICE[tier]) || (billing.PCD_PAYER_ID && billing.PCD_PAYER_ID !== payerId) ) throw new Error('renewal_response_mismatch') const eventTime = billing.PCD_PAY_TIME ? parsePaypleTimestamp(billing.PCD_PAY_TIME) : new Date() const { start, end } = calcSubscriptionPeriod(eventTime) const { data: applyData, error: applyError } = await serviceClient.rpc( 'apply_payment_provider_event', { p_user_id: userId, p_provider: 'payple', p_event_id: payplePaymentEventId(orderId), p_event_created_at: eventTime.toISOString(), p_event_type: 'payment.completed', p_payload_digest: await payplePaymentEventDigest({ orderId, payerId, payType: 'card', amount: TIER_PRICE[tier], }), p_provider_resource_id: resourceId, p_tier: 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: payerId, p_provider_order_id: orderId, p_store_product_id: null, p_store_purchase_id: null, p_operation_id: operationId, }, ) if (applyError) throw new Error('renewal_entitlement_apply_failed') const applied = applyData as { applied?: boolean; duplicate?: boolean; reason?: string } | null if (!applied?.applied && !applied?.duplicate) { throw new Error('renewal_requires_reconciliation') } results.push({ userId, tier, success: true, orderId }) } catch (error) { const chargeOutcomeUnknown = error instanceof PaypleBillingError && !error.definitive if (operationId && !charged) { if (chargeOutcomeUnknown) { await serviceClient.rpc('mark_payment_provider_operation', { p_operation_id: operationId, p_state: 'external_created', p_external_reference: orderId, p_error_code: null, }) } else if (error instanceof PaypleBillingError && error.definitive) { const eventTime = new Date() await serviceClient.rpc('record_payment_provider_renewal_failure', { p_user_id: userId, p_provider: 'payple', p_event_id: `renewal-failed:${operationId}`, p_event_created_at: eventTime.toISOString(), p_payload_digest: await sha256Text(`${operationId}:payple_renewal_failed`), p_provider_resource_id: resourceId, p_error_code: 'payple_renewal_failed', p_failure_threshold: MAX_RENEWAL_FAILURES, p_operation_id: operationId, }) } else { await serviceClient.rpc('mark_payment_provider_operation', { p_operation_id: operationId, p_state: 'failed', p_external_reference: null, p_error_code: 'payple_renewal_failed', }) } } results.push({ userId, tier, success: false, error: charged || chargeOutcomeUnknown ? 'renewal_requires_reconciliation' : 'payple_renewal_failed', }) } } return jsonResponse({ renewed: results.filter((result) => result.success).length, failed: results.filter((result) => !result.success).length, expired: endingSubscriptions?.length ?? 0, results, }) } catch (error) { if (error instanceof PaypleConfigurationError) { return jsonResponse({ error: error.code }, 503) } return jsonResponse({ error: 'payple_renewal_processing_failed' }, 500) } })