fix(cloud): clarify plan comparison and raise new service limits

This commit is contained in:
Hydra
2026-09-30 11:55:52 +03:00
parent c28d0c75f4
commit 53a3c8d7ce
38 changed files with 397 additions and 94 deletions
@@ -1,5 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import { PRICING } from "@repo/core";
import { PRICING, planServiceResources } from "@repo/core";
import { BillingPlansSchema } from "@repo/contracts";
import { Value } from "@sinclair/typebox/value";
vi.mock("@repo/platform/engine/lib/oblien-client", () => ({ getOblienClient() { throw new Error("Capacity must be provider-enforced, not computed from owner resources"); } }));
import { cloudPlan, presentCloudPlans, subscriptionMetadata, subscriptionOffer, subscriptionPlan, topupOffer } from "@repo/platform/engine/modules/billing/billing-catalog";
import type { OblienSubscription } from "@repo/platform/engine/lib/oblien-billing-api";
@@ -16,7 +18,12 @@ describe("funded Cloud offers and isolated capacity", () => {
expect(Object.values(offer.resourceLimits!).every(value => Number.isInteger(value) && value! > 0)).toBe(true);
expect(offer.policy).toMatchObject({ overdraft: 0, suspendThreshold: 0 });
expect(presentCloudPlans().plans.find(plan => plan.id === tier)?.resourceLimits).toEqual(offer.resourceLimits);
expect(offer.reference).toBe(`openship:${tier}:v4`);
const saved = subscriptionPlan({ ...savedPro(), offer, metadata: subscriptionMetadata(tier, "org-a", "ns-a") });
const published = presentCloudPlans().plans.find(plan => plan.id === tier)!;
expect(planServiceResources(saved.limits)).toEqual(planServiceResources(published.limits));
}
expect(Value.Check(BillingPlansSchema, presentCloudPlans())).toBe(true);
});
it("a top-up only adds metered credits and cannot raise hardware limits or grace", () => {
for (const pack of PRICING.creditPacks) {
@@ -24,6 +31,7 @@ describe("funded Cloud offers and isolated capacity", () => {
expect(offer.credits * 1000).toBe(pack.creditsMilli);
expect(offer.credits).toBeLessThanOrEqual(offer.unitAmount);
expect(offer.resourceLimits).toBeUndefined(); expect(offer.policy).toBeUndefined();
expect(offer.reference).toBe(`openship:${pack.id}:v3`);
}
});
it("bounds legacy inherited capacity while preserving the customer's paid credits and price", async () => {
@@ -51,6 +59,54 @@ describe("funded Cloud offers and isolated capacity", () => {
expect(subscription).toEqual(before);
} finally { Object.assign(raw, old); }
});
it("keeps v3 paid service limits, price, credits and capacity unchanged after new offers are published", async () => {
const subscription: NonNullable<OblienSubscription> = {
...savedPro(),
offer: { reference: "openship:pro:v3", name: "Openship Pro", currency: "usd", unitAmount: 4000, credits: 3500,
policy: { overdraft: 0, suspendThreshold: 0, onOverdraftAction: "stop_workspaces" },
resourceLimits: { max_workspaces: 6, max_vcpus: 2, max_ram_mb: 8192, max_disk_gb: 32,
max_total_vcpus: 4, max_total_ram_mb: 8192, max_total_disk_gb: 128 } },
metadata: { openship_plan: "pro", openship_offer_version: "3", openship_organization: "org-a", openship_namespace: "ns-a",
openship_limits: JSON.stringify({ workloads: ["static", "web", "worker"], services: true, runningServices: 10,
maxProjects: 50, maxResourceTier: "high", computeMinutesPerMonth: null, buildMinutesPerMonth: null,
freeSubdomains: 100, customDomains: null, seats: null }) },
};
const before = structuredClone(subscription);
const saved = subscriptionPlan(subscription, "org-a", "ns-a");
expect(saved.limits).toEqual(JSON.parse(subscription.metadata!.openship_limits!));
expect(saved.resourceLimits).toEqual(subscription.offer!.resourceLimits);
expect(planServiceResources(saved.limits)).toEqual({ cpuCores: 2, memoryMb: 2048 });
expect(planServiceResources(presentCloudPlans().plans.find(plan => plan.id === "pro")!.limits))
.toEqual({ cpuCores: 2, memoryMb: 4096 });
const displayed = await cloudPlan("pro", subscription);
expect(displayed).toMatchObject({ price: { monthly: 4000 }, monthlyCredits: 3_500_000,
limits: saved.limits, resourceLimits: subscription.offer!.resourceLimits });
expect(subscription).toEqual(before);
});
it("retains Hobby v3 and pre-reseller Starter presets without applying the new RAM allowance", () => {
const subscription = savedPro();
subscription.offer = { ...subscriptionOffer("hobby", "monthly"), reference: "openship:hobby:v3" };
subscription.metadata = { ...subscriptionMetadata("hobby", "org-a", "ns-a"), openship_offer_version: "3" };
const limits = JSON.parse(subscription.metadata.openship_limits!);
delete limits.maxServiceResources;
subscription.metadata.openship_limits = JSON.stringify(limits);
expect(planServiceResources(subscriptionPlan(subscription).limits)).toEqual({ cpuCores: 0.5, memoryMb: 512 });
expect(planServiceResources(subscriptionPlan({ ...subscription, tierId: "hobby" }).limits))
.toEqual({ cpuCores: 1, memoryMb: 1024 });
});
it.each([null, undefined, { cpuCores: 0, memoryMb: 4096 }])("rejects an unverifiable v4 service ceiling: %j", maxServiceResources => {
const subscription = savedPro();
subscription.metadata!.openship_limits = JSON.stringify({ ...JSON.parse(subscription.metadata!.openship_limits!), maxServiceResources });
expect(() => subscriptionPlan(subscription)).toThrow(/could not be verified/);
});
it("rejects unknown versions and mismatched offer metadata", () => {
const subscription = savedPro();
subscription.metadata!.openship_offer_version = "3";
expect(() => subscriptionPlan(subscription)).toThrow(/could not be verified/);
subscription.metadata!.openship_offer_version = "5";
subscription.offer!.reference = "openship:pro:v5";
expect(() => subscriptionPlan(subscription)).toThrow(/could not be verified/);
});
it.each([undefined, null])("rejects an incomplete or unbounded new retail capacity contract (%s)", value => {
const subscription = savedPro(); subscription.offer!.resourceLimits!.max_total_vcpus = value;
expect(() => subscriptionPlan(subscription, "org-a", "ns-a")).toThrow(/could not be verified/);
@@ -92,7 +92,7 @@ describe("Cloud customer checkout", () => {
kind: "subscription",
billingInterval: "monthly",
offer: {
reference: "openship:starter:v3",
reference: "openship:starter:v4",
unitAmount: 2000,
credits: 1700,
policy: { overdraft: 0, suspendThreshold: 0, onOverdraftAction: "stop_workspaces" },
@@ -118,7 +118,7 @@ describe("Cloud customer checkout", () => {
expect(h.checkout).toHaveBeenCalledWith(expect.objectContaining({
namespace: "ns-org-a",
offer: expect.objectContaining({
reference: "openship:hobby:v3", unitAmount: 500, credits: 400,
reference: "openship:hobby:v4", unitAmount: 500, credits: 400,
resourceLimits: { max_workspaces: 1, max_vcpus: 1, max_ram_mb: 4096, max_disk_gb: 16,
max_total_vcpus: 1, max_total_ram_mb: 4096, max_total_disk_gb: 16 },
}),
@@ -217,7 +217,7 @@ describe("Cloud customer checkout", () => {
expect(h.checkout).toHaveBeenCalledWith(
expect.objectContaining({
namespace: "ns-org-a",
offer: expect.objectContaining({ reference: "openship:team:v3", unitAmount: 9900 }),
offer: expect.objectContaining({ reference: "openship:team:v4", unitAmount: 9900 }),
billingInterval: "monthly",
}),
);
@@ -7,7 +7,8 @@ vi.mock("@repo/db", () => ({ repos: {
service: { countRunningForOrg: h.count }, deployment: { sumBuildMillisForOrg: h.usage },
} }));
vi.mock("@repo/platform/engine/modules/billing/billing-oblien-quota", () => ({ syncOblienEntitlement: h.sync }));
import { assertCloudDeploymentLimits, assertRunningServiceQuota, assertBuildMinutesAvailable } from "@repo/platform/engine/lib/plan-guard";
import { assertCloudDeploymentLimits, assertRunningServiceQuota, assertBuildMinutesAvailable,
assertPlanAllowsResourceTier, assertCloudRuntimeLimits } from "@repo/platform/engine/lib/plan-guard";
import { resolveCloudServiceResources } from "@repo/platform/engine/lib/resources";
beforeEach(() => {
vi.resetAllMocks(); h.cloud = true; h.tier = "starter";
@@ -22,6 +23,52 @@ beforeEach(() => {
const base = { cpuCores: 1, memoryMb: 1024, diskMb: 8192 };
const services = () => [{ enabled: true }, { enabled: true }, { enabled: true }];
describe("Cloud deploy and update resource gates", () => {
it.each([
["hobby", 0.5, 2048], ["starter", 1, 3072], ["pro", 2, 4096], ["team", 4, 8192],
] as const)("applies the new %s ceiling to resource edits, deployments and existing runtimes", async (tier, cpuCores, memoryMb) => {
h.tier = tier;
const resources = { cpuCores, memoryMb, diskMb: 8192 };
const runtime = { supports: () => false, getContainerInfo: vi.fn().mockResolvedValue({ status: "stopped", resources }) };
await expect(assertPlanAllowsResourceTier("org-a", { tier: "custom", ...resources })).resolves.toBeUndefined();
await expect(assertCloudDeploymentLimits("org-a", { runsApplication: true, resources })).resolves.toBeUndefined();
await expect(assertCloudDeploymentLimits("org-a", { dockerWorkspace: true,
services: [{ image: "redis:8", advanced: { resources } }] })).resolves.toBeUndefined();
await expect(assertCloudRuntimeLimits("org-a", runtime, [{ containerId: "saved-container" }])).resolves.toBeUndefined();
for (const tooLarge of [{ ...resources, cpuCores: cpuCores + 0.25 }, { ...resources, memoryMb: memoryMb + 1 }]) {
await expect(assertPlanAllowsResourceTier("org-a", { tier: "custom", ...tooLarge })).rejects.toMatchObject({ reason: "resource-tier" });
await expect(assertCloudDeploymentLimits("org-a", { services: [{ image: "redis:8", advanced: { resources: tooLarge } }] }))
.rejects.toMatchObject({ reason: "resource-tier" });
runtime.getContainerInfo.mockResolvedValue({ status: "stopped", resources: tooLarge });
await expect(assertCloudRuntimeLimits("org-a", runtime, [{ containerId: "saved-container" }])).rejects.toMatchObject({ reason: "resource-tier" });
}
});
it("compares named presets on their CPU and RAM, not a separate preset rank", async () => {
await expect(assertPlanAllowsResourceTier("org-a", { tier: "medium" })).resolves.toBeUndefined();
await expect(assertPlanAllowsResourceTier("org-a", { tier: "high" })).rejects.toMatchObject({ reason: "resource-tier" });
h.sync.mockResolvedValue({ tier: "starter", limits: { ...planLimits("starter"), maxResourceTier: "low" },
resourceLimits: resolvePlan("starter").oblienLimits });
await expect(assertPlanAllowsResourceTier("org-a", { tier: "medium" })).resolves.toBeUndefined();
});
it.each([
{ tier: "unknown" }, { tier: "__proto__" }, { tier: "unlimited" }, {},
{ cpuCores: 0, memoryMb: 512 }, { cpuCores: 1, memoryMb: 0 },
{ cpuCores: -1, memoryMb: 512 }, { cpuCores: NaN, memoryMb: 512 },
{ cpuCores: Infinity, memoryMb: 512 }, { cpuCores: 1, memoryMb: Infinity },
])("rejects unknown, unbounded or invalid service sizes: %j", async requested => {
await expect(assertPlanAllowsResourceTier("org-a", requested)).rejects.toMatchObject({ reason: "resource-tier" });
});
it("keeps the preset ceiling from an older subscription through edits, deployment and restart", async () => {
h.sync.mockResolvedValue({ tier: "starter", limits: { ...planLimits("starter"), maxServiceResources: undefined },
resourceLimits: resolvePlan("starter").oblienLimits });
const resources = { cpuCores: 1, memoryMb: 3072, diskMb: 8192 };
await expect(assertPlanAllowsResourceTier("org-a", { tier: "medium" })).resolves.toBeUndefined();
await expect(assertPlanAllowsResourceTier("org-a", { tier: "custom", ...resources }))
.rejects.toThrow("1 vCPU and 1 GB RAM per service");
await expect(assertCloudDeploymentLimits("org-a", { runsApplication: true, resources })).rejects.toMatchObject({ reason: "resource-tier" });
await expect(assertCloudRuntimeLimits("org-a", {
supports: () => false, getContainerInfo: vi.fn().mockResolvedValue({ status: "stopped", resources }),
}, [{ containerId: "saved-container" }])).rejects.toMatchObject({ reason: "resource-tier" });
});
it("includes the combined Docker workspace allocation, even when each service fits", async () => {
h.tier = "team";
const stack = Array.from({ length: 5 }, (_, index) => ({
@@ -185,6 +232,7 @@ describe("Cloud deploy and update resource gates", () => {
});
it("does not apply Cloud quotas to self-hosted workloads", async () => {
h.cloud = false;
await assertPlanAllowsResourceTier("org-a", { tier: "unlimited" });
await assertCloudDeploymentLimits("org-a", { resources: { ...base, cpuCores: 128 }, services: services() });
expect(h.count).not.toHaveBeenCalled(); expect(h.sync).not.toHaveBeenCalled();
});
@@ -71,7 +71,7 @@ import { handleApiError } from "../../../src/middleware/error-handler";
import * as repository from "@repo/platform/engine/modules/billing/billing.repository";
import { flushAudit } from "@repo/platform/engine/lib/audit-emitter";
import { eq } from "@repo/db";
import { presentCloudPlans } from "@repo/platform/engine/modules/billing/billing-catalog";
import { presentCloudPlans, subscriptionMetadata, subscriptionOffer } from "@repo/platform/engine/modules/billing/billing-catalog";
const app = new Hono().onError(handleApiError)
.use("*", async (c, next) => { c.set("clientIp", "192.0.2.64"); await next(); })
@@ -341,6 +341,37 @@ describe("billing through the same SDK and HTTP application operations", () => {
expect(provider.quota).not.toHaveBeenCalled();
});
it.each([["3", 1024, "medium"], ["4", 3072, "custom"]] as const)(
"shows the purchased v%s service ceiling through both SDK and HTTP billing state", async (version, memoryMb, machineTier) => {
const owner = await seedOwner(), c = await clients(owner);
await c.native.getState();
const namespace = (await repos.organization.findById(owner.orgId))!.oblienNamespace!;
const metadata: Record<string, string> = { ...subscriptionMetadata("starter", owner.orgId, namespace), openship_offer_version: version };
if (version === "3") {
const limits = JSON.parse(metadata.openship_limits!);
delete limits.maxServiceResources;
metadata.openship_limits = JSON.stringify(limits);
}
const saved: NonNullable<OblienSubscription> = {
tierId: "reseller", status: "active", billingInterval: "monthly", cancelAtPeriodEnd: false, canceledAt: null,
periodStart: "2026-09-01T00:00:00Z", periodEnd: "2026-10-01T00:00:00Z",
offer: { ...subscriptionOffer("starter", "monthly"), reference: `openship:starter:v${version}` }, metadata,
};
const before = structuredClone(saved);
provider.subscriptions.set(namespace, saved);
provider.resourceUpdate.mockClear();
for (const client of [c.native, c.remote]) {
const state = await client.getState();
expect(state.maxServiceMachine).toEqual({ tier: machineTier, cpuCores: 1, memoryMb });
expect(state.plan?.limits).toEqual(JSON.parse(metadata.openship_limits!));
expect(state.plan?.price.monthly).toBe(saved.offer!.unitAmount);
expect(state.plan?.monthlyCredits).toBe(saved.offer!.credits * 1000);
}
expect(provider.subscriptions.get(namespace)).toEqual(before);
expect(provider.resourceUpdate).not.toHaveBeenCalled();
},
);
it("never presents an unsubscribed namespace with a missing policy as unlimited", async () => {
provider.entitlement.mockImplementation(async namespace => ({
success: true, namespace, tierId: null, status: "active", periodStart: null, periodEnd: null,
@@ -482,9 +513,9 @@ describe("billing through the same SDK and HTTP application operations", () => {
expect(input).toMatchObject({
namespace: org!.oblienNamespace,
kind: "subscription",
offer: { reference: `openship:${tier}:v3`, unitAmount, credits },
offer: { reference: `openship:${tier}:v4`, unitAmount, credits },
billingInterval: "monthly",
metadata: { openship_organization: owner.orgId, openship_namespace: org!.oblienNamespace, openship_offer_version: "3" },
metadata: { openship_organization: owner.orgId, openship_namespace: org!.oblienNamespace, openship_offer_version: "4" },
});
expect(input).not.toHaveProperty("customer");
expect(input).not.toHaveProperty("line_items");
@@ -174,7 +174,7 @@ describe("Cloud quotas at real application mutation boundaries", () => {
});
it("enforces the paid machine-size snapshot before queueing new compute", async () => {
h.tier = "team";
h.savedLimits = { ...planLimits("team"), maxResourceTier: "low" };
h.savedLimits = { ...planLimits("team"), maxResourceTier: "low", maxServiceResources: undefined };
await expect(queue(projectId)).rejects.toMatchObject({ reason: "resource-tier" });
expect(
await db.query.deployment.findMany({ where: eq(schema.deployment.projectId, projectId) }),
@@ -2,7 +2,7 @@
import { Icon as UiIcon } from "@repo/ui/icons";
import { PLANS, RESOURCE_TIER_SPECS, formatCpuCores, formatMemoryMb } from "@repo/core";
import { PLANS, planServiceResources, formatCpuCores, formatMemoryMb } from "@repo/core";
import { useI18n, interpolate } from "@/components/i18n-provider";
import type { BillingState } from "@/lib/api/billing";
import { formatBillingNumber, formatMilliCredits } from "@/lib/billing-usage";
@@ -20,8 +20,8 @@ export function BillingCapacity({ state }: { state: BillingState }) {
const cap = state.capacity;
const noPlan = state.tier === "free";
const number = (value: number) => formatBillingNumber(value, locale);
const spec = state.maxServiceMachine === undefined
? (limits.maxResourceTier ? RESOURCE_TIER_SPECS[limits.maxResourceTier] : null) : state.maxServiceMachine;
const serviceCeiling = planServiceResources(limits);
const spec = state.maxServiceMachine === undefined ? serviceCeiling : state.maxServiceMachine;
const poolHint = copy.poolHint;
const buildMeter = cap?.buildMinutes ?? { used: state.buildTimeMinutes, max: limits.buildMinutesPerMonth };
const rows = [
@@ -62,8 +62,8 @@ export function BillingCapacity({ state }: { state: BillingState }) {
<div className="mt-5 flex flex-wrap items-center justify-between gap-4 rounded-xl bg-muted/20 px-4 py-3.5">
<MetricLabel label={copy.machine} hint={copy.machineHint} />
<div className="flex flex-wrap items-center gap-x-5 gap-y-2 text-sm font-medium tabular-nums text-foreground">
<span className="inline-flex items-center gap-2"><UiIcon name="cpu" className="size-4 text-muted-foreground" aria-hidden="true" /><bdi>{spec ? formatCpuCores(spec.cpuCores) : limits.maxResourceTier === null ? copy.unlimited : "—"}</bdi></span>
<span className="inline-flex items-center gap-2"><UiIcon name="memory" className="size-4 text-muted-foreground" aria-hidden="true" /><bdi>{spec ? formatMemoryMb(spec.memoryMb) : limits.maxResourceTier === null ? copy.unlimited : "—"}</bdi></span>
<span className="inline-flex items-center gap-2"><UiIcon name="cpu" className="size-4 text-muted-foreground" aria-hidden="true" /><bdi>{spec ? formatCpuCores(spec.cpuCores) : serviceCeiling === null ? copy.unlimited : "—"}</bdi></span>
<span className="inline-flex items-center gap-2"><UiIcon name="memory" className="size-4 text-muted-foreground" aria-hidden="true" /><bdi>{spec ? formatMemoryMb(spec.memoryMb) : serviceCeiling === null ? copy.unlimited : "—"}</bdi></span>
</div>
</div>
{limits.buildMinutesPerMonth !== null && resetAt && Number.isFinite(resetAt.getTime()) && <p className="mt-3 text-xs text-muted-foreground">{interpolate(copy.reset, {
@@ -2,30 +2,34 @@
import { Icon as UiIcon } from "@repo/ui/icons";
import { RESOURCE_TIER_SPECS, formatCpuCores, formatMemoryMb } from "@repo/core";
import { planServiceResources, formatCpuCores, formatMemoryMb } from "@repo/core";
import { useI18n, interpolate } from "@/components/i18n-provider";
import { formatBillingNumber, formatMilliCredits } from "@/lib/billing-usage";
import type { ApiPlan } from "./PricingCards";
/** Lead with enforced project and resource allowances; keep metering details available. */
/** Show capacity, build usage and included credits together before checkout. */
export function PlanResources({ plan, interval = "monthly", compact = false }: { plan: ApiPlan; interval?: "monthly" | "annual"; compact?: boolean }) {
const { t, locale } = useI18n();
const copy = t.billing.resourcesGuide;
if (plan.id === "free") return <p className="text-xs leading-relaxed text-muted-foreground">{copy.setupHint}</p>;
const count = (n: number | null) => n === null ? copy.unlimited : formatBillingNumber(n, locale);
const spec = plan.limits.maxResourceTier ? RESOURCE_TIER_SPECS[plan.limits.maxResourceTier] : null;
const spec = planServiceResources(plan.limits);
const credits = interval === "annual" ? plan.annualCredits : plan.monthlyCredits;
const pool = plan.resourceLimits;
const hasPool = pool && [pool.max_total_vcpus, pool.max_total_ram_mb, pool.max_total_disk_gb]
.every(value => typeof value === "number" && Number.isFinite(value));
const facts = [
{ Icon: "folder-open" as const, label: copy.projects, value: count(plan.limits.maxProjects) },
{ Icon: "layers" as const, label: copy.apps, value: count(plan.limits.runningServices) },
...(plan.limits.buildMinutesPerMonth === null ? [] : [{ Icon: "clock" as const, label: copy.buildTime, value: interpolate(copy.buildMinutes, { amount: count(plan.limits.buildMinutesPerMonth) }) }]),
...(hasPool ? [{ Icon: "cloud" as const, label: copy.poolTitle, value: interpolate(copy.poolCapacity, {
cpu: count(pool.max_total_vcpus), ram: count(pool.max_total_ram_mb! / 1024), disk: count(pool.max_total_disk_gb),
}) }] : []),
{ Icon: "cpu" as const, label: copy.machine, value: spec ? `${formatCpuCores(spec.cpuCores)} · ${formatMemoryMb(spec.memoryMb)}` : copy.unlimited },
{ Icon: "clock" as const, label: copy.buildTime, value: plan.limits.buildMinutesPerMonth === null
? copy.buildUsageBased : interpolate(copy.buildMinutes, { amount: count(plan.limits.buildMinutesPerMonth) }) },
{ Icon: "bolt" as const, label: copy.credits, value: credits == null
? plan.id === "enterprise" ? t.billing.pricing.custom : "—"
: interpolate(copy.creditsPerCycle, { amount: formatMilliCredits(credits, locale) }) },
{ Icon: "folder-open" as const, label: copy.projects, value: count(plan.limits.maxProjects) },
{ Icon: "layers" as const, label: copy.apps, value: count(plan.limits.runningServices) },
...(plan.edge ? [{ Icon: "globe" as const, label: t.billing.resourceOverview.bandwidth, value: plan.edge.bandwidthGb === null ? copy.unlimited : interpolate(t.billing.resourceOverview.bandwidthPerMonth, { amount: count(plan.edge.bandwidthGb) }) }] : []),
];
if (compact) {
@@ -44,7 +48,7 @@ export function PlanResources({ plan, interval = "monthly", compact = false }: {
<div className="border-t border-border/40 py-4">
<dl className="space-y-4">
{facts.map(({ Icon, label, value }) => <div key={label} className="flex flex-wrap items-start justify-between gap-x-3 gap-y-1">
<dt className="flex items-center gap-2 text-xs text-muted-foreground"><UiIcon name={Icon} className="size-3.5 shrink-0" aria-hidden="true" />{label}</dt>
<dt className="flex items-center gap-2 text-sm text-muted-foreground"><UiIcon name={Icon} className="size-4 shrink-0" aria-hidden="true" />{label}</dt>
<dd className="ms-auto text-end text-sm font-medium tabular-nums text-foreground"><bdi>{value}</bdi></dd>
</div>)}
</dl>
@@ -55,9 +59,6 @@ export function PlanResources({ plan, interval = "monthly", compact = false }: {
</summary>
<p className="mt-2 text-xs leading-relaxed text-muted-foreground">{copy.usageSummary}</p>
<p className="mt-2 text-xs leading-relaxed text-muted-foreground">{copy.balanceRule}</p>
<p className="mt-3 border-t border-border/40 pt-2 text-xs tabular-nums text-muted-foreground">
{credits == null ? plan.id === "enterprise" ? t.billing.pricing.custom : "—" : interpolate(copy.creditsPerCycle, { amount: formatMilliCredits(credits, locale) })}
</p>
</details>
</div>
);
@@ -101,11 +101,11 @@ describe("Cloud billing before the first subscription", () => {
expect(card?.querySelector("[data-resource-value]")?.textContent).toMatch(/^0/);
expect(card?.textContent).toContain(copy.onboarding.planRequired);
}
expect(visibleText()).not.toMatch(/credits|0 of 3|500/i);
expect(visibleText()).not.toMatch(/0 of 3|500/i);
expect(container.textContent).not.toMatch(/Unlimited|No set limit|∞/);
expect(container.textContent).toContain("$15");
expect(container.textContent).toContain("1,234 credits / billing cycle");
expect(container.querySelector("dl")?.textContent).not.toMatch(/credits/i);
expect(visibleText()).toContain("1,234 credits / billing cycle");
expect(container.querySelector("dl")?.textContent).toContain(copy.resourcesGuide.buildUsageBased);
expect(button("Subscribe to Hobby").disabled).toBe(false);
expect(mocks.get).toHaveBeenCalledOnce();
expect(mocks.get.mock.calls[0]![0]).toContain("billing/plans");
@@ -334,6 +334,35 @@ describe("customer credit limits", () => {
expect(container.textContent).toContain("7 vCPU · 10 GB RAM · 192 GB disk");
expect(container.textContent).toContain(copy.resourcesGuide.poolHint);
expect(container.textContent).not.toMatch(/No set limit|3,000 min/);
expect(visibleText()).toContain(copy.resourcesGuide.buildTime);
expect(visibleText()).toContain(copy.resourcesGuide.buildUsageBased);
expect(visibleText()).toContain("1,234 credits / billing cycle");
expect(visibleText()).toContain("1 vCPU · 3 GB");
});
it("shows saved preset ceilings and fixed build allowances without using new catalog limits", async () => {
await render(<PlanResources plan={{ ...hobby, limits: { ...hobby.limits,
maxServiceResources: undefined, buildMinutesPerMonth: 3000 } }} />);
expect(visibleText()).toContain("1 vCPU · 1 GB");
expect(visibleText()).not.toContain("1 vCPU · 3 GB");
expect(visibleText()).toContain("3,000 min / month");
expect(visibleText()).not.toContain(copy.resourcesGuide.buildUsageBased);
});
it("shows annual credits from the paid offer and leaves missing credit amounts unknown", async () => {
await render(<PlanResources plan={hobby} interval="annual" />);
expect(visibleText()).toContain("14,555 credits / billing cycle");
expect(visibleText()).not.toContain("1,234 credits / billing cycle");
await render(<PlanResources plan={{ ...hobby, annualCredits: null }} interval="annual" />);
const credits = [...container.querySelectorAll("dt")].find(label => label.textContent === copy.resourcesGuide.credits);
expect(credits?.parentElement?.querySelector("dd")?.textContent).toBe("—");
});
it("uses the same explicit service ceiling in billing when no precomputed machine is supplied", async () => {
await render(<BillingCapacity state={{ ...paid, plan: { ...hobby,
limits: { ...hobby.limits, maxServiceResources: { cpuCores: 1, memoryMb: 3072 } } } }} />);
expect(visibleText()).toContain("3 GB");
await render(<BillingCapacity state={{ ...paid, plan: { ...hobby,
limits: { ...hobby.limits, maxServiceResources: undefined } } }} />);
expect(visibleText()).toContain("1 GB");
expect(visibleText()).not.toContain("3 GB");
});
it("does not turn an unknown paid balance into unlimited credits", async () => {
await render(<BillingCapacity state={{ ...paid, balance: { total: null, quotaLimit: null, quotaUsed: 300_000, quotaRemaining: null, unlimited: false } }} />);
@@ -24,7 +24,7 @@ export function useCloudDeployPricing() {
const id = showModal({
customContent: <CloudDeployPlanModal restriction={restriction} onClose={() => hideModal(id)} />,
width: "100%",
maxWidth: "1160px",
maxWidth: "1440px",
showCloseButton: false,
onClose: () => { openModal.current = null; },
});
@@ -330,6 +330,7 @@
"creditsHint": "يستهلك البناء وتشغيل التطبيقات رصيدًا مقابل المعالج والذاكرة ونشاط القرص ونقل البيانات. الأعمال الأكبر تستهلك الرصيد أسرع.",
"creditsPerCycle": "{amount} وحدة رصيد / دورة فوترة",
"buildTime": "وقت البناء",
"buildUsageBased": "يستهلك الرصيد المشترك",
"buildMinutes": "{amount} دقيقة / شهر",
"buildHint": "تستهلك عمليات البناء الرصيد المشترك بحسب استهلاكها للموارد. لا يُطبق حد زمني منفصل إلا إذا كانت خطتك المحفوظة تتضمنه.",
"apps": "حصة الخدمات",
@@ -330,6 +330,7 @@
"creditsHint": "Builds und laufende Apps verbrauchen Credits für CPU, Arbeitsspeicher, Festplattenzugriffe und Datentransfer. Größere Arbeitslasten verbrauchen Credits schneller.",
"creditsPerCycle": "{amount} Credits / Abrechnungszeitraum",
"buildTime": "Build-Zeit",
"buildUsageBased": "Nutzt gemeinsames Guthaben",
"buildMinutes": "{amount} Min. / Monat",
"buildHint": "Builds verbrauchen das gemeinsame Nutzungsguthaben entsprechend ihrem Ressourcenverbrauch. Ein separates Zeitlimit gilt nur, wenn es in deinem gespeicherten Tarif enthalten ist.",
"apps": "Dienstplätze",
@@ -330,6 +330,7 @@
"creditsHint": "Builds and running apps use credits for CPU, memory, disk activity and data transfer. Larger workloads use credits faster.",
"creditsPerCycle": "{amount} credits / billing cycle",
"buildTime": "Build time",
"buildUsageBased": "Uses shared credits",
"buildMinutes": "{amount} min / month",
"buildHint": "Builds use the shared usage allowance according to their resource consumption. A separate time limit applies only when included in your saved plan.",
"apps": "Service slots",
@@ -330,6 +330,7 @@
"creditsHint": "Las compilaciones y las aplicaciones activas consumen créditos por CPU, memoria, actividad de disco y transferencia de datos. Las cargas mayores consumen créditos más rápido.",
"creditsPerCycle": "{amount} créditos / ciclo de facturación",
"buildTime": "Tiempo de compilación",
"buildUsageBased": "Usa créditos compartidos",
"buildMinutes": "{amount} min / mes",
"buildHint": "Las compilaciones consumen la asignación compartida según los recursos que utilizan. Solo se aplica un límite de tiempo independiente si está incluido en tu plan guardado.",
"apps": "Cupos de servicios",
@@ -330,6 +330,7 @@
"creditsHint": "Les compilations et les applications actives consomment des crédits pour le CPU, la mémoire, les accès disque et les transferts de données. Les charges plus importantes les consomment plus vite.",
"creditsPerCycle": "{amount} crédits / cycle de facturation",
"buildTime": "Temps de compilation",
"buildUsageBased": "Utilise les crédits partagés",
"buildMinutes": "{amount} min / mois",
"buildHint": "Les builds consomment le quota partagé selon les ressources utilisées. Une limite de temps distincte ne s’applique que si elle est incluse dans votre offre enregistrée.",
"apps": "Places de services",
@@ -330,6 +330,7 @@
"creditsHint": "ビルドと実行中のアプリは、CPU、メモリ、ディスクアクセス、データ転送にクレジットを使用します。大きな処理ほど消費が速くなります。",
"creditsPerCycle": "請求サイクルあたり {amount} クレジット",
"buildTime": "ビルド時間",
"buildUsageBased": "共通クレジットを使用",
"buildMinutes": "月あたり {amount} 分",
"buildHint": "ビルドはリソースの消費量に応じて共有の利用枠を消費します。個別の時間制限は、保存済みプランに含まれている場合にのみ適用されます。",
"apps": "サービス枠",
@@ -330,6 +330,7 @@
"creditsHint": "Compilações e aplicativos ativos consomem créditos por CPU, memória, atividade de disco e transferência de dados. Cargas maiores consomem créditos mais rápido.",
"creditsPerCycle": "{amount} créditos / ciclo de cobrança",
"buildTime": "Tempo de compilação",
"buildUsageBased": "Usa créditos compartilhados",
"buildMinutes": "{amount} min / mês",
"buildHint": "As compilações consomem a franquia compartilhada conforme os recursos utilizados. Um limite de tempo separado só se aplica quando incluído no seu plano salvo.",
"apps": "Vagas de serviços",
@@ -330,6 +330,7 @@
"creditsHint": "Derlemeler ve çalışan uygulamalar CPU, bellek, disk etkinliği ve veri aktarımı için kredi tüketir. Daha büyük iş yükleri kredileri daha hızlı tüketir.",
"creditsPerCycle": "Fatura dönemi başına {amount} kredi",
"buildTime": "Derleme süresi",
"buildUsageBased": "Ortak kredileri kullanır",
"buildMinutes": "Ayda {amount} dk",
"buildHint": "Derlemeler, kaynak tüketimlerine göre ortak kullanım hakkını tüketir. Ayrı bir süre sınırı yalnızca kayıtlı planınızda yer alıyorsa uygulanır.",
"apps": "Hizmet kotası",
@@ -330,6 +330,7 @@
"creditsHint": "构建和运行中的应用会因 CPU、内存、磁盘读写和数据传输消耗积分。工作负载越大,积分消耗越快。",
"creditsPerCycle": "每个账单周期 {amount} 积分",
"buildTime": "构建时间",
"buildUsageBased": "使用共享额度",
"buildMinutes": "每月 {amount} 分钟",
"buildHint": "构建根据实际资源消耗使用共享额度。仅当已保存的套餐包含单独的构建时长限制时,才会应用该限制。",
"apps": "服务名额",
+17 -1
View File
@@ -34,7 +34,23 @@ including temporary build resources when source code must be built. Image-only t
it does not combine them into an arbitrarily large VM. Split a large stack
across projects or reduce its machine sizes if it exceeds that capacity.
The current monthly plans include 400 / 1,700 / 3,500 / 9,000 metered credits. Total namespace ceilings are 1 / 2 / 4 / 8 vCPU, 4 / 6 / 8 / 16 GB RAM, and 16 / 32 / 128 / 256 GB disk for Hobby / Starter / Pro / Team. Stopped workspaces, build hosts and pending resource changes count toward allocation. Per-VM limits apply as well; service counts do not promise every service can run at its maximum size simultaneously.
New monthly subscriptions include:
| Plan | Price | Usage credits | Shared capacity | Per service | Projects | Service slots |
| --- | --- | --- | --- | --- | --- | --- |
| Hobby | $5 | 400 | 1 vCPU · 4 GB RAM · 16 GB disk | 0.5 vCPU · 2 GB RAM | 3 | 1 |
| Starter | $20 | 1,700 | 2 vCPU · 6 GB RAM · 32 GB disk | 1 vCPU · 3 GB RAM | 10 | 3 |
| Pro | $40 | 3,500 | 4 vCPU · 8 GB RAM · 128 GB disk | 2 vCPU · 4 GB RAM | 50 | 10 |
| Scale | $99 | 9,000 | 8 vCPU · 16 GB RAM · 256 GB disk | 4 vCPU · 8 GB RAM | No set limit | 50 |
Build time uses the same credits as running apps, with no separate monthly minute
cap on these offers. Credits renew each billing cycle; they do not promise a
full month at maximum capacity. Existing subscriptions keep their purchased terms,
which are shown in Billing.
Stopped workspaces, build hosts and pending resource changes count toward shared
allocation. Per-VM limits apply as well; service counts do not promise every service
can run at its maximum size simultaneously. Undeployed drafts do not use service slots.
Credit packs add 400 credits for $5, 1,700 for $20, or 4,500 for $50. A credit is not a minute: consumption depends on CPU, memory and transfer under the active Oblien rate card. Extra credits extend usage; upgrading the plan increases capacity. Previously purchased credit grants and prices remain attached to their saved subscription.
+9 -5
View File
@@ -1,9 +1,13 @@
# Cloud capacity and unit economics — 2026-09-29
# Cloud capacity and unit economics — 2026-09-30
Version 3 introduces **$5 / $20 / $40 / $99** Hobby / Starter / Pro / Team offers,
with **400 / 1,700 / 3,500 / 9,000** metered credits. The prior capacity fixes and
Version 4 retains **$5 / $20 / $40 / $99** Hobby / Starter / Pro / Team offers,
with **400 / 1,700 / 3,500 / 9,000** metered credits. New offers allow one service
up to half the shared CPU/RAM pool: **0.5 vCPU / 2 GB**, **1 vCPU / 3 GB**,
**2 vCPU / 4 GB**, and **4 vCPU / 8 GB**. Total allocation, VM ceilings and
funding do not change. Existing paid snapshots are not upgraded automatically.
The prior capacity fixes and
source-builder corrections remain in place. [The catalog reference](../packages/core/src/pricing/README.md)
is the exact customer contract, including saved v1/v2 renewal behavior.
is the exact customer contract, including saved v1/v2/v3 renewal behavior.
## Hardware and revenue budget
@@ -58,7 +62,7 @@ credits. Grace stays zero unless the reseller explicitly configures it.
2. Deploy this Openship API/dashboard together. Published SDK 2.5.0 is sufficient.
3. Reconcile organizations through the billing sweep or guarded Cloud actions.
v1 paid credits and periods remain unchanged; finite safety ceilings replace
inherited capacity. v2 saved commercial terms remain intact.
inherited capacity. v2/v3 saved commercial terms and per-service ceilings remain intact.
4. Review oversized existing Docker hosts while no deployment is in progress.
The shared helper reduces CPU/RAM only after checking ownership, namespace
binding and bounded running containers. It retains disks and restores exactly
+3
View File
@@ -26,6 +26,9 @@ const planLimits = Type.Object({
workloads: Type.Array(Type.Union(WORKLOAD_TYPES.map(value => Type.Literal(value)))),
services: Type.Boolean(), runningServices: numberOrNull, maxProjects: numberOrNull,
maxResourceTier: Type.Union([...RESOURCE_TIER_ORDER.map(value => Type.Literal(value)), Type.Null()]),
maxServiceResources: Type.Optional(Type.Union([Type.Object({
cpuCores: Type.Number({ exclusiveMinimum: 0 }), memoryMb: Type.Integer({ exclusiveMinimum: 0 }),
}, { additionalProperties: false }), Type.Null()])),
computeMinutesPerMonth: numberOrNull, buildMinutesPerMonth: numberOrNull,
freeSubdomains: numberOrNull, customDomains: numberOrNull, seats: numberOrNull,
});
+26 -11
View File
@@ -6,14 +6,26 @@ same catalog to the dashboard, marketing and linked installations. Checkout uses
a generic Oblien offer; the subscription belongs to the customer's namespace.
The Enterprise reseller owner receives wallet funding and keeps its own plan.
## Version 3 offers
## Version 4 offers
| Plan | Monthly price | Included credits | Total vCPU | Total RAM | Total disk | Workspace count | Per VM: CPU / RAM / disk |
| --- | ---: | ---: | ---: | ---: | ---: | ---: | --- |
| Hobby | $5 | 400 | 1 | 4 GB | 16 GB | 1 | 1 / 4 GB / 16 GB |
| Starter | $20 | 1,700 | 2 | 6 GB | 32 GB | 3 | 2 / 6 GB / 16 GB |
| Pro | $40 | 3,500 | 4 | 8 GB | 128 GB | 6 | 2 / 8 GB / 32 GB |
| Team | $99 | 9,000 | 8 | 16 GB | 256 GB | 12 | 4 / 12 GB / 64 GB |
| Plan | Monthly price | Included credits | Shared CPU / RAM / disk | Per service: CPU / RAM | Projects | Service slots |
| --- | ---: | ---: | --- | --- | ---: | ---: |
| Hobby | $5 | 400 | 1 / 4 GB / 16 GB | 0.5 / 2 GB | 3 | 1 |
| Starter | $20 | 1,700 | 2 / 6 GB / 32 GB | 1 / 3 GB | 10 | 3 |
| Pro | $40 | 3,500 | 4 / 8 GB / 128 GB | 2 / 4 GB | 50 | 10 |
| Team | $99 | 9,000 | 8 / 16 GB / 256 GB | 4 / 8 GB | No set limit | 50 |
CPU values are vCPU. New retail offers allow one service up to half of the shared
CPU and RAM pool. `limits.maxServiceResources` records that ceiling explicitly,
including custom sizes; it does not change machine presets or workload defaults.
Prices, included credits, project/service counts and aggregate capacity are unchanged.
| Plan | Workspace count | Per VM: vCPU / RAM / disk |
| --- | ---: | --- |
| Hobby | 1 | 1 / 4 GB / 16 GB |
| Starter | 3 | 2 / 6 GB / 16 GB |
| Pro | 6 | 2 / 8 GB / 32 GB |
| Team | 12 | 4 / 12 GB / 64 GB |
Hobby includes a finite allowance for light use. A continuously running Docker
host can need top-ups; $5 does not buy an unlimited always-on VM.
@@ -80,13 +92,16 @@ stopped services stay stopped.
## Existing subscriptions and upgrades
`openship:<tier>:v3` saves the price, credits, grace, application limits and all
`openship:<tier>:v4` saves the price, credits, grace, application limits and all
seven capacity fields. Renewals use that snapshot, even after catalog edits.
Unknown versions, missing v2/v3 capacity fields and organization/namespace mismatches
Unknown versions, missing v2/v3/v4 capacity fields and organization/namespace mismatches
fail closed. Price and credit metadata never come from browser input.
Saved v2 subscriptions retain their original price, credits and capacity snapshot.
The new Hobby tier exists only in v3: a legacy provider `hobby` subscription still
Saved v2/v3 subscriptions retain their original price, credits and capacity snapshot.
Snapshots without `maxServiceResources` retain their purchased `maxResourceTier`
ceiling. There is no automatic uplift or rewrite of existing paid subscriptions;
any adjustment is an explicit operator action. Unchanged top-ups retain v3 references.
The new Hobby tier exists from v3: a legacy provider `hobby` subscription still
maps to its original Openship Starter tier, never to the new $5 plan.
Legacy v1 offers left VM sizes inherited from the Enterprise owner. Reconciliation
+14 -3
View File
@@ -163,8 +163,10 @@ export interface PlanLimits {
/** Concurrent services; several Compose services may share a workspace. */
runningServices: number | null;
maxProjects: number | null;
/** Largest per-service machine this tier may select, or null for uncapped. */
/** Preset ceiling for saved offers without explicit service resources. */
maxResourceTier: FixedResourceTier | null;
/** Explicit per-service ceiling; null is uncapped, absent retains the saved preset. */
maxServiceResources?: { cpuCores: number; memoryMb: number } | null;
/** Legacy display field; paid offers use credits rather than fixed runtime minutes. */
computeMinutesPerMonth: number | null;
buildMinutesPerMonth: number | null;
@@ -229,6 +231,16 @@ export function planLimits(planId: string | null | undefined): PlanLimits {
return plan.limits;
}
/** Resolve the supplied contract, never substitute current catalog limits for a saved offer. */
export function planServiceResources(limits: Pick<PlanLimits, "maxResourceTier" | "maxServiceResources">): {
cpuCores: number; memoryMb: number;
} | null {
if (limits.maxServiceResources !== undefined) return limits.maxServiceResources;
if (limits.maxResourceTier === null) return null;
const { cpuCores, memoryMb } = RESOURCE_TIER_SPECS[limits.maxResourceTier];
return { cpuCores, memoryMb };
}
/** @deprecated Historical display conversion. Not an Oblien metering rate. */
export const MILLI_PER_COMPUTE_MINUTE = 1000;
@@ -269,7 +281,7 @@ function placeholders(plan: PricingCatalogRaw["plans"][number], locale: PricingL
const unlimited = uiString(locale, "unlimited");
const n = (v: number | null) => (v === null ? unlimited : formatCount(v, locale));
const { limits } = plan;
const svc = limits.maxResourceTier ? RESOURCE_TIER_SPECS[limits.maxResourceTier] : null;
const svc = planServiceResources(limits);
return {
credits: n(plan.billing.creditsPerCycle),
namespaceCpu: n(plan.billing.resourceLimits.max_total_vcpus),
@@ -284,7 +296,6 @@ function placeholders(plan: PricingCatalogRaw["plans"][number], locale: PricingL
// Per-SERVICE machine size, not a pool — the copy must say so.
powerCpu: svc ? formatDecimal(svc.cpuCores, locale) : unlimited,
powerRamGb: svc ? formatDecimal(svc.memoryMb / 1024, locale) : unlimited,
powerDiskGb: svc ? formatCount(Math.round(svc.diskMb / 1024), locale) : unlimited,
computeMinutes: n(limits.computeMinutesPerMonth),
inherited: plan.inherits ? planName(plan.inherits, locale) : "",
freeDomainSuffix: PRICING.freeDomainSuffix,
@@ -31,6 +31,7 @@
"anyWorkload": "شغّل أي شيء — حاويات وCompose وعمليات خلفية وPostgres وRedis",
"runningServices": "{runningServices} تطبيق أو قاعدة بيانات أو عملية خلفية قيد التشغيل",
"buildMinutes": "{buildMinutes} دقيقة بناء شهريًا",
"meteredBuilds": "تستهلك عمليات البناء الرصيد المشترك دون حد زمني شهري منفصل",
"power": "حتى {powerCpu} vCPU و{powerRamGb} GB RAM لكل تطبيق",
"projects": "{maxProjects} مشروع",
"projectsUnlimited": "مشاريع غير محدودة",
@@ -31,6 +31,7 @@
"anyWorkload": "Beliebige Workloads – Container, Compose, Worker, Postgres, Redis",
"runningServices": "{runningServices} laufende Apps, Datenbanken oder Worker",
"buildMinutes": "{buildMinutes} Build-Minuten pro Monat",
"meteredBuilds": "Builds nutzen gemeinsames Guthaben ohne monatliches Zeitlimit",
"power": "Bis zu {powerCpu} vCPU und {powerRamGb} GB RAM pro App",
"projects": "{maxProjects} Projekte",
"projectsUnlimited": "Unbegrenzt viele Projekte",
@@ -31,6 +31,7 @@
"anyWorkload": "Run anything — containers, Compose, workers, Postgres, Redis",
"runningServices": "{runningServices} running apps, databases or workers",
"buildMinutes": "{buildMinutes} build minutes per month",
"meteredBuilds": "Builds use shared credits, with no monthly time cap",
"power": "Up to {powerCpu} vCPU and {powerRamGb} GB RAM per app",
"projects": "{maxProjects} projects",
"projectsUnlimited": "Unlimited projects",
@@ -31,6 +31,7 @@
"anyWorkload": "Ejecuta lo que sea: contenedores, Compose, workers, Postgres, Redis",
"runningServices": "{runningServices} apps, bases de datos o workers en ejecución",
"buildMinutes": "{buildMinutes} minutos de build al mes",
"meteredBuilds": "Las compilaciones usan créditos compartidos, sin límite de tiempo mensual",
"power": "Hasta {powerCpu} vCPU y {powerRamGb} GB de RAM por app",
"projects": "{maxProjects} proyectos",
"projectsUnlimited": "Proyectos ilimitados",
@@ -31,6 +31,7 @@
"anyWorkload": "Exécutez tout — conteneurs, Compose, workers, Postgres, Redis",
"runningServices": "{runningServices} applications, bases de données ou workers actifs",
"buildMinutes": "{buildMinutes} minutes de build par mois",
"meteredBuilds": "Les builds utilisent les crédits partagés, sans quota de durée mensuel",
"power": "Jusqu'à {powerCpu} vCPU et {powerRamGb} GB de RAM par application",
"projects": "{maxProjects} projets",
"projectsUnlimited": "Projets illimités",
@@ -31,6 +31,7 @@
"anyWorkload": "何でも動かせる — コンテナ、Compose、ワーカー、Postgres、Redis",
"runningServices": "同時に稼働するアプリ・データベース・ワーカー {runningServices} 個",
"buildMinutes": "毎月 {buildMinutes} 分のビルド時間",
"meteredBuilds": "ビルドは共通クレジットを使用し、月ごとの時間上限はありません",
"power": "アプリ 1 つあたり最大 {powerCpu} vCPU と {powerRamGb} GB RAM",
"projects": "プロジェクト {maxProjects} 個",
"projectsUnlimited": "プロジェクトは無制限",
@@ -31,6 +31,7 @@
"anyWorkload": "Execute qualquer coisa: contêineres, Compose, workers, Postgres, Redis",
"runningServices": "{runningServices} apps, bancos de dados ou workers em execução",
"buildMinutes": "{buildMinutes} minutos de build por mês",
"meteredBuilds": "Os builds usam créditos compartilhados, sem limite de tempo mensal",
"power": "Até {powerCpu} vCPU e {powerRamGb} GB de RAM por app",
"projects": "{maxProjects} projetos",
"projectsUnlimited": "Projetos ilimitados",
@@ -31,6 +31,7 @@
"anyWorkload": "Her şeyi çalıştırın — container, Compose, worker, Postgres, Redis",
"runningServices": "{runningServices} çalışan uygulama, veritabanı veya worker",
"buildMinutes": "Ayda {buildMinutes} build dakikası",
"meteredBuilds": "Derlemeler aylık süre sınırı olmadan ortak kredileri kullanır",
"power": "Uygulama başına en fazla {powerCpu} vCPU ve {powerRamGb} GB RAM",
"projects": "{maxProjects} proje",
"projectsUnlimited": "Sınırsız proje",
@@ -31,6 +31,7 @@
"anyWorkload": "任意工作负载:容器、Compose、后台任务、Postgres、Redis",
"runningServices": "{runningServices} 个同时运行的应用、数据库或后台任务",
"buildMinutes": "每月 {buildMinutes} 分钟构建时长",
"meteredBuilds": "构建使用共享额度,无每月时长上限",
"power": "每个应用最高可达 {powerCpu} vCPU 和 {powerRamGb} GB RAM",
"projects": "{maxProjects} 个项目",
"projectsUnlimited": "项目不限数量",
+20
View File
@@ -90,6 +90,10 @@
"runningServices": 1,
"maxProjects": 3,
"maxResourceTier": "low",
"maxServiceResources": {
"cpuCores": 0.5,
"memoryMb": 2048
},
"computeMinutesPerMonth": null,
"buildMinutesPerMonth": null,
"freeSubdomains": 10,
@@ -100,6 +104,7 @@
"everythingIn",
"usageCredits",
"namespaceCapacity",
"meteredBuilds",
"anyWorkload",
"oneRunningService",
"power",
@@ -148,6 +153,10 @@
"runningServices": 3,
"maxProjects": 10,
"maxResourceTier": "medium",
"maxServiceResources": {
"cpuCores": 1,
"memoryMb": 3072
},
"computeMinutesPerMonth": null,
"buildMinutesPerMonth": null,
"freeSubdomains": 25,
@@ -158,6 +167,7 @@
"everythingIn",
"usageCredits",
"namespaceCapacity",
"meteredBuilds",
"runningServices",
"power",
"projects",
@@ -204,6 +214,10 @@
"runningServices": 10,
"maxProjects": 50,
"maxResourceTier": "high",
"maxServiceResources": {
"cpuCores": 2,
"memoryMb": 4096
},
"computeMinutesPerMonth": null,
"buildMinutesPerMonth": null,
"freeSubdomains": 100,
@@ -214,6 +228,7 @@
"everythingIn",
"usageCredits",
"namespaceCapacity",
"meteredBuilds",
"runningServices",
"power",
"projects",
@@ -260,6 +275,10 @@
"runningServices": 50,
"maxProjects": null,
"maxResourceTier": "xlarge",
"maxServiceResources": {
"cpuCores": 4,
"memoryMb": 8192
},
"computeMinutesPerMonth": null,
"buildMinutesPerMonth": null,
"freeSubdomains": null,
@@ -270,6 +289,7 @@
"everythingIn",
"usageCredits",
"namespaceCapacity",
"meteredBuilds",
"runningServices",
"power",
"projectsUnlimited",
+47 -9
View File
@@ -8,6 +8,7 @@ import {
PRICING_LOCALES,
CREDIT_PACKS,
planLimits,
planServiceResources,
planAllowsWorkload,
planAllowsServices,
planMonthlyCredits,
@@ -27,7 +28,7 @@ import {
type PlanTierId,
type PricingLocale,
} from "./index";
import { pricingCatalogSchema, pricingCopySchema } from "./schema";
import { pricingCatalogSchema, pricingCopySchema, planLimitsSchema } from "./schema";
import { RESOURCE_TIER_ORDER, RESOURCE_TIER_SPECS } from "../resources";
const localesDir = fileURLToPath(new URL("./locales", import.meta.url));
@@ -148,7 +149,8 @@ describe("pricing catalog (pricing.json)", () => {
"services", // plan-guard: assertPlanAllowsServices
"runningServices", // Oblien max_workspaces + plan-guard
"maxProjects", // plan-guard: assertProjectQuota
"maxResourceTier", // Oblien max_vcpus/max_ram_mb/max_disk_gb
"maxResourceTier", // saved preset ceiling, plan-guard
"maxServiceResources", // explicit per-service CPU/RAM, plan-guard
"computeMinutesPerMonth", // legacy display field, null on paid plans
"buildMinutesPerMonth", // plan-guard: assertBuildMinutesAvailable
"freeSubdomains", // plan-guard: assertFreeSubdomainQuota
@@ -257,13 +259,49 @@ describe("pricing catalog (pricing.json)", () => {
expect(PRICING.oblien.buildResources.diskGb).toBeGreaterThan(0);
});
it("caps per-service power to a tier the deploy wizard can actually select", () => {
// The page used to advertise up to 64 vCPU while the picker topped out at 2,
// so nothing a customer read was choosable.
for (const id of PLAN_IDS) {
const tier = planLimits(id).maxResourceTier;
if (tier === null) continue;
expect(RESOURCE_TIER_ORDER, `${id} maxResourceTier`).toContain(tier);
it.each([
["hobby", 0.5, 2048], ["starter", 1, 3072], ["pro", 2, 4096], ["team", 4, 8192],
] as const)("allows one %s service up to half its shared CPU and RAM", (id, cpuCores, memoryMb) => {
const limits = planServiceResources(planLimits(id));
expect(limits).toEqual({ cpuCores, memoryMb });
expect(cpuCores).toBe(PLANS[id].oblienLimits.max_total_vcpus! / 2);
expect(memoryMb).toBe(PLANS[id].oblienLimits.max_total_ram_mb! / 2);
expect(resolvePlan(id).features).toContain(`Up to ${cpuCores} vCPU and ${memoryMb / 1024} GB RAM per app`);
});
it.each(RESOURCE_TIER_ORDER)("retains a saved %s preset when no explicit ceiling was purchased", maxResourceTier => {
const { cpuCores, memoryMb } = RESOURCE_TIER_SPECS[maxResourceTier];
expect(planServiceResources({ maxResourceTier })).toEqual({ cpuCores, memoryMb });
});
it("resolves explicit limits independently of the preset, including uncapped contracts", () => {
expect(planServiceResources({ maxResourceTier: "xlarge", maxServiceResources: { cpuCores: 1, memoryMb: 3072 } }))
.toEqual({ cpuCores: 1, memoryMb: 3072 });
expect(planServiceResources({ maxResourceTier: "low", maxServiceResources: null })).toBeNull();
expect(planServiceResources({ maxResourceTier: null })).toBeNull();
});
it.each([
{ cpuCores: 0, memoryMb: 1024 }, { cpuCores: 1, memoryMb: 0 },
{ cpuCores: -1, memoryMb: 1024 }, { cpuCores: Infinity, memoryMb: 1024 },
{ cpuCores: NaN, memoryMb: 1024 }, { cpuCores: 1, memoryMb: 1.5 },
{ cpuCores: 1 }, { cpuCores: 1, memoryMb: 1024, diskMb: 8192 },
])("rejects invalid saved service ceilings: %j", maxServiceResources => {
expect(planLimitsSchema.safeParse({ ...planLimits("starter"), maxServiceResources }).success).toBe(false);
});
it.each([null, undefined, { cpuCores: 3, memoryMb: 3072 }, { cpuCores: 1, memoryMb: 7168 }])(
"rejects new retail ceilings that are missing or exceed provider capacity: %j", maxServiceResources => {
const catalog = structuredClone(PRICING);
catalog.plans.find(plan => plan.id === "starter")!.limits.maxServiceResources = maxServiceResources;
expect(pricingCatalogSchema.safeParse(catalog).success).toBe(false);
},
);
it("keeps metered build usage visible without inventing a fixed-minute allowance", () => {
for (const id of ["hobby", "starter", "pro", "team"] as const) {
expect(planLimits(id).buildMinutesPerMonth).toBeNull();
expect(resolvePlan(id).features).toContain("Builds use shared credits, with no monthly time cap");
}
});
+21 -6
View File
@@ -44,13 +44,13 @@ export const planLimitsSchema = z.object({
runningServices: limitNumber,
/** Projects (project groups). Oblien has no project concept — Openship gates it. */
maxProjects: limitNumber,
/**
* The largest per-service machine this tier may select, named in the deploy
* wizard's OWN vocabulary (`RESOURCE_TIER_SPECS`). Stating a raw vCPU number
* here is what made the pricing page advertise sizes no picker offered.
* `null` = uncapped (enterprise).
*/
/** Preset ceiling for saved offers without explicit service resources. */
maxResourceTier: z.enum(RESOURCE_TIER_ORDER).nullable(),
/** CPU/RAM ceiling, including custom sizes. Absent on older paid snapshots. */
maxServiceResources: z.object({
cpuCores: z.number().finite().positive(),
memoryMb: z.number().int().positive(),
}).strict().nullable().optional(),
/** Legacy display field. Paid offers leave it null: metered credits do not
* imply a fixed number of runtime minutes. It never determines a credit grant. */
computeMinutesPerMonth: limitNumber,
@@ -258,6 +258,21 @@ export const pricingCatalogSchema = z
if (monthlyPurchasable && !plan.contactSales && Object.values(plan.billing.resourceLimits).some(value => value === null)) {
ctx.addIssue({ code: "custom", path: ["plans", i, "billing", "resourceLimits"], message: "Retail plans require explicit VM and total namespace capacity limits" });
}
const service = plan.limits.maxServiceResources;
if (monthlyPurchasable && !plan.contactSales && !service) {
ctx.addIssue({ code: "custom", path: ["plans", i, "limits", "maxServiceResources"], message: "Retail plans require explicit per-service CPU and memory limits" });
}
if (service) {
const policy = plan.billing.resourceLimits;
for (const [dimension, caps] of [
["cpuCores", [policy.max_vcpus, policy.max_total_vcpus]],
["memoryMb", [policy.max_ram_mb, policy.max_total_ram_mb]],
] as const) {
if (caps.some(cap => cap !== null && service[dimension] > cap)) {
ctx.addIssue({ code: "custom", path: ["plans", i, "limits", "maxServiceResources", dimension], message: "A service must fit within its workspace and shared capacity" });
}
}
}
if (annualPurchasable && !plan.billing.yearlyCreditsPerCycle) {
ctx.addIssue({
code: "custom",
+15 -18
View File
@@ -24,6 +24,7 @@ import {
AppError,
FREE_DOMAIN_SUFFIX,
planLimits,
planServiceResources,
resolvePlan,
PRICING,
RESOURCE_TIER_ORDER,
@@ -214,9 +215,8 @@ export async function assertPlanAllowsServices(organizationId: string): Promise<
* can share one VM. Openship also enforces the selected per-service tier inside
* that host, so a container cannot silently exceed its service configuration.
*
* Sizes are compared through `RESOURCE_TIER_ORDER` — the deploy wizard's own
* ordering — so "larger than your plan" means the same thing here and in the
* picker. A `custom` size is compared on its numbers against the tier's spec.
* Named presets and custom sizes are compared on CPU and RAM against the
* purchased service ceiling, independently of the workspace allocation.
*/
export async function assertPlanAllowsResourceTier(
organizationId: string,
@@ -233,32 +233,29 @@ function assertResourcesFitPlan(
requested: { tier?: string | null; cpuCores?: number | null; memoryMb?: number | null },
limits: PlanLimits,
): void {
const maxTier = limits.maxResourceTier;
if (maxTier === null) return; // uncapped (enterprise)
const ceiling = RESOURCE_TIER_SPECS[maxTier];
const refuse = () => {
const ceiling = planServiceResources(limits);
if (ceiling === null) return;
const refuse = (): never => {
throw new PlanUpgradeRequiredError(
`Your plan allows up to ${ceiling.cpuCores} vCPU and ${Math.round(ceiling.memoryMb / 1024)} GB RAM per service. Upgrade for bigger machines.`,
`Your plan allows up to ${formatCpuCores(ceiling.cpuCores)} and ${formatMemoryMb(ceiling.memoryMb)} RAM per service. Upgrade for bigger machines.`,
"resource-tier",
tier,
);
};
// A named tier: compare position in the shared order, so an unknown name is
// treated as over-limit rather than waved through.
// Resolve named presets before comparing; unknown names cannot bypass the cap.
const requestedTier = requested.tier?.trim();
let size = requested;
if (requestedTier && requestedTier !== "custom") {
const wantIdx = RESOURCE_TIER_ORDER.indexOf(requestedTier as never);
const maxIdx = RESOURCE_TIER_ORDER.indexOf(maxTier);
if (wantIdx < 0 || wantIdx > maxIdx) refuse();
return;
const preset = RESOURCE_TIER_ORDER.find(name => name === requestedTier);
if (!preset) return refuse();
size = RESOURCE_TIER_SPECS[preset];
}
// Custom numbers: either dimension over the ceiling is over-limit. `0` means
// "unlimited" in ResourceValues and must never read as "small".
const cpu = requested.cpuCores ?? 0;
const mem = requested.memoryMb ?? 0;
const cpu = size.cpuCores ?? 0;
const mem = size.memoryMb ?? 0;
if (!Number.isFinite(cpu) || !Number.isFinite(mem) || cpu <= 0 || mem <= 0 || cpu > ceiling.cpuCores || mem > ceiling.memoryMb) refuse();
}
@@ -398,7 +395,7 @@ export async function assertCloudRuntimeLimits(organizationId: string,
): Promise<void> {
if (!env.CLOUD_MODE || containers.length === 0) return;
const { tier, limits } = await planFor(organizationId);
if (limits.maxResourceTier === null) return;
if (planServiceResources(limits) === null) return;
for (const container of containers) {
const info = await runtime.getContainerInfo(container.containerId);
const recorded = container.allocatedResources;
@@ -33,11 +33,12 @@ export const CLOUD_EDGE_BANDWIDTH_GB: Readonly<Record<PlanTierId, number | null>
enterprise: null,
};
export const OFFER_VERSION = "3";
export const OFFER_VERSION = "4";
const TOPUP_OFFER_VERSION = "3";
export const offerReference = (tier: PlanTierId) => `openship:${tier}:v${OFFER_VERSION}`;
export function supportedOfferReference(reference: string | undefined, tier: PlanTierId): boolean {
return reference === offerReference(tier) || (tier !== "hobby" &&
return reference === offerReference(tier) || reference === `openship:${tier}:v3` || (tier !== "hobby" &&
(reference === `openship:${tier}:v1` || reference === `openship:${tier}:v2`));
}
@@ -74,19 +75,22 @@ export function subscriptionPlan(subscription: OblienSubscription, organizationI
const providerTier = subscription?.tierId;
const tier = providerTier == null ? "free" : PLAN_IDS.find(id => LEGACY_PLAN_IDS[id] === providerTier);
if (!tier) throw new AppError("This cloud plan is not supported by this Openship version", 503, "OBLIEN_PLAN_UNSUPPORTED");
return { tier, limits: planLimits(tier), resourceLimits: cloudNamespaceLimits(tier) };
// Platform subscriptions predate explicit service ceilings; retain their
// preset rather than applying a new retail offer to an existing customer.
return { tier, limits: { ...planLimits(tier), maxServiceResources: undefined }, resourceLimits: cloudNamespaceLimits(tier) };
}
const { offer, metadata } = subscription;
const tier = metadata?.openship_plan as PlanTierId;
const version = metadata?.openship_offer_version;
if (!PLAN_IDS.includes(tier) || tier === "free" || !offer || !metadata || !supportedOfferReference(offer.reference, tier) ||
!["1", "2", OFFER_VERSION].includes(version ?? "") || offer.reference !== `openship:${tier}:v${version}` || !metadata.openship_organization || !metadata.openship_namespace ||
offer.reference !== `openship:${tier}:v${version}` || !metadata.openship_organization || !metadata.openship_namespace ||
(organizationId !== undefined && metadata.openship_organization !== organizationId) ||
(namespace !== undefined && metadata.openship_namespace !== namespace) || !offer.policy || !offer.resourceLimits) invalidContract();
let decoded: unknown;
try { decoded = JSON.parse(metadata.openship_limits ?? ""); } catch { invalidContract(); }
const parsed = planLimitsSchema.strict().safeParse(decoded);
if (!parsed.success) invalidContract();
if (version === OFFER_VERSION && tier !== "enterprise" && !parsed.data.maxServiceResources) invalidContract();
return { tier, limits: parsed.data, resourceLimits: savedResourceLimits(tier, offer) };
}
@@ -128,7 +132,7 @@ export function topupOffer(packId: string): OblienOffer {
if (!pack)
throw new AppError("This credit pack is no longer available", 404, "BILLING_PACK_NOT_FOUND");
return {
reference: `openship:${pack.id}:v${OFFER_VERSION}`,
reference: `openship:${pack.id}:v${TOPUP_OFFER_VERSION}`,
name: "Openship compute credits",
description: `${toOblienCredits(pack.creditsMilli).toLocaleString("en-US")} additional credits for your namespace`,
unitAmount: pack.priceCents,
@@ -6,7 +6,8 @@ import {
safeErrorMessage,
CREDIT_PACKS,
PLANS,
RESOURCE_TIER_SPECS,
planServiceResources,
detectTier,
resolveCreditPackPriceId,
type PlanTierId,
type CreditPackDefinition,
@@ -106,6 +107,7 @@ export async function getBillingState(orgId: string): Promise<BillingState> {
readCloudCapacity(entitlement.namespace).catch(() => ({})),
]);
const buildTimeMinutes = buildMinutes.usedMinutes;
const serviceResources = tier === "free" ? null : planServiceResources(planLimitsForTier);
return {
tier,
@@ -146,13 +148,8 @@ export async function getBillingState(orgId: string): Promise<BillingState> {
overQuota,
buildTimeMinutes,
buildMinutesResetAt: buildMinutes.periodEnd,
maxServiceMachine: tier !== "free" && planLimitsForTier.maxResourceTier
? {
tier: planLimitsForTier.maxResourceTier,
cpuCores: RESOURCE_TIER_SPECS[planLimitsForTier.maxResourceTier].cpuCores,
memoryMb: RESOURCE_TIER_SPECS[planLimitsForTier.maxResourceTier].memoryMb,
}
: null,
maxServiceMachine: serviceResources
? { tier: detectTier({ ...serviceResources, diskMb: 0 }), ...serviceResources } : null,
/**
* The two allowances the plan gate actually refuses on, as meters. Both were
* declared in this contract and never populated — the dashboard's "Free