fix(quota): spend overage credits against the weekly window and count 7 days, not 8
This commit is contained in:
parent
f4f9653361
commit
102f3ef934
5 changed files with 601 additions and 65 deletions
84
server/supabase/functions/_shared/quota-policy.ts
Normal file
84
server/supabase/functions/_shared/quota-policy.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
// 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,
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue