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:
@@ -1,8 +1,17 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
|
||||
import { QuotaExceededError } from '@/lib/entitlements'
|
||||
import { reserveAiUsageOrThrow } from '@/lib/ai-quota'
|
||||
import { slideGenerateCreditCost, resolveCreditCost } from '@/lib/credits'
|
||||
import type { FeatureName } from '@/lib/quota-utils'
|
||||
import { logAuditEvent, getClientIp } from '@/lib/audit-log'
|
||||
import {
|
||||
encodeSlideIntentDescription,
|
||||
normalizeSlideIntent,
|
||||
type SlideAudience,
|
||||
type SlidePurpose,
|
||||
} from '@/lib/ai/services/slide-intent'
|
||||
|
||||
type GenerateType = 'slide-generator' | 'excalidraw-generator'
|
||||
|
||||
@@ -12,9 +21,10 @@ const TYPE_DEFAULTS: Record<GenerateType, {
|
||||
maxSteps: number
|
||||
}> = {
|
||||
'slide-generator': {
|
||||
role: 'Génère une présentation professionnelle à partir du contenu de la note fournie. Appelle generate_slides avec un objet JSON structuré {title, theme, slides:[...]}.',
|
||||
tools: ['note_search', 'note_read', 'generate_slides'],
|
||||
maxSteps: 6,
|
||||
// tools unused: slide-generator runs a structured-output pipeline (no function calling)
|
||||
role: 'Génère un deck exécutif de haute qualité (titres d\'action, une idée par slide, pas de filler, graphiques uniquement avec vrais chiffres).',
|
||||
tools: [],
|
||||
maxSteps: 1,
|
||||
},
|
||||
'excalidraw-generator': {
|
||||
role: 'Génère un diagramme Excalidraw clair et professionnel à partir du contenu de la note fournie.',
|
||||
@@ -32,26 +42,46 @@ export async function POST(req: NextRequest) {
|
||||
const userId = session.user.id
|
||||
|
||||
const body = await req.json()
|
||||
const { noteId, type, theme, style, language, template } = body as {
|
||||
const { noteId, type, theme, style, language, template, purpose, audience, slideCount } = body as {
|
||||
noteId: string
|
||||
type: GenerateType
|
||||
theme?: string
|
||||
style?: string
|
||||
language?: string
|
||||
template?: string
|
||||
purpose?: SlidePurpose
|
||||
audience?: SlideAudience
|
||||
slideCount?: number
|
||||
}
|
||||
|
||||
if (!noteId || !type || !TYPE_DEFAULTS[type]) {
|
||||
return NextResponse.json({ error: 'Paramètres invalides' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Quota check — feature key depends on generation type
|
||||
const featureKey = type === 'slide-generator' ? 'slide_generate' : 'excalidraw_generate'
|
||||
// Crédits globaux (respecte le BYOK) — slides = 1 + N
|
||||
const featureKey = (type === 'slide-generator' ? 'slide_generate' : 'excalidraw_generate') as FeatureName
|
||||
const creditCost =
|
||||
type === 'slide-generator'
|
||||
? slideGenerateCreditCost(slideCount)
|
||||
: resolveCreditCost(featureKey)
|
||||
try {
|
||||
await reserveUsageOrThrow(userId, featureKey)
|
||||
await reserveAiUsageOrThrow(userId, featureKey, {
|
||||
amount: creditCost,
|
||||
slideCount: type === 'slide-generator' ? slideCount : undefined,
|
||||
metadata: { noteId, type, slideCount: slideCount ?? null },
|
||||
lane: 'chat',
|
||||
})
|
||||
} catch (e) {
|
||||
if (e instanceof QuotaExceededError) {
|
||||
return NextResponse.json({ error: e.message }, { status: 402 })
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: e.message,
|
||||
code: 'QUOTA_EXCEEDED',
|
||||
feature: featureKey,
|
||||
creditCost,
|
||||
},
|
||||
{ status: 402 },
|
||||
)
|
||||
}
|
||||
throw e
|
||||
}
|
||||
@@ -71,25 +101,50 @@ export async function POST(req: NextRequest) {
|
||||
if (isEn) {
|
||||
if (type === 'slide-generator') {
|
||||
const recipeHint = (theme && theme !== 'auto') ? ` Use theme:"${theme}".` : ''
|
||||
role = `Generate a professional presentation from the provided note content.${recipeHint} Call generate_slides with structured JSON {title, theme, slides:[...]}.`
|
||||
role = `Generate a high-quality executive deck (action titles, one idea per slide, no filler, charts only with real numbers).${recipeHint}`
|
||||
} else {
|
||||
role = 'Generate a clear and professional Excalidraw diagram from the provided note content.'
|
||||
}
|
||||
} else if (type === 'slide-generator') {
|
||||
const recipeHint = (theme && theme !== 'auto') ? ` Utilise le thème "${theme}".` : ''
|
||||
role = `Génère une présentation professionnelle à partir du contenu de la note fournie.${recipeHint} Appelle generate_slides avec le JSON structuré {title, theme, slides:[...]}.`
|
||||
role = `Génère un deck exécutif de haute qualité (titres d'action, une idée par slide, pas de filler, graphiques uniquement avec vrais chiffres).${recipeHint}`
|
||||
}
|
||||
|
||||
const agentName = type === 'slide-generator'
|
||||
? `${isEn ? 'Slides' : 'Présentation'} — ${(note.title || 'Note').substring(0, 40)}`
|
||||
: `${isEn ? 'Diagram' : 'Diagramme'} — ${(note.title || 'Note').substring(0, 40)}`
|
||||
|
||||
const slideIntent =
|
||||
type === 'slide-generator'
|
||||
? normalizeSlideIntent({
|
||||
purpose,
|
||||
audience,
|
||||
slideCount,
|
||||
template: template || 'auto',
|
||||
})
|
||||
: null
|
||||
|
||||
// Map purpose → legacy template when user picks intent without template
|
||||
if (slideIntent && (!slideIntent.template || slideIntent.template === 'auto')) {
|
||||
const purposeToTemplate: Record<string, string> = {
|
||||
board: 'board-update',
|
||||
project: 'project-status',
|
||||
strategy: 'strategy-review',
|
||||
}
|
||||
if (slideIntent.purpose && purposeToTemplate[slideIntent.purpose]) {
|
||||
slideIntent.template = purposeToTemplate[slideIntent.purpose]
|
||||
}
|
||||
}
|
||||
|
||||
const agent = await prisma.agent.create({
|
||||
data: {
|
||||
name: agentName,
|
||||
type,
|
||||
role,
|
||||
description: (type === 'slide-generator' && template && template !== 'auto') ? `template:${template}` : undefined,
|
||||
description:
|
||||
type === 'slide-generator' && slideIntent
|
||||
? encodeSlideIntentDescription(slideIntent)
|
||||
: undefined,
|
||||
tools: JSON.stringify(defaults.tools),
|
||||
maxSteps: defaults.maxSteps,
|
||||
frequency: 'one-shot',
|
||||
@@ -104,8 +159,9 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
// ── Fire and forget — do NOT await so the HTTP response returns immediately ──
|
||||
// In Node.js / Docker self-hosted, the process keeps running after response.
|
||||
// skipQuota: units already reserved above (avoids double-charge in executeAgent).
|
||||
import('@/lib/ai/services/agent-executor.service')
|
||||
.then(({ executeAgent }) => executeAgent(agent.id, userId))
|
||||
.then(({ executeAgent }) => executeAgent(agent.id, userId, undefined, { skipQuota: true }))
|
||||
.catch(err => console.error('[run-for-note] Background agent error:', err))
|
||||
|
||||
logAuditEvent({
|
||||
|
||||
135
memento-note/app/api/billing/create-pack-checkout/route.ts
Normal file
135
memento-note/app/api/billing/create-pack-checkout/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { auth } from '@/auth';
|
||||
import { getUserInfo, getEffectiveTier } from '@/lib/entitlements';
|
||||
import { stripe } from '@/lib/stripe';
|
||||
import type Stripe from 'stripe';
|
||||
import { priceIdToTier, getDynamicPrices, isBillingEnabled } from '@/lib/billing/stripe-prices';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
@@ -76,6 +75,42 @@ export async function GET(req: NextRequest) {
|
||||
const subscription = await prisma.subscription.findUnique({ where: { userId } });
|
||||
const prices = await getDynamicPrices();
|
||||
const billingEnabled = await isBillingEnabled();
|
||||
const { getPackPublicPrices } = await import('@/lib/billing/credit-packs');
|
||||
const creditPacks = await getPackPublicPrices();
|
||||
|
||||
// Si retour checkout pack : créditer au cas où le webhook n'a pas encore tourné
|
||||
if (sessionId && sessionId.startsWith('cs_')) {
|
||||
try {
|
||||
const checkoutSession = await stripe.checkout.sessions.retrieve(sessionId);
|
||||
if (
|
||||
checkoutSession.mode === 'payment' &&
|
||||
checkoutSession.status === 'complete' &&
|
||||
(checkoutSession.metadata?.type === 'credit_pack' || checkoutSession.metadata?.packId) &&
|
||||
checkoutSession.metadata?.userId === userId
|
||||
) {
|
||||
const { addPurchasedCredits } = await import('@/lib/credits');
|
||||
const {
|
||||
getCreditPack,
|
||||
isCreditPackId,
|
||||
} = await import('@/lib/billing/credit-packs');
|
||||
const packId = checkoutSession.metadata?.packId;
|
||||
let credits = Number(checkoutSession.metadata?.credits ?? 0);
|
||||
if (packId && isCreditPackId(packId) && (!Number.isFinite(credits) || credits <= 0)) {
|
||||
credits = getCreditPack(packId).credits;
|
||||
}
|
||||
if (Number.isFinite(credits) && credits > 0) {
|
||||
await addPurchasedCredits(userId, credits, {
|
||||
stripeSessionId: checkoutSession.id,
|
||||
packId: packId ?? null,
|
||||
type: 'credit_pack',
|
||||
source: 'billing_status_sync',
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (packSyncErr) {
|
||||
console.error('[billing/status] pack sync failed:', packSyncErr);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
tier,
|
||||
@@ -86,6 +121,7 @@ export async function GET(req: NextRequest) {
|
||||
cancelAtPeriodEnd: subscription?.cancelAtPeriodEnd ?? false,
|
||||
hasStripeSubscription: !!subscription?.stripeSubscriptionId,
|
||||
prices,
|
||||
creditPacks,
|
||||
billingEnabled,
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -6,11 +6,79 @@ import {
|
||||
handleSubscriptionDeleted,
|
||||
resolveUserIdFromStripeEvent,
|
||||
} from '@/lib/billing/sync-subscription-from-stripe';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { addPurchasedCredits } from '@/lib/credits';
|
||||
import {
|
||||
getCreditPack,
|
||||
isCreditPackId,
|
||||
resolvePackFromPriceId,
|
||||
} from '@/lib/billing/credit-packs';
|
||||
import type Stripe from 'stripe';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
async function fulfillCreditPackPurchase(session: Stripe.Checkout.Session) {
|
||||
if (session.mode !== 'payment') return;
|
||||
if (session.payment_status && session.payment_status !== 'paid' && session.payment_status !== 'no_payment_required') {
|
||||
console.warn('[billing/webhook] pack checkout not paid yet', session.id, session.payment_status);
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = session.metadata?.userId as string | undefined;
|
||||
if (!userId) {
|
||||
console.warn('[billing/webhook] credit pack: no userId', session.id);
|
||||
return;
|
||||
}
|
||||
|
||||
let packId = session.metadata?.packId as string | undefined;
|
||||
let credits = Number(session.metadata?.credits ?? 0);
|
||||
|
||||
if ((!packId || !Number.isFinite(credits) || credits <= 0) && session.line_items == null) {
|
||||
try {
|
||||
const full = await stripe.checkout.sessions.retrieve(session.id, {
|
||||
expand: ['line_items.data.price'],
|
||||
});
|
||||
const priceId = full.line_items?.data?.[0]?.price?.id;
|
||||
if (priceId) {
|
||||
const resolved = await resolvePackFromPriceId(priceId);
|
||||
if (resolved) {
|
||||
packId = resolved.packId;
|
||||
credits = resolved.credits;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[billing/webhook] expand line_items failed', err);
|
||||
}
|
||||
}
|
||||
|
||||
if (packId && isCreditPackId(packId) && (!Number.isFinite(credits) || credits <= 0)) {
|
||||
credits = getCreditPack(packId).credits;
|
||||
}
|
||||
|
||||
if (!Number.isFinite(credits) || credits <= 0) {
|
||||
console.warn('[billing/webhook] credit pack: invalid credits', session.id, { packId, credits });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await addPurchasedCredits(userId, credits, {
|
||||
stripeSessionId: session.id,
|
||||
packId: packId ?? null,
|
||||
type: 'credit_pack',
|
||||
paymentIntent:
|
||||
typeof session.payment_intent === 'string'
|
||||
? session.payment_intent
|
||||
: session.payment_intent?.id ?? null,
|
||||
});
|
||||
|
||||
if (result.applied) {
|
||||
console.info('[billing/webhook] credited pack', {
|
||||
userId,
|
||||
credits: result.units,
|
||||
packId,
|
||||
sessionId: session.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const body = await req.text();
|
||||
const headersList = await headers();
|
||||
@@ -49,6 +117,11 @@ export async function POST(req: NextRequest) {
|
||||
} else {
|
||||
console.warn('[billing/webhook] checkout.session.completed: no userId in metadata', session.id);
|
||||
}
|
||||
} else if (
|
||||
session.mode === 'payment' &&
|
||||
(session.metadata?.type === 'credit_pack' || session.metadata?.packId)
|
||||
) {
|
||||
await fulfillCreditPackPurchase(session);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -1,27 +1,48 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { auth } from '@/auth';
|
||||
import { getUserQuotas, getEffectiveTier } from '@/lib/entitlements';
|
||||
import { NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import { getCreditUsageSummary, getQuotasCompatFromCredits } from '@/lib/credits'
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function GET() {
|
||||
const session = await auth();
|
||||
const session = await auth()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const [quotas, tier] = await Promise.all([
|
||||
getUserQuotas(session.user.id),
|
||||
getEffectiveTier(session.user.id),
|
||||
]);
|
||||
return NextResponse.json({ quotas, tier });
|
||||
const summary = await getCreditUsageSummary(session.user.id)
|
||||
// Compat anciens clients : quotas dérivés des crédits
|
||||
const quotas = await getQuotasCompatFromCredits(session.user.id)
|
||||
|
||||
return NextResponse.json({
|
||||
tier: summary.tier,
|
||||
period: summary.period,
|
||||
balance: {
|
||||
totalRemaining: summary.balance.unlimited
|
||||
? null
|
||||
: summary.balance.totalRemaining,
|
||||
subscriptionRemaining: summary.balance.unlimited
|
||||
? null
|
||||
: summary.balance.subscriptionRemaining,
|
||||
purchasedRemaining: summary.balance.purchasedRemaining,
|
||||
subscriptionGranted: summary.balance.unlimited
|
||||
? null
|
||||
: summary.balance.subscriptionGranted,
|
||||
subscriptionSpent: summary.balance.subscriptionSpent,
|
||||
spentThisPeriod: summary.balance.spentThisPeriod,
|
||||
unlimited: summary.balance.unlimited,
|
||||
},
|
||||
breakdown: summary.breakdown,
|
||||
// legacy
|
||||
quotas,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[usage/current] Failed to fetch quotas:', error);
|
||||
console.error('[usage/current] Failed to fetch usage data:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch usage data' },
|
||||
{ status: 503 },
|
||||
);
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user