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:
Antigravity
2026-07-16 20:43:18 +00:00
parent 704fed1191
commit 556a0b2f3f
77 changed files with 35745 additions and 10430 deletions

753
memento-note/lib/credits.ts Normal file
View 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 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)
}