import { Plan } from "@prisma/client"; import { EXTRA_CREDITS_MAX, FREE_TOP_UP_CREDITS, MAX_CREDIT_CAP, monthlyCreditsForPlan, } from "./credits"; import { prisma } from "./db"; import { getNextMonthlyQuotaReset } from "./plans"; export class CreditInsufficientError extends Error { readonly status = 402; constructor(message = "Payment Required: no credits remaining.") { super(message); this.name = "CreditInsufficientError"; } } export type DeductResult = { fromMonthly: number; fromExtra: number; }; export type RenewalBalances = { monthlyCredits: number; videosUsed: number; extraCredits: number; bonusQuota: number; trimmed: number; totalAfter: number; }; /** * Rollover + hard cap on renewal. * prospective = unused (monthly remaining + extras) + newMonthly * If prospective > MAX_CREDIT_CAP (30), trim excess (no refund). * * Storage: prefer filling the new monthly allocation first, leftover as extras. */ export function computeRenewalBalances(input: { monthlyCredits: number; videosUsed: number; bonusQuota?: number; extraCredits: number; newMonthlyCredits: number; maxCap?: number; }): RenewalBalances { const cap = input.maxCap ?? MAX_CREDIT_CAP; const bonus = input.bonusQuota ?? 0; const unusedMonthly = Math.max(0, input.monthlyCredits + bonus - input.videosUsed); const currentUnused = unusedMonthly + Math.max(0, input.extraCredits); const prospective = currentUnused + input.newMonthlyCredits; const totalAfter = Math.min(prospective, cap); const trimmed = Math.max(0, prospective - totalAfter); const monthlyCredits = Math.min(input.newMonthlyCredits, totalAfter); const extraCredits = Math.max(0, totalAfter - monthlyCredits); return { monthlyCredits, videosUsed: 0, extraCredits, bonusQuota: 0, trimmed, totalAfter, }; } /** * Apply monthly credit renewal with MAX_CREDIT_CAP rollover trim. */ export async function applyMonthlyCreditRenewal( userId: string, opts: { plan?: Plan; newMonthlyCredits?: number; quotaResetAt?: Date; } = {}, ) { return prisma.$transaction(async (tx) => { const user = await tx.user.findUniqueOrThrow({ where: { id: userId } }); const plan = opts.plan ?? user.plan; const newMonthly = opts.newMonthlyCredits ?? monthlyCreditsForPlan(plan); const balances = computeRenewalBalances({ monthlyCredits: user.monthlyCredits, videosUsed: user.videosUsed, bonusQuota: user.bonusQuota, extraCredits: user.extraCredits, newMonthlyCredits: newMonthly, }); return tx.user.update({ where: { id: userId }, data: { plan, monthlyCredits: balances.monthlyCredits, videosUsed: balances.videosUsed, extraCredits: balances.extraCredits, bonusQuota: balances.bonusQuota, quotaResetAt: opts.quotaResetAt ?? getNextMonthlyQuotaReset(), }, }); }); } /** * Deduct video creation credits for `count` items. * Order: monthly allocation first, then extraCredits. */ export async function deductUserCredit( userId: string, count = 1, ): Promise { if (count <= 0) return { fromMonthly: 0, fromExtra: 0 }; return prisma.$transaction(async (tx) => { const rows = await tx.$queryRaw< Array<{ id: string; plan: Plan; videosUsed: number; monthlyCredits: number; bonusQuota: number; videoCredits: number; }> >`SELECT id, plan, "videosUsed", "monthlyCredits", "bonusQuota", "videoCredits" FROM "User" WHERE id = ${userId} FOR UPDATE`; const user = rows[0]; if (!user) throw new Error("User not found"); const monthlyLimit = user.monthlyCredits + user.bonusQuota; const monthlyRemaining = Math.max(0, monthlyLimit - user.videosUsed); const extra = user.videoCredits; const total = monthlyRemaining + extra; if (total < count) { throw new CreditInsufficientError( `Not enough credits. Available: ${total} (monthly remaining ${monthlyRemaining} + extras ${extra}).`, ); } const fromMonthly = Math.min(count, monthlyRemaining); const fromExtra = count - fromMonthly; await tx.user.update({ where: { id: userId }, data: { ...(fromMonthly > 0 ? { videosUsed: { increment: fromMonthly } } : {}), ...(fromExtra > 0 ? { extraCredits: { decrement: fromExtra } } : {}), }, }); return { fromMonthly, fromExtra }; }); } /** Refund reserved credits (job create failure / worker failure). */ export async function refundUserCredit( userId: string, fromMonthly: number, fromExtra: number, ) { if (fromMonthly > 0) { await prisma.$executeRaw` UPDATE "User" SET "videosUsed" = GREATEST(0, "videosUsed" - ${fromMonthly}) WHERE id = ${userId} `; } if (fromExtra > 0) { await prisma.$executeRaw` UPDATE "User" SET "videoCredits" = LEAST(${EXTRA_CREDITS_MAX}, "videoCredits" + ${fromExtra}) WHERE id = ${userId} `; } } /** * Admin / support: reset or adjust a user's billing cycle and extras. */ export async function adminResetUserCredits( userId: string, options: { plan?: Plan; monthlyCredits?: number; creditsUsed?: number; extraCredits?: number; clearFreeTopUp?: boolean; } = {}, ) { const user = await prisma.user.findUniqueOrThrow({ where: { id: userId } }); const plan = options.plan ?? user.plan; const monthlyCredits = options.monthlyCredits ?? monthlyCreditsForPlan(plan); const extras = options.extraCredits !== undefined ? Math.max(0, Math.min(EXTRA_CREDITS_MAX, options.extraCredits)) : user.extraCredits; const used = options.creditsUsed ?? 0; const remainingMonthly = Math.max(0, monthlyCredits - used); const total = remainingMonthly + extras; const cappedExtras = total > MAX_CREDIT_CAP ? Math.max(0, MAX_CREDIT_CAP - remainingMonthly) : extras; return prisma.user.update({ where: { id: userId }, data: { plan, subscriptionStatus: plan === "PREMIUM" ? "pro" : "free", monthlyCredits, videosUsed: used, extraCredits: cappedExtras, ...(options.clearFreeTopUp ? { freeTopUpPurchased: false } : {}), quotaResetAt: getNextMonthlyQuotaReset(), ...(plan === "FREE" ? { stripeSubscriptionId: null, subscribedAt: null, cardLast4: null, bonusQuota: 0, } : {}), }, }); } export async function activateProPlan( userId: string, opts: { stripeCustomerId?: string | null; stripeSubscriptionId?: string | null; cardLast4?: string | null; } = {}, ) { await applyMonthlyCreditRenewal(userId, { plan: "PREMIUM", newMonthlyCredits: monthlyCreditsForPlan("PREMIUM"), }); return prisma.user.update({ where: { id: userId }, data: { plan: "PREMIUM", subscriptionStatus: "pro", subscribedAt: new Date(), ...(opts.stripeCustomerId !== undefined ? { stripeCustomerId: opts.stripeCustomerId } : {}), ...(opts.stripeSubscriptionId !== undefined ? { stripeSubscriptionId: opts.stripeSubscriptionId } : {}), ...(opts.cardLast4 !== undefined ? { cardLast4: opts.cardLast4 } : {}), }, }); } export async function downgradeToFreePlan(userId: string) { await applyMonthlyCreditRenewal(userId, { plan: "FREE", newMonthlyCredits: monthlyCreditsForPlan("FREE"), }); return prisma.user.update({ where: { id: userId }, data: { plan: "FREE", subscriptionStatus: "free", stripeSubscriptionId: null, subscribedAt: null, cardLast4: null, apiKeyHash: null, apiKeyPrefix: null, apiRateLimitBonus: 0, }, }); } export async function grantExtraCredits(userId: string, credits: number) { if (credits <= 0) return; await prisma.$transaction(async (tx) => { const user = await tx.user.findUniqueOrThrow({ where: { id: userId } }); const monthlyRemaining = Math.max( 0, user.monthlyCredits + user.bonusQuota - user.videosUsed, ); const room = Math.max(0, MAX_CREDIT_CAP - monthlyRemaining - user.extraCredits); const grant = Math.min(credits, room); if (grant <= 0) return; await tx.user.update({ where: { id: userId }, data: { extraCredits: { increment: grant } }, }); }); } export async function markFreeTopUpPurchased(userId: string) { await prisma.user.update({ where: { id: userId }, data: { freeTopUpPurchased: true }, }); await grantExtraCredits(userId, FREE_TOP_UP_CREDITS); }