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 {
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;
}
}

View File

@@ -12,7 +12,12 @@ type ScoreReason =
| 'onboarding_update'
| 'qr_created'
| 'scan_recorded'
| 'subscription_changed';
| 'subscription_changed'
| 'subscription_created'
| 'subscription_updated'
| 'subscription_canceled_at_period_end'
| 'subscription_deleted'
| 'subscription_synced';
type UserForScoring = {
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({
data: {
userId,

View File

@@ -20,7 +20,7 @@ export function validateStripeKey() {
}
}
export const STRIPE_PLANS = {
export const STRIPE_PLANS = {
FREE: {
name: 'Free / Starter',
price: 0,
@@ -80,6 +80,28 @@ export const STRIPE_PLANS = {
priceId: process.env.STRIPE_PRICE_ID_BUSINESS_MONTHLY,
priceIdYearly: process.env.STRIPE_PRICE_ID_BUSINESS_YEARLY,
},
} as const;
export type PlanType = keyof typeof STRIPE_PLANS;
} as const;
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;
}