/** * Crédits IA globaux (modèle A). * Un seul solde ; chaque action a un coût ; le tableau d'usage agrège le ledger par feature. */ import { prisma } from '@/lib/prisma' import { redis } from '@/lib/redis' import { getCurrentPeriodKey, VALID_FEATURES, type FeatureName } from '@/lib/quota-utils' import type { SubscriptionTier } from '@/lib/plan-entitlements' // Imports dynamiques vers entitlements pour éviter les cycles // (entitlements délègue aussi vers credits pour reserveUsageOrThrow). async function getEffectiveTier(userId: string): Promise { const { getEffectiveTier: fn } = await import('@/lib/entitlements') return fn(userId) } async function makeQuotaExceeded( feature: string, limit: number, used: number, userId: string, ) { const { QuotaExceededError } = await import('@/lib/entitlements') const tier = await getEffectiveTier(userId) return new QuotaExceededError('PRO', feature, limit, used, false, { currentTier: tier }) } /** * Allocations mensuelles — option M (alignée marché IA 2025–2026). * Sources marché : enveloppes type 100 / ~1 000 / ~4 000 (agrégateurs multi-modèles, * ordres de grandeur Copilot ~1 000 crédits Pro, grilles Starter/Pro/Ultra). * BASIC 100 · PRO 1 000 · BUSINESS 4 000 · ENTERPRISE illimité. */ export const CREDIT_ALLOCATIONS: Record = { BASIC: 100, PRO: 1000, BUSINESS: 4000, ENTERPRISE: 'unlimited', } /** Coût unitaire fixe par feature (slides : voir slideGenerateCreditCost). */ export const CREDIT_COSTS: Record = { semantic_search: 1, auto_tag: 1, auto_title: 1, reformulate: 1, chat: 1, brainstorm_create: 5, brainstorm_expand: 1, brainstorm_enrich: 1, suggest_charts: 2, publish_enhance: 3, ai_flashcard: 3, voice_transcribe: 1, excalidraw_generate: 4, slide_generate: 7, // défaut si pas de slideCount (1+6) } export function slideGenerateCreditCost(slideCount?: number | null): number { const n = typeof slideCount === 'number' && Number.isFinite(slideCount) ? Math.min(8, Math.max(3, Math.round(slideCount))) : 6 return 1 + n } export function notebookSlideCreditCost(opts: { slideCount?: number | null noteCount?: number | null }): number { const n = typeof opts.slideCount === 'number' && Number.isFinite(opts.slideCount) ? Math.min(12, Math.max(5, Math.round(opts.slideCount))) : 8 const notes = typeof opts.noteCount === 'number' && Number.isFinite(opts.noteCount) ? Math.max(0, Math.round(opts.noteCount)) : 0 return 2 + n + Math.floor(notes / 5) } export function resolveCreditCost( feature: string, options?: { amount?: number; slideCount?: number | null }, ): number { if (feature === 'slide_generate') { if (options?.slideCount != null) return slideGenerateCreditCost(options.slideCount) if (options?.amount != null && options.amount > 1) return Math.max(1, Math.floor(options.amount)) return slideGenerateCreditCost(null) } if (options?.amount != null && options.amount > 1) { return Math.max(1, Math.floor(options.amount)) } return CREDIT_COSTS[feature] ?? 1 } // ── Types ─────────────────────────────────────────────────────────────────── export type CreditBalance = { period: string unlimited: boolean totalRemaining: number subscriptionRemaining: number purchasedRemaining: number subscriptionGranted: number subscriptionSpent: number spentThisPeriod: number } export type CreditBreakdownRow = { feature: string creditsUsed: number actionsCount: number percentOfSpent: number } export type CreditUsageSummary = { tier: SubscriptionTier period: string balance: CreditBalance breakdown: CreditBreakdownRow[] } const TTL_SECONDS = 90 * 24 * 60 * 60 function redisKeys(userId: string, period: string) { return { subSpent: `credits:${userId}:${period}:sub_spent`, purchased: `credits:${userId}:${period}:purchased`, granted: `credits:${userId}:${period}:granted`, } } // ── Ensure account / monthly grant ────────────────────────────────────────── export async function ensureCreditAccount(userId: string): Promise<{ periodKey: string subscriptionGranted: number subscriptionSpent: number purchasedBalance: number unlimited: boolean }> { const periodKey = getCurrentPeriodKey() const tier = await getEffectiveTier(userId) const allocation = CREDIT_ALLOCATIONS[tier] const unlimited = allocation === 'unlimited' const grant = unlimited ? 0 : (allocation as number) let account = await prisma.aiCreditAccount.findUnique({ where: { userId } }) if (!account) { account = await prisma.aiCreditAccount.create({ data: { userId, periodKey, subscriptionGranted: grant, subscriptionSpent: 0, purchasedBalance: 0, }, }) if (!unlimited && grant > 0) { await appendLedger(userId, { kind: 'GRANT_SUBSCRIPTION', amount: grant, feature: null, balanceAfter: grant, metadata: { periodKey, tier }, }) } await syncRedisFromAccount(userId, account) return { periodKey, subscriptionGranted: account.subscriptionGranted, subscriptionSpent: account.subscriptionSpent, purchasedBalance: account.purchasedBalance, unlimited, } } // Nouvelle période : expire reliquat abonnement, conserve packs, re-grant if (account.periodKey !== periodKey) { const purchased = account.purchasedBalance account = await prisma.aiCreditAccount.update({ where: { userId }, data: { periodKey, subscriptionGranted: grant, subscriptionSpent: 0, purchasedBalance: purchased, }, }) if (!unlimited && grant > 0) { await appendLedger(userId, { kind: 'GRANT_SUBSCRIPTION', amount: grant, feature: null, balanceAfter: grant + purchased, metadata: { periodKey, tier, rolledPurchased: purchased }, }) } await syncRedisFromAccount(userId, account) } else if (!unlimited && account.subscriptionGranted !== grant) { // Palier changé en cours de mois : met à jour l'allocation (ne retire pas le déjà consommé) account = await prisma.aiCreditAccount.update({ where: { userId }, data: { subscriptionGranted: grant }, }) await syncRedisFromAccount(userId, account) } return { periodKey: account.periodKey, subscriptionGranted: account.subscriptionGranted, subscriptionSpent: account.subscriptionSpent, purchasedBalance: account.purchasedBalance, unlimited, } } async function syncRedisFromAccount( userId: string, account: { periodKey: string subscriptionGranted: number subscriptionSpent: number purchasedBalance: number }, ) { const k = redisKeys(userId, account.periodKey) try { await redis .multi() .set(k.granted, String(account.subscriptionGranted), 'EX', TTL_SECONDS) .set(k.subSpent, String(account.subscriptionSpent), 'EX', TTL_SECONDS) .set(k.purchased, String(account.purchasedBalance), 'EX', TTL_SECONDS) .exec() } catch (err) { console.error('[credits] Redis sync failed:', err) } } async function appendLedger( userId: string, entry: { kind: string amount: number feature: string | null balanceAfter: number | null metadata?: Record }, ) { try { await prisma.aiCreditLedger.create({ data: { userId, kind: entry.kind, amount: entry.amount, feature: entry.feature, balanceAfter: entry.balanceAfter, metadata: entry.metadata ? JSON.stringify(entry.metadata) : null, }, }) } catch (err) { console.error('[credits] ledger write failed:', err) } } // ── Balance ───────────────────────────────────────────────────────────────── export async function getCreditBalance(userId: string): Promise { const acc = await ensureCreditAccount(userId) const period = acc.periodKey if (acc.unlimited) { return { period, unlimited: true, totalRemaining: Infinity, subscriptionRemaining: Infinity, purchasedRemaining: acc.purchasedBalance, subscriptionGranted: Infinity, subscriptionSpent: acc.subscriptionSpent, spentThisPeriod: acc.subscriptionSpent, } } // Prefer Redis if present const k = redisKeys(userId, period) try { const [subSpentRaw, purchasedRaw, grantedRaw] = await redis.mget( k.subSpent, k.purchased, k.granted, ) if (subSpentRaw != null || purchasedRaw != null) { const subscriptionSpent = Math.max(0, parseInt(subSpentRaw || '0', 10) || 0) const purchasedRemaining = Math.max(0, parseInt(purchasedRaw || '0', 10) || 0) const subscriptionGranted = Math.max( 0, parseInt(grantedRaw || String(acc.subscriptionGranted), 10) || acc.subscriptionGranted, ) const subscriptionRemaining = Math.max(0, subscriptionGranted - subscriptionSpent) return { period, unlimited: false, totalRemaining: subscriptionRemaining + purchasedRemaining, subscriptionRemaining, purchasedRemaining, subscriptionGranted, subscriptionSpent, spentThisPeriod: subscriptionSpent, // packs spend tracked separately via ledger } } } catch { /* fall through to DB */ } const subscriptionRemaining = Math.max(0, acc.subscriptionGranted - acc.subscriptionSpent) return { period, unlimited: false, totalRemaining: subscriptionRemaining + acc.purchasedBalance, subscriptionRemaining, purchasedRemaining: acc.purchasedBalance, subscriptionGranted: acc.subscriptionGranted, subscriptionSpent: acc.subscriptionSpent, spentThisPeriod: acc.subscriptionSpent, } } // ── Reserve / release ─────────────────────────────────────────────────────── const RESERVE_CREDITS_LUA = ` local cost = tonumber(ARGV[1]) or 1 local granted = tonumber(redis.call('GET', KEYS[1]) or '0') local subSpent = tonumber(redis.call('GET', KEYS[2]) or '0') local purchased = tonumber(redis.call('GET', KEYS[3]) or '0') local ttl = tonumber(ARGV[2]) or 7776000 if cost < 1 then cost = 1 end local subRem = math.max(0, granted - subSpent) local total = subRem + purchased if total < cost then return {-1, subSpent, purchased} end local fromSub = math.min(subRem, cost) local fromPurch = cost - fromSub subSpent = subSpent + fromSub purchased = purchased - fromPurch redis.call('SET', KEYS[2], tostring(subSpent)) redis.call('EXPIRE', KEYS[2], ttl) redis.call('SET', KEYS[3], tostring(purchased)) redis.call('EXPIRE', KEYS[3], ttl) if redis.call('TTL', KEYS[1]) < 0 then redis.call('EXPIRE', KEYS[1], ttl) end return {cost, subSpent, purchased, fromSub, fromPurch} ` /** * Réserve `cost` crédits. Lance QuotaExceededError si insuffisant. * Enterprise unlimited : no-op. */ export async function reserveCreditsOrThrow( userId: string, cost: number, options?: { feature?: string; metadata?: Record }, ): Promise<{ cost: number; unlimited: boolean }> { const units = Math.max(1, Math.floor(Number(cost) || 1)) const acc = await ensureCreditAccount(userId) if (acc.unlimited) { // Journal informatif (montant 0) pour le tableau si feature fournie if (options?.feature) { await appendLedger(userId, { kind: 'CONSUME', amount: 0, feature: options.feature, balanceAfter: null, metadata: { ...(options.metadata || {}), unlimited: true, notionalCost: units }, }) } return { cost: 0, unlimited: true } } const period = acc.periodKey const k = redisKeys(userId, period) // Ensure granted key is set try { const g = await redis.get(k.granted) if (g == null) { await syncRedisFromAccount(userId, { periodKey: period, subscriptionGranted: acc.subscriptionGranted, subscriptionSpent: acc.subscriptionSpent, purchasedBalance: acc.purchasedBalance, }) } } catch { /* continue */ } let fromSub = units let fromPurch = 0 let newSubSpent = acc.subscriptionSpent + units let newPurchased = acc.purchasedBalance try { const raw = (await redis.eval( RESERVE_CREDITS_LUA, 3, k.granted, k.subSpent, k.purchased, String(units), String(TTL_SECONDS), )) as number[] if (!raw || raw[0] === -1) { const bal = await getCreditBalance(userId) throw await makeQuotaExceeded( options?.feature || 'credits', bal.subscriptionGranted + bal.purchasedRemaining, bal.spentThisPeriod, userId, ) } newSubSpent = Number(raw[1]) || 0 newPurchased = Number(raw[2]) || 0 fromSub = Number(raw[3]) ?? units fromPurch = Number(raw[4]) ?? 0 } catch (err) { if (err instanceof QuotaExceededError) throw err // DB fallback const subRem = Math.max(0, acc.subscriptionGranted - acc.subscriptionSpent) const total = subRem + acc.purchasedBalance if (total < units) { throw await makeQuotaExceeded( options?.feature || 'credits', acc.subscriptionGranted + acc.purchasedBalance, acc.subscriptionSpent, userId, ) } fromSub = Math.min(subRem, units) fromPurch = units - fromSub newSubSpent = acc.subscriptionSpent + fromSub newPurchased = acc.purchasedBalance - fromPurch } await prisma.aiCreditAccount.update({ where: { userId }, data: { subscriptionSpent: newSubSpent, purchasedBalance: newPurchased, }, }) const remaining = Math.max(0, acc.subscriptionGranted - newSubSpent) + Math.max(0, newPurchased) await appendLedger(userId, { kind: 'CONSUME', amount: -units, feature: options?.feature ?? null, balanceAfter: remaining, metadata: { ...(options?.metadata || {}), fromSubscription: fromSub, fromPurchased: fromPurch, }, }) return { cost: units, unlimited: false } } /** * Remet des crédits après échec (best-effort). * Simplification : recrédite d'abord l'abonnement (réduit subscriptionSpent). */ export async function releaseCredits( userId: string, cost: number, options?: { feature?: string }, ): Promise { const units = Math.max(1, Math.floor(Number(cost) || 1)) const acc = await ensureCreditAccount(userId) if (acc.unlimited) return const period = acc.periodKey const k = redisKeys(userId, period) let newSubSpent = Math.max(0, acc.subscriptionSpent - units) // Si on a plus dépensé en abonnement que available, le reste va en packs let refundPurch = 0 const subRefund = Math.min(units, acc.subscriptionSpent) refundPurch = units - subRefund newSubSpent = acc.subscriptionSpent - subRefund const newPurchased = acc.purchasedBalance + refundPurch try { await redis .multi() .set(k.subSpent, String(newSubSpent), 'EX', TTL_SECONDS) .set(k.purchased, String(newPurchased), 'EX', TTL_SECONDS) .exec() } catch (err) { console.error('[credits] release Redis failed:', err) } await prisma.aiCreditAccount.update({ where: { userId }, data: { subscriptionSpent: newSubSpent, purchasedBalance: newPurchased, }, }) const remaining = Math.max(0, acc.subscriptionGranted - newSubSpent) + Math.max(0, newPurchased) await appendLedger(userId, { kind: 'RELEASE', amount: units, feature: options?.feature ?? null, balanceAfter: remaining, metadata: { refundSubscription: subRefund, refundPurchased: refundPurch }, }) } export async function addPurchasedCredits( userId: string, amount: number, metadata?: Record, ): Promise<{ applied: boolean; units: number }> { const units = Math.max(1, Math.floor(amount)) // Idempotence webhook : un même checkout Stripe ne crédite qu'une fois const stripeSessionId = typeof metadata?.stripeSessionId === 'string' ? metadata.stripeSessionId : null if (stripeSessionId) { const existing = await prisma.aiCreditLedger.findFirst({ where: { userId, kind: 'PURCHASE_PACK', metadata: { contains: stripeSessionId }, }, select: { id: true }, }) if (existing) { return { applied: false, units } } } const acc = await ensureCreditAccount(userId) const newPurchased = acc.purchasedBalance + units await prisma.aiCreditAccount.update({ where: { userId }, data: { purchasedBalance: newPurchased }, }) const period = acc.periodKey const k = redisKeys(userId, period) try { await redis.set(k.purchased, String(newPurchased), 'EX', TTL_SECONDS) } catch { /* ignore */ } const bal = await getCreditBalance(userId) await appendLedger(userId, { kind: 'PURCHASE_PACK', amount: units, feature: null, balanceAfter: bal.unlimited ? null : bal.totalRemaining, metadata, }) return { applied: true, units } } /** Reset admin : remet conso à 0 et re-grant allocation, packs optionnels conservés. */ export async function adminResetCredits( userId: string, options?: { clearPurchased?: boolean }, ): Promise { const periodKey = getCurrentPeriodKey() const tier = await getEffectiveTier(userId) const allocation = CREDIT_ALLOCATIONS[tier] const unlimited = allocation === 'unlimited' const grant = unlimited ? 0 : (allocation as number) const existing = await prisma.aiCreditAccount.findUnique({ where: { userId } }) const purchased = options?.clearPurchased ? 0 : (existing?.purchasedBalance ?? 0) const account = await prisma.aiCreditAccount.upsert({ where: { userId }, create: { userId, periodKey, subscriptionGranted: grant, subscriptionSpent: 0, purchasedBalance: purchased, }, update: { periodKey, subscriptionGranted: grant, subscriptionSpent: 0, purchasedBalance: purchased, }, }) await syncRedisFromAccount(userId, account) await appendLedger(userId, { kind: 'ADMIN_RESET', amount: grant, feature: null, balanceAfter: grant + purchased, metadata: { clearPurchased: !!options?.clearPurchased, tier }, }) return getCreditBalance(userId) } // ── Breakdown for usage table ─────────────────────────────────────────────── /** * Date de début pour le détail d'usage : après le dernier ADMIN_RESET du mois, * sinon début de période. Évite « 300 restants + 16 utilisés » après un reset admin. */ async function usageWindowStart(userId: string): Promise { const period = getCurrentPeriodKey() const periodStart = new Date(`${period}-01T00:00:00.000Z`) const lastReset = await prisma.aiCreditLedger.findFirst({ where: { userId, kind: 'ADMIN_RESET', createdAt: { gte: periodStart }, }, orderBy: { createdAt: 'desc' }, select: { createdAt: true }, }) return lastReset?.createdAt ?? periodStart } export async function getCreditBreakdown(userId: string): Promise { const since = await usageWindowStart(userId) const rows = await prisma.aiCreditLedger.groupBy({ by: ['feature'], where: { userId, kind: 'CONSUME', createdAt: { gte: since }, feature: { not: null }, }, _sum: { amount: true }, _count: { _all: true }, }) // Toutes les fonctionnalités connues (comme l'ancien tableau) — pas seulement slides const usedMap = new Map() for (const feature of VALID_FEATURES) { usedMap.set(feature, { creditsUsed: 0, actionsCount: 0 }) } for (const r of rows) { if (!r.feature) continue const creditsUsed = Math.abs(r._sum.amount ?? 0) usedMap.set(r.feature, { creditsUsed, actionsCount: r._count._all, }) } const mapped: CreditBreakdownRow[] = [] let totalSpent = 0 for (const [feature, v] of usedMap) { totalSpent += v.creditsUsed mapped.push({ feature, creditsUsed: v.creditsUsed, actionsCount: v.actionsCount, percentOfSpent: 0, }) } for (const r of mapped) { r.percentOfSpent = totalSpent > 0 ? Math.round((r.creditsUsed / totalSpent) * 1000) / 10 : 0 } // Tri : d'abord celles avec usage, puis ordre alphabétique du feature key mapped.sort((a, b) => { if (b.creditsUsed !== a.creditsUsed) return b.creditsUsed - a.creditsUsed if (b.actionsCount !== a.actionsCount) return b.actionsCount - a.actionsCount return a.feature.localeCompare(b.feature) }) return mapped } export async function getCreditUsageSummary(userId: string): Promise { const tier = await getEffectiveTier(userId) const balance = await getCreditBalance(userId) const breakdown = await getCreditBreakdown(userId) // Conso affichée = somme réelle des CONSUME dans la fenêtre (après dernier reset) const spentFromBreakdown = breakdown.reduce((s, r) => s + r.creditsUsed, 0) balance.spentThisPeriod = spentFromBreakdown return { tier, period: balance.period, balance, breakdown, } } /** Compat : fabrique un objet quotas-like pour l'ancien UI le temps de la migration. */ export async function getQuotasCompatFromCredits( userId: string, ): Promise> { const summary = await getCreditUsageSummary(userId) const result: Record = {} // Lignes du breakdown const usedByFeature = new Map(summary.breakdown.map((b) => [b.feature, b.creditsUsed])) for (const feature of VALID_FEATURES) { const used = usedByFeature.get(feature) ?? 0 if (summary.balance.unlimited) { result[feature] = { remaining: Infinity, limit: Infinity, used } } else { // Pas de plafond par feature : on expose used + remaining global pour info result[feature] = { used, limit: summary.balance.subscriptionGranted + summary.balance.purchasedRemaining + used, remaining: summary.balance.totalRemaining, } } } return result } export function isCreditFeature(feature: string): feature is FeatureName { return (VALID_FEATURES as readonly string[]).includes(feature as FeatureName) }