fix(billing): stop back-office edits from changing Payple renewal charges
This commit is contained in:
parent
957e136789
commit
f456d737c4
8 changed files with 639 additions and 3 deletions
|
|
@ -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 : '',
|
||||
|
|
|
|||
111
server/supabase/functions/payple-renew/paid-tier.test.ts
Normal file
111
server/supabase/functions/payple-renew/paid-tier.test.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import { latestPaidTierByUser, withPaidTier } from './paid-tier.ts'
|
||||
import {
|
||||
type ChargeRequest,
|
||||
normalizeRenewalCandidate,
|
||||
type RenewalDeps,
|
||||
type RenewalPaymentApplication,
|
||||
renewSubscription,
|
||||
} from './renewal.ts'
|
||||
import { TIER_PRICE } from '../_shared/payple.ts'
|
||||
|
||||
function assert(condition: boolean, message: string): asserts condition {
|
||||
if (!condition) throw new Error(message)
|
||||
}
|
||||
|
||||
const USER = 'user0019-0000-0000-0000-000000000000'
|
||||
const OTHER_USER = 'user0020-0000-0000-0000-000000000000'
|
||||
const ORDER = 'D3RO-20260928100000-user0019-cccccccc'
|
||||
|
||||
/** A Payple Pro subscriber whose row tier a back-office edit rewrote to pro_plus. */
|
||||
const tamperedDueRow: Record<string, unknown> = {
|
||||
user_id: USER,
|
||||
tier: 'pro_plus',
|
||||
payple_payer_id: 'payer-19',
|
||||
provider_resource_id: 'payer-19',
|
||||
current_period_end: '2026-09-27T00:00:00.000Z',
|
||||
renewal_failures: 0,
|
||||
}
|
||||
|
||||
const appliedOperations: Record<string, unknown>[] = [
|
||||
{ user_id: USER, requested_tier: 'pro', updated_at: '2026-08-27T00:00:05.000Z', created_at: '2026-08-27T00:00:00.000Z' },
|
||||
{ user_id: OTHER_USER, requested_tier: 'pro_plus', updated_at: '2026-09-01T00:00:00.000Z', created_at: '2026-09-01T00:00:00.000Z' },
|
||||
]
|
||||
|
||||
function chargingHarness(): { deps: RenewalDeps; charges: ChargeRequest[]; applied: RenewalPaymentApplication[] } {
|
||||
const charges: ChargeRequest[] = []
|
||||
const applied: RenewalPaymentApplication[] = []
|
||||
const deps: RenewalDeps = {
|
||||
now: () => new Date('2026-09-28T01:00:00.000Z'),
|
||||
newOrderId: () => ORDER,
|
||||
store: {
|
||||
reserveRenewal: () => Promise.resolve({ kind: 'created', operationId: 'op-19' }),
|
||||
getOperation: () => Promise.resolve(null),
|
||||
markOperation: () => Promise.resolve(true),
|
||||
recordRenewalFailure: () => Promise.resolve(true),
|
||||
applyRenewalPayment: (application) => {
|
||||
applied.push(application)
|
||||
return Promise.resolve({ applied: true, duplicate: false })
|
||||
},
|
||||
},
|
||||
gateway: {
|
||||
charge: (request) => {
|
||||
charges.push(request)
|
||||
return Promise.resolve({ orderId: request.orderId, total: String(request.amount) })
|
||||
},
|
||||
lookup: () => Promise.resolve({ kind: 'unknown' }),
|
||||
},
|
||||
}
|
||||
return { deps, charges, applied }
|
||||
}
|
||||
|
||||
// ── 회귀: 관리자 등급 수정이 다음 갱신 청구 금액을 바꾸던 버그 ─────────
|
||||
|
||||
Deno.test('renewal charges the tier the customer last paid for, not an edited row tier', async () => {
|
||||
const candidate = normalizeRenewalCandidate(
|
||||
withPaidTier(tamperedDueRow, latestPaidTierByUser(appliedOperations)),
|
||||
)
|
||||
assert(candidate !== null, 'candidate is valid')
|
||||
const { deps, charges, applied } = chargingHarness()
|
||||
|
||||
const result = await renewSubscription(deps, candidate)
|
||||
|
||||
assert(result.success, `renewal succeeds: ${JSON.stringify(result)}`)
|
||||
assert(charges.length === 1, 'charged once')
|
||||
assert(charges[0].amount === TIER_PRICE.pro, `charged the pro price, got ${charges[0].amount}`)
|
||||
assert(applied.length === 1 && applied[0].candidate.tier === 'pro', 'entitlement renewed at the paid tier')
|
||||
})
|
||||
|
||||
Deno.test('legacy subscriptions without a payment ledger entry keep their row tier', () => {
|
||||
const row = { ...tamperedDueRow, user_id: 'legacy-user', tier: 'pro' }
|
||||
const adjusted = withPaidTier(row, latestPaidTierByUser(appliedOperations))
|
||||
assert(adjusted === row, 'row is returned unchanged')
|
||||
assert(normalizeRenewalCandidate(adjusted)?.tier === 'pro', 'legacy tier kept')
|
||||
})
|
||||
|
||||
Deno.test('latest applied payment operation wins per user', () => {
|
||||
const tiers = latestPaidTierByUser([
|
||||
{ user_id: USER, requested_tier: 'pro', updated_at: '2026-07-01T00:00:00.000Z', created_at: '2026-07-01T00:00:00.000Z' },
|
||||
{ user_id: USER, requested_tier: 'pro_plus', updated_at: '2026-09-01T00:00:00.000Z', created_at: '2026-09-01T00:00:00.000Z' },
|
||||
{ user_id: USER, requested_tier: 'pro', updated_at: '2026-08-01T00:00:00.000Z', created_at: '2026-08-01T00:00:00.000Z' },
|
||||
])
|
||||
assert(tiers.get(USER) === 'pro_plus', 'most recently applied upgrade is charged')
|
||||
})
|
||||
|
||||
Deno.test('same applied time falls back to the later created operation', () => {
|
||||
const tiers = latestPaidTierByUser([
|
||||
{ user_id: USER, requested_tier: 'pro_plus', updated_at: '2026-09-01T00:00:00.000Z', created_at: '2026-08-31T00:00:00.000Z' },
|
||||
{ user_id: USER, requested_tier: 'pro', updated_at: '2026-09-01T00:00:00.000Z', created_at: '2026-09-01T00:00:00.000Z' },
|
||||
])
|
||||
assert(tiers.get(USER) === 'pro', 'later-created operation wins the tie')
|
||||
})
|
||||
|
||||
Deno.test('malformed ledger rows are ignored', () => {
|
||||
const tiers = latestPaidTierByUser([
|
||||
{ user_id: USER, requested_tier: 'free', updated_at: '2026-09-01T00:00:00.000Z' },
|
||||
{ user_id: USER, requested_tier: null, updated_at: '2026-09-02T00:00:00.000Z' },
|
||||
{ user_id: USER, requested_tier: 'pro_plus', updated_at: 'not-a-date' },
|
||||
{ user_id: 42, requested_tier: 'pro', updated_at: '2026-09-03T00:00:00.000Z' },
|
||||
{ user_id: USER, requested_tier: 'pro', updated_at: '2026-08-01T00:00:00.000Z' },
|
||||
])
|
||||
assert(tiers.size === 1 && tiers.get(USER) === 'pro', 'only the valid row counts')
|
||||
})
|
||||
58
server/supabase/functions/payple-renew/paid-tier.ts
Normal file
58
server/supabase/functions/payple-renew/paid-tier.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
// server/supabase/functions/payple-renew/paid-tier.ts
|
||||
// 갱신 청구 등급 정책 — 청구 금액은 고객이 실제로 결제한 등급에서 정한다.
|
||||
//
|
||||
// subscriptions.tier는 관리자 화면·동기화 트리거 등 여러 경로에서 바뀔 수 있는 엔타이틀먼트
|
||||
// 값이다. 저장된 빌링키로 청구할 금액을 이 값에서 읽으면, 등급 수정 한 번으로 고객이 동의하지
|
||||
// 않은 금액이 청구된다. 그래서 갱신은 마지막으로 반영(applied)된 Payple 결제 작업의
|
||||
// requested_tier를 정본으로 삼는다. 결제 작업 원장이 생기기 전의 레거시 구독처럼 기록이
|
||||
// 없을 때만 행의 tier를 그대로 쓴다.
|
||||
|
||||
import type { RenewableTier } from './renewal.ts'
|
||||
|
||||
function renewableTier(value: unknown): RenewableTier | null {
|
||||
return value === 'pro' || value === 'pro_plus' ? value : null
|
||||
}
|
||||
|
||||
function timestampOf(value: unknown): number {
|
||||
if (typeof value !== 'string') return Number.NaN
|
||||
return new Date(value).getTime()
|
||||
}
|
||||
|
||||
/**
|
||||
* payment_provider_operations 행(state='applied', 결제 유형)에서 사용자별로 가장 나중에
|
||||
* 반영된 작업의 등급을 고른다. 반영 시각은 updated_at(반영 후에는 더 바뀌지 않는다),
|
||||
* 같으면 created_at으로 가린다. 형식이 맞지 않는 행은 무시한다.
|
||||
*/
|
||||
export function latestPaidTierByUser(
|
||||
rows: readonly Record<string, unknown>[],
|
||||
): Map<string, RenewableTier> {
|
||||
const latest = new Map<string, { tier: RenewableTier; appliedAt: number; createdAt: number }>()
|
||||
for (const row of rows) {
|
||||
const userId = typeof row.user_id === 'string' ? row.user_id : ''
|
||||
const tier = renewableTier(row.requested_tier)
|
||||
const appliedAt = timestampOf(row.updated_at)
|
||||
if (!userId || !tier || !Number.isFinite(appliedAt)) continue
|
||||
const createdAt = timestampOf(row.created_at)
|
||||
const current = latest.get(userId)
|
||||
const newer = !current
|
||||
|| appliedAt > current.appliedAt
|
||||
|| (appliedAt === current.appliedAt
|
||||
&& Number.isFinite(createdAt)
|
||||
&& (!Number.isFinite(current.createdAt) || createdAt > current.createdAt))
|
||||
if (newer) latest.set(userId, { tier, appliedAt, createdAt })
|
||||
}
|
||||
return new Map([...latest].map(([userId, entry]) => [userId, entry.tier]))
|
||||
}
|
||||
|
||||
/**
|
||||
* 갱신 후보 행의 tier를 고객이 마지막으로 결제한 등급으로 맞춘다.
|
||||
* 결제 기록이 없으면 행을 그대로 돌려준다(레거시 호환).
|
||||
*/
|
||||
export function withPaidTier(
|
||||
row: Record<string, unknown>,
|
||||
paidTiers: ReadonlyMap<string, RenewableTier>,
|
||||
): Record<string, unknown> {
|
||||
const userId = typeof row.user_id === 'string' ? row.user_id : ''
|
||||
const paidTier = userId ? paidTiers.get(userId) : undefined
|
||||
return paidTier ? { ...row, tier: paidTier } : row
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue