Files
Momento/memento-note/lib/credits.ts
Antigravity 556a0b2f3f 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.
2026-07-16 20:43:18 +00:00

754 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 20252026).
* 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)
}