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:
158
memento-note/lib/billing/credit-packs.ts
Normal file
158
memento-note/lib/billing/credit-packs.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Packs de crédits IA (achat one-shot Stripe, mode payment).
|
||||
* Les crédits achetés s’ajoutent à `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 d’affichage 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
|
||||
}
|
||||
Reference in New Issue
Block a user