mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
feat(stripe-billing): add Stripe stats retrieval and display in admin dashboard
This commit is contained in:
+78
-4
@@ -1,12 +1,12 @@
|
|||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { auth } from '@/lib/auth';
|
import { auth } from '@/lib/auth';
|
||||||
import { isBunnyUploadsFeatureEnabled } from '@/lib/feature-flags';
|
import { isBunnyUploadsFeatureEnabled, isStripeBillingEnabled } from '@/lib/feature-flags';
|
||||||
import { redirect } from 'next/navigation';
|
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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { RefreshR2StatsButton } from '@/components/admin/refresh-r2-stats-button';
|
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 = {
|
export const metadata: Metadata = {
|
||||||
title: 'Admin Dashboard | OpenFrame',
|
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]}`;
|
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() {
|
export default async function AdminDashboardPage() {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session?.user?.isAdmin) {
|
if (!session?.user?.isAdmin) {
|
||||||
@@ -72,9 +82,10 @@ export default async function AdminDashboardPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. Storage Stats (Cached)
|
// 2. Storage Stats (Cached)
|
||||||
const [totalStorageBytes, bunnyStorageStats] = await Promise.all([
|
const [totalStorageBytes, bunnyStorageStats, stripeStats] = await Promise.all([
|
||||||
getCachedTotalStorage(),
|
getCachedTotalStorage(),
|
||||||
getCachedBunnyStorageStats(),
|
getCachedBunnyStorageStats(),
|
||||||
|
getCachedStripeStats(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -180,6 +191,69 @@ export default async function AdminDashboardPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isStripeBillingEnabled() && stripeStats && (
|
||||||
|
<>
|
||||||
|
<h3 className="text-xl font-semibold tracking-tight pt-2">Billing & Revenue</h3>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Monthly Recurring Revenue</CardTitle>
|
||||||
|
<TrendingUp className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{formatMrr(stripeStats.mrrCents, stripeStats.currency)}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">Based on active subscriptions</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Active Subscribers</CardTitle>
|
||||||
|
<UserCheck className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{stripeStats.activeSubscribers}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">On Trial</CardTitle>
|
||||||
|
<CreditCard className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{stripeStats.trialingUsers}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Free Users</CardTitle>
|
||||||
|
<Users className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{stripeStats.freeUsers}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Past Due</CardTitle>
|
||||||
|
<AlertCircle className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{stripeStats.pastDueUsers}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Canceled</CardTitle>
|
||||||
|
<UserX className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{stripeStats.canceledUsers}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+61
-1
@@ -2,7 +2,8 @@ import { unstable_cache } from 'next/cache';
|
|||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { r2Client, R2_BUCKET_NAME } from '@/lib/r2';
|
import { r2Client, R2_BUCKET_NAME } from '@/lib/r2';
|
||||||
import { ListObjectsV2Command, type ListObjectsV2CommandInput } from '@aws-sdk/client-s3';
|
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';
|
import { logError } from '@/lib/logger';
|
||||||
|
|
||||||
const BUNNY_API_BASE = 'https://video.bunnycdn.com';
|
const BUNNY_API_BASE = 'https://video.bunnycdn.com';
|
||||||
@@ -407,3 +408,62 @@ export const getCachedUserDownloadEgress = unstable_cache(
|
|||||||
['admin-user-download-egress'],
|
['admin-user-download-egress'],
|
||||||
{ revalidate: STORAGE_CACHE_SECONDS }
|
{ 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<StripeStats | null> => {
|
||||||
|
if (!isStripeBillingEnabled()) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const statusCounts = await db.user.groupBy({
|
||||||
|
by: ['subscriptionStatus'],
|
||||||
|
_count: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const counts: Record<string, number> = {};
|
||||||
|
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 }
|
||||||
|
);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user