d3ro-voice/server/supabase/functions/payple-renew/index.ts

180 lines
6.7 KiB
TypeScript

import { corsHeaders } from '../_shared/cors.ts'
import { createServiceRoleClient } from '../_shared/quota.ts'
import {
generateOrderId,
getPaypleConfig,
PaypleConfigurationError,
sha256Text,
} from '../_shared/payple.ts'
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<typeof createServiceRoleClient>
function jsonResponse(body: Record<string, unknown>, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
})
}
function isAuthorizedCron(req: Request): boolean {
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 false
let mismatch = 0
for (let index = 0; index < token.length; index += 1) {
mismatch |= token.charCodeAt(index) ^ cronSecret.charCodeAt(index)
}
return mismatch === 0
}
/** End-of-term cancellations are revoked through the same serialized RPC. */
async function expireEndedSubscriptions(serviceClient: ServiceClient, now: Date): Promise<number> {
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')
}
return endingSubscriptions?.length ?? 0
}
async function selectDueRenewals(
serviceClient: ServiceClient,
now: Date,
): Promise<Record<string, unknown>[]> {
const { data, error } = 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 (error) throw new Error('renewal_query_failed')
return (data ?? []) as Record<string, unknown>[]
}
const PAID_TIER_QUERY_CHUNK = 100
/**
* 갱신 대상 사용자별로 마지막으로 반영된 Payple 결제 등급을 읽는다.
* 청구 금액의 정본은 이 원장이다 — subscriptions.tier는 청구 근거로 쓰지 않는다.
*/
async function loadLastPaidTiers(
serviceClient: ServiceClient,
userIds: readonly string[],
): Promise<Map<string, RenewableTier>> {
const rows: Record<string, unknown>[] = []
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<string, unknown>[]))
}
return latestPaidTierByUser(rows)
}
export async function paypleRenewHandler(req: Request): Promise<Response> {
if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })
if (req.method !== 'POST') return jsonResponse({ error: 'method_not_allowed' }, 405)
if (!isAuthorizedCron(req)) return jsonResponse({ error: 'unauthorized' }, 401)
const serviceClient = createServiceRoleClient()
const now = new Date()
const results: RenewalResult[] = []
try {
const expired = await expireEndedSubscriptions(serviceClient, now)
const dueRenewals = await selectDueRenewals(serviceClient, now)
if (!dueRenewals.length) {
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()),
now: () => new Date(),
newOrderId: (userId) => generateOrderId(userId),
}
for (const row of dueRenewals) {
const candidate = normalizeRenewalCandidate(withPaidTier(row, paidTiers))
if (!candidate) {
results.push({
userId: typeof row.user_id === 'string' ? row.user_id : '',
tier: String(row.tier),
success: false,
error: 'invalid_subscription',
})
continue
}
results.push(await renewSubscription(deps, candidate))
}
return jsonResponse({
renewed: results.filter((result) => result.success).length,
failed: results.filter((result) => !result.success).length,
expired,
results,
})
} catch (error) {
if (error instanceof PaypleConfigurationError) {
return jsonResponse({ error: error.code }, 503)
}
return jsonResponse({ error: 'payple_renewal_processing_failed' }, 500)
}
}
if (import.meta.main) Deno.serve(paypleRenewHandler)