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,135 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { stripe } from '@/lib/stripe'
import { isBillingEnabled } from '@/lib/billing/stripe-prices'
import {
isCreditPackId,
resolvePackPriceId,
getCreditPack,
} from '@/lib/billing/credit-packs'
import { prisma } from '@/lib/prisma'
import { z } from 'zod'
const bodySchema = z.object({
packId: z.enum(['S', 'M', 'L']),
})
export async function POST(req: NextRequest) {
const session = await auth()
if (!session?.user?.id || !session.user.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
if (!(await isBillingEnabled())) {
return NextResponse.json({ error: 'Billing is not enabled' }, { status: 403 })
}
const secret = process.env.STRIPE_SECRET_KEY
if (!secret || secret === 'sk_test_placeholder') {
return NextResponse.json(
{ error: 'Stripe is not configured (STRIPE_SECRET_KEY missing)' },
{ status: 503 },
)
}
const parsed = bodySchema.safeParse(await req.json())
if (!parsed.success || !isCreditPackId(parsed.data.packId)) {
return NextResponse.json({ error: 'Invalid pack' }, { status: 400 })
}
const packId = parsed.data.packId
const pack = getCreditPack(packId)
const userId = session.user.id
const userEmail = session.user.email
try {
let priceId: string
try {
priceId = await resolvePackPriceId(packId)
} catch (e) {
console.error('[billing/create-pack-checkout] price resolve failed:', e)
return NextResponse.json(
{ error: 'Credit pack price IDs not configured. Set them in Admin > Billing.' },
{ status: 503 },
)
}
if (priceId.startsWith('price_mock_')) {
return NextResponse.json(
{ error: 'Credit pack price IDs not configured. Set them in Admin > Billing.' },
{ status: 503 },
)
}
const subscription = await prisma.subscription.findUnique({ where: { userId } })
let customerId = subscription?.stripeCustomerId ?? undefined
if (customerId && customerId.startsWith('cus_mock')) {
customerId = undefined
}
if (!customerId) {
const customer = await stripe.customers.create({
email: userEmail,
metadata: { userId },
})
customerId = customer.id
await prisma.subscription.upsert({
where: { userId },
update: { stripeCustomerId: customerId },
create: {
userId,
stripeCustomerId: customerId,
tier: 'BASIC',
status: 'ACTIVE',
currentPeriodStart: new Date(),
currentPeriodEnd: new Date(Date.now() + 30 * 24 * 3600 * 1000),
},
})
}
const host = req.headers.get('x-forwarded-host') ?? req.headers.get('host') ?? 'localhost:3000'
const proto = req.headers.get('x-forwarded-proto') ?? 'http'
const origin = `${proto}://${host}`
const checkoutSession = await stripe.checkout.sessions.create({
customer: customerId,
mode: 'payment',
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${origin}/settings/billing?session_id={CHECKOUT_SESSION_ID}&pack=1`,
cancel_url: `${origin}/settings/billing?canceled=1`,
metadata: {
userId,
type: 'credit_pack',
packId,
credits: String(pack.credits),
},
payment_intent_data: {
metadata: {
userId,
type: 'credit_pack',
packId,
credits: String(pack.credits),
},
},
customer_update: { address: 'auto' },
allow_promotion_codes: true,
})
if (!checkoutSession.url) {
return NextResponse.json({ error: 'Checkout session has no URL' }, { status: 500 })
}
return NextResponse.json({
url: checkoutSession.url,
sessionId: checkoutSession.id,
packId,
credits: pack.credits,
})
} catch (error) {
console.error('[billing/create-pack-checkout]', error)
const msg = error instanceof Error ? error.message : 'Failed to create pack checkout'
return NextResponse.json({ error: msg }, { status: 500 })
}
}