// 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 } 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, ): 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.') } const result = data as Record 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, ): 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).status if (status !== 'completed' && status !== 'released') { throw new Error('Invalid STT quota finalization response.') } return status } 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, } } /** 기간 창(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 }, }) }