mirror of
https://github.com/hanzoai/dataroom.git
synced 2026-08-05 08:32:18 +00:00
ci: amd64-only build (matches hanzo runner pool topology)
hanzo-build-linux-amd64 is the only ARC runner pool installed on hanzo-k8s today (arm64 pool is paused until DigitalOcean ships arm64 droplets, per ~/work/hanzo/.github/RUNNERS.md). The arm64 job was waiting on spark-hanzoai-arm64 — not provisioned in this cluster — so every build sat in queue forever and the amd64 leg got cancelled. Set platforms: linux/amd64 on the reusable build call to skip arm64 entirely. Re-add arm64 if/when DO ships arm64 droplets and the spark runner pool comes back online.
This commit is contained in:
@@ -16,6 +16,7 @@ jobs:
|
||||
uses: hanzoai/.github/.github/workflows/docker-build.yml@main
|
||||
with:
|
||||
image: ghcr.io/hanzoai/dataroom
|
||||
platforms: linux/amd64
|
||||
deploy: true
|
||||
deploy-deployment: dataroom
|
||||
deploy-namespace: hanzo
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
The Hanzo Dataroom Commercial License (the “Commercial License”)
|
||||
Copyright (c) 2023-present Hanzo AI, Inc.
|
||||
|
||||
With regard to the Hanzo Dataroom Software:
|
||||
|
||||
This software and associated documentation files (the "Software") may only be
|
||||
used in production, if you (and any entity that you represent) have agreed to,
|
||||
and are in compliance with, an agreement governing
|
||||
the use of the Software, as mutually agreed by you and Hanzo AI, Inc. ("Hanzo Dataroom"),
|
||||
and otherwise have a valid Hanzo Dataroom Enterprise Edition subscription ("Commercial Subscription").
|
||||
Subject to the foregoing sentence, you are free to modify this Software and publish patches to the Software.
|
||||
You agree that Hanzo Dataroom and/or its licensors (as applicable) retain all right, title and interest in
|
||||
and to all such modifications and/or patches, and all such modifications and/or
|
||||
patches may only be used, copied, modified, displayed, distributed, or otherwise
|
||||
exploited with a valid Commercial Subscription for the correct number of hosts.
|
||||
Notwithstanding the foregoing, you may copy and modify the Software for development
|
||||
and testing purposes, without requiring a subscription. You agree that Hanzo Dataroom and/or
|
||||
its licensors (as applicable) retain all right, title and interest in and to all such
|
||||
modifications. You are not granted any other rights beyond what is expressly stated herein.
|
||||
Subject to the foregoing, it is forbidden to copy, merge, publish, distribute, sublicense,
|
||||
and/or sell the Software.
|
||||
|
||||
This Commercial License applies only to the part of this Software that is not distributed under
|
||||
the AGPLv3 license. Any part of this Software distributed under the MIT license or which
|
||||
is served client-side as an image, font, cascading stylesheet (CSS), file which produces
|
||||
or is compiled, arranged, augmented, or combined into client-side JavaScript, in whole or
|
||||
in part, is copyrighted under the AGPLv3 license. The full text of this Commercial License shall
|
||||
be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
For all third party components incorporated into the Hanzo Dataroom Software, those
|
||||
components are licensed under the original license provided by the owner of the
|
||||
applicable component.
|
||||
@@ -1,16 +0,0 @@
|
||||
<div align="center">
|
||||
<h1 align="center">Hanzo Dataroom</h1>
|
||||
<a href="https://www.dataroom.hanzo.ai/enterprise">Get an Enterprise License</a>
|
||||
</div>
|
||||
|
||||
# Enterprise Edition
|
||||
|
||||
Welcome to the Enterprise Edition of Hanzo Dataroom.
|
||||
|
||||
The [/(ee)](https://github.com/hanzoai/dataroom/tree/main/ee) subfolder is the place for all the **Enterprise Edition** features from our [hosted](https://www.dataroom.hanzo.ai/pricing) plan and enterprise-grade features for [Enterprise](https://www.dataroom.hanzo.ai/enterprise), included but not limited to the following:
|
||||
|
||||
- Data Rooms
|
||||
- Q&A Conversations
|
||||
- Advanced Permissions
|
||||
|
||||
> _❗ WARNING: This repository is copyrighted. You are not allowed to use this code to host your own version of app.dataroom.hanzo.ai without obtaining a proper [license](https://www.dataroom.hanzo.ai/enterprise) first❗_
|
||||
@@ -1,141 +0,0 @@
|
||||
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://dataroom.hanzo.ai";
|
||||
|
||||
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="Hanzo Dataroom"
|
||||
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 Hanzo Dataroom 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 Hanzo Dataroom. If you believe this was sent in error, please
|
||||
contact our support team.
|
||||
</Text>
|
||||
|
||||
<Text className="text-[12px] leading-[16px] text-[#666]">
|
||||
Hanzo Dataroom - The secure document sharing platform
|
||||
</Text>
|
||||
</Section>
|
||||
</Container>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { stripeInstance } from "@/ee/stripe";
|
||||
import { isOldAccount } from "@/ee/stripe/utils";
|
||||
import { waitUntil } from "@vercel/functions";
|
||||
import { timingSafeEqual } from "crypto";
|
||||
|
||||
import prisma from "@/lib/prisma";
|
||||
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") {
|
||||
res.setHeader("Allow", ["POST"]);
|
||||
res.status(405).end(`Method ${req.method} Not Allowed`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract the API Key from the Authorization header with strict Bearer parsing
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
// Verify Authorization header exists and starts with "Bearer "
|
||||
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
||||
res.status(401).json({ message: "Unauthorized" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract token after "Bearer " prefix
|
||||
const token = authHeader.substring(7); // "Bearer ".length === 7
|
||||
const envKey = process.env.INTERNAL_API_KEY;
|
||||
|
||||
// Verify both token and environment key are present and have equal length
|
||||
if (!token || !envKey || token.length !== envKey.length) {
|
||||
res.status(401).json({ message: "Unauthorized" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Perform timing-safe comparison using Buffers
|
||||
try {
|
||||
const tokenBuffer = Buffer.from(token, "utf8");
|
||||
const envKeyBuffer = Buffer.from(envKey, "utf8");
|
||||
|
||||
if (!timingSafeEqual(tokenBuffer, envKeyBuffer)) {
|
||||
res.status(401).json({ message: "Unauthorized" });
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
// Handle any Buffer creation errors
|
||||
res.status(401).json({ message: "Unauthorized" });
|
||||
return;
|
||||
}
|
||||
|
||||
const { teamId } = req.body;
|
||||
|
||||
if (!teamId) {
|
||||
return res.status(400).json({ error: "teamId is required" });
|
||||
}
|
||||
|
||||
try {
|
||||
// Get team details and verify it's still paused
|
||||
const team = await prisma.team.findUnique({
|
||||
where: {
|
||||
id: teamId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
plan: true,
|
||||
subscriptionId: true,
|
||||
pauseStartsAt: true,
|
||||
pauseEndsAt: true,
|
||||
pausedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
return res.status(404).json({ error: "Team not found" });
|
||||
}
|
||||
|
||||
// Check if team is still paused
|
||||
if (!team.pausedAt || !team.pauseStartsAt || !team.pauseEndsAt) {
|
||||
return res.status(400).json({
|
||||
error: "Team is no longer paused",
|
||||
teamId,
|
||||
pausedAt: team.pausedAt,
|
||||
pauseStartsAt: team.pauseStartsAt,
|
||||
pauseEndsAt: team.pauseEndsAt,
|
||||
});
|
||||
}
|
||||
|
||||
// Check if we're past the pause end date
|
||||
const now = new Date();
|
||||
if (now < team.pauseEndsAt) {
|
||||
return res.status(400).json({
|
||||
error: "Pause period has not ended yet",
|
||||
teamId,
|
||||
pauseEndsAt: team.pauseEndsAt,
|
||||
currentTime: now,
|
||||
});
|
||||
}
|
||||
|
||||
if (!team.subscriptionId) {
|
||||
return res.status(400).json({
|
||||
error: "No subscription ID found",
|
||||
teamId,
|
||||
});
|
||||
}
|
||||
|
||||
// Perform the automatic unpause
|
||||
const isOld = isOldAccount(team.plan);
|
||||
const stripe = stripeInstance(isOld);
|
||||
|
||||
// First, check the subscription to determine if it was paused with old method or new method
|
||||
const subscription = await stripe.subscriptions.retrieve(
|
||||
team.subscriptionId,
|
||||
);
|
||||
|
||||
// Check if this subscription was paused using the old pause_collection method
|
||||
const isOldPauseMethod = subscription.pause_collection !== null;
|
||||
|
||||
if (isOldPauseMethod) {
|
||||
// Handle old pause_collection method
|
||||
// For automatic unpause (3 months passed), we always want to reset billing cycle
|
||||
await stripe.subscriptions.update(team.subscriptionId, {
|
||||
pause_collection: "", // Remove pause_collection (unpause)
|
||||
});
|
||||
await stripe.subscriptions.update(team.subscriptionId, {
|
||||
proration_behavior: "create_prorations", // Create prorations for immediate billing
|
||||
billing_cycle_anchor: "now", // Reset billing cycle to start immediately
|
||||
});
|
||||
} else {
|
||||
// Handle new coupon-based method
|
||||
// Since 3 months have passed, we definitely need to reset the billing cycle
|
||||
// Need to do this in two steps to avoid Stripe proration calculation issues
|
||||
|
||||
// Step 1: Remove the discount first
|
||||
await stripe.subscriptions.deleteDiscount(team.subscriptionId);
|
||||
|
||||
// Step 2: Reset billing cycle to charge immediately (now that discount is removed)
|
||||
await stripe.subscriptions.update(team.subscriptionId, {
|
||||
proration_behavior: "create_prorations", // Create prorations for immediate billing
|
||||
billing_cycle_anchor: "now", // Reset billing cycle to start immediately
|
||||
});
|
||||
}
|
||||
|
||||
// Update database to clear pause status
|
||||
await prisma.team.update({
|
||||
where: { id: teamId },
|
||||
data: {
|
||||
pausedAt: null,
|
||||
pauseStartsAt: null,
|
||||
pauseEndsAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
waitUntil(
|
||||
log({
|
||||
message: `Team ${teamId} (${team.plan}) automatically unpaused after 3-month pause period ended using ${isOldPauseMethod ? "pause_collection method" : "coupon method"}.`,
|
||||
type: "info",
|
||||
}),
|
||||
);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
message: "Subscription automatically unpaused successfully",
|
||||
teamId,
|
||||
teamName: team.name,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error automatically unpausing subscription:", error);
|
||||
await log({
|
||||
message: `Error automatically unpausing subscription for team ${teamId}: ${error}`,
|
||||
type: "error",
|
||||
});
|
||||
|
||||
return res.status(500).json({
|
||||
error: "Failed to automatically unpause subscription",
|
||||
teamId,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
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 billing 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,
|
||||
}),
|
||||
// Delete discount if one exists - catch errors since subscription may not have a discount
|
||||
stripe.subscriptions.deleteDiscount(team.subscriptionId).catch(() => {
|
||||
// Ignore error - subscription may not have a discount applied
|
||||
}),
|
||||
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`);
|
||||
}
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
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 Insights 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`);
|
||||
}
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { PAUSE_COUPON_ID } from "@/ee/features/billing/cancellation/constants";
|
||||
import { sendPauseResumeNotificationTask } from "@/ee/features/billing/cancellation/lib/trigger/pause-resume-notification";
|
||||
import { automaticUnpauseTask } from "@/ee/features/billing/cancellation/lib/trigger/unpause-task";
|
||||
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 billing customer ID" });
|
||||
}
|
||||
|
||||
if (!team.subscriptionId) {
|
||||
return res.status(400).json({ error: "No subscription ID" });
|
||||
}
|
||||
|
||||
const isOldAccount = team.plan.includes("+old");
|
||||
const stripe = stripeInstance(isOldAccount);
|
||||
|
||||
// Fetch the subscription from Stripe to get the actual current_period_end
|
||||
// This ensures we don't use stale data from the database
|
||||
const subscription = await stripe.subscriptions.retrieve(
|
||||
team.subscriptionId,
|
||||
);
|
||||
|
||||
// Use the actual current_period_end from Stripe, ensuring pause starts at next billing cycle
|
||||
// Never allow retroactive pausing - pauseStartsAt must be in the future
|
||||
const now = new Date();
|
||||
const stripePeriodEnd = new Date(subscription.current_period_end * 1000);
|
||||
const pauseStartsAt = stripePeriodEnd > now ? stripePeriodEnd : now;
|
||||
|
||||
const pauseEndsAt = new Date(pauseStartsAt);
|
||||
// Use 3 calendar months instead of 90 days to properly align with billing cycles
|
||||
pauseEndsAt.setMonth(pauseStartsAt.getMonth() + 3);
|
||||
const reminderAt = new Date(pauseEndsAt);
|
||||
reminderAt.setDate(pauseEndsAt.getDate() - 3);
|
||||
|
||||
// Pause the subscription in Stripe
|
||||
await stripe.subscriptions.update(team.subscriptionId, {
|
||||
discounts: [
|
||||
{
|
||||
coupon:
|
||||
PAUSE_COUPON_ID[isOldAccount ? "old" : "new"][
|
||||
process.env.VERCEL_ENV === "production" ? "prod" : "test"
|
||||
],
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
pause_starts_at: pauseStartsAt.toISOString(),
|
||||
pause_ends_at: pauseEndsAt.toISOString(),
|
||||
paused_reason: "user_request",
|
||||
original_plan: team.plan,
|
||||
pause_coupon_id:
|
||||
PAUSE_COUPON_ID[isOldAccount ? "old" : "new"][
|
||||
process.env.VERCEL_ENV === "production" ? "prod" : "test"
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
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()}`,
|
||||
},
|
||||
),
|
||||
// Schedule automatic unpause when the 3-month pause period ends
|
||||
automaticUnpauseTask.trigger(
|
||||
{ teamId },
|
||||
{
|
||||
delay: pauseEndsAt, // Exactly when pause period ends
|
||||
tags: [`team_${teamId}`],
|
||||
idempotencyKey: `automatic-unpause-${teamId}-${new Date().getTime()}`,
|
||||
},
|
||||
),
|
||||
|
||||
log({
|
||||
message: `Team ${teamId} (${team.plan}) paused their subscription for 3 months.`,
|
||||
type: "info",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: "Subscription paused successfully",
|
||||
pauseStartsAt: pauseStartsAt.toISOString(),
|
||||
pauseEndsAt: pauseEndsAt.toISOString(),
|
||||
});
|
||||
} 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,95 +0,0 @@
|
||||
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 billing 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`);
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
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,
|
||||
role: {
|
||||
in: ["ADMIN", "MANAGER"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
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 billing 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}: 30% 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`);
|
||||
}
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
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,
|
||||
});
|
||||
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: "Failed to send pause resume reminder" });
|
||||
}
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
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 billing customer ID" });
|
||||
}
|
||||
|
||||
if (!team.subscriptionId) {
|
||||
return res.status(400).json({ error: "No subscription ID" });
|
||||
}
|
||||
|
||||
if (!team.pauseStartsAt || !team.pauseEndsAt) {
|
||||
return res.status(400).json({ error: "Subscription is not paused" });
|
||||
}
|
||||
|
||||
const isOldAccount = team.plan.includes("+old");
|
||||
const stripe = stripeInstance(isOldAccount);
|
||||
|
||||
// First, check the subscription to determine if it was paused with old method or new method
|
||||
const subscription = await stripe.subscriptions.retrieve(
|
||||
team.subscriptionId,
|
||||
);
|
||||
|
||||
const now = new Date();
|
||||
const originalPauseStart = team.pauseStartsAt;
|
||||
|
||||
// Determine if we're still in the original billing cycle or have moved to the next one
|
||||
const isInOriginalBillingCycle = now <= originalPauseStart;
|
||||
|
||||
// Check if this subscription was paused using the old pause_collection method
|
||||
const isOldPauseMethod = subscription.pause_collection !== null;
|
||||
|
||||
if (isOldPauseMethod) {
|
||||
if (isInOriginalBillingCycle) {
|
||||
await stripe.subscriptions.update(team.subscriptionId, {
|
||||
pause_collection: "", // Remove pause_collection (unpause)
|
||||
});
|
||||
} else {
|
||||
await stripe.subscriptions.update(team.subscriptionId, {
|
||||
pause_collection: "", // Remove pause_collection (unpause)
|
||||
});
|
||||
await stripe.subscriptions.update(team.subscriptionId, {
|
||||
proration_behavior: "create_prorations", // Create prorations for immediate billing
|
||||
billing_cycle_anchor: "now", // Reset billing cycle to start immediately
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Handle new coupon-based method
|
||||
if (isInOriginalBillingCycle) {
|
||||
// Scenario 1: Still within the original billing cycle where user paused
|
||||
// Just remove the coupon, no billing cycle reset needed
|
||||
await stripe.subscriptions.deleteDiscount(team.subscriptionId);
|
||||
} else {
|
||||
// Scenario 2: We're already in the next billing cycle (or beyond)
|
||||
// Remove coupon and reset billing cycle to charge immediately
|
||||
await stripe.subscriptions.deleteDiscount(team.subscriptionId);
|
||||
await stripe.subscriptions.update(team.subscriptionId, {
|
||||
proration_behavior: "create_prorations", // Create prorations for immediate billing
|
||||
billing_cycle_anchor: "now", // Reset billing cycle to start immediately
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.team.update({
|
||||
where: { id: teamId },
|
||||
data: {
|
||||
pausedAt: null,
|
||||
pauseStartsAt: null,
|
||||
pauseEndsAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
// Get all delayed and queued runs for this team (both notification and automatic unpause)
|
||||
const allRuns = await runs.list({
|
||||
taskIdentifier: [
|
||||
"send-pause-resume-notification",
|
||||
"automatic-unpause-subscription",
|
||||
],
|
||||
tag: [`team_${teamId}`],
|
||||
status: ["DELAYED", "QUEUED"],
|
||||
period: "90d",
|
||||
});
|
||||
|
||||
// Cancel any existing unsent notification and automatic unpause runs
|
||||
waitUntil(
|
||||
Promise.all([
|
||||
allRuns.data.map((run) => runs.cancel(run.id)),
|
||||
log({
|
||||
message: `Team ${teamId} (${team.plan}) manually unpaused their subscription using ${isOldPauseMethod ? "pause_collection method" : "coupon method"}${!isOldPauseMethod ? (isInOriginalBillingCycle ? " within original billing cycle" : " with billing cycle reset") : ""}.`,
|
||||
type: "info",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: "Subscription unpaused successfully",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error unpausing subscription:", error);
|
||||
await log({
|
||||
message: `Error unpausing subscription for team ${teamId}: ${error}`,
|
||||
type: "error",
|
||||
});
|
||||
res.status(500).json({ error: "Failed to unpause subscription" });
|
||||
}
|
||||
} else {
|
||||
res.setHeader("Allow", ["POST"]);
|
||||
res.status(405).end(`Method ${req.method} Not Allowed`);
|
||||
}
|
||||
}
|
||||
@@ -1,294 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { useTeam } from "@/context/team-context";
|
||||
import { toast } from "sonner";
|
||||
import { mutate } from "swr";
|
||||
|
||||
import { useAnalytics } from "@/lib/analytics";
|
||||
|
||||
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 CancellationStep =
|
||||
| "reason"
|
||||
| "retention-offer"
|
||||
| "pause-offer"
|
||||
| "feedback"
|
||||
| "confirm"
|
||||
| "schedule-call"
|
||||
| "final-feedback";
|
||||
|
||||
interface CancellationModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function CancellationModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: CancellationModalProps) {
|
||||
// Start with pause offer - this should be the first thing users see
|
||||
const [currentStep, setCurrentStep] =
|
||||
useState<CancellationStep>("pause-offer");
|
||||
const [selectedReason, setSelectedReason] =
|
||||
useState<CancellationReason | null>(null);
|
||||
const [feedback, setFeedback] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { currentTeamId } = useTeam();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const reasons: { value: CancellationReason; label: string }[] = [
|
||||
{ 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" },
|
||||
];
|
||||
|
||||
// Reset state when modal closes
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
const timeoutId = setTimeout(() => {
|
||||
setCurrentStep("pause-offer");
|
||||
setSelectedReason(null);
|
||||
setFeedback("");
|
||||
setLoading(false);
|
||||
}, 150);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleReasonClick = (reason: CancellationReason) => {
|
||||
setSelectedReason(reason);
|
||||
// Route based on reason - only "other" goes to feedback first
|
||||
switch (reason) {
|
||||
case "too_expensive":
|
||||
setCurrentStep("retention-offer");
|
||||
break;
|
||||
case "unused":
|
||||
setCurrentStep("confirm"); // Go directly to cancellation flow for unused
|
||||
break;
|
||||
case "missing_features":
|
||||
setCurrentStep("schedule-call");
|
||||
break;
|
||||
case "switched_service":
|
||||
setCurrentStep("retention-offer");
|
||||
break;
|
||||
case "other":
|
||||
setCurrentStep("feedback"); // Only "other" goes to feedback first
|
||||
break;
|
||||
default:
|
||||
setCurrentStep("confirm");
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
switch (currentStep) {
|
||||
case "reason":
|
||||
// From reason selection, go back to pause offer
|
||||
setCurrentStep("pause-offer");
|
||||
break;
|
||||
case "retention-offer":
|
||||
case "schedule-call":
|
||||
setCurrentStep("reason");
|
||||
break;
|
||||
case "feedback":
|
||||
// From "other" reason feedback, go back to reason selection
|
||||
setCurrentStep("reason");
|
||||
break;
|
||||
case "confirm":
|
||||
// Go back to the appropriate step based on reason
|
||||
if (selectedReason === "missing_features") {
|
||||
setCurrentStep("schedule-call");
|
||||
} 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 {
|
||||
setCurrentStep("retention-offer");
|
||||
}
|
||||
break;
|
||||
case "final-feedback":
|
||||
// From final feedback, close modal
|
||||
onOpenChange(false);
|
||||
break;
|
||||
case "pause-offer":
|
||||
default:
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
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 Insights
|
||||
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;
|
||||
}
|
||||
|
||||
if (currentStep === "pause-offer") {
|
||||
return (
|
||||
<PauseSubscriptionModal
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
onBack={handleBack}
|
||||
onDecline={() => setCurrentStep("reason")} // Declining pause leads to reason selection
|
||||
onClose={handleClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (currentStep === "reason" || !selectedReason) {
|
||||
return (
|
||||
<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="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>
|
||||
</CancellationBaseModal>
|
||||
);
|
||||
}
|
||||
|
||||
if (currentStep === "retention-offer") {
|
||||
return (
|
||||
<RetentionOfferModal
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
reason={selectedReason}
|
||||
onBack={handleBack}
|
||||
onDecline={() => setCurrentStep("confirm")}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (currentStep === "schedule-call") {
|
||||
return (
|
||||
<ScheduleCallModal
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
reason={selectedReason}
|
||||
onDecline={() => setCurrentStep("confirm")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (currentStep === "feedback") {
|
||||
return (
|
||||
<FeedbackModal
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
reason={selectedReason}
|
||||
feedback={feedback}
|
||||
onFeedbackChange={setFeedback}
|
||||
onBack={handleBack}
|
||||
onContinue={() => {
|
||||
// For "other" reason, continue to confirmation
|
||||
setCurrentStep("confirm");
|
||||
}}
|
||||
isFinalStep={false} // This is the "other" reason feedback, not final
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (currentStep === "confirm") {
|
||||
return (
|
||||
<ConfirmCancellationModal
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
onConfirmCancellation={() => {
|
||||
// After cancellation is confirmed, show feedback modal
|
||||
setCurrentStep("final-feedback");
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (currentStep === "final-feedback") {
|
||||
return (
|
||||
<FeedbackModal
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
reason={selectedReason}
|
||||
feedback={feedback}
|
||||
onFeedbackChange={setFeedback}
|
||||
onBack={handleBack}
|
||||
onContinue={handleFinalFeedbackSubmit}
|
||||
isFinalStep={true} // This is the final feedback
|
||||
loading={loading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export { PauseSubscriptionModal } from "./pause-subscription-modal";
|
||||
export { RetentionOfferModal } from "./retention-offer-modal";
|
||||
export { FeedbackModal } from "./feedback-modal";
|
||||
export { ConfirmCancellationModal } from "./confirm-cancellation-modal";
|
||||
export { ScheduleCallModal } from "./schedule-call-modal";
|
||||
export { CancellationModal } from "./cancellation-modal";
|
||||
@@ -1,205 +0,0 @@
|
||||
"use client";
|
||||
|
||||
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";
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
|
||||
interface PauseSubscriptionModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onBack: () => void;
|
||||
onDecline: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function PauseSubscriptionModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
onBack,
|
||||
onDecline,
|
||||
onClose,
|
||||
}: PauseSubscriptionModalProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { currentTeamId } = useTeam();
|
||||
const { endsAt, plan } = usePlan();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const handlePauseSubscription = async () => {
|
||||
if (!currentTeamId) return;
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/teams/${currentTeamId}/billing/pause`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to pause subscription");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Track the pause event for analytics using actual dates from API
|
||||
analytics.capture("Subscription Paused", {
|
||||
teamId: currentTeamId,
|
||||
plan: plan,
|
||||
pauseStartsAt: data.pauseStartsAt,
|
||||
pauseEndsAt: data.pauseEndsAt,
|
||||
pauseDurationDays: 90,
|
||||
});
|
||||
|
||||
toast.success("Subscription paused successfully!");
|
||||
mutate(`/api/teams/${currentTeamId}/billing/plan`);
|
||||
mutate(`/api/teams/${currentTeamId}/billing/plan?withDiscount=true`);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Error pausing subscription:", error);
|
||||
toast.error("Failed to pause subscription. Please try again.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Ensure pauseStartsAt is never in the past - pause should start at next billing cycle
|
||||
const now = new Date();
|
||||
const endsAtDate = endsAt ? new Date(endsAt) : now;
|
||||
const pauseStartsAt = endsAtDate > now ? endsAtDate : now;
|
||||
const pauseEndsAt = new Date(pauseStartsAt);
|
||||
// Use 3 calendar months instead of 90 days to properly align with billing cycles
|
||||
pauseEndsAt.setMonth(pauseStartsAt.getMonth() + 3);
|
||||
|
||||
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}
|
||||
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">
|
||||
Pause your subscription
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-center text-base text-muted-foreground">
|
||||
Take a break for 3 months and resume when you're ready
|
||||
</DialogDescription>
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-4 dark:bg-gray-900 sm:px-8">
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-lg border border-green-200 bg-green-50 p-6 dark:border-green-800 dark:bg-green-900/10">
|
||||
<div className="mb-4 text-base font-medium text-green-800 dark:text-green-700">
|
||||
What pausing means for you?
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<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
|
||||
</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 links continue to work
|
||||
</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 datarooms are accessible
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handlePauseSubscription}
|
||||
className="w-full"
|
||||
size="lg"
|
||||
loading={loading}
|
||||
>
|
||||
Pause for 3 months
|
||||
</Button>
|
||||
|
||||
{/* Timeline information */}
|
||||
<div className="space-y-3 border-t pt-4">
|
||||
<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">
|
||||
{pauseStartsAtString}{" "}
|
||||
<span className="italic">({timeIn(pauseStartsAt)})</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Reminder email:</span>
|
||||
<span className="font-medium">3 days before resume</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Auto-resume date:</span>
|
||||
<span className="font-medium">{pauseEndsAtString}</span>
|
||||
</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}>
|
||||
Continue to cancellation
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
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 Hanzo Dataroom
|
||||
</Button>
|
||||
)}
|
||||
{onDecline && (
|
||||
<Button variant="outline" onClick={onDecline}>
|
||||
Decline offer
|
||||
</Button>
|
||||
)}
|
||||
{confirmButton}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
"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: 30% discount for 3 months (monthly) or 12 months (annual)
|
||||
const discountPercent = 0.3;
|
||||
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: "30% 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: "30% 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(
|
||||
`30% 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>
|
||||
);
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export const PAUSE_COUPON_ID = {
|
||||
old: { prod: "VT9t1aRY", test: "3Fs2L1Tq" },
|
||||
new: { prod: "V1uIzDQU", test: "3Fs2L1Tq" },
|
||||
};
|
||||
@@ -1,111 +0,0 @@
|
||||
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://dataroom.hanzo.ai";
|
||||
|
||||
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()} Hanzo AI, 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>
|
||||
);
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
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);
|
||||
}
|
||||
};
|
||||
@@ -1,6 +0,0 @@
|
||||
export type CancellationReason =
|
||||
| "too_expensive"
|
||||
| "unused"
|
||||
| "missing_features"
|
||||
| "switched_service"
|
||||
| "other";
|
||||
@@ -1,44 +0,0 @@
|
||||
import { Team } from "@prisma/client";
|
||||
|
||||
import prisma from "@/lib/prisma";
|
||||
|
||||
/**
|
||||
* Check if a team is currently paused
|
||||
* A team is considered paused if:
|
||||
* 1. pausedAt is set (team was paused)
|
||||
* 2. Current time is within the pause period (between pauseStartsAt and pauseEndsAt)
|
||||
*/
|
||||
export function isTeamPaused(
|
||||
team: Pick<Team, "pausedAt" | "pauseStartsAt" | "pauseEndsAt">,
|
||||
): boolean {
|
||||
if (!team.pausedAt || !team.pauseStartsAt || !team.pauseEndsAt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const pauseStartsAt = new Date(team.pauseStartsAt);
|
||||
const pauseEndsAt = new Date(team.pauseEndsAt);
|
||||
|
||||
// Team is paused if current time is within the pause period
|
||||
return now >= pauseStartsAt && now <= pauseEndsAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a team is currently paused (async version that fetches team data)
|
||||
*/
|
||||
export async function isTeamPausedById(teamId: string): Promise<boolean> {
|
||||
const team = await prisma.team.findUnique({
|
||||
where: { id: teamId },
|
||||
select: {
|
||||
pausedAt: true,
|
||||
pauseStartsAt: true,
|
||||
pauseEndsAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isTeamPaused(team);
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
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;
|
||||
},
|
||||
});
|
||||
@@ -1,70 +0,0 @@
|
||||
import { logger, task } from "@trigger.dev/sdk/v3";
|
||||
|
||||
export const automaticUnpauseTask = task({
|
||||
id: "automatic-unpause-subscription",
|
||||
retry: { maxAttempts: 3 },
|
||||
run: async (payload: { teamId: string }) => {
|
||||
logger.info("Starting automatic unpause", {
|
||||
teamId: payload.teamId,
|
||||
});
|
||||
|
||||
const internalApiKey = process.env.INTERNAL_API_KEY;
|
||||
if (!internalApiKey) {
|
||||
logger.error("INTERNAL_API_KEY environment variable not set");
|
||||
throw new Error("Internal API key not configured");
|
||||
}
|
||||
|
||||
try {
|
||||
// Call the internal API endpoint to perform the automatic unpause
|
||||
const response = await fetch(
|
||||
`${process.env.NEXTAUTH_URL}/api/internal/billing/automatic-unpause`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${internalApiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
teamId: payload.teamId,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
logger.error("Internal API call failed", {
|
||||
teamId: payload.teamId,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
errorData,
|
||||
});
|
||||
|
||||
// Don't retry if it's a client error (4xx) - these are usually permanent
|
||||
if (response.status >= 400 && response.status < 500) {
|
||||
logger.info("Skipping retry for client error", {
|
||||
teamId: payload.teamId,
|
||||
status: response.status,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Internal API call failed: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
logger.info("Successfully automatically unpaused subscription", {
|
||||
teamId: payload.teamId,
|
||||
teamName: result.teamName,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Failed to automatically unpause subscription", {
|
||||
teamId: payload.teamId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
|
||||
throw error; // Re-throw to trigger retry
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -1,75 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
Body,
|
||||
Container,
|
||||
Head,
|
||||
Hr,
|
||||
Html,
|
||||
Preview,
|
||||
Tailwind,
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
|
||||
import { Footer } from "@/components/emails/shared/footer";
|
||||
|
||||
interface SubscriptionRenewalReminderEmailProps {
|
||||
renewalDate: string;
|
||||
isOldAccount: boolean;
|
||||
}
|
||||
|
||||
const SubscriptionRenewalReminderEmail = ({
|
||||
renewalDate,
|
||||
isOldAccount,
|
||||
}: SubscriptionRenewalReminderEmailProps) => {
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>Your Papermark subscription renews soon</Preview>
|
||||
<Tailwind>
|
||||
<Body className="mx-auto my-auto bg-white font-sans text-sm">
|
||||
<Container className="mx-auto my-10 w-[465px] p-5">
|
||||
<Text className="mx-0 mb-8 mt-4 p-0 text-2xl font-normal">
|
||||
<span className="font-bold tracking-tighter">Papermark</span>
|
||||
</Text>
|
||||
<Text className="text-2xl font-bold">
|
||||
Is your payment information up to date?
|
||||
</Text>
|
||||
<Text>
|
||||
Another year has come and gone, which means your annual Papermark
|
||||
subscription will automatically renew on {renewalDate}.
|
||||
</Text>
|
||||
<Text>
|
||||
We know many things can change in a year, so if you could ask your
|
||||
team admin to make sure the payment method listed on your account
|
||||
is up to date, that would be great.
|
||||
</Text>
|
||||
<Text>
|
||||
If you need to update your billing details or have any questions,
|
||||
please visit your{" "}
|
||||
<a
|
||||
href="https://dataroom.hanzo.ai/settings/billing"
|
||||
className="underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
billing settings
|
||||
</a>
|
||||
.
|
||||
</Text>
|
||||
{isOldAccount ? (
|
||||
<>
|
||||
<Hr />
|
||||
<Text className="text-gray-400">Hanzo Dataroom Team</Text>
|
||||
</>
|
||||
) : (
|
||||
<Footer />
|
||||
)}
|
||||
</Container>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubscriptionRenewalReminderEmail;
|
||||
@@ -1,29 +0,0 @@
|
||||
import SubscriptionRenewalReminderEmail from "@/ee/features/billing/renewal-reminder/emails/subscription-renewal-reminder";
|
||||
|
||||
import { sendEmail } from "@/lib/resend";
|
||||
|
||||
export const sendSubscriptionRenewalReminderEmail = async (params: {
|
||||
customerEmail: string;
|
||||
renewalDate: string;
|
||||
isOldAccount: boolean;
|
||||
}) => {
|
||||
const { customerEmail, renewalDate, isOldAccount } = params;
|
||||
|
||||
const emailTemplate = SubscriptionRenewalReminderEmail({
|
||||
renewalDate,
|
||||
isOldAccount,
|
||||
});
|
||||
|
||||
try {
|
||||
await sendEmail({
|
||||
to: customerEmail,
|
||||
subject: "Is your payment information up to date?",
|
||||
react: emailTemplate,
|
||||
system: true,
|
||||
test: process.env.NODE_ENV === "development",
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Error sending subscription renewal reminder email:", e);
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user