Rendre le dashboard actionnable (inbox, peek, carte mentale), aligner la facturation sur l’essai 7 jours, et bloquer le login e-mail tant que l’adresse n’est pas confirmée. Co-authored-by: Cursor <cursoragent@cursor.com>
419 lines
12 KiB
TypeScript
419 lines
12 KiB
TypeScript
'use server'
|
|
|
|
import prisma from '@/lib/prisma'
|
|
import { auth } from '@/auth'
|
|
import { SubscriptionTier } from '@prisma/client'
|
|
import { VALID_FEATURES, getCurrentPeriodKey } from '@/lib/quota-utils'
|
|
import {
|
|
getAllEntitlementsForAdmin,
|
|
invalidateEntitlementCache,
|
|
ENTITLEMENT_UNAVAILABLE,
|
|
type SubscriptionTier as TierType,
|
|
} from '@/lib/plan-entitlements'
|
|
import { logAuditEventAsync } from '@/lib/audit-log'
|
|
import { revalidatePath } from 'next/cache'
|
|
|
|
const BILLING_CONFIG_KEYS = [
|
|
'BILLING_ENABLED',
|
|
'STRIPE_PRICE_PRO_MONTHLY',
|
|
'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']
|
|
|
|
async function checkAdmin() {
|
|
const session = await auth()
|
|
if (!session?.user?.id || (session.user as { role?: string }).role !== 'ADMIN') {
|
|
throw new Error('Unauthorized: Admin access required')
|
|
}
|
|
return session
|
|
}
|
|
|
|
function assertValidFeature(feature: string) {
|
|
if (!(VALID_FEATURES as readonly string[]).includes(feature)) {
|
|
throw new Error(`Invalid feature: ${feature}`)
|
|
}
|
|
}
|
|
|
|
function assertValidTier(tier: string): asserts tier is TierType {
|
|
if (!TIERS.includes(tier as TierType)) {
|
|
throw new Error(`Invalid tier: ${tier}`)
|
|
}
|
|
}
|
|
|
|
async function getStripeHealth(billingConfig: Record<string, string>) {
|
|
const secret = process.env.STRIPE_SECRET_KEY ?? ''
|
|
const publishable = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY ?? ''
|
|
const webhook = process.env.STRIPE_WEBHOOK_SECRET ?? ''
|
|
|
|
let secretMode: 'test' | 'live' | 'missing' | 'placeholder' = 'missing'
|
|
if (!secret) secretMode = 'missing'
|
|
else if (secret === 'sk_test_placeholder' || secret.includes('placeholder')) secretMode = 'placeholder'
|
|
else if (secret.startsWith('sk_live_')) secretMode = 'live'
|
|
else if (secret.startsWith('sk_test_')) secretMode = 'test'
|
|
else secretMode = 'placeholder'
|
|
|
|
const { isBillingEnabled } = await import('@/lib/billing/stripe-prices')
|
|
const { SUBSCRIPTION_TRIAL_DAYS } = await import('@/lib/billing/trial-constants')
|
|
const billingEnabled = await isBillingEnabled()
|
|
|
|
const priceKeys = [
|
|
...BILLING_CONFIG_KEYS.filter((k) => k.startsWith('STRIPE_PRICE_')),
|
|
] as string[]
|
|
|
|
const canCallStripe = secretMode === 'test' || secretMode === 'live'
|
|
let stripe: Awaited<ReturnType<typeof import('@/lib/stripe').getStripe>> | null = null
|
|
if (canCallStripe) {
|
|
try {
|
|
const { getStripe } = await import('@/lib/stripe')
|
|
stripe = getStripe()
|
|
} catch {
|
|
stripe = null
|
|
}
|
|
}
|
|
|
|
const prices = await Promise.all(
|
|
priceKeys.map(async (key) => {
|
|
const priceId = (billingConfig[key] || process.env[key] || '').trim()
|
|
const configured = Boolean(priceId) && !priceId.startsWith('price_mock_')
|
|
let stripeAmount: number | null = null
|
|
let stripeCurrency: string | null = null
|
|
let stripeActive: boolean | null = null
|
|
let error: string | null = null
|
|
|
|
if (configured && stripe) {
|
|
try {
|
|
const price = await stripe.prices.retrieve(priceId)
|
|
stripeAmount = price.unit_amount != null ? price.unit_amount / 100 : null
|
|
stripeCurrency = price.currency?.toUpperCase() ?? null
|
|
stripeActive = price.active
|
|
} catch (e) {
|
|
error = e instanceof Error ? e.message : 'Stripe price lookup failed'
|
|
}
|
|
}
|
|
|
|
return {
|
|
key,
|
|
priceId: configured ? priceId : '',
|
|
configured,
|
|
source: billingConfig[key] ? 'db' : process.env[key] ? 'env' : 'missing',
|
|
stripeAmount,
|
|
stripeCurrency,
|
|
stripeActive,
|
|
error,
|
|
}
|
|
}),
|
|
)
|
|
|
|
return {
|
|
secretConfigured: secretMode === 'test' || secretMode === 'live',
|
|
secretMode,
|
|
publishableConfigured: Boolean(publishable) && publishable.startsWith('pk_'),
|
|
webhookSecretConfigured: Boolean(webhook) && webhook.startsWith('whsec_'),
|
|
billingEnabled,
|
|
trialDays: SUBSCRIPTION_TRIAL_DAYS,
|
|
prices,
|
|
}
|
|
}
|
|
|
|
async function getSubscriptionStats() {
|
|
const [byTierRaw, byStatusRaw, cancelAtPeriodEnd, recent] = await Promise.all([
|
|
prisma.subscription.groupBy({
|
|
by: ['tier'],
|
|
_count: { _all: true },
|
|
}),
|
|
prisma.subscription.groupBy({
|
|
by: ['status'],
|
|
_count: { _all: true },
|
|
}),
|
|
prisma.subscription.count({ where: { cancelAtPeriodEnd: true } }),
|
|
prisma.subscription.findMany({
|
|
where: {
|
|
OR: [
|
|
{ tier: { in: ['PRO', 'BUSINESS', 'ENTERPRISE'] } },
|
|
{ status: { in: ['TRIALING', 'PAST_DUE', 'ACTIVE'] } },
|
|
],
|
|
},
|
|
orderBy: { updatedAt: 'desc' },
|
|
take: 15,
|
|
select: {
|
|
tier: true,
|
|
status: true,
|
|
trialEndsAt: true,
|
|
currentPeriodEnd: true,
|
|
cancelAtPeriodEnd: true,
|
|
updatedAt: true,
|
|
stripeSubscriptionId: true,
|
|
user: { select: { email: true, name: true } },
|
|
},
|
|
}),
|
|
])
|
|
|
|
const byTier: Record<string, number> = Object.fromEntries(TIERS.map((t) => [t, 0]))
|
|
for (const row of byTierRaw) {
|
|
byTier[row.tier] = row._count._all
|
|
}
|
|
|
|
const byStatus: Record<string, number> = {}
|
|
for (const row of byStatusRaw) {
|
|
byStatus[row.status] = row._count._all
|
|
}
|
|
|
|
const usersWithoutSub = await prisma.user.count({
|
|
where: { subscription: null },
|
|
})
|
|
|
|
return {
|
|
byTier,
|
|
byStatus,
|
|
cancelAtPeriodEnd,
|
|
usersWithoutSub,
|
|
trialing: byStatus.TRIALING ?? 0,
|
|
pastDue: byStatus.PAST_DUE ?? 0,
|
|
paidActive:
|
|
(byStatus.ACTIVE ?? 0) +
|
|
(byStatus.TRIALING ?? 0),
|
|
recent: recent.map((s) => ({
|
|
email: s.user.email,
|
|
name: s.user.name,
|
|
tier: s.tier,
|
|
status: s.status,
|
|
trialEndsAt: s.trialEndsAt?.toISOString() ?? null,
|
|
currentPeriodEnd: s.currentPeriodEnd?.toISOString() ?? null,
|
|
cancelAtPeriodEnd: s.cancelAtPeriodEnd,
|
|
hasStripeSub: Boolean(s.stripeSubscriptionId),
|
|
updatedAt: s.updatedAt.toISOString(),
|
|
})),
|
|
}
|
|
}
|
|
|
|
export async function getBillingAdminData() {
|
|
await checkAdmin()
|
|
const { getSystemConfig } = await import('@/lib/config')
|
|
const config = await getSystemConfig()
|
|
const entitlements = await getAllEntitlementsForAdmin()
|
|
const usageOverview = await getUsageOverviewInternal()
|
|
|
|
const billingConfig = Object.fromEntries(
|
|
BILLING_CONFIG_KEYS.map((key) => [key, config[key] ?? '']),
|
|
)
|
|
|
|
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,
|
|
}
|
|
})
|
|
|
|
const [stripeHealth, subscriptionStats] = await Promise.all([
|
|
getStripeHealth(billingConfig),
|
|
getSubscriptionStats(),
|
|
])
|
|
|
|
return {
|
|
entitlements,
|
|
billingConfig,
|
|
usageOverview,
|
|
features: [...VALID_FEATURES],
|
|
tiers: TIERS,
|
|
creditAllocations,
|
|
creditCosts,
|
|
creditPacks,
|
|
stripeHealth,
|
|
subscriptionStats,
|
|
}
|
|
}
|
|
|
|
export async function updatePlanEntitlement(
|
|
tier: string,
|
|
feature: string,
|
|
mode: 'unavailable' | 'unlimited' | 'limited',
|
|
limitValue?: number,
|
|
) {
|
|
const session = await checkAdmin()
|
|
assertValidTier(tier)
|
|
assertValidFeature(feature)
|
|
|
|
if (mode === 'limited') {
|
|
if (limitValue === undefined || !Number.isFinite(limitValue) || limitValue < 0) {
|
|
throw new Error('Limit must be a non-negative number')
|
|
}
|
|
}
|
|
|
|
if (mode === 'unavailable') {
|
|
await prisma.planEntitlement.upsert({
|
|
where: {
|
|
tier_feature: {
|
|
tier: tier as SubscriptionTier,
|
|
feature,
|
|
},
|
|
},
|
|
update: { limitValue: ENTITLEMENT_UNAVAILABLE },
|
|
create: {
|
|
tier: tier as SubscriptionTier,
|
|
feature,
|
|
limitValue: ENTITLEMENT_UNAVAILABLE,
|
|
},
|
|
});
|
|
} else {
|
|
await prisma.planEntitlement.upsert({
|
|
where: {
|
|
tier_feature: {
|
|
tier: tier as SubscriptionTier,
|
|
feature,
|
|
},
|
|
},
|
|
update: {
|
|
limitValue: mode === 'unlimited' ? null : Math.round(limitValue!),
|
|
},
|
|
create: {
|
|
tier: tier as SubscriptionTier,
|
|
feature,
|
|
limitValue: mode === 'unlimited' ? null : Math.round(limitValue!),
|
|
},
|
|
})
|
|
}
|
|
|
|
invalidateEntitlementCache()
|
|
|
|
await logAuditEventAsync({
|
|
userId: session.user?.id,
|
|
action: 'PLAN_ENTITLEMENT_UPDATED',
|
|
resource: `${tier}:${feature}`,
|
|
metadata: { tier, feature, mode, limitValue: mode === 'limited' ? limitValue : mode },
|
|
})
|
|
|
|
revalidatePath('/admin/billing')
|
|
return { success: true }
|
|
}
|
|
|
|
export async function updateBillingConfig(data: Record<string, string>) {
|
|
const session = await checkAdmin()
|
|
|
|
const filtered = Object.fromEntries(
|
|
Object.entries(data).filter(([key, value]) =>
|
|
(BILLING_CONFIG_KEYS as readonly string[]).includes(key)
|
|
&& value !== ''
|
|
&& !value.includes('sk_')
|
|
&& !value.includes('whsec_'),
|
|
),
|
|
)
|
|
|
|
if (filtered.BILLING_ENABLED === 'true') {
|
|
const required = [
|
|
'STRIPE_PRICE_PRO_MONTHLY',
|
|
'STRIPE_PRICE_PRO_ANNUAL',
|
|
'STRIPE_PRICE_BUSINESS_MONTHLY',
|
|
'STRIPE_PRICE_BUSINESS_ANNUAL',
|
|
] as const
|
|
for (const key of required) {
|
|
if (!filtered[key] && !process.env[key]) {
|
|
throw new Error(`Missing ${key} when billing is enabled`)
|
|
}
|
|
}
|
|
}
|
|
|
|
const operations = Object.entries(filtered).map(([key, value]) =>
|
|
prisma.systemConfig.upsert({
|
|
where: { key },
|
|
update: { value },
|
|
create: { key, value },
|
|
}),
|
|
)
|
|
|
|
await prisma.$transaction(operations)
|
|
|
|
await logAuditEventAsync({
|
|
userId: session.user?.id,
|
|
action: 'BILLING_CONFIG_UPDATED',
|
|
resource: 'billing',
|
|
metadata: { keys: Object.keys(filtered) },
|
|
})
|
|
|
|
revalidatePath('/admin/billing')
|
|
revalidatePath('/settings/billing')
|
|
return { success: true }
|
|
}
|
|
|
|
async function getUsageOverviewInternal() {
|
|
const period = getCurrentPeriodKey()
|
|
const periodStart = new Date(`${period}-01T00:00:00.000Z`)
|
|
|
|
const aggregated = await prisma.usageLog.groupBy({
|
|
by: ['feature'],
|
|
where: { periodStart },
|
|
_sum: { requestsCount: true, tokensUsed: true },
|
|
_count: { userId: true },
|
|
})
|
|
|
|
const lastSync = await prisma.usageLog.findFirst({
|
|
where: { periodStart },
|
|
orderBy: { syncedAt: 'desc' },
|
|
select: { syncedAt: true },
|
|
})
|
|
|
|
const topUsers = await prisma.usageLog.groupBy({
|
|
by: ['userId'],
|
|
where: { periodStart },
|
|
_sum: { requestsCount: true },
|
|
orderBy: { _sum: { requestsCount: 'desc' } },
|
|
take: 10,
|
|
})
|
|
|
|
const userIds = topUsers.map((u) => u.userId)
|
|
const users = userIds.length
|
|
? await prisma.user.findMany({
|
|
where: { id: { in: userIds } },
|
|
select: { id: true, email: true, name: true },
|
|
})
|
|
: []
|
|
|
|
const userMap = Object.fromEntries(users.map((u) => [u.id, u]))
|
|
|
|
return {
|
|
period,
|
|
lastSyncedAt: lastSync?.syncedAt?.toISOString() ?? null,
|
|
byFeature: aggregated.map((row) => ({
|
|
feature: row.feature,
|
|
requests: row._sum.requestsCount ?? 0,
|
|
tokens: row._sum.tokensUsed ?? 0,
|
|
users: row._count.userId,
|
|
})),
|
|
topUsers: topUsers.map((row) => ({
|
|
userId: row.userId,
|
|
email: userMap[row.userId]?.email ?? row.userId,
|
|
name: userMap[row.userId]?.name ?? null,
|
|
requests: row._sum.requestsCount ?? 0,
|
|
})),
|
|
}
|
|
}
|
|
|
|
export async function getUsageOverview() {
|
|
await checkAdmin()
|
|
return getUsageOverviewInternal()
|
|
}
|