diff --git a/app/composables/useUsage.ts b/app/composables/useUsage.ts
index e5afdf5e..ab82798f 100644
--- a/app/composables/useUsage.ts
+++ b/app/composables/useUsage.ts
@@ -23,6 +23,8 @@ export interface UsageCategory {
overageUnits: number
overageUnitPrice: number
overageAmount: number
+ /** With overage on, where this meter stops anyway (abuse ceiling, in `unit`); null = none. Absent from an older server. */
+ overageCeiling?: number | null
unit: string
percentage: number
/** When this meter resets; null for a level that does not (storage). Absent from an older server. */
diff --git a/docs/CDN_EDGE.md b/docs/CDN_EDGE.md
index 9127a8bc..8d1d375e 100644
--- a/docs/CDN_EDGE.md
+++ b/docs/CDN_EDGE.md
@@ -87,7 +87,7 @@ closes on whole days. Cache hits never reach the origin and never count.
| Setting | Values | Default |
|---|---|---|
-| `NUXT_CDN_ORIGIN_LIMIT` | `enforce`: past the plan limit delivery **continues** (usage alert + banner with the upgrade link); at **120 %** of the limit the origin answers **429** + `Retry-After` until the month resets, unless overage is on · `observe`: count and log, never refuse (self-hosters, operators) · `off` | `enforce` |
+| `NUXT_CDN_ORIGIN_LIMIT` | `enforce`: past the plan limit delivery **continues** (usage alert + banner with the upgrade link); at **120 %** of the limit the origin answers **429** + `Retry-After` until the month resets. With overage on, transfer past the plan is billed per GB and the 429 comes at the abuse ceiling, 10× the plan · `observe`: count and log, never refuse (self-hosters, operators) · `off` | `enforce` |
| `NUXT_CDN_ORIGIN_METER` | `true` sends one `cdn_origin_gb` event per workspace per finished UTC day to the payment meter | `false` |
`enforce` is the default on every deployment profile, dedicated and on-premise
@@ -107,8 +107,9 @@ Rollout:
1. Do the Cloudflare steps above. With the cache in front, a normal site stays
far below its origin limit.
2. Run polar-sync so the `cdn_origin_gb` meter exists, then set
- `NUXT_CDN_ORIGIN_METER=true`. Overage stays unsold (`overageBillable: false`)
- until the price decision (PR-F / ST-6).
+ `NUXT_CDN_ORIGIN_METER=true`. Overage is sold per GB of origin transfer
+ (`overage_price` on `cdn.bandwidth_gb`); a subscription bills it once it
+ prices `cdn_origin_gb`, until then its switch shows as locked.
## Rollback
diff --git a/docs/PAYMENT_PROVIDERS.md b/docs/PAYMENT_PROVIDERS.md
index f9af5a5a..762be828 100644
--- a/docs/PAYMENT_PROVIDERS.md
+++ b/docs/PAYMENT_PROVIDERS.md
@@ -62,9 +62,9 @@ NUXT_POLAR_ACCESS_TOKEN=polar_oat_… NUXT_POLAR_SERVER=sandbox \
The script is idempotent and content-driven. On every run it:
-- Creates the six meters if missing (`ai_messages`, `api_messages`,
- `mcp_calls`, `form_submissions`, `cdn_bandwidth_bytes`,
- `media_storage_byte_hours` — from `shared/utils/usage-meters.ts`).
+- Creates the six meters if missing (`ai_credits_1c`, `api_credits_1c`,
+ `mcp_calls`, `form_submissions`, `cdn_origin_gb`,
+ `media_storage_gb_months` — from `shared/utils/usage-meters.ts`).
- Creates Starter + Pro products if missing, stamped with
`metadata.contentrain_slug` so future runs find them reliably.
Name and description mutate in place when content changes.
diff --git a/nuxt.config.ts b/nuxt.config.ts
index 9983d273..1662af41 100644
--- a/nuxt.config.ts
+++ b/nuxt.config.ts
@@ -85,6 +85,9 @@ export default defineNuxtConfig({
originLimit: 'enforce', // NUXT_CDN_ORIGIN_LIMIT
// Daily `cdn_origin_gb` meter events. Off until the meter exists in Polar.
originMeter: false, // NUXT_CDN_ORIGIN_METER
+ // Daily `media_storage_gb_months` meter events (stored media, billed per
+ // GB-month past the plan). Off until the meter exists in Polar.
+ storageMeter: false, // NUXT_CDN_STORAGE_METER
},
// Provider selection — pairs must match: supabase+supabase (default) or
// managed+postgres. Enforced at boot by server/plugins/00.validate-config.ts.
diff --git a/server/api/cdn/v1/[projectId]/[...path].get.ts b/server/api/cdn/v1/[projectId]/[...path].get.ts
index be217180..6d410971 100644
--- a/server/api/cdn/v1/[projectId]/[...path].get.ts
+++ b/server/api/cdn/v1/[projectId]/[...path].get.ts
@@ -1,7 +1,7 @@
import { trackEnterpriseCdnUsage, trackEnterprisePublicCdnUsage } from '../../../../utils/enterprise'
import { addCdnOriginBytes, checkCdnOriginBudget } from '../../../../utils/cdn-origin-budget'
import { resolveUsagePeriodCached } from '../../../../utils/usage-period'
-import { getEffectiveLimit } from '../../../../utils/overage'
+import { getEffectiveLimit, isOverageEnabled } from '../../../../utils/overage'
import { isMediaSourcePath } from '../../../../utils/media-source'
import { requireDeliveryScope } from '../../../../utils/cdn-keys'
@@ -133,14 +133,14 @@ export default defineEventHandler(async (event) => {
// expired Pro trial serves within the Pro cap — bounded by that cap, and
// only until revoke; a site going dark at trial end is the worse outcome.
const workspaceId = project.workspace_id as string
- const limitGb = getEffectiveLimit(
- getPlanLimit(plan, 'cdn.bandwidth_gb'),
- 'cdn.bandwidth_gb',
- (workspace?.overage_settings as Record | null | undefined) ?? null,
- )
+ const overageSettings = (workspace?.overage_settings as Record | null | undefined) ?? null
+ const limitGb = getEffectiveLimit(getPlanLimit(plan, 'cdn.bandwidth_gb'), 'cdn.bandwidth_gb', overageSettings)
+ // With overage on, past the plan is billed per GB and `limitGb` is the abuse
+ // ceiling, which stops at itself; the plan limit keeps its unbilled buffer.
+ const overageOn = isOverageEnabled('cdn.bandwidth_gb', overageSettings)
// The window the budget counts over: a subscribed workspace's billing slice, else the calendar month.
const usagePeriod = await resolveUsagePeriodCached(workspaceId)
- const budget = await checkCdnOriginBudget({ workspaceId, limitGb, period: usagePeriod })
+ const budget = await checkCdnOriginBudget({ workspaceId, limitGb, period: usagePeriod, ...(overageOn ? { hardStopRatio: 1 } : {}) })
if (!budget.allowed) {
setResponseHeader(event, 'Retry-After', budget.retryAfterSeconds)
throw createError({ statusCode: 429, message: errorMessage('cdn.origin_limit_reached', { limit: limitGb }) })
diff --git a/server/api/workspaces/[workspaceId]/projects/[projectId]/migration/media.get.ts b/server/api/workspaces/[workspaceId]/projects/[projectId]/migration/media.get.ts
index 4674d6cc..bae52ad7 100644
--- a/server/api/workspaces/[workspaceId]/projects/[projectId]/migration/media.get.ts
+++ b/server/api/workspaces/[workspaceId]/projects/[projectId]/migration/media.get.ts
@@ -66,7 +66,7 @@ export default defineEventHandler(async (event) => {
tree,
plan,
usedBytes: Number(ws?.media_storage_bytes ?? 0),
- overageSettings: (ws?.overage_settings as Record | null) ?? {},
+ overageSettings: event.context.billing?.overageSettings ?? (ws?.overage_settings as Record | null) ?? {},
root: found.root,
signedOrigin: await migrationSignedOrigin(workspaceId, project),
}),
diff --git a/server/plugins/media-storage-meter.ts b/server/plugins/media-storage-meter.ts
new file mode 100644
index 00000000..404df733
--- /dev/null
+++ b/server/plugins/media-storage-meter.ts
@@ -0,0 +1,77 @@
+/**
+ * Daily media-storage meter — Nitro plugin.
+ *
+ * Storage is a level (`workspaces.media_storage_bytes`, kept by the upload
+ * and delete paths), not a stream of events, so nothing reaches the payment
+ * meter unless it is sampled. Once per UTC day each workspace with a paying
+ * account gets one `media_storage_gb_months` event: the bytes stored at the
+ * sample, in GB, divided by the days in its billing period
+ * (`recordMediaStorageDay`). A period's events then sum to the average GB
+ * stored over it, which is what "per GB/month" sells, and the plan's GB is
+ * the included allowance as is.
+ *
+ * Runs every six hours; the event is keyed by workspace + day, so the first run of a
+ * day samples it and the rest record nothing. A missed day is not filled in
+ * afterwards: a past level cannot be read back, and the gap only ever bills
+ * less. Workspaces without a payment customer are skipped — nothing would
+ * bill them, and the outbox would retry their rows until it dropped them.
+ *
+ * Off unless `NUXT_CDN_STORAGE_METER` is set — the meter has to exist in
+ * the payment provider first (polar-sync). Self-hosted deployments without
+ * billing record nothing either way (`recordUsage`).
+ */
+import { useDatabaseProvider } from '../utils/providers'
+import { recordMediaStorageDay } from '../utils/usage-metering'
+import { usagePeriodFrom } from '../utils/usage-period'
+import type { UsagePeriodAccount } from '../utils/usage-period'
+
+const INTERVAL_MS = 6 * 60 * 60 * 1000
+const DAY_MS = 24 * 60 * 60 * 1000
+
+export default defineNitroPlugin((nitroApp) => {
+ if (!useRuntimeConfig().cdn?.storageMeter) return
+
+ const timeout = setTimeout(() => {
+ meterMediaStorageDay().catch(logFailure)
+ }, 90_000)
+ const interval = setInterval(() => {
+ meterMediaStorageDay().catch(logFailure)
+ }, INTERVAL_MS)
+
+ nitroApp.hooks.hook('close', () => {
+ clearTimeout(timeout)
+ clearInterval(interval)
+ })
+})
+
+function logFailure(err: unknown) {
+ // eslint-disable-next-line no-console -- background job; failure must surface somewhere
+ console.error('[media-storage-meter] Run failed:', err)
+}
+
+/** Days in the period (`startsAt`..`resetsAt`), at least one. */
+export function periodDays(period: { startsAt: string, resetsAt: string }): number {
+ const span = new Date(period.resetsAt).getTime() - new Date(period.startsAt).getTime()
+ return Math.max(1, span / DAY_MS)
+}
+
+export async function meterMediaStorageDay(now: Date = new Date()): Promise {
+ const db = useDatabaseProvider()
+ const day = now.toISOString().substring(0, 10)
+ let recorded = 0
+ for (const { workspaceId, bytes } of await db.listWorkspaceMediaStorageBytes()) {
+ let account: UsagePeriodAccount & { customer_id?: unknown } | null
+ try {
+ account = await db.getActivePaymentAccount(workspaceId) as UsagePeriodAccount & { customer_id?: unknown } | null
+ }
+ catch (err) {
+ // One unreadable account must not stop the others; the next run retries it.
+ logFailure(err)
+ continue
+ }
+ if (!account?.customer_id) continue
+ await recordMediaStorageDay({ workspaceId, day, bytes, periodDays: periodDays(usagePeriodFrom(account, now)) })
+ recorded++
+ }
+ return recorded
+}
diff --git a/server/providers/database.ts b/server/providers/database.ts
index 1ac50ddf..1137b4fc 100644
--- a/server/providers/database.ts
+++ b/server/providers/database.ts
@@ -1019,6 +1019,12 @@ export interface DatabaseProvider {
* out. Feeds the daily `cdn_origin_gb` meter event.
*/
listWorkspaceCDNBandwidthForDay: (day: string) => Promise>
+ /**
+ * Every workspace's stored media bytes (`workspaces.media_storage_bytes`),
+ * workspaces storing nothing left out. Feeds the daily
+ * `media_storage_gb_months` meter event. A failed read throws.
+ */
+ listWorkspaceMediaStorageBytes: () => Promise>
// ═══════════════════════════════════════════════════
// PAYMENT ACCOUNTS (per-provider subscription state)
diff --git a/server/providers/postgres-db/usage.ts b/server/providers/postgres-db/usage.ts
index 31dac7cf..213c5d76 100644
--- a/server/providers/postgres-db/usage.ts
+++ b/server/providers/postgres-db/usage.ts
@@ -16,6 +16,7 @@ type UsageMethods = Pick<
| 'getWorkspaceMonthlyAPIUsage'
| 'getWorkspaceMonthlyCDNBandwidth'
| 'listWorkspaceCDNBandwidthForDay'
+ | 'listWorkspaceMediaStorageBytes'
>
export function usageMethods(): UsageMethods {
@@ -77,5 +78,16 @@ export function usageMethods(): UsageMethods {
.map(r => ({ workspaceId: String(r.workspace_id), bytes: Number(r.total ?? 0) }))
.filter(r => r.bytes > 0)
},
+
+ async listWorkspaceMediaStorageBytes() {
+ // A failure propagates — the meter job must not record "nothing stored".
+ const rows = await getAdmin()
+ .selectFrom('workspaces')
+ .select(['id', 'media_storage_bytes'])
+ .where('media_storage_bytes', '>', 0)
+ .execute()
+
+ return rows.map(r => ({ workspaceId: String(r.id), bytes: Number(r.media_storage_bytes ?? 0) }))
+ },
}
}
diff --git a/server/providers/supabase-db/usage.ts b/server/providers/supabase-db/usage.ts
index 667269fb..9445bc36 100644
--- a/server/providers/supabase-db/usage.ts
+++ b/server/providers/supabase-db/usage.ts
@@ -18,6 +18,7 @@ type UsageMethods = Pick<
| 'getWorkspaceMonthlyAPIUsage'
| 'getWorkspaceMonthlyCDNBandwidth'
| 'listWorkspaceCDNBandwidthForDay'
+ | 'listWorkspaceMediaStorageBytes'
>
export function usageMethods(): UsageMethods {
@@ -109,5 +110,16 @@ export function usageMethods(): UsageMethods {
}
return [...totals].map(([workspaceId, bytes]) => ({ workspaceId, bytes })).filter(r => r.bytes > 0)
},
+
+ async listWorkspaceMediaStorageBytes() {
+ // A failure propagates — the meter job must not record "nothing stored".
+ const { data, error } = await getAdmin()
+ .from('workspaces')
+ .select('id, media_storage_bytes')
+ .gt('media_storage_bytes', 0)
+ if (error) throw error
+ return ((data ?? []) as Array>)
+ .map(r => ({ workspaceId: String(r.id), bytes: Number(r.media_storage_bytes ?? 0) }))
+ },
}
}
diff --git a/server/utils/cdn-delivery-cache.ts b/server/utils/cdn-delivery-cache.ts
index 0f19e940..367b74f5 100644
--- a/server/utils/cdn-delivery-cache.ts
+++ b/server/utils/cdn-delivery-cache.ts
@@ -20,6 +20,7 @@
*/
import type { DatabaseRow } from '../providers/database'
+import { billableOverageSettings } from './workspace-billing'
interface CacheEntry {
value: T
@@ -140,14 +141,27 @@ export async function cachedProjectDelivery(projectId: string): Promise {
const hit = getFresh(workspacePlanCache, workspaceId)
if (hit) return hit
const db = useDatabaseProvider()
const workspace = await db.getWorkspaceById(workspaceId, 'plan, overage_settings')
- if (workspace) setEntry(workspacePlanCache, workspaceId, workspace)
+ if (workspace) {
+ if (workspace.overage_settings)
+ workspace.overage_settings = await billableOverageSettings(db, workspaceId, workspace.overage_settings)
+ setEntry(workspacePlanCache, workspaceId, workspace)
+ }
return workspace
}
diff --git a/server/utils/cdn-origin-budget.ts b/server/utils/cdn-origin-budget.ts
index dc2a74e1..19dc14d1 100644
--- a/server/utils/cdn-origin-budget.ts
+++ b/server/utils/cdn-origin-budget.ts
@@ -15,8 +15,10 @@
* Mode (`NUXT_CDN_ORIGIN_LIMIT`):
* - `enforce` (default) — past the limit delivery continues (the owner is
* alerted), and at `CDN_ORIGIN_HARD_STOP_RATIO` of it the origin
- * answers 429 with Retry-After until the window resets, unless
- * overage is on (`getEffectiveLimit`).
+ * answers 429 with Retry-After until the window resets. With
+ * overage on, the limit passed in is the abuse ceiling
+ * (`getEffectiveLimit`, 10× the plan), billed per GB past the
+ * plan, and the stop is at the ceiling itself (`hardStopRatio: 1`).
* - `observe` — count and log, never refuse (self-hosters, operators).
* - `off` — neither count nor refuse.
*/
@@ -84,6 +86,11 @@ export async function checkCdnOriginBudget(input: {
now?: Date
/** The workspace's usage window; absent = calendar month. */
period?: UsagePeriod
+ /**
+ * Where, as a multiple of `limitGb`, the origin stops. The plan limit
+ * keeps the unbilled 20 % buffer; an overage ceiling stops at itself.
+ */
+ hardStopRatio?: number
}): Promise<{ allowed: true } | { allowed: false, retryAfterSeconds: number }> {
const mode = cdnOriginLimitMode()
if (mode === 'off' || !Number.isFinite(input.limitGb)) return { allowed: true }
@@ -104,7 +111,8 @@ export async function checkCdnOriginBudget(input: {
console.warn(`[cdn-origin] workspace=${input.workspaceId} over its ${input.limitGb} GB origin limit (${(used / GIB).toFixed(2)} GB, mode=${mode})`)
}
// Past the limit and under the hard stop: keep serving, the owner is alerted.
- if (mode === 'observe' || used < input.limitGb * GIB * CDN_ORIGIN_HARD_STOP_RATIO) return { allowed: true }
+ const stopRatio = input.hardStopRatio ?? CDN_ORIGIN_HARD_STOP_RATIO
+ if (mode === 'observe' || used < input.limitGb * GIB * stopRatio) return { allowed: true }
return { allowed: false, retryAfterSeconds: secondsUntilMonthReset(now, input.period) }
}
diff --git a/server/utils/conversation-engine.ts b/server/utils/conversation-engine.ts
index 8ff21a08..9bc42097 100644
--- a/server/utils/conversation-engine.ts
+++ b/server/utils/conversation-engine.ts
@@ -29,6 +29,7 @@ import { isEntryWriteMode } from './content-engine/entry-mode'
import type { TextEdit } from './content-engine/replace-text'
import type { AttachmentPromotionContext, PromotedAttachment } from './attachment-promotion'
import { hasAttachmentMarker, promoteAttachmentMarkers } from './attachment-promotion'
+import { billableOverageSettings } from './workspace-billing'
/**
* Conversation Engine — reusable AI conversation loop with tool execution.
@@ -1636,7 +1637,8 @@ export async function executeToolWithAutoMerge(
const db = useDatabaseProvider()
const workspace = await db.getWorkspaceById(workspaceId, 'id, overage_settings')
- const overageSettings = (workspace?.overage_settings as Record | null) ?? {}
+ // A toggle the subscription cannot bill does not raise the cap.
+ const overageSettings = await billableOverageSettings(db, workspaceId, workspace?.overage_settings)
const baseLimit = getPlanLimit(plan, 'media.storage_gb') * 1024 * 1024 * 1024
const storageLimit = getEffectiveLimit(baseLimit, 'media.storage_gb', overageSettings)
diff --git a/server/utils/mcp-media-facet.ts b/server/utils/mcp-media-facet.ts
index 16bb79c3..962e777b 100644
--- a/server/utils/mcp-media-facet.ts
+++ b/server/utils/mcp-media-facet.ts
@@ -31,6 +31,7 @@ import type { Plan } from './license'
import { fetchRemoteMedia } from './media-ingest'
import { withMediaUrls } from './media-url'
import { resolveVariantConfigWithPlan } from './media-variants'
+import { billableOverageSettings } from './workspace-billing'
export interface McpMediaFacetInput {
projectId: string
@@ -161,7 +162,8 @@ export function buildMcpMediaFacet(input: McpMediaFacetInput): ContractMediaProv
// call rather than trusting the (up to 15-min-old) session snapshot.
const db = useDatabaseProvider()
const workspace = await db.getWorkspaceById(input.workspaceId, 'id, overage_settings')
- const overageSettings = (workspace?.overage_settings as Record | null) ?? {}
+ // A toggle the subscription cannot bill does not raise the cap.
+ const overageSettings = await billableOverageSettings(db, input.workspaceId, workspace?.overage_settings)
const baseLimit = getPlanLimit(input.plan, 'media.storage_gb') * 1024 * 1024 * 1024
const storageLimit = getEffectiveLimit(baseLimit, 'media.storage_gb', overageSettings)
diff --git a/server/utils/overage.ts b/server/utils/overage.ts
index 72ace9fd..e92e5c47 100644
--- a/server/utils/overage.ts
+++ b/server/utils/overage.ts
@@ -3,7 +3,8 @@
*
* Determines effective limits based on workspace overage preferences.
* When overage is enabled for a category, the RPC limit is raised to
- * Postgres INT max — the RPC functions themselves are unchanged.
+ * Postgres INT max — the RPC functions themselves are unchanged — or, for a
+ * limit with an abuse ceiling, to that multiple of the plan.
* Overage amounts are computed at query time: max(0, usage - planLimit) * price.
*/
@@ -12,10 +13,11 @@ import { USAGE_METER_LIST } from '../../shared/utils/usage-meters'
/**
* Limits whose usage is not sold past the plan allowance, whatever the
- * workspace has toggled. A meter that cannot carry an included allowance
- * would bill from the first unit, so selling overage against it would
- * charge for what the plan already covers — the limit stays hard until
- * the meter counts the unit the plan sells.
+ * workspace has toggled (`overageBillable: false` in the meter manifest).
+ * A meter that cannot carry an included allowance would bill from the
+ * first unit, so selling overage against it would charge for what the
+ * plan already covers. Empty today; kept so a future meter can be listed
+ * before it is sold.
*/
const UNSELLABLE_LIMIT_KEYS: ReadonlySet = new Set(
USAGE_METER_LIST.filter(m => !m.overageBillable).map(m => m.limitKey),
@@ -29,11 +31,36 @@ export function isOverageSellable(limitKey: string): boolean {
/** Postgres INT max — used as soft cap when overage is enabled. */
const SOFT_CAP_MAX = 2_147_483_647
+/**
+ * Abuse ceilings: with overage on, how many times its plan allowance a
+ * limit may reach before it stops again, whatever is paid.
+ *
+ * Overage is invoiced after the fact, so usage past the plan is credit
+ * Studio extends until the period closes. For CDN transfer and media
+ * storage that credit is real money (egress, storage) and a leaked key or
+ * a runaway sync could run it up without bound. Ten times the plan is far
+ * past any site that is merely busy (Pro: 600 GB of origin transfer,
+ * 250 GB stored), so it bounds the exposure without making a growing
+ * site unusable. Reaching it stops the same way the hard limit does
+ * (CDN 429 until the window resets, uploads refused) and the owner is
+ * told; upgrading or contacting support lifts it.
+ *
+ * Also the unit fix: callers pass the media limit in bytes, and the
+ * int32 soft cap read as bytes is 2 GiB — below every paid plan — so a
+ * byte-valued limit must be raised by a multiple, never to SOFT_CAP_MAX.
+ */
+export const OVERAGE_ABUSE_CEILING_RATIO: Readonly> = {
+ 'cdn.bandwidth_gb': 10,
+ 'media.storage_gb': 10,
+}
+
/**
* Get the effective limit to pass to atomic RPC functions.
*
* - Overage disabled (default): returns the plan limit (hard cap).
- * - Overage enabled: returns SOFT_CAP_MAX (effectively unlimited for the RPC check).
+ * - Overage enabled: returns SOFT_CAP_MAX (effectively unlimited for the RPC check),
+ * or the plan limit × its abuse ceiling ratio where one is set — in the
+ * caller's own unit, so a limit passed in bytes stays in bytes.
* - Infinity limits (enterprise): returns SOFT_CAP_MAX regardless.
* - Limits that are not sellable: always the plan limit, toggle or not.
*/
@@ -52,7 +79,19 @@ export function getEffectiveLimit(
if (!isOverageSellable(limitKey)) return planLimit
const enabled = overageSettings?.[pricing.settingsKey] === true
- return enabled ? SOFT_CAP_MAX : planLimit
+ if (!enabled) return planLimit
+ const ceiling = OVERAGE_ABUSE_CEILING_RATIO[limitKey]
+ return ceiling ? Math.round(planLimit * ceiling) : SOFT_CAP_MAX
+}
+
+/**
+ * The most a limit can reach with overage on, in the limit's own unit;
+ * Infinity when overage on it has no ceiling.
+ */
+export function overageCeiling(planLimit: number, limitKey: string): number {
+ if (planLimit === Infinity) return Infinity
+ const ratio = OVERAGE_ABUSE_CEILING_RATIO[limitKey]
+ return ratio ? planLimit * ratio : Infinity
}
/**
diff --git a/server/utils/usage-alerts.ts b/server/utils/usage-alerts.ts
index 6b7a04ae..d2026ae5 100644
--- a/server/utils/usage-alerts.ts
+++ b/server/utils/usage-alerts.ts
@@ -19,6 +19,10 @@
* still serving, so its 100 % mail says so and asks for an upgrade; the stop
* comes at 120 %, which is its own third alert.
*
+ * With overage on, a meter with an abuse ceiling (CDN, storage: 10× the plan,
+ * `server/utils/overage.ts`) stops there; reaching it is the same third
+ * alert (threshold 120, "stopped"), with an upgrade as the next step.
+ *
* Send is at most once per key: the row is claimed first (primary key), the
* email goes out only if this run won the claim, and a failed send releases it
* so the next run retries.
@@ -63,6 +67,10 @@ export function planUsageAlerts(categories: WorkspaceUsageCategory[]): PlannedAl
if (category.key === 'cdn_bandwidth' && !category.overageEnabled && category.current >= category.limit * CDN_ORIGIN_HARD_STOP_RATIO) {
planned.push({ category, threshold: 120, template: 'usage-limit-reached' })
}
+ // Overage on and the abuse ceiling reached: it has stopped, billed or not.
+ else if (category.overageEnabled && category.overageCeiling !== null && category.current >= category.overageCeiling) {
+ planned.push({ category, threshold: 120, template: 'usage-limit-reached' })
+ }
else if (category.current >= category.limit) {
planned.push({ category, threshold: 100, template: category.overageEnabled ? 'usage-overage-started' : 'usage-limit-reached' })
}
@@ -108,14 +116,17 @@ function formatAmount(value: number, unit: string): string {
* 80 % (an upload, a delete, an upload) would otherwise mail the owner on every sweep.
*/
export const STORAGE_REARM_BELOW: Record<80 | 100, number> = { 80: 0.7, 100: 0.9 }
+/** The same for the overage ceiling's alert, as a share of the ceiling. */
+export const STORAGE_CEILING_REARM_BELOW = 0.9
/** Releases the storage `level` claims of thresholds the workspace is now well back below. */
async function rearmStorageAlerts(db: AlertDatabase, workspaceId: string, categories: WorkspaceUsageCategory[]): Promise {
const storage = categories.find(c => c.key === 'media_storage')
if (!storage || storage.limit <= 0) return
- const below: Array<80 | 100> = []
+ const below: Array<80 | 100 | 120> = []
if (storage.current < storage.limit * STORAGE_REARM_BELOW[100]) below.push(100)
if (storage.current < storage.limit * STORAGE_REARM_BELOW[80]) below.push(80)
+ if (storage.current < storage.limit * STORAGE_REARM_BELOW[100] || (storage.overageCeiling !== null && storage.current < storage.overageCeiling * STORAGE_CEILING_REARM_BELOW)) below.push(120)
for (const threshold of below)
await db.releaseUsageAlert({ workspaceId, meter: 'media_storage', periodKey: STORAGE_PERIOD_KEY, threshold })
}
@@ -193,7 +204,8 @@ export async function runUsageAlerts(deps: UsageAlertDeps): Promise {
return recordUsage({
workspaceId: input.workspaceId,
- meterName: USAGE_METERS.MEDIA_STORAGE_BYTE_HOURS.name,
- value: input.byteHours,
- idempotencyKey: `storage:${input.workspaceId}:${input.sampleHour}`,
- metadata: { sample_hour: input.sampleHour },
+ meterName: USAGE_METERS.MEDIA_STORAGE_GB_MONTHS.name,
+ value: storageGbMonthsForDay(input.bytes, input.periodDays),
+ idempotencyKey: `storage-gbm:${input.workspaceId}:${input.day}`,
+ metadata: { day: input.day, bytes: input.bytes, period_days: input.periodDays },
})
}
diff --git a/server/utils/workspace-billing.ts b/server/utils/workspace-billing.ts
index 6eaabf6e..d6b368ad 100644
--- a/server/utils/workspace-billing.ts
+++ b/server/utils/workspace-billing.ts
@@ -91,3 +91,29 @@ export async function resolveWorkspaceBilling(
creditUnit: resolveCreditUnit(account as { credit_unit?: unknown } | null),
}
}
+
+/**
+ * Stored `overage_settings` as the subscription can bill them, for a
+ * caller that read the row itself (outside the billing middleware and
+ * `resolveWorkspaceBilling`): every toggle the account cannot invoice is
+ * off. The account is read only when some toggle is on; an unreadable
+ * account turns them all off — usage stops at the plan, never past it unbilled.
+ */
+export async function billableOverageSettings(
+ db: Pick, 'getActivePaymentAccount'>,
+ workspaceId: string,
+ stored: unknown,
+): Promise> {
+ const settings = stored && typeof stored === 'object' ? stored as Record : null
+ if (!settings || !Object.values(settings).includes(true)) return settings ?? {}
+ // No subscription state machine (self-hosted, dedicated): nothing is billed, nothing locks.
+ if (resolveDeployment().planSource !== 'subscription') return settings
+ try {
+ const account = await db.getActivePaymentAccount(workspaceId)
+ return withoutLockedOverage(settings, resolveOverageLocks(account as OverageLockAccount | null))
+ }
+ catch {
+ // Unreadable billing: serve within the plan's own limit, never past it unbilled.
+ return Object.fromEntries(Object.keys(settings).map(key => [key, false]))
+ }
+}
diff --git a/server/utils/workspace-usage.ts b/server/utils/workspace-usage.ts
index 21441e17..9ea75c28 100644
--- a/server/utils/workspace-usage.ts
+++ b/server/utils/workspace-usage.ts
@@ -14,7 +14,9 @@
* is a level and does not reset.
*
* Money is only quoted where it can be charged: a meter's overage units,
- * amount and projection are zero unless overage is turned on for it. With the
+ * amount and projection are zero unless overage is turned on for it.
+ * Storage is quoted at today's level; the invoice bills the period's
+ * average (`media_storage_gb_months`), and the screen says so. With the
* switch off the meter is hard-capped, and quoting "$54.88 overage" there
* told customers they owed something they did not.
*
@@ -28,7 +30,7 @@ import { OVERAGE_PRICING, getPlanLimitForPlan } from '../../shared/utils/license
import { CURRENT_CREDIT_UNIT, creditTermsFor, isCreditLimitKey } from '../../shared/utils/credit-unit'
import type { CreditUnit } from '../../shared/utils/credit-unit'
import type { DatabaseProvider } from '../providers/database'
-import { calculateOverageUnits, isOverageSellable } from './overage'
+import { calculateOverageUnits, isOverageSellable, overageCeiling } from './overage'
import type { OverageLock } from './overage-lock'
import { reportBillingRisk } from './alert'
import { usagePeriodFrom, usageWindowOf } from './usage-period'
@@ -50,6 +52,11 @@ export interface WorkspaceUsageCategory {
/** Price per unit past the limit; shown beside the switch before it is turned on. */
overageUnitPrice: number
overageAmount: number
+ /**
+ * With overage on, where this meter stops anyway (the abuse ceiling, in
+ * `unit`); null when overage on it has no ceiling or is not sold.
+ */
+ overageCeiling: number | null
unit: string
percentage: number
/** When this meter goes back to zero; null for a meter that does not reset (storage). */
@@ -169,6 +176,7 @@ export async function computeWorkspaceUsage(db: UsageReader, input: {
const overageUnitPrice = sellable ? terms.overagePrice(m.limitKey) ?? 0 : 0
const overageUnits = overageEnabled && !unavailable ? calculateOverageUnits(current, planLimit) : 0
const overageAmount = overageUnits * overageUnitPrice
+ const ceiling = sellable ? overageCeiling(planLimit, m.limitKey) : Infinity
if (overageEnabled && !unavailable && planLimit !== Infinity && planLimit > 0) {
if (!m.window) {
@@ -196,6 +204,7 @@ export async function computeWorkspaceUsage(db: UsageReader, input: {
overageUnits: round2(overageUnits),
overageUnitPrice,
overageAmount: round2(overageAmount),
+ overageCeiling: Number.isFinite(ceiling) ? ceiling : null,
unit: m.unit,
percentage: unavailable || planLimit === Infinity || planLimit === 0 ? 0 : Math.round((current / planLimit) * 100),
resetsAt: m.window?.resetsAt ?? null,
diff --git a/shared/utils/usage-meters.ts b/shared/utils/usage-meters.ts
index 6633c7c8..547b8161 100644
--- a/shared/utils/usage-meters.ts
+++ b/shared/utils/usage-meters.ts
@@ -44,12 +44,11 @@ export interface UsageMeterDefinition {
*
* False means the limit is hard: no metered price, no included
* allowance, and the app refuses to raise the cap even if the workspace
- * has the overage toggle on. The two byte meters are false because they
- * cannot carry an allowance — Polar caps a meter credit at int32 and a
- * gigabyte in bytes exceeds it — and billing overage against an
- * allowance that cannot be expressed would charge from the first byte.
- * Events still flow, so the usage is measured and shown; it just is not
- * sold until the meter counts the unit the plan sells.
+ * has the overage toggle on. A meter can only be billable when it counts
+ * the unit the plan sells (`unitsPerLimitUnit: 1`): Polar caps a meter
+ * credit at int32, so an allowance counted in a smaller unit (a gigabyte
+ * in bytes) cannot be expressed, and billing against it would charge
+ * from the first unit. Every meter in the manifest meets that today.
*/
readonly overageBillable: boolean
}
@@ -108,9 +107,9 @@ export const USAGE_METERS = {
unitLabel: 'GB',
aggregation: 'sum',
unitsPerLimitUnit: 1,
- // Still a hard limit: turning overage on is the price decision (PR-F /
- // ST-6), which also attaches the metered price in Polar.
- overageBillable: false,
+ // Sold past the plan (founder, 2026-10-09): the allowance is the plan's
+ // GB as is, and each GB past it bills at the catalogue price.
+ overageBillable: true,
},
FORM_SUBMISSIONS: {
name: 'form_submissions',
@@ -121,14 +120,22 @@ export const USAGE_METERS = {
unitsPerLimitUnit: 1,
overageBillable: true,
},
- MEDIA_STORAGE_BYTE_HOURS: {
- name: 'media_storage_byte_hours',
+ // `media_storage_gb_months`, not the older `media_storage_byte_hours`:
+ // the plan sells gigabytes per month, and a byte meter cannot carry that
+ // allowance (int32 meter credit). One event per workspace per finished
+ // UTC day (`server/plugins/media-storage-meter.ts`) carrying the stored GB
+ // divided by the days in the workspace's billing period, so a period's
+ // sum is the average GB stored over it — a GB-month. The plan's included
+ // GB is the allowance as is; the average past it bills per GB-month.
+ // The byte-hour meter never received an event and stays unused in Polar.
+ MEDIA_STORAGE_GB_MONTHS: {
+ name: 'media_storage_gb_months',
limitKey: 'media.storage_gb',
settingsKey: 'media_storage',
- unitLabel: 'byte·hour',
+ unitLabel: 'GB·month',
aggregation: 'sum',
- unitsPerLimitUnit: 1024 ** 3,
- overageBillable: false,
+ unitsPerLimitUnit: 1,
+ overageBillable: true,
},
} as const satisfies Record
diff --git a/tests/integration/overage-settings.integration.test.ts b/tests/integration/overage-settings.integration.test.ts
index 9de3a0a8..6d8ab369 100644
--- a/tests/integration/overage-settings.integration.test.ts
+++ b/tests/integration/overage-settings.integration.test.ts
@@ -54,8 +54,8 @@ describe('overage settings API', () => {
expect(result.overageSettings).toEqual({ ai_messages: true, cdn_bandwidth: false })
expect(result.categories).toBeInstanceOf(Array)
- // Priced overage only: CDN and media are hard limits with no overage price.
- expect(result.categories.length).toBe(4)
+ // Every priced overage, CDN origin transfer and media storage included.
+ expect(result.categories.length).toBe(6)
const aiCategory = result.categories.find((c: { settingsKey: string }) => c.settingsKey === 'ai_messages')
expect(aiCategory).toMatchObject({
@@ -134,21 +134,29 @@ describe('overage settings API', () => {
})
})
- it('refuses to enable a limit that is not sold', async () => {
- // CDN bandwidth and media storage are hard limits: the meter counts
- // bytes, so Polar cannot carry the plan's gigabyte allowance and
- // overage would bill the included gigabytes as well.
- mockDb()
+ it('turns CDN and storage overage on when the subscription prices their meters', async () => {
+ mockDb({ paymentAccount: { plugin_metadata: { billable_meters: ['ai_credits_1c', 'cdn_origin_gb', 'media_storage_gb_months'] } } })
+ vi.stubGlobal('readBody', vi.fn().mockResolvedValue({ cdn_bandwidth: true, media_storage: true }))
+
+ const handler = (await import('../../server/api/workspaces/[workspaceId]/overage-settings.patch.ts')).default
+ await expect(handler({} as never)).resolves.toEqual({ overageSettings: { cdn_bandwidth: true, media_storage: true } })
+ })
+
+ it('refuses CDN overage on a subscription that has no price for it yet', async () => {
+ // Polar keeps a subscription's prices: one created before CDN overage
+ // was sold would serve the overage and never invoice it.
+ mockDb({ paymentAccount: { plugin_metadata: { billable_meters: ['ai_credits_1c', 'api_credits_1c', 'mcp_calls', 'form_submissions'] } } })
vi.stubGlobal('readBody', vi.fn().mockResolvedValue({ cdn_bandwidth: true }))
const handler = (await import('../../server/api/workspaces/[workspaceId]/overage-settings.patch.ts')).default
- await expect(handler({} as never)).rejects.toMatchObject({ statusCode: 409 })
+ await expect(handler({} as never)).rejects.toMatchObject({
+ statusCode: 409,
+ data: { code: 'overage_locked', reason: 'not_in_subscription' },
+ })
expect(updateWorkspace).not.toHaveBeenCalled()
})
- it('still lets a stale hard-limit toggle be turned off', async () => {
- // Refusing `false` too would trap a workspace that has a stale
- // `true` stored from before the limit became hard.
+ it('lets a CDN toggle be turned off', async () => {
mockDb({ workspace: { overage_settings: { cdn_bandwidth: true } } })
vi.stubGlobal('readBody', vi.fn().mockResolvedValue({ cdn_bandwidth: false }))
diff --git a/tests/unit/cdn-delivery-cache.test.ts b/tests/unit/cdn-delivery-cache.test.ts
index 4753f1d8..332a5971 100644
--- a/tests/unit/cdn-delivery-cache.test.ts
+++ b/tests/unit/cdn-delivery-cache.test.ts
@@ -171,4 +171,34 @@ describe('cdn delivery cache', () => {
expect(await cachedWorkspacePlan('ws-1')).toEqual({ plan: 'pro' })
expect(getWorkspaceById).toHaveBeenCalledOnce()
})
+
+ it('turns off an overage toggle the subscription cannot bill, so it never raises the delivery cap', async () => {
+ // CDN overage is billed per GB on `cdn_origin_gb`; a subscription that does
+ // not price it (an older one, or a stale `true`) must stay at the plan limit.
+ vi.doMock('../../server/utils/deployment', () => ({ resolveDeployment: () => ({ planSource: 'subscription' }) }))
+ const getWorkspaceById = vi.fn().mockResolvedValue({ plan: 'pro', overage_settings: { cdn_bandwidth: true, ai_messages: true } })
+ const getActivePaymentAccount = vi.fn().mockResolvedValue({
+ subscription_status: 'active',
+ plugin_metadata: { billable_meters: ['ai_credits_1c', 'api_credits_1c', 'mcp_calls', 'form_submissions'] },
+ })
+ vi.stubGlobal('useDatabaseProvider', vi.fn().mockReturnValue({ getWorkspaceById, getActivePaymentAccount }))
+
+ const { cachedWorkspacePlan } = await loadCacheModule()
+ expect(await cachedWorkspacePlan('ws-locked')).toEqual({ plan: 'pro', overage_settings: { cdn_bandwidth: false, ai_messages: true } })
+ vi.doUnmock('../../server/utils/deployment')
+ })
+
+ it('keeps a CDN toggle the subscription prices, and fails closed when billing is unreadable', async () => {
+ vi.doMock('../../server/utils/deployment', () => ({ resolveDeployment: () => ({ planSource: 'subscription' }) }))
+ const getWorkspaceById = vi.fn().mockResolvedValue({ plan: 'pro', overage_settings: { cdn_bandwidth: true } })
+ const getActivePaymentAccount = vi.fn()
+ .mockResolvedValueOnce({ subscription_status: 'active', plugin_metadata: { billable_meters: ['cdn_origin_gb'] } })
+ .mockRejectedValueOnce(new Error('db down'))
+ vi.stubGlobal('useDatabaseProvider', vi.fn().mockReturnValue({ getWorkspaceById, getActivePaymentAccount }))
+
+ const { cachedWorkspacePlan } = await loadCacheModule()
+ expect(await cachedWorkspacePlan('ws-priced')).toEqual({ plan: 'pro', overage_settings: { cdn_bandwidth: true } })
+ expect(await cachedWorkspacePlan('ws-unreadable')).toEqual({ plan: 'pro', overage_settings: { cdn_bandwidth: false } })
+ vi.doUnmock('../../server/utils/deployment')
+ })
})
diff --git a/tests/unit/cdn-origin-budget.test.ts b/tests/unit/cdn-origin-budget.test.ts
index 5f3cae5a..b77d4c5a 100644
--- a/tests/unit/cdn-origin-budget.test.ts
+++ b/tests/unit/cdn-origin-budget.test.ts
@@ -37,6 +37,15 @@ describe('CDN origin budget', () => {
expect(result).toEqual({ allowed: false, retryAfterSeconds: 7.5 * 24 * 3600 })
})
+ it('with overage on, stops at the ceiling it is given, with no buffer past it', async () => {
+ // Overage on: the route passes the abuse ceiling (10x the plan) and hardStopRatio 1.
+ const { checkCdnOriginBudget } = await load('enforce')
+ db.getWorkspaceMonthlyCDNBandwidth.mockResolvedValue(599 * GIB)
+ expect(await checkCdnOriginBudget({ workspaceId: 'below', limitGb: 600, now: NOW, hardStopRatio: 1 })).toEqual({ allowed: true })
+ db.getWorkspaceMonthlyCDNBandwidth.mockResolvedValue(600 * GIB)
+ expect(await checkCdnOriginBudget({ workspaceId: 'at', limitGb: 600, now: NOW, hardStopRatio: 1 })).toMatchObject({ allowed: false })
+ })
+
it('counts served bytes on top of the seed', async () => {
const { addCdnOriginBytes, checkCdnOriginBudget } = await load('enforce')
db.getWorkspaceMonthlyCDNBandwidth.mockResolvedValue(GIB)
diff --git a/tests/unit/license-content-parity.test.ts b/tests/unit/license-content-parity.test.ts
index 9ecff37e..c34c3d91 100644
--- a/tests/unit/license-content-parity.test.ts
+++ b/tests/unit/license-content-parity.test.ts
@@ -90,14 +90,8 @@ const REQUIRED_OVERAGE_KEYS = [
'ai.messages_per_month',
'api.messages_per_month',
'api.mcp_calls_per_month',
- 'forms.submissions_per_month',
-] as const
-
-// Hard limits: the meter cannot bill past an allowance yet
-// (`overageBillable: false`, usage-meters.ts), so the catalogue lists no
-// overage price for them. Selling it is a founder decision (#437).
-const UNSOLD_OVERAGE_KEYS = [
'cdn.bandwidth_gb',
+ 'forms.submissions_per_month',
'media.storage_gb',
] as const
@@ -304,10 +298,6 @@ describe('license ↔ content parity', () => {
expect(OVERAGE_PRICING[key]).toBeDefined()
})
- it.each(UNSOLD_OVERAGE_KEYS)('lists no overage price for the hard limit "%s"', (key) => {
- expect(OVERAGE_PRICING[key]).toBeUndefined()
- })
-
it('pins canonical unit prices (catalog v2; pre-v2 products keep theirs, credit-unit.ts)', () => {
// $0.08 ≈ 2.7x AI_CREDIT_UNIT_USD ($0.03) — above the SS-14
// profit-policy floor (overage price >= 2x marginal cost) with
@@ -321,14 +311,20 @@ describe('license ↔ content parity', () => {
expect(OVERAGE_PRICING['ai.messages_per_month']!.price).toBe(0.025)
expect(OVERAGE_PRICING['api.messages_per_month']!.price).toBe(0.025)
expect(OVERAGE_PRICING['api.mcp_calls_per_month']!.price).toBe(0.001)
+ // CDN origin transfer and media storage (founder, 2026-10-09): at least
+ // 2x what each costs Studio — Railway egress, R2 storage.
+ expect(OVERAGE_PRICING['cdn.bandwidth_gb']!.price).toBe(0.15)
expect(OVERAGE_PRICING['forms.submissions_per_month']!.price).toBe(0.01)
+ expect(OVERAGE_PRICING['media.storage_gb']!.price).toBe(0.25)
})
it('pins canonical JSONB settings keys', () => {
expect(OVERAGE_PRICING['ai.messages_per_month']!.settingsKey).toBe('ai_messages')
expect(OVERAGE_PRICING['api.messages_per_month']!.settingsKey).toBe('api_messages')
expect(OVERAGE_PRICING['api.mcp_calls_per_month']!.settingsKey).toBe('mcp_calls')
+ expect(OVERAGE_PRICING['cdn.bandwidth_gb']!.settingsKey).toBe('cdn_bandwidth')
expect(OVERAGE_PRICING['forms.submissions_per_month']!.settingsKey).toBe('form_submissions')
+ expect(OVERAGE_PRICING['media.storage_gb']!.settingsKey).toBe('media_storage')
})
it('OVERAGE_SETTINGS_KEYS is kept in sync with OVERAGE_PRICING', () => {
diff --git a/tests/unit/media-storage-meter.test.ts b/tests/unit/media-storage-meter.test.ts
new file mode 100644
index 00000000..5412c77c
--- /dev/null
+++ b/tests/unit/media-storage-meter.test.ts
@@ -0,0 +1,77 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const db = vi.hoisted(() => ({
+ listWorkspaceMediaStorageBytes: vi.fn(),
+ getActivePaymentAccount: vi.fn(),
+}))
+const recordMediaStorageDay = vi.hoisted(() => vi.fn().mockResolvedValue(undefined))
+vi.mock('../../server/utils/providers', () => ({ useDatabaseProvider: () => db }))
+vi.mock('../../server/utils/usage-metering', () => ({ recordMediaStorageDay }))
+
+const GB = 1024 ** 3
+
+/**
+ * Media storage is a level, so it reaches the payment meter only by being
+ * sampled: one `media_storage_gb_months` event per paying workspace per UTC
+ * day, carrying GB ÷ the days in its billing period.
+ */
+describe('daily media storage meter', () => {
+ beforeEach(() => {
+ vi.stubGlobal('defineNitroPlugin', (fn: unknown) => fn)
+ db.listWorkspaceMediaStorageBytes.mockReset()
+ db.getActivePaymentAccount.mockReset()
+ recordMediaStorageDay.mockClear()
+ })
+
+ it('samples each paying workspace once for the day, with its billing period length', async () => {
+ const { meterMediaStorageDay } = await import('../../server/plugins/media-storage-meter')
+ const now = new Date('2026-10-09T00:40:00Z')
+ db.listWorkspaceMediaStorageBytes.mockResolvedValue([
+ { workspaceId: 'ws-paid', bytes: 30 * GB },
+ { workspaceId: 'ws-no-customer', bytes: 2 * GB },
+ ])
+ db.getActivePaymentAccount.mockImplementation(async (id: string) => id === 'ws-paid'
+ ? { customer_id: 'cus_1', subscription_status: 'active', current_period_start: '2026-09-21T10:00:00Z', current_period_end: '2026-10-21T10:00:00Z' }
+ : { customer_id: null })
+
+ expect(await meterMediaStorageDay(now)).toBe(1)
+ expect(recordMediaStorageDay).toHaveBeenCalledOnce()
+ expect(recordMediaStorageDay).toHaveBeenCalledWith({ workspaceId: 'ws-paid', day: '2026-10-09', bytes: 30 * GB, periodDays: 30 })
+ })
+
+ it('uses the calendar month for an account without a billing period', async () => {
+ const { meterMediaStorageDay } = await import('../../server/plugins/media-storage-meter')
+ db.listWorkspaceMediaStorageBytes.mockResolvedValue([{ workspaceId: 'ws-1', bytes: GB }])
+ db.getActivePaymentAccount.mockResolvedValue({ customer_id: 'cus_1', subscription_status: null })
+
+ await meterMediaStorageDay(new Date('2026-02-10T03:00:00Z'))
+ expect(recordMediaStorageDay).toHaveBeenCalledWith(expect.objectContaining({ periodDays: 28 }))
+ })
+
+ it('skips one unreadable account and meters the rest', async () => {
+ const error = vi.spyOn(console, 'error').mockImplementation(() => {})
+ const { meterMediaStorageDay } = await import('../../server/plugins/media-storage-meter')
+ db.listWorkspaceMediaStorageBytes.mockResolvedValue([{ workspaceId: 'ws-bad', bytes: GB }, { workspaceId: 'ws-ok', bytes: GB }])
+ db.getActivePaymentAccount.mockImplementation(async (id: string) => {
+ if (id === 'ws-bad') throw new Error('connection reset')
+ return { customer_id: 'cus_2' }
+ })
+
+ expect(await meterMediaStorageDay(new Date('2026-10-09T00:40:00Z'))).toBe(1)
+ expect(recordMediaStorageDay).toHaveBeenCalledWith(expect.objectContaining({ workspaceId: 'ws-ok' }))
+ error.mockRestore()
+ })
+
+ it('lets a failed storage listing reject: no day is recorded as empty', async () => {
+ const { meterMediaStorageDay } = await import('../../server/plugins/media-storage-meter')
+ db.listWorkspaceMediaStorageBytes.mockRejectedValue(new Error('db down'))
+ await expect(meterMediaStorageDay(new Date())).rejects.toThrow('db down')
+ expect(recordMediaStorageDay).not.toHaveBeenCalled()
+ })
+
+ it('counts a period in days, never less than one', async () => {
+ const { periodDays } = await import('../../server/plugins/media-storage-meter')
+ expect(periodDays({ startsAt: '2026-10-01T00:00:00Z', resetsAt: '2026-11-01T00:00:00Z' })).toBe(31)
+ expect(periodDays({ startsAt: '2026-10-01T00:00:00Z', resetsAt: '2026-10-01T00:00:00Z' })).toBe(1)
+ })
+})
diff --git a/tests/unit/overage-byte-meters.test.ts b/tests/unit/overage-byte-meters.test.ts
new file mode 100644
index 00000000..f0b39592
--- /dev/null
+++ b/tests/unit/overage-byte-meters.test.ts
@@ -0,0 +1,58 @@
+import { describe, expect, it } from 'vitest'
+import { OVERAGE_ABUSE_CEILING_RATIO, getEffectiveLimit, isOverageEnabled, isOverageSellable, overageCeiling } from '../../server/utils/overage'
+import { USAGE_METERS, USAGE_METER_LIST } from '../../shared/utils/usage-meters'
+
+/**
+ * CDN origin transfer and media storage are sold past the plan (founder,
+ * 2026-10-09): billed per GB / per GB-month once the workspace turns the
+ * switch on, and bounded by an abuse ceiling of 10x the plan. With the
+ * switch off the limit holds as before.
+ */
+describe('CDN and storage overage', () => {
+ const SOFT_CAP_MAX = 2_147_483_647
+ const GB = 1024 ** 3
+ const bothOn = { cdn_bandwidth: true, media_storage: true, ai_messages: true }
+
+ it('sells every metered limit', () => {
+ for (const key of ['cdn.bandwidth_gb', 'media.storage_gb', 'ai.messages_per_month', 'api.messages_per_month', 'api.mcp_calls_per_month', 'forms.submissions_per_month'])
+ expect(isOverageSellable(key), key).toBe(true)
+ })
+
+ it('derives that from the meter manifest, not a second list', () => {
+ expect(USAGE_METER_LIST.every(m => m.overageBillable)).toBe(true)
+ expect(USAGE_METERS.CDN_ORIGIN_GB.overageBillable).toBe(true)
+ expect(USAGE_METERS.MEDIA_STORAGE_GB_MONTHS.overageBillable).toBe(true)
+ })
+
+ it('raises the cap to the abuse ceiling, not unbounded', () => {
+ expect(OVERAGE_ABUSE_CEILING_RATIO['cdn.bandwidth_gb']).toBe(10)
+ expect(OVERAGE_ABUSE_CEILING_RATIO['media.storage_gb']).toBe(10)
+ expect(getEffectiveLimit(60, 'cdn.bandwidth_gb', bothOn)).toBe(600)
+ expect(getEffectiveLimit(3, 'cdn.bandwidth_gb', bothOn)).toBe(30)
+ expect(getEffectiveLimit(25 * GB, 'media.storage_gb', bothOn)).toBe(250 * GB)
+ expect(isOverageEnabled('cdn.bandwidth_gb', bothOn)).toBe(true)
+ expect(isOverageEnabled('media.storage_gb', bothOn)).toBe(true)
+ })
+
+ it('keeps the plan limit while the switch is off', () => {
+ expect(getEffectiveLimit(60, 'cdn.bandwidth_gb', {})).toBe(60)
+ expect(getEffectiveLimit(5 * GB, 'media.storage_gb', { ai_messages: true })).toBe(5 * GB)
+ })
+
+ it('leaves the other meters on the int32 soft cap', () => {
+ expect(getEffectiveLimit(350, 'ai.messages_per_month', bothOn)).toBe(SOFT_CAP_MAX)
+ expect(getEffectiveLimit(350, 'ai.messages_per_month', {})).toBe(350)
+ })
+
+ it('leaves an unlimited plan unlimited', () => {
+ // Enterprise: the soft cap keeps the RPC integer-typed.
+ expect(getEffectiveLimit(Infinity, 'cdn.bandwidth_gb', {})).toBe(SOFT_CAP_MAX)
+ expect(overageCeiling(Infinity, 'cdn.bandwidth_gb')).toBe(Infinity)
+ })
+
+ it('names the ceiling in the limit unit, and none where there is none', () => {
+ expect(overageCeiling(60, 'cdn.bandwidth_gb')).toBe(600)
+ expect(overageCeiling(25, 'media.storage_gb')).toBe(250)
+ expect(overageCeiling(350, 'ai.messages_per_month')).toBe(Infinity)
+ })
+})
diff --git a/tests/unit/overage-hard-limits.test.ts b/tests/unit/overage-hard-limits.test.ts
deleted file mode 100644
index e30472e6..00000000
--- a/tests/unit/overage-hard-limits.test.ts
+++ /dev/null
@@ -1,53 +0,0 @@
-import { describe, expect, it } from 'vitest'
-import { getEffectiveLimit, isOverageEnabled, isOverageSellable } from '../../server/utils/overage'
-import { USAGE_METERS } from '../../shared/utils/usage-meters'
-
-/**
- * Two meters cannot carry an included allowance: Polar caps a meter
- * credit at int32, and a gigabyte counted in bytes exceeds it. Selling
- * overage against an allowance that cannot be expressed would bill from
- * the first byte — the plan's included gigabytes among them. So those
- * limits are hard, and no stored toggle may raise them.
- */
-describe('limits that are not sold past the plan allowance', () => {
- const SOFT_CAP_MAX = 2_147_483_647
- const bothOn = { cdn_bandwidth: true, media_storage: true, ai_messages: true }
-
- it('marks exactly the byte meters unsellable', () => {
- expect(isOverageSellable('cdn.bandwidth_gb')).toBe(false)
- expect(isOverageSellable('media.storage_gb')).toBe(false)
-
- expect(isOverageSellable('ai.messages_per_month')).toBe(true)
- expect(isOverageSellable('api.messages_per_month')).toBe(true)
- expect(isOverageSellable('api.mcp_calls_per_month')).toBe(true)
- expect(isOverageSellable('forms.submissions_per_month')).toBe(true)
- })
-
- it('derives that from the meter manifest, not a second list', () => {
- expect(USAGE_METERS.CDN_ORIGIN_GB.overageBillable).toBe(false)
- expect(USAGE_METERS.MEDIA_STORAGE_BYTE_HOURS.overageBillable).toBe(false)
- expect(USAGE_METERS.AI_MESSAGES.overageBillable).toBe(true)
- })
-
- it('keeps the cap hard even when the workspace has the toggle on', () => {
- // A stale `true` in `overage_settings` — from before the limit became
- // hard, or set by an older client — must not raise the cap.
- expect(getEffectiveLimit(60, 'cdn.bandwidth_gb', bothOn)).toBe(60)
- expect(getEffectiveLimit(15, 'media.storage_gb', bothOn)).toBe(15)
- expect(isOverageEnabled('cdn.bandwidth_gb', bothOn)).toBe(false)
- expect(isOverageEnabled('media.storage_gb', bothOn)).toBe(false)
- })
-
- it('still raises the cap for the limits that are sold', () => {
- expect(getEffectiveLimit(350, 'ai.messages_per_month', bothOn)).toBe(SOFT_CAP_MAX)
- expect(isOverageEnabled('ai.messages_per_month', bothOn)).toBe(true)
- // And still stops at the plan limit when the toggle is off.
- expect(getEffectiveLimit(350, 'ai.messages_per_month', {})).toBe(350)
- })
-
- it('leaves an unlimited plan unlimited', () => {
- // Enterprise: the soft cap keeps the RPC integer-typed; a hard limit
- // must not turn that into Infinity or back into a real ceiling.
- expect(getEffectiveLimit(Infinity, 'cdn.bandwidth_gb', {})).toBe(SOFT_CAP_MAX)
- })
-})
diff --git a/tests/unit/overage-lock.test.ts b/tests/unit/overage-lock.test.ts
index 76049b9a..2843a1a9 100644
--- a/tests/unit/overage-lock.test.ts
+++ b/tests/unit/overage-lock.test.ts
@@ -14,8 +14,7 @@ describe('resolveOverageLocks', () => {
it('locks every toggle during a trial, until the trial ends', () => {
const locks = resolveOverageLocks({ subscription_status: 'trialing', trial_ends_at: '2026-09-29T07:36:51.653Z' })
expect(locks.ai_messages).toEqual({ reason: 'trialing', until: '2026-09-29T07:36:51.653Z' })
- // Only priced overage has a toggle to lock; CDN and media are hard limits (no overage price).
- expect(Object.keys(locks).toSorted()).toEqual(['ai_messages', 'api_messages', 'form_submissions', 'mcp_calls'])
+ expect(Object.keys(locks).toSorted()).toEqual(['ai_messages', 'api_messages', 'cdn_bandwidth', 'form_submissions', 'mcp_calls', 'media_storage'])
})
it('falls back to the period end when a trial has no recorded trial end', () => {
@@ -60,8 +59,7 @@ describe('resolveOverageLocks', () => {
plugin_metadata: { billable_meters: [] },
})
expect(locks.ai_messages).toEqual({ reason: 'yearly_plan', until: null })
- // Only priced overage has a toggle to lock; CDN and media are hard limits (no overage price).
- expect(Object.keys(locks).toSorted()).toEqual(['ai_messages', 'api_messages', 'form_submissions', 'mcp_calls'])
+ expect(Object.keys(locks).toSorted()).toEqual(['ai_messages', 'api_messages', 'cdn_bandwidth', 'form_submissions', 'mcp_calls', 'media_storage'])
})
it('keeps a monthly subscription with no price on the generic reason', () => {
diff --git a/tests/unit/overage.test.ts b/tests/unit/overage.test.ts
index 69ec6fc4..0acd4965 100644
--- a/tests/unit/overage.test.ts
+++ b/tests/unit/overage.test.ts
@@ -10,7 +10,9 @@ describe('OVERAGE_PRICING constant', () => {
'ai.messages_per_month',
'api.messages_per_month',
'api.mcp_calls_per_month',
+ 'cdn.bandwidth_gb',
'forms.submissions_per_month',
+ 'media.storage_gb',
]))
})
@@ -68,15 +70,25 @@ describe('getEffectiveLimit', () => {
expect(getEffectiveLimit(5000, 'api.mcp_calls_per_month', settings)).toBe(5000) // not in settings
})
- it('keeps the byte limits hard even with the toggle on', () => {
- // These two are not sold past the plan allowance — the meter counts
- // bytes and Polar cannot express a gigabyte allowance against it, so
- // selling overage would bill the included gigabytes too. A stale
- // `true` from before the limit became hard must not raise the cap.
+ it('raises CDN and storage to their abuse ceiling, not to the int32 soft cap', () => {
+ // Sold per GB past the plan, but bounded: 10x the plan, whatever is paid.
const settings = { cdn_bandwidth: true, media_storage: true }
- expect(getEffectiveLimit(2, 'cdn.bandwidth_gb', settings)).toBe(2)
- expect(getEffectiveLimit(1, 'media.storage_gb', settings)).toBe(1)
+ expect(getEffectiveLimit(60, 'cdn.bandwidth_gb', settings)).toBe(600)
+ expect(getEffectiveLimit(25, 'media.storage_gb', settings)).toBe(250)
+ // Off: the plan limit holds.
+ expect(getEffectiveLimit(60, 'cdn.bandwidth_gb', {})).toBe(60)
+ })
+
+ it('keeps a storage limit passed in bytes in bytes', () => {
+ // Every media caller passes the plan limit in bytes. The int32 soft cap
+ // read as bytes is 2 GiB — below Starter's 5 GB — so turning overage on
+ // would have shrunk the cap.
+ const GB = 1024 ** 3
+ const raised = getEffectiveLimit(5 * GB, 'media.storage_gb', { media_storage: true })
+ expect(raised).toBe(50 * GB)
+ expect(raised).toBeGreaterThan(5 * GB)
+ expect(raised).toBeGreaterThan(2_147_483_647)
})
it('returns plan limit for zero limits (free plan)', () => {
@@ -117,9 +129,10 @@ describe('isOverageEnabled', () => {
expect(isOverageEnabled('forms.submissions_per_month', { form_submissions: true })).toBe(true)
})
- it('stays false for the limits that are not sold', () => {
- expect(isOverageEnabled('cdn.bandwidth_gb', { cdn_bandwidth: true })).toBe(false)
- expect(isOverageEnabled('media.storage_gb', { media_storage: true })).toBe(false)
+ it('is true for CDN and storage when their toggle is on', () => {
+ expect(isOverageEnabled('cdn.bandwidth_gb', { cdn_bandwidth: true })).toBe(true)
+ expect(isOverageEnabled('media.storage_gb', { media_storage: true })).toBe(true)
+ expect(isOverageEnabled('cdn.bandwidth_gb', { media_storage: true })).toBe(false)
})
})
diff --git a/tests/unit/usage-alerts.test.ts b/tests/unit/usage-alerts.test.ts
index 07dbd88b..a0faa9f1 100644
--- a/tests/unit/usage-alerts.test.ts
+++ b/tests/unit/usage-alerts.test.ts
@@ -106,6 +106,35 @@ describe('usage alerts', () => {
expect(await runUsageAlerts(deps(stopped, stopMail))).toEqual([])
})
+ it('CDN with overage on: 100 % says it is billed, the 10x ceiling says it stopped — and offers an upgrade, not overage', async () => {
+ // Pro: 60 GB, overage on → billed past 60, stops at 600.
+ const billed = fakeDb({ cdn: 65 }, { overage_settings: { cdn_bandwidth: true } })
+ expect(await runUsageAlerts(deps(billed))).toEqual([
+ expect.objectContaining({ meter: 'cdn_bandwidth', threshold: 100, template: 'usage-overage-started' }),
+ ])
+
+ const ceiling = fakeDb({ cdn: 601 }, { overage_settings: { cdn_bandwidth: true } })
+ const mail = vi.fn().mockResolvedValue(undefined)
+ expect(await runUsageAlerts(deps(ceiling, mail))).toEqual([
+ expect.objectContaining({ meter: 'cdn_bandwidth', threshold: 120, template: 'usage-limit-reached' }),
+ ])
+ const html = mail.mock.calls[0]![0].html as string
+ expect(html).toContain('has stopped')
+ expect(html).not.toContain('allow overage')
+ })
+
+ it('storage with overage on stops at 10x the plan, and that alert re-arms well below the ceiling', async () => {
+ const at = (gb: number) => [{ id: 'ws-1', name: 'Lanista', slug: 'lanista', type: 'team', plan: 'pro', owner_id: 'owner-1', overage_settings: { media_storage: true }, media_storage_bytes: gb * 1024 ** 3 }]
+ const db = fakeDb({}, { overage_settings: { media_storage: true }, media_storage_bytes: 251 * 1024 ** 3 })
+ expect(await runUsageAlerts(deps(db))).toEqual([expect.objectContaining({ meter: 'media_storage', threshold: 120, periodKey: 'level' })])
+ db.listWorkspacesForUsageAlerts.mockResolvedValue(at(240)) // 96 % of the ceiling: stays claimed
+ await runUsageAlerts(deps(db))
+ expect(db.releaseUsageAlert).not.toHaveBeenCalledWith(expect.objectContaining({ threshold: 120 }))
+ db.listWorkspacesForUsageAlerts.mockResolvedValue(at(200)) // 80 %: re-armed
+ await runUsageAlerts(deps(db))
+ expect(db.releaseUsageAlert).toHaveBeenCalledWith({ workspaceId: 'ws-1', meter: 'media_storage', periodKey: 'level', threshold: 120 })
+ })
+
it('a failed send is retried on the next run', async () => {
const db = fakeDb({ ai: 1036 })
const failing = vi.fn().mockRejectedValueOnce(new Error('resend down')).mockResolvedValue(undefined)
@@ -194,7 +223,7 @@ describe('usage alerts', () => {
})
it('never plans an alert for an unavailable meter, whatever its numbers say', () => {
- const base = { limitKey: 'ai.messages_per_month', name: 'AI Credits', limit: 350, overageEnabled: false, overageSellable: true, overageLock: null, overageUnits: 0, overageUnitPrice: 0, overageAmount: 0, unit: 'credits', percentage: 120, resetsAt: null, resetBasis: null, periodKey: '2026-09-15' }
+ const base = { limitKey: 'ai.messages_per_month', name: 'AI Credits', limit: 350, overageEnabled: false, overageSellable: true, overageLock: null, overageUnits: 0, overageUnitPrice: 0, overageAmount: 0, overageCeiling: null, unit: 'credits', percentage: 120, resetsAt: null, resetBasis: null, periodKey: '2026-09-15' }
expect(planUsageAlerts([{ ...base, key: 'ai_messages', current: 420, unavailable: true }])).toEqual([])
expect(planUsageAlerts([{ ...base, key: 'ai_messages', current: 420 }])).toHaveLength(1)
})
diff --git a/tests/unit/usage-metering-units.test.ts b/tests/unit/usage-metering-units.test.ts
index 1230fd82..b99c0300 100644
--- a/tests/unit/usage-metering-units.test.ts
+++ b/tests/unit/usage-metering-units.test.ts
@@ -32,3 +32,49 @@ describe('usage metering — credit meters by unit', () => {
])
})
})
+
+/**
+ * The two byte meters send the unit the plan sells, so the plan's GB is the
+ * Polar allowance as is: CDN origin transfer in GB per day, storage in
+ * GB-months (a day's GB over the days in its period).
+ */
+describe('usage metering — CDN and storage in plan units', () => {
+ const GB = 1024 ** 3
+
+ beforeEach(() => {
+ enqueueUsageEvent.mockClear()
+ vi.stubGlobal('useDatabaseProvider', () => ({ enqueueUsageEvent }))
+ })
+
+ it('sends a day of CDN origin transfer in GB, keyed by workspace and day', async () => {
+ const { recordCDNOriginUsage } = await import('../../server/utils/usage-metering')
+ await recordCDNOriginUsage({ workspaceId: 'ws', day: '2026-10-08', bytes: 1.5 * GB })
+ expect(enqueueUsageEvent).toHaveBeenCalledWith(expect.objectContaining({
+ meterName: 'cdn_origin_gb',
+ value: 1.5,
+ idempotencyKey: 'cdn-origin:ws:2026-10-08',
+ }))
+ })
+
+ it('sends a day of storage as GB over the period\'s days, keyed by workspace and day', async () => {
+ const { recordMediaStorageDay } = await import('../../server/utils/usage-metering')
+ await recordMediaStorageDay({ workspaceId: 'ws', day: '2026-10-08', bytes: 30 * GB, periodDays: 30 })
+ expect(enqueueUsageEvent).toHaveBeenCalledWith(expect.objectContaining({
+ meterName: 'media_storage_gb_months',
+ value: 1,
+ idempotencyKey: 'storage-gbm:ws:2026-10-08',
+ metadata: { day: '2026-10-08', bytes: 30 * GB, period_days: 30 },
+ }))
+ })
+
+ it('sums a full period of daily samples to the average GB stored (GB-months)', async () => {
+ const { storageGbMonthsForDay } = await import('../../server/utils/usage-metering')
+ // 31-day period: 10 days at 20 GB, 21 days at 40 GB → average 33.548 GB.
+ const days = [...Array.from({ length: 10 }, () => 20 * GB), ...Array.from({ length: 21 }, () => 40 * GB)]
+ const sum = days.reduce((total, bytes) => total + storageGbMonthsForDay(bytes, 31), 0)
+ expect(sum).toBeCloseTo((10 * 20 + 21 * 40) / 31, 4)
+ // Pro includes 25 GB-months; overage is billed on the rest at $0.25.
+ expect(Math.round((sum - 25) * 0.25 * 100) / 100).toBe(2.14)
+ expect(storageGbMonthsForDay(0, 30)).toBe(0)
+ })
+})
diff --git a/tests/unit/usage-meters-manifest.test.ts b/tests/unit/usage-meters-manifest.test.ts
index 7bdc5a8f..82187cb5 100644
--- a/tests/unit/usage-meters-manifest.test.ts
+++ b/tests/unit/usage-meters-manifest.test.ts
@@ -16,7 +16,7 @@ describe('usage meter manifest', () => {
expect(USAGE_METERS.AI_MESSAGES.aggregation).toBe('sum')
expect(USAGE_METERS.API_MESSAGES.aggregation).toBe('sum')
expect(USAGE_METERS.CDN_ORIGIN_GB.aggregation).toBe('sum')
- expect(USAGE_METERS.MEDIA_STORAGE_BYTE_HOURS.aggregation).toBe('sum')
+ expect(USAGE_METERS.MEDIA_STORAGE_GB_MONTHS.aggregation).toBe('sum')
// These two always carry value 1, so counting and summing agree.
expect(USAGE_METERS.MCP_CALLS.aggregation).toBe('count')
expect(USAGE_METERS.FORM_SUBMISSIONS.aggregation).toBe('count')
@@ -31,10 +31,13 @@ describe('usage meter manifest', () => {
expect(USAGE_METERS.API_MESSAGES.name).toBe('api_credits_1c')
})
- it('converts a gigabyte limit into the byte unit the storage meter sums', () => {
- // The plan limit is in GB; the meter sums raw bytes. Sending the limit
- // straight through would include two *bytes* on a 2 GB plan.
- expect(USAGE_METERS.MEDIA_STORAGE_BYTE_HOURS.unitsPerLimitUnit).toBe(1024 ** 3)
+ it('meters storage in the unit the plan sells: GB-months', () => {
+ // The byte-hour meter could not carry a gigabyte allowance (int32 meter
+ // credit) and never received an event; the GB-month meter's allowance
+ // is the plan value as is.
+ expect(USAGE_METERS.MEDIA_STORAGE_GB_MONTHS.name).toBe('media_storage_gb_months')
+ expect(USAGE_METERS.MEDIA_STORAGE_GB_MONTHS.unitsPerLimitUnit).toBe(1)
+ expect(USAGE_METER_LIST.some(m => m.name === 'media_storage_byte_hours')).toBe(false)
})
it('meters CDN origin transfer in the unit the plan sells: gigabytes', () => {
@@ -47,16 +50,21 @@ describe('usage meter manifest', () => {
expect(starterGb * USAGE_METERS.CDN_ORIGIN_GB.unitsPerLimitUnit).toBe(starterGb)
})
- it('keeps every other meter at parity with its limit', () => {
+ it('keeps every billable meter at parity with its limit', () => {
+ // A billable meter's allowance is the plan value; a meter counting a
+ // smaller unit cannot carry it (int32 meter credit) and would bill from
+ // the first unit.
for (const meter of USAGE_METER_LIST) {
- if (meter.unitLabel === 'byte' || meter.unitLabel === 'byte·hour') continue
- expect(meter.unitsPerLimitUnit).toBe(1)
+ if (meter.overageBillable) expect(meter.unitsPerLimitUnit).toBe(1)
}
})
- it('meters CDN per gigabyte, so a future per-GB overage price maps to Polar 1:1', () => {
- // What the sync would write as Polar's `unit_amount` (cents per meter unit).
- expect(USAGE_METERS.CDN_ORIGIN_GB.unitsPerLimitUnit).toBe(1)
+ it('sells CDN and storage overage, priced per plan unit', () => {
+ // What the sync writes as Polar's `unit_amount` (cents per meter unit).
+ expect(USAGE_METERS.CDN_ORIGIN_GB.overageBillable).toBe(true)
+ expect(USAGE_METERS.MEDIA_STORAGE_GB_MONTHS.overageBillable).toBe(true)
+ expect(OVERAGE_PRICING['cdn.bandwidth_gb']!.price * 100 / USAGE_METERS.CDN_ORIGIN_GB.unitsPerLimitUnit).toBe(15)
+ expect(OVERAGE_PRICING['media.storage_gb']!.price * 100 / USAGE_METERS.MEDIA_STORAGE_GB_MONTHS.unitsPerLimitUnit).toBe(25)
})
it('keeps every settings key distinct; a billable meter has a priced limit, a hard limit none', () => {
diff --git a/tests/unit/workspace-usage-byte-overage.test.ts b/tests/unit/workspace-usage-byte-overage.test.ts
new file mode 100644
index 00000000..5715a345
--- /dev/null
+++ b/tests/unit/workspace-usage-byte-overage.test.ts
@@ -0,0 +1,65 @@
+import { describe, expect, it, vi } from 'vitest'
+import { computeWorkspaceUsage } from '../../server/utils/workspace-usage'
+import { usagePeriodFrom } from '../../server/utils/usage-period'
+
+/**
+ * CDN origin transfer ($0.15/GB) and media storage ($0.25/GB-month) are
+ * charged past the plan once overage is on. The usage screen quotes the same
+ * amount the meter bills: units past the plan × the catalogue price.
+ */
+const NOW = new Date('2026-09-23T12:00:00Z')
+const GB = 1024 ** 3
+
+function db(cdnGb: number) {
+ return {
+ getWorkspaceMonthlyAIUsage: vi.fn().mockResolvedValue(0),
+ getWorkspaceMonthlyAPIUsage: vi.fn().mockResolvedValue(0),
+ countMonthlySubmissions: vi.fn().mockResolvedValue(0),
+ getWorkspaceMonthlyCDNBandwidth: vi.fn().mockResolvedValue(cdnGb * GB),
+ getWorkspaceMonthlyMcpCloudUsage: vi.fn().mockResolvedValue(0),
+ countMonthlyComments: vi.fn().mockResolvedValue(0),
+ }
+}
+
+const base = { workspaceId: 'ws-1', plan: 'pro', period: usagePeriodFrom(null, NOW), now: NOW }
+
+describe('CDN and storage overage charge', () => {
+ it('charges each GB past the plan at the catalogue price when overage is on', async () => {
+ // Pro: 60 GB CDN, 25 GB storage.
+ const usage = await computeWorkspaceUsage(db(70) as never, {
+ ...base,
+ overageSettings: { cdn_bandwidth: true, media_storage: true },
+ storageBytes: 30 * GB,
+ })
+ const cdn = usage.categories.find(c => c.key === 'cdn_bandwidth')!
+ const storage = usage.categories.find(c => c.key === 'media_storage')!
+ expect(cdn).toMatchObject({ overageSellable: true, overageEnabled: true, overageUnits: 10, overageUnitPrice: 0.15, overageAmount: 1.5, overageCeiling: 600 })
+ expect(storage).toMatchObject({ overageSellable: true, overageEnabled: true, overageUnits: 5, overageUnitPrice: 0.25, overageAmount: 1.25, overageCeiling: 250 })
+ expect(usage.totalOverageAmount).toBe(2.75)
+ // Storage is a level: projected at today's level. CDN is projected over its window.
+ expect(usage.projectedOverageAmount).toBeGreaterThan(2.75)
+ })
+
+ it('quotes nothing while overage is off, but shows the price and ceiling before it is turned on', async () => {
+ const usage = await computeWorkspaceUsage(db(70) as never, { ...base, overageSettings: {}, storageBytes: 30 * GB })
+ const cdn = usage.categories.find(c => c.key === 'cdn_bandwidth')!
+ expect(cdn).toMatchObject({ overageSellable: true, overageEnabled: false, overageUnits: 0, overageAmount: 0, overageUnitPrice: 0.15, overageCeiling: 600 })
+ expect(usage.totalOverageAmount).toBe(0)
+ })
+
+ it('quotes nothing for a toggle the subscription cannot bill yet', async () => {
+ const usage = await computeWorkspaceUsage(db(70) as never, {
+ ...base,
+ overageSettings: { cdn_bandwidth: true },
+ storageBytes: 0,
+ overageLocks: { cdn_bandwidth: { reason: 'not_in_subscription', until: null } },
+ })
+ const cdn = usage.categories.find(c => c.key === 'cdn_bandwidth')!
+ expect(cdn).toMatchObject({ overageEnabled: false, overageAmount: 0 })
+ })
+
+ it('has no ceiling on the meters that are not bounded', async () => {
+ const usage = await computeWorkspaceUsage(db(0) as never, { ...base, overageSettings: {}, storageBytes: 0 })
+ expect(usage.categories.find(c => c.key === 'ai_messages')!.overageCeiling).toBeNull()
+ })
+})
From de659ba4ba742be4d0cbf44e500138f38cf8917e Mon Sep 17 00:00:00 2001
From: AHMET BAYHAN BAYRAMOGLU <49499275+ABB65@users.noreply.github.com>
Date: Fri, 9 Oct 2026 23:43:38 +0300
Subject: [PATCH 2/4] test(billing): usage panel and banner show cdn and
storage overage, price and ceiling
---
.../usage-alert-banner.nuxt.test.ts | 7 ++++++
.../workspace-usage-panel.nuxt.test.ts | 22 +++++++++++++++++++
2 files changed, 29 insertions(+)
diff --git a/tests/nuxt/components/usage-alert-banner.nuxt.test.ts b/tests/nuxt/components/usage-alert-banner.nuxt.test.ts
index 5dcde021..fb7f0c9d 100644
--- a/tests/nuxt/components/usage-alert-banner.nuxt.test.ts
+++ b/tests/nuxt/components/usage-alert-banner.nuxt.test.ts
@@ -92,4 +92,11 @@ describe('UsageAlertBanner', () => {
expect((await mountSuspended(UsageAlertBanner)).text()).toContain('AI credits are used up')
spy.mockRestore()
})
+ it('with CDN overage on: no notice while billed past the limit, "stopped" at the ceiling', async () => {
+ const billed = category('cdn_bandwidth', 'CDN Bandwidth', 100, 60, '2026-10-01T00:00:00.000Z', { overageEnabled: true, overageCeiling: 600 })
+ state.usage = usage(billed)
+ expect((await mountSuspended(UsageAlertBanner)).text()).not.toContain('has stopped')
+ state.usage = usage({ ...billed, current: 600 })
+ expect((await mountSuspended(UsageAlertBanner)).text()).toContain('CDN delivery has stopped')
+ })
})
diff --git a/tests/nuxt/components/workspace-usage-panel.nuxt.test.ts b/tests/nuxt/components/workspace-usage-panel.nuxt.test.ts
index ce55cc1d..5c6e7ce0 100644
--- a/tests/nuxt/components/workspace-usage-panel.nuxt.test.ts
+++ b/tests/nuxt/components/workspace-usage-panel.nuxt.test.ts
@@ -97,4 +97,26 @@ describe('WorkspaceUsagePanel', () => {
// The other meters still show their numbers.
expect(text).toContain('$0.08 per credit past the limit')
})
+ it('sells CDN and storage overage: price, ceiling and how each is measured, before the switch is on', async () => {
+ state.usage = { ...staging(true), categories: [
+ category('cdn_bandwidth', 'cdn.bandwidth_gb', 'CDN Bandwidth', 5.3, 60, 'GB', '2026-10-01T00:00:00.000Z', { overageUnitPrice: 0.15, overageCeiling: 600, resetBasis: 'calendar' }),
+ category('media_storage', 'media.storage_gb', 'Media Storage', 1.9, 25, 'GB', null, { overageUnitPrice: 0.25, overageCeiling: 250 }),
+ ] }
+ const text = (await mountSuspended(WorkspaceUsagePanel, { props: { workspaceId: 'ws-1' } })).text()
+ expect(text).toContain('$0.15 per GB past the limit')
+ expect(text).toContain('$0.25 per GB/month past the limit')
+ expect(text).toContain('Continues past the plan up to 600 GB, then stops')
+ expect(text).toContain('Continues past the plan up to 250 GB, then stops')
+ expect(text).toContain('Responses served from the Cloudflare cache are not counted.')
+ expect(text).toContain('billed on the average stored over the billing period')
+ expect(text).not.toContain('Fixed limit')
+ })
+
+ it('says a meter stopped at its ceiling even with overage on', async () => {
+ state.usage = { ...staging(true), categories: [
+ category('cdn_bandwidth', 'cdn.bandwidth_gb', 'CDN Bandwidth', 600, 60, 'GB', '2026-10-01T00:00:00.000Z', { overageEnabled: true, overageUnitPrice: 0.15, overageUnits: 540, overageAmount: 81, overageCeiling: 600 }),
+ ] }
+ const text = (await mountSuspended(WorkspaceUsagePanel, { props: { workspaceId: 'ws-1' } })).text()
+ expect(text).toContain('CDN Bandwidth reached its 600 GB ceiling and has stopped.')
+ })
})
From f8b4762800b2119ae46dfb6ca70ec7ba03e18a1d Mon Sep 17 00:00:00 2001
From: AHMET BAYHAN BAYRAMOGLU <49499275+ABB65@users.noreply.github.com>
Date: Fri, 9 Oct 2026 23:47:25 +0300
Subject: [PATCH 3/4] docs(cdn): overage-on alert levels and the 10x ceiling
---
docs/CDN_EDGE.md | 6 ++++++
.../api/workspaces/[workspaceId]/overage-settings.patch.ts | 6 +++---
2 files changed, 9 insertions(+), 3 deletions(-)
diff --git a/docs/CDN_EDGE.md b/docs/CDN_EDGE.md
index 8d1d375e..d3ce6e8b 100644
--- a/docs/CDN_EDGE.md
+++ b/docs/CDN_EDGE.md
@@ -103,6 +103,12 @@ once per workspace and month at each level:
The in-app banner shows the same levels. The buffer caps what Studio pays past
a plan at 20 % of its transfer (`shared/utils/cdn-limit.ts`).
+With overage on (Billing › Usage, needs a subscription that prices
+`cdn_origin_gb`) there is no unbilled buffer: past the plan every GB of origin
+transfer is billed at the catalogue price, the 100 % mail says so, and delivery
+stops only at the abuse ceiling, 10× the plan (`OVERAGE_ABUSE_CEILING_RATIO`,
+`server/utils/overage.ts`), which sends the "stopped" mail.
+
Rollout:
1. Do the Cloudflare steps above. With the cache in front, a normal site stays
far below its origin limit.
diff --git a/server/api/workspaces/[workspaceId]/overage-settings.patch.ts b/server/api/workspaces/[workspaceId]/overage-settings.patch.ts
index 0300fda8..c7f6e327 100644
--- a/server/api/workspaces/[workspaceId]/overage-settings.patch.ts
+++ b/server/api/workspaces/[workspaceId]/overage-settings.patch.ts
@@ -28,9 +28,9 @@ function lockedError(lock: OverageLock) {
}
const LIMIT_KEY_BY_SETTINGS_KEY: Record = Object.fromEntries([
- // Hard limits (CDN, media) carry no overage price, so they are not in
- // OVERAGE_PRICING; their meter still names the toggle, so a stale `true`
- // can be turned off and turning one on is refused as "not sold".
+ // A meter whose limit carries no overage price is not in OVERAGE_PRICING;
+ // it still names the toggle, so a stale `true` can be turned off and
+ // turning one on is refused as "not sold".
...USAGE_METER_LIST.map(m => [m.settingsKey, m.limitKey]),
...Object.entries(OVERAGE_PRICING).map(([limitKey, pricing]) => [pricing.settingsKey, limitKey]),
])
From 1900a767e7c4fa20360fb6b72935bf8e8f3b56bb Mon Sep 17 00:00:00 2001
From: AHMET BAYHAN BAYRAMOGLU <49499275+ABB65@users.noreply.github.com>
Date: Sat, 10 Oct 2026 00:11:12 +0300
Subject: [PATCH 4/4] fix(billing): cdn and storage overage only where it is
metered and priced
- bulk media ingest (bulk-ingest route, Migrate media import) drops
toggles the subscription cannot bill before raising the storage cap
- a payment account whose priced meters were never reported, or no
account at all, counts as locked for cdn and storage overage
(`requiresKnownPrice`); the credit and event meters keep the old rule
- cdn and storage overage is sellable only while its meter flag is on
(NUXT_CDN_ORIGIN_METER / NUXT_CDN_STORAGE_METER, `eventFlag`); flag off
is the hard limit of before, whatever is toggled
- the plan cards quote the cdn and storage price only once the usage
route says this deployment sells it
---
.env.example | 3 +-
.../organisms/PlanSelectionModal.vue | 13 ++++-
docs/CDN_EDGE.md | 7 ++-
server/utils/media-bulk-ingest.ts | 4 +-
server/utils/overage-lock.ts | 20 ++++++-
server/utils/overage.ts | 30 +++++++++-
shared/utils/usage-meters.ts | 19 +++++++
.../overage-settings.integration.test.ts | 13 +++++
...-selection-modal-byte-overage.nuxt.test.ts | 57 +++++++++++++++++++
tests/unit/migration-media.test.ts | 19 +++++++
tests/unit/overage-byte-meters.test.ts | 47 ++++++++++++++-
tests/unit/overage-lock.test.ts | 20 +++++--
tests/unit/overage.test.ts | 14 ++++-
tests/unit/usage-alerts.test.ts | 22 ++++++-
.../unit/workspace-usage-byte-overage.test.ts | 20 ++++++-
15 files changed, 289 insertions(+), 19 deletions(-)
create mode 100644 tests/nuxt/components/plan-selection-modal-byte-overage.nuxt.test.ts
diff --git a/.env.example b/.env.example
index 3c1ceed5..800ae095 100644
--- a/.env.example
+++ b/.env.example
@@ -150,7 +150,8 @@ NUXT_CDN_R2_BUCKET=contentrain-cdn
# NUXT_CDN_ORIGIN_LIMIT=enforce
# Daily cdn_origin_gb meter events (needs the meter in the payment provider)
# NUXT_CDN_ORIGIN_METER=false
-# Daily media_storage_gb_months meter events (needs the meter in the payment provider)
+# Daily media_storage_gb_months meter events (needs the meter in the payment provider).
+# CDN / storage overage is sold only while its meter flag is on; off = hard limit.
# NUXT_CDN_STORAGE_METER=false
# -----------------------------------------------------------------------------
diff --git a/app/components/organisms/PlanSelectionModal.vue b/app/components/organisms/PlanSelectionModal.vue
index 038a956a..94d60597 100644
--- a/app/components/organisms/PlanSelectionModal.vue
+++ b/app/components/organisms/PlanSelectionModal.vue
@@ -34,6 +34,13 @@ const emit = defineEmits<{
const toast = useToast()
const loading = ref(null)
+// Whether this deployment sells CDN and storage overage: their meters run on
+// a flag the server knows, and the usage route reports it as `overageSellable`.
+const { usage, fetchUsage } = useUsage()
+watch(() => props.open, (open) => {
+ if (open && !usage.value) fetchUsage().catch(() => {})
+}, { immediate: true })
+
const enterpriseMailto = computed(() => {
const subject = encodeURIComponent('Contentrain Studio — Enterprise inquiry')
return `mailto:${ENTERPRISE_CONTACT_EMAIL}?subject=${subject}`
@@ -123,12 +130,16 @@ interface LimitRow {
/**
* What a unit past the included amount costs, so the price is known before
* anyone chooses a plan. A limit whose meter is not billable
- * (`overageBillable: false`) shows nothing, whatever the data lists.
+ * (`overageBillable: false`) shows nothing, whatever the data lists, and a
+ * meter fed by a background job (CDN origin, storage: `eventFlag`) shows its
+ * price only once the server says this deployment sells it — never on a guess.
*/
function overageLabel(key: string, ownTerms = false): string | null {
const pricing = OVERAGE_PRICING[key]
if (!pricing) return null
if (USAGE_METER_LIST.some(m => m.limitKey === key && !m.overageBillable)) return null
+ if (USAGE_METER_LIST.some(m => m.limitKey === key && m.eventFlag)
+ && usage.value?.categories.find(c => c.limitKey === key)?.overageSellable !== true) return null
const price = ownTerms ? creditTermsFor(LEGACY_CREDIT_UNIT).overagePrice(key) ?? pricing.price : pricing.price
return t('plans.overage_then', { price: `$${price}`, unit: pricing.unit })
}
diff --git a/docs/CDN_EDGE.md b/docs/CDN_EDGE.md
index d3ce6e8b..3027be17 100644
--- a/docs/CDN_EDGE.md
+++ b/docs/CDN_EDGE.md
@@ -114,8 +114,11 @@ Rollout:
far below its origin limit.
2. Run polar-sync so the `cdn_origin_gb` meter exists, then set
`NUXT_CDN_ORIGIN_METER=true`. Overage is sold per GB of origin transfer
- (`overage_price` on `cdn.bandwidth_gb`); a subscription bills it once it
- prices `cdn_origin_gb`, until then its switch shows as locked.
+ (`overage_price` on `cdn.bandwidth_gb`), and only while this flag is on: with
+ it off no event reaches Polar, so the limit stays hard whatever a workspace
+ has toggled. A subscription bills it once it is known to price
+ `cdn_origin_gb` (`billable_meters`); until then — and for an account whose
+ priced meters were never reported — its switch shows as locked.
## Rollback
diff --git a/server/utils/media-bulk-ingest.ts b/server/utils/media-bulk-ingest.ts
index d19ad6ce..2e4eb209 100644
--- a/server/utils/media-bulk-ingest.ts
+++ b/server/utils/media-bulk-ingest.ts
@@ -32,6 +32,7 @@ import type { Plan } from './license'
import type { RemoteMedia } from './media-ingest'
import { fetchRemoteMedia } from './media-ingest'
import { getEffectiveLimit } from './overage'
+import { billableOverageSettings } from './workspace-billing'
import { resolveVariantConfigWithPlan } from './media-variants'
import { toDeliveryUrl } from './media-url'
@@ -146,7 +147,8 @@ export async function createMediaIngestContext(input: {
variantsPerFieldLimit: getPlanLimit(input.plan, 'media.variants_per_field'),
})
const workspace = await db.getWorkspaceById(input.workspaceId, 'id, overage_settings')
- const overageSettings = (workspace?.overage_settings as Record | null) ?? {}
+ // A toggle the subscription cannot bill does not raise the cap.
+ const overageSettings = await billableOverageSettings(db, input.workspaceId, workspace?.overage_settings)
const baseLimit = getPlanLimit(input.plan, 'media.storage_gb') * 1024 * 1024 * 1024
return {
diff --git a/server/utils/overage-lock.ts b/server/utils/overage-lock.ts
index 4918f4db..185cf1da 100644
--- a/server/utils/overage-lock.ts
+++ b/server/utils/overage-lock.ts
@@ -19,7 +19,9 @@
* the subscription's price list, and fires again whenever it changes). No
* list recorded means the provider never reported one: only the trial rule
* applies then, which keeps providers that do not report prices working as
- * before.
+ * before — except for overage that costs Studio per unit (CDN origin,
+ * storage: `requiresKnownPrice`), which stays locked until a list names its
+ * meter, as it does for a workspace with no payment account at all.
*
* A lock only ever stops the sale of usage *past* the plan limit; what the
* plan includes is never touched. It is also temporary by construction: a
@@ -110,10 +112,19 @@ export function readBillableMeters(pluginMetadata: unknown): string[] | null {
return [...new Set([...(own ?? []), ...companion])].toSorted()
}
+/** Locks every `requiresKnownPrice` toggle not already locked. */
+function lockUnknownPrices(locks: Record): Record {
+ for (const meter of USAGE_METER_LIST) {
+ if (meter.requiresKnownPrice && OVERAGE_SETTINGS_KEYS.includes(meter.settingsKey) && !locks[meter.settingsKey])
+ locks[meter.settingsKey] = { reason: 'not_in_subscription', until: null }
+ }
+ return locks
+}
+
/** Locked overage settings keys, each with why and until when. */
export function resolveOverageLocks(account: OverageLockAccount | null | undefined): Record {
const locks: Record = {}
- if (!account) return locks
+ if (!account) return lockUnknownPrices(locks)
if (account.subscription_status === 'trialing') {
const until = toIso(account.trial_ends_at) ?? toIso(account.current_period_end)
@@ -122,7 +133,10 @@ export function resolveOverageLocks(account: OverageLockAccount | null | undefin
}
const billable = readBillableMeters(account.plugin_metadata)
- if (!billable) return locks
+ // Never reported which meters the subscription prices: the credit and
+ // event meters keep working as before, but overage that costs Studio per
+ // unit (CDN, storage) is sold only once it is known to be priced.
+ if (!billable) return lockUnknownPrices(locks)
// Credit overage is priced on the meters of the subscription's own unit:
// a pre-v2 subscription prices `ai_credits`, a v2 one `ai_credits_1c`.
const creditMeters = creditTermsFor(creditUnitFromMeters(billable) ?? CURRENT_CREDIT_UNIT).meters
diff --git a/server/utils/overage.ts b/server/utils/overage.ts
index e92e5c47..ddf96384 100644
--- a/server/utils/overage.ts
+++ b/server/utils/overage.ts
@@ -10,6 +10,7 @@
import { OVERAGE_PRICING } from '../../shared/utils/license'
import { USAGE_METER_LIST } from '../../shared/utils/usage-meters'
+import type { UsageMeterDefinition } from '../../shared/utils/usage-meters'
/**
* Limits whose usage is not sold past the plan allowance, whatever the
@@ -23,9 +24,34 @@ const UNSELLABLE_LIMIT_KEYS: ReadonlySet = new Set(
USAGE_METER_LIST.filter(m => !m.overageBillable).map(m => m.limitKey),
)
-/** Whether usage past the plan limit may be sold for this limit at all. */
+/** limitKey → the runtime flag its meter's events need (`eventFlag`). */
+const EVENT_FLAG_BY_LIMIT_KEY: ReadonlyMap> = new Map(
+ USAGE_METER_LIST.filter(m => m.eventFlag).map(m => [m.limitKey, m.eventFlag!]),
+)
+
+/**
+ * Whether the meter's events are on. A meter fed by a background job
+ * (CDN origin, storage) sends nothing while its flag is off, so overage
+ * sold then would be served and never invoiced. Unreadable config = off.
+ */
+function meterEventsOn(limitKey: string): boolean {
+ const flag = EVENT_FLAG_BY_LIMIT_KEY.get(limitKey)
+ if (!flag) return true
+ try {
+ const value = (useRuntimeConfig().cdn as Record | undefined)?.[flag]
+ return value === true || value === 'true'
+ }
+ catch {
+ return false
+ }
+}
+
+/**
+ * Whether usage past the plan limit may be sold for this limit at all:
+ * the meter is billable and, where it has one, its event flag is on.
+ */
export function isOverageSellable(limitKey: string): boolean {
- return !UNSELLABLE_LIMIT_KEYS.has(limitKey)
+ return !UNSELLABLE_LIMIT_KEYS.has(limitKey) && meterEventsOn(limitKey)
}
/** Postgres INT max — used as soft cap when overage is enabled. */
diff --git a/shared/utils/usage-meters.ts b/shared/utils/usage-meters.ts
index 547b8161..c29f414a 100644
--- a/shared/utils/usage-meters.ts
+++ b/shared/utils/usage-meters.ts
@@ -51,6 +51,21 @@ export interface UsageMeterDefinition {
* from the first unit. Every meter in the manifest meets that today.
*/
readonly overageBillable: boolean
+ /**
+ * The `runtimeConfig.cdn` flag that turns this meter's events on, for a
+ * meter fed by a background job rather than by the request itself.
+ * While the flag is off no event reaches the payment provider, so the
+ * overage is not sold either: the limit stays hard, whatever the
+ * workspace has toggled.
+ */
+ readonly eventFlag?: 'originMeter' | 'storageMeter'
+ /**
+ * The overage costs Studio real money per unit (egress, storage), so it
+ * is sold only to a subscription known to price this meter: a payment
+ * account whose priced meters were never reported counts as locked for
+ * it, never as unlocked.
+ */
+ readonly requiresKnownPrice?: boolean
}
export const USAGE_METERS = {
@@ -110,6 +125,8 @@ export const USAGE_METERS = {
// Sold past the plan (founder, 2026-10-09): the allowance is the plan's
// GB as is, and each GB past it bills at the catalogue price.
overageBillable: true,
+ eventFlag: 'originMeter',
+ requiresKnownPrice: true,
},
FORM_SUBMISSIONS: {
name: 'form_submissions',
@@ -136,6 +153,8 @@ export const USAGE_METERS = {
aggregation: 'sum',
unitsPerLimitUnit: 1,
overageBillable: true,
+ eventFlag: 'storageMeter',
+ requiresKnownPrice: true,
},
} as const satisfies Record
diff --git a/tests/integration/overage-settings.integration.test.ts b/tests/integration/overage-settings.integration.test.ts
index 6d8ab369..f4f26d74 100644
--- a/tests/integration/overage-settings.integration.test.ts
+++ b/tests/integration/overage-settings.integration.test.ts
@@ -135,6 +135,7 @@ describe('overage settings API', () => {
})
it('turns CDN and storage overage on when the subscription prices their meters', async () => {
+ vi.stubGlobal('useRuntimeConfig', () => ({ cdn: { originMeter: true, storageMeter: true } }))
mockDb({ paymentAccount: { plugin_metadata: { billable_meters: ['ai_credits_1c', 'cdn_origin_gb', 'media_storage_gb_months'] } } })
vi.stubGlobal('readBody', vi.fn().mockResolvedValue({ cdn_bandwidth: true, media_storage: true }))
@@ -143,6 +144,7 @@ describe('overage settings API', () => {
})
it('refuses CDN overage on a subscription that has no price for it yet', async () => {
+ vi.stubGlobal('useRuntimeConfig', () => ({ cdn: { originMeter: true, storageMeter: true } }))
// Polar keeps a subscription's prices: one created before CDN overage
// was sold would serve the overage and never invoice it.
mockDb({ paymentAccount: { plugin_metadata: { billable_meters: ['ai_credits_1c', 'api_credits_1c', 'mcp_calls', 'form_submissions'] } } })
@@ -156,6 +158,17 @@ describe('overage settings API', () => {
expect(updateWorkspace).not.toHaveBeenCalled()
})
+ it('refuses CDN and storage overage while their meter flags are off, even on a subscription that prices them', async () => {
+ // No events reach Polar with the flag off: the overage would be served and never invoiced.
+ vi.stubGlobal('useRuntimeConfig', () => ({ cdn: { originMeter: false, storageMeter: false } }))
+ mockDb({ paymentAccount: { plugin_metadata: { billable_meters: ['ai_credits_1c', 'cdn_origin_gb', 'media_storage_gb_months'] } } })
+ vi.stubGlobal('readBody', vi.fn().mockResolvedValue({ media_storage: true }))
+
+ const handler = (await import('../../server/api/workspaces/[workspaceId]/overage-settings.patch.ts')).default
+ await expect(handler({} as never)).rejects.toMatchObject({ statusCode: 409 })
+ expect(updateWorkspace).not.toHaveBeenCalled()
+ })
+
it('lets a CDN toggle be turned off', async () => {
mockDb({ workspace: { overage_settings: { cdn_bandwidth: true } } })
vi.stubGlobal('readBody', vi.fn().mockResolvedValue({ cdn_bandwidth: false }))
diff --git a/tests/nuxt/components/plan-selection-modal-byte-overage.nuxt.test.ts b/tests/nuxt/components/plan-selection-modal-byte-overage.nuxt.test.ts
new file mode 100644
index 00000000..7c7fc39e
--- /dev/null
+++ b/tests/nuxt/components/plan-selection-modal-byte-overage.nuxt.test.ts
@@ -0,0 +1,57 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { computed, nextTick } from 'vue'
+import { mockNuxtImport, mountSuspended } from '@nuxt/test-utils/runtime'
+import PlanSelectionModal from '../../../app/components/organisms/PlanSelectionModal.vue'
+
+/**
+ * CDN and storage overage is sold only where the deployment runs their
+ * meters (NUXT_CDN_ORIGIN_METER / NUXT_CDN_STORAGE_METER). The plan cards
+ * quote its price only once the server says so (`overageSellable`).
+ */
+const { state } = vi.hoisted(() => ({ state: { sellable: false } }))
+
+mockNuxtImport('useBilling', () => () => ({
+ billingState: computed(() => 'subscribed'),
+ effectivePlan: computed(() => 'starter'),
+ trialConsumed: computed(() => true),
+ activeAccount: computed(() => ({ credit_unit: '0.01' })),
+ startCheckout: async () => {},
+ openPortal: async () => {},
+}))
+mockNuxtImport('useWorkspaceRole', () => () => ({ role: computed(() => 'owner'), isOwnerOrAdmin: computed(() => true) }))
+mockNuxtImport('useUsage', () => () => ({
+ usage: computed(() => ({
+ billingPeriod: '2026-10-01',
+ categories: [
+ { key: 'cdn_bandwidth', limitKey: 'cdn.bandwidth_gb', overageSellable: state.sellable },
+ { key: 'media_storage', limitKey: 'media.storage_gb', overageSellable: state.sellable },
+ ],
+ totalOverageAmount: 0,
+ projectedOverageAmount: 0,
+ })),
+ fetchUsage: vi.fn().mockResolvedValue(undefined),
+}))
+
+describe('PlanSelectionModal — CDN and storage overage prices', () => {
+ beforeEach(() => {
+ document.body.innerHTML = ''
+ })
+
+ it('quotes no CDN or storage price while the deployment does not sell it', async () => {
+ state.sellable = false
+ await mountSuspended(PlanSelectionModal, { props: { open: true }, attachTo: document.body })
+ await nextTick()
+ const text = document.body.textContent ?? ''
+ expect(text).not.toContain('$0.15')
+ expect(text).not.toContain('$0.25')
+ })
+
+ it('quotes them once it does', async () => {
+ state.sellable = true
+ await mountSuspended(PlanSelectionModal, { props: { open: true }, attachTo: document.body })
+ await nextTick()
+ const text = document.body.textContent ?? ''
+ expect(text).toContain('then $0.15 per GB')
+ expect(text).toContain('then $0.25 per GB/month')
+ })
+})
diff --git a/tests/unit/migration-media.test.ts b/tests/unit/migration-media.test.ts
index 360c6a8e..3f0aa188 100644
--- a/tests/unit/migration-media.test.ts
+++ b/tests/unit/migration-media.test.ts
@@ -294,6 +294,25 @@ describe('ingestMediaBytes — the repository source shares the upload path', ()
expect(media.upload).not.toHaveBeenCalled()
})
+ it('a storage toggle the subscription cannot bill does not raise the cap (bulk ingest, Migrate import)', async () => {
+ vi.resetModules()
+ vi.doMock('../../server/utils/deployment', () => ({ resolveDeployment: () => ({ planSource: 'subscription' }) }))
+ const { createMediaIngestContext } = await import('../../server/utils/media-bulk-ingest')
+ vi.stubGlobal('useRuntimeConfig', () => ({ public: { siteUrl: 'https://studio.test' }, cdn: { originMeter: true, storageMeter: true } }))
+ const unpriced = { subscription_status: 'active', plugin_metadata: { billable_meters: ['ai_credits_1c', 'form_submissions'] } }
+ vi.stubGlobal('useDatabaseProvider', () => ({ ...db, getWorkspaceById: vi.fn().mockResolvedValue({ id: 'ws-1', overage_settings: { media_storage: true } }), getActivePaymentAccount: vi.fn().mockResolvedValue(unpriced) }))
+ const media = { upload: vi.fn() }
+ const locked = await createMediaIngestContext({ projectId: 'p-1', workspaceId: 'ws-1', plan: 'starter', uploadedBy: 'u-1', source: 'repo', media: media as never })
+ expect(locked.storageLimit).toBe(1024 * 1024 * 1024)
+
+ // Priced: the toggle raises the cap to the 10x ceiling.
+ vi.stubGlobal('useDatabaseProvider', () => ({ ...db, getWorkspaceById: vi.fn().mockResolvedValue({ id: 'ws-1', overage_settings: { media_storage: true } }), getActivePaymentAccount: vi.fn().mockResolvedValue({ ...unpriced, plugin_metadata: { billable_meters: ['media_storage_gb_months'] } }) }))
+ const priced = await createMediaIngestContext({ projectId: 'p-1', workspaceId: 'ws-1', plan: 'starter', uploadedBy: 'u-1', source: 'repo', media: media as never })
+ expect(priced.storageLimit).toBe(10 * 1024 * 1024 * 1024)
+ vi.doUnmock('../../server/utils/deployment')
+ vi.resetModules()
+ })
+
it('bytes the project already holds: the existing asset, no quota, no upload', async () => {
const media = { upload: vi.fn(), getAssetByContentHash: vi.fn(async () => uploaded) }
const ctx = await createMediaIngestContext({ projectId: 'p-1', workspaceId: 'ws-1', plan: 'starter', uploadedBy: 'u-1', source: 'repo', media: media as never })
diff --git a/tests/unit/overage-byte-meters.test.ts b/tests/unit/overage-byte-meters.test.ts
index f0b39592..58c2b2e9 100644
--- a/tests/unit/overage-byte-meters.test.ts
+++ b/tests/unit/overage-byte-meters.test.ts
@@ -1,4 +1,4 @@
-import { describe, expect, it } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { OVERAGE_ABUSE_CEILING_RATIO, getEffectiveLimit, isOverageEnabled, isOverageSellable, overageCeiling } from '../../server/utils/overage'
import { USAGE_METERS, USAGE_METER_LIST } from '../../shared/utils/usage-meters'
@@ -13,6 +13,14 @@ describe('CDN and storage overage', () => {
const GB = 1024 ** 3
const bothOn = { cdn_bandwidth: true, media_storage: true, ai_messages: true }
+ // Both meters' events on (NUXT_CDN_ORIGIN_METER / NUXT_CDN_STORAGE_METER).
+ beforeEach(() => {
+ vi.stubGlobal('useRuntimeConfig', () => ({ cdn: { originMeter: true, storageMeter: true } }))
+ })
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ })
+
it('sells every metered limit', () => {
for (const key of ['cdn.bandwidth_gb', 'media.storage_gb', 'ai.messages_per_month', 'api.messages_per_month', 'api.mcp_calls_per_month', 'forms.submissions_per_month'])
expect(isOverageSellable(key), key).toBe(true)
@@ -56,3 +64,40 @@ describe('CDN and storage overage', () => {
expect(overageCeiling(350, 'ai.messages_per_month')).toBe(Infinity)
})
})
+
+/**
+ * A meter fed by a background job sends nothing while its flag is off, so
+ * its overage would be served and never invoiced. Flag off = the hard limit
+ * of before, whatever the workspace has toggled.
+ */
+describe('CDN and storage overage with the meter flag off', () => {
+ const GB = 1024 ** 3
+ const bothOn = { cdn_bandwidth: true, media_storage: true, ai_messages: true }
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ })
+
+ it('is not sold, and the toggle raises nothing', () => {
+ vi.stubGlobal('useRuntimeConfig', () => ({ cdn: { originMeter: false, storageMeter: false } }))
+ expect(isOverageSellable('cdn.bandwidth_gb')).toBe(false)
+ expect(isOverageSellable('media.storage_gb')).toBe(false)
+ expect(getEffectiveLimit(60, 'cdn.bandwidth_gb', bothOn)).toBe(60)
+ expect(getEffectiveLimit(25 * GB, 'media.storage_gb', bothOn)).toBe(25 * GB)
+ expect(isOverageEnabled('cdn.bandwidth_gb', bothOn)).toBe(false)
+ // Meters fed by the request itself are unaffected.
+ expect(isOverageSellable('ai.messages_per_month')).toBe(true)
+ })
+
+ it('follows each flag on its own, and reads an env string "true" as on', () => {
+ vi.stubGlobal('useRuntimeConfig', () => ({ cdn: { originMeter: 'true', storageMeter: false } }))
+ expect(isOverageSellable('cdn.bandwidth_gb')).toBe(true)
+ expect(isOverageSellable('media.storage_gb')).toBe(false)
+ })
+
+ it('treats unreadable config as off', () => {
+ vi.stubGlobal('useRuntimeConfig', () => {
+ throw new Error('no nitro')
+ })
+ expect(isOverageSellable('cdn.bandwidth_gb')).toBe(false)
+ })
+})
diff --git a/tests/unit/overage-lock.test.ts b/tests/unit/overage-lock.test.ts
index 2843a1a9..f980a073 100644
--- a/tests/unit/overage-lock.test.ts
+++ b/tests/unit/overage-lock.test.ts
@@ -7,8 +7,9 @@ const LEGACY_PRICES = ['ai_messages', 'api_messages', 'cdn_bandwidth_bytes', 'fo
const CURRENT_PRICES = ['ai_credits', 'api_credits', 'form_submissions', 'mcp_calls']
describe('resolveOverageLocks', () => {
- it('locks nothing without an account', () => {
- expect(resolveOverageLocks(null)).toEqual({})
+ it('without an account, locks only the overage that costs Studio per unit (CDN, storage)', () => {
+ const unknown = { reason: 'not_in_subscription', until: null }
+ expect(resolveOverageLocks(null)).toEqual({ cdn_bandwidth: unknown, media_storage: unknown })
})
it('locks every toggle during a trial, until the trial ends', () => {
@@ -72,9 +73,18 @@ describe('resolveOverageLocks', () => {
expect(locks.ai_messages?.reason).toBe('not_in_subscription')
})
- it('applies only the trial rule when the provider never reported prices', () => {
- expect(resolveOverageLocks({ subscription_status: 'active', plugin_metadata: {} })).toEqual({})
- expect(resolveOverageLocks({ subscription_status: 'past_due', plugin_metadata: null })).toEqual({})
+ it('when the provider never reported prices, keeps the others open but locks CDN and storage', () => {
+ // Unknown is not "priced": Studio would pay the egress and storage and
+ // never invoice them. The credit and event meters keep the old rule.
+ const unknown = { reason: 'not_in_subscription', until: null }
+ expect(resolveOverageLocks({ subscription_status: 'active', plugin_metadata: {} })).toEqual({ cdn_bandwidth: unknown, media_storage: unknown })
+ expect(resolveOverageLocks({ subscription_status: 'past_due', plugin_metadata: null })).toEqual({ cdn_bandwidth: unknown, media_storage: unknown })
+ })
+
+ it('unlocks CDN and storage once the subscription is known to price them', () => {
+ const locks = resolveOverageLocks({ subscription_status: 'active', plugin_metadata: { billable_meters: ['ai_credits_1c', 'cdn_origin_gb', 'media_storage_gb_months'] } })
+ expect(locks.cdn_bandwidth).toBeUndefined()
+ expect(locks.media_storage).toBeUndefined()
})
})
diff --git a/tests/unit/overage.test.ts b/tests/unit/overage.test.ts
index 0acd4965..1dbf274a 100644
--- a/tests/unit/overage.test.ts
+++ b/tests/unit/overage.test.ts
@@ -1,7 +1,11 @@
-import { describe, expect, it } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getEffectiveLimit, isOverageEnabled, calculateOverageUnits } from '../../server/utils/overage'
import { OVERAGE_PRICING, OVERAGE_SETTINGS_KEYS } from '../../shared/utils/license'
+afterEach(() => {
+ vi.unstubAllGlobals()
+})
+
describe('OVERAGE_PRICING constant', () => {
it('contains every metered category', () => {
// Order-agnostic: derivation reads content keys alphabetically,
@@ -32,6 +36,10 @@ describe('OVERAGE_PRICING constant', () => {
})
describe('getEffectiveLimit', () => {
+ beforeEach(() => {
+ vi.stubGlobal('useRuntimeConfig', () => ({ cdn: { originMeter: true, storageMeter: true } }))
+ })
+
it('returns plan limit when overage is disabled (empty settings)', () => {
expect(getEffectiveLimit(50, 'ai.messages_per_month', {})).toBe(50)
})
@@ -98,6 +106,10 @@ describe('getEffectiveLimit', () => {
})
describe('isOverageEnabled', () => {
+ beforeEach(() => {
+ vi.stubGlobal('useRuntimeConfig', () => ({ cdn: { originMeter: true, storageMeter: true } }))
+ })
+
it('returns false when settings is null', () => {
expect(isOverageEnabled('ai.messages_per_month', null)).toBe(false)
})
diff --git a/tests/unit/usage-alerts.test.ts b/tests/unit/usage-alerts.test.ts
index a0faa9f1..28f838bf 100644
--- a/tests/unit/usage-alerts.test.ts
+++ b/tests/unit/usage-alerts.test.ts
@@ -1,4 +1,4 @@
-import { describe, expect, it, vi } from 'vitest'
+import { afterEach, describe, expect, it, vi } from 'vitest'
import { planUsageAlerts, runUsageAlerts } from '../../server/utils/usage-alerts'
import type { UsageAlertKey } from '../../server/providers/database'
@@ -45,6 +45,21 @@ function deps(db: ReturnType, sendEmail = vi.fn().mockResolvedVal
return { db: db as never, sendEmail, ownerEmail: async () => 'owner@lanista.test', siteUrl: 'https://studio.test', now: NOW }
}
+/** The subscription prices the CDN and storage meters (`billable_meters`). */
+function pricesByteMeters(db: ReturnType) {
+ db.getActivePaymentAccount.mockResolvedValue({
+ credit_unit: '0.01',
+ subscription_id: 'sub_1', subscription_status: 'active',
+ current_period_start: '2026-09-15T00:00:00Z', current_period_end: '2026-10-15T00:00:00Z',
+ trial_ends_at: null, grace_period_ends_at: null,
+ plugin_metadata: { billable_meters: ['ai_credits_1c', 'api_credits_1c', 'mcp_calls', 'form_submissions', 'cdn_origin_gb', 'media_storage_gb_months'] },
+ })
+}
+
+afterEach(() => {
+ vi.unstubAllGlobals()
+})
+
describe('usage alerts', () => {
it('tells the owner when AI credits and public form submissions have stopped, and until when', async () => {
// Pro: 350 AI credits (billing period from the 15th), 3000 submissions (calendar month).
@@ -107,13 +122,16 @@ describe('usage alerts', () => {
})
it('CDN with overage on: 100 % says it is billed, the 10x ceiling says it stopped — and offers an upgrade, not overage', async () => {
+ vi.stubGlobal('useRuntimeConfig', () => ({ cdn: { originMeter: true, storageMeter: true } }))
// Pro: 60 GB, overage on → billed past 60, stops at 600.
const billed = fakeDb({ cdn: 65 }, { overage_settings: { cdn_bandwidth: true } })
+ pricesByteMeters(billed)
expect(await runUsageAlerts(deps(billed))).toEqual([
expect.objectContaining({ meter: 'cdn_bandwidth', threshold: 100, template: 'usage-overage-started' }),
])
const ceiling = fakeDb({ cdn: 601 }, { overage_settings: { cdn_bandwidth: true } })
+ pricesByteMeters(ceiling)
const mail = vi.fn().mockResolvedValue(undefined)
expect(await runUsageAlerts(deps(ceiling, mail))).toEqual([
expect.objectContaining({ meter: 'cdn_bandwidth', threshold: 120, template: 'usage-limit-reached' }),
@@ -124,8 +142,10 @@ describe('usage alerts', () => {
})
it('storage with overage on stops at 10x the plan, and that alert re-arms well below the ceiling', async () => {
+ vi.stubGlobal('useRuntimeConfig', () => ({ cdn: { originMeter: true, storageMeter: true } }))
const at = (gb: number) => [{ id: 'ws-1', name: 'Lanista', slug: 'lanista', type: 'team', plan: 'pro', owner_id: 'owner-1', overage_settings: { media_storage: true }, media_storage_bytes: gb * 1024 ** 3 }]
const db = fakeDb({}, { overage_settings: { media_storage: true }, media_storage_bytes: 251 * 1024 ** 3 })
+ pricesByteMeters(db)
expect(await runUsageAlerts(deps(db))).toEqual([expect.objectContaining({ meter: 'media_storage', threshold: 120, periodKey: 'level' })])
db.listWorkspacesForUsageAlerts.mockResolvedValue(at(240)) // 96 % of the ceiling: stays claimed
await runUsageAlerts(deps(db))
diff --git a/tests/unit/workspace-usage-byte-overage.test.ts b/tests/unit/workspace-usage-byte-overage.test.ts
index 5715a345..1a3163da 100644
--- a/tests/unit/workspace-usage-byte-overage.test.ts
+++ b/tests/unit/workspace-usage-byte-overage.test.ts
@@ -1,4 +1,4 @@
-import { describe, expect, it, vi } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { computeWorkspaceUsage } from '../../server/utils/workspace-usage'
import { usagePeriodFrom } from '../../server/utils/usage-period'
@@ -23,7 +23,15 @@ function db(cdnGb: number) {
const base = { workspaceId: 'ws-1', plan: 'pro', period: usagePeriodFrom(null, NOW), now: NOW }
+afterEach(() => {
+ vi.unstubAllGlobals()
+})
+
describe('CDN and storage overage charge', () => {
+ beforeEach(() => {
+ vi.stubGlobal('useRuntimeConfig', () => ({ cdn: { originMeter: true, storageMeter: true } }))
+ })
+
it('charges each GB past the plan at the catalogue price when overage is on', async () => {
// Pro: 60 GB CDN, 25 GB storage.
const usage = await computeWorkspaceUsage(db(70) as never, {
@@ -63,3 +71,13 @@ describe('CDN and storage overage charge', () => {
expect(usage.categories.find(c => c.key === 'ai_messages')!.overageCeiling).toBeNull()
})
})
+
+describe('CDN and storage with the meter flags off', () => {
+ it('stay a fixed limit: not sellable, no price, no ceiling, nothing quoted', async () => {
+ vi.stubGlobal('useRuntimeConfig', () => ({ cdn: { originMeter: false, storageMeter: false } }))
+ const usage = await computeWorkspaceUsage(db(70) as never, { ...base, overageSettings: { cdn_bandwidth: true, media_storage: true }, storageBytes: 30 * GB })
+ for (const key of ['cdn_bandwidth', 'media_storage'])
+ expect(usage.categories.find(c => c.key === key)).toMatchObject({ overageSellable: false, overageEnabled: false, overageUnitPrice: 0, overageAmount: 0, overageCeiling: null })
+ expect(usage.totalOverageAmount).toBe(0)
+ })
+})