d3ro-voice/server/supabase/functions/_shared/quota.ts
Yun Chan 88f24d84a1 refactor(core): keep plan prices, cloud quotas and public URLs in one contract (WS-A)
Prices, quotas and site URLs were copied by hand into the edge functions,
admin, desktop and the landing site, and the copies disagreed (Payple billed
9,900/29,900 KRW, admin labels said 12,900/24,900 KRW and $9.9/$19.9, the
site said 2,900/8,900 KRW).

- packages/core/src/plan-catalog.ts is the single source for PLAN_PRICE_KRW
  (Free 0 / Pro 2,900 / Pro+ 8,900 a month) and PLAN_QUOTA.
- packages/core/src/web-urls.ts is the single source for the public origin,
  the /app web-app base path, SITE_URLS and billingUrl().
- Deno cannot bundle packages/core, so scripts/ci/sync-core-contract.mjs
  generates _shared/core-contract.generated.ts; `npm run contract:check`
  fails on drift (same pattern as version:sync).
- Payple checkout, renewal and webhook amount checks now bill the catalog
  price, so existing subscribers move to the new price at their next renewal.
  quota.ts, team-contract.ts and the tests read the generated values.
- Admin MRR/ARR is computed in KRW from the catalog; license labels, the
  release link and desktop PREMIUM_LLM limits derive from core; the site
  imports prices and quotas directly.

Policy: docs/REFACTOR_POLICY.md Wave 3, W3-1 and W3-2.
2026-09-26 15:48:18 +09:00

241 lines
7.4 KiB
TypeScript

