Un pot de crédits global (BASIC 100 / PRO 1 000 / BUSINESS 4 000) remplace les seaux par fonction côté débit. Packs one-shot S/M/L, admin sans seaux legacy, usage multi-features, hints de coût, anti-flash thème et hydratation admin. Inclut aussi le pipeline slides renforcé et la page publique polishée.
754 lines
22 KiB
TypeScript
754 lines
22 KiB
TypeScript
/**
|
||
* 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<SubscriptionTier> {
|
||
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<SubscriptionTier, number | 'unlimited'> = {
|
||
BASIC: 100,
|
||
PRO: 1000,
|
||
BUSINESS: 4000,
|
||
ENTERPRISE: 'unlimited',
|
||
}
|
||
|
||
/** Coût unitaire fixe par feature (slides : voir slideGenerateCreditCost). */
|
||
export const CREDIT_COSTS: Record<string, number> = {
|
||
semantic_search: 1,
|
||
auto_tag: 1,
|
||
auto_title: 1,
|
||
reformulate: 1,
|
||
chat: 1,
|
||
brainstorm_create: 5,
|
||
brainstorm_expand: 1,
|
||
brainstorm_enrich: 1,
|
||
suggest_charts: 1,
|
||
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<string, unknown>
|
||
},
|
||
) {
|
||
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<CreditBalance> {
|
||
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<string, unknown> },
|
||
): 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<void> {
|
||
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<string, unknown>,
|
||
): 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<CreditBalance> {
|
||
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<Date> {
|
||
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<CreditBreakdownRow[]> {
|
||
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<string, { creditsUsed: number; actionsCount: number }>()
|
||
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<CreditUsageSummary> {
|
||
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<Record<string, { remaining: number; limit: number; used: number }>> {
|
||
const summary = await getCreditUsageSummary(userId)
|
||
const result: Record<string, { remaining: number; limit: number; used: number }> = {}
|
||
|
||
// 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)
|
||
}
|