Pro/business
This commit is contained in:
163
manual-billing-sql.txt
Normal file
163
manual-billing-sql.txt
Normal 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;
|
||||||
@@ -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)} -> {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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user