// server/supabase/functions/_shared/quota.ts
// Phase 3.2: 모델별 쿼터 + 주간/일간 기간 분리
// Free=Haiku 250/주간, Pro=모델별 일간, Pro+=Haiku 무제한
import { createClient } from '@supabase/supabase-js'
import {
PLAN_QUOTA,
type PlanQuota,
type PlanQuotaFeature,
type PlanQuotaPeriod,
type PlanQuotaTier,
} from './core-contract.generated.ts'
export type Tier = PlanQuotaTier
/** 쿼터 추적 키 — 모델별 분리 */
export type QuotaFeature = PlanQuotaFeature
export type QuotaPeriod = PlanQuotaPeriod
type ModelQuota = PlanQuota
/** 모델별 쿼터 정책. 정본은 packages/core/src/plan-catalog.ts `PLAN_QUOTA` (생성 사본 경유). */
const MODEL_QUOTA: Readonly<Record<Tier, Readonly<Record<QuotaFeature, ModelQuota>>>> = PLAN_QUOTA
/** Anthropic 모델명 → 쿼터 키 매핑 */
export function modelToQuotaKey(model: string): QuotaFeature {
if (model.includes('haiku')) return 'llm_haiku'
if (model.includes('sonnet')) return 'llm_sonnet'
if (model.includes('opus')) return 'llm_opus'
return 'llm_haiku' // fallback
}
/** 티어+feature → 쿼터 정책 조회 */
export function getQuotaPolicy(tier: Tier, feature: QuotaFeature): ModelQuota {
return MODEL_QUOTA[tier]?.[feature] ?? { limit: 0, period: 'daily' }
}
export interface QuotaCheck {
allowed: boolean
current: number
limit: number
period: QuotaPeriod
tier: Tier
overageCredits: number
}
export interface QuotaConsumeResult {
allowed: boolean
current: number
limit: number
overageCredits: number
consumedFrom: 'base' | 'overage' | 'unlimited' | 'none'
}
export interface SttQuotaReservation {
allowed: boolean
reservationId: string | null
status: 'reserved' | 'completed' | 'released' | 'denied'
current: number
limit: number
period: QuotaPeriod
tier: Tier
overageCredits: number
consumedFrom: 'base' | 'overage' | 'unlimited' | 'none'
}
export async function reserveSttQuota(
userId: string,
reservationId: string,
serviceRoleClient: ReturnType<typeof createClient>,
): Promise<SttQuotaReservation> {
const { data, error } = await serviceRoleClient.rpc('reserve_stt_quota', {
p_user_id: userId,
p_reservation_id: reservationId,
})
if (error || !data || typeof data !== 'object' || Array.isArray(data)) {
throw new Error('Failed to reserve STT quota.')
}
const result = data as Record<string, unknown>
if (
typeof result.allowed !== 'boolean'
|| (result.reservation_id !== null && typeof result.reservation_id !== 'string')
|| !['reserved', 'completed', 'released', 'denied'].includes(String(result.status))
|| typeof result.current !== 'number'
|| typeof result.limit !== 'number'
|| !['daily', 'weekly'].includes(String(result.period))
|| !['free', 'pro', 'pro_plus', 'team', 'enterprise'].includes(String(result.tier))
|| typeof result.overage_credits !== 'number'
|| !['base', 'overage', 'unlimited', 'none'].includes(String(result.consumed_from))
) {
throw new Error('Invalid STT quota reservation response.')
}
return {
allowed: result.allowed,
reservationId: result.reservation_id as string | null,
status: result.status as SttQuotaReservation['status'],
current: result.current,
limit: result.limit,
period: result.period as QuotaPeriod,
tier: result.tier as Tier,
overageCredits: result.overage_credits,
consumedFrom: result.consumed_from as SttQuotaReservation['consumedFrom'],
}
}
export async function finalizeSttQuota(
reservationId: string,
succeeded: boolean,
serviceRoleClient: ReturnType<typeof createClient>,
): Promise<'completed' | 'released'> {
const { data, error } = await serviceRoleClient.rpc('finalize_stt_quota', {
p_reservation_id: reservationId,
p_succeeded: succeeded,
})
if (error || !data || typeof data !== 'object' || Array.isArray(data)) {
throw new Error('Failed to finalize STT quota.')
}
const status = (data as Record<string, unknown>).status
if (status !== 'completed' && status !== 'released') {
throw new Error('Invalid STT quota finalization response.')
}
return status
}
/**
* 쿼터 확인 — 모델별, 기간별(daily/weekly).
* weekly인 경우 최근 7일 daily_usage를 합산.
*/
export async function checkQuota(
userId: string,
feature: QuotaFeature,
serviceRoleClient: ReturnType<typeof createClient>,
): Promise<QuotaCheck> {
// 티어 + overage 조회
const { data: sub } = await serviceRoleClient
.from('subscriptions')
.select('tier, overage_credits')
.eq('user_id', userId)
.single()
const tier: Tier = (sub?.tier as Tier) ?? 'free'
const overageCredits = (sub?.overage_credits as number) ?? 0
const policy = getQuotaPolicy(tier, feature)
// 사용불가 (limit=0)
if (policy.limit === 0) {
return { allowed: false, current: 0, limit: 0, period: policy.period, tier, overageCredits }
}
// 무제한
if (policy.limit === -1) {
return { allowed: true, current: 0, limit: -1, period: policy.period, tier, overageCredits }
}
// 사용량 조회 (daily vs weekly)
let current: number
if (policy.period === 'weekly') {
// 최근 7일 합산
const weekAgo = new Date()
weekAgo.setDate(weekAgo.getDate() - 7)
const { data: rows } = await serviceRoleClient
.from('daily_usage')
.select('count')
.eq('user_id', userId)
.eq('feature', feature)
.gte('date', weekAgo.toISOString().slice(0, 10))
const usageRows = (rows ?? []) as Array<{ count: number | null }>
current = usageRows.reduce((sum, row) => sum + (row.count ?? 0), 0)
} else {
// 오늘만
const today = new Date().toISOString().slice(0, 10)
const { data: usage } = await serviceRoleClient
.from('daily_usage')
.select('count')
.eq('user_id', userId)
.eq('date', today)
.eq('feature', feature)
.maybeSingle()
current = (usage?.count as number) ?? 0
}
return {
allowed: current < policy.limit || overageCredits > 0,
current,
limit: policy.limit,
period: policy.period,
tier,
overageCredits,
}
}
/**
* 쿼터 소비 — 항상 오늘 날짜의 daily_usage를 +1 증가.
* (weekly 집계는 checkQuota에서 7일 합산으로 처리)
* 무제한(-1)이면 카운터만 증가하고 allowed=true.
* base 소진 + overage 있으면 overage 차감.
*/
export async function consumeQuota(
userId: string,
feature: QuotaFeature,
serviceRoleClient: ReturnType<typeof createClient>,
baseLimit: number,
): Promise<QuotaConsumeResult> {
const { data, error } = await serviceRoleClient.rpc('consume_quota', {
p_user_id: userId,
p_feature: feature,
p_base_limit: baseLimit,
})
if (error) {
throw new Error(`Failed to consume quota: ${error.message}`)
}
const result = data as {
allowed: boolean
current: number
limit: number
overage_credits: number
consumed_from: 'base' | 'overage' | 'unlimited' | 'none'
}
return {
allowed: result.allowed,
current: result.current,
limit: result.limit,
overageCredits: result.overage_credits,
consumedFrom: result.consumed_from,
}
}
/**
* service role 클라이언트 생성 헬퍼
*/
export function createServiceRoleClient(): ReturnType<typeof createClient> {
const url = Deno.env.get('SUPABASE_URL') ?? ''
const serviceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''
return createClient(url, serviceKey, {
auth: { persistSession: false, autoRefreshToken: false },
})
}