From d302ac9e46caf05dc7091b69e48be4e0e1892758 Mon Sep 17 00:00:00 2001 From: AHMET BAYHAN BAYRAMOGLU <49499275+ABB65@users.noreply.github.com> Date: Fri, 9 Oct 2026 23:37:16 +0300 Subject: [PATCH 1/4] feat(billing): bill cdn and media storage overage, with a 10x abuse ceiling CDN origin transfer is sold at $0.15 per GB past the plan and media storage at $0.25 per GB-month (average stored over the billing period), the same way AI, API, MCP and form overage are: an opt-in switch per workspace, a Polar metered price plus a meter credit for the plan's allowance, and the usage screen quoting what the meter bills. - catalogue: overage_price back on cdn.bandwidth_gb and media.storage_gb - meters: cdn_origin_gb billable; media storage gets media_storage_gb_months (one event per paying workspace per UTC day, GB / days in its billing period) and a sampler plugin behind NUXT_CDN_STORAGE_METER; the unused byte-hour meter leaves the manifest - limits: with overage on, CDN and storage stop at 10x the plan instead of the int32 soft cap, which read as bytes was 2 GiB and would have shrunk every paid storage limit - locks: the CDN delivery route, the chat media tool and the MCP media facet now drop toggles the subscription cannot bill, like the billing middleware does - alerts, banner and usage panel: the ceiling and how each meter is measured are shown; reaching the ceiling alerts as stopped --- .../content/system/plan-features/data.json | 2 + .../content/system/ui-strings/en.json | 4 + .env.example | 2 + ROADMAP.md | 2 +- app/components/molecules/UsageAlertBanner.vue | 11 ++- .../organisms/PlanSelectionModal.vue | 4 +- .../organisms/WorkspaceUsagePanel.vue | 47 ++++++++++- app/composables/useUsage.ts | 2 + docs/CDN_EDGE.md | 7 +- docs/PAYMENT_PROVIDERS.md | 6 +- nuxt.config.ts | 3 + .../api/cdn/v1/[projectId]/[...path].get.ts | 14 ++-- .../[projectId]/migration/media.get.ts | 2 +- server/plugins/media-storage-meter.ts | 77 +++++++++++++++++++ server/providers/database.ts | 6 ++ server/providers/postgres-db/usage.ts | 12 +++ server/providers/supabase-db/usage.ts | 12 +++ server/utils/cdn-delivery-cache.ts | 18 ++++- server/utils/cdn-origin-budget.ts | 14 +++- server/utils/conversation-engine.ts | 4 +- server/utils/mcp-media-facet.ts | 4 +- server/utils/overage.ts | 53 +++++++++++-- server/utils/usage-alerts.ts | 16 +++- server/utils/usage-metering.ts | 30 ++++++-- server/utils/workspace-billing.ts | 26 +++++++ server/utils/workspace-usage.ts | 13 +++- shared/utils/usage-meters.ts | 35 +++++---- .../overage-settings.integration.test.ts | 30 +++++--- tests/unit/cdn-delivery-cache.test.ts | 30 ++++++++ tests/unit/cdn-origin-budget.test.ts | 9 +++ tests/unit/license-content-parity.test.ts | 18 ++--- tests/unit/media-storage-meter.test.ts | 77 +++++++++++++++++++ tests/unit/overage-byte-meters.test.ts | 58 ++++++++++++++ tests/unit/overage-hard-limits.test.ts | 53 ------------- tests/unit/overage-lock.test.ts | 6 +- tests/unit/overage.test.ts | 33 +++++--- tests/unit/usage-alerts.test.ts | 31 +++++++- tests/unit/usage-metering-units.test.ts | 46 +++++++++++ tests/unit/usage-meters-manifest.test.ts | 30 +++++--- .../unit/workspace-usage-byte-overage.test.ts | 65 ++++++++++++++++ 40 files changed, 750 insertions(+), 162 deletions(-) create mode 100644 server/plugins/media-storage-meter.ts create mode 100644 tests/unit/media-storage-meter.test.ts create mode 100644 tests/unit/overage-byte-meters.test.ts delete mode 100644 tests/unit/overage-hard-limits.test.ts create mode 100644 tests/unit/workspace-usage-byte-overage.test.ts diff --git a/.contentrain/content/system/plan-features/data.json b/.contentrain/content/system/plan-features/data.json index edd21854..33f8c27a 100644 --- a/.contentrain/content/system/plan-features/data.json +++ b/.contentrain/content/system/plan-features/data.json @@ -229,6 +229,7 @@ "free_value": "0", "key": "cdn.bandwidth_gb", "name": "CDN Bandwidth (GB)", + "overage_price": 0.15, "overage_settings_key": "cdn_bandwidth", "overage_unit": "per GB", "pro_value": "60", @@ -564,6 +565,7 @@ "free_value": "0", "key": "media.storage_gb", "name": "Media Storage (GB)", + "overage_price": 0.25, "overage_settings_key": "media_storage", "overage_unit": "per GB/month", "pro_value": "25", diff --git a/.contentrain/content/system/ui-strings/en.json b/.contentrain/content/system/ui-strings/en.json index 7671ef99..a41a97ab 100644 --- a/.contentrain/content/system/ui-strings/en.json +++ b/.contentrain/content/system/ui-strings/en.json @@ -112,6 +112,8 @@ "billing.on_premise_badge": "On-premise", "billing.on_premise_description": "You're running Contentrain Studio on-premise with the Enterprise Edition. Plan tier is managed by the deployment operator.", "billing.on_premise_title": "On-premise deployment", + "billing.overage_ceiling": "Continues past the plan up to {ceiling}, then stops", + "billing.overage_ceiling_reached": "{name} reached its {ceiling} ceiling and has stopped. Change the plan to continue.", "billing.overage_disabled": "Hard limit", "billing.overage_hard_limit": "Fixed limit", "billing.overage_locked_subscription": "Not available on your current subscription yet — contact support to update it", @@ -145,6 +147,8 @@ "billing.usage_byoa_requests": "With your own API key: {count} requests (not counted toward credits, not billed)", "billing.usage_level_note": "Current total — does not reset", "billing.usage_member_note": "Only workspace owners and admins can change the plan or allow overage. Ask one of them if your team needs more.", + "billing.usage_note_cdn_bandwidth": "Counts what Studio's servers deliver. Responses served from the Cloudflare cache are not counted.", + "billing.usage_note_media_storage": "Extra storage is billed on the average stored over the billing period; the amount shown is at today's level.", "billing.usage_overage_units": "+{amount} overage", "billing.usage_percentage": "used", "billing.usage_period_billing": "Billing period", diff --git a/.env.example b/.env.example index bde51ca9..3c1ceed5 100644 --- a/.env.example +++ b/.env.example @@ -150,6 +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) +# NUXT_CDN_STORAGE_METER=false # ----------------------------------------------------------------------------- # Email diff --git a/ROADMAP.md b/ROADMAP.md index e6ceb3d4..79a27ed2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -26,7 +26,7 @@ Core platform: | Approvals & Scheduling | Approval gates for branches and releases (`server/utils/approval-gate.ts`, `branch-approval.ts`, `release-approval.ts`), scheduled publication (`server/utils/schedule-registry.ts`) | | Content Brain | IndexedDB offline cache, semantic search, full-text index, delta sync | | Audit Logs | Application + database-level audit trail, 90-day retention | -| Overage Billing | Metered overage for AI credits, API credits, MCP Cloud calls and form submissions, with a usage dashboard. CDN bandwidth and media storage are hard limits; their overage is not sold (`overageBillable: false` in `shared/utils/usage-meters.ts`) | +| Overage Billing | Metered overage for AI credits, API credits, MCP Cloud calls, form submissions, CDN origin transfer (per GB, `cdn_origin_gb`) and media storage (per GB-month of average stored, `media_storage_gb_months`), with a usage dashboard. Each is an opt-in switch; with it off the limit holds. CDN and storage overage stop at 10× the plan (abuse ceiling, `server/utils/overage.ts`) | | CLI Integration | Auth (OAuth + token refresh), activity feed, usage API for Studio CLI | | Self-Hosting | Docker Compose deployment, 3-stage build; plain PostgreSQL 14+ provider (`server/providers/postgres-db/`) as an alternative to Supabase | diff --git a/app/components/molecules/UsageAlertBanner.vue b/app/components/molecules/UsageAlertBanner.vue index 30fc2a6c..6a184907 100644 --- a/app/components/molecules/UsageAlertBanner.vue +++ b/app/components/molecules/UsageAlertBanner.vue @@ -16,7 +16,9 @@ import { CDN_ORIGIN_HARD_STOP_RATIO } from '~~/shared/utils/cdn-limit' * * CDN bandwidth has a buffer: past its limit delivery continues (a grace * notice with the upgrade link, owners and admins) and it stops only at - * `CDN_ORIGIN_HARD_STOP_RATIO` of the limit. + * `CDN_ORIGIN_HARD_STOP_RATIO` of the limit. With overage on, CDN and storage + * continue past the limit, billed, and stop at their abuse ceiling + * (`overageCeiling`). */ const { t } = useContent() @@ -26,8 +28,9 @@ const { isOwnerOrAdmin } = useWorkspaceRole() const ALERTING = ['ai_messages', 'form_submissions', 'comments', 'api_messages', 'mcp_calls', 'media_storage', 'cdn_bandwidth'] -/** Where a meter stops: its limit, or the CDN's hard stop past it. */ +/** Where a meter stops: its limit, the CDN's hard stop past it, or with overage on its ceiling (Infinity = none). */ function stopAt(c: UsageCategory): number { + if (c.overageEnabled) return c.overageCeiling ?? Infinity return c.key === 'cdn_bandwidth' ? c.limit * CDN_ORIGIN_HARD_STOP_RATIO : c.limit } /** CDN between its limit and the hard stop: still serving. */ @@ -44,7 +47,7 @@ const relevant = computed(() => ) /** At the limit with nothing billed past it: this has stopped. */ // Raw values, not the rounded percentage: 995 / 1000 shows 100 % but nothing has stopped. -const stopped = computed(() => relevant.value.filter(c => c.current >= stopAt(c) && !c.overageEnabled)) +const stopped = computed(() => relevant.value.filter(c => c.current >= stopAt(c))) const warnings = computed(() => isOwnerOrAdmin.value ? relevant.value.filter(c => (c.current >= c.limit * 0.8 && c.current < c.limit) || inGrace(c)) : []) @@ -66,7 +69,7 @@ const text = computed(() => { : t('usage_banner.warning_undated', { name: c.name, percentage: c.percentage }) }) -const isStop = computed(() => !!primary.value && primary.value.current >= stopAt(primary.value) && !primary.value.overageEnabled) +const isStop = computed(() => !!primary.value && primary.value.current >= stopAt(primary.value)) const billingPath = computed(() => { const slug = activeWorkspace.value?.slug diff --git a/app/components/organisms/PlanSelectionModal.vue b/app/components/organisms/PlanSelectionModal.vue index 0a56e8d0..038a956a 100644 --- a/app/components/organisms/PlanSelectionModal.vue +++ b/app/components/organisms/PlanSelectionModal.vue @@ -122,8 +122,8 @@ interface LimitRow { /** * What a unit past the included amount costs, so the price is known before - * anyone chooses a plan. Limits that are hard caps (media, CDN: their meters - * cannot bill past an allowance) show nothing, whatever the data lists. + * anyone chooses a plan. A limit whose meter is not billable + * (`overageBillable: false`) shows nothing, whatever the data lists. */ function overageLabel(key: string, ownTerms = false): string | null { const pricing = OVERAGE_PRICING[key] diff --git a/app/components/organisms/WorkspaceUsagePanel.vue b/app/components/organisms/WorkspaceUsagePanel.vue index 26eca5fe..ee1aeb57 100644 --- a/app/components/organisms/WorkspaceUsagePanel.vue +++ b/app/components/organisms/WorkspaceUsagePanel.vue @@ -79,11 +79,34 @@ function unitPriceLabel(category: UsageCategory): string | null { function describedBy(category: UsageCategory): string | undefined { const ids = [ unitPriceLabel(category) ? `overage-price-${category.key}` : null, + ceilingLabel(category) ? `overage-ceiling-${category.key}` : null, category.overageLock ? `overage-lock-${category.key}` : null, ].filter(Boolean) return ids.length ? ids.join(' ') : undefined } +/** How a meter is measured or billed, where that is not obvious from its name. */ +function meterNote(category: UsageCategory): string | null { + if (category.key === 'cdn_bandwidth') return t('billing.usage_note_cdn_bandwidth') + if (category.key === 'media_storage' && canManage.value && category.overageSellable !== false) return t('billing.usage_note_media_storage') + return null +} + +function formatCeiling(category: UsageCategory): string { + return category.unit === 'GB' ? `${category.overageCeiling} GB` : `${category.overageCeiling} ${category.unit}` +} + +/** With overage on, where the meter stops anyway — said before it is turned on. */ +function ceilingLabel(category: UsageCategory): string | null { + if (!category.overageCeiling || category.overageSellable === false) return null + return t('billing.overage_ceiling', { ceiling: formatCeiling(category) }) +} + +/** Overage on and the ceiling reached: it has stopped, billed or not. */ +function atCeiling(category: UsageCategory): boolean { + return category.overageEnabled && !!category.overageCeiling && category.current >= category.overageCeiling +} + /** What to say when a meter is at its limit and nothing extra is billed. */ function limitReachedText(category: UsageCategory): string { if (!canManage.value) return t('billing.limit_reached_member', { name: category.name }) @@ -171,6 +194,14 @@ function categoryIcon(key: string): string { > {{ unitPriceLabel(category) }} + + + {{ ceilingLabel(category) }} + +

+ {{ meterNote(category) }} +

+

+ +

+

+ {{ t('billing.overage_ceiling_reached', { name: category.name, ceiling: formatCeiling(category) }) }} +

+
+

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) + }) +})