diff --git a/.server-changes/hide-self-serve-billing-ui.md b/.server-changes/hide-self-serve-billing-ui.md new file mode 100644 index 00000000000..1aef06007e7 --- /dev/null +++ b/.server-changes/hide-self-serve-billing-ui.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: improvement +--- + +Hide self-serve billing and upgrade options for directly-billed organizations; show Contact us instead. diff --git a/apps/webapp/app/components/BlankStatePanels.tsx b/apps/webapp/app/components/BlankStatePanels.tsx index 4922108a6a8..4d448e1a1d2 100644 --- a/apps/webapp/app/components/BlankStatePanels.tsx +++ b/apps/webapp/app/components/BlankStatePanels.tsx @@ -434,7 +434,7 @@ export function NoWaitpointTokens() { ); } -export function BranchesNoBranchableEnvironment() { +export function BranchesNoBranchableEnvironment({ showSelfServe }: { showSelfServe: boolean }) { const { isManagedCloud } = useFeatures(); const organization = useOrganization(); @@ -462,9 +462,16 @@ export function BranchesNoBranchableEnvironment() { iconClassName="text-preview" panelClassName="max-w-full" accessory={ - - Upgrade - + showSelfServe ? ( + + Upgrade + + ) : ( + Request more} + defaultValue="enterprise" + /> + ) } > @@ -483,10 +490,12 @@ export function BranchesNoBranches({ parentEnvironment, limits, canUpgrade, + showSelfServe, }: { parentEnvironment: { id: string }; limits: { used: number; limit: number }; canUpgrade: boolean; + showSelfServe: boolean; }) { const organization = useOrganization(); @@ -498,14 +507,18 @@ export function BranchesNoBranches({ iconClassName="text-preview" panelClassName="max-w-full" accessory={ - canUpgrade ? ( + showSelfServe && canUpgrade ? ( Upgrade ) : ( Request more} - defaultValue="help" + button={ + + } + defaultValue={showSelfServe ? "help" : "enterprise"} /> ) } diff --git a/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx b/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx index aa6737e1ad8..c25a433d7f9 100644 --- a/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx +++ b/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx @@ -29,6 +29,7 @@ import { LinkButton } from "../primitives/Buttons"; import { HelpAndFeedback } from "./HelpAndFeedbackPopover"; import { SideMenuHeader } from "./SideMenuHeader"; import { SideMenuItem } from "./SideMenuItem"; +import { useShowSelfServe } from "~/hooks/useShowSelfServe"; import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route"; import { Paragraph } from "../primitives/Paragraph"; import { Badge } from "../primitives/Badge"; @@ -55,6 +56,7 @@ export function OrganizationSettingsSideMenu({ const { isManagedCloud } = useFeatures(); const featureFlags = useFeatureFlags(); const currentPlan = useCurrentPlan(); + const showSelfServe = useShowSelfServe(); const isAdmin = useHasAdminAccess(); const showBuildInfo = isAdmin || !isManagedCloud; @@ -103,14 +105,16 @@ export function OrganizationSettingsSideMenu({ ) : undefined } /> - + {showSelfServe ? ( + + ) : null} )} Request more} + /> + ); + } + return ( diff --git a/apps/webapp/app/components/schedules/ScheduleLimitActions.tsx b/apps/webapp/app/components/schedules/ScheduleLimitActions.tsx new file mode 100644 index 00000000000..a2bc2f773ee --- /dev/null +++ b/apps/webapp/app/components/schedules/ScheduleLimitActions.tsx @@ -0,0 +1,83 @@ +import { ArrowUpCircleIcon } from "@heroicons/react/20/solid"; +import { Feedback } from "~/components/Feedback"; +import { Button, LinkButton } from "~/components/primitives/Buttons"; +import { useShowSelfServe } from "~/hooks/useShowSelfServe"; +import { type MatchedOrganization } from "~/hooks/useOrganizations"; +import { v3BillingPath } from "~/utils/pathBuilder"; +import { PurchaseSchedulesModal, type SchedulePricing } from "./PurchaseSchedulesModal"; + +type Props = { + actionPath: string; + canPurchaseSchedules: boolean; + schedulePricing: SchedulePricing | null; + extraSchedules: number; + limits: { used: number; limit: number }; + maxScheduleQuota: number; + planScheduleLimit: number; + canUpgrade: boolean; + organization: MatchedOrganization; + variant?: "dialog" | "banner"; +}; + +export function ScheduleLimitActions({ + actionPath, + canPurchaseSchedules, + schedulePricing, + extraSchedules, + limits, + maxScheduleQuota, + planScheduleLimit, + canUpgrade, + organization, + variant = "banner", +}: Props) { + const showSelfServe = useShowSelfServe(); + + if (!showSelfServe) { + return ( + Request more} + defaultValue="enterprise" + /> + ); + } + + if (canPurchaseSchedules && schedulePricing) { + return ( + Purchase more… + ) : undefined + } + /> + ); + } + + if (canUpgrade) { + return variant === "dialog" ? ( + + Upgrade + + ) : ( + + Upgrade + + ); + } + + return ( + Request more} defaultValue="help" /> + ); +} diff --git a/apps/webapp/app/components/schedules/SchedulesUsageBar.tsx b/apps/webapp/app/components/schedules/SchedulesUsageBar.tsx index 756a5b36da5..c5ea05d1ea7 100644 --- a/apps/webapp/app/components/schedules/SchedulesUsageBar.tsx +++ b/apps/webapp/app/components/schedules/SchedulesUsageBar.tsx @@ -1,14 +1,9 @@ -import { ArrowUpCircleIcon } from "@heroicons/react/20/solid"; -import { Feedback } from "~/components/Feedback"; -import { Button, LinkButton } from "~/components/primitives/Buttons"; import { Header3 } from "~/components/primitives/Headers"; import { InfoIconTooltip, SimpleTooltip } from "~/components/primitives/Tooltip"; import { useOrganization } from "~/hooks/useOrganizations"; -import { v3BillingPath, v3SchedulesAddOnPath } from "~/utils/pathBuilder"; -import { - PurchaseSchedulesModal, - type SchedulePricing, -} from "./PurchaseSchedulesModal"; +import { v3SchedulesAddOnPath } from "~/utils/pathBuilder"; +import { ScheduleLimitActions } from "./ScheduleLimitActions"; +import { type SchedulePricing } from "./PurchaseSchedulesModal"; type Props = { limits: { used: number; limit: number }; @@ -81,30 +76,17 @@ export function SchedulesUsageBar({ )} - {canPurchaseSchedules && schedulePricing ? ( - - ) : canUpgrade ? ( - - Upgrade - - ) : ( - Request more} - defaultValue="help" - /> - )} + diff --git a/apps/webapp/app/hooks/useShowSelfServe.ts b/apps/webapp/app/hooks/useShowSelfServe.ts new file mode 100644 index 00000000000..594bf7217a5 --- /dev/null +++ b/apps/webapp/app/hooks/useShowSelfServe.ts @@ -0,0 +1,7 @@ +import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route"; + +/** Whether the org should see self-serve billing UI (plan picker, Stripe checkout, upgrades). */ +export function useShowSelfServe(): boolean { + const plan = useCurrentPlan(); + return plan?.v3Subscription?.showSelfServe ?? true; +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.alerts/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.alerts/route.tsx index 9b888a43624..3824c6a44d5 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.alerts/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.alerts/route.tsx @@ -18,6 +18,7 @@ import assertNever from "assert-never"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { AlertsNoneDev, AlertsNoneDeployed } from "~/components/BlankStatePanels"; +import { Feedback } from "~/components/Feedback"; import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel"; import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout"; import { Button, LinkButton } from "~/components/primitives/Buttons"; @@ -45,6 +46,7 @@ import { import { EnabledStatus } from "~/components/runs/v3/EnabledStatus"; import { prisma } from "~/db.server"; import { useEnvironment } from "~/hooks/useEnvironment"; +import { useShowSelfServe } from "~/hooks/useShowSelfServe"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; import { redirectWithSuccessMessage } from "~/models/message.server"; @@ -182,6 +184,7 @@ export default function Page() { const organization = useOrganization(); const project = useProject(); const environment = useEnvironment(); + const showSelfServe = useShowSelfServe(); const requiresUpgrade = limits.used >= limits.limit; @@ -343,9 +346,16 @@ export default function Page() { )} - - Upgrade - + {showSelfServe ? ( + + Upgrade + + ) : ( + Request more} + /> + )} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx index 39db1d96f3a..dfcaff0db48 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx @@ -12,6 +12,7 @@ import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons"; import { BranchesNoBranchableEnvironment, BranchesNoBranches } from "~/components/BlankStatePanels"; +import { Feedback } from "~/components/Feedback"; import { GitMetadata } from "~/components/GitMetadata"; import { V4Title } from "~/components/V4Badge"; import { AdminDebugTooltip } from "~/components/admin/debugTooltip"; @@ -56,6 +57,7 @@ import { } from "~/components/primitives/Table"; import { InfoIconTooltip, SimpleTooltip } from "~/components/primitives/Tooltip"; import { useEnvironment } from "~/hooks/useEnvironment"; +import { useShowSelfServe } from "~/hooks/useShowSelfServe"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; @@ -63,6 +65,7 @@ import { findProjectBySlug } from "~/models/project.server"; import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; import { BranchesPresenter } from "~/presenters/v3/BranchesPresenter.server"; import { logger } from "~/services/logger.server"; +import { getCurrentPlan, getSelfServePurchaseBlockReason } from "~/services/platform.v3.server"; import { requireUserId } from "~/services/session.server"; import { UpsertBranchService } from "~/services/upsertBranch.server"; import { cn } from "~/utils/cn"; @@ -155,6 +158,21 @@ export async function action({ request, params }: ActionFunctionArgs) { throw redirectWithErrorMessage(redirectPath, request, "Project not found"); } + const currentPlan = await getCurrentPlan(project.organizationId); + const purchaseBlockReason = getSelfServePurchaseBlockReason(currentPlan); + if (purchaseBlockReason === "plan_unavailable") { + return json( + { ok: false, error: "Unable to verify billing status. Please try again." } as const, + { status: 503 } + ); + } + if (purchaseBlockReason === "managed_billing") { + return json( + { ok: false, error: "Contact us to request more branches." } as const, + { status: 403 } + ); + } + const submission = parse(formData, { schema: PurchaseSchema }); if (!submission.value || submission.intent !== "submit") { @@ -237,6 +255,7 @@ export default function Page() { const environment = useEnvironment(); const plan = useCurrentPlan(); + const showSelfServe = useShowSelfServe(); const requiresUpgrade = plan?.v3Subscription?.plan && limits.used >= plan.v3Subscription.plan.limits.branches.number && @@ -254,7 +273,7 @@ export default function Page() { - + @@ -322,6 +341,7 @@ export default function Page() { parentEnvironment={branchableEnvironment} limits={limits} canUpgrade={canUpgrade ?? false} + showSelfServe={showSelfServe} /> ) : ( @@ -484,19 +504,26 @@ export default function Page() { planBranchLimit={planBranchLimit} /> ) : canUpgrade ? ( -
- - Upgrade plan for more Preview Branches - - - Upgrade - -
+ showSelfServe ? ( +
+ + Upgrade plan for more Preview Branches + + + Upgrade + +
+ ) : ( + Request more} + /> + ) ) : null} @@ -559,6 +586,7 @@ function UpgradePanel({ planBranchLimit: number; }) { const organization = useOrganization(); + const showSelfServe = useShowSelfServe(); if (canPurchaseBranches && branchPricing) { return ( @@ -604,9 +632,16 @@ function UpgradePanel({ {canUpgrade ? ( - - Upgrade - + showSelfServe ? ( + + Upgrade + + ) : ( + Request more} + /> + ) ) : null} @@ -632,6 +667,7 @@ function PurchaseBranchesModal({ planBranchLimit: number; triggerButton?: React.ReactNode; }) { + const showSelfServe = useShowSelfServe(); const fetcher = useFetcher(); const lastSubmission = fetcher.data && typeof fetcher.data === "object" && "intent" in fetcher.data @@ -679,6 +715,15 @@ function PurchaseBranchesModal({ const pricePerBranch = branchPricing.centsPerStep / branchPricing.stepSize / 100; const title = extraBranches === 0 ? "Purchase extra branches…" : "Add/remove extra branches…"; + if (!showSelfServe) { + return ( + Request more} + /> + ); + } + return ( diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.concurrency/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.concurrency/route.tsx index 1c96b8c6422..e2b535a1892 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.concurrency/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.concurrency/route.tsx @@ -23,6 +23,7 @@ import simplur from "simplur"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { AdminDebugTooltip } from "~/components/admin/debugTooltip"; +import { Feedback } from "~/components/Feedback"; import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel"; import { MainHorizontallyCenteredContainer, @@ -52,6 +53,7 @@ import { } from "~/components/primitives/Table"; import { InfoIconTooltip } from "~/components/primitives/Tooltip"; import { useFeatures } from "~/hooks/useFeatures"; +import { useShowSelfServe } from "~/hooks/useShowSelfServe"; import { useOrganization } from "~/hooks/useOrganizations"; import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; import { findProjectBySlug } from "~/models/project.server"; @@ -60,7 +62,11 @@ import { type ConcurrencyResult, type EnvironmentWithConcurrency, } from "~/presenters/v3/ManageConcurrencyPresenter.server"; -import { getPlans } from "~/services/platform.v3.server"; +import { + getCurrentPlan, + getPlans, + getSelfServePurchaseBlockReason, +} from "~/services/platform.v3.server"; import { requireUserId } from "~/services/session.server"; import { formatCurrency, formatNumber } from "~/utils/numberFormatter"; import { concurrencyPath, EnvironmentParamSchema, v3BillingPath } from "~/utils/pathBuilder"; @@ -186,6 +192,17 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { ); } + const currentPlan = await getCurrentPlan(project.organizationId); + const purchaseBlockReason = getSelfServePurchaseBlockReason(currentPlan); + if (purchaseBlockReason === "plan_unavailable") { + submission.error.amount = ["Unable to verify billing status. Please try again."]; + return json(submission, { status: 503 }); + } + if (purchaseBlockReason === "managed_billing") { + submission.error.amount = ["Contact us to request more concurrency."]; + return json(submission, { status: 403 }); + } + const service = new SetConcurrencyAddOnService(); const [error, result] = await tryCatch( service.call({ @@ -530,6 +547,7 @@ function NotUpgradable({ environments }: { environments: EnvironmentWithConcurre const { isManagedCloud } = useFeatures(); const plan = useCurrentPlan(); const organization = useOrganization(); + const showSelfServe = useShowSelfServe(); return (
@@ -543,9 +561,16 @@ function NotUpgradable({ environments }: { environments: EnvironmentWithConcurre upgrade your plan to get more concurrency. You are currently on the{" "} {plan?.v3Subscription?.plan?.title ?? "Free"} plan. - - Upgrade for more concurrency - + {showSelfServe ? ( + + Upgrade for more concurrency + + ) : ( + Contact us} + /> + )} ) : null}
@@ -588,6 +613,7 @@ function PurchaseConcurrencyModal({ maxQuota: number; disabled: boolean; }) { + const showSelfServe = useShowSelfServe(); const lastSubmission = useActionData(); const [form, { amount }] = useForm({ id: "purchase-concurrency", @@ -629,6 +655,15 @@ function PurchaseConcurrencyModal({ const title = extraConcurrency === 0 ? "Purchase extra concurrency" : "Add/remove concurrency"; + if (!showSelfServe) { + return ( + Request more} + /> + ); + } + return ( diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx index ce19dd3a8cb..782d2eef8d4 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx @@ -27,6 +27,7 @@ import { import { InfoIconTooltip } from "~/components/primitives/Tooltip"; import { useAutoRevalidate } from "~/hooks/useAutoRevalidate"; import { useEnvironment } from "~/hooks/useEnvironment"; +import { useShowSelfServe } from "~/hooks/useShowSelfServe"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; import { findProjectBySlug } from "~/models/project.server"; @@ -189,6 +190,8 @@ function CurrentPlanSection({ isOnTopPlan: boolean; billingPath: string; }) { + const showSelfServe = useShowSelfServe(); + return (
@@ -205,10 +208,15 @@ function CurrentPlanSection({ button={} defaultValue="help" /> - ) : ( + ) : showSelfServe ? ( View plans + ) : ( + Contact us} + /> )} @@ -260,6 +268,8 @@ function RateLimitsSection({ project: ReturnType; environment: ReturnType; }) { + const showSelfServe = useShowSelfServe(); + return (
@@ -325,15 +335,23 @@ function RateLimitsSection({ /> - Upgrade + {showSelfServe ? ( + Upgrade + ) : null} - + @@ -345,10 +363,12 @@ function RateLimitRow({ info, isOnTopPlan, billingPath, + showSelfServe, }: { info: RateLimitInfo; isOnTopPlan: boolean; billingPath: string; + showSelfServe: boolean; }) { const maxTokens = info.config.type === "tokenBucket" ? info.config.maxTokens : info.config.tokens; const percentage = @@ -389,27 +409,29 @@ function RateLimitRow({ )} - -
- {info.name === "Batch rate limit" ? ( - isOnTopPlan ? ( + {showSelfServe ? ( + +
+ {info.name === "Batch rate limit" ? ( + isOnTopPlan ? ( + Contact us} + defaultValue="help" + /> + ) : ( + + View plans + + ) + ) : ( Contact us} defaultValue="help" /> - ) : ( - - View plans - - ) - ) : ( - Contact us} - defaultValue="help" - /> - )} -
-
+ )} +
+
+ ) : null} ); } @@ -516,6 +538,8 @@ function QuotasSection({ if (quotas.metricWidgetsPerDashboard) quotaRows.push(quotas.metricWidgetsPerDashboard); if (quotas.queryPeriodDays) quotaRows.push(quotas.queryPeriodDays); + const showSelfServe = useShowSelfServe(); + return (
@@ -533,7 +557,9 @@ function QuotasSection({ Limit Current Source - Upgrade + {showSelfServe ? ( + Upgrade + ) : null} @@ -543,6 +569,7 @@ function QuotasSection({ quota={quota} isOnTopPlan={isOnTopPlan} billingPath={billingPath} + showSelfServe={showSelfServe} /> ))} @@ -555,10 +582,12 @@ function QuotaRow({ quota, isOnTopPlan, billingPath, + showSelfServe, }: { quota: QuotaInfo; isOnTopPlan: boolean; billingPath: string; + showSelfServe: boolean; }) { // For log retention and query period, we don't show current usage as it's a duration, not a count // For widgets per dashboard, the usage varies per dashboard so we don't show a single number @@ -567,7 +596,7 @@ function QuotaRow({ const isRetentionQuota = isDurationQuota || isPerItemQuota; const isQueueSizeQuota = quota.name === "Max queued runs"; const hideCurrentUsage = isRetentionQuota || isQueueSizeQuota; - + const percentage = !hideCurrentUsage && quota.limit && quota.limit > 0 ? quota.currentUsage / quota.limit : null; @@ -582,8 +611,8 @@ function QuotaRow({ {quota.limit !== null - ? `${formatNumber(quota.limit)} ${quota.limit === 1 ? "day" : "days"}` - : "Unlimited"} + ? `${formatNumber(quota.limit)} ${quota.limit === 1 ? "day" : "days"}` + : "Unlimited"} – @@ -591,20 +620,22 @@ function QuotaRow({ - -
- {canUpgrade ? ( - - View plans - - ) : ( - Contact us} - defaultValue="help" - /> - )} -
-
+ {showSelfServe ? ( + +
+ {canUpgrade ? ( + + View plans + + ) : ( + Contact us} + defaultValue="help" + /> + )} +
+
+ ) : null} ); } @@ -678,7 +709,7 @@ function QuotaRow({ - {renderUpgrade()} + {showSelfServe ? {renderUpgrade()} : null} ); } @@ -694,6 +725,7 @@ function FeaturesSection({ }) { // For staging environment: show View plans if not enabled (i.e., on Free plan) const stagingUpgradeType = features.hasStagingEnvironment.enabled ? "none" : "view-plans"; + const showSelfServe = useShowSelfServe(); return (
@@ -706,7 +738,9 @@ function FeaturesSection({ Feature Status - Upgrade + {showSelfServe ? ( + Upgrade + ) : null} @@ -714,16 +748,19 @@ function FeaturesSection({ feature={features.hasStagingEnvironment} upgradeType={stagingUpgradeType} billingPath={billingPath} + showSelfServe={showSelfServe} /> @@ -735,10 +772,12 @@ function FeatureRow({ feature, upgradeType, billingPath, + showSelfServe, }: { feature: FeatureInfo; upgradeType: "view-plans" | "contact-us" | "none"; billingPath: string; + showSelfServe: boolean; }) { const displayValue = () => { if (feature.name === "Included compute" && typeof feature.value === "number") { @@ -795,7 +834,7 @@ function FeatureRow({ {displayValue()} - {renderUpgrade()} + {showSelfServe ? {renderUpgrade()} : null} ); } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx index f90f3e829b0..c73fa253046 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx @@ -21,7 +21,7 @@ import { DialogHeader, DialogTrigger, } from "~/components/primitives/Dialog"; -import { PurchaseSchedulesModal } from "~/components/schedules/PurchaseSchedulesModal"; +import { ScheduleLimitActions } from "~/components/schedules/ScheduleLimitActions"; import { SchedulesUsageBar } from "~/components/schedules/SchedulesUsageBar"; import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route"; import { CopyableText } from "~/components/primitives/CopyableText"; @@ -439,21 +439,18 @@ function CreateScheduleButton({ You've used {limits.used}/{limits.limit} of your schedules. - {canPurchaseSchedules && schedulePricing ? ( - Purchase more…} - /> - ) : canUpgrade ? ( - - Upgrade - - ) : null} +
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.billing-alerts/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.billing-alerts/route.tsx index c4f8762639b..85f47ac9e4e 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.billing-alerts/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.billing-alerts/route.tsx @@ -28,7 +28,7 @@ import { InfoIconTooltip } from "~/components/primitives/Tooltip"; import { prisma } from "~/db.server"; import { featuresForRequest } from "~/features.server"; import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; -import { getBillingAlerts, setBillingAlert } from "~/services/platform.v3.server"; +import { getBillingAlerts, getCurrentPlan, setBillingAlert } from "~/services/platform.v3.server"; import { requireUserId } from "~/services/session.server"; import { formatCurrency, formatNumber } from "~/utils/numberFormatter"; import { @@ -36,6 +36,7 @@ import { OrganizationParamsSchema, organizationPath, v3BillingAlertsPath, + v3BillingPath, } from "~/utils/pathBuilder"; import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route"; @@ -64,6 +65,11 @@ export async function loader({ params, request }: LoaderFunctionArgs) { throw new Response(null, { status: 404, statusText: "Organization not found" }); } + const currentPlan = await getCurrentPlan(organization.id); + if (currentPlan?.v3Subscription?.showSelfServe === false) { + return redirect(v3BillingPath({ slug: organizationSlug })); + } + const [error, alerts] = await tryCatch(getBillingAlerts(organization.id)); if (error) { throw new Response(null, { status: 404, statusText: `Billing alerts error: ${error}` }); @@ -108,6 +114,23 @@ export const action: ActionFunction = async ({ request, params }) => { const userId = await requireUserId(request); const { organizationSlug } = OrganizationParamsSchema.parse(params); + const organization = await prisma.organization.findFirst({ + where: { slug: organizationSlug, members: { some: { userId } } }, + }); + + if (!organization) { + return redirectWithErrorMessage( + v3BillingPath({ slug: organizationSlug }), + request, + "You are not authorized to update billing alerts" + ); + } + + const currentPlan = await getCurrentPlan(organization.id); + if (currentPlan?.v3Subscription?.showSelfServe === false) { + return redirect(v3BillingPath({ slug: organizationSlug })); + } + const formData = await request.formData(); const submission = parse(formData, { schema }); @@ -116,18 +139,6 @@ export const action: ActionFunction = async ({ request, params }) => { } try { - const organization = await prisma.organization.findFirst({ - where: { slug: organizationSlug, members: { some: { userId } } }, - }); - - if (!organization) { - return redirectWithErrorMessage( - v3BillingAlertsPath({ slug: organizationSlug }), - request, - "You are not authorized to update billing alerts" - ); - } - const [error, updatedAlert] = await tryCatch( setBillingAlert(organization.id, { ...submission.value, diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.billing/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.billing/route.tsx index 1e579908a92..e891efd087d 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.billing/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.billing/route.tsx @@ -1,11 +1,14 @@ -import { CalendarDaysIcon, StarIcon } from "@heroicons/react/20/solid"; +import { CalendarDaysIcon, CreditCardIcon, StarIcon } from "@heroicons/react/20/solid"; import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { type PlanDefinition } from "@trigger.dev/platform"; import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson"; -import { PageBody, PageContainer } from "~/components/layout/AppLayout"; -import { LinkButton } from "~/components/primitives/Buttons"; +import { Feedback } from "~/components/Feedback"; +import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout"; +import { Button, LinkButton } from "~/components/primitives/Buttons"; import { DateTime } from "~/components/primitives/DateTime"; +import { InfoPanel } from "~/components/primitives/InfoPanel"; import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; +import { Paragraph } from "~/components/primitives/Paragraph"; import { prisma } from "~/db.server"; import { featuresForRequest } from "~/features.server"; import { getCurrentPlan, getPlans } from "~/services/platform.v3.server"; @@ -14,6 +17,7 @@ import { OrganizationParamsSchema, organizationPath, v3StripePortalPath, + v3UsagePath, } from "~/utils/pathBuilder"; import { PricingPlans } from "../resources.orgs.$organizationSlug.select-plan"; import { type MetaFunction } from "@remix-run/react"; @@ -36,11 +40,6 @@ export async function loader({ params, request }: LoaderFunctionArgs) { return redirect(organizationPath({ slug: organizationSlug })); } - const plans = await getPlans(); - if (!plans) { - throw new Response(null, { status: 404, statusText: "Plans not found" }); - } - const organization = await prisma.organization.findFirst({ where: { slug: organizationSlug, members: { some: { userId } } }, }); @@ -50,6 +49,7 @@ export async function loader({ params, request }: LoaderFunctionArgs) { } const currentPlan = await getCurrentPlan(organization.id); + const showSelfServe = currentPlan?.v3Subscription?.showSelfServe !== false; //periods const periodStart = new Date(); @@ -69,7 +69,25 @@ export async function loader({ params, request }: LoaderFunctionArgs) { const url = new URL(request.url); const message = url.searchParams.get("message"); + if (!showSelfServe) { + return typedjson({ + showSelfServe: false as const, + ...currentPlan, + organizationSlug, + periodStart, + periodEnd, + daysRemaining, + message, + }); + } + + const plans = await getPlans(); + if (!plans) { + throw new Response(null, { status: 404, statusText: "Plans not found" }); + } + return typedjson({ + showSelfServe: true as const, ...plans, ...currentPlan, organizationSlug, @@ -81,22 +99,23 @@ export async function loader({ params, request }: LoaderFunctionArgs) { } export default function ChoosePlanPage() { + const loaderData = useTypedLoaderData(); const { - plans, - addOnPricing, + showSelfServe, v3Subscription, organizationSlug, periodStart, periodEnd, daysRemaining, message, - } = useTypedLoaderData(); + } = loaderData; + return ( - {v3Subscription?.isPaying && ( + {v3Subscription?.isPaying && showSelfServe && ( <> - -
- {message && ( - - {message} - - )} -
-
- - {planLabel(v3Subscription?.plan, v3Subscription?.canceledAt !== undefined, periodEnd)} -
- {v3Subscription?.isPaying ? ( + + {showSelfServe ? ( +
+ {message && ( + + {message} + + )} +
- - Billing period: {" "} - to ({daysRemaining}{" "} - days remaining) + + {planLabel( + v3Subscription?.plan, + v3Subscription?.canceledAt !== undefined, + periodEnd + )}
- ) : null} -
-
- + {v3Subscription?.isPaying ? ( +
+ + Billing period: {" "} + to ( + {daysRemaining} days remaining) +
+ ) : null} +
+
+ +
-
+ ) : ( + + Contact us} + /> + } + > + + Your billing is managed by our team. + + + Get in touch for invoices, plan changes, or other billing questions. + + + + )} ); @@ -161,7 +208,7 @@ function planLabel(plan: PlanDefinition | undefined, canceled: boolean, periodEn } if (plan.type === "enterprise") { - return `You're on the Enterprise plan`; + return "You're on the Enterprise plan"; } const text = `You're on the $${plan.tierPrice}/mo ${plan.title} plan`; diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.roles/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.roles/route.tsx index 79f2356250a..6b3d037fbfe 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.roles/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.roles/route.tsx @@ -3,6 +3,7 @@ import { type MetaFunction } from "@remix-run/react"; import { useState } from "react"; import { type UseDataFunctionReturn, typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; +import { Feedback } from "~/components/Feedback"; import { PageBody, PageContainer } from "~/components/layout/AppLayout"; import { Badge } from "~/components/primitives/Badge"; import { Button } from "~/components/primitives/Buttons"; @@ -24,7 +25,7 @@ import { $replica } from "~/db.server"; import { useOrganization } from "~/hooks/useOrganizations"; import { rbac } from "~/services/rbac.server"; import { dashboardLoader } from "~/services/routeBuilders/dashboardBuilder"; -import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route"; +import { useShowSelfServe } from "~/hooks/useShowSelfServe"; import { TextLink } from "~/components/primitives/TextLink"; export const meta: MetaFunction = () => { @@ -68,10 +69,7 @@ export const loader = dashboardLoader( rbac.getAssignableRoleIds(orgId), rbac.allPermissions(orgId), rbac.systemRoles(orgId), - // OSS self-host: no enterprise plugin → no role infrastructure to - // show. Render a "roles aren't available" layout in that case - // rather than the plan-upsell empty state (which assumes a cloud - // plan and would be misleading). + // OSS self-host has no RBAC plugin. rbac.isUsingPlugin(), ]); @@ -90,33 +88,19 @@ type LoaderRole = LoaderData["roles"][number]; type LoaderPermission = LoaderData["allPermissions"][number]; type RolePermission = LoaderRole["permissions"][number]; -// Permissions are bucketed by `permission.group` from the plugin. -// Section order = first-seen order in `allPermissions()`. Permissions -// without a group fall into "Other" at the bottom. +// Ungrouped permissions fall into "Other". const FALLBACK_GROUP = "Other"; export default function Page() { const { roles, assignableRoleIds, allPermissions, systemRoles, isUsingPlugin } = useTypedLoaderData(); const organization = useOrganization(); - const plan = useCurrentPlan(); - const planCode = plan?.v3Subscription?.plan?.code; - const isEnterprise = planCode === "enterprise"; + const showSelfServe = useShowSelfServe(); - // Map role-id → role for fast cell lookup. Each role's permissions are - // already the expanded `effectivePermissions` output (system roles - // populated server-side; custom roles too) so cells just filter that - // list by permission name. const rolesById = new Map(roles.map((r) => [r.id, r])); const assignable = new Set(assignableRoleIds); - // Column ordering follows the plugin's canonical systemRoles order - // (highest authority first), then any custom roles in the order - // rbac.allRoles returned them. systemRoles is null when no plugin is - // installed; fall through to whatever order rbac.allRoles returns. - // Each entry's `available` flag reflects plan-tier eligibility — we - // render unavailable system roles too, but PlanBadge tags them so - // customers see the comparison and know what an upgrade unlocks. + // System roles first (plugin order), then custom roles. const systemRoleOrder = systemRoles ?? []; const systemRoleIdSet = new Set(systemRoleOrder.map((r) => r.id)); const systemColumns = systemRoleOrder.flatMap((meta) => { @@ -134,12 +118,8 @@ export default function Page() { - {/* Suppress the Enterprise-upsell button on OSS — there's no - plan to upgrade to in a self-hosted deployment, and the - dialog copy ("Available on the Enterprise plan") doesn't - apply. The not-supported empty state below makes the - absence of role infrastructure clear instead. */} - {isUsingPlugin && !isEnterprise ? : null} + {/* Hide on OSS self-host and managed customers (!showSelfServe). */} + {isUsingPlugin && showSelfServe ? : null}
@@ -152,7 +132,7 @@ export default function Page() {
{columns.length === 0 ? ( - + ) : ( @@ -223,17 +203,14 @@ export default function Page() { ); } -function EmptyState({ isUsingPlugin }: { isUsingPlugin: boolean }) { - // Two distinct empty states: - // - // 1. Plugin loaded, but rbac.allRoles returned nothing the org can - // use under its plan tier. The plan-upsell copy is correct — - // upgrade unlocks the role infrastructure. - // 2. No plugin loaded (OSS self-host). There's no "plan" to upgrade - // to. RBAC simply isn't part of this deployment; we use a - // permissive ability for every authenticated user and rely on - // org-membership for access control. Surface that honestly - // instead of dangling a fake upgrade carrot. +function EmptyState({ + isUsingPlugin, + showSelfServe, +}: { + isUsingPlugin: boolean; + showSelfServe: boolean; +}) { + // OSS self-host vs plan-gated empty state. if (!isUsingPlugin) { return (
@@ -249,8 +226,16 @@ function EmptyState({ isUsingPlugin }: { isUsingPlugin: boolean }) {
No roles available on this plan. - Upgrade to Pro to unlock RBAC. + {showSelfServe + ? "Upgrade to Pro to unlock RBAC." + : "Contact us to discuss RBAC for your organization."} + {!showSelfServe ? ( + Contact us} + /> + ) : null}
); } @@ -264,23 +249,14 @@ function PlanBadge({ assignable: ReadonlySet; systemRoleIdSet: ReadonlySet; }) { - // Roles the org's plan doesn't permit get a small upgrade-tier hint - // in the column header. The cell rendering is identical regardless - // — the comparison value is still useful even on Free/Hobby. if (assignable.has(roleId)) return null; - // System roles render as "Pro" (the gating tier where they unlock — - // Free/Hobby see Owner+Admin only, Pro adds the rest). Custom roles - // render as "Enterprise" — only Enterprise plans can create or assign - // them. + // Unassignable system roles → Pro; custom roles → Enterprise. if (systemRoleIdSet.has(roleId)) { return Pro; } return Enterprise; } -// Render a single (role × permission) cell. Filters the role's -// effectivePermissions list to entries matching this permission name -// and emits an icon + optional condition badge based on the rules. function RoleCell({ permissionName, rolePermissions, @@ -291,7 +267,6 @@ function RoleCell({ const matching = rolePermissions.filter((p) => p.name === permissionName); if (matching.length === 0) { - // No rule matches — the role denies this permission by omission. return ( @@ -302,8 +277,6 @@ function RoleCell({ const allowed = matching.filter((p) => !p.inverted); const denied = matching.filter((p) => p.inverted); - // Only inverted rules apply — the role explicitly denies this - // permission. Render as ✗ in error colour. if (allowed.length === 0) { return ( @@ -312,10 +285,6 @@ function RoleCell({ ); } - // At least one allow rule applies. If there's a conditional cannot - // rule, replace the ✓ with just the condition label so the user sees - // the restriction without a misleading tick. Plain unconditional - // allow keeps the ✓. const conditionalDeny = denied.find((p) => p.conditions); if (conditionalDeny?.conditions) { return ( @@ -329,10 +298,7 @@ function RoleCell({ ); } -// Render a CASL conditions object into a tier badge label. Only -// `envType` is recognised today (the catalogue's only allowed condition); -// extending this requires adding a new branch when ALLOWED_CONDITIONS -// grows. +// Only `envType` is supported today. function conditionLabel(conditions: Record): string { if (typeof conditions.envType === "string") { if (conditions.envType === "PRODUCTION") return "Non-prod only"; @@ -344,9 +310,6 @@ function conditionLabel(conditions: Record): string { function groupPermissions( permissions: LoaderPermission[] ): { group: string; permissions: LoaderPermission[] }[] { - // Insertion-ordered map: groups appear in the order their first - // permission was seen. Plugins that want a specific section order - // just emit permissions in that order from `allPermissions()`. const buckets = new Map(); for (const permission of permissions) { const group = permission.group ?? FALLBACK_GROUP; @@ -357,7 +320,7 @@ function groupPermissions( return Array.from(buckets, ([group, permissions]) => ({ group, permissions })); } -function CreateRoleUpsell() { +function RequestCustomRoles() { const [open, setOpen] = useState(false); return ( diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx index c95ca471f85..f9e7c0b0ee1 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx @@ -15,6 +15,7 @@ import { useEffect, useRef, useState } from "react"; import { type UseDataFunctionReturn, typedjson, useTypedLoaderData } from "remix-typedjson"; import invariant from "tiny-invariant"; import { z } from "zod"; +import { Feedback } from "~/components/Feedback"; import { UserAvatar } from "~/components/UserProfilePhoto"; import { AdminDebugTooltip } from "~/components/admin/debugTooltip"; import { PageBody, PageContainer } from "~/components/layout/AppLayout"; @@ -45,11 +46,13 @@ import { Select, SelectItem, SelectLinkItem } from "~/components/primitives/Sele import { SpinnerWhite } from "~/components/primitives/Spinner"; import { SimpleTooltip } from "~/components/primitives/Tooltip"; import { $replica } from "~/db.server"; +import { useShowSelfServe } from "~/hooks/useShowSelfServe"; import { useOrganization } from "~/hooks/useOrganizations"; import { useUser } from "~/hooks/useUser"; import { removeTeamMember } from "~/models/member.server"; import { redirectWithSuccessMessage } from "~/models/message.server"; import { TeamPresenter } from "~/presenters/TeamPresenter.server"; +import { getCurrentPlan, getSelfServePurchaseBlockReason } from "~/services/platform.v3.server"; import { rbac } from "~/services/rbac.server"; import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder"; import { cn } from "~/utils/cn"; @@ -210,6 +213,21 @@ export const action = dashboardAction( return json({ ok: false, error: "Organization not found" } as const); } + const currentPlan = await getCurrentPlan(orgId); + const purchaseBlockReason = getSelfServePurchaseBlockReason(currentPlan); + if (purchaseBlockReason === "plan_unavailable") { + return json( + { ok: false, error: "Unable to verify billing status. Please try again." } as const, + { status: 503 } + ); + } + if (purchaseBlockReason === "managed_billing") { + return json( + { ok: false, error: "Contact us to request more seats." } as const, + { status: 403 } + ); + } + const submission = parse(formData, { schema: PurchaseSchema }); if (!submission.value || submission.intent !== "submit") { @@ -310,6 +328,7 @@ export default function Page() { const organization = useOrganization(); const plan = useCurrentPlan(); + const showSelfServe = useShowSelfServe(); const requiresUpgrade = limits.used >= limits.limit; const usageRatio = limits.limit > 0 ? Math.min(limits.used / limits.limit, 1) : 0; const canUpgrade = @@ -515,9 +534,16 @@ export default function Page() { planSeatLimit={planSeatLimit} /> ) : canUpgrade ? ( - - Upgrade - + showSelfServe ? ( + + Upgrade + + ) : ( + Request more} + /> + ) ) : null}
@@ -841,6 +867,7 @@ export function PurchaseSeatsModal({ planSeatLimit: number; triggerButton?: React.ReactElement; }) { + const showSelfServe = useShowSelfServe(); const fetcher = useFetcher(); const organization = useOrganization(); const lastSubmission = @@ -889,6 +916,15 @@ export function PurchaseSeatsModal({ const pricePerSeat = seatPricing.centsPerStep / seatPricing.stepSize / 100; const title = extraSeats === 0 ? "Purchase extra seats…" : "Add/remove extra seats…"; + if (!showSelfServe) { + return ( + Request more} + /> + ); + } + return ( diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.schedules-addon.ts b/apps/webapp/app/routes/resources.orgs.$organizationSlug.schedules-addon.ts index 43257a2905f..f765da3534c 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.schedules-addon.ts +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.schedules-addon.ts @@ -3,6 +3,7 @@ import { type ActionFunctionArgs, json } from "@remix-run/server-runtime"; import { tryCatch } from "@trigger.dev/core/v3"; import { z } from "zod"; import { prisma } from "~/db.server"; +import { getCurrentPlan, getSelfServePurchaseBlockReason } from "~/services/platform.v3.server"; import { requireUserId } from "~/services/session.server"; import { SetSchedulesAddOnService } from "~/v3/services/setSchedulesAddOn.server"; @@ -34,6 +35,21 @@ export async function action({ request, params }: ActionFunctionArgs) { return json({ error: "Organization not found" }, { status: 404 }); } + const currentPlan = await getCurrentPlan(organization.id); + const purchaseBlockReason = getSelfServePurchaseBlockReason(currentPlan); + if (purchaseBlockReason === "plan_unavailable") { + return json( + { ok: false, error: "Unable to verify billing status. Please try again." } as const, + { status: 503 } + ); + } + if (purchaseBlockReason === "managed_billing") { + return json( + { ok: false, error: "Contact us to request more schedules." } as const, + { status: 403 } + ); + } + const formData = await request.formData(); const submission = parse(formData, { schema: PurchaseSchema }); diff --git a/apps/webapp/app/services/platform.v3.server.ts b/apps/webapp/app/services/platform.v3.server.ts index 6e2980d4e24..fdc709fb1b1 100644 --- a/apps/webapp/app/services/platform.v3.server.ts +++ b/apps/webapp/app/services/platform.v3.server.ts @@ -255,6 +255,30 @@ export async function getCurrentPlan(orgId: string) { } } +export type SelfServePurchaseBlockReason = "plan_unavailable" | "managed_billing"; + +/** + * When cloud billing is configured, self-serve purchase endpoints must fail closed + * if the current plan can't be loaded or the org is on managed billing. + */ +export function getSelfServePurchaseBlockReason( + currentPlan: Awaited> +): SelfServePurchaseBlockReason | undefined { + if (!isBillingConfigured()) { + return undefined; + } + + if (!currentPlan) { + return "plan_unavailable"; + } + + if (currentPlan.v3Subscription?.showSelfServe === false) { + return "managed_billing"; + } + + return undefined; +} + export async function getLimits(orgId: string) { if (!client) return undefined;