mirror of
https://github.com/oblien/openship.git
synced 2026-10-02 07:44:35 +08:00
fix(cloud): clarify plan comparison and raise new service limits
This commit is contained in:
@@ -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": "服务名额",
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": "项目不限数量",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user