import { CheckIcon, XMarkIcon } from "@heroicons/react/20/solid";
import { ArrowDownCircleIcon, ArrowUpCircleIcon } from "@heroicons/react/24/outline";
import { Form, useLocation, useNavigation } from "@remix-run/react";
import { uiComponent } from "@team-plain/typescript-sdk";
import {
type AddOnPricing,
type FreePlanDefinition,
type Limits,
type PaidPlanDefinition,
type Plans,
type SetPlanBody,
type SubscriptionResult,
} from "@trigger.dev/platform";
import React, { useEffect, useState } from "react";
import { z } from "zod";
import { DefinitionTip } from "~/components/DefinitionTooltip";
import { Feedback } from "~/components/Feedback";
import { Button } from "~/components/primitives/Buttons";
import { CheckboxWithLabel } from "~/components/primitives/Checkbox";
import { DateTime } from "~/components/primitives/DateTime";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTrigger,
} from "~/components/primitives/Dialog";
import { Header2 } from "~/components/primitives/Headers";
import { Paragraph } from "~/components/primitives/Paragraph";
import { Spinner } from "~/components/primitives/Spinner";
import { TextArea } from "~/components/primitives/TextArea";
import { TextLink } from "~/components/primitives/TextLink";
import { prisma } from "~/db.server";
import { redirectWithErrorMessage } from "~/models/message.server";
import { resolveOrgIdFromSlug } from "~/models/organization.server";
import { logger } from "~/services/logger.server";
import { applyPromoCode, bustPromoCreditsCache, setPlan } from "~/services/platform.v3.server";
import { clearPromoCodeCookie, getPromoCodeFromCookie } from "~/services/promoCode.server";
import { dashboardAction } from "~/services/routeBuilders/dashboardBuilder";
import { engine } from "~/v3/runEngine.server";
import { cn } from "~/utils/cn";
import { sendToPlain } from "~/utils/plain.server";
import { formatCurrency } from "~/utils/numberFormatter";
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
const Params = z.object({
organizationSlug: z.string(),
});
const schema = z.object({
type: z.enum(["free", "paid"]),
planCode: z.string().optional(),
callerPath: z.string(),
reasons: z.union([z.string(), z.array(z.string())]).optional(),
message: z.string().optional(),
});
export const action = dashboardAction(
{
params: Params,
context: async (params) => {
const organizationId = await resolveOrgIdFromSlug(params.organizationSlug);
return organizationId ? { organizationId } : {};
},
authorization: { action: "manage", resource: { type: "billing" } },
},
async ({ request, params, user }) => {
const { organizationSlug } = params;
const formData = await request.formData();
const reasons = formData.getAll("reasons");
const message = formData.get("message");
const form = schema.parse({
...Object.fromEntries(formData),
reasons,
message: message || undefined,
});
const organization = await prisma.organization.findFirst({
where: { slug: organizationSlug, members: { some: { userId: user.id } } },
});
if (!organization) {
throw await redirectWithErrorMessage(form.callerPath, request, "Organization not found");
}
let payload: SetPlanBody;
switch (form.type) {
case "free": {
try {
if (reasons.length > 0 || (message && message.toString().trim() !== "")) {
await sendToPlain({
userId: user.id,
email: user.email,
name: user.name ?? "",
title: "Plan cancelation feedback",
components: [
uiComponent.text({
text: `${user.name} (${user.email}) just canceled their plan.`,
}),
uiComponent.divider({ spacingSize: "M" }),
...(reasons.length > 0
? [
uiComponent.spacer({ size: "L" }),
uiComponent.text({
size: "L",
color: "NORMAL",
text: "Reasons:",
}),
uiComponent.text({
text: reasons.join(", "),
}),
]
: []),
...(message
? [
uiComponent.spacer({ size: "L" }),
uiComponent.text({
size: "L",
color: "NORMAL",
text: "Comment:",
}),
uiComponent.text({
text: message.toString(),
}),
]
: []),
],
});
}
} catch (e) {
logger.error("Failed to submit to Plain the unsubscribe reason", { error: e });
}
payload = {
type: "free" as const,
userId: user.id,
};
break;
}
case "paid": {
if (form.planCode === undefined) {
throw await redirectWithErrorMessage(form.callerPath, request, "Not a valid plan");
}
payload = {
type: "paid" as const,
planCode: form.planCode,
userId: user.id,
};
break;
}
default: {
throw new Error("Invalid form type");
}
}
const result = await setPlan(organization, request, form.callerPath, payload, {
invalidateBillingCache: engine.invalidateBillingCache.bind(engine),
// Redeem a promo code carried from the /promo landing page. This runs only
// once the Free plan has actually been provisioned (the grant target), so a
// failed plan change never burns the one-time code. Best-effort: it must
// never change the plan-selection outcome.
onFreePlanProvisioned: async (response) => {
const promoCode = await getPromoCodeFromCookie(request);
if (!promoCode) {
return;
}
const applied = await applyPromoCode(organization.id, user.id, promoCode);
if (applied?.applied) {
bustPromoCreditsCache(organization.id);
response.headers.append("Set-Cookie", await clearPromoCodeCookie());
}
},
});
return result;
}
);
const pricingDefinitions = {
usage: {
title: "Usage",
content: "The compute cost when tasks are executing.",
},
concurrentRuns: {
title: "Concurrent runs",
content: "The number of runs that can be executed at the same time.",
},
additionalConcurrency: {
title: "Additional concurrency",
},
taskRun: {
title: "Task runs",
content: "A single execution of a task.",
},
tasks: {
title: "Tasks",
content:
"Tasks are functions that can run for a long time and provide strong resilience to failure.",
},
environment: {
title: "Environments",
content: "The different environments available for running your tasks.",
},
schedules: {
title: "Schedules",
content: "You can attach recurring schedules to tasks using cron syntax.",
},
additionalSchedules: {
title: "Additional schedules",
content: "Then $10/month per 1,000",
},
alerts: {
title: "Alert destination",
content:
"A single email address, Slack channel, or webhook URL that you want to send alerts to.",
},
realtime: {
title: "Realtime connections",
content:
"Realtime allows you to send the live status and data from your runs to your frontend. This is the number of simultaneous Realtime connections that can be made.",
},
additionalRealtimeConnections: {
title: "Additional Realtime connections",
content: "Then $10/month per 1000",
},
additionalSeats: {
title: "Additional seats",
content: "Then $20/month per seat",
},
branches: {
title: "Branches",
content:
"Preview branches allow you to test changes before deploying to production. You can have a limited number active at once (but can archive old ones).",
},
additionalBranches: {
title: "Additional branches",
content: "Then $10/month per branch",
},
metricDashboards: {
title: "Custom dashboards",
content: "Custom metric dashboards for monitoring and visualizing your task data.",
},
additionalDashboards: {
title: "Additional dashboards",
content: "Then $10/month per dashboard",
},
queryPeriod: {
title: "Query period",
content: "The maximum number of days a query can look back when analyzing your task data.",
},
hipaaBaa: {
title: "HIPAA BAA",
content:
"A signed Business Associate Agreement (BAA) is required to run tasks that process Protected Health Information (PHI) on Trigger.dev Cloud.",
},
};
type PricingPlansProps = {
plans: Plans;
concurrencyAddOnPricing: AddOnPricing;
subscription?: SubscriptionResult;
organizationSlug: string;
hasPromotedPlan: boolean;
periodEnd: Date;
};
export function PricingPlans({
plans,
concurrencyAddOnPricing,
subscription,
organizationSlug,
hasPromotedPlan,
periodEnd,
}: PricingPlansProps) {
return (
);
}
export function TierFree({
plan,
subscription,
organizationSlug,
periodEnd,
}: {
plan: FreePlanDefinition;
subscription?: SubscriptionResult;
organizationSlug: string;
periodEnd: Date;
}) {
const location = useLocation();
const navigation = useNavigation();
const formAction = `/resources/orgs/${organizationSlug}/select-plan`;
const isLoading = navigation.formAction === formAction;
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [isLackingFeaturesChecked, setIsLackingFeaturesChecked] = useState(false);
useEffect(() => {
setIsDialogOpen(false);
}, [subscription]);
return (
{subscription?.plan !== undefined &&
subscription.plan.type !== "free" &&
subscription.canceledAt === undefined ? (
{`Downgrade to ${plan.title}`}
) : (
{subscription?.plan === undefined
? "Select plan"
: subscription.plan.type === "free"
? "Current plan"
: subscription.canceledAt !== undefined
? "Current plan"
: "Select plan"}
)}
${plan.limits.includedUsage / 100} / month free credits
Unlimited{" "}
tasks
);
}
export function TierHobby({
plan,
organizationSlug,
subscription,
isHighlighted,
}: {
plan: PaidPlanDefinition;
organizationSlug: string;
subscription?: SubscriptionResult;
isHighlighted: boolean;
}) {
const location = useLocation();
const navigation = useNavigation();
const formAction = `/resources/orgs/${organizationSlug}/select-plan`;
const isLoading = navigation.formAction === formAction;
const [isDialogOpen, setIsDialogOpen] = useState(false);
useEffect(() => {
setIsDialogOpen(false);
}, [subscription]);
return (
{subscription?.plan !== undefined &&
subscription.plan.type !== "free" &&
subscription.canceledAt === undefined &&
subscription.plan.code !== plan.code ? (
{`Downgrade to ${plan.title}`}
Downgrade plan?
By downgrading you will lose access to your current plan's features and your
included credits will be reduced.
setIsDialogOpen(false)}>
Dismiss
: undefined}
form="subscribe-hobby"
>
{`Downgrade to ${plan.title}`}
) : (
{subscription?.plan === undefined
? "Select plan"
: subscription.plan.type === "free" || subscription.canceledAt !== undefined
? `Upgrade to ${plan.title}`
: subscription.plan.code === plan.code
? "Current plan"
: `Upgrade to ${plan.title}`}
)}
This plan includes ${plan.limits.includedUsage / 100} of compute each month. After
that's used, tasks keep running and you're billed per our{" "}
compute usage rates
.
}
>
${plan.limits.includedUsage / 100} / month credits included
Unlimited{" "}
tasks
Request a BAA
}
/>
);
}
export function TierPro({
plan,
concurrencyAddOnPricing,
organizationSlug,
subscription,
}: {
plan: PaidPlanDefinition;
concurrencyAddOnPricing: AddOnPricing;
organizationSlug: string;
subscription?: SubscriptionResult;
}) {
const location = useLocation();
const navigation = useNavigation();
const formAction = `/resources/orgs/${organizationSlug}/select-plan`;
const isLoading = navigation.formAction === formAction;
const [isDialogOpen, setIsDialogOpen] = useState(false);
useEffect(() => {
setIsDialogOpen(false);
}, [subscription]);
return (
{subscription?.plan !== undefined &&
subscription?.plan?.type === "paid" &&
subscription?.plan?.code !== plan.code &&
subscription.canceledAt === undefined ? (
{`Upgrade to ${plan.title}`}
Upgrade plan
Upgrade to get instant access to all the Pro features. You will be charged the
new plan price for the remainder of this month on a pro rata basis.
setIsDialogOpen(false)}>
Dismiss
: undefined}
form="subscribe-pro"
>
{`Upgrade to ${plan.title}`}
) : (
{subscription?.plan === undefined
? "Select plan"
: subscription.plan.type === "free" || subscription.canceledAt !== undefined
? `Upgrade to ${plan.title}`
: subscription.plan.code === plan.code
? "Current plan"
: `Upgrade to ${plan.title}`}
)}
This plan includes ${plan.limits.includedUsage / 100} of compute each month. After
that's used, tasks keep running and you're billed per our{" "}
compute usage rates
.
}
>
${plan.limits.includedUsage / 100} / month credits included
{`Then ${formatCurrency(concurrencyAddOnPricing.centsPerStep / 100, true)}/month per ${
concurrencyAddOnPricing.stepSize
}`}
Unlimited{" "}
tasks
{pricingDefinitions.additionalSeats.content}
{pricingDefinitions.additionalBranches.content}
{pricingDefinitions.additionalDashboards.content}
{pricingDefinitions.additionalSchedules.content}
{pricingDefinitions.additionalRealtimeConnections.content}
Request a BAA
}
/>
);
}
export function TierEnterprise() {
return (
Enterprise
Tailor a custom plan
Contact us
}
/>
All Pro plan features +
Custom log retention
Priority support
Role-based access control
);
}
function TierContainer({
children,
isHighlighted,
className,
}: {
children: React.ReactNode;
isHighlighted?: boolean;
className?: string;
}) {
return (
{children}
);
}
function PricingHeader({
title,
cost: flatCost,
isHighlighted,
per = "/month",
maximumFractionDigits = 0,
}: {
title: string;
cost?: number;
isHighlighted?: boolean;
per?: string;
maximumFractionDigits?: number;
}) {
const dollarFormatter = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits,
});
return (
{title}
{flatCost === 0 || flatCost ? (
{dollarFormatter.format(flatCost)}
{per}
) : (
Custom
)}
);
}
function FeatureItem({
checked,
checkedColor = "primary",
children,
}: {
checked?: boolean;
checkedColor?: "primary" | "bright";
children: React.ReactNode;
}) {
return (
{checked ? (
) : (
)}
{children}
);
}
function ConcurrentRuns({ limits, children }: { limits: Limits; children?: React.ReactNode }) {
return (
{limits.concurrentRuns.canExceed ? (
<>
{limits.concurrentRuns.number}
{"+"}
>
) : (
<>{limits.concurrentRuns.number} >
)}
{pricingDefinitions.concurrentRuns.content}
{limits.concurrentRuns.production}
{limits.concurrentRuns.canExceed ? "+" : ""}
{limits.concurrentRuns.staging}
{limits.concurrentRuns.canExceed ? "+" : ""}
{limits.concurrentRuns.preview}
{limits.concurrentRuns.canExceed ? "+" : ""}
{limits.concurrentRuns.development}
{limits.concurrentRuns.canExceed ? "+" : ""}
}
>
concurrent runs
{children && {children} }
);
}
function TeamMembers({ limits, children }: { limits: Limits; children?: React.ReactNode }) {
return (
{limits.teamMembers.number}
{limits.teamMembers.canExceed ? "+" : ""} team members
{children &&
{children} }
);
}
function Environments({ limits }: { limits: Limits }) {
return (
{limits.hasStagingEnvironment ? "Dev, Preview and Prod" : "Dev and Prod"}{" "}
environments
);
}
function Schedules({ limits, children }: { limits: Limits; children?: React.ReactNode }) {
return (
{limits.schedules.number}
{limits.schedules.canExceed ? "+" : ""}{" "}
schedules
{children &&
{children} }
);
}
function LogRetention({ limits }: { limits: Limits }) {
return {limits.logRetentionDays.number} day log retention ;
}
function SupportLevel({ limits }: { limits: Limits }) {
return (
{limits.support === "community" ? "Community support" : "Priority support"}
);
}
function Alerts({ limits }: { limits: Limits }) {
if (limits.alerts.number === 0) {
return (
Alert destinations
);
}
return (
{limits.alerts.number}
{limits.alerts.canExceed ? "+" : ""}{" "}
alert destinations
);
}
function RealtimeConcurrency({ limits, children }: { limits: Limits; children?: React.ReactNode }) {
return (
{limits.realtimeConcurrentConnections.canExceed ? (
<>
{limits.realtimeConcurrentConnections.number}
{"+"}
>
) : (
<>{limits.realtimeConcurrentConnections.number} >
)}{" "}
concurrent Realtime connections
{children &&
{children} }
);
}
function MetricDashboards({ limits, children }: { limits: Limits; children?: React.ReactNode }) {
if (limits.metricDashboards.number === 0) {
return (
Custom dashboards
);
}
return (
{limits.metricDashboards.number}
{limits.metricDashboards.canExceed ? "+" : ""}{" "}
custom {limits.metricDashboards.number === 1 ? "dashboard" : "dashboards"}
{children &&
{children} }
);
}
function QueryPeriod({ limits }: { limits: Limits }) {
return (
{limits.queryPeriodDays.number} {limits.queryPeriodDays.number === 1 ? "day" : "days"}{" "}
query period
);
}
function HIPAAAddOn({
children,
checkedColor = "primary",
}: {
children?: React.ReactNode;
checkedColor?: "primary" | "bright";
}) {
return (
HIPAA BAA
{" "}
add-on
{children &&
{children} }
);
}
function Branches({ limits, children }: { limits: Limits; children?: React.ReactNode }) {
return (
0}>
{limits.branches.number > 0 && (
<>
{limits.branches.number}
{limits.branches.canExceed ? "+ " : " "}
>
)}
{limits.branches.number > 0 ? "preview" : "Preview"} branches
{children &&
{children} }
);
}