feat(ee): add native billing flow

This commit is contained in:
Marc Seitz
2025-08-06 18:55:13 +02:00
parent 114e27cde0
commit a38401218a
48 changed files with 3056 additions and 1585 deletions

View File

@@ -1,12 +1,15 @@
import Link from "next/link";
import { useRouter } from "next/router";
import { useState } from "react";
import { useTeam } from "@/context/team-context";
import { CancellationModal } from "@/ee/features/cancellation/components";
import { CancellationModal } from "@/ee/features/billing/cancellation/components";
import { PlanEnum } from "@/ee/stripe/constants";
import { CreditCardIcon, MoreVertical, ReceiptTextIcon } from "lucide-react";
import { toast } from "sonner";
import { mutate } from "swr";
import { useAnalytics } from "@/lib/analytics";
import { usePlan } from "@/lib/swr/use-billing";
import { Button } from "@/components/ui/button";
@@ -18,28 +21,55 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { UpgradeButton } from "@/components/ui/upgrade-button";
export default function UpgradePlanContainer() {
const router = useRouter();
const [loading, setLoading] = useState<boolean>(false);
const [unpauseLoading, setUnpauseLoading] = useState<boolean>(false);
const [cancellationModalOpen, setCancellationModalOpen] =
useState<boolean>(false);
const teamInfo = useTeam();
const { plan, isFree, isDataroomsPlus } = usePlan();
const { currentTeamId } = useTeam();
const {
plan,
isFree,
isDataroomsPlus,
isPaused,
isCancelled,
isCustomer,
startsAt,
endsAt,
discount,
mutate: mutatePlan,
} = usePlan({ withDiscount: true });
const analytics = useAnalytics();
const manageSubscription = async () => {
if (!teamInfo?.currentTeam?.id) {
return;
}
const teamId = teamInfo?.currentTeam?.id;
const manageSubscription = async ({
type,
}: {
type:
| "manage"
| "invoices"
| "subscription_update"
| "payment_method_update";
}) => {
if (!currentTeamId) return;
setLoading(true);
try {
fetch(`/api/teams/${teamId}/billing/manage`, {
fetch(`/api/teams/${currentTeamId}/billing/manage`, {
method: "POST",
body: JSON.stringify({ type }),
headers: {
"Content-Type": "application/json",
},
})
.then(async (res) => {
const url = await res.json();
@@ -58,31 +88,192 @@ export default function UpgradePlanContainer() {
}
};
const handleUnpauseSubscription = async () => {
if (!currentTeamId) return;
setUnpauseLoading(true);
try {
const response = await fetch(
`/api/teams/${currentTeamId}/billing/unpause`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
},
);
if (!response.ok) {
throw new Error("Failed to unpause subscription");
}
// Track the unpause event for analytics
analytics.capture("Subscription Unpaused", {
teamId: currentTeamId,
plan: plan,
});
toast.success("Subscription unpaused successfully!");
mutate(`/api/teams/${currentTeamId}/billing/plan`);
mutate(`/api/teams/${currentTeamId}/billing/plan?withDiscount=true`);
} catch (error) {
console.error(error);
} finally {
setUnpauseLoading(false);
}
};
const handleReactivateSubscription = async () => {
if (!currentTeamId) return;
setUnpauseLoading(true);
try {
const response = await fetch(
`/api/teams/${currentTeamId}/billing/reactivate`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
},
);
if (!response.ok) {
throw new Error("Failed to reactivate subscription");
}
// Track the reactivation event for analytics
analytics.capture("Subscription Reactivated", {
teamId: currentTeamId,
plan: plan,
});
toast.success("Subscription reactivated successfully!");
mutate(`/api/teams/${currentTeamId}/billing/plan`);
mutate(`/api/teams/${currentTeamId}/billing/plan?withDiscount=true`);
} catch (error) {
console.error(error);
} finally {
setUnpauseLoading(false);
}
};
const isBillingCycleCurrent = () => {
if (!startsAt || !endsAt) return false;
const currentDate = new Date();
return currentDate >= new Date(startsAt) && currentDate <= new Date(endsAt);
};
const getDiscountText = () => {
if (!discount || !discount.valid) return null;
let discountText = "";
if (discount.percentOff) {
discountText = `${discount.percentOff}% off`;
} else if (discount.amountOff) {
discountText = `$${(discount.amountOff / 100).toFixed(2)} off`;
}
if (discount.duration === "repeating" && discount.durationInMonths) {
discountText += ` for ${discount.durationInMonths} month${discount.durationInMonths > 1 ? "s" : ""}`;
} else if (discount.duration === "once") {
discountText += " (one-time)";
}
return discountText;
};
const ButtonList = () => {
if (isFree) {
return (
<UpgradeButton
text=""
customText="Update plan"
clickedPlan={PlanEnum.Business}
trigger="upgrade_plan"
useModal={false}
onClick={() => router.push("/settings/upgrade")}
/>
<div className="flex items-center gap-3">
<UpgradeButton
text=""
customText="Upgrade"
clickedPlan={PlanEnum.Business}
trigger="upgrade_plan"
useModal={false}
onClick={() => router.push("/settings/upgrade")}
/>
{isCustomer && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-9 w-9 p-0">
<MoreVertical className="h-4 w-4" />
<span className="sr-only">More options</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => manageSubscription({ type: "invoices" })}
>
<ReceiptTextIcon className="h-4 w-4" />
View invoices
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
);
} else if (isCancelled) {
return (
<Button onClick={handleReactivateSubscription} loading={unpauseLoading}>
Reactivate subscription
</Button>
);
} else {
return (
<div className="flex items-center gap-3">
<Button
variant="outline"
onClick={() => setCancellationModalOpen(true)}
className="text-muted-foreground hover:text-foreground"
>
Cancel subscription
</Button>
<Button onClick={manageSubscription} loading={loading}>
Manage Subscription
</Button>
{isPaused ? (
<Button
onClick={handleUnpauseSubscription}
loading={unpauseLoading}
>
Unpause subscription
</Button>
) : (
<>
<Button
variant="outline"
onClick={() => setCancellationModalOpen(true)}
>
Cancel subscription
</Button>
<Button
variant="outline"
onClick={() =>
manageSubscription({ type: "subscription_update" })
}
loading={loading}
>
Change plan
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-9 w-9 p-0">
<MoreVertical className="h-4 w-4" />
<span className="sr-only">More options</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => manageSubscription({ type: "manage" })}
>
<CreditCardIcon className="h-4 w-4" />
Change billing information
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => manageSubscription({ type: "invoices" })}
>
<ReceiptTextIcon className="h-4 w-4" />
View invoices
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
)}
</div>
);
}
@@ -93,22 +284,67 @@ export default function UpgradePlanContainer() {
<div className="rounded-lg">
<Card className="bg-transparent">
<CardHeader>
<CardTitle>Billing</CardTitle>
<CardDescription>
You are currently on the{" "}
<span className="mx-0.5 rounded-full bg-background px-2.5 py-1 text-xs font-bold tracking-normal text-foreground ring-1 ring-gray-800 dark:ring-gray-400">
{isDataroomsPlus
? "Datarooms+"
: plan.charAt(0).toUpperCase() + plan.slice(1)}
</span>{" "}
plan.{" "}
<Link
href="/settings/upgrade"
className="text-sm underline underline-offset-4 hover:text-foreground"
>
See all plans
</Link>
</CardDescription>
<CardTitle>
{isDataroomsPlus
? "Datarooms+"
: plan.charAt(0).toUpperCase() + plan.slice(1)}{" "}
Plan
</CardTitle>
{!isCancelled && startsAt && endsAt && isBillingCycleCurrent() && (
<CardDescription>
<span className="font-medium text-foreground">
Current billing cycle:{" "}
</span>
<span className="text-foreground">
{new Date(startsAt).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})}
{" - "}
{new Date(endsAt).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})}
</span>
</CardDescription>
)}
{isPaused && endsAt && (
<CardDescription>
<span className="font-medium text-foreground">
Subscription will pause on:{" "}
</span>
<span className="text-foreground">
{new Date(endsAt).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})}
</span>
</CardDescription>
)}
{isCancelled && endsAt && (
<CardDescription>
<span className="font-medium text-foreground">
Subscription cancels on:{" "}
</span>
<span className="text-foreground">
{new Date(endsAt).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})}
</span>
</CardDescription>
)}
{discount && discount.valid && getDiscountText() && (
<CardDescription>
<div className="inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-600/20 dark:bg-green-400/10 dark:text-green-400 dark:ring-green-400/30">
🎉 {getDiscountText()} applied
</div>
</CardDescription>
)}
</CardHeader>
<CardContent></CardContent>
<CardFooter className="flex items-center justify-end rounded-b-lg border-t px-6 py-3">

View File

@@ -0,0 +1,141 @@
import {
Body,
Container,
Head,
Heading,
Html,
Img,
Link,
Preview,
Section,
Tailwind,
Text,
} from "@react-email/components";
interface PauseResumeReminderEmailProps {
teamName?: string;
userName?: string;
resumeDate?: string;
plan?: string;
userRole?: string;
}
const baseUrl =
process.env.NEXT_PUBLIC_MARKETING_URL || "https://www.papermark.com";
export default function PauseResumeReminderEmail({
teamName = "Your Team",
userName = "Team Member",
resumeDate = "March 15, 2024",
plan = "Pro",
userRole = "Admin",
}: PauseResumeReminderEmailProps) {
const previewText = `Your ${teamName} subscription will resume billing in 3 days`;
return (
<Html>
<Head />
<Preview>{previewText}</Preview>
<Tailwind>
<Body className="mx-auto my-auto bg-white font-sans">
<Container className="mx-auto my-[40px] w-[465px] rounded border border-solid border-[#eaeaea] p-[20px]">
<Section className="mt-[32px]">
<Img
src={`${baseUrl}/_static/papermark-logo.png`}
width="160"
height="48"
alt="Papermark"
className="mx-auto my-0"
/>
</Section>
<Heading className="mx-0 my-[30px] p-0 text-center text-[24px] font-normal text-black">
Subscription Resume Reminder
</Heading>
<Text className="text-[14px] leading-[24px] text-black">
Hello {userName},
</Text>
<Text className="text-[14px] leading-[24px] text-black">
This is a friendly reminder that your <strong>{teamName}</strong>{" "}
team's paused subscription will automatically resume billing in{" "}
<strong>3 days</strong>.
</Text>
<Section className="my-[32px] rounded-lg border border-solid border-[#e5e7eb] bg-[#f9fafb] p-[24px]">
<Text className="m-0 text-[14px] font-semibold leading-[24px] text-black">
📅 Resume Details:
</Text>
<Text className="mb-0 mt-[12px] text-[14px] leading-[20px] text-[#6b7280]">
<strong>Team:</strong> {teamName}
<br />
<strong>Plan:</strong> {plan}
<br />
<strong>Resume Date:</strong> {resumeDate}
<br />
<strong>Your Role:</strong> {userRole}
</Text>
</Section>
<Text className="text-[14px] leading-[24px] text-black">
<strong>What happens next?</strong>
</Text>
<Text className="text-[14px] leading-[20px] text-black">
• Your subscription will automatically resume on{" "}
<strong>{resumeDate}</strong>
<br />
• Billing will restart at your regular plan rate
<br />
• All features will be fully restored
<br />• Your existing data and links remain unchanged
</Text>
<Text className="text-[14px] leading-[24px] text-black">
<strong>Need to make changes?</strong>
</Text>
<Text className="text-[14px] leading-[24px] text-black">
If you'd like to cancel your subscription instead of resuming, or
need to update your billing information, you can manage your
subscription in your account settings.
</Text>
<Section className="my-[32px] text-center">
<Link
className="rounded bg-[#000000] px-5 py-3 text-center text-[12px] font-semibold text-white no-underline"
href={`${baseUrl}/settings/billing`}
>
Manage Subscription
</Link>
</Section>
<Text className="text-[14px] leading-[24px] text-black">
If you have any questions or concerns, please don't hesitate to
reach out to our support team.
</Text>
<Text className="text-[14px] leading-[24px] text-black">
Best regards,
<br />
The Papermark Team
</Text>
<Section className="mt-[32px] border-t border-solid border-[#eaeaea] pt-[20px]">
<Text className="text-[12px] leading-[16px] text-[#666]">
This email was sent to you as an admin/manager of the {teamName}{" "}
team on Papermark. If you believe this was sent in error, please
contact our support team.
</Text>
<Text className="text-[12px] leading-[16px] text-[#666]">
Papermark - The secure document sharing platform
</Text>
</Section>
</Container>
</Body>
</Tailwind>
</Html>
);
}

View File

@@ -0,0 +1,92 @@
import { NextApiRequest, NextApiResponse } from "next";
import { stripeInstance } from "@/ee/stripe";
import { isOldAccount } from "@/ee/stripe/utils";
import { authOptions } from "@/pages/api/auth/[...nextauth]";
import { waitUntil } from "@vercel/functions";
import { getServerSession } from "next-auth/next";
import prisma from "@/lib/prisma";
import { CustomUser } from "@/lib/types";
import { log } from "@/lib/utils";
export async function handleRoute(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "POST") {
// POST /api/teams/:teamId/billing/cancel cancel a user's subscription
const session = await getServerSession(req, res, authOptions);
if (!session) {
res.status(401).end("Unauthorized");
return;
}
const userId = (session.user as CustomUser).id;
const { teamId } = req.query as { teamId: string };
try {
const team = await prisma.team.findUnique({
where: {
id: teamId,
users: {
some: {
userId: userId,
role: {
in: ["ADMIN", "MANAGER"],
},
},
},
},
select: {
id: true,
stripeId: true,
subscriptionId: true,
plan: true,
},
});
if (!team) {
return res.status(400).json({ error: "Team does not exist" });
}
if (!team.stripeId) {
return res.status(400).json({ error: "No Stripe customer ID" });
}
if (!team.subscriptionId) {
return res.status(400).json({ error: "No subscription ID" });
}
const stripe = stripeInstance(isOldAccount(team.plan));
waitUntil(
Promise.all([
stripe.subscriptions.update(team.subscriptionId, {
cancel_at_period_end: true,
}),
stripe.subscriptions.deleteDiscount(team.subscriptionId),
prisma.team.update({
where: { id: teamId },
data: {
cancelledAt: new Date(),
},
}),
log({
message: `Team ${teamId} cancelled their subscription.`,
type: "info",
}),
]),
);
return res.status(200).json({ success: true });
} catch (error) {
console.error("Error cancelling subscription:", error);
await log({
message: `Error cancelling subscription for team ${teamId}: ${error}`,
type: "error",
});
res.status(500).json({ error: "Failed to cancel subscription" });
}
} else {
res.setHeader("Allow", ["POST"]);
res.status(405).end(`Method ${req.method} Not Allowed`);
}
}

