Pro/business

This commit is contained in:
Timo Knuth
2026-07-06 00:18:06 +02:00
parent 0b9c8d2a8f
commit 91bd940edc
10 changed files with 384 additions and 40 deletions

View File

@@ -17,6 +17,7 @@ import {
import {
BarChart3,
Copy,
CreditCard,
Download,
Filter,
Loader2,
@@ -68,6 +69,12 @@ type SegmentRow = {
qrCount: number;
dynamicQrCount: number;
scanCount: number;
lifecycleLogs: Array<{
fromStage: string | null;
toStage: string;
reason: string | null;
createdAt: string;
}>;
};
type DashboardData = {
@@ -76,6 +83,7 @@ type DashboardData = {
mismatchCount: number;
activatedUsers: number;
paidUsers: number;
recentBillingEvents: number;
};
acquisition: {
bySource: Array<{
@@ -119,6 +127,16 @@ type DashboardData = {
byTeamSize: Array<any>;
};
lifecycleSummary: Record<string, number>;
recentBillingActivity: Array<{
userId: string;
name: string | null;
email: string;
plan: string;
reason: string | null;
fromStage: string | null;
toStage: string;
createdAt: string;
}>;
campaignSourceQuality: Array<any>;
upgradeCandidates: Array<SegmentRow>;
filterOptions: {
@@ -184,6 +202,27 @@ function buildQuery(filters: Filters) {
return params.toString();
}
function formatStage(value: string | null) {
return value ? value.replace(/_/g, ' ') : 'none';
}
function formatBillingReason(value: string | null) {
switch (value) {
case 'subscription_created':
return 'Started paid plan';
case 'subscription_updated':
return 'Updated subscription';
case 'subscription_canceled_at_period_end':
return 'Cancels at period end';
case 'subscription_deleted':
return 'Ended subscription';
case 'subscription_synced':
return 'Synced subscription';
default:
return 'Subscription changed';
}
}
function LifecycleCard({
label,
value,
@@ -475,6 +514,50 @@ export default function NewsletterClient() {
</Card>
</div>
<Card className="mb-8">
<CardHeader>
<div className="flex items-center gap-3">
<CreditCard className="h-5 w-5 text-slate-500" />
<CardTitle>Recent Billing Activity</CardTitle>
</div>
</CardHeader>
<CardContent>
{data.recentBillingActivity.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>User</TableHead>
<TableHead>Plan</TableHead>
<TableHead>Action</TableHead>
<TableHead>Lifecycle change</TableHead>
<TableHead>Date</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.recentBillingActivity.map((event) => (
<TableRow key={`${event.userId}-${event.createdAt}`}>
<TableCell>
<div>
<div className="font-medium text-slate-900">{event.name || event.email}</div>
<div className="text-xs text-slate-500">{event.email}</div>
</div>
</TableCell>
<TableCell>{event.plan}</TableCell>
<TableCell>{formatBillingReason(event.reason)}</TableCell>
<TableCell>
{formatStage(event.fromStage)} -&gt; {formatStage(event.toStage)}
</TableCell>
<TableCell>{new Date(event.createdAt).toLocaleString()}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : (
<p className="text-sm text-slate-500">No billing activity logged yet.</p>
)}
</CardContent>
</Card>
<div className="mb-8 grid gap-6 xl:grid-cols-[1.4fr_1fr]">
<Card>
<CardHeader>

View File

@@ -77,7 +77,7 @@ export default function PricingPage() {
const handleDowngrade = async () => {
// Show confirmation dialog
const confirmed = window.confirm(
'Are you sure you want to downgrade to the Free plan? Your subscription will be canceled immediately and you will lose access to premium features.'
'Are you sure you want to cancel your paid plan? You will keep premium features until the end of your current billing period.'
);
if (!confirmed) {
@@ -99,7 +99,7 @@ export default function PricingPage() {
throw new Error(error.error || 'Failed to cancel subscription');
}
showToast('Successfully downgraded to Free plan', 'success');
showToast('Subscription will end at the end of your current billing period.', 'success');
// Refresh to update the plan
setTimeout(() => {
@@ -148,7 +148,7 @@ export default function PricingPage() {
'Standard QR design templates',
'Download as SVG/PNG',
],
buttonText: currentPlan === 'FREE' ? 'Current Plan' : 'Downgrade to Free',
buttonText: currentPlan === 'FREE' ? 'Current Plan' : 'Cancel paid plan',
buttonVariant: 'outline' as const,
disabled: currentPlan === 'FREE',
popular: false,

View File

@@ -293,6 +293,32 @@ export async function GET(request: NextRequest) {
},
});
const recentBillingLogs = await db.userLifecycleLog.findMany({
where: {
reason: {
startsWith: 'subscription_',
},
},
orderBy: {
createdAt: 'desc',
},
take: 10,
select: {
fromStage: true,
toStage: true,
reason: true,
createdAt: true,
user: {
select: {
id: true,
name: true,
email: true,
plan: true,
},
},
},
});
const users: HydratedUser[] = rawUsers.map((user) => {
const metrics = getMetricSnapshot(user.qrCodes);
@@ -371,6 +397,17 @@ export async function GET(request: NextRequest) {
.sort((a, b) => b.leadScore - a.leadScore)
.slice(0, 25);
const recentBillingActivity = recentBillingLogs.map((log) => ({
userId: log.user.id,
name: log.user.name,
email: log.user.email,
plan: log.user.plan,
reason: log.reason,
fromStage: log.fromStage,
toStage: log.toStage,
createdAt: log.createdAt.toISOString(),
}));
const filterOptions = {
stages: ['cold', 'activated', 'warm', 'hot', 'upgrade_candidate', 'paid'],
sources: Array.from(new Set(users.map((user) => user.signupSource).filter((value): value is string => Boolean(value)))),
@@ -389,6 +426,7 @@ export async function GET(request: NextRequest) {
mismatchCount,
activatedUsers: funnel.activated,
paidUsers: lifecycleSummary.paid,
recentBillingEvents: recentBillingActivity.length,
},
acquisition: {
bySource: acquisitionBySource,
@@ -403,6 +441,7 @@ export async function GET(request: NextRequest) {
byTeamSize: buildGroupedRows(users, 'teamSizeBucket').map((row) => ({ ...row, label: getTeamSizeLabel(row.key) })),
},
lifecycleSummary,
recentBillingActivity,
campaignSourceQuality: acquisitionBySource,
upgradeCandidates,
filterOptions,

View File

@@ -34,13 +34,14 @@ export async function POST(request: NextRequest) {
}
// Get user with subscription info
const user = await db.user.findUnique({
where: { id: userId },
select: {
stripeSubscriptionId: true,
plan: true,
},
});
const user = await db.user.findUnique({
where: { id: userId },
select: {
stripeSubscriptionId: true,
stripeCurrentPeriodEnd: true,
plan: true,
},
});
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
@@ -62,27 +63,34 @@ export async function POST(request: NextRequest) {
stripeCurrentPeriodEnd: null,
},
});
await scoreUserLifecycle(userId, 'subscription_changed');
await scoreUserLifecycle(userId, 'subscription_deleted');
return NextResponse.json({ success: true });
}
// Cancel the Stripe subscription
await stripe.subscriptions.cancel(user.stripeSubscriptionId);
// Update user plan to FREE
// Schedule cancellation at the end of the paid period so paid features stay active.
const subscription: any = await stripe.subscriptions.update(user.stripeSubscriptionId, {
cancel_at_period_end: true,
});
const periodEndTimestamp = subscription.current_period_end
|| subscription.currentPeriodEnd
|| subscription.billing_cycle_anchor;
const currentPeriodEnd = periodEndTimestamp
? new Date(periodEndTimestamp * 1000)
: user.stripeCurrentPeriodEnd;
// Keep the paid plan locally until Stripe sends customer.subscription.deleted.
await db.user.update({
where: { id: userId },
data: {
plan: 'FREE',
stripeSubscriptionId: null,
stripePriceId: null,
stripeCurrentPeriodEnd: null,
stripeCurrentPeriodEnd: currentPeriodEnd,
},
});
await scoreUserLifecycle(userId, 'subscription_changed');
await scoreUserLifecycle(userId, 'subscription_canceled_at_period_end');
return NextResponse.json({ success: true });
return NextResponse.json({ success: true, currentPeriodEnd });
} catch (error) {
console.error('Error canceling subscription:', error);
return NextResponse.json(

View File

@@ -48,7 +48,7 @@ export async function POST(request: NextRequest) {
},
});
await scoreUserLifecycle(user.id, 'subscription_changed');
await scoreUserLifecycle(user.id, 'subscription_deleted');
return NextResponse.json({
success: true,
@@ -100,7 +100,7 @@ export async function POST(request: NextRequest) {
},
});
await scoreUserLifecycle(user.id, 'subscription_changed');
await scoreUserLifecycle(user.id, 'subscription_synced');
return NextResponse.json({
success: true,

View File

@@ -73,7 +73,7 @@ export async function POST(request: NextRequest) {
},
});
await scoreUserLifecycle(user.id, 'subscription_changed');
await scoreUserLifecycle(user.id, 'subscription_created');
return NextResponse.json({
success: true,

View File

@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { headers } from 'next/headers';
import { stripe } from '@/lib/stripe';
import { NextRequest, NextResponse } from 'next/server';
import { headers } from 'next/headers';
import { getPlanFromStripePriceId, stripe } from '@/lib/stripe';
import { db } from '@/lib/db';
import Stripe from 'stripe';
import { sendConversionEvent } from '@/lib/metaConversions';
@@ -63,7 +63,7 @@ export async function POST(request: NextRequest) {
},
});
await scoreUserLifecycle(updatedUser.id, 'subscription_changed');
await scoreUserLifecycle(updatedUser.id, 'subscription_created');
// Meta CAPI — Purchase event
const amountCents = session.amount_total ?? 0;
@@ -98,10 +98,11 @@ export async function POST(request: NextRequest) {
await db.user.update({
where: {
stripeSubscriptionId: subscription.id,
},
data: {
stripePriceId: subscription.items.data[0].price.id,
stripeCurrentPeriodEnd: currentPeriodEnd,
},
data: {
stripePriceId: subscription.items.data[0].price.id,
stripeCurrentPeriodEnd: currentPeriodEnd,
plan: getPlanFromStripePriceId(subscription.items.data[0].price.id) ?? undefined,
},
});
const updated = await db.user.findUnique({
@@ -109,7 +110,10 @@ export async function POST(request: NextRequest) {
select: { id: true },
});
if (updated?.id) {
await scoreUserLifecycle(updated.id, 'subscription_changed');
await scoreUserLifecycle(
updated.id,
subscription.cancel_at_period_end ? 'subscription_canceled_at_period_end' : 'subscription_updated'
);
}
break;
}
@@ -129,7 +133,7 @@ export async function POST(request: NextRequest) {
},
});
await scoreUserLifecycle(updatedUser.id, 'subscription_changed');
await scoreUserLifecycle(updatedUser.id, 'subscription_deleted');
break;
}
}