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

@@ -0,0 +1,158 @@
/**
* Packs de crédits IA (achat one-shot Stripe, mode payment).
* Les crédits achetés sajoutent à `purchasedBalance` (reportables entre mois).
*/
import { getConfigValue } from '@/lib/config'
import { stripe } from '@/lib/stripe'
export type CreditPackId = 'S' | 'M' | 'L'
export type CreditPackDef = {
id: CreditPackId
credits: number
/** Prix daffichage par défaut (si Stripe non configuré) */
defaultDisplay: string
defaultAmount: number
currency: string
priceConfigKey: string
}
/** Catalogue fixe — crédits par pack (option M). */
export const CREDIT_PACKS: Record<CreditPackId, CreditPackDef> = {
S: {
id: 'S',
credits: 100,
defaultDisplay: '4,90 €',
defaultAmount: 4.9,
currency: 'EUR',
priceConfigKey: 'STRIPE_PRICE_CREDITS_S',
},
M: {
id: 'M',
credits: 500,
defaultDisplay: '19,90 €',
defaultAmount: 19.9,
currency: 'EUR',
priceConfigKey: 'STRIPE_PRICE_CREDITS_M',
},
L: {
id: 'L',
credits: 2000,
defaultDisplay: '49,90 €',
defaultAmount: 49.9,
currency: 'EUR',
priceConfigKey: 'STRIPE_PRICE_CREDITS_L',
},
}
export const CREDIT_PACK_IDS = Object.keys(CREDIT_PACKS) as CreditPackId[]
export function isCreditPackId(value: string): value is CreditPackId {
return value === 'S' || value === 'M' || value === 'L'
}
export function getCreditPack(id: CreditPackId): CreditPackDef {
return CREDIT_PACKS[id]
}
export async function resolvePackPriceId(packId: CreditPackId): Promise<string> {
const pack = CREDIT_PACKS[packId]
const fromDb = await getConfigValue(pack.priceConfigKey, '')
const priceId = fromDb || process.env[pack.priceConfigKey] || ''
if (priceId) return priceId
const isMock =
!process.env.STRIPE_SECRET_KEY || process.env.STRIPE_SECRET_KEY === 'sk_test_placeholder'
if (isMock && process.env.NODE_ENV !== 'test') {
return `price_mock_credits_${packId.toLowerCase()}`
}
throw new Error(`No Stripe price ID configured for credit pack ${packId}`)
}
export type PackPublicPrice = {
id: CreditPackId
credits: number
display: string
amount: number
currency: string
configured: boolean
}
function formatMoney(amount: number, currency: string): string {
const cur = currency.toUpperCase()
if (cur === 'EUR') {
return `${amount.toLocaleString('fr-FR', { minimumFractionDigits: 0, maximumFractionDigits: 2 })} €`
}
if (cur === 'USD') {
return `$${amount.toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 2 })}`
}
if (cur === 'GBP') {
return `£${amount.toLocaleString('en-GB', { minimumFractionDigits: 0, maximumFractionDigits: 2 })}`
}
return `${amount} ${cur}`
}
export async function getPackPublicPrices(): Promise<PackPublicPrice[]> {
const isMock =
!process.env.STRIPE_SECRET_KEY || process.env.STRIPE_SECRET_KEY === 'sk_test_placeholder'
return Promise.all(
CREDIT_PACK_IDS.map(async (id) => {
const pack = CREDIT_PACKS[id]
let display = pack.defaultDisplay
let amount = pack.defaultAmount
let currency = pack.currency
let configured = false
try {
const priceId = await resolvePackPriceId(id)
if (priceId && !priceId.startsWith('price_mock_')) {
configured = true
if (!isMock) {
const price = await stripe.prices.retrieve(priceId)
if (price.unit_amount != null) {
amount = price.unit_amount / 100
currency = price.currency.toUpperCase()
display = formatMoney(amount, currency)
}
}
}
} catch {
/* pack non configuré — on garde le prix catalogue */
}
return {
id,
credits: pack.credits,
display,
amount,
currency,
configured: configured || isMock,
}
}),
)
}
/** Retrouve packId + crédits depuis un price ID Stripe (webhook). */
export async function resolvePackFromPriceId(
priceId: string,
): Promise<{ packId: CreditPackId; credits: number } | null> {
if (!priceId) return null
if (priceId.startsWith('price_mock_credits_')) {
const suffix = priceId.replace('price_mock_credits_', '').toUpperCase()
if (isCreditPackId(suffix)) {
return { packId: suffix, credits: CREDIT_PACKS[suffix].credits }
}
}
for (const id of CREDIT_PACK_IDS) {
const pack = CREDIT_PACKS[id]
const fromDb = await getConfigValue(pack.priceConfigKey, '')
const configured = fromDb || process.env[pack.priceConfigKey] || ''
if (configured && configured === priceId) {
return { packId: id, credits: pack.credits }
}
}
return null
}