diff --git a/app/admin/page.tsx b/app/admin/page.tsx index 676d0c9..692e54c 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -1,12 +1,12 @@ import { Metadata } from 'next'; import { db } from '@/lib/db'; import { auth } from '@/lib/auth'; -import { isBunnyUploadsFeatureEnabled } from '@/lib/feature-flags'; +import { isBunnyUploadsFeatureEnabled, isStripeBillingEnabled } from '@/lib/feature-flags'; import { redirect } from 'next/navigation'; -import { getCachedBunnyStorageStats, getCachedTotalStorage } from '@/lib/admin-stats'; +import { getCachedBunnyStorageStats, getCachedTotalStorage, getCachedStripeStats } from '@/lib/admin-stats'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { RefreshR2StatsButton } from '@/components/admin/refresh-r2-stats-button'; -import { Users, Folder, Video, MessageSquare, Mic, HardDrive, Image as ImageIcon, Film, MessageSquareQuote, Star } from 'lucide-react'; +import { Users, Folder, Video, MessageSquare, Mic, HardDrive, Image as ImageIcon, Film, MessageSquareQuote, Star, CreditCard, TrendingUp, UserCheck, AlertCircle, UserX } from 'lucide-react'; export const metadata: Metadata = { title: 'Admin Dashboard | OpenFrame', @@ -23,6 +23,16 @@ function formatBytes(bytes: number, decimals = 2) { return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`; } +function formatMrr(cents: number, currency: string) { + const safeCurrency = /^[a-zA-Z]{3}$/.test(currency) ? currency.toUpperCase() : 'USD'; + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: safeCurrency, + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(cents / 100); +} + export default async function AdminDashboardPage() { const session = await auth(); if (!session?.user?.isAdmin) { @@ -72,9 +82,10 @@ export default async function AdminDashboardPage() { } // 2. Storage Stats (Cached) - const [totalStorageBytes, bunnyStorageStats] = await Promise.all([ + const [totalStorageBytes, bunnyStorageStats, stripeStats] = await Promise.all([ getCachedTotalStorage(), getCachedBunnyStorageStats(), + getCachedStripeStats(), ]); return ( @@ -180,6 +191,69 @@ export default async function AdminDashboardPage() { + + {isStripeBillingEnabled() && stripeStats && ( + <> +

Billing & Revenue

+
+ + + Monthly Recurring Revenue + + + +
{formatMrr(stripeStats.mrrCents, stripeStats.currency)}
+

Based on active subscriptions

+
+
+ + + Active Subscribers + + + +
{stripeStats.activeSubscribers}
+
+
+ + + On Trial + + + +
{stripeStats.trialingUsers}
+
+
+ + + Free Users + + + +
{stripeStats.freeUsers}
+
+
+ + + Past Due + + + +
{stripeStats.pastDueUsers}
+
+
+ + + Canceled + + + +
{stripeStats.canceledUsers}
+
+
+
+ + )} ); } diff --git a/lib/admin-stats.ts b/lib/admin-stats.ts index a3f6b2f..3600ea3 100644 --- a/lib/admin-stats.ts +++ b/lib/admin-stats.ts @@ -2,7 +2,8 @@ import { unstable_cache } from 'next/cache'; import { db } from '@/lib/db'; import { r2Client, R2_BUCKET_NAME } from '@/lib/r2'; import { ListObjectsV2Command, type ListObjectsV2CommandInput } from '@aws-sdk/client-s3'; -import { isBunnyUploadsFeatureEnabled } from '@/lib/feature-flags'; +import { isBunnyUploadsFeatureEnabled, isStripeBillingEnabled } from '@/lib/feature-flags'; +import { getStripe, getStripePriceId } from '@/lib/stripe'; import { logError } from '@/lib/logger'; const BUNNY_API_BASE = 'https://video.bunnycdn.com'; @@ -407,3 +408,62 @@ export const getCachedUserDownloadEgress = unstable_cache( ['admin-user-download-egress'], { revalidate: STORAGE_CACHE_SECONDS } ); + +export interface StripeStats { + activeSubscribers: number; + trialingUsers: number; + pastDueUsers: number; + canceledUsers: number; + freeUsers: number; + mrrCents: number; + currency: string; +} + +const STRIPE_STATS_CACHE_SECONDS = 300; + +export const getCachedStripeStats = unstable_cache( + async (): Promise => { + if (!isStripeBillingEnabled()) return null; + + try { + const statusCounts = await db.user.groupBy({ + by: ['subscriptionStatus'], + _count: { id: true }, + }); + + const counts: Record = {}; + for (const row of statusCounts) { + const key = row.subscriptionStatus ?? 'UNKNOWN'; + counts[key] = row._count.id; + } + + const activeSubscribers = counts['ACTIVE'] ?? 0; + const trialingUsers = counts['TRIALING'] ?? 0; + const pastDueUsers = counts['PAST_DUE'] ?? 0; + const canceledUsers = counts['CANCELED'] ?? 0; + const freeUsers = counts['FREE'] ?? 0; + + let mrrCents = 0; + let currency = 'usd'; + + try { + const stripe = getStripe(); + const priceId = getStripePriceId(); + const price = await stripe.prices.retrieve(priceId); + const unitAmount = price.unit_amount ?? 0; + currency = price.currency ?? 'usd'; + mrrCents = activeSubscribers * unitAmount; + } catch (err) { + logError('Failed to fetch Stripe price for MRR calculation:', err); + } + + return { activeSubscribers, trialingUsers, pastDueUsers, canceledUsers, freeUsers, mrrCents, currency }; + } catch (err) { + logError('Failed to fetch Stripe stats:', err); + return null; + } + }, + ['admin-stripe-stats'], + { revalidate: STRIPE_STATS_CACHE_SECONDS } +); +