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

View File

@@ -19,6 +19,9 @@ const BILLING_CONFIG_KEYS = [
'STRIPE_PRICE_PRO_ANNUAL',
'STRIPE_PRICE_BUSINESS_MONTHLY',
'STRIPE_PRICE_BUSINESS_ANNUAL',
'STRIPE_PRICE_CREDITS_S',
'STRIPE_PRICE_CREDITS_M',
'STRIPE_PRICE_CREDITS_L',
] as const
const TIERS: TierType[] = ['BASIC', 'PRO', 'BUSINESS', 'ENTERPRISE']
@@ -54,7 +57,42 @@ export async function getBillingAdminData() {
BILLING_CONFIG_KEYS.map((key) => [key, config[key] ?? '']),
)
return { entitlements, billingConfig, usageOverview, features: [...VALID_FEATURES], tiers: TIERS }
const { CREDIT_ALLOCATIONS, CREDIT_COSTS, slideGenerateCreditCost } = await import('@/lib/credits')
const { CREDIT_PACKS, CREDIT_PACK_IDS } = await import('@/lib/billing/credit-packs')
const creditAllocations = TIERS.map((tier) => {
const raw = CREDIT_ALLOCATIONS[tier]
return {
tier,
monthlyCredits: raw === 'unlimited' ? null : raw,
unlimited: raw === 'unlimited',
}
})
const creditCosts = {
...CREDIT_COSTS,
slide_generate_example: slideGenerateCreditCost(7),
}
const creditPacks = CREDIT_PACK_IDS.map((id) => {
const p = CREDIT_PACKS[id]
return {
id: p.id,
credits: p.credits,
defaultDisplay: p.defaultDisplay,
}
})
return {
entitlements,
billingConfig,
usageOverview,
features: [...VALID_FEATURES],
tiers: TIERS,
creditAllocations,
creditCosts,
creditPacks,
}
}
export async function updatePlanEntitlement(

View File

@@ -42,24 +42,47 @@ export async function getSystemConfig() {
return getCachedConfig()
}
/** Keys that may be cleared (empty string) to fall back to chat / defaults. */
const CLEARABLE_CONFIG_KEYS = new Set([
'AI_PROVIDER_SLIDES',
'AI_MODEL_SLIDES',
])
export async function updateSystemConfig(data: Record<string, string>) {
await checkAdmin()
try {
// Filter out empty values but keep 'false' as valid
const filteredData = Object.fromEntries(
Object.entries(data).filter(([key, value]) => value !== '' && value !== null && value !== undefined)
)
// Filter out empty values but keep 'false' as valid.
// Slides provider/model may be empty intentionally (= use Chat fallback).
const toUpsert: Record<string, string> = {}
const toDelete: string[] = []
const operations = Object.entries(filteredData).map(([key, value]) =>
prisma.systemConfig.upsert({
where: { key },
update: { value },
create: { key, value }
})
)
for (const [key, value] of Object.entries(data)) {
if (value === null || value === undefined) continue
if (value === '' && CLEARABLE_CONFIG_KEYS.has(key)) {
toDelete.push(key)
continue
}
if (value === '') continue
toUpsert[key] = value
}
await prisma.$transaction(operations)
const operations = [
...Object.entries(toUpsert).map(([key, value]) =>
prisma.systemConfig.upsert({
where: { key },
update: { value },
create: { key, value },
}),
),
...toDelete.map((key) =>
prisma.systemConfig.deleteMany({ where: { key } }),
),
]
if (operations.length > 0) {
await prisma.$transaction(operations)
}
return { success: true }
} catch (error) {

View File

@@ -186,3 +186,60 @@ export async function updateUserSubscription(userId: string, tier: string) {
throw new Error('Failed to update subscription')
}
}
/**
* Remet à zéro les crédits IA + anciens compteurs feature du mois pour un utilisateur.
* @param feature (optionnel, legacy) — le reset crédits est global ; feature ignorée pour le solde.
*/
export async function resetUserQuotas(userId: string, feature?: string) {
const session = await checkAdmin()
if (!userId?.trim()) {
throw new Error('Identifiant utilisateur manquant')
}
const user = await prisma.user.findUnique({
where: { id: userId },
select: { id: true, email: true },
})
if (!user) {
throw new Error('Utilisateur introuvable')
}
// Nouveau système : solde de crédits global
const { adminResetCredits } = await import('@/lib/credits')
const balance = await adminResetCredits(userId, { clearPurchased: false })
// Ancien système feature (compat / métriques)
const { resetUserUsageForCurrentPeriod } = await import('@/lib/entitlements')
const legacy = await resetUserUsageForCurrentPeriod(
userId,
feature ? { features: [feature] } : undefined,
)
const { logAuditEventAsync } = await import('@/lib/audit-log')
await logAuditEventAsync({
userId: session.user?.id,
action: 'QUOTA_RESET',
resource: userId,
metadata: {
targetUserId: userId,
targetEmail: user.email,
period: balance.period,
creditsRemaining: balance.unlimited ? 'unlimited' : balance.totalRemaining,
legacyFeaturesReset: legacy.featuresReset,
scope: feature || 'all',
},
})
revalidatePath('/admin')
revalidatePath('/admin/users')
return {
success: true as const,
period: balance.period,
featuresReset: legacy.featuresReset,
redisDeleted: legacy.redisDeleted,
dbUpdated: legacy.dbUpdated,
creditsRemaining: balance.unlimited ? null : balance.totalRemaining,
}
}