import { corsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts' import { requireUser, authErrorResponse, type AuthError } from '../_shared/auth.ts' import { createServiceRoleClient } from '../_shared/quota.ts' import { getPaypleConfig, paypleAuth, paypleDeleteBillingKey, PaypleConfigurationError, sha256Text, } from '../_shared/payple.ts' interface ManageRequest { action?: unknown idempotency_key?: unknown } interface OperationReservation { created?: boolean operation_id?: string state?: string reason?: string } interface ApplyResult { applied?: boolean duplicate?: boolean 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) => { const preflight = handleCorsPreflightRequest(req) if (preflight) return preflight if (req.method !== 'POST') return jsonResponse({ error: 'method_not_allowed' }, 405) const serviceClient = createServiceRoleClient() let operationId: string | null = null let externalCancellationCompleted = false try { const user = await requireUser(req) const body = await req.json() as ManageRequest if ( body.action !== 'cancel' && body.action !== 'info' ) return jsonResponse({ error: 'invalid_action' }, 400) if ( body.idempotency_key !== undefined && (typeof body.idempotency_key !== 'string' || !/^[A-Za-z0-9._:-]{12,160}$/.test(body.idempotency_key)) ) return jsonResponse({ error: 'invalid_idempotency_key' }, 400) const { data: subscription, error: subscriptionError } = await serviceClient .from('subscriptions') .select( 'tier, status, provider, provider_resource_id, payple_payer_id, current_period_start, current_period_end, cancel_at, auto_renewing', ) .eq('user_id', user.id) .maybeSingle() if (subscriptionError) throw new Error('subscription_lookup_failed') if (!subscription) return jsonResponse({ error: 'subscription_not_found' }, 404) if (body.action === 'info') { return jsonResponse({ tier: subscription.tier, status: subscription.status, provider: subscription.provider, payment_provider: subscription.provider, current_period_end: subscription.current_period_end, cancel_at: subscription.cancel_at, auto_renewing: subscription.auto_renewing, has_billing_key: typeof subscription.payple_payer_id === 'string', }) } if ( subscription.provider !== 'payple' || typeof subscription.provider_resource_id !== 'string' || typeof subscription.payple_payer_id !== 'string' || (subscription.tier !== 'pro' && subscription.tier !== 'pro_plus') ) { return jsonResponse({ error: 'payple_subscription_not_cancellable' }, 409) } if (subscription.auto_renewing === false && subscription.cancel_at) { return jsonResponse({ success: true, duplicate: true, cancel_at: subscription.cancel_at, }) } const config = getPaypleConfig() const idempotencyKey = typeof body.idempotency_key === 'string' ? body.idempotency_key : `payple-cancel:${crypto.randomUUID()}` const { data: reservationData, error: reservationError } = await serviceClient.rpc( 'reserve_payment_provider_operation', { p_user_id: user.id, p_provider: 'payple', p_operation_type: 'cancellation', p_requested_tier: null, p_idempotency_key: idempotencyKey, p_provider_order_id: null, p_provider_resource_id: subscription.provider_resource_id, }, ) 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) } operationId = reservation.operation_id const auth = await paypleAuth(config, { payWork: 'PUSERDEL' }) await paypleDeleteBillingKey(config, auth, subscription.payple_payer_id) externalCancellationCompleted = true const eventTime = new Date() const periodEnd = typeof subscription.current_period_end === 'string' ? subscription.current_period_end : eventTime.toISOString() const { error: operationError } = await serviceClient.rpc('mark_payment_provider_operation', { p_operation_id: operationId, p_state: 'external_created', p_external_reference: subscription.provider_resource_id, p_error_code: null, }) if (operationError) throw new Error('payment_operation_update_failed') const { data: applyData, error: applyError } = await serviceClient.rpc( 'apply_payment_provider_event', { p_user_id: user.id, p_provider: 'payple', p_event_id: `manage-cancel:${operationId}`, p_event_created_at: eventTime.toISOString(), p_event_type: 'billing_key.revoked', p_payload_digest: await sha256Text( `${user.id}:${subscription.provider_resource_id}:${operationId}`, ), p_provider_resource_id: subscription.provider_resource_id, p_tier: subscription.tier, p_status: 'canceled', p_entitled: true, p_current_period_start: subscription.current_period_start, p_current_period_end: periodEnd, p_cancel_at: periodEnd, p_auto_renewing: false, // Empty is an explicit instruction to clear the deleted Payple key; // provider_resource_id remains as the ownership correlation key. p_provider_customer_id: '', p_provider_order_id: null, p_store_product_id: null, p_store_purchase_id: null, p_operation_id: operationId, }, ) if (applyError) throw new Error('entitlement_persistence_failed') const result = applyData as ApplyResult | null if (!result?.applied && !result?.duplicate) { return jsonResponse({ error: 'cancellation_requires_reconciliation', reason: result?.reason ?? 'entitlement_not_applied', }, 409) } return jsonResponse({ success: true, cancel_at: periodEnd }) } catch (error) { if (operationId && !externalCancellationCompleted) { await serviceClient.rpc('mark_payment_provider_operation', { p_operation_id: operationId, p_state: 'failed', p_external_reference: null, p_error_code: 'payple_cancellation_failed', }) } 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: externalCancellationCompleted ? 'cancellation_requires_reconciliation' : 'payple_cancellation_failed', }, externalCancellationCompleted ? 409 : 502) } })