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..c25e029ef5b --- /dev/null +++ b/.server-changes/hide-self-serve-billing-ui.md @@ -0,0 +1,9 @@ +--- +area: webapp +type: improvement +--- + +Hide self-serve billing and upgrade UI for organizations that are billed +directly. When self-serve is off, plan pickers, upgrade buttons, billing +alerts, and seat/branch purchase flows are replaced with a "Contact us" +option, and the billing page shows a managed-billing message instead. diff --git a/apps/webapp/app/components/BlankStatePanels.tsx b/apps/webapp/app/components/BlankStatePanels.tsx index 9a4884e09d3..e7784ed1a9d 100644 --- a/apps/webapp/app/components/BlankStatePanels.tsx +++ b/apps/webapp/app/components/BlankStatePanels.tsx @@ -473,10 +473,12 @@ export function BranchesNoBranches({ parentEnvironment, limits, canUpgrade, + showSelfServe, }: { parentEnvironment: { id: string }; limits: { used: number; limit: number }; canUpgrade: boolean; + showSelfServe: boolean; }) { const organization = useOrganization(); @@ -488,14 +490,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 3c17ff482ba..32003c4d2a2 100644 --- a/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx +++ b/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx @@ -30,6 +30,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"; @@ -56,6 +57,7 @@ export function OrganizationSettingsSideMenu({ const { isManagedCloud } = useFeatures(); const featureFlags = useFeatureFlags(); const currentPlan = useCurrentPlan(); + const showSelfServe = useShowSelfServe(); const isAdmin = useHasAdminAccess(); const showBuildInfo = isAdmin || !isManagedCloud; @@ -104,14 +106,16 @@ export function OrganizationSettingsSideMenu({ ) : undefined } /> - + {showSelfServe ? ( + + ) : null} )} {featureFlags.hasPrivateConnections && ( 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..e7eecff4a25 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 && @@ -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.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.schedules/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules/route.tsx index b3181eefa36..3301e630c02 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 @@ -49,6 +49,7 @@ import { scheduleTypeName, } from "~/components/runs/v3/ScheduleType"; import { useEnvironment } from "~/hooks/useEnvironment"; +import { useShowSelfServe } from "~/hooks/useShowSelfServe"; import { useOrganization } from "~/hooks/useOrganizations"; import { usePathName } from "~/hooks/usePathName"; import { useProject } from "~/hooks/useProject"; @@ -117,6 +118,7 @@ export default function Page() { const pathName = usePathName(); const plan = useCurrentPlan(); + const showSelfServe = useShowSelfServe(); const requiresUpgrade = plan?.v3Subscription?.plan && limits.used >= plan.v3Subscription.plan.limits.schedules.number && @@ -194,7 +196,7 @@ export default function Page() { You've used {limits.used}/{limits.limit} of your schedules. - {canUpgrade ? ( + {showSelfServe && canUpgrade ? ( Request more + } - defaultValue="help" + defaultValue={showSelfServe ? "help" : "enterprise"} /> )} @@ -215,7 +221,9 @@ export default function Page() { ) : ( )} - {canUpgrade ? ( + {showSelfServe && canUpgrade ? ( ) : ( Request more} - defaultValue="help" + button={ + + } + defaultValue={showSelfServe ? "help" : "enterprise"} /> )}
@@ -423,7 +435,12 @@ function SchedulesTable({ const selectedActionClass = isSelected ? "text-text-bright" : undefined; return ( - + {schedule.friendlyId} 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..b8840473ad4 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,10 +17,12 @@ import { OrganizationParamsSchema, organizationPath, v3StripePortalPath, + v3UsagePath, } from "~/utils/pathBuilder"; import { PricingPlans } from "../resources.orgs.$organizationSlug.select-plan"; import { type MetaFunction } from "@remix-run/react"; import { Callout } from "~/components/primitives/Callout"; +import { useShowSelfServe } from "~/hooks/useShowSelfServe"; export const meta: MetaFunction = () => { return [ @@ -91,12 +96,14 @@ export default function ChoosePlanPage() { daysRemaining, message, } = useTypedLoaderData(); + const showSelfServe = useShowSelfServe(); + 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. + + + + )} ); @@ -160,10 +195,6 @@ function planLabel(plan: PlanDefinition | undefined, canceled: boolean, periodEn return "You're on the Free plan"; } - if (plan.type === "enterprise") { - return `You're on the Enterprise plan`; - } - const text = `You're on the $${plan.tierPrice}/mo ${plan.title} plan`; if (canceled) { 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..9d745b5b1de 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 = () => { @@ -99,9 +100,7 @@ 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 @@ -139,7 +138,7 @@ export default function Page() { 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} + {isUsingPlugin && showSelfServe ? : null}
@@ -152,7 +151,7 @@ export default function Page() {
{columns.length === 0 ? ( - + ) : ( @@ -223,12 +222,18 @@ export default function Page() { ); } -function EmptyState({ isUsingPlugin }: { isUsingPlugin: boolean }) { - // Two distinct empty states: +function EmptyState({ + isUsingPlugin, + showSelfServe, +}: { + isUsingPlugin: boolean; + showSelfServe: boolean; +}) { + // Three 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. + // use under its plan tier. Self-serve orgs see upgrade copy; + // managed-billing orgs get a contact path instead. // 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 @@ -249,8 +254,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}
); } 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/services/platform.v3.server.ts b/apps/webapp/app/services/platform.v3.server.ts index bd189b4fb5a..c479043b47c 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;