d3ro-voice/server/supabase/functions/_shared/quota-policy.ts

84 lines
3.1 KiB
TypeScript

// server/supabase/functions/_shared/quota-policy.ts
// Pure quota policy shared by the quota adapter (quota.ts) and its tests.
//
// Policy (pure): window arithmetic, allow/deny decision, RPC response parsing.
// Adapter: quota.ts (subscriptions + daily_usage + consume_quota RPC).
//
// The weekly window is the rolling 7 calendar days ending today
// (today-6 .. today, UTC). The SQL quota functions (`consume_quota`,
// `reserve_stt_quota`, the meeting-document RPC) use `CURRENT_DATE - 6`;
// this module must stay on the same window so a pre-check never blocks usage
// that the atomic SQL consumption has already rolled off.
import type { PlanQuotaPeriod } from './core-contract.generated.ts'
/** Days before today that still count toward a weekly quota. */
export const WEEKLY_WINDOW_EXTRA_DAYS = 6
export type QuotaConsumedFrom = 'base' | 'overage' | 'unlimited' | 'none'
export interface QuotaConsumeResponse {
allowed: boolean
current: number
limit: number
overageCredits: number
consumedFrom: QuotaConsumedFrom
}
function isoDate(date: Date): string {
return date.toISOString().slice(0, 10)
}
/** Today's usage date (UTC, matches the database `CURRENT_DATE` on Supabase). */
export function quotaUsageDate(now: Date): string {
return isoDate(now)
}
/**
* First usage date (inclusive) that counts toward the period.
* daily → today; weekly → today-6 (7 calendar days including today).
*/
export function quotaWindowStart(period: PlanQuotaPeriod, now: Date): string {
if (period !== 'weekly') return isoDate(now)
const start = new Date(now.getTime())
start.setUTCDate(start.getUTCDate() - WEEKLY_WINDOW_EXTRA_DAYS)
return isoDate(start)
}
/**
* Whether one more call may go through.
* limit: -1 = unlimited, 0 = not available, >0 = base allowance in the window.
* Past the base allowance a positive overage balance lets the call through;
* the atomic `consume_quota` RPC then spends one credit for it.
*/
export function isQuotaAllowed(limit: number, current: number, overageCredits: number): boolean {
if (limit === 0) return false
if (limit === -1) return true
return current < limit || overageCredits > 0
}
const CONSUMED_FROM: readonly QuotaConsumedFrom[] = ['base', 'overage', 'unlimited', 'none']
/** Validate the `consume_quota` RPC payload; throws on any unexpected shape. */
export function parseQuotaConsumeResponse(data: unknown): QuotaConsumeResponse {
if (!data || typeof data !== 'object' || Array.isArray(data)) {
throw new Error('Invalid quota consumption response.')
}
const result = data as Record<string, unknown>
if (
typeof result.allowed !== 'boolean'
|| typeof result.current !== 'number'
|| typeof result.limit !== 'number'
|| typeof result.overage_credits !== 'number'
|| !(CONSUMED_FROM as readonly unknown[]).includes(result.consumed_from)
) {
throw new Error('Invalid quota consumption response.')
}
return {
allowed: result.allowed,
current: result.current,
limit: result.limit,
overageCredits: result.overage_credits,
consumedFrom: result.consumed_from as QuotaConsumedFrom,
}
}