mirror of
https://github.com/mfts/papermark.git
synced 2025-12-20 01:03:24 +08:00
feat(ee): add native billing flow
This commit is contained in:
@@ -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">
|
||||
|
||||
141
ee/emails/pause-resume-reminder.tsx
Normal file
141
ee/emails/pause-resume-reminder.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
92
ee/features/billing/cancellation/api/cancel-route.ts
Normal file
92
ee/features/billing/cancellation/api/cancel-route.ts
Normal 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`);
|
||||
}
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
130
ee/features/billing/cancellation/api/pause-route.ts
Normal file
130
ee/features/billing/cancellation/api/pause-route.ts
Normal 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`);
|
||||
}
|
||||
}
|
||||
95
ee/features/billing/cancellation/api/reactivate-route.ts
Normal file
95
ee/features/billing/cancellation/api/reactivate-route.ts
Normal 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`);
|
||||
}
|
||||
}
|
||||
103
ee/features/billing/cancellation/api/retention-route.ts
Normal file
103
ee/features/billing/cancellation/api/retention-route.ts
Normal 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`);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
119
ee/features/billing/cancellation/api/unpause-route.ts
Normal file
119
ee/features/billing/cancellation/api/unpause-route.ts
Normal 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`);
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
144
ee/features/billing/cancellation/components/feedback-modal.tsx
Normal file
144
ee/features/billing/cancellation/components/feedback-modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
6
ee/features/billing/cancellation/lib/constants.ts
Normal file
6
ee/features/billing/cancellation/lib/constants.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export type CancellationReason =
|
||||
| "too_expensive"
|
||||
| "unused"
|
||||
| "missing_features"
|
||||
| "switched_service"
|
||||
| "other";
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
137
ee/features/cancellation/api/pause-route.ts
Normal file
137
ee/features/cancellation/api/pause-route.ts
Normal 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`);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -202,12 +202,6 @@ export const sendConversationTeamMemberNotificationTask = task({
|
||||
},
|
||||
select: {
|
||||
userId: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
17
ee/stripe/functions/get-coupon-from-plan.ts
Normal file
17
ee/stripe/functions/get-coupon-from-plan.ts
Normal 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;
|
||||
};
|
||||
7
ee/stripe/functions/get-display-name-from-plan.ts
Normal file
7
ee/stripe/functions/get-display-name-from-plan.ts
Normal 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";
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
3
lib/trigger/pause-reminder-notification.ts
Normal file
3
lib/trigger/pause-reminder-notification.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { sendPauseResumeNotificationTask } from "@/ee/features/billing/cancellation/lib/trigger/pause-resume-notification";
|
||||
|
||||
export { sendPauseResumeNotificationTask };
|
||||
@@ -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
647
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
33
package.json
33
package.json
@@ -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",
|
||||
|
||||
2
pages/api/jobs/send-pause-resume-notification.ts
Normal file
2
pages/api/jobs/send-pause-resume-notification.ts
Normal 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";
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
15
pages/api/teams/[teamId]/billing/cancellation-feedback.ts
Normal file
15
pages/api/teams/[teamId]/billing/cancellation-feedback.ts
Normal 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);
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
15
pages/api/teams/[teamId]/billing/reactivate.ts
Normal file
15
pages/api/teams/[teamId]/billing/reactivate.ts
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
15
pages/api/teams/[teamId]/billing/unpause.ts
Normal file
15
pages/api/teams/[teamId]/billing/unpause.ts
Normal 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);
|
||||
}
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
70
prisma/schema/team.prisma
Normal 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])
|
||||
}
|
||||
Reference in New Issue
Block a user