View File

@@ -0,0 +1,114 @@
import { NextApiRequest, NextApiResponse } from "next";
import { CancellationReason } from "@/ee/features/billing/cancellation/lib/constants";
import { stripeInstance } from "@/ee/stripe";
import { isOldAccount } from "@/ee/stripe/utils";
import { authOptions } from "@/pages/api/auth/[...nextauth]";
import { waitUntil } from "@vercel/functions";
import { getServerSession } from "next-auth/next";
import prisma from "@/lib/prisma";
import { CustomUser } from "@/lib/types";
import { log } from "@/lib/utils";
export const config = {
// in order to enable `waitUntil` function
supportsResponseStreaming: true,
};
export async function handleRoute(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "POST") {
// POST /api/teams/:teamId/billing/cancellation-feedback submit cancellation feedback
const session = await getServerSession(req, res, authOptions);
if (!session) {
res.status(401).end("Unauthorized");
return;
}
const userId = (session.user as CustomUser).id;
const { teamId } = req.query as { teamId: string };
const { reason, feedback } = req.body as {
reason: CancellationReason;
feedback: string;
};
try {
const team = await prisma.team.findUnique({
where: {
id: teamId,
users: {
some: {
userId: userId,
role: {
in: ["ADMIN", "MANAGER"],
},
},
},
},
select: {
id: true,
stripeId: true,
subscriptionId: true,
plan: true,
},
});
if (!team) {
return res.status(400).json({ error: "Team does not exist" });
}
const reasonLabels: Record<CancellationReason, string> = {
too_expensive: "Too expensive",
unused: "Not used enough",
missing_features: "Missing features",
switched_service: "Switched to another service",
other: "Other reason",
};
// Prepare feedback data for logging and analytics
const feedbackData = {
reason,
reasonLabel: reasonLabels[reason],
feedback: feedback || "",
timestamp: new Date().toISOString(),
};
// Update the subscription cancellation details
if (team.stripeId && team.subscriptionId) {
const stripe = stripeInstance(isOldAccount(team.plan));
waitUntil(
stripe.subscriptions.update(team.subscriptionId, {
cancellation_details: {
feedback: reason,
comment: feedback || "",
},
}),
);
}
// Log to Slack
waitUntil(
log({
message: `💔 **Cancellation Feedback Received**\n\n**Team:** ${teamId} (${team.plan})\n**Reason:** ${reasonLabels[reason]}\n**Feedback:** ${feedback || "No additional feedback provided"}\n\nTime: ${new Date().toLocaleString()}`,
type: "info",
}),
);
return res.status(200).json({
success: true,
feedbackData, // Return this for PostHog tracking in the frontend
});
} catch (error) {
console.error("Error submitting cancellation feedback:", error);
await log({
message: `Error submitting cancellation feedback for team ${teamId}: ${error}`,
type: "error",
});
res.status(500).json({ error: "Failed to submit feedback" });
}
} else {
res.setHeader("Allow", ["POST"]);
res.status(405).end(`Method ${req.method} Not Allowed`);
}
}

View File

@@ -0,0 +1,130 @@
import { NextApiRequest, NextApiResponse } from "next";
import { sendPauseResumeNotificationTask } from "@/ee/features/billing/cancellation/lib/trigger/pause-resume-notification";
import { stripeInstance } from "@/ee/stripe";
import { authOptions } from "@/pages/api/auth/[...nextauth]";
import { waitUntil } from "@vercel/functions";
import { getServerSession } from "next-auth/next";
import prisma from "@/lib/prisma";
import { CustomUser } from "@/lib/types";
import { log } from "@/lib/utils";
export const config = {
// in order to enable `waitUntil` function
supportsResponseStreaming: true,
};
export async function handleRoute(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "POST") {
// POST /api/teams/:teamId/billing/pause pause a user's subscription
const session = await getServerSession(req, res, authOptions);
if (!session) {
res.status(401).end("Unauthorized");
return;
}
const userId = (session.user as CustomUser).id;
const { teamId } = req.query as { teamId: string };
try {
const team = await prisma.team.findUnique({
where: {
id: teamId,
users: {
some: {
userId: userId,
},
},
},
select: {
id: true,
stripeId: true,
subscriptionId: true,
endsAt: true,
plan: true,
limits: true,
},
});
if (!team) {
return res.status(400).json({ error: "Team does not exist" });
}
if (!team.stripeId) {
return res.status(400).json({ error: "No Stripe customer ID" });
}
if (!team.subscriptionId) {
return res.status(400).json({ error: "No subscription ID" });
}
const isOldAccount = team.plan.includes("+old");
const stripe = stripeInstance(isOldAccount);
const pauseStartsAt = team.endsAt ? new Date(team.endsAt) : new Date();
const pauseEndsAt = new Date(pauseStartsAt);
pauseEndsAt.setDate(pauseStartsAt.getDate() + 90);
const reminderAt = new Date(pauseEndsAt);
reminderAt.setDate(pauseEndsAt.getDate() - 3);
// Pause the subscription in Stripe
await stripe.subscriptions.update(team.subscriptionId, {
pause_collection: {
behavior: "void",
resumes_at: pauseEndsAt.getTime() / 1000, // Convert to seconds
},
metadata: {
pause_starts_at: pauseStartsAt.toISOString(),
pause_ends_at: pauseEndsAt.toISOString(),
paused_reason: "user_request",
original_plan: team.plan,
},
});
await prisma.team.update({
where: { id: teamId },
data: {
pausedAt: new Date(),
pauseStartsAt,
pauseEndsAt,
},
});
waitUntil(
Promise.all([
// Schedule the pause resume notifications
sendPauseResumeNotificationTask.trigger(
{ teamId },
{
delay: reminderAt, // 3 days before pause ends
tags: [`team_${teamId}`],
idempotencyKey: `pause-resume-${teamId}-${new Date().getTime()}`,
},
),
// Remove the existing discounts from the subscription
stripe.subscriptions.deleteDiscount(team.subscriptionId),
log({
message: `Team ${teamId} (${team.plan}) paused their subscription for 3 months.`,
type: "info",
}),
]),
);
res.status(200).json({
success: true,
message: "Subscription paused successfully",
});
} catch (error) {
console.error("Error pausing subscription:", error);
await log({
message: `Error pausing subscription for team ${teamId}: ${error}`,
type: "error",
});
res.status(500).json({ error: "Failed to pause subscription" });
}
} else {
res.setHeader("Allow", ["POST"]);
res.status(405).end(`Method ${req.method} Not Allowed`);
}
}

View File

@@ -0,0 +1,95 @@
import { NextApiRequest, NextApiResponse } from "next";
import { stripeInstance } from "@/ee/stripe";
import { isOldAccount } from "@/ee/stripe/utils";
import { authOptions } from "@/pages/api/auth/[...nextauth]";
import { waitUntil } from "@vercel/functions";
import { getServerSession } from "next-auth/next";
import prisma from "@/lib/prisma";
import { CustomUser } from "@/lib/types";
import { log } from "@/lib/utils";
export async function handleRoute(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "POST") {
// POST /api/teams/:teamId/billing/reactivate reactivate a user's subscription
const session = await getServerSession(req, res, authOptions);
if (!session) {
res.status(401).end("Unauthorized");
return;
}
const userId = (session.user as CustomUser).id;
const { teamId } = req.query as { teamId: string };
try {
const team = await prisma.team.findUnique({
where: {
id: teamId,
users: {
some: {
userId: userId,
role: {
in: ["ADMIN", "MANAGER"],
},
},
},
},
select: {
id: true,
stripeId: true,
subscriptionId: true,
plan: true,
},
});
if (!team) {
return res.status(400).json({ error: "Team does not exist" });
}
if (!team.stripeId) {
return res.status(400).json({ error: "No Stripe customer ID" });
}
if (!team.subscriptionId) {
return res.status(400).json({ error: "No subscription ID" });
}
const stripe = stripeInstance(isOldAccount(team.plan));
const subscription = await stripe.subscriptions.update(
team.subscriptionId,
{
cancel_at_period_end: false,
},
);
await prisma.team.update({
where: { id: teamId },
data: {
cancelledAt: null,
pauseStartsAt: null,
},
});
waitUntil(
log({
message: `Team ${teamId} reactivated their subscription.`,
type: "info",
}),
);
return res.status(200).json({ success: true });
} catch (error) {
console.error("Error reactivating subscription:", error);
await log({
message: `Error reactivating subscription for team ${teamId}: ${error}`,
type: "error",
});
res.status(500).json({ error: "Failed to reactivate subscription" });
}
} else {
res.setHeader("Allow", ["POST"]);
res.status(405).end(`Method ${req.method} Not Allowed`);
}
}

View File

@@ -0,0 +1,103 @@
import { NextApiRequest, NextApiResponse } from "next";
import { stripeInstance } from "@/ee/stripe";
import { getCouponFromPlan } from "@/ee/stripe/functions/get-coupon-from-plan";
import { isOldAccount } from "@/ee/stripe/utils";
import { authOptions } from "@/pages/api/auth/[...nextauth]";
import { waitUntil } from "@vercel/functions";
import { getServerSession } from "next-auth/next";
import prisma from "@/lib/prisma";
import { CustomUser } from "@/lib/types";
import { log } from "@/lib/utils";
export async function handleRoute(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "POST") {
// POST /api/teams/:teamId/billing/retention-offer apply retention offer
const session = await getServerSession(req, res, authOptions);
if (!session) {
res.status(401).end("Unauthorized");
return;
}
const userId = (session.user as CustomUser).id;
const { teamId } = req.query as { teamId: string };
try {
const team = await prisma.team.findUnique({
where: {
id: teamId,
users: {
some: {
userId: userId,
},
},
},
select: {
id: true,
stripeId: true,
subscriptionId: true,
plan: true,
pausedAt: true,
startsAt: true,
endsAt: true,
limits: true,
},
});
if (!team) {
return res.status(400).json({ error: "Team does not exist" });
}
if (!team.stripeId) {
return res.status(400).json({ error: "No Stripe customer ID" });
}
if (!team.subscriptionId) {
return res.status(400).json({ error: "No subscription ID" });
}
let isAnnualPlan = false;
if (team?.startsAt && team?.endsAt) {
const durationInDays = Math.round(
(team.endsAt.getTime() - team.startsAt.getTime()) /
(1000 * 60 * 60 * 24),
);
// If duration is more than 31 days, consider it yearly
isAnnualPlan = durationInDays > 31;
}
const stripe = stripeInstance(isOldAccount(team.plan));
const couponId = getCouponFromPlan(team.plan, isAnnualPlan);
await stripe.subscriptions.update(team.subscriptionId, {
discounts: [
{
coupon: couponId,
},
],
});
waitUntil(
log({
message: `Retention offer applied to team ${teamId}: 50% off for ${
isAnnualPlan ? "12 months" : "3 months"
}`,
type: "info",
}),
);
return res.status(200).json({ success: true });
} catch (error) {
console.error("Error applying retention offer:", error);
await log({
message: `Error applying retention offer for team ${teamId}: ${error}`,
type: "error",
});
res.status(500).json({ error: "Failed to apply retention offer" });
}
} else {
res.setHeader("Allow", ["POST"]);
res.status(405).end(`Method ${req.method} Not Allowed`);
}
}

View File

@@ -0,0 +1,129 @@
import { NextApiRequest, NextApiResponse } from "next";
import { sendConversationTeamNotification } from "@/ee/features/conversations/emails/lib/send-conversation-team-notification";
import { getDisplayNameFromPlan } from "@/ee/stripe/functions/get-display-name-from-plan";
import { z } from "zod";
import prisma from "@/lib/prisma";
import { log } from "@/lib/utils";
import { sendEmailPauseResumeReminder } from "../emails/lib/send-email-pause-resume-reminder";
export default async function handle(
req: NextApiRequest,
res: NextApiResponse,
) {
// We only allow POST requests
if (req.method !== "POST") {
res.status(405).json({ message: "Method Not Allowed" });
return;
}
// Extract the API Key from the Authorization header
const authHeader = req.headers.authorization;
const token = authHeader?.split(" ")[1]; // Assuming the format is "Bearer [token]"
// Check if the API Key matches
if (token !== process.env.INTERNAL_API_KEY) {
res.status(401).json({ message: "Unauthorized" });
return;
}
// Define validation schema
const requestSchema = z.object({
teamId: z
.string()
.min(1, "teamId is required and must be a non-empty string"),
});
// Validate request body
const validationResult = requestSchema.safeParse(req.body);
if (!validationResult.success) {
const firstError = validationResult.error.errors[0];
res.status(400).json({
message: firstError.message,
field: firstError.path[0],
});
return;
}
const { teamId } = validationResult.data;
try {
// Get all team members (ADMIN/MANAGER) for this team
const team = await prisma.team.findUnique({
where: { id: teamId },
select: {
name: true,
plan: true,
pauseEndsAt: true,
users: {
where: {
role: {
in: ["ADMIN", "MANAGER"],
},
blockedAt: null, // Only active team members (not blocked)
},
select: {
user: {
select: {
id: true,
email: true,
},
},
},
},
},
});
const teamMembers = team?.users;
if (!teamMembers || teamMembers.length === 0) {
res.status(200).json({
message: "No team members found for notifications",
notified: 0,
});
return;
}
if (!team.pauseEndsAt) {
res.status(200).json({
message: "Team is not paused",
notified: 0,
});
return;
}
// Get all team member emails
const teamMemberEmails = teamMembers
.map((tm) => tm.user.email)
.filter((email): email is string => !!email);
// Send email to all team members at once
await sendEmailPauseResumeReminder({
teamName: team.name,
plan: getDisplayNameFromPlan(team.plan),
resumeDate: team.pauseEndsAt.toLocaleDateString("en-US", {
month: "long",
day: "numeric",
year: "numeric",
}),
teamMemberEmails,
});
res.status(200).json({
message: "Successfully sent pause resume reminder to team members",
notified: teamMemberEmails.length,
teamMemberEmails,
});
return;
} catch (error) {
log({
message: `Failed to send pause resume reminder for team ${teamId} to team members. \n\n Error: ${error} \n\n*Metadata*: \`{teamId: ${teamId}}\``,
type: "error",
mention: true,
});
return res.status(500).json({ message: (error as Error).message });
}
}

