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:
Hanzo AI
2026-06-07 11:38:49 -07:00
parent 4aebc8dc0d
commit 48eecb2485
179 changed files with 1 additions and 2861 deletions
+1
View File
@@ -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
-40
View File
@@ -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.
-16
View File
@@ -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❗_
-141
View File
@@ -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