fix(billing): the paid tier is Team, and it is not unlimited users (#7730)

Copy only. No behaviour, no lookup keys, no licence semantics, no
backend.

## Current state

Every surface that sells the paid self-hosted tier offers **"unlimited
seats"** for **"$99/server/mo"**, and the portal's free plan badges
**"Unlimited users"** and **"SSO included"** as free-tier facts.

## Problem

Both claims are now enforceably false.
[#7492](https://github.com/Stirling-Tools/Stirling-PDF/pull/7492) makes
the licence carry a real user cap, and
[Stirling-PDF-SaaS#325](https://github.com/Stirling-Tools/Stirling-PDF-SaaS/pull/325)
sells capacity in blocks of 100 users. An admin reading "unlimited
seats" and then hitting a 409 at the invite screen is the worst version
of this.

The demo has already dropped both claims; ours were the last ones
standing.

## Solution

| Surface | Was | Now |
|---|---|---|
| Onboarding licence slide | "Stirling Server plan, **unlimited seats**
… $99/server/mo" | "Stirling Team plan, **100 users** … $99/mo" |
| Plan comparison table | `unlimitedUsers` = "Unlimited users" |
`usersIncluded` = "100 users included" |
| Plan card highlights | "Unlimited users" | "100 users included" |
| Static plan section | `name: "Server"`, `maxUsers: "Unlimited users"`
| `plan.team.name`, `plan.team.maxUsers` |
| Upgrade banner | "Upgrade to Server Plan" / "unlimited users" |
"Upgrade to the Team plan" / "100 users, SSO" |
| Portal free plan | "Editor" + "SSO included" + "Unlimited users" |
"Editor" + "Every PDF tool" + "Web, desktop & self-hosted" |

The i18n keys are **renamed** (`unlimitedUsers` to `usersIncluded`)
rather than just revalued, so the key name cannot outlive the claim.

Also drops "per server" from `plan.licenseWarning` — we price a block of
100 users and count the provisioned roster, never nodes. And deletes the
orphaned `[settings.planBilling.tier]` block: zero source references,
and it described a retired model (50 credits/mo free, 500 included plus
overage billing).

## Deliberately unchanged

**"Processor" stays the name of the product surface.** The demo names
each plan for its price tier (Editor = $0, Team = $99/mo, Credits = 1¢
each) while keeping Processor as the surface a plan unlocks. Renaming
the surface here would conflate the two, so the plan-name split is left
for the explicit plan catalogue. The free plan also gains no "500 free
credits monthly" badge yet: that is true in the demo but not in our
backend, which still grants a one-time lifetime pool.

## How to test

Self-hosted, as an admin over the free user limit: Settings → Plan
should offer the Team plan at "100 users included", and the onboarding
licence slide should no longer promise unlimited seats. On the portal
billing page, the free plan should read "Free" with no SSO or
unlimited-users badge.

Green locally: 4/4 i18n audits (missing, unused, structure,
translation), 876 tests across 110 files, oxlint, prettier, and all four
typecheck variants (core, proprietary, saas, portal).
This commit is contained in:
ConnorYoh
2026-09-01 19:09:57 +00:00
committed by GitHub
parent f6661a8f87
commit d30faf246b
9 changed files with 53 additions and 55 deletions
@@ -5756,10 +5756,10 @@ rolePlaceholder = "Confirm your role"
roleUser = "User"
[onboarding.serverLicense]
freeBody = "Our <strong>Open-Core</strong> licensing permits up to <strong>{{freeTierLimit}}</strong> users for free per server. To scale uninterrupted, we recommend the Stirling Server plan - <strong>unlimited seats</strong> and <strong>SSO support</strong> for $99/server/mo."
freeTitle = "Server License"
overLimitBody = "Our licensing permits up to <strong>{{freeTierLimit}}</strong> users for free per server. You have <strong>{{overLimitUserCopy}}</strong> Stirling users. To continue uninterrupted, upgrade to the Stirling Server plan - <strong>unlimited seats</strong>, PDF text editing, and full admin control for $99/server/mo."
overLimitTitle = "Server License Needed"
freeBody = "Our <strong>Open-Core</strong> licensing permits up to <strong>{{freeTierLimit}}</strong> users for free. To scale uninterrupted, we recommend the Stirling Team plan - <strong>100 users</strong> and <strong>SSO support</strong> for $99/mo."
freeTitle = "Team plan"
overLimitBody = "Our licensing permits up to <strong>{{freeTierLimit}}</strong> users for free. You have <strong>{{overLimitUserCopy}}</strong> Stirling users. To continue uninterrupted, upgrade to the Stirling Team plan - <strong>100 users</strong>, PDF text editing, and full admin control for $99/mo."
overLimitTitle = "Team plan needed"
seePlans = "See Plans →"
upgrade = "Upgrade now →"
@@ -6570,7 +6570,7 @@ popular = "Popular"
selectPlan = "Select Plan"
showComparison = "Compare All Features"
upgrade = "Upgrade"
withServer = "+ Server Plan"
withServer = "+ Team plan"
[plan.api]
large = "5,000 Credits"
@@ -6588,8 +6588,8 @@ highlight1 = "Custom pricing"
highlight2 = "Dedicated support"
highlight3 = "Latest features"
name = "Enterprise"
requiresServer = "Requires Server"
requiresServerMessage = "Please upgrade to the Server plan first before upgrading to Enterprise."
requiresServer = "Requires Team plan"
requiresServerMessage = "Please upgrade to the Team plan first before upgrading to Enterprise."
[plan.feature]
api = "API Access"
@@ -6614,10 +6614,10 @@ saml = "SAML"
secureLoginSupport = "Secure Login Support"
selfHostedDeployment = "Self-hosted deployment"
sso = "SSO"
unlimitedUsers = "Unlimited users"
upToFiveUsers = "Up to 5 users"
upToFiveUsersLowercase = "up to 5 users"
usageTracking = "Usage tracking"
usersIncluded = "100 users included"
usersLimitedToSeats = "Users limited to seats"
[plan.free]
@@ -6646,12 +6646,12 @@ saveWithAnnualBilling = "Save with annual billing"
selfHosted = "Self-hosted"
selfHostedOnInfrastructure = "Self-hosted on your infrastructure"
ssoOAuth = "SSO (OAuth2/OIDC)"
unlimitedUsers = "Unlimited users"
upToFiveUsers = "Up to 5 users"
usageTrackingPrometheus = "Usage tracking & Prometheus"
usersIncluded = "100 users included"
[plan.licenseWarning]
body = "You have {{total}} users but the free tier only supports {{limit}} per server. Upgrade to keep Stirling PDF running smoothly."
body = "You have {{total}} users but the free tier only supports {{limit}}. Upgrade to keep Stirling PDF running smoothly."
cta = "See plans"
overLimit = "more than {{limit}}"
title = "Free self-hosted limit reached"
@@ -6674,7 +6674,7 @@ title = "You're on a Roll!"
[plan.static]
activateLicense = "Activate Your License"
contactToUpgrade = "Contact us to upgrade or customize your plan"
getLicense = "Get Server License"
getLicense = "Get the Team plan"
monthlyBilling = "Monthly Billing"
selectPeriod = "Select Billing Period"
upgradeToEnterprise = "Upgrade to Enterprise"
@@ -6695,6 +6695,10 @@ keyDescription = "Paste the license key from your email"
success = "License Activated!"
successMessage = "Your license has been successfully activated. You can now close this window."
[plan.team]
maxUsers = "100 users"
name = "Team"
[policies.activity]
outputsUnavailable = "Policy outputs are no longer available to download."
partialOutputsUnavailable = "Some policy outputs are no longer available to download."
@@ -7000,16 +7004,16 @@ subtitle = "Deploy anywhere, for your whole team."
title = "Free PDF Editors"
[portal.billing.freePlan]
anywhere = "Web, desktop & self-hosted"
checkoutErrorTitle = "Couldn't start checkout"
currentPlan = "Current plan"
everyPdfTool = "Every PDF tool"
freeForever = "Free forever"
noTeamResolved = "No team is resolved on your wallet yet — refresh and try again."
ownerOnly = "Only the team owner can switch on the Processor plan."
payInvoice = "Pay invoice to complete"
planName = "Editor"
ssoIncluded = "SSO included"
switchOnProcessor = "Switch on the Processor →"
unlimitedUsers = "Unlimited users"
viewQuote = "View quote"
[portal.billing.invoices]
@@ -10444,18 +10448,6 @@ memberCount_one = "{{count}} team member"
memberCount_other = "{{count}} team members"
memberCount_zero = "no team members"
[settings.planBilling.tier]
enterprise = "Enterprise"
enterpriseDescription = "Custom enterprise features and support"
free = "Free"
freeDescription = "50 credits per month"
team = "Team"
teamBadge = "Team"
teamDescription = "500 credits/month included, automatic overage billing for uninterrupted service"
teamTooltipCredits = "Team plan includes {{credits}} credits/month."
teamTooltipFineprint = "Only pay for what you use beyond included credits."
teamTooltipOverage = "Automatic overage billing at {{price}}/credit ensures uninterrupted service."
[settings.planBilling.trial]
daysRemaining = "{{days}} days remaining"
daysRemainingFull = "Your trial ends in {{days}} days"
@@ -11414,9 +11406,9 @@ urgent = "Urgent"
attentionBody = "Your admin needs to sign in to see more info. Please contact them immediately."
attentionBodyAdmin = "Review the license requirements to keep this server compliant."
attentionTitle = "This server needs admin attention"
message = "Get the most out of Stirling PDF with unlimited users and advanced features"
message = "Get the most out of Stirling PDF with 100 users, SSO, and advanced features"
seeInfo = "See info"
title = "Upgrade to Server Plan"
title = "Upgrade to the Team plan"
upgradeButton = "Upgrade Now"
[URLToPDF]
@@ -20,8 +20,8 @@ export default function ServerLicenseSlide({
totalUsers != null ? totalUsers.toLocaleString() : null;
const overLimitUserCopy = formattedTotalUsers ?? `more than ${freeTierLimit}`;
const title = isOverLimit
? i18n.t("onboarding.serverLicense.overLimitTitle", "Server License Needed")
: i18n.t("onboarding.serverLicense.freeTitle", "Server License");
? i18n.t("onboarding.serverLicense.overLimitTitle", "Team plan needed")
: i18n.t("onboarding.serverLicense.freeTitle", "Team plan");
const key = isOverLimit ? "server-license-over-limit" : "server-license";
const overLimitBody = (
@@ -31,7 +31,7 @@ export default function ServerLicenseSlide({
components={{
strong: <strong />,
}}
defaults="Our licensing permits up to <strong>{{freeTierLimit}}</strong> users for free per server. You have <strong>{{overLimitUserCopy}}</strong> Stirling users. To continue uninterrupted, upgrade to the Stirling Server plan - <strong>unlimited seats</strong>, PDF text editing, and full admin control for $99/server/mo."
defaults="Our licensing permits up to <strong>{{freeTierLimit}}</strong> users for free. You have <strong>{{overLimitUserCopy}}</strong> Stirling users. To continue uninterrupted, upgrade to the Stirling Team plan - <strong>100 users</strong>, PDF text editing, and full admin control for $99/mo."
/>
);
@@ -42,7 +42,7 @@ export default function ServerLicenseSlide({
components={{
strong: <strong />,
}}
defaults="Our <strong>Open-Core</strong> licensing permits up to <strong>{{freeTierLimit}}</strong> users for free per server. To scale uninterrupted, we recommend the Stirling Server plan - <strong>unlimited seats</strong> and <strong>SSO support</strong> for $99/server/mo."
defaults="Our <strong>Open-Core</strong> licensing permits up to <strong>{{freeTierLimit}}</strong> users for free. To scale uninterrupted, we recommend the Stirling Team plan - <strong>100 users</strong> and <strong>SSO support</strong> for $99/mo."
/>
);
@@ -70,7 +70,7 @@ export const CertificateSelector: React.FC<CertificateSelectorProps> = ({
return (
<Stack gap="md">
{/* Managed certificate options — server plan only */}
{/* Managed certificate options — Team plan only */}
{isServerPlan && (
<Radio.Group
value={certType}
@@ -114,10 +114,13 @@ export function FreePlanView({ wallet, unsynced, onSubscribed }: Props) {
{t("portal.billing.freePlan.freeForever", "Free forever")}
</StatusBadge>
<StatusBadge tone="info" size="sm" showDot={false}>
{t("portal.billing.freePlan.ssoIncluded", "SSO included")}
{t("portal.billing.freePlan.everyPdfTool", "Every PDF tool")}
</StatusBadge>
<StatusBadge tone="purple" size="sm" showDot={false}>
{t("portal.billing.freePlan.unlimitedUsers", "Unlimited users")}
{t(
"portal.billing.freePlan.anywhere",
"Web, desktop & self-hosted",
)}
</StatusBadge>
</div>
</div>
@@ -330,10 +330,10 @@ const UpgradeBanner: React.FC = () => {
{friendlyVisible && (
<AppBanner
icon="stars-rounded"
title={t("upgradeBanner.title", "Upgrade to Server Plan")}
title={t("upgradeBanner.title", "Upgrade to the Team plan")}
message={t(
"upgradeBanner.message",
"Get the most out of Stirling PDF with unlimited users and advanced features.",
"Get the most out of Stirling PDF with 100 users, SSO, and advanced features.",
)}
buttonText={t("upgradeBanner.upgradeButton", "Upgrade Now")}
buttonIcon="upgrade-rounded"
@@ -115,10 +115,10 @@ const AdminPlanSection: React.FC = () => {
if (currentTier === "free" && planGroup.tier === "enterprise") {
alert({
alertType: "warning",
title: t("plan.enterprise.requiresServer", "Server Plan Required"),
title: t("plan.enterprise.requiresServer", "Requires Team plan"),
body: t(
"plan.enterprise.requiresServerMessage",
"Please upgrade to the Server plan first before upgrading to Enterprise.",
"Please upgrade to the Team plan first before upgrading to Enterprise.",
),
});
return;
@@ -144,7 +144,7 @@ const PlanCard: React.FC<PlanCardProps> = ({
</Text>
<Text size="sm" c="dimmed" mt="xs">
{t("plan.perMonth", "/month")}{" "}
{t("plan.withServer", "+ Server Plan")}
{t("plan.withServer", "+ Team plan")}
</Text>
</>
) : (
@@ -185,7 +185,7 @@ const PlanCard: React.FC<PlanCardProps> = ({
{/* Single Upgrade Button */}
<Tooltip
label={t("plan.enterprise.requiresServer", "Requires Server plan")}
label={t("plan.enterprise.requiresServer", "Requires Team plan")}
disabled={!isEnterpriseBlockedForFree}
position="top"
withArrow
@@ -48,10 +48,10 @@ const StaticPlanSection: React.FC<StaticPlanSectionProps> = ({
if (currentTier === "free" && plan === "enterprise") {
alert({
alertType: "warning",
title: t("plan.enterprise.requiresServer", "Server Plan Required"),
title: t("plan.enterprise.requiresServer", "Requires Team plan"),
body: t(
"plan.enterprise.requiresServerMessage",
"Please upgrade to the Server plan first before upgrading to Enterprise.",
"Please upgrade to the Team plan first before upgrading to Enterprise.",
),
});
return;
@@ -92,14 +92,14 @@ const StaticPlanSection: React.FC<StaticPlanSectionProps> = ({
},
{
id: "server",
name: "Server",
name: t("plan.team.name", "Team"),
price: 0,
currency: "",
period: "",
popular: false,
highlights: planHighlights.SERVER_MONTHLY,
features: planFeatures.SERVER,
maxUsers: "Unlimited users",
maxUsers: t("plan.team.maxUsers", "100 users"),
},
{
id: "enterprise",
@@ -227,7 +227,7 @@ const StaticPlanSection: React.FC<StaticPlanSectionProps> = ({
);
}
// Server Plan
// Team plan
if (plan.id === "server") {
if (currentTier === "free") {
return (
@@ -274,7 +274,7 @@ const StaticPlanSection: React.FC<StaticPlanSectionProps> = ({
<Tooltip
label={t(
"plan.enterprise.requiresServer",
"Requires Server plan",
"Requires Team plan",
)}
position="top"
withArrow
@@ -50,7 +50,10 @@ export const usePlanFeatures = (): PlanFeaturesMap => {
"up to 5 users",
);
const upToFiveUsers = t("plan.features.upToFiveUsers", "Up to 5 users");
const unlimitedUsers = t("plan.features.unlimitedUsers", "Unlimited users");
const usersIncluded = t(
"plan.features.usersIncluded",
"100 users included",
);
const googleDriveIntegration = t(
"plan.features.googleDriveIntegration",
"Google drive integration",
@@ -88,7 +91,7 @@ export const usePlanFeatures = (): PlanFeaturesMap => {
{ name: communitySupport, included: true },
{ name: regularUpdates, included: true },
{ name: upToFiveUsersLowercase, included: true },
{ name: unlimitedUsers, included: false },
{ name: usersIncluded, included: false },
{ name: googleDriveIntegration, included: false },
{ name: externalDatabase, included: false },
{ name: editingTextInPdfs, included: false },
@@ -107,7 +110,7 @@ export const usePlanFeatures = (): PlanFeaturesMap => {
{ name: communitySupport, included: true },
{ name: regularUpdates, included: true },
{ name: upToFiveUsers, included: false },
{ name: unlimitedUsers, included: true },
{ name: usersIncluded, included: true },
{ name: googleDriveIntegration, included: true },
{ name: externalDatabase, included: true },
{ name: editingTextInPdfs, included: true },
@@ -126,7 +129,7 @@ export const usePlanFeatures = (): PlanFeaturesMap => {
{ name: communitySupport, included: true },
{ name: regularUpdates, included: true },
{ name: upToFiveUsersLowercase, included: false },
{ name: unlimitedUsers, included: false },
{ name: usersIncluded, included: false },
{ name: googleDriveIntegration, included: true },
{ name: externalDatabase, included: true },
{ name: editingTextInPdfs, included: true },
@@ -150,9 +153,9 @@ export const usePlanHighlights = (): PlanHighlightsMap => {
"plan.highlights.selfHostedOnInfrastructure",
"Self-hosted on your infrastructure",
);
const unlimitedUsers = t(
"plan.highlights.unlimitedUsers",
"Unlimited users",
const usersIncluded = t(
"plan.highlights.usersIncluded",
"100 users included",
);
const advancedIntegrations = t(
"plan.highlights.advancedIntegrations",
@@ -188,7 +191,7 @@ export const usePlanHighlights = (): PlanHighlightsMap => {
],
SERVER_MONTHLY: [
selfHostedOnInfrastructure,
unlimitedUsers,
usersIncluded,
advancedIntegrations,
ssoOAuth,
editingTextInPdfsCaps,
@@ -196,7 +199,7 @@ export const usePlanHighlights = (): PlanHighlightsMap => {
],
SERVER_YEARLY: [
selfHostedOnInfrastructure,
unlimitedUsers,
usersIncluded,
advancedIntegrations,
ssoOAuth,
editingTextInPdfsCaps,