View File

@@ -0,0 +1,119 @@
import { NextApiRequest, NextApiResponse } from "next";
import { stripeInstance } from "@/ee/stripe";
import { authOptions } from "@/pages/api/auth/[...nextauth]";
import { runs } from "@trigger.dev/sdk/v3";
import { waitUntil } from "@vercel/functions";
import { getServerSession } from "next-auth/next";
import prisma from "@/lib/prisma";
import { CustomUser } from "@/lib/types";
import { log } from "@/lib/utils";
export const config = {
// in order to enable `waitUntil` function
supportsResponseStreaming: true,
};
export async function handleRoute(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "POST") {
// POST /api/teams/:teamId/billing/unpause unpause a user's subscription
const session = await getServerSession(req, res, authOptions);
if (!session) {
res.status(401).end("Unauthorized");
return;
}
const userId = (session.user as CustomUser).id;
const { teamId } = req.query as { teamId: string };
try {
const team = await prisma.team.findUnique({
where: {
id: teamId,
users: {
some: {
userId: userId,
},
},
},
select: {
id: true,
stripeId: true,
subscriptionId: true,
endsAt: true,
plan: true,
pauseStartsAt: true,
pauseEndsAt: true,
},
});
if (!team) {
return res.status(400).json({ error: "Team does not exist" });
}
if (!team.stripeId) {
return res.status(400).json({ error: "No Stripe customer ID" });
}
if (!team.subscriptionId) {
return res.status(400).json({ error: "No subscription ID" });
}
const isOldAccount = team.plan.includes("+old");
const stripe = stripeInstance(isOldAccount);
const pauseStartsAt = team.endsAt ? new Date(team.endsAt) : new Date();
const pauseEndsAt = new Date(pauseStartsAt);
pauseEndsAt.setDate(pauseStartsAt.getDate() + 90);
// Pause the subscription in Stripe
await stripe.subscriptions.update(team.subscriptionId, {
pause_collection: "",
});
await prisma.team.update({
where: { id: teamId },
data: {
pausedAt: null,
pauseStartsAt: null,
pauseEndsAt: null,
},
});
// Get all delayed and queued runs for this dataroom
const allRuns = await runs.list({
taskIdentifier: ["send-pause-resume-notification"],
tag: [`team_${teamId}`],
status: ["DELAYED", "QUEUED"],
period: "90d",
});
// Cancel any existing unsent notification runs for this dataroom
waitUntil(
Promise.all([
allRuns.data.map((run) => runs.cancel(run.id)),
log({
message: `Team ${teamId} (${team.plan}) unpaused their subscription. Next billing date: ${team.endsAt}`,
type: "info",
}),
]),
);
res.status(200).json({
success: true,
message: "Subscription paused successfully",
});
} catch (error) {
console.error("Error pausing subscription:", error);
await log({
message: `Error pausing subscription for team ${teamId}: ${error}`,
type: "error",
});
res.status(500).json({ error: "Failed to pause subscription" });
}
} else {
res.setHeader("Allow", ["POST"]);
res.status(405).end(`Method ${req.method} Not Allowed`);
}
}

View File

