// 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' import { isQuotaAllowed, parseQuotaConsumeResponse, type QuotaConsumedFrom, quotaUsageDate, quotaWindowStart, } from './quota-policy.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>>> = 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: QuotaConsumedFrom } /** reserve_*_quota RPC 결과 — 공급자 호출 전에 한 단위를 잡아 두는 리스(lease). */ export interface QuotaReservation { 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 type SttQuotaReservation = QuotaReservation export type QuotaReservationFinalStatus = 'completed' | 'released' const RESERVATION_STATUSES: readonly unknown[] = ['reserved', 'completed', 'released', 'denied'] const QUOTA_PERIODS: readonly unknown[] = ['daily', 'weekly'] const QUOTA_TIERS: readonly unknown[] = ['free', 'pro', 'pro_plus', 'team', 'enterprise'] const RESERVATION_SOURCES: readonly unknown[] = ['base', 'overage', 'unlimited', 'none'] /** reserve_stt_quota / reserve_llm_quota 공통 응답 검증. 형식이 어긋나면 throw. */ export function parseQuotaReservationResponse(data: unknown, label: string): QuotaReservation { if (!data || typeof data !== 'object' || Array.isArray(data)) { throw new Error(`Invalid ${label} quota reservation response.`) } const result = data as Record if ( typeof result.allowed !== 'boolean' || (result.reservation_id !== null && typeof result.reservation_id !== 'string') || !RESERVATION_STATUSES.includes(result.status) || typeof result.current !== 'number' || typeof result.limit !== 'number' || !QUOTA_PERIODS.includes(result.period) || !QUOTA_TIERS.includes(result.tier) || typeof result.overage_credits !== 'number' || !RESERVATION_SOURCES.includes(result.consumed_from) ) { throw new Error(`Invalid ${label} quota reservation response.`) } return { allowed: result.allowed, reservationId: result.reservation_id as string | null, status: result.status as QuotaReservation['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 QuotaReservation['consumedFrom'], } } /** finalize_*_quota RPC 공통 응답 검증. */ export function parseQuotaFinalizeResponse(data: unknown, label: string): QuotaReservationFinalStatus { if (!data || typeof data !== 'object' || Array.isArray(data)) { throw new Error(`Invalid ${label} quota finalization response.`) } const status = (data as Record).status if (status !== 'completed' && status !== 'released') { throw new Error(`Invalid ${label} quota finalization response.`) } return status } export async function reserveSttQuota( userId: string, reservationId: string, serviceRoleClient: ReturnType, ): Promise { 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.') } return parseQuotaReservationResponse(data, 'STT') } export async function finalizeSttQuota( reservationId: string, succeeded: boolean, serviceRoleClient: ReturnType, ): Promise { 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.') } return parseQuotaFinalizeResponse(data, 'STT') } /** * LLM 쿼터 한 단위를 공급자 호출 **전에** 잡는다 (`reserve_llm_quota`). * consume_quota 와 같은 per-user/feature advisory lock 아래에서 진행 중인 요청까지 * 사용량에 포함하므로, 동시 요청이 한도를 넘어 공급자 비용을 쓰지 못한다. * 성공/실패는 finalizeLlmQuota 로 확정하고, 확정되지 않은 리스는 10분 뒤 환불된다. */ export async function reserveLlmQuota( userId: string, reservationId: string, feature: QuotaFeature, baseLimit: number, period: QuotaPeriod, serviceRoleClient: ReturnType, ): Promise { const { data, error } = await serviceRoleClient.rpc('reserve_llm_quota', { p_user_id: userId, p_reservation_id: reservationId, p_feature: feature, p_base_limit: baseLimit, p_period: period, }) if (error || !data || typeof data !== 'object' || Array.isArray(data)) { throw new Error('Failed to reserve LLM quota.') } return parseQuotaReservationResponse(data, 'LLM') } /** 예약을 확정한다 — succeeded=true 면 소비 유지, false 면 사용량과 overage 크레딧 환불. */ export async function finalizeLlmQuota( reservationId: string, succeeded: boolean, serviceRoleClient: ReturnType, ): Promise { const { data, error } = await serviceRoleClient.rpc('finalize_llm_quota', { p_reservation_id: reservationId, p_succeeded: succeeded, }) if (error || !data || typeof data !== 'object' || Array.isArray(data)) { throw new Error('Failed to finalize LLM quota.') } return parseQuotaFinalizeResponse(data, 'LLM') } interface SubscriptionQuotaState { tier: Tier overageCredits: number } /** 구독 티어 + overage 잔액. 행이 없으면 free/0 (기존 checkQuota 동작 유지). */ async function readSubscriptionQuotaState( userId: string, serviceRoleClient: ReturnType, ): Promise { const { data: sub } = await serviceRoleClient .from('subscriptions') .select('tier, overage_credits') .eq('user_id', userId) .single() return { tier: (sub?.tier as Tier) ?? 'free', overageCredits: (sub?.overage_credits as number) ?? 0, } } /** 구독 티어만 조회 (행이 없으면 free). 사용량은 읽지 않는다. */ export async function readSubscriptionTier( userId: string, serviceRoleClient: ReturnType, ): Promise { return (await readSubscriptionQuotaState(userId, serviceRoleClient)).tier } /** 기간 창(daily=오늘, weekly=오늘-6..오늘) 안의 daily_usage 합계. */ async function readWindowUsage( userId: string, feature: QuotaFeature, period: QuotaPeriod, serviceRoleClient: ReturnType, now: Date, ): Promise { const { data: rows } = await serviceRoleClient .from('daily_usage') .select('count') .eq('user_id', userId) .eq('feature', feature) .gte('date', quotaWindowStart(period, now)) .lte('date', quotaUsageDate(now)) const usageRows = (rows ?? []) as Array<{ count: number | null }> return usageRows.reduce((sum, row) => sum + (row.count ?? 0), 0) } /** * 쿼터 확인 — 모델별, 기간별(daily/weekly). * weekly인 경우 최근 7일(오늘 포함, 오늘-6..오늘) daily_usage를 합산 — * SQL 쪽 `consume_quota`·`reserve_stt_quota`의 `CURRENT_DATE - 6` 창과 같다. */ export async function checkQuota( userId: string, feature: QuotaFeature, serviceRoleClient: ReturnType, now: Date = new Date(), ): Promise { const { tier, overageCredits } = await readSubscriptionQuotaState(userId, serviceRoleClient) const policy = getQuotaPolicy(tier, feature) // 사용불가 (limit=0) / 무제한 (limit=-1) 은 사용량 조회가 필요 없다. if (policy.limit === 0 || policy.limit === -1) { return { allowed: isQuotaAllowed(policy.limit, 0, overageCredits), current: 0, limit: policy.limit, period: policy.period, tier, overageCredits, } } const current = await readWindowUsage(userId, feature, policy.period, serviceRoleClient, now) return { allowed: isQuotaAllowed(policy.limit, current, overageCredits), current, limit: policy.limit, period: policy.period, tier, overageCredits, } } /** * 쿼터 소비 — 오늘 날짜의 daily_usage를 +1 증가. * `consume_quota` RPC가 기간 창(weekly=최근 7일 합산) 사용량을 base 한도와 비교해 * base 소진 + overage 있으면 overage 1 크레딧을 원자적으로 차감한다. * 무제한(-1)이면 카운터만 증가하고 allowed=true. * * `period`를 넘기지 않으면 구독 티어로 정책 기간을 다시 조회한다 * (checkQuota 결과의 `period`를 넘기면 조회 1회를 아낀다). */ export async function consumeQuota( userId: string, feature: QuotaFeature, serviceRoleClient: ReturnType, baseLimit: number, period?: QuotaPeriod, ): Promise { const resolvedPeriod = period ?? getQuotaPolicy((await readSubscriptionQuotaState(userId, serviceRoleClient)).tier, feature).period const { data, error } = await serviceRoleClient.rpc('consume_quota', { p_user_id: userId, p_feature: feature, p_base_limit: baseLimit, p_period: resolvedPeriod, }) if (error) { throw new Error(`Failed to consume quota: ${error.message}`) } return parseQuotaConsumeResponse(data) } /** * service role 클라이언트 생성 헬퍼 */ export function createServiceRoleClient(): ReturnType { 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 }, }) }