fix(billing): stop back-office edits from changing Payple renewal charges

This commit is contained in:
Yun Chan 2026-09-28 02:16:21 +09:00
parent 957e136789
commit f456d737c4
8 changed files with 639 additions and 3 deletions

View file

@ -9,10 +9,12 @@ import {
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>
@ -94,6 +96,32 @@ async function selectDueRenewals(
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)
@ -109,6 +137,11 @@ export async function paypleRenewHandler(req: Request): Promise<Response> {
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()),
@ -117,7 +150,7 @@ export async function paypleRenewHandler(req: Request): Promise<Response> {
}
for (const row of dueRenewals) {
const candidate = normalizeRenewalCandidate(row)
const candidate = normalizeRenewalCandidate(withPaidTier(row, paidTiers))
if (!candidate) {
results.push({
userId: typeof row.user_id === 'string' ? row.user_id : '',