diff --git a/apps/webapp/app/presenters/v3/ScheduleListPresenter.server.ts b/apps/webapp/app/presenters/v3/ScheduleListPresenter.server.ts index 19812b0a548..912993dbb43 100644 --- a/apps/webapp/app/presenters/v3/ScheduleListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ScheduleListPresenter.server.ts @@ -2,7 +2,7 @@ import { type RuntimeEnvironmentType, type ScheduleType } from "@trigger.dev/dat import { type ScheduleListFilters } from "~/components/runs/v3/ScheduleFilters"; import { displayableEnvironment } from "~/models/runtimeEnvironment.server"; import { getTaskIdentifiers } from "~/models/task.server"; -import { getLimit } from "~/services/platform.v3.server"; +import { getCurrentPlan, getLimit, getPlans } from "~/services/platform.v3.server"; import { findCurrentWorkerFromEnvironment } from "~/v3/models/workerDeployment.server"; import { ServiceValidationError } from "~/v3/services/baseService.server"; import { CheckScheduleService } from "~/v3/services/checkSchedule.server"; @@ -103,7 +103,27 @@ export class ScheduleListPresenter extends BasePresenter { projectId, }); - const limit = await getLimit(project.organizationId, "schedules", 100_000_000); + const baseLimit = await getLimit(project.organizationId, "schedules", 100_000_000); + const [currentPlan, plans] = await Promise.all([ + getCurrentPlan(project.organizationId), + getPlans(), + ]); + + const extraSchedules = currentPlan?.v3Subscription?.addOns?.schedules?.purchased ?? 0; + const limit = baseLimit + extraSchedules; + const canPurchaseSchedules = + currentPlan?.v3Subscription?.plan?.limits.schedules.canExceed === true; + const maxScheduleQuota = currentPlan?.v3Subscription?.addOns?.schedules?.quota ?? 0; + const planScheduleLimit = currentPlan?.v3Subscription?.plan?.limits.schedules.number ?? 0; + const schedulePricing = plans?.addOnPricing.schedules ?? null; + + const purchaseInfo = { + canPurchaseSchedules, + extraSchedules, + maxScheduleQuota, + planScheduleLimit, + schedulePricing, + }; //get the latest BackgroundWorker const latestWorker = await findCurrentWorkerFromEnvironment(environment, this._replica); @@ -119,6 +139,7 @@ export class ScheduleListPresenter extends BasePresenter { used: schedulesCount, limit, }, + ...purchaseInfo, filters: { tasks, search, @@ -314,6 +335,7 @@ export class ScheduleListPresenter extends BasePresenter { used: schedulesCount, limit, }, + ...purchaseInfo, filters: { tasks, search, diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules/route.tsx index b3181eefa36..bcb10409442 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules/route.tsx @@ -1,8 +1,14 @@ -import { ArrowUpCircleIcon, PlusIcon } from "@heroicons/react/20/solid"; +import { useForm } from "@conform-to/react"; +import { parse } from "@conform-to/zod"; +import { ArrowUpCircleIcon, EnvelopeIcon, PlusIcon } from "@heroicons/react/20/solid"; import { BookOpenIcon } from "@heroicons/react/24/solid"; -import { type MetaFunction, Outlet, useLocation, useParams } from "@remix-run/react"; -import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { DialogClose } from "@radix-ui/react-dialog"; +import { type MetaFunction, Outlet, useFetcher, useLocation, useParams } from "@remix-run/react"; +import { type ActionFunctionArgs, json, type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { tryCatch } from "@trigger.dev/core/v3"; +import { useEffect, useState } from "react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; +import { z } from "zod"; import { SchedulesNoneAttached, SchedulesNoPossibleTaskPanel } from "~/components/BlankStatePanels"; import { Feedback } from "~/components/Feedback"; import { AdminDebugTooltip } from "~/components/admin/debugTooltip"; @@ -19,11 +25,18 @@ import { DialogHeader, DialogTrigger, } from "~/components/primitives/Dialog"; +import { Fieldset } from "~/components/primitives/Fieldset"; +import { FormButtons } from "~/components/primitives/FormButtons"; +import { FormError } from "~/components/primitives/FormError"; import { Header3 } from "~/components/primitives/Headers"; +import { InputGroup } from "~/components/primitives/InputGroup"; +import { InputNumberStepper } from "~/components/primitives/InputNumberStepper"; +import { Label } from "~/components/primitives/Label"; import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; import { PaginationControls } from "~/components/primitives/Pagination"; import { Paragraph } from "~/components/primitives/Paragraph"; import * as Property from "~/components/primitives/PropertyTable"; +import { SpinnerWhite } from "~/components/primitives/Spinner"; import { RESIZABLE_PANEL_ANIMATION, ResizableHandle, @@ -61,13 +74,16 @@ import { } from "~/presenters/v3/ScheduleListPresenter.server"; import { requireUserId } from "~/services/session.server"; import { cn } from "~/utils/cn"; +import { formatCurrency, formatNumber } from "~/utils/numberFormatter"; import { docsPath, EnvironmentParamSchema, v3BillingPath, v3NewSchedulePath, v3SchedulePath, + v3SchedulesPath, } from "~/utils/pathBuilder"; +import { SetSchedulesAddOnService } from "~/v3/services/setSchedulesAddOn.server"; import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route"; export const meta: MetaFunction = () => { @@ -107,9 +123,83 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { return typedjson(list); }; +const PurchaseSchema = z.discriminatedUnion("action", [ + z.object({ + action: z.literal("purchase"), + amount: z.coerce + .number() + .int("Must be a whole number") + .min(0, "Amount must be 0 or more"), + }), + z.object({ + action: z.literal("quota-increase"), + amount: z.coerce + .number() + .int("Must be a whole number") + .min(1, "Amount must be greater than 0"), + }), +]); + +export async function action({ request, params }: ActionFunctionArgs) { + const userId = await requireUserId(request); + const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params); + + const formData = await request.formData(); + + const project = await findProjectBySlug(organizationSlug, projectParam, userId); + const redirectPath = v3SchedulesPath( + { slug: organizationSlug }, + { slug: projectParam }, + { slug: envParam } + ); + + if (!project) { + throw redirectWithErrorMessage(redirectPath, request, "Project not found"); + } + + const submission = parse(formData, { schema: PurchaseSchema }); + + if (!submission.value || submission.intent !== "submit") { + return json(submission); + } + + const service = new SetSchedulesAddOnService(); + const [error, result] = await tryCatch( + service.call({ + userId, + organizationId: project.organizationId, + action: submission.value.action, + amount: submission.value.amount, + }) + ); + + if (error) { + submission.error.amount = [error instanceof Error ? error.message : "Unknown error"]; + return json(submission); + } + + if (!result.success) { + submission.error.amount = [result.error]; + return json(submission); + } + + return json({ ok: true } as const); +} + export default function Page() { - const { schedules, possibleTasks, hasFilters, limits, currentPage, totalPages } = - useTypedLoaderData(); + const { + schedules, + possibleTasks, + hasFilters, + limits, + currentPage, + totalPages, + canPurchaseSchedules, + extraSchedules, + maxScheduleQuota, + planScheduleLimit, + schedulePricing, + } = useTypedLoaderData(); const location = useLocation(); const organization = useOrganization(); const project = useProject(); @@ -194,7 +284,18 @@ export default function Page() { You've used {limits.used}/{limits.limit} of your schedules. - {canUpgrade ? ( + {canPurchaseSchedules && schedulePricing ? ( + Purchase more… + } + /> + ) : canUpgrade ? ( )} - {canUpgrade ? ( + {canPurchaseSchedules && schedulePricing ? ( + + ) : canUpgrade ? ( ); } + +function PurchaseSchedulesModal({ + schedulePricing, + extraSchedules, + usedSchedules, + maxQuota, + planScheduleLimit, + triggerButton, +}: { + schedulePricing: { + stepSize: number; + centsPerStep: number; + }; + extraSchedules: number; + usedSchedules: number; + maxQuota: number; + planScheduleLimit: number; + triggerButton?: React.ReactNode; +}) { + const fetcher = useFetcher(); + const lastSubmission = + fetcher.data && typeof fetcher.data === "object" && "intent" in fetcher.data + ? fetcher.data + : undefined; + const [form, { amount }] = useForm({ + id: "purchase-schedules", + lastSubmission: lastSubmission as any, + onValidate({ formData }) { + return parse(formData, { schema: PurchaseSchema }); + }, + shouldRevalidate: "onSubmit", + }); + + const stepSize = schedulePricing.stepSize; + const [bundles, setBundles] = useState(Math.round(extraSchedules / stepSize)); + useEffect(() => { + setBundles(Math.round(extraSchedules / stepSize)); + }, [extraSchedules, stepSize]); + const amountValue = bundles * stepSize; + const isLoading = fetcher.state !== "idle"; + + const [open, setOpen] = useState(false); + useEffect(() => { + const data = fetcher.data; + if ( + fetcher.state === "idle" && + data !== null && + typeof data === "object" && + "ok" in data && + data.ok + ) { + setOpen(false); + } + }, [fetcher.state, fetcher.data]); + + const state = updateScheduleState({ + value: amountValue, + existingValue: extraSchedules, + quota: maxQuota, + usedSchedules, + planScheduleLimit, + }); + const changeClassName = + state === "decrease" ? "text-error" : state === "increase" ? "text-success" : undefined; + + const pricePerSchedule = schedulePricing.centsPerStep / stepSize / 100; + const pricePerStep = schedulePricing.centsPerStep / 100; + const stepUnit = formatNumber(stepSize); + const title = extraSchedules === 0 ? "Purchase extra schedules…" : "Add/remove extra schedules…"; + + return ( + + + {triggerButton ?? ( + + )} + + + {title} + +
+
+ + Schedules are purchased in bundles of {stepUnit}, at{" "} + {formatCurrency(pricePerStep, false)}/month per bundle. Reducing will take effect at + the start of the next billing cycle (1st of the month). + +
+
+ + + setBundles(Number(e.target.value))} + disabled={isLoading} + /> + + + {formatNumber(bundles)} {bundles === 1 ? "bundle" : "bundles"} ={" "} + {formatNumber(amountValue)} schedules + + + {amount.error ?? amount.initialError?.[""]?.[0]} + + {form.error} + +
+ {state === "need_to_delete" ? ( +
+ + You need to delete{" "} + {formatNumber(usedSchedules - (planScheduleLimit + amountValue))} more{" "} + {usedSchedules - (planScheduleLimit + amountValue) === 1 ? "schedule" : "schedules"}{" "} + before you can reduce to this level. + +
+ ) : state === "above_quota" ? ( +
+ + Currently you can only have up to {formatNumber(maxQuota)} extra schedules. Send a + request below to lift your current limit. We'll get back to you soon. + +
+ ) : ( +
+
+ Summary + Total +
+
+ + {formatNumber(extraSchedules)} current + extra + + + {formatCurrency(extraSchedules * pricePerSchedule, true)} + +
+
+ + ({formatNumber(extraSchedules)}{" "} + {extraSchedules === 1 ? "schedule" : "schedules"}) + + /mth +
+
+ + {state === "increase" ? "+" : null} + {formatNumber(amountValue - extraSchedules)} + + + {state === "increase" ? "+" : null} + {formatCurrency((amountValue - extraSchedules) * pricePerSchedule, true)} + +
+
+ + ({formatNumber(Math.abs(amountValue - extraSchedules))}{" "} + {Math.abs(amountValue - extraSchedules) === 1 ? "schedule" : "schedules"} @{" "} + {formatCurrency(pricePerStep, false)}/mth per {stepUnit}) + + /mth +
+
+ + {formatNumber(amountValue)} new total + + + {formatCurrency(amountValue * pricePerSchedule, true)} + +
+
+ + ({formatNumber(amountValue)} {amountValue === 1 ? "schedule" : "schedules"}) + + /mth +
+
+ )} +
+ + + + + ) : state === "decrease" || state === "need_to_delete" ? ( + <> + + + + ) : ( + <> + + + + ) + } + cancelButton={ + + + + } + /> +
+
+
+ ); +} + +function updateScheduleState({ + value, + existingValue, + quota, + usedSchedules, + planScheduleLimit, +}: { + value: number; + existingValue: number; + quota: number; + usedSchedules: number; + planScheduleLimit: number; +}): "no_change" | "increase" | "decrease" | "above_quota" | "need_to_delete" { + if (value === existingValue) return "no_change"; + if (value < existingValue) { + const newTotalLimit = planScheduleLimit + value; + if (usedSchedules > newTotalLimit) { + return "need_to_delete"; + } + return "decrease"; + } + if (value > quota) return "above_quota"; + return "increase"; +} diff --git a/apps/webapp/app/services/platform.v3.server.ts b/apps/webapp/app/services/platform.v3.server.ts index bd189b4fb5a..6e2980d4e24 100644 --- a/apps/webapp/app/services/platform.v3.server.ts +++ b/apps/webapp/app/services/platform.v3.server.ts @@ -478,6 +478,22 @@ export async function setBranchesAddOn(organizationId: string, amount: number) { } } +export async function setSchedulesAddOn(organizationId: string, amount: number) { + if (!client) return undefined; + + try { + const result = await client.setAddOn(organizationId, { type: "schedules", amount }); + if (!result.success) { + recordPlatformFailure("setSchedulesAddOn", "no_success"); + return undefined; + } + return result; + } catch (e) { + recordPlatformFailure("setSchedulesAddOn", "caught"); + return undefined; + } +} + export async function getUsage(organizationId: string, { from, to }: { from: Date; to: Date }) { if (!client) return undefined; diff --git a/apps/webapp/app/v3/services/checkSchedule.server.ts b/apps/webapp/app/v3/services/checkSchedule.server.ts index 7c85641c404..ae3370ea0a8 100644 --- a/apps/webapp/app/v3/services/checkSchedule.server.ts +++ b/apps/webapp/app/v3/services/checkSchedule.server.ts @@ -1,7 +1,7 @@ import { ZodError } from "zod"; import { CronPattern } from "../schedules"; import { BaseService, ServiceValidationError } from "./baseService.server"; -import { getLimit } from "~/services/platform.v3.server"; +import { getCurrentPlan, getLimit } from "~/services/platform.v3.server"; import { getTimezones } from "~/utils/timezones.server"; import { env } from "~/env.server"; import { type PrismaClientOrTransaction, type RuntimeEnvironmentType } from "@trigger.dev/database"; @@ -89,7 +89,11 @@ export class CheckScheduleService extends BaseService { //if creating a schedule, check they're under the limits if (!schedule.friendlyId) { - const limit = await getLimit(project.organizationId, "schedules", 100_000_000); + const baseLimit = await getLimit(project.organizationId, "schedules", 100_000_000); + const currentPlan = await getCurrentPlan(project.organizationId); + const purchasedSchedules = + currentPlan?.v3Subscription?.addOns?.schedules?.purchased ?? 0; + const limit = baseLimit + purchasedSchedules; const schedulesCount = await CheckScheduleService.getUsedSchedulesCount({ prisma: this._prisma, projectId, diff --git a/apps/webapp/app/v3/services/setSchedulesAddOn.server.ts b/apps/webapp/app/v3/services/setSchedulesAddOn.server.ts new file mode 100644 index 00000000000..a96a6000765 --- /dev/null +++ b/apps/webapp/app/v3/services/setSchedulesAddOn.server.ts @@ -0,0 +1,100 @@ +import { BaseService } from "./baseService.server"; +import { tryCatch } from "@trigger.dev/core/utils"; +import { setSchedulesAddOn } from "~/services/platform.v3.server"; +import assertNever from "assert-never"; +import { sendToPlain } from "~/utils/plain.server"; +import { uiComponent } from "@team-plain/typescript-sdk"; + +type Input = { + userId: string; + organizationId: string; + action: "purchase" | "quota-increase"; + amount: number; +}; + +type Result = + | { + success: true; + } + | { + success: false; + error: string; + }; + +export class SetSchedulesAddOnService extends BaseService { + async call({ userId, organizationId, action, amount }: Input): Promise { + switch (action) { + case "purchase": { + const result = await setSchedulesAddOn(organizationId, amount); + if (!result) { + return { + success: false, + error: "Failed to update schedules", + }; + } + + switch (result.result) { + case "success": { + return { success: true }; + } + case "error": { + return { success: false, error: result.error }; + } + case "max_quota_reached": { + return { + success: false, + error: `You can't purchase more than ${result.maxQuota} schedules without requesting an increase.`, + }; + } + default: { + return { + success: false, + error: "Failed to update schedules, unknown result.", + }; + } + } + } + case "quota-increase": { + const user = await this._replica.user.findFirst({ + where: { id: userId }, + }); + + if (!user) { + return { success: false, error: "No matching user found." }; + } + + const organization = await this._replica.organization.findFirst({ + select: { title: true }, + where: { id: organizationId }, + }); + + const [error] = await tryCatch( + sendToPlain({ + userId, + email: user.email, + name: user.name ?? user.displayName ?? user.email, + title: `Schedules quota request: ${amount}`, + components: [ + uiComponent.text({ + text: `Org: ${organization?.title} (${organizationId})`, + }), + uiComponent.divider({ spacingSize: "M" }), + uiComponent.text({ + text: `Total schedules requested: ${amount}`, + }), + ], + }) + ); + + if (error) { + return { success: false, error: error.message }; + } + + return { success: true }; + } + default: { + assertNever(action); + } + } + } +}