@@ -1,28 +1,20 @@
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import { useTeam } from "@/context/team-context";
import { ArrowLeft } from "lucide-react";
import { toast } from "sonner";
import { mutate } from "swr";
import { Button } from "@/components/ui/button";
import { DialogDescription, DialogTitle } from "@/components/ui/dialog";
import { Modal } from "@/components/ui/modal";
import { useAnalytics } from "@/lib/analytics";
import { usePlan } from "@/lib/swr/use-billing";
import { type CancellationReason } from "../lib/constants";
import { ConfirmCancellationModal } from "./confirm-cancellation-modal";
import { FeedbackModal } from "./feedback-modal";
import { PauseSubscriptionModal } from "./pause-subscription-modal";
import { CancellationBaseModal } from "./reason-base-modal";
import { RetentionOfferModal } from "./retention-offer-modal";
import { ScheduleCallModal } from "./schedule-call-modal";
type CancellationReason =
| "too-expensive"
| "not-using-enough"
| "missing-features"
| "technical-issues"
| "switching-competitor"
| "other";
type CancellationStep =
| "reason"
| "retention-offer"
@@ -49,28 +41,18 @@ export function CancellationModal({
const [feedback, setFeedback] = useState("");
const [loading, setLoading] = useState(false);
// Absolutely ensure we start with pause offer when component mounts
useEffect(() => {
setCurrentStep("pause-offer");
setSelectedReason(null);
setFeedback("");
setLoading(false);
}, []); // Run once on mount
// Debug state changes
useEffect(() => {
console.log("Current step changed to:", currentStep);
}, [currentStep]);
const router = useRouter();
const teamInfo = useTeam();
const { currentTeamId } = useTeam();
const { mutate: mutatePlan } = usePlan();
const analytics = useAnalytics();
const reasons: { value: CancellationReason; label: string }[] = [
{ value: "too-expensive", label: "Too expensive" },
{ value: "not-using-enough", label: "I'm not using it enough" },
{ value: "missing-features", label: "Missing features I need" },
{ value: "technical-issues", label: "Technical issues" },
{ value: "switching-competitor", label: "Switching to a competitor" },
{ value: "too_expensive", label: "It's too expensive" },
{ value: "unused", label: "I don't use the service enough" },
{ value: "missing_features", label: "Some features are missing" },
{
value: "switched_service",
label: "I'm switching to a different service",
},
{ value: "other", label: "Other reason" },
];
@@ -92,19 +74,16 @@ export function CancellationModal({
setSelectedReason(reason);
// Route based on reason - only "other" goes to feedback first
switch (reason) {
case "too-expensive":
case "too_expensive":
setCurrentStep("retention-offer");
break;
case "not-using-enough":
setCurrentStep("pause-offer");
case "unused":
setCurrentStep("confirm"); // Go directly to cancellation flow for unused
break;
case "missing-features":
case "missing_features":
setCurrentStep("schedule-call");
break;
case "technical-issues":
setCurrentStep("retention-offer");
break;
case "switching-competitor":
case "switched_service":
setCurrentStep("retention-offer");
break;
case "other":
@@ -131,10 +110,10 @@ export function CancellationModal({
break;
case "confirm":
// Go back to the appropriate step based on reason
if (selectedReason === "missing-features") {
if (selectedReason === "missing_features") {
setCurrentStep("schedule-call");
} else if (selectedReason === "not-using-enough") {
setCurrentStep("pause-offer");
} else if (selectedReason === "unused") {
setCurrentStep("reason"); // Go back to reason selection for unused
} else if (selectedReason === "other") {
setCurrentStep("feedback"); // Go back to "other" feedback
} else {
@@ -155,6 +134,53 @@ export function CancellationModal({
onOpenChange(false);
};
const handleFinalFeedbackSubmit = async () => {
if (!currentTeamId || !selectedReason) return;
setLoading(true);
try {
const response = await fetch(
`/api/teams/${currentTeamId}/billing/cancellation-feedback`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
reason: selectedReason,
feedback: feedback,
}),
},
);
if (response.ok) {
const data = await response.json();
// Track in PostHog
analytics.capture("Cancellation Feedback Submitted", {
teamId: currentTeamId,
reason: selectedReason,
reasonLabel: data.feedbackData?.reasonLabel || selectedReason,
feedback: feedback || "",
hasCustomFeedback: !!feedback,
});
toast.success("Thank you for your feedback!");
mutate(`/api/teams/${currentTeamId}/billing/plan`);
mutate(`/api/teams/${currentTeamId}/billing/plan?withDiscount=true`);
onOpenChange(false);
} else {
throw new Error("Failed to submit feedback");
}
} catch (error) {
console.error("Error submitting feedback:", error);
toast.error("Sorry, we couldn't submit your feedback. Please try again.");
} finally {
setLoading(false);
}
};
if (!open) {
return null;
}
@@ -171,53 +197,28 @@ export function CancellationModal({
);
}
if (currentStep === "reason") {
if (currentStep === "reason" || !selectedReason) {
return (
<Modal
showModal={open}
setShowModal={(show: boolean | ((prev: boolean) => boolean)) => {
if (typeof show === "function") {
onOpenChange(show(open));
} else {
onOpenChange(show);
}
}}
className="max-w-lg"
<CancellationBaseModal
open={open}
onOpenChange={onOpenChange}
title="Why do you want to cancel?"
description="Help us understand what we could improve"
showKeepButton={true}
onBack={handleBack}
>
<div className="flex flex-col items-center justify-center space-y-3 border-b border-border bg-white px-4 py-4 pt-8 dark:border-gray-900 dark:bg-gray-900 sm:px-8">
<DialogTitle className="text-2xl font-semibold">
Why do you want to cancel?
</DialogTitle>
<DialogDescription className="text-center text-base text-muted-foreground">
Help us understand what we could improve
</DialogDescription>
<div className="space-y-3">
{reasons.map((reason) => (
<button
key={reason.value}
onClick={() => handleReasonClick(reason.value)}
className="w-full rounded-lg border border-gray-200 bg-white p-4 text-left transition-colors hover:ring-1 hover:ring-gray-800 dark:border-gray-700 dark:bg-gray-800 dark:hover:ring-gray-200"
>
<div className="text-sm font-medium">{reason.label}</div>
</button>
))}
</div>
<div className="px-4 py-6 dark:bg-gray-900 sm:px-8">
<div className="space-y-3">
{reasons.map((reason) => (
<button
key={reason.value}
onClick={() => handleReasonClick(reason.value)}
className="w-full rounded-lg border border-gray-200 bg-white p-4 text-left transition-colors hover:ring-1 hover:ring-gray-800 dark:border-gray-700 dark:bg-gray-800 dark:hover:ring-gray-200"
>
<div className="text-sm font-medium">{reason.label}</div>
</button>
))}
<div className="flex items-center justify-between border-t pt-4">
<Button
variant="ghost"
onClick={handleBack}
className="flex items-center gap-2"
>
<ArrowLeft className="h-4 w-4" />
Back
</Button>
</div>
</div>
</div>
</Modal>
</CancellationBaseModal>
);
}
@@ -226,7 +227,7 @@ export function CancellationModal({
<RetentionOfferModal
open={open}
onOpenChange={onOpenChange}
reason={selectedReason!}
reason={selectedReason}
onBack={handleBack}
onDecline={() => setCurrentStep("confirm")}
onClose={handleClose}
@@ -239,38 +240,19 @@ export function CancellationModal({
<ScheduleCallModal
open={open}
onOpenChange={onOpenChange}
reason={selectedReason!}
reason={selectedReason}
onBack={handleBack}
onDecline={() => setCurrentStep("confirm")}
/>
);
}
if (currentStep === "confirm") {
return (
<ConfirmCancellationModal
open={open}
onOpenChange={onOpenChange}
onBack={handleBack}
onClose={handleClose}
reason={selectedReason!}
feedback={feedback}
onConfirm={() => {
console.log(
"Confirm cancellation onConfirm called, setting step to final-feedback",
);
setCurrentStep("final-feedback");
}} // After confirming cancellation, show final feedback
/>
);
}
if (currentStep === "feedback") {
return (
<FeedbackModal
open={open}
onOpenChange={onOpenChange}
reason={selectedReason!}
reason={selectedReason}
feedback={feedback}
onFeedbackChange={setFeedback}
onBack={handleBack}
@@ -283,22 +265,31 @@ export function CancellationModal({
);
}
if (currentStep === "confirm") {
return (
<ConfirmCancellationModal
open={open}
onOpenChange={onOpenChange}
onConfirmCancellation={() => {
// After cancellation is confirmed, show feedback modal
setCurrentStep("final-feedback");
}}
/>
);
}
if (currentStep === "final-feedback") {
console.log("Rendering final-feedback modal");
return (
<FeedbackModal
open={open}
onOpenChange={onOpenChange}
reason={selectedReason!}
reason={selectedReason}
feedback={feedback}
onFeedbackChange={setFeedback}
onBack={handleBack}
onContinue={() => {
// Final step - close modal after feedback
console.log("Final feedback submitted, closing modal");
onOpenChange(false);
}}
isFinalStep={true} // This is the final "Sorry to see you go" feedback
onContinue={handleFinalFeedbackSubmit}
isFinalStep={true} // This is the final feedback
loading={loading}
/>
);
}

View File

@@ -0,0 +1,123 @@
"use client";
import { useState } from "react";
import { useTeam } from "@/context/team-context";
import { toast } from "sonner";
import { mutate } from "swr";
import { useAnalytics } from "@/lib/analytics";
import { usePlan } from "@/lib/swr/use-billing";
import { Button } from "@/components/ui/button";
import { CancellationBaseModal } from "./reason-base-modal";
interface ConfirmCancellationModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirmCancellation: () => void;
}
export function ConfirmCancellationModal({
open,
onOpenChange,
onConfirmCancellation,
}: ConfirmCancellationModalProps) {
const [loading, setLoading] = useState(false);
const { currentTeamId } = useTeam();
const { plan: currentPlan, endsAt } = usePlan();
const analytics = useAnalytics();
const handleConfirmCancellation = async () => {
if (!currentTeamId) return;
setLoading(true);
// If we have a callback, call it instead of redirecting
try {
// Still make the API call to get the cancellation URL for future use
const response = await fetch(
`/api/teams/${currentTeamId}/billing/cancel`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
},
);
if (response.ok) {
// track the event
analytics.capture("Subscription Cancelled", {
teamId: currentTeamId,
plan: currentPlan,
endsAt: endsAt,
});
// Call the callback instead of redirecting
onConfirmCancellation();
mutate(`/api/teams/${currentTeamId}/billing/plan`);
mutate(`/api/teams/${currentTeamId}/billing/plan?withDiscount=true`);
} else {
toast.error("Something went wrong");
}
} catch (err) {
toast.error("Something went wrong");
} finally {
setLoading(false);
}
};
const getDescription = `Your subscription will end on ${new Date(
endsAt!,
).toLocaleDateString("en-US", {
month: "long",
day: "numeric",
year: "numeric",
})}`;
return (
<CancellationBaseModal
open={open}
onOpenChange={onOpenChange}
title="Confirm cancellation"
description={getDescription}
showKeepButton={true}
confirmButton={
<Button
variant="destructive"
onClick={handleConfirmCancellation}
loading={loading}
>
Confirm cancellation
</Button>
}
>
<div>
<h3 className="mb-4 text-lg font-semibold">
After cancellation, you'll lose access to:
</h3>
<ul className="space-y-2 text-muted-foreground">
<li className="flex items-center">
<span className="mr-3 h-2 w-2 rounded-full bg-muted-foreground"></span>
All premium features
</li>
<li className="flex items-center">
<span className="mr-3 h-2 w-2 rounded-full bg-muted-foreground"></span>
Priority customer support
</li>
<li className="flex items-center">
<span className="mr-3 h-2 w-2 rounded-full bg-muted-foreground"></span>
Advanced analytics
</li>
<li className="flex items-center">
<span className="mr-3 h-2 w-2 rounded-full bg-muted-foreground"></span>
Team collaboration tools
</li>
</ul>
</div>
</CancellationBaseModal>
);
}

View File

@@ -0,0 +1,144 @@
"use client";
import { ArrowLeftIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { CancellationReason } from "../lib/constants";
import { CancellationBaseModal } from "./reason-base-modal";
interface FeedbackModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
reason: CancellationReason;
feedback: string;
onFeedbackChange: (feedback: string) => void;
onBack?: () => void;
onContinue: () => void;
isFinalStep?: boolean;
loading?: boolean;
}
export function FeedbackModal({
open,
onOpenChange,
reason,
feedback,
onFeedbackChange,
onBack,
onContinue,
isFinalStep = false,
loading = false,
}: FeedbackModalProps) {
const getTitle = () => {
if (isFinalStep) {
return "Sorry to see you go!";
}
switch (reason) {
case "too_expensive":
return "Tell us about pricing";
case "unused":
return "Help us understand your usage";
case "missing_features":
return "What features do you need?";
case "switched_service":
return "What's driving your decision?";
case "other":
return "Tell us more";
default:
return "Help us understand what we could improve";
}
};
const getSubtitle = () => {
if (isFinalStep) {
return "Please share your feedback to help us improve";
}
switch (reason) {
case "too_expensive":
return "What would make the pricing work better for your budget?";
case "unused":
return "Help us understand how we can make Papermark more valuable for your workflow.";
case "missing_features":
return "What features are you missing? This helps us prioritize our roadmap.";
case "switched_service":
return "What does the competitor offer that we don't? This helps us improve.";
case "other":
return "We'd love to hear your specific feedback.";
default:
return "Help us understand what we could improve";
}
};
const getPlaceholder = () => {
if (isFinalStep) {
return "Help us understand what we could improve...";
}
switch (reason) {
case "too_expensive":
return "Tell us about your budget constraints or what pricing would work...";
case "unused":
return "Share how you use Papermark and what would make it more valuable...";
case "missing_features":
return "Tell us about the features you need...";
case "switched_service":
return "Tell us about the competitor and what attracted you to them...";
case "other":
return "Share your thoughts...";
default:
return "Help us understand what we could improve...";
}
};
const cancelButton = (isFinalStep: boolean) => {
if (isFinalStep) {
return null;
} else {
return (
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
className="flex items-center gap-2"
>
<ArrowLeftIcon className="h-4 w-4" />
Back
</Button>
);
}
};
const proceedButton = (isFinalStep: boolean) => {
if (isFinalStep) {
return (
<Button className="ml-auto" onClick={onContinue} loading={loading}>
Submit
</Button>
);
} else {
return <Button onClick={onContinue}>Submit</Button>;
}
};
return (
<CancellationBaseModal
open={open}
onOpenChange={onOpenChange}
title={getTitle()}
description={getSubtitle()}
cancelButton={cancelButton(isFinalStep)}
proceedButton={proceedButton(isFinalStep)}
>
<Textarea
placeholder={getPlaceholder()}
value={feedback}
onChange={(e) => onFeedbackChange(e.target.value)}
rows={6}
className="resize-none bg-white dark:bg-gray-800"
/>
</CancellationBaseModal>
);
}

View File

@@ -5,6 +5,11 @@ import { useState } from "react";
import { useTeam } from "@/context/team-context";
import { ArrowLeft, CheckCircle2 } from "lucide-react";
import { toast } from "sonner";
import { mutate } from "swr";
import { useAnalytics } from "@/lib/analytics";
import { usePlan } from "@/lib/swr/use-billing";
import { timeIn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { DialogDescription, DialogTitle } from "@/components/ui/dialog";
@@ -26,19 +31,17 @@ export function PauseSubscriptionModal({
onClose,
}: PauseSubscriptionModalProps) {
const [loading, setLoading] = useState(false);
const teamInfo = useTeam();
const { currentTeamId } = useTeam();
const { endsAt, plan } = usePlan();
const analytics = useAnalytics();
const handlePauseSubscription = async () => {
if (!teamInfo?.currentTeam?.id) {
toast.error("Team information not found");
return;
}
if (!currentTeamId) return;
setLoading(true);
try {
const response = await fetch(
`/api/teams/${teamInfo.currentTeam.id}/billing/pause`,
`/api/teams/${currentTeamId}/billing/pause`,
{
method: "POST",
headers: {
@@ -51,13 +54,19 @@ export function PauseSubscriptionModal({
throw new Error("Failed to pause subscription");
}
const data = await response.json();
// Track the pause event for analytics
analytics.capture("Subscription Paused", {
teamId: currentTeamId,
plan: plan,
pauseStartsAt: pauseStartsAt.toISOString(),
pauseEndsAt: pauseEndsAt.toISOString(),
pauseDurationDays: 90,
});
toast.success("Subscription paused successfully!");
mutate(`/api/teams/${currentTeamId}/billing/plan`);
mutate(`/api/teams/${currentTeamId}/billing/plan?withDiscount=true`);
onClose();
// Refresh the page to show updated billing status
window.location.reload();
} catch (error) {
console.error("Error pausing subscription:", error);
toast.error("Failed to pause subscription. Please try again.");
@@ -66,6 +75,31 @@ export function PauseSubscriptionModal({
}
};
const pauseStartsAt = endsAt ? new Date(endsAt) : new Date();
const pauseEndsAt = new Date(pauseStartsAt);
pauseEndsAt.setDate(pauseStartsAt.getDate() + 90);
const pauseStartsAtString = new Date(pauseStartsAt).toLocaleDateString(
"en-US",
{
month: "long",
day: "numeric",
year:
new Date(pauseStartsAt).getFullYear() === new Date().getFullYear()
? undefined
: "numeric",
},
);
const pauseEndsAtString = new Date(pauseEndsAt).toLocaleDateString("en-US", {
month: "long",
day: "numeric",
year:
new Date(pauseEndsAt).getFullYear() === new Date().getFullYear()
? undefined
: "numeric",
});
return (
<Modal
showModal={open}
@@ -97,13 +131,13 @@ export function PauseSubscriptionModal({
<div className="flex items-center gap-3">
<CheckCircle2 className="h-5 w-5 text-green-600 dark:text-green-500" />
<span className="text-sm font-medium text-green-800 dark:text-green-700">
You pay 0 for 3 months
You pay $0 for 3 months
</span>
</div>
<div className="flex items-center gap-3">
<CheckCircle2 className="h-5 w-5 text-green-600 dark:text-green-500" />
<span className="text-sm font-medium text-green-800 dark:text-green-700">
All your custom links continue to work
All your links continue to work
</span>
</div>
<div className="flex items-center gap-3">
@@ -129,7 +163,10 @@ export function PauseSubscriptionModal({
<div className="space-y-2 text-sm text-muted-foreground">
<div className="flex items-center justify-between">
<span>Pause starts:</span>
<span className="font-medium">Today</span>
<span className="font-medium">
{pauseStartsAtString}{" "}
<span className="italic">({timeIn(pauseStartsAt)})</span>
</span>
</div>
<div className="flex items-center justify-between">
<span>Reminder email:</span>
@@ -137,15 +174,7 @@ export function PauseSubscriptionModal({
</div>
<div className="flex items-center justify-between">
<span>Auto-resume date:</span>
<span className="font-medium">
{new Date(
Date.now() + 90 * 24 * 60 * 60 * 1000,
).toLocaleDateString("en-US", {
month: "long",
day: "numeric",
year: "numeric",
})}
</span>
<span className="font-medium">{pauseEndsAtString}</span>
</div>
</div>
</div>

View File

@@ -0,0 +1,86 @@
import { ArrowLeftIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { DialogDescription, DialogTitle } from "@/components/ui/dialog";
import { Modal } from "@/components/ui/modal";
export function CancellationBaseModal({
open,
onOpenChange,
children,
title,
description,
onBack,
onDecline,
onConfirm,
confirmButton,
showKeepButton,
cancelButton,
proceedButton,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
children: React.ReactNode;
title: string;
description: string;
onBack?: () => void;
onDecline?: () => void;
onConfirm?: () => void;
confirmButton?: React.ReactNode;
showKeepButton?: boolean;
cancelButton?: React.ReactNode;
proceedButton?: React.ReactNode;
}) {
return (
<Modal
showModal={open}
setShowModal={(show: boolean | ((prev: boolean) => boolean)) => {
if (typeof show === "function") {
onOpenChange(show(open));
} else {
onOpenChange(show);
}
}}
className="max-w-lg"
>
<div className="flex flex-col items-center justify-center space-y-2 border-b border-border bg-white px-4 py-4 pt-8 dark:border-gray-900 dark:bg-gray-900 sm:px-8">
<DialogTitle className="text-2xl font-semibold">{title}</DialogTitle>
<DialogDescription className="max-w-md text-center text-base text-muted-foreground">
{description}
</DialogDescription>
</div>
<div className="space-y-6 bg-white px-4 py-6 dark:bg-gray-900 sm:px-8">
<div className="space-y-6">{children}</div>
<div className="flex items-center justify-between border-t pt-4">
{cancelButton}
{proceedButton}
{onBack && (
<Button
variant="ghost"
onClick={onBack}
className="flex items-center gap-2"
>
<ArrowLeftIcon className="h-4 w-4" />
Back
</Button>
)}
{showKeepButton && (
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="flex items-center gap-2"
>
Stay on Papermark
</Button>
)}
{onDecline && (
<Button variant="outline" onClick={onDecline}>
Decline offer
</Button>
)}
{confirmButton}
</div>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,167 @@
"use client";
import { useRouter } from "next/router";
import { useState } from "react";
import { useTeam } from "@/context/team-context";
import { PLANS } from "@/ee/stripe/utils";
import { toast } from "sonner";
import { mutate } from "swr";
import { usePlan } from "@/lib/swr/use-billing";
import useLimits from "@/lib/swr/use-limits";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { CancellationReason } from "../lib/constants";
import { CancellationBaseModal } from "./reason-base-modal";
interface RetentionOfferModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
reason: CancellationReason;
onBack: () => void;
onDecline: () => void;
onClose: () => void;
}
export function RetentionOfferModal({
open,
onOpenChange,
reason,
onBack,
onDecline,
onClose,
}: RetentionOfferModalProps) {
const [loading, setLoading] = useState(false);
const { currentTeamId } = useTeam();
const router = useRouter();
const { plan: userPlan, isAnnualPlan } = usePlan();
const { limits } = useLimits();
const currentQuantity = limits?.users ?? 1;
const calculateSavings = () => {
// Find current plan pricing
const currentPlan = PLANS.find((p) => p.slug === userPlan);
if (!currentPlan) return { savings: "€0" };
const monthlyPrice = currentPlan.price.monthly.unitPrice;
const yearlyPrice = currentPlan.price.yearly.unitPrice;
// Simple logic: 50% discount for 3 months (monthly) or 12 months (annual)
const discountPercent = 0.5;
const durationMonths = isAnnualPlan ? 12 : 3;
const basePrice = isAnnualPlan ? yearlyPrice : monthlyPrice;
// Calculate savings
const totalSavings = Math.round(
(basePrice * durationMonths * discountPercent * currentQuantity) / 100,
);
return { savings: `${totalSavings}` };
};
const getOfferDetails = () => {
const { savings } = calculateSavings();
if (isAnnualPlan) {
return {
title: "Special offer just for you",
subtitle: "Let us make this work for your budget",
discount: "50% off your next year",
savings,
duration: "12 months",
};
} else {
return {
title: "Special offer just for you",
subtitle: "Let us make this work for your budget",
discount: "50% off for the next 3 months",
savings,
duration: "3 months",
};
}
};
const offerDetails = getOfferDetails();
const handleAcceptOffer = async () => {
if (!currentTeamId) return;
setLoading(true);
await fetch(`/api/teams/${currentTeamId}/billing/retention-offer`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
isAnnualPlan,
}),
})
.then(async (res) => {
const data = await res.json();
if (data.success) {
mutate(`/api/teams/${currentTeamId}/billing/plan`);
mutate(`/api/teams/${currentTeamId}/billing/plan?withDiscount=true`);
onClose();
toast.success(
`50% discount applied for ${isAnnualPlan ? "12 months" : "3 months"}!`,
);
}
})
.catch((err) => {
toast.error("Something went wrong");
})
.finally(() => {
setLoading(false);
});
};
return (
<CancellationBaseModal
open={open}
onOpenChange={onOpenChange}
title={offerDetails.title}
description={offerDetails.subtitle}
showKeepButton={true}
// onBack={onBack}
onDecline={onDecline}
>
<div className="flex items-center justify-between">
<Badge
variant="secondary"
className="border-blue-200 bg-blue-50 text-blue-700"
>
Special offer
</Badge>
<span className="text-sm text-muted-foreground">Limited time</span>
</div>
<div className="text-center">
<h3 className="mb-2 text-xl font-semibold">{offerDetails.discount}</h3>
</div>
<div className="rounded-lg border border-green-200 bg-green-50 p-6 text-center">
<div className="mb-2 text-2xl font-bold text-green-800">
You save {offerDetails.savings}
</div>
<div className="text-sm font-medium text-green-700">
Over {offerDetails.duration}
</div>
</div>
<Button
onClick={handleAcceptOffer}
className="w-full"
size="lg"
loading={loading}
>
Accept this offer
</Button>
</CancellationBaseModal>
);
}

View File

@@ -0,0 +1,106 @@
"use client";
import { useEffect, useState } from "react";
import Cal, { getCalApi } from "@calcom/embed-react";
import { useSession } from "next-auth/react";
import { CancellationReason } from "../lib/constants";
import { CancellationBaseModal } from "./reason-base-modal";
interface ScheduleCallModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
reason: CancellationReason;
onDecline: () => void;
}
export function ScheduleCallModal({
open,
onOpenChange,
reason,
onDecline,
}: ScheduleCallModalProps) {
const [calLoaded, setCalLoaded] = useState(false);
const { data: session } = useSession();
useEffect(() => {
if (open) {
(async function () {
try {
const cal = await getCalApi({ namespace: "papermark-support" });
cal("ui", { hideEventTypeDetails: true, layout: "month_view" });
setCalLoaded(true);
} catch (error) {
console.error("Error loading Cal.com:", error);
setCalLoaded(true); // Set to true anyway to show fallback
}
})();
}
}, [open]);
const getTitle = () => {
switch (reason) {
case "missing_features":
return "Let's talk about what you need";
default:
return "Schedule a consultation call";
}
};
const getSubtitle = () => {
switch (reason) {
case "missing_features":
return "Our team will reach out to understand your requirements";
default:
return "We'd love to understand how we can better serve you";
}
};
return (
<CancellationBaseModal
open={open}
onOpenChange={onOpenChange}
title={getTitle()}
description={getSubtitle()}
showKeepButton={true}
onDecline={onDecline}
>
<div className="rounded-lg border border-green-200 bg-green-50 p-4">
<div className="mb-2 text-lg font-semibold text-green-800">
Free consultation
</div>
<div className="text-sm text-green-700">
30-minute call within 24 hours
</div>
</div>
{/* Cal.com embed container */}
<div className="max-h-[540px] rounded-lg border bg-white dark:bg-gray-800">
{calLoaded ? (
<Cal
namespace="papermark-support"
calLink={`marcseitz/papermark-support?email=${session?.user?.email}&name=${session?.user?.name}`}
style={{
width: "100%",
height: "540px",
overflow: "scroll",
}}
config={{ layout: "month_view" }}
/>
) : (
<div className="flex h-[500px] items-center justify-center">
<div className="text-center">
<div className="mb-2 text-lg font-semibold">
Loading calendar...
</div>
<div className="text-sm text-muted-foreground">
Please wait while we load the booking calendar
</div>
</div>
</div>
)}
</div>
</CancellationBaseModal>
);
}

View File

@@ -0,0 +1,111 @@
import {
Body,
Button,
Container,
Head,
Heading,
Hr,
Html,
Img,
Link,
Preview,
Section,
Tailwind,
Text,
} from "@react-email/components";
interface PauseResumeReminderEmailProps {
teamName?: string;
userName?: string;
resumeDate?: string;
plan?: string;
userRole?: string;
}
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "https://app.papermark.com";
export default function PauseResumeReminderEmail({
teamName = "Your Team",
resumeDate = "March 15, 2024",
plan = "Pro",
}: PauseResumeReminderEmailProps) {
const previewText = `${teamName}'s paused subscription will resume billing soon`;
return (
<Html>
<Head />
<Preview>{previewText}</Preview>
<Tailwind>
<Body className="mx-auto my-auto bg-white font-sans">
<Container className="mx-auto my-10 w-[465px] p-5">
<Text className="mx-0 mb-8 mt-4 p-0 text-center text-2xl font-normal">
<span className="font-bold tracking-tighter">Papermark</span>
</Text>
<Text className="mx-0 mb-8 mt-4 p-0 text-center text-xl font-semibold">
Subscription Resume Reminder
</Text>
<Text className="text-sm leading-6 text-black">
This is a friendly reminder that your{" "}
<span className="font-semibold">{teamName}</span> team's paused
subscription will automatically resume billing in{" "}
<span className="font-semibold">3 days</span>.
</Text>
<Text className="text-sm font-semibold leading-6 text-black">
What happens next?
</Text>
<Text className="break-all text-sm leading-6 text-black">
<ul>
<li className="text-sm leading-6 text-black">
Your subscription will resume on{" "}
<span className="font-semibold">{resumeDate}</span>
</li>
<li className="text-sm leading-6 text-black">
Billing will restart at your{" "}
<span className="font-semibold">{plan}</span> plan rate
</li>
<li className="text-sm leading-6 text-black">
All features will be fully restored
</li>
<li className="text-sm leading-6 text-black">
Your existing data and links remain unchanged
</li>
</ul>
</Text>
<Text className="text-sm font-semibold leading-6 text-black">
Need to make changes?
</Text>
<Text className="text-sm leading-6 text-black">
If you'd like to cancel your subscription or need to update your
billing information, you can manage your subscription in your{" "}
<span className="font-semibold">account settings</span>.
</Text>
<Section className="my-8 text-center">
<Button
className="rounded bg-black text-center text-xs font-semibold text-white no-underline"
href={`${baseUrl}/settings/billing`}
style={{ padding: "12px 20px" }}
>
Manage Subscription
</Button>
</Section>
<Hr />
<Section className="mt-8 text-gray-400">
<Text className="text-xs">
© {new Date().getFullYear()} Papermark, Inc.
</Text>
<Text className="text-xs">
If you have any feedback or questions about this email, simply
reply to it.
</Text>
</Section>
</Container>
</Body>
</Tailwind>
</Html>
);
}

View File

@@ -0,0 +1,37 @@
import { sendEmail } from "@/lib/resend";
import PauseResumeReminderEmail from "../components/pause-resume-reminder";
export const sendEmailPauseResumeReminder = async ({
teamName,
plan,
resumeDate,
teamMemberEmails,
}: {
teamName: string;
plan: string;
resumeDate: string;
teamMemberEmails: string[];
}) => {
try {
if (!teamMemberEmails || teamMemberEmails.length === 0) {
console.log("No team member emails provided");
return;
}
await sendEmail({
to: teamMemberEmails[0], // Send to first team member
cc: teamMemberEmails.slice(1).join(","), // Send to all other team members
subject: "Your Papermark subscription will resume in 3 days",
react: PauseResumeReminderEmail({
teamName,
plan,
resumeDate,
}),
test: process.env.NODE_ENV === "development",
system: true,
});
} catch (e) {
console.error("Failed to send team member notification:", e);
}
};

View File

@@ -0,0 +1,6 @@
export type CancellationReason =
| "too_expensive"
| "unused"
| "missing_features"
| "switched_service"
| "other";

View File

@@ -0,0 +1,109 @@
import { logger, task } from "@trigger.dev/sdk/v3";
import prisma from "@/lib/prisma";
type PauseResumeNotificationPayload = {
teamId: string;
};
export const sendPauseResumeNotificationTask = task({
id: "send-pause-resume-notification",
retry: { maxAttempts: 3 },
run: async (payload: PauseResumeNotificationPayload) => {
logger.info("Starting pause resume notification", {
teamId: payload.teamId,
});
// Verify the team is still paused and get team details
const team = await prisma.team.findUnique({
where: {
id: payload.teamId,
},
select: {
id: true,
name: true,
plan: true,
pauseEndsAt: true,
pausedAt: true,
users: {
where: {
role: {
in: ["ADMIN", "MANAGER"],
},
// Only active team members (not blocked)
blockedAt: null,
},
select: {
userId: true,
role: true,
},
},
},
});
if (!team) {
logger.error("Team not found for pause resume notification", {
teamId: payload.teamId,
});
return;
}
// Check if team is still paused
if (!team.pausedAt || !team.pauseEndsAt) {
logger.info("Team is no longer paused, skipping notification", {
teamId: payload.teamId,
pausedAt: team.pausedAt,
pauseEndsAt: team.pauseEndsAt,
});
return;
}
if (team.users.length === 0) {
logger.info("No admin/manager users found for team", {
teamId: payload.teamId,
teamName: team.name,
});
return;
}
try {
const response = await fetch(
`${process.env.NEXT_PUBLIC_BASE_URL}/api/jobs/send-pause-resume-notification`,
{
method: "POST",
body: JSON.stringify({
teamId: payload.teamId,
}),
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.INTERNAL_API_KEY}`,
},
},
);
if (!response.ok) {
logger.error("Failed to send pause resume notification", {
teamId: payload.teamId,
error: await response.text(),
});
}
const { message } = (await response.json()) as { message: string };
logger.info("Pause resume notification sent successfully", {
teamId: payload.teamId,
message,
});
} catch (error) {
logger.error("Error sending pause resume notification", {
teamId: payload.teamId,
error,
});
}
logger.info("Completed pause resume notifications", {
teamId: payload.teamId,
});
return;
},
});

View File

@@ -0,0 +1,137 @@
import { NextApiRequest, NextApiResponse } from "next";
import { scheduledPauseResumeNotificationTask } from "@/ee/features/billing/lib/trigger/pause-resume-notification";
import { stripeInstance } from "@/ee/stripe";
import { authOptions } from "@/pages/api/auth/[...nextauth]";
import { schedules } from "@trigger.dev/sdk/v3";
import { waitUntil } from "@vercel/functions";
import { getServerSession } from "next-auth/next";
import prisma from "@/lib/prisma";
import { CustomUser } from "@/lib/types";
import { log } from "@/lib/utils";
export const config = {
// in order to enable `waitUntil` function
supportsResponseStreaming: true,
};
export async function handleRoute(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "POST") {
// POST /api/teams/:teamId/billing/pause pause a user's subscription
const session = await getServerSession(req, res, authOptions);
if (!session) {
res.status(401).end("Unauthorized");
return;
}
const userId = (session.user as CustomUser).id;
const { teamId } = req.query as { teamId: string };
try {
const team = await prisma.team.findUnique({
where: {
id: teamId,
users: {
some: {
userId: userId,
},
},
},
select: {
id: true,
stripeId: true,
subscriptionId: true,
endsAt: true,
plan: true,
limits: true,
},
});
if (!team) {
return res.status(400).json({ error: "Team does not exist" });
}
if (!team.stripeId) {
return res.status(400).json({ error: "No Stripe customer ID" });
}
if (!team.subscriptionId) {
return res.status(400).json({ error: "No subscription ID" });
}
const isOldAccount = team.plan.includes("+old");
const stripe = stripeInstance(isOldAccount);
const pauseStartsAt = team.endsAt ? new Date(team.endsAt) : new Date();
const pauseEndsAt = new Date(pauseStartsAt);
pauseEndsAt.setDate(pauseStartsAt.getDate() + 90);
// Pause the subscription in Stripe
await stripe.subscriptions.update(team.subscriptionId, {
pause_collection: {
behavior: "void",
resumes_at: pauseEndsAt.getTime() / 1000, // Convert to seconds
},
metadata: {
pause_starts_at: pauseStartsAt.toISOString(),
pause_ends_at: pauseEndsAt.toISOString(),
paused_reason: "user_request",
original_plan: team.plan,
},
});
await prisma.team.update({
where: { id: teamId },
data: {
pausedAt: new Date(),
pauseStartsAt,
pauseEndsAt,
},
});
// Schedule notification reminder 3 days before pause ends (87 days from now)
const notificationDate = new Date(pauseStartsAt);
notificationDate.setDate(notificationDate.getDate() + 87); // 90 - 3 = 87 days
waitUntil(
(async () => {
try {
// Create one-time schedule for pause resume notification
const schedule = await schedules.create({
task: scheduledPauseResumeNotificationTask.id,
cron: `${notificationDate.getMinutes()} ${notificationDate.getHours()} ${notificationDate.getDate()} ${notificationDate.getMonth() + 1} *`,
deduplicationKey: `pause-resume-${teamId}-${pauseEndsAt.getTime()}`,
externalId: `${teamId}:${pauseEndsAt.toISOString()}:${team.plan}`,
});
await log({
message: `Team ${teamId} (${team.plan}) paused their subscription for 3 months. Reminder scheduled for ${notificationDate.toISOString()}`,
type: "info",
});
} catch (error) {
await log({
message: `Team ${teamId} paused successfully but failed to schedule reminder: ${error}`,
type: "error",
});
}
})(),
);
res.status(200).json({
success: true,
message: "Subscription paused successfully",
});
} catch (error) {
console.error("Error pausing subscription:", error);
await log({
message: `Error pausing subscription for team ${teamId}: ${error}`,
type: "error",
});
res.status(500).json({ error: "Failed to pause subscription" });
}
} else {
res.setHeader("Allow", ["POST"]);
res.status(405).end(`Method ${req.method} Not Allowed`);
}
}

View File

@@ -1,160 +0,0 @@
"use client";
import { useState } from "react";
import { useTeam } from "@/context/team-context";
import { ArrowLeft } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { DialogDescription, DialogTitle } from "@/components/ui/dialog";
import { Modal } from "@/components/ui/modal";
type CancellationReason =
| "too-expensive"
| "not-using-enough"
| "missing-features"
| "technical-issues"
| "switching-competitor"
| "other";
interface ConfirmCancellationModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onBack: () => void;
onClose: () => void;
reason: CancellationReason;
feedback: string;
onConfirm?: () => void;
}
export function ConfirmCancellationModal({
open,
onOpenChange,
onBack,
onClose,
reason,
feedback,
onConfirm,
}: ConfirmCancellationModalProps) {
const [loading, setLoading] = useState(false);
const teamInfo = useTeam();
const handleConfirmCancellation = async () => {
if (!teamInfo?.currentTeam?.id) {
toast.error("Team information not found");
return;
}
setLoading(true);
try {
const response = await fetch(
`/api/teams/${teamInfo.currentTeam.id}/billing/cancel`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
reason,
feedback,
}),
},
);
if (!response.ok) {
throw new Error("Failed to cancel subscription");
}
const data = await response.json();
toast.success("Subscription cancelled successfully.");
// Always show feedback modal after successful cancellation
console.log("Cancellation successful, calling onConfirm:", !!onConfirm);
if (onConfirm) {
onConfirm();
} else {
// Fallback if onConfirm not provided - should not happen in normal flow
console.warn("onConfirm not provided, closing modal");
onClose();
window.location.reload();
}
} catch (error) {
console.error("Error cancelling subscription:", error);
toast.error("Failed to cancel subscription. Please try again.");
} finally {
setLoading(false);
}
};
return (
<Modal
showModal={open}
setShowModal={(show: boolean | ((prev: boolean) => boolean)) => {
if (typeof show === "function") {
onOpenChange(show(open));
} else {
onOpenChange(show);
}
}}
className="max-w-lg"
>
<div className="flex flex-col items-center justify-center space-y-3 border-b border-border bg-white px-4 py-4 pt-8 dark:border-gray-900 dark:bg-gray-900 sm:px-8">
<DialogTitle className="text-2xl font-semibold">
Confirm cancellation
</DialogTitle>
<DialogDescription className="text-center text-base text-muted-foreground">
Your subscription will end on January 15, 2024
</DialogDescription>
</div>
<div className="bg-muted px-4 py-8 dark:bg-gray-900 sm:px-8">
<div className="space-y-6">
<div>
<h3 className="mb-4 text-lg font-semibold">
After cancellation, you'll lose access to:
</h3>
<ul className="space-y-2 text-muted-foreground">
<li className="flex items-center">
<span className="mr-3 h-2 w-2 rounded-full bg-muted-foreground"></span>
All premium features
</li>
<li className="flex items-center">
<span className="mr-3 h-2 w-2 rounded-full bg-muted-foreground"></span>
Priority customer support
</li>
<li className="flex items-center">
<span className="mr-3 h-2 w-2 rounded-full bg-muted-foreground"></span>
Advanced analytics
</li>
<li className="flex items-center">
<span className="mr-3 h-2 w-2 rounded-full bg-muted-foreground"></span>
Team collaboration tools
</li>
</ul>
</div>
<div className="flex items-center justify-between border-t pt-4">
<Button
variant="ghost"
onClick={onBack}
className="flex items-center gap-2"
>
<ArrowLeft className="h-4 w-4" />
Back
</Button>
<Button
variant="destructive"
onClick={handleConfirmCancellation}
loading={loading}
>
Confirm cancellation
</Button>
</div>
</div>
</div>
</Modal>
);
}

View File

@@ -1,162 +0,0 @@
"use client";
import { ArrowLeft } from "lucide-react";
import { Button } from "@/components/ui/button";
import { DialogDescription, DialogTitle } from "@/components/ui/dialog";
import { Modal } from "@/components/ui/modal";
import { Textarea } from "@/components/ui/textarea";
type CancellationReason =
| "too-expensive"
| "not-using-enough"
| "missing-features"
| "technical-issues"
| "switching-competitor"
| "other";
interface FeedbackModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
reason: CancellationReason;
feedback: string;
onFeedbackChange: (feedback: string) => void;
onBack: () => void;
onContinue: () => void;
isFinalStep?: boolean;
}
export function FeedbackModal({
open,
onOpenChange,
reason,
feedback,
onFeedbackChange,
onBack,
onContinue,
isFinalStep = false,
}: FeedbackModalProps) {
const getTitle = () => {
if (isFinalStep) {
return "Sorry to see you go";
}
switch (reason) {
case "too-expensive":
return "Tell us about pricing";
case "not-using-enough":
return "Help us understand your usage";
case "missing-features":
return "What features do you need?";
case "technical-issues":
return "Tell us about the issues";
case "switching-competitor":
return "What's driving your decision?";
case "other":
return "Tell us more";
default:
return "Help us understand what we could improve";
}
};
const getSubtitle = () => {
if (isFinalStep) {
return "Please share the main reason for your cancellation to help us improve";
}
switch (reason) {
case "too-expensive":
return "What would make the pricing work better for your budget?";
case "not-using-enough":
return "Help us understand how we can make Papermark more valuable for your workflow.";
case "missing-features":
return "What features are you missing? This helps us prioritize our roadmap.";
case "technical-issues":
return "What technical problems have you encountered? We'd love to help resolve them.";
case "switching-competitor":
return "What does the competitor offer that we don't? This helps us improve.";
case "other":
return "We'd love to hear your specific feedback.";
default:
return "Help us understand what we could improve";
}
};
const getPlaceholder = () => {
if (isFinalStep) {
return "Please tell us the main reason you decided to cancel your subscription...";
}
switch (reason) {
case "too-expensive":
return "Tell us about your budget constraints or what pricing would work...";
case "not-using-enough":
return "Share how you use Papermark and what would make it more valuable...";
case "missing-features":
return "Tell us about the features you need...";
case "technical-issues":
return "Describe the technical problems you've experienced...";
case "switching-competitor":
return "Tell us about the competitor and what attracted you to them...";
case "other":
return "Share your thoughts...";
default:
return "Help us understand what we could improve...";
}
};
return (
<Modal
showModal={open}
setShowModal={(show: boolean | ((prev: boolean) => boolean)) => {
if (typeof show === "function") {
onOpenChange(show(open));
} else {
onOpenChange(show);
}
}}
className="max-w-lg"
>
<div className="flex flex-col items-center justify-center space-y-3 border-b border-border bg-white px-4 py-4 pt-8 dark:border-gray-900 dark:bg-gray-900 sm:px-8">
<DialogTitle className="text-2xl font-semibold">
{getTitle()}
</DialogTitle>
<DialogDescription className="text-center text-base text-muted-foreground">
{getSubtitle()}
</DialogDescription>
</div>
<div className="bg-muted px-4 py-8 dark:bg-gray-900 sm:px-8">
<div className="space-y-6">
<Textarea
placeholder={getPlaceholder()}
value={feedback}
onChange={(e) => onFeedbackChange(e.target.value)}
rows={6}
className="resize-none bg-white dark:bg-gray-800"
/>
<div className="flex items-center justify-between border-t pt-4">
{!isFinalStep && (
<Button
variant="ghost"
onClick={onBack}
className="flex items-center gap-2"
>
<ArrowLeft className="h-4 w-4" />
Back
</Button>
)}
{isFinalStep && <div />} {/* Spacer for final step */}
<Button
onClick={onContinue}
className="bg-gray-900 hover:bg-gray-800"
>
{isFinalStep ? "Submit" : "Continue"}
</Button>
</div>
</div>
</div>
</Modal>
);
}

View File

@@ -1,193 +0,0 @@
"use client";
import { useState } from "react";
import { useTeam } from "@/context/team-context";
import { ArrowLeft } from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { DialogDescription, DialogTitle } from "@/components/ui/dialog";
import { Modal } from "@/components/ui/modal";
type CancellationReason =
| "too-expensive"
| "not-using-enough"
| "missing-features"
| "technical-issues"
| "switching-competitor"
| "other";
interface RetentionOfferModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
reason: CancellationReason;
onBack: () => void;
onDecline: () => void;
onClose: () => void;
}
export function RetentionOfferModal({
open,
onOpenChange,
reason,
onBack,
onDecline,
onClose,
}: RetentionOfferModalProps) {
const [loading, setLoading] = useState(false);
const teamInfo = useTeam();
const getOfferDetails = () => {
switch (reason) {
case "too-expensive":
case "switching-competitor":
return {
title: "We hear you on the price",
subtitle: "Let us make this work for your budget",
discount: "25% off for 3 months",
savings: "€63",
monthlyDiscount: "€21/month",
duration: "over 3 months",
};
case "technical-issues":
return {
title: "Let us help you succeed",
subtitle: "50% off plus dedicated onboarding support",
discount: "50% off for 2 months + onboarding call",
savings: "€170",
monthlyDiscount: "€85/month",
duration: "over 2 months + free onboarding",
};
default:
return {
title: "Let's make this work",
subtitle: "25% off plus a call to address your specific needs",
discount: "25% off for 2 months + consultation call",
savings: "€42",
monthlyDiscount: "€21/month",
duration: "over 2 months + consultation call",
};
}
};
const offerDetails = getOfferDetails();
const handleAcceptOffer = async () => {
if (!teamInfo?.currentTeam?.id) {
toast.error("Team information not found");
return;
}
setLoading(true);
try {
const response = await fetch(
`/api/teams/${teamInfo.currentTeam.id}/billing/retention-offer`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
reason,
offerType: "discount",
}),
},
);
if (!response.ok) {
throw new Error("Failed to apply retention offer");
}
const data = await response.json();
toast.success("Offer applied successfully!");
onClose();
// Refresh the page to show updated billing status
window.location.reload();
} catch (error) {
console.error("Error applying retention offer:", error);
toast.error("Failed to apply offer. Please try again.");
} finally {
setLoading(false);
}
};
return (
<Modal
showModal={open}
setShowModal={(show: boolean | ((prev: boolean) => boolean)) => {
if (typeof show === "function") {
onOpenChange(show(open));
} else {
onOpenChange(show);
}
}}
className="max-w-lg"
>
<div className="flex flex-col items-center justify-center space-y-3 border-b border-border bg-white px-4 py-4 pt-8 dark:border-gray-900 dark:bg-gray-900 sm:px-8">
<DialogTitle className="text-2xl font-semibold">
{offerDetails.title}
</DialogTitle>
<DialogDescription className="text-center text-base text-muted-foreground">
{offerDetails.subtitle}
</DialogDescription>
</div>
<div className="bg-muted px-4 py-8 dark:bg-gray-900 sm:px-8">
<div className="space-y-6">
<div className="flex items-center justify-between">
<Badge
variant="secondary"
className="border-blue-200 bg-blue-50 text-blue-700"
>
Special offer
</Badge>
<span className="text-sm text-muted-foreground">Limited time</span>
</div>
<div className="text-center">
<h3 className="mb-2 text-xl font-semibold">
{offerDetails.discount}
</h3>
</div>
<div className="rounded-lg border border-green-200 bg-green-50 p-4">
<div className="mb-1 text-lg font-semibold text-green-800">
You save {offerDetails.savings}
</div>
<div className="text-sm text-green-700">
Save {offerDetails.monthlyDiscount} {offerDetails.duration}
</div>
</div>
<Button
onClick={handleAcceptOffer}
className="w-full"
size="lg"
loading={loading}
>
Accept this offer
</Button>
<div className="flex items-center justify-between border-t pt-4">
<Button
variant="ghost"
onClick={onBack}
className="flex items-center gap-2"
>
<ArrowLeft className="h-4 w-4" />
Back
</Button>
<Button variant="outline" onClick={onDecline}>
Decline offer
</Button>
</div>
</div>
</div>
</Modal>
);
}

View File

@@ -1,149 +0,0 @@
"use client";
import { useEffect, useState } from "react";
// Cal.com embed imports
import Cal, { getCalApi } from "@calcom/embed-react";
import { ArrowLeft } from "lucide-react";
import { useSession } from "next-auth/react";
import { Button } from "@/components/ui/button";
import { DialogDescription, DialogTitle } from "@/components/ui/dialog";
import { Modal } from "@/components/ui/modal";
type CancellationReason =
| "too-expensive"
| "not-using-enough"
| "missing-features"
| "technical-issues"
| "switching-competitor"
| "other";
interface ScheduleCallModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
reason: CancellationReason;
onBack: () => void;
onDecline: () => void;
}
export function ScheduleCallModal({
open,
onOpenChange,
reason,
onBack,
onDecline,
}: ScheduleCallModalProps) {
const [calLoaded, setCalLoaded] = useState(false);
const { data: session } = useSession();
useEffect(() => {
if (open) {
(async function () {
try {
const cal = await getCalApi({ namespace: "papermark-support" });
cal("ui", { hideEventTypeDetails: true, layout: "month_view" });
setCalLoaded(true);
} catch (error) {
console.error("Error loading Cal.com:", error);
setCalLoaded(true); // Set to true anyway to show fallback
}
})();
}
}, [open]);
const getTitle = () => {
switch (reason) {
case "missing-features":
return "Let's talk about what you need";
default:
return "Schedule a consultation call";
}
};
const getSubtitle = () => {
switch (reason) {
case "missing-features":
return "Our team will reach out to understand your requirements";
default:
return "We'd love to understand how we can better serve you";
}
};
return (
<Modal
showModal={open}
setShowModal={(show: boolean | ((prev: boolean) => boolean)) => {
if (typeof show === "function") {
onOpenChange(show(open));
} else {
onOpenChange(show);
}
}}
className="max-w-lg"
>
<div className="flex flex-col items-center justify-center space-y-3 border-b border-border bg-white px-4 py-4 pt-8 dark:border-gray-900 dark:bg-gray-900 sm:px-8">
<DialogTitle className="text-2xl font-semibold">
{getTitle()}
</DialogTitle>
<DialogDescription className="text-center text-base text-muted-foreground">
{getSubtitle()}
</DialogDescription>
</div>
<div className="bg-muted px-4 py-8 dark:bg-gray-900 sm:px-8">
<div className="space-y-6">
<div className="rounded-lg border border-green-200 bg-green-50 p-4">
<div className="mb-2 text-lg font-semibold text-green-800">
Free consultation
</div>
<div className="text-sm text-green-700">
30-minute call within 24 hours
</div>
</div>
{/* Cal.com embed container */}
<div className="max-h-[540px] rounded-lg border bg-white dark:bg-gray-800">
{calLoaded ? (
<Cal
namespace="papermark-support"
calLink={`marcseitz/papermark-support?email=${session?.user?.email}&name=${session?.user?.name}`}
style={{
width: "100%",
height: "540px",
overflow: "scroll",
}}
config={{ layout: "month_view" }}
/>
) : (
<div className="flex h-[500px] items-center justify-center">
<div className="text-center">
<div className="mb-2 text-lg font-semibold">
Loading calendar...
</div>
<div className="text-sm text-muted-foreground">
Please wait while we load the booking calendar
</div>
</div>
</div>
)}
</div>
<div className="flex items-center justify-between border-t pt-4">
<Button
variant="ghost"
onClick={onBack}
className="flex items-center gap-2"
>
<ArrowLeft className="h-4 w-4" />
Back
</Button>
<Button variant="outline" onClick={onDecline}>
Decline offer
</Button>
</div>
</div>
</div>
</Modal>
);
}

View File

@@ -202,12 +202,6 @@ export const sendConversationTeamMemberNotificationTask = task({
},
select: {
userId: true,
user: {
select: {
id: true,
email: true,
},
},
},
});

View File

@@ -0,0 +1,17 @@
import { isOldAccount } from "../utils";
const COUPON_MAP = {
monthly: {
old: "uAYqcOkk",
new: "BuzdmLfl",
},
yearly: {
old: "9VvXFpF0",
new: "pgJhUesw",
},
};
export const getCouponFromPlan = (plan: string, isAnnualPlan: boolean) => {
const period = isAnnualPlan ? "yearly" : "monthly";
return isOldAccount(plan) ? COUPON_MAP[period].old : COUPON_MAP[period].new;
};

View File

@@ -0,0 +1,7 @@
import { PLANS } from "../utils";
export function getDisplayNameFromPlan(planSlug: string) {
const cleanPlanName = planSlug.split("+")[0];
const plan = PLANS.find((p) => p.slug === cleanPlanName);
return plan?.name ?? "paid";
}

View File

@@ -1,11 +1,46 @@
import { stripeInstance } from "..";
export interface SubscriptionDiscount {
couponId: string;
percentOff?: number;
amountOff?: number;
duration: string;
durationInMonths?: number;
valid: boolean;
end?: number;
}
export default async function getSubscriptionItem(
subscriptionId: string,
isOldAccount: boolean,
) {
const stripe = stripeInstance(isOldAccount);
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
const subscriptionItemId = subscription.items.data[0].id;
return subscriptionItemId;
const subscription = await stripe.subscriptions.retrieve(subscriptionId, {
expand: ["discount.coupon"],
});
const subscriptionItem = subscription.items.data[0];
// Extract discount information if available
let discount: SubscriptionDiscount | null = null;
if (subscription.discount && subscription.discount.coupon) {
const coupon = subscription.discount.coupon;
discount = {
couponId: coupon.id,
percentOff: coupon.percent_off || undefined,
amountOff: coupon.amount_off || undefined,
duration: coupon.duration,
durationInMonths: coupon.duration_in_months || undefined,
valid: coupon.valid,
end: subscription.discount.end || undefined,
};
}
console.log(discount);
return {
id: subscriptionItem.id,
currentPeriodStart: subscription.current_period_start,
currentPeriodEnd: subscription.current_period_end,
discount,
};
}

View File

@@ -1,5 +1,6 @@
import { useTeam } from "@/context/team-context";
import { PLAN_NAME_MAP } from "@/ee/stripe/constants";
import { SubscriptionDiscount } from "@/ee/stripe/functions/get-subscription-item";
import useSWR from "swr";
import { fetcher } from "@/lib/utils";
@@ -47,8 +48,13 @@ type PlanWithOld = `${BasePlan}+old` | `${BasePlan}+drtrial+old`;
type PlanResponse = {
plan: BasePlan | PlanWithTrial | PlanWithOld;
startsAt: Date | null;
endsAt: Date | null;
pauseStartsAt: Date | null;
cancelledAt: Date | null;
isCustomer: boolean;
subscriptionCycle: "monthly" | "yearly";
discount: SubscriptionDiscount | null;
};
interface PlanDetails {
@@ -69,12 +75,19 @@ function parsePlan(plan: BasePlan | PlanWithTrial | PlanWithOld): PlanDetails {
};
}
export function usePlan() {
export function usePlan({
withDiscount = false,
}: { withDiscount?: boolean } = {}) {
const teamInfo = useTeam();
const teamId = teamInfo?.currentTeam?.id;
const { data: plan, error } = useSWR<PlanResponse>(
teamId && `/api/teams/${teamId}/billing/plan`,
const {
data: plan,
error,
mutate,
} = useSWR<PlanResponse>(
teamId &&
`/api/teams/${teamId}/billing/plan${withDiscount ? "?withDiscount=true" : ""}`,
fetcher,
);
@@ -86,11 +99,19 @@ export function usePlan() {
return {
plan: parsedPlan.plan ?? "free",
planName: PLAN_NAME_MAP[parsedPlan.plan ?? "free"],
originalPlan: parsedPlan.plan + (parsedPlan.old ? "+old" : ""),
trial: parsedPlan.trial,
isTrial: !!parsedPlan.trial,
isOldAccount: parsedPlan.old,
isCustomer: plan?.isCustomer,
isAnnualPlan: plan?.subscriptionCycle === "yearly",
startsAt: plan?.startsAt,
endsAt: plan?.endsAt,
cancelledAt: plan?.cancelledAt,
isPaused: !!plan?.pauseStartsAt,
isCancelled: !!plan?.cancelledAt,
pauseStartsAt: plan?.pauseStartsAt,
discount: plan?.discount || null,
isFree: parsedPlan.plan === "free",
isStarter: parsedPlan.plan === "starter",
isPro: parsedPlan.plan === "pro",
@@ -100,5 +121,6 @@ export function usePlan() {
isDataroomsPlus: parsedPlan.plan === "datarooms-plus",
loading: !plan && !error,
error,
mutate,
};
}

View File

@@ -0,0 +1,3 @@
import { sendPauseResumeNotificationTask } from "@/ee/features/billing/cancellation/lib/trigger/pause-resume-notification";
export { sendPauseResumeNotificationTask };

View File

@@ -170,6 +170,15 @@ export const timeAgo = (timestamp?: Date): string => {
return `${ms(diff)} ago`;
};
export const timeIn = (timestamp?: Date): string => {
if (!timestamp) return "Just now";
const diff = new Date(timestamp).getTime() - Date.now();
if (diff < 60000) {
return "Just now";
}
return `in ${ms(diff, { long: true })}`;
};
export const durationFormat = (durationInMilliseconds?: number): string => {
if (!durationInMilliseconds) return "0 secs";

647
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -20,11 +20,12 @@
"dev:prisma": "npx prisma generate && npx prisma migrate deploy"
},
"dependencies": {
"@aws-sdk/client-lambda": "^3.855.0",
"@aws-sdk/client-s3": "^3.855.0",
"@aws-sdk/cloudfront-signer": "^3.821.0",
"@aws-sdk/lib-storage": "^3.855.0",
"@aws-sdk/s3-request-presigner": "^3.855.0",
"@aws-sdk/client-lambda": "^3.859.0",
"@aws-sdk/client-s3": "^3.859.0",
"@aws-sdk/cloudfront-signer": "^3.858.0",
"@aws-sdk/lib-storage": "^3.859.0",
"@aws-sdk/s3-request-presigner": "^3.859.0",
"@calcom/embed-react": "^1.5.3",
"@chronark/zod-bird": "^0.3.10",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
@@ -32,7 +33,7 @@
"@github/webauthn-json": "^2.1.1",
"@jitsu/js": "^1.10.3",
"@next-auth/prisma-adapter": "^1.0.7",
"@next/third-parties": "^15.4.4",
"@next/third-parties": "^15.4.5",
"@pdf-lib/fontkit": "^1.1.1",
"@prisma/client": "^6.5.0",
"@radix-ui/react-accordion": "^1.2.11",
@@ -68,12 +69,12 @@
"@tus/s3-store": "^1.9.1",
"@tus/server": "^1.10.2",
"@tus/utils": "^0.5.1",
"@upstash/qstash": "^2.8.1",
"@upstash/qstash": "^2.8.2",
"@upstash/ratelimit": "^2.0.6",
"@upstash/redis": "^1.35.2",
"@upstash/redis": "^1.35.3",
"@vercel/blob": "^1.1.1",
"@vercel/edge-config": "^1.4.0",
"@vercel/functions": "^2.2.5",
"@vercel/functions": "^2.2.7",
"ai": "2.2.37",
"autoprefixer": "^10.4.21",
"base-x": "^5.0.1",
@@ -85,19 +86,19 @@
"cmdk": "^1.1.1",
"date-fns": "^3.6.0",
"eslint": "8.57.0",
"eslint-config-next": "^14.2.30",
"eslint-config-next": "^14.2.31",
"exceljs": "^4.4.0",
"fluent-ffmpeg": "^2.1.3",
"input-otp": "^1.4.2",
"js-cookie": "^3.0.5",
"jsonwebtoken": "^9.0.2",
"lucide-react": "^0.525.0",
"lucide-react": "^0.536.0",
"mime-types": "^3.0.1",
"motion": "^12.23.11",
"motion": "^12.23.12",
"ms": "^2.1.3",
"mupdf": "^1.3.6",
"nanoid": "^5.1.5",
"next": "^14.2.30",
"next": "^14.2.31",
"next-auth": "^4.24.11",
"next-plausible": "^3.12.4",
"next-themes": "^0.4.6",
@@ -107,7 +108,7 @@
"openai": "4.20.1",
"pdf-lib": "^1.17.1",
"postcss": "^8.5.6",
"posthog-js": "^1.258.2",
"posthog-js": "^1.258.5",
"react": "^18.3.1",
"react-colorful": "^5.6.1",
"react-day-picker": "^8.10.1",
@@ -124,8 +125,8 @@
"react-zoom-pan-pinch": "^3.7.0",
"resend": "^4.7.0",
"sanitize-html": "^2.17.0",
"shiki": "^3.8.1",
"sonner": "^2.0.6",
"shiki": "^3.9.2",
"sonner": "^2.0.7",
"stripe": "^16.12.0",
"swr": "^2.3.4",
"tailwind-merge": "^2.6.0",

View File

@@ -0,0 +1,2 @@
// API route: /api/jobs/send-pause-resume-notification
export { default } from "@/ee/features/billing/cancellation/api/send-pause-resume-notification";

View File

@@ -1,113 +1,15 @@
import { NextApiRequest, NextApiResponse } from "next";
import { stripeInstance } from "@/ee/stripe";
import { authOptions } from "@/pages/api/auth/[...nextauth]";
import { getServerSession } from "next-auth/next";
import { handleRoute } from "@/ee/features/billing/cancellation/api/cancel-route";
import prisma from "@/lib/prisma";
import { CustomUser } from "@/lib/types";
import { log } from "@/lib/utils";
export const config = {
// in order to enable `waitUntil` function
supportsResponseStreaming: true,
};
export default async function handle(
req: NextApiRequest,
res: NextApiResponse,
) {
if (req.method === "POST") {
// POST /api/teams/:teamId/billing/cancel cancel a user's subscription
const session = await getServerSession(req, res, authOptions);
if (!session) {
res.status(401).end("Unauthorized");
return;
}
const userId = (session.user as CustomUser).id;
const { teamId } = req.query as { teamId: string };
const { reason, feedback } = req.body as {
reason: string;
feedback?: string;
};
try {
const team = await prisma.team.findUnique({
where: {
id: teamId,
users: {
some: {
userId: userId,
},
},
},
select: {
id: true,
stripeId: true,
subscriptionId: true,
plan: true,
},
});
if (!team) {
return res.status(400).json({ error: "Team does not exist" });
}
if (!team.stripeId) {
return res.status(400).json({ error: "No Stripe customer ID" });
}
if (!team.subscriptionId) {
return res.status(400).json({ error: "No subscription ID" });
}
const isOldAccount = team.plan.includes("+old");
const stripe = stripeInstance(isOldAccount);
// Cancel the subscription in Stripe (at period end)
const cancelledSubscription = await stripe.subscriptions.update(
team.subscriptionId,
{
cancel_at_period_end: true,
cancellation_details: {
comment:
feedback || `Customer cancelled via flow - reason: ${reason}`,
feedback: reason,
},
metadata: {
cancelled_at: new Date().toISOString(),
cancellation_reason: reason,
user_feedback: feedback || "",
},
},
);
// Log the cancellation with feedback
await prisma.team.update({
where: { id: teamId },
data: {
cancellationReason: reason,
cancellationFeedback: feedback,
cancelledAt: new Date(),
},
});
await log({
message: `Team ${teamId} cancelled their subscription. Reason: ${reason}. Feedback: ${feedback || "None"}. Subscription will end at period end: ${new Date(cancelledSubscription.current_period_end * 1000).toISOString()}`,
type: "info",
});
res.status(200).json({
success: true,
message: "Subscription cancelled successfully",
endsAt: new Date(cancelledSubscription.current_period_end * 1000),
});
} catch (error) {
console.error("Error cancelling subscription:", error);
await log({
message: `Error cancelling subscription for team ${teamId}: ${error}`,
type: "error",
});
res.status(500).json({ error: "Failed to cancel subscription" });
}
} else {
res.setHeader("Allow", ["POST"]);
res.status(405).end(`Method ${req.method} Not Allowed`);
}
return handleRoute(req, res);
}

View File

@@ -0,0 +1,15 @@
import { NextApiRequest, NextApiResponse } from "next";
import { handleRoute } from "@/ee/features/billing/cancellation/api/cancellation-feedback-route";
export const config = {
// in order to enable `waitUntil` function
supportsResponseStreaming: true,
};
export default async function handle(
req: NextApiRequest,
res: NextApiResponse,
) {
return handleRoute(req, res);
}

View File

@@ -1,10 +1,9 @@
import { NextApiRequest, NextApiResponse } from "next";
import { stripeInstance } from "@/ee/stripe";
import { getPriceIdFromPlan } from "@/ee/stripe/functions/get-price-id-from-plan";
import { getQuantityFromPriceId } from "@/ee/stripe/functions/get-quantity-from-plan";
import getSubscriptionItem from "@/ee/stripe/functions/get-subscription-item";
import { PLANS, isOldAccount } from "@/ee/stripe/utils";
import { isOldAccount } from "@/ee/stripe/utils";
import { waitUntil } from "@vercel/functions";
import { getServerSession } from "next-auth/next";
@@ -43,6 +42,7 @@ export default async function handle(
addSeat,
proAnnualBanner,
return_url,
type = "manage",
} = req.body as {
priceId: string;
upgradePlan: boolean;
@@ -50,6 +50,11 @@ export default async function handle(
addSeat?: boolean;
proAnnualBanner?: boolean;
return_url?: string;
type?:
| "manage"
| "invoices"
| "subscription_update"
| "payment_method_update";
};
try {
const team = await prisma.team.findUnique({
@@ -79,7 +84,11 @@ export default async function handle(
return res.status(400).json({ error: "No subscription ID" });
}
const subscriptionItemId = await getSubscriptionItem(
const {
id: subscriptionItemId,
currentPeriodStart,
currentPeriodEnd,
} = await getSubscriptionItem(
team.subscriptionId,
isOldAccount(team.plan),
);
@@ -90,7 +99,8 @@ export default async function handle(
const { url } = await stripe.billingPortal.sessions.create({
customer: team.stripeId,
return_url: `${process.env.NEXTAUTH_URL}/settings/billing?cancel=true`,
...((upgradePlan || addSeat) &&
...(type === "manage" &&
(upgradePlan || addSeat) &&
subscriptionItemId && {
flow_data: {
type: "subscription_update_confirm",
@@ -116,6 +126,14 @@ export default async function handle(
},
},
}),
...(type === "subscription_update" && {
flow_data: {
type: "subscription_update",
subscription_update: {
subscription: team.subscriptionId,
},
},
}),
});
waitUntil(identifyUser(userEmail ?? userId));

View File

@@ -1,120 +1,15 @@
import { NextApiRequest, NextApiResponse } from "next";
import { PAUSED_PLAN_LIMITS } from "@/ee/limits/constants";
import { stripeInstance } from "@/ee/stripe";
import { authOptions } from "@/pages/api/auth/[...nextauth]";
import { getServerSession } from "next-auth/next";
import { handleRoute } from "@/ee/features/billing/cancellation/api/pause-route";
import prisma from "@/lib/prisma";
import { CustomUser } from "@/lib/types";
import { log } from "@/lib/utils";
export const config = {
// in order to enable `waitUntil` function
supportsResponseStreaming: true,
};
export default async function handle(
req: NextApiRequest,
res: NextApiResponse,
) {
if (req.method === "POST") {
// POST /api/teams/:teamId/billing/pause pause a user's subscription
const session = await getServerSession(req, res, authOptions);
if (!session) {
res.status(401).end("Unauthorized");
return;
}
const userId = (session.user as CustomUser).id;
const { teamId } = req.query as { teamId: string };
try {
const team = await prisma.team.findUnique({
where: {
id: teamId,
users: {
some: {
userId: userId,
},
},
},
select: {
id: true,
stripeId: true,
subscriptionId: true,
plan: true,
limits: true,
},
});
if (!team) {
return res.status(400).json({ error: "Team does not exist" });
}
if (!team.stripeId) {
return res.status(400).json({ error: "No Stripe customer ID" });
}
if (!team.subscriptionId) {
return res.status(400).json({ error: "No subscription ID" });
}
// Check if already paused
if (team.plan.includes("paused")) {
return res.status(400).json({ error: "Subscription already paused" });
}
const isOldAccount = team.plan.includes("+old");
const stripe = stripeInstance(isOldAccount);
// Pause the subscription in Stripe
await stripe.subscriptions.update(team.subscriptionId, {
pause_collection: {
behavior: "void",
},
metadata: {
paused_at: new Date().toISOString(),
paused_reason: "user_request",
original_plan: team.plan,
},
});
// Calculate pause end date (3 months from now)
const pauseEndDate = new Date();
pauseEndDate.setMonth(pauseEndDate.getMonth() + 3);
// Update team with paused status and limits
const pausedPlanLimits = {
...team.limits,
...PAUSED_PLAN_LIMITS,
};
await prisma.team.update({
where: { id: teamId },
data: {
plan: `${team.plan}+paused`,
limits: pausedPlanLimits,
pausedAt: new Date(),
pauseEndsAt: pauseEndDate,
},
});
await log({
message: `Team ${teamId} paused their subscription for 3 months`,
type: "info",
});
res.status(200).json({
success: true,
message: "Subscription paused successfully",
pauseEndsAt: pauseEndDate,
});
} catch (error) {
console.error("Error pausing subscription:", error);
await log({
message: `Error pausing subscription for team ${teamId}: ${error}`,
type: "error",
});
res.status(500).json({ error: "Failed to pause subscription" });
}
} else {
res.setHeader("Allow", ["POST"]);
res.status(405).end(`Method ${req.method} Not Allowed`);
}
return handleRoute(req, res);
}

View File

@@ -1,5 +1,9 @@
import { NextApiRequest, NextApiResponse } from "next";
import getSubscriptionItem, {
SubscriptionDiscount,
} from "@/ee/stripe/functions/get-subscription-item";
import { isOldAccount } from "@/ee/stripe/utils";
import { getServerSession } from "next-auth/next";
import { errorhandler } from "@/lib/errorHandler";
@@ -21,6 +25,7 @@ export default async function handle(
const { teamId } = req.query as { teamId: string };
const userId = (session.user as CustomUser).id;
const withDiscount = req.query.withDiscount === "true";
try {
const team = await prisma.team.findUnique({
@@ -35,8 +40,11 @@ export default async function handle(
select: {
plan: true,
stripeId: true,
subscriptionId: true,
startsAt: true,
endsAt: true,
pauseStartsAt: true,
cancelledAt: true,
},
});
@@ -53,10 +61,35 @@ export default async function handle(
subscriptionCycle = durationInDays > 31 ? "yearly" : "monthly";
}
// Fetch discount information if team has an active subscription
let discount: SubscriptionDiscount | null = null;
if (
withDiscount &&
team?.subscriptionId &&
team.plan &&
team.plan !== "free"
) {
try {
const subscriptionData = await getSubscriptionItem(
team.subscriptionId,
isOldAccount(team.plan),
);
discount = subscriptionData.discount;
} catch (error) {
// If we can't fetch discount info, just log and continue without it
console.error("Failed to fetch discount information:", error);
}
}
return res.status(200).json({
plan: team?.plan,
startsAt: team?.startsAt,
endsAt: team?.endsAt,
isCustomer,
subscriptionCycle,
pauseStartsAt: team?.pauseStartsAt,
cancelledAt: team?.cancelledAt,
discount,
});
} catch (error) {
errorhandler(error, res);

View File

@@ -0,0 +1,15 @@
import { NextApiRequest, NextApiResponse } from "next";
import { handleRoute } from "@/ee/features/billing/cancellation/api/reactivate-route";
export const config = {
// in order to enable `waitUntil` function
supportsResponseStreaming: true,
};
export default async function handle(
req: NextApiRequest,
res: NextApiResponse,
) {
return handleRoute(req, res);
}

View File

@@ -1,124 +1,15 @@
import { NextApiRequest, NextApiResponse } from "next";
import { stripeInstance } from "@/ee/stripe";
import { authOptions } from "@/pages/api/auth/[...nextauth]";
import { getServerSession } from "next-auth/next";
import { handleRoute } from "@/ee/features/billing/cancellation/api/retention-route";
import prisma from "@/lib/prisma";
import { CustomUser } from "@/lib/types";
import { log } from "@/lib/utils";
export const config = {
// in order to enable `waitUntil` function
supportsResponseStreaming: true,
};
export default async function handle(
req: NextApiRequest,
res: NextApiResponse,
) {
if (req.method === "POST") {
// POST /api/teams/:teamId/billing/retention-offer apply retention offer
const session = await getServerSession(req, res, authOptions);
if (!session) {
res.status(401).end("Unauthorized");
return;
}
const userId = (session.user as CustomUser).id;
const { teamId } = req.query as { teamId: string };
const { reason, offerType } = req.body as {
reason: string;
offerType: string;
};
try {
const team = await prisma.team.findUnique({
where: {
id: teamId,
users: {
some: {
userId: userId,
},
},
},
select: {
id: true,
stripeId: true,
subscriptionId: true,
plan: true,
},
});
if (!team) {
return res.status(400).json({ error: "Team does not exist" });
}
if (!team.stripeId) {
return res.status(400).json({ error: "No Stripe customer ID" });
}
if (!team.subscriptionId) {
return res.status(400).json({ error: "No subscription ID" });
}
const isOldAccount = team.plan.includes("+old");
const stripe = stripeInstance(isOldAccount);
// Determine discount based on reason
let discountPercent = 25;
let durationMonths = 3;
if (reason === "technical-issues") {
discountPercent = 50;
durationMonths = 2;
} else if (
reason === "too-expensive" ||
reason === "switching-competitor"
) {
discountPercent = 25;
durationMonths = 3;
}
// Create a discount coupon in Stripe
const coupon = await stripe.coupons.create({
percent_off: discountPercent,
duration: "repeating",
duration_in_months: durationMonths,
name: `Retention Offer - ${discountPercent}% off for ${durationMonths} months`,
metadata: {
reason,
team_id: teamId,
applied_at: new Date().toISOString(),
},
});
// Apply the coupon to the subscription
await stripe.subscriptions.update(team.subscriptionId, {
discounts: [{ coupon: coupon.id }],
metadata: {
retention_offer_applied: new Date().toISOString(),
retention_reason: reason,
},
});
await log({
message: `Retention offer applied to team ${teamId}: ${discountPercent}% off for ${durationMonths} months (reason: ${reason})`,
type: "info",
});
res.status(200).json({
success: true,
message: "Retention offer applied successfully",
discount: discountPercent,
duration: durationMonths,
couponId: coupon.id,
});
} catch (error) {
console.error("Error applying retention offer:", error);
await log({
message: `Error applying retention offer for team ${teamId}: ${error}`,
type: "error",
});
res.status(500).json({ error: "Failed to apply retention offer" });
}
} else {
res.setHeader("Allow", ["POST"]);
res.status(405).end(`Method ${req.method} Not Allowed`);
}
return handleRoute(req, res);
}

View File

@@ -0,0 +1,15 @@
import { NextApiRequest, NextApiResponse } from "next";
import { handleRoute } from "@/ee/features/billing/cancellation/api/unpause-route";
export const config = {
// in order to enable `waitUntil` function
supportsResponseStreaming: true,
};
export default async function handle(
req: NextApiRequest,
res: NextApiResponse,
) {
return handleRoute(req, res);
}

View File

@@ -33,12 +33,18 @@ export default function Billing() {
});
sendGTMEvent({ event: "upgraded" });
// Remove the success query parameter
router.replace("/settings/billing", undefined, { shallow: true });
}
if (router.query.cancel) {
analytics.capture("Stripe Checkout Cancelled", {
teamId: teamId,
});
// Remove the cancel query parameter
router.replace("/settings/billing", undefined, { shallow: true });
}
}, [router.query]);

View File

@@ -0,0 +1,6 @@
-- AlterTable
ALTER TABLE "Team" ADD COLUMN "cancelledAt" TIMESTAMP(3),
ADD COLUMN "pauseEndsAt" TIMESTAMP(3),
ADD COLUMN "pauseStartsAt" TIMESTAMP(3),
ADD COLUMN "pausedAt" TIMESTAMP(3);

View File

@@ -64,55 +64,6 @@ model User {
messages Message[]
}
model Team {
id String @id @default(cuid())
name String
users UserTeam[]
documents Document[]
folders Folder[]
domains Domain[]
invitations Invitation[]
sentEmails SentEmail[]
brand Brand?
datarooms Dataroom[]
agreements Agreement[]
viewerGroups ViewerGroup[]
viewers Viewer[]
permissionGroups PermissionGroup[]
links Link[]
views View[]
plan String @default("free")
stripeId String? @unique // Stripe customer ID
subscriptionId String? @unique // Stripe subscription ID
startsAt DateTime? // Stripe subscription start date
endsAt DateTime? // Stripe subscription end date
linkPresets LinkPreset[] // Link presets for the team
limits Json? // Plan limits // {datarooms: 1, users: 1, domains: 1, customDomainOnPro: boolean, customDomainInDataroom: boolean}
incomingWebhooks IncomingWebhook[]
restrictedTokens RestrictedToken[]
webhooks Webhook[]
conversations Conversation[]
uploadedDocuments DocumentUpload[]
// team settings
enableExcelAdvancedMode Boolean @default(false) // Enable Excel advanced mode for all documents in the team
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
Tag Tag[]
ignoredDomains String[] // Domains that are ignored for the team
globalBlockList String[]
}
model Brand {
id String @id @default(cuid())
logo String? // This should be a reference to where the file is stored (S3, Google Cloud Storage, etc.)
@@ -125,28 +76,6 @@ model Brand {
updatedAt DateTime @updatedAt
}
enum Role {
ADMIN
MANAGER
MEMBER
}
model UserTeam {
role Role @default(MEMBER)
status String @default("ACTIVE")
userId String
teamId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
blockedAt DateTime? // When the user was blocked
notificationPreferences Json? // Format: { yearInReview: { "enabled": false } }
@@id([userId, teamId])
@@index([userId])
@@index([teamId])
}
model VerificationToken {
identifier String
token String @unique

70
prisma/schema/team.prisma Normal file
View File

@@ -0,0 +1,70 @@
model Team {
id String @id @default(cuid())
name String
users UserTeam[]
documents Document[]
folders Folder[]
domains Domain[]
invitations Invitation[]
sentEmails SentEmail[]
brand Brand?
datarooms Dataroom[]
agreements Agreement[]
viewerGroups ViewerGroup[]
viewers Viewer[]
permissionGroups PermissionGroup[]
linkPresets LinkPreset[] // Link presets for the team
incomingWebhooks IncomingWebhook[]
restrictedTokens RestrictedToken[]
webhooks Webhook[]
conversations Conversation[]
uploadedDocuments DocumentUpload[]
Tag Tag[]
links Link[]
views View[]
plan String @default("free")
stripeId String? @unique // Stripe customer ID
subscriptionId String? @unique // Stripe subscription ID
startsAt DateTime? // Stripe subscription start date
endsAt DateTime? // Stripe subscription end date
pausedAt DateTime? // When the subscription was paused
pauseStartsAt DateTime? // When the pause period starts
pauseEndsAt DateTime? // When the pause period ends
cancelledAt DateTime? // When the subscription was cancelled
limits Json? // Plan limits // {datarooms: 1, users: 1, domains: 1, customDomainOnPro: boolean, customDomainInDataroom: boolean}
// team settings
enableExcelAdvancedMode Boolean @default(false) // Enable Excel advanced mode for all documents in the team
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
ignoredDomains String[] // Domains that are ignored for the team
globalBlockList String[] // Email and domains that are blocked for the team
}
enum Role {
ADMIN
MANAGER
MEMBER
}
model UserTeam {
role Role @default(MEMBER)
status String @default("ACTIVE")
userId String
teamId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
blockedAt DateTime? // When the user was blocked
notificationPreferences Json? // Format: { yearInReview: { "enabled": false } }
@@id([userId, teamId])
@@index([userId])
@@index([teamId])
}