84 lines
3.1 KiB
TypeScript
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,
|
|
}
|
|
}
|