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

163
manual-billing-sql.txt Normal file
View File

@@ -0,0 +1,163 @@
Manual billing SQL for QR Master
================================
Use these statements for manual plan changes in the database.
Replace USER_EMAIL_HERE before running.
Do not use a naked UPDATE like:
UPDATE "User" SET "plan" = 'PRO' WHERE email = '...';
That changes the plan but creates no billing log. Always use the matching full
transaction below so UserLifecycleLog gets the alert entry.
Upgrade user to PRO
===================
BEGIN;
WITH old_user AS (
SELECT *
FROM "User"
WHERE email = 'USER_EMAIL_HERE'
FOR UPDATE
),
updated_user AS (
UPDATE "User"
SET
"plan" = 'PRO',
"lifecycleStage" = 'paid',
"lastScoredAt" = NOW(),
"lastQualifiedAt" = NOW(),
"updatedAt" = NOW()
WHERE "id" = (SELECT "id" FROM old_user)
RETURNING *
)
INSERT INTO "UserLifecycleLog" (
"id",
"userId",
"fromStage",
"toStage",
"fitScore",
"intentScore",
"leadScore",
"reason",
"createdAt"
)
SELECT
CONCAT('manual_', MD5(updated_user."id" || clock_timestamp()::TEXT || random()::TEXT)),
updated_user."id",
old_user."lifecycleStage",
'paid',
updated_user."fitScore",
updated_user."intentScore",
updated_user."leadScore",
'subscription_created',
NOW()
FROM updated_user
JOIN old_user ON old_user."id" = updated_user."id";
COMMIT;
Upgrade user to BUSINESS
========================
BEGIN;
WITH old_user AS (
SELECT *
FROM "User"
WHERE email = 'USER_EMAIL_HERE'
FOR UPDATE
),
updated_user AS (
UPDATE "User"
SET
"plan" = 'BUSINESS',
"lifecycleStage" = 'paid',
"lastScoredAt" = NOW(),
"lastQualifiedAt" = NOW(),
"updatedAt" = NOW()
WHERE "id" = (SELECT "id" FROM old_user)
RETURNING *
)
INSERT INTO "UserLifecycleLog" (
"id",
"userId",
"fromStage",
"toStage",
"fitScore",
"intentScore",
"leadScore",
"reason",
"createdAt"
)
SELECT
CONCAT('manual_', MD5(updated_user."id" || clock_timestamp()::TEXT || random()::TEXT)),
updated_user."id",
old_user."lifecycleStage",
'paid',
updated_user."fitScore",
updated_user."intentScore",
updated_user."leadScore",
'subscription_created',
NOW()
FROM updated_user
JOIN old_user ON old_user."id" = updated_user."id";
COMMIT;
Set user to FREE after deabo / subscription ended
=================================================
BEGIN;
WITH old_user AS (
SELECT *
FROM "User"
WHERE email = 'USER_EMAIL_HERE'
FOR UPDATE
),
updated_user AS (
UPDATE "User"
SET
"plan" = 'FREE',
"stripeSubscriptionId" = NULL,
"stripePriceId" = NULL,
"stripeCurrentPeriodEnd" = NULL,
"lifecycleStage" = CASE
WHEN COALESCE("leadScore", 0) >= 70 THEN 'upgrade_candidate'
WHEN COALESCE("leadScore", 0) >= 55 THEN 'hot'
WHEN COALESCE("leadScore", 0) >= 30 THEN 'warm'
WHEN "activationAt" IS NOT NULL THEN 'activated'
ELSE 'cold'
END,
"lastScoredAt" = NOW(),
"updatedAt" = NOW()
WHERE "id" = (SELECT "id" FROM old_user)
RETURNING *
)
INSERT INTO "UserLifecycleLog" (
"id",
"userId",
"fromStage",
"toStage",
"fitScore",
"intentScore",
"leadScore",
"reason",
"createdAt"
)
SELECT
CONCAT('manual_', MD5(updated_user."id" || clock_timestamp()::TEXT || random()::TEXT)),
updated_user."id",
old_user."lifecycleStage",
updated_user."lifecycleStage",
updated_user."fitScore",
updated_user."intentScore",
updated_user."leadScore",
'subscription_deleted',
NOW()
FROM updated_user
JOIN old_user ON old_user."id" = updated_user."id";
COMMIT;

