Files
Momento/memento-note/app/actions/admin-billing.ts
Antigravity 80ccc1f6de
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 7m14s
CI / Deploy production (on server) (push) Successful in 1m25s
feat: dashboard Second Brain, essai 7 jours et vérification e-mail
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>
2026-08-30 07:19:36 +00:00

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()
}