// server/supabase/functions/_shared/quota.ts // Phase 3.2: 모델별 쿼터 + 주간/일간 기간 분리 // Free=Haiku 250/주간, Pro=모델별 일간, Pro+=Haiku 무제한 // @ts-expect-error — Deno 런타임 import import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.39.7' export type Tier = 'free' | 'pro' | 'pro_plus' /** 쿼터 추적 키 — 모델별 분리 */ export type QuotaFeature = | 'stt_transcribe' | 'llm_haiku' | 'llm_sonnet' | 'llm_opus' export type QuotaPeriod = 'daily' | 'weekly' interface ModelQuota { /** -1=무제한, 0=사용불가, 양수=한도 */ limit: number period: QuotaPeriod } /** 모델별 쿼터 정책 */ const MODEL_QUOTA: Record> = { free: { stt_transcribe: { limit: 250, period: 'weekly' }, llm_haiku: { limit: 250, period: 'weekly' }, llm_sonnet: { limit: 0, period: 'daily' }, // 사용불가 llm_opus: { limit: 0, period: 'daily' }, // 사용불가 }, pro: { stt_transcribe: { limit: -1, period: 'daily' }, llm_haiku: { limit: 1500, period: 'daily' }, llm_sonnet: { limit: 300, period: 'daily' }, llm_opus: { limit: 50, period: 'daily' }, }, pro_plus: { stt_transcribe: { limit: -1, period: 'daily' }, llm_haiku: { limit: -1, period: 'daily' }, // 무제한 llm_sonnet: { limit: 1500, period: 'daily' }, llm_opus: { limit: 300, period: 'daily' }, }, } /** 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' } /** * 쿼터 확인 — 모델별, 기간별(daily/weekly). * weekly인 경우 최근 7일 daily_usage를 합산. */ export async function checkQuota( userId: string, feature: QuotaFeature, serviceRoleClient: ReturnType, ): Promise { // 티어 + 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)) current = rows?.reduce((sum: number, row: { count: number }) => 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, baseLimit: number, ): Promise { 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 { // @ts-expect-error — Deno.env는 Deno 런타임 전역 const url = Deno.env.get('SUPABASE_URL') ?? '' // @ts-expect-error — Deno.env는 Deno 런타임 전역 const serviceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? '' return createClient(url, serviceKey, { auth: { persistSession: false, autoRefreshToken: false }, }) }