'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) { 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> | 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 = Object.fromEntries(TIERS.map((t) => [t, 0])) for (const row of byTierRaw) { byTier[row.tier] = row._count._all } const byStatus: Record = {} 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) { 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() }