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
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