feat(credits): solde IA unique (option M), packs Stripe et polish billing
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.
This commit is contained in:
753
memento-note/lib/credits.ts
Normal file
753
memento-note/lib/credits.ts
Normal file
@@ -0,0 +1,753 @@
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
Reference in New Issue
Block a user