82 lines
2.6 KiB
TypeScript
82 lines
2.6 KiB
TypeScript
import { getEffectivePlan } from "@/lib/billing/subscription";
|
|
import { getUsage, reserveUsage } from "./meter";
|
|
import { PLANS, UNLIMITED, withinLimit, type PlanKey, type UsageMetric } from "@/lib/billing/plans";
|
|
|
|
export interface LimitCheck {
|
|
allowed: boolean;
|
|
used: number;
|
|
limit: number; // UNLIMITED (-1) when uncapped
|
|
plan: PlanKey;
|
|
metric: UsageMetric;
|
|
}
|
|
|
|
/** Thrown by enforceLimit when a metric's monthly cap is reached. */
|
|
export class LimitExceededError extends Error {
|
|
constructor(public check: LimitCheck) {
|
|
super(`Monthly ${check.metric} limit reached on the ${check.plan} plan`);
|
|
this.name = "LimitExceededError";
|
|
}
|
|
}
|
|
|
|
/** Read-only check of a metric against the subject's plan + current usage. */
|
|
export async function checkLimit(
|
|
userId: string,
|
|
metric: UsageMetric,
|
|
activeOrgId?: string | null
|
|
): Promise<LimitCheck> {
|
|
const { key, subjectId } = await getEffectivePlan(userId, activeOrgId);
|
|
const used = await getUsage(subjectId, metric);
|
|
return {
|
|
allowed: withinLimit(key, metric, used),
|
|
used,
|
|
limit: PLANS[key].limits[metric],
|
|
plan: key,
|
|
metric,
|
|
};
|
|
}
|
|
|
|
/** Throw LimitExceededError if the metric is over its cap. */
|
|
export async function enforceLimit(
|
|
userId: string,
|
|
metric: UsageMetric,
|
|
activeOrgId?: string | null
|
|
): Promise<LimitCheck> {
|
|
const check = await checkLimit(userId, metric, activeOrgId);
|
|
if (!check.allowed) throw new LimitExceededError(check);
|
|
return check;
|
|
}
|
|
|
|
/**
|
|
* Atomically RESERVE one unit of `metric` against the subject's plan cap.
|
|
* Unlike `enforceLimit` (a read-only check), this consumes quota up front and
|
|
* is race-safe — the canonical write path for any action that will generate
|
|
* content. Throws `LimitExceededError` when the cap is already reached.
|
|
*
|
|
* Callers that proceed to enqueue/generate must REFUND (see `refundUsage`) if
|
|
* the downstream work fails, so quota isn't permanently consumed by a failure.
|
|
*/
|
|
export async function reserveLimit(
|
|
userId: string,
|
|
metric: UsageMetric,
|
|
activeOrgId?: string | null
|
|
): Promise<LimitCheck> {
|
|
const { key, subjectId, subjectType } = await getEffectivePlan(userId, activeOrgId);
|
|
const limit = PLANS[key].limits[metric];
|
|
const ok = await reserveUsage(subjectId, subjectType, metric, limit);
|
|
// Re-read for an accurate `used` in the result; reservation already enforced.
|
|
const used = await getUsage(subjectId, metric);
|
|
const check: LimitCheck = {
|
|
allowed: ok,
|
|
used,
|
|
limit,
|
|
plan: key,
|
|
metric,
|
|
};
|
|
if (!ok) throw new LimitExceededError(check);
|
|
return check;
|
|
}
|
|
|
|
export function isUnlimited(limit: number): boolean {
|
|
return limit === UNLIMITED;
|
|
}
|