c8f4b50ef3
All literal "team" tier-value references renamed to "family" across API routes, admin UI, OpenAPI schemas, and lib/tiers.ts. The DB enum value itself is renamed in place via ALTER TYPE ... RENAME VALUE (migration 0044) rather than drizzle-kit's auto-generated drop-and-recreate-the-enum migration, which would have failed against any existing row still holding 'team' — RENAME VALUE preserves existing data with no cast/backfill needed. Also adds STRIPE_PLAN.md — a full Stripe billing integration plan (Checkout+Portal, tier→Price mapping, admin billing dashboard, and a multi-user Family-group design since Family is meant to cover several accounts under one subscription, not one payer). Planning only, no Stripe code yet. v0.47.0
146 lines
5.0 KiB
TypeScript
146 lines
5.0 KiB
TypeScript
import { db } from "@epicure/db";
|
|
import { tierDefinitions, userUsage, users } from "@epicure/db";
|
|
import { eq, sql } from "@epicure/db";
|
|
|
|
function currentMonth() {
|
|
const now = new Date();
|
|
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
|
|
}
|
|
|
|
export type LimitKey = "recipe" | "aiCall" | "storage";
|
|
|
|
/** Sentinel stored in tier_definitions numeric columns to mean "no cap". */
|
|
export const UNLIMITED = -1;
|
|
|
|
export class TierLimitError extends Error {
|
|
constructor(public readonly limit: LimitKey, public readonly tier: string) {
|
|
super(`Tier limit reached: ${limit} (tier: ${tier})`);
|
|
this.name = "TierLimitError";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Atomically checks the tier limit and increments the usage counter in a
|
|
* single SQL statement, eliminating the TOCTOU race that existed when
|
|
* checkTierLimit and incrementUsage were called separately.
|
|
*
|
|
* The caller's `userTier` is never trusted directly — it comes from the
|
|
* session's 5-minute cookieCache (see lib/auth/server.ts), so a just-downgraded
|
|
* user would otherwise keep the old tier's caps for up to 5 minutes. The
|
|
* current tier is always re-read from the DB here.
|
|
*
|
|
* Throws TierLimitError if the limit has already been reached.
|
|
* Use this instead of the separate checkTierLimit + incrementUsage pair
|
|
* for "recipe" and "aiCall" keys.
|
|
*/
|
|
export async function checkAndIncrementTierLimit(
|
|
userId: string,
|
|
fallbackTier: "free" | "pro" | "family",
|
|
key: "recipe" | "aiCall" | "storage",
|
|
amount = 1
|
|
): Promise<void> {
|
|
const [dbUser] = await db.select({ tier: users.tier }).from(users).where(eq(users.id, userId));
|
|
const userTier = (dbUser?.tier as "free" | "pro" | "family" | undefined) ?? fallbackTier;
|
|
|
|
const [tierDef] = await db
|
|
.select()
|
|
.from(tierDefinitions)
|
|
.where(eq(tierDefinitions.tier, userTier));
|
|
|
|
if (!tierDef) throw new TierLimitError(key, userTier);
|
|
|
|
const month = currentMonth();
|
|
const id = `${userId}-${month}`;
|
|
|
|
if (key === "aiCall") {
|
|
const limit = tierDef.aiCallsPerMonth;
|
|
const cap = limit === UNLIMITED ? sql`true` : sql`user_usage.ai_calls_used < ${limit}`;
|
|
const result = await db.execute(sql`
|
|
INSERT INTO user_usage (id, user_id, month, ai_calls_used, recipe_count, storage_used_mb)
|
|
VALUES (${id}, ${userId}, ${month}, 1, 0, 0)
|
|
ON CONFLICT (user_id, month) DO UPDATE
|
|
SET ai_calls_used = user_usage.ai_calls_used + 1
|
|
WHERE ${cap}
|
|
RETURNING ai_calls_used
|
|
`);
|
|
if (result.length === 0) {
|
|
throw new TierLimitError("aiCall", userTier);
|
|
}
|
|
} else if (key === "recipe") {
|
|
const limit = tierDef.maxRecipes;
|
|
const cap = limit === UNLIMITED ? sql`true` : sql`user_usage.recipe_count < ${limit}`;
|
|
const result = await db.execute(sql`
|
|
INSERT INTO user_usage (id, user_id, month, ai_calls_used, recipe_count, storage_used_mb)
|
|
VALUES (${id}, ${userId}, ${month}, 0, 1, 0)
|
|
ON CONFLICT (user_id, month) DO UPDATE
|
|
SET recipe_count = user_usage.recipe_count + 1
|
|
WHERE ${cap}
|
|
RETURNING recipe_count
|
|
`);
|
|
if (result.length === 0) {
|
|
throw new TierLimitError("recipe", userTier);
|
|
}
|
|
} else {
|
|
const limit = tierDef.storageMb;
|
|
const cap = limit === UNLIMITED ? sql`true` : sql`user_usage.storage_used_mb + ${amount} <= ${limit}`;
|
|
const result = await db.execute(sql`
|
|
INSERT INTO user_usage (id, user_id, month, ai_calls_used, recipe_count, storage_used_mb)
|
|
VALUES (${id}, ${userId}, ${month}, 0, 0, ${amount})
|
|
ON CONFLICT (user_id, month) DO UPDATE
|
|
SET storage_used_mb = user_usage.storage_used_mb + ${amount}
|
|
WHERE ${cap}
|
|
RETURNING storage_used_mb
|
|
`);
|
|
if (result.length === 0) {
|
|
throw new TierLimitError("storage", userTier);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Refunds one aiCall credit for the current month. Call this when an AI
|
|
* request failed after the quota was already charged (e.g. provider error)
|
|
* so users aren't billed against their limit for a call that never succeeded.
|
|
*/
|
|
export async function refundAiCall(userId: string): Promise<void> {
|
|
const month = currentMonth();
|
|
await db.execute(sql`
|
|
UPDATE user_usage
|
|
SET ai_calls_used = GREATEST(ai_calls_used - 1, 0)
|
|
WHERE user_id = ${userId} AND month = ${month}
|
|
`);
|
|
}
|
|
|
|
export async function incrementUsage(
|
|
userId: string,
|
|
key: LimitKey,
|
|
amount = 1
|
|
): Promise<void> {
|
|
const month = currentMonth();
|
|
const id = `${userId}-${month}`;
|
|
|
|
const initialValues = {
|
|
id,
|
|
userId,
|
|
month,
|
|
aiCallsUsed: key === "aiCall" ? amount : 0,
|
|
recipeCount: key === "recipe" ? amount : 0,
|
|
storageUsedMb: key === "storage" ? amount : 0,
|
|
};
|
|
|
|
const incrementSet =
|
|
key === "aiCall"
|
|
? { aiCallsUsed: sql`${userUsage.aiCallsUsed} + ${amount}` }
|
|
: key === "recipe"
|
|
? { recipeCount: sql`${userUsage.recipeCount} + ${amount}` }
|
|
: { storageUsedMb: sql`${userUsage.storageUsedMb} + ${amount}` };
|
|
|
|
await db
|
|
.insert(userUsage)
|
|
.values(initialValues)
|
|
.onConflictDoUpdate({
|
|
target: [userUsage.userId, userUsage.month],
|
|
set: incrementSet,
|
|
});
|
|
}
|