From 91bd940edc3aa8b79ef6bdd33e91723120de4ac4 Mon Sep 17 00:00:00 2001 From: Timo Knuth Date: Mon, 6 Jul 2026 00:18:06 +0200 Subject: [PATCH] Pro/business --- manual-billing-sql.txt | 163 ++++++++++++++++++ .../newsletter/NewsletterClient.tsx | 83 +++++++++ .../(marketing)/pricing/PricingClient.tsx | 6 +- src/app/(main)/api/admin/revops/route.ts | 39 +++++ .../api/stripe/cancel-subscription/route.ts | 44 +++-- .../api/stripe/sync-subscription/route.ts | 4 +- .../(main)/api/stripe/verify-session/route.ts | 2 +- src/app/(main)/api/stripe/webhook/route.ts | 24 +-- src/lib/revops-server.ts | 29 +++- src/lib/stripe.ts | 30 +++- 10 files changed, 384 insertions(+), 40 deletions(-) create mode 100644 manual-billing-sql.txt diff --git a/manual-billing-sql.txt b/manual-billing-sql.txt new file mode 100644 index 0000000..fdb7673 --- /dev/null +++ b/manual-billing-sql.txt @@ -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; diff --git a/src/app/(main)/(marketing)/newsletter/NewsletterClient.tsx b/src/app/(main)/(marketing)/newsletter/NewsletterClient.tsx index f73c57a..669cd9d 100644 --- a/src/app/(main)/(marketing)/newsletter/NewsletterClient.tsx +++ b/src/app/(main)/(marketing)/newsletter/NewsletterClient.tsx @@ -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; }; lifecycleSummary: Record; + recentBillingActivity: Array<{ + userId: string; + name: string | null; + email: string; + plan: string; + reason: string | null; + fromStage: string | null; + toStage: string; + createdAt: string; + }>; campaignSourceQuality: Array; upgradeCandidates: Array; 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() { + + +
+ + Recent Billing Activity +
+
+ + {data.recentBillingActivity.length > 0 ? ( + + + + User + Plan + Action + Lifecycle change + Date + + + + {data.recentBillingActivity.map((event) => ( + + +
+
{event.name || event.email}
+
{event.email}
+
+
+ {event.plan} + {formatBillingReason(event.reason)} + + {formatStage(event.fromStage)} -> {formatStage(event.toStage)} + + {new Date(event.createdAt).toLocaleString()} +
+ ))} +
+
+ ) : ( +

No billing activity logged yet.

+ )} +
+
+
diff --git a/src/app/(main)/(marketing)/pricing/PricingClient.tsx b/src/app/(main)/(marketing)/pricing/PricingClient.tsx index c73a50e..dd51072 100644 --- a/src/app/(main)/(marketing)/pricing/PricingClient.tsx +++ b/src/app/(main)/(marketing)/pricing/PricingClient.tsx @@ -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, diff --git a/src/app/(main)/api/admin/revops/route.ts b/src/app/(main)/api/admin/revops/route.ts index c258bae..0d231b0 100644 --- a/src/app/(main)/api/admin/revops/route.ts +++ b/src/app/(main)/api/admin/revops/route.ts @@ -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, diff --git a/src/app/(main)/api/stripe/cancel-subscription/route.ts b/src/app/(main)/api/stripe/cancel-subscription/route.ts index c98df2f..ca3f91c 100644 --- a/src/app/(main)/api/stripe/cancel-subscription/route.ts +++ b/src/app/(main)/api/stripe/cancel-subscription/route.ts @@ -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( diff --git a/src/app/(main)/api/stripe/sync-subscription/route.ts b/src/app/(main)/api/stripe/sync-subscription/route.ts index 068c279..c78a9d7 100644 --- a/src/app/(main)/api/stripe/sync-subscription/route.ts +++ b/src/app/(main)/api/stripe/sync-subscription/route.ts @@ -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, diff --git a/src/app/(main)/api/stripe/verify-session/route.ts b/src/app/(main)/api/stripe/verify-session/route.ts index 81063d8..a11deba 100644 --- a/src/app/(main)/api/stripe/verify-session/route.ts +++ b/src/app/(main)/api/stripe/verify-session/route.ts @@ -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, diff --git a/src/app/(main)/api/stripe/webhook/route.ts b/src/app/(main)/api/stripe/webhook/route.ts index 8660322..ae6883c 100644 --- a/src/app/(main)/api/stripe/webhook/route.ts +++ b/src/app/(main)/api/stripe/webhook/route.ts @@ -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; } } diff --git a/src/lib/revops-server.ts b/src/lib/revops-server.ts index d107834..28c956c 100644 --- a/src/lib/revops-server.ts +++ b/src/lib/revops-server.ts @@ -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, diff --git a/src/lib/stripe.ts b/src/lib/stripe.ts index d272c4b..67891f9 100644 --- a/src/lib/stripe.ts +++ b/src/lib/stripe.ts @@ -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 | 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; +}