d3ro-voice/server/supabase/functions/payple-manage/index.ts
2026-08-29 18:33:45 +09:00

198 lines
7.1 KiB
TypeScript

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<string, unknown>, 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)
}
})