View File

@@ -17,6 +17,7 @@ import {
import { import {
BarChart3, BarChart3,
Copy, Copy,
CreditCard,
Download, Download,
Filter, Filter,
Loader2, Loader2,
@@ -68,6 +69,12 @@ type SegmentRow = {
qrCount: number; qrCount: number;
dynamicQrCount: number; dynamicQrCount: number;
scanCount: number; scanCount: number;
lifecycleLogs: Array<{
fromStage: string | null;
toStage: string;
reason: string | null;
createdAt: string;
}>;
}; };
type DashboardData = { type DashboardData = {
@@ -76,6 +83,7 @@ type DashboardData = {
mismatchCount: number; mismatchCount: number;
activatedUsers: number; activatedUsers: number;
paidUsers: number; paidUsers: number;
recentBillingEvents: number;
}; };
acquisition: { acquisition: {
bySource: Array<{ bySource: Array<{
@@ -119,6 +127,16 @@ type DashboardData = {
byTeamSize: Array<any>; byTeamSize: Array<any>;
}; };
lifecycleSummary: Record<string, number>; 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>; campaignSourceQuality: Array<any>;
upgradeCandidates: Array<SegmentRow>; upgradeCandidates: Array<SegmentRow>;
filterOptions: { filterOptions: {
@@ -184,6 +202,27 @@ function buildQuery(filters: Filters) {
return params.toString(); 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({ function LifecycleCard({
label, label,
value, value,
@@ -475,6 +514,50 @@ export default function NewsletterClient() {
</Card> </Card>
</div> </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]"> <div className="mb-8 grid gap-6 xl:grid-cols-[1.4fr_1fr]">
<Card> <Card>
<CardHeader> <CardHeader>

View File

@@ -77,7 +77,7 @@ export default function PricingPage() {
const handleDowngrade = async () => { const handleDowngrade = async () => {
// Show confirmation dialog // Show confirmation dialog
const confirmed = window.confirm( 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) { if (!confirmed) {
@@ -99,7 +99,7 @@ export default function PricingPage() {
throw new Error(error.error || 'Failed to cancel subscription'); 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 // Refresh to update the plan
setTimeout(() => { setTimeout(() => {
@@ -148,7 +148,7 @@ export default function PricingPage() {
'Standard QR design templates', 'Standard QR design templates',
'Download as SVG/PNG', 'Download as SVG/PNG',
], ],
buttonText: currentPlan === 'FREE' ? 'Current Plan' : 'Downgrade to Free', buttonText: currentPlan === 'FREE' ? 'Current Plan' : 'Cancel paid plan',
buttonVariant: 'outline' as const, buttonVariant: 'outline' as const,
disabled: currentPlan === 'FREE', disabled: currentPlan === 'FREE',
popular: false, 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 users: HydratedUser[] = rawUsers.map((user) => {
const metrics = getMetricSnapshot(user.qrCodes); const metrics = getMetricSnapshot(user.qrCodes);
@@ -371,6 +397,17 @@ export async function GET(request: NextRequest) {
.sort((a, b) => b.leadScore - a.leadScore) .sort((a, b) => b.leadScore - a.leadScore)
.slice(0, 25); .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 = { const filterOptions = {
stages: ['cold', 'activated', 'warm', 'hot', 'upgrade_candidate', 'paid'], 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)))), 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, mismatchCount,
activatedUsers: funnel.activated, activatedUsers: funnel.activated,
paidUsers: lifecycleSummary.paid, paidUsers: lifecycleSummary.paid,
recentBillingEvents: recentBillingActivity.length,
}, },
acquisition: { acquisition: {
bySource: acquisitionBySource, bySource: acquisitionBySource,
@@ -403,6 +441,7 @@ export async function GET(request: NextRequest) {
byTeamSize: buildGroupedRows(users, 'teamSizeBucket').map((row) => ({ ...row, label: getTeamSizeLabel(row.key) })), byTeamSize: buildGroupedRows(users, 'teamSizeBucket').map((row) => ({ ...row, label: getTeamSizeLabel(row.key) })),
}, },
lifecycleSummary, lifecycleSummary,
recentBillingActivity,
campaignSourceQuality: acquisitionBySource, campaignSourceQuality: acquisitionBySource,
upgradeCandidates, upgradeCandidates,
filterOptions, filterOptions,

View File

@@ -38,6 +38,7 @@ export async function POST(request: NextRequest) {
where: { id: userId }, where: { id: userId },
select: { select: {
stripeSubscriptionId: true, stripeSubscriptionId: true,
stripeCurrentPeriodEnd: true,
plan: true, plan: true,
}, },
}); });
@@ -62,27 +63,34 @@ export async function POST(request: NextRequest) {
stripeCurrentPeriodEnd: null, stripeCurrentPeriodEnd: null,
}, },
}); });
await scoreUserLifecycle(userId, 'subscription_changed'); await scoreUserLifecycle(userId, 'subscription_deleted');
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });
} }
// Cancel the Stripe subscription // Schedule cancellation at the end of the paid period so paid features stay active.
await stripe.subscriptions.cancel(user.stripeSubscriptionId); const subscription: any = await stripe.subscriptions.update(user.stripeSubscriptionId, {
cancel_at_period_end: true,
});
// Update user plan to FREE 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({ await db.user.update({
where: { id: userId }, where: { id: userId },
data: { data: {
plan: 'FREE', stripeCurrentPeriodEnd: currentPeriodEnd,
stripeSubscriptionId: null,
stripePriceId: null,
stripeCurrentPeriodEnd: null,
}, },
}); });
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) { } catch (error) {
console.error('Error canceling subscription:', error); console.error('Error canceling subscription:', error);
return NextResponse.json( 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({ return NextResponse.json({
success: true, 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({ return NextResponse.json({
success: true, 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({ return NextResponse.json({
success: true, success: true,

View File

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

View File

@@ -12,7 +12,12 @@ type ScoreReason =
| 'onboarding_update' | 'onboarding_update'
| 'qr_created' | 'qr_created'
| 'scan_recorded' | 'scan_recorded'
| 'subscription_changed'; | 'subscription_changed'
| 'subscription_created'
| 'subscription_updated'
| 'subscription_canceled_at_period_end'
| 'subscription_deleted'
| 'subscription_synced';
type UserForScoring = { type UserForScoring = {
id: string; id: string;
@@ -159,7 +164,27 @@ export async function scoreUserLifecycle(userId: string, reason: ScoreReason) {
}, },
}); });
if (user.lifecycleStage !== nextStage) { const isSubscriptionReason = reason.startsWith('subscription_');
const recentSubscriptionLog = isSubscriptionReason
? await db.userLifecycleLog.findFirst({
where: {
userId,
reason,
createdAt: {
gte: new Date(Date.now() - 10 * 60 * 1000),
},
},
select: {
id: true,
},
})
: null;
const shouldLogLifecycleEvent =
user.lifecycleStage !== nextStage ||
(isSubscriptionReason && !recentSubscriptionLog);
if (shouldLogLifecycleEvent) {
await db.userLifecycleLog.create({ await db.userLifecycleLog.create({
data: { data: {
userId, userId,

View File

@@ -83,3 +83,25 @@ export const STRIPE_PLANS = {
} as const; } as const;
export type PlanType = keyof typeof STRIPE_PLANS; export type PlanType = keyof typeof STRIPE_PLANS;
export function getPlanFromStripePriceId(priceId?: string | null): Exclude<PlanType, 'FREE'> | null {
if (!priceId) {
return null;
}
if (
priceId === process.env.STRIPE_PRICE_ID_BUSINESS_MONTHLY ||
priceId === process.env.STRIPE_PRICE_ID_BUSINESS_YEARLY
) {
return 'BUSINESS';
}
if (
priceId === process.env.STRIPE_PRICE_ID_PRO_MONTHLY ||
priceId === process.env.STRIPE_PRICE_ID_PRO_YEARLY
) {
return 'PRO';
}
return null;
}