Skip to content

feat(billing): bill cdn and media storage overage, with a 10x abuse ceiling - #449

Merged
ABB65 merged 5 commits into
mainfrom
feat/billable-cdn-media-overage
Oct 10, 2026
Merged

ABB65 merged 5 commits into
mainfrom
feat/billable-cdn-media-overage

Conversation

@ABB65

@ABB65 ABB65 commented Oct 9, 2026 •

Copy link
Copy Markdown
Member

Founder decision (2026-10-09): CDN bandwidth and media storage overage are charged and billed. This PR makes both work the same way AI, API, MCP and form overage already do: an opt-in switch per workspace, a Polar metered price plus a meter credit for the plan's allowance, and a usage screen that quotes what the meter bills. It also keeps an abuse ceiling.

What was wrong (audit)

  • Catalogue: fix(billing,members): reviewer/viewer gated on the server; the catalogue says only what is sold #445 (earlier today) took overage_price off cdn.bandwidth_gb (0.15) and media.storage_gb (0.25). Both were overageBillable: false in shared/utils/usage-meters.ts, so polar-sync made no metered price and no meter credit for them, and isOverageSellable kept them hard.
  • CDN measurement: real, but it counts origin transfer only. server/api/cdn/v1/[projectId]/[...path].get.ts writes bytes per project per UTC day to cdn_usage. Cloudflare edge hits never reach the origin, so they are not counted, which is what egress costs. server/plugins/cdn-origin-meter.ts sends one cdn_origin_gb event per workspace per finished day, but only when NUXT_CDN_ORIGIN_METER=true (default false).
  • Storage measurement: workspaces.media_storage_bytes is a live level. It never reached billing. recordStorageSample (the media_storage_byte_hours meter) had no callers, and a byte-hour meter cannot hold a GB allowance anyway (Polar's meter credit is int32).
  • Hidden bug that would have shipped with the toggle: every media caller passes the storage limit in bytes to getEffectiveLimit. With overage on, that returned the int32 soft cap, 2,147,483,647, which as bytes is 2 GiB. That is below every paid plan (Starter 5 GB, Pro 25 GB), so turning storage overage on would have shrunk the cap.
  • Lock bypass: the CDN delivery route (cachedWorkspacePlan), the chat media tool (conversation-engine.ts) and the MCP media facet read raw overage_settings. A stale true would have raised the cap even on a subscription that has no price for the meter. The billing middleware already drops those toggles; these three paths did not.
  • At the cap today: CDN keeps serving to 120 % of the plan, then answers 429 + Retry-After until the window resets. Uploads are refused (403 storage.quota_exceeded) at the plan limit.

What this changes

  • Catalogue: overage_price goes back on both rows: $0.15 per GB of CDN origin transfer and $0.25 per GB-month of storage.
  • Meters:
    • cdn_origin_gb becomes overageBillable: true. It already counts GB, so the plan's GB is the meter credit as is.
    • Storage gets a new meter, media_storage_gb_months (sum, 1 unit = 1 GB-month), replacing the unused byte-hour one in the manifest. The old meter is left alone in Polar.
    • New plugin server/plugins/media-storage-meter.ts, behind NUXT_CDN_STORAGE_METER. Once per UTC day it sends one event per workspace that has a payment customer: stored GB ÷ the days in that workspace's billing period. A full period therefore sums to the average GB stored, and the included GB is the plan value.
    • New DatabaseProvider.listWorkspaceMediaStorageBytes, in both the postgres and supabase providers.
  • Soft limit with a ceiling: with the switch on, CDN and storage continue past the plan and are billed. They stop at 10× the plan (OVERAGE_ABUSE_CEILING_RATIO, server/utils/overage.ts). The ceiling is a multiple of the plan, in the caller's unit, so a limit passed in bytes stays in bytes. CDN with overage on stops at the ceiling itself, with no extra 20 % buffer on top (hardStopRatio: 1). With the switch off, the behaviour is the same as today.
  • Locks: billableOverageSettings (server/utils/workspace-billing.ts) is now applied in the CDN workspace cache, the chat media tool and the MCP media facet. If billing can't be read, every toggle is treated as off.
  • UI and alerts:
    • The usage panel shows the price, the ceiling ("Continues past the plan up to 600 GB, then stops") and how each meter is measured: CDN does not count Cloudflare cache hits, and storage is billed on the period average while the screen quotes today's level.
    • The plan modal shows "then $0.15 per GB" and "then $0.25 per GB/month".
    • The banner and alert emails treat reaching the ceiling as "stopped" (threshold 120) and suggest an upgrade, not overage.
  • Docs: ROADMAP, docs/CDN_EDGE.md, docs/PAYMENT_PROVIDERS.md and .env.example are updated.

Label: GENERIC. This is billing infrastructure for two meter classes that every paid workspace has, not a fix for one customer.

Review fixes (1900a76)

  • Bulk ingest lock: media-bulk-ingest.ts (the bulk-ingest route and the Migrate media import) now goes through billableOverageSettings. It was the fourth path that read overage_settings directly.
  • Unknown price list is locked: if the subscription has never reported its billable_meters (null), or there is no payment account at all, CDN and storage overage counts as locked (requiresKnownPrice). The credit and event meters keep the old behaviour.
  • Meter flag required: CDN and storage overage can only be sold while the matching meter flag is on (NUXT_CDN_ORIGIN_METER / NUXT_CDN_STORAGE_METER, eventFlag). With the flag off they behave like today's hard limit, even if overage_settings is true. The plan cards show the CDN and storage price only after the usage route confirms it is sellable.

Tests

  • overage-byte-meters.test.ts (replaces overage-hard-limits): sellability, the ceiling, byte-unit safety, and unlimited plans.
  • media-storage-meter.test.ts: the sampler. It samples once per day per paying customer, takes the length from the billing or calendar period, skips an account it can't read, and rejects instead of recording "empty" when the listing fails.
  • usage-metering-units.test.ts: the GB and GB-month event values and idempotency keys. A 31-day period of mixed levels sums to the average; Pro at an average of 33.55 GB is charged $2.14.
  • workspace-usage-byte-overage.test.ts: the charge. Pro at 70 GB CDN / 30 GB storage gives 10 × $0.15 + 5 × $0.25 = $2.75. Nothing is quoted while the switch is off or locked.
  • Also updated: CDN budget ceiling, CDN cache locks (including the fail-closed case), the alert ceiling and its re-arm, the overage-settings PATCH (allowed when priced, 409 not_in_subscription when not), and the panel and banner nuxt tests.

Gate (HEAD, local)

  • pnpm test:ci: unit 2247 passed / 1 skipped (196 files), integration 517 passed (54 files), nuxt 348 passed (52 files)
  • pnpm typecheck: 0 errors
  • pnpm lint: 0 errors (7 warnings that were already there, none in changed files)
  • pnpm check:strings: all keys defined

Sandbox polar-sync dry run (no --apply, NUXT_POLAR_SERVER=sandbox)

  ✓ meter "cdn_origin_gb" exists
  + meter "media_storage_gb_months" would be created (aggregation: sum(value))
  ~ product "Starter" would be updated — +2 metered price(s)
    + include 3 cdn_origin_gb (meter credit)
    + include 5 media_storage_gb_months (meter credit)
  ~ product "Pro" would be updated — +2 metered price(s)
    + include 60 cdn_origin_gb (meter credit)
    + include 25 media_storage_gb_months (meter credit)
  + product "Studio Starter Usage (monthly)" would be created — free base + 6 metered prices
  + product "Studio Pro Usage (monthly)" would be created — free base + 6 metered prices

The run exits 1 because of two warnings that already existed in sandbox and have nothing to do with this PR: fixed-price drift on "Migrate with Studio Starter/Pro" (7200 vs 9000, 39200 vs 49000 cents).

Known limits

  • CDN events are sent for finished days, so a period's last day lands in the next invoice. On a cancelled subscription, that final day goes unbilled.
  • Storage is sampled once a day. The sum over a period can be off by about one day of the stored level. A missed day is not filled in afterwards, so the error is always in the customer's favour.
  • Subscriptions that already exist keep the prices they were created with (Polar grandfathers them). Their switch shows as locked (not_in_subscription) until the subscription prices the new meters. See the production steps in the hand-off.

Not run against production. No live billing was changed.

ABB65 added 4 commits October 9, 2026 23:37
…eiling

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
…iced

- 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
@ABB65

ABB65 commented Oct 9, 2026

Copy link
Copy Markdown
Member Author

ONAY (reviewer): head 1900a76, test-merge on main de7be8c: test:ci green with --maxWorkers=2 (unit 2247 passed + 1 skipped / 196 files, integration 517 / 54, nuxt 348 / 52), typecheck 0 errors, lint 0 errors (7 existing warnings), check:strings ok. Delta checked: every media cap path now gets lock-filtered settings; an account with no reported priced meters keeps CDN/storage locked (requiresKnownPrice); CDN/storage are sellable only while their meter flag is on (otherwise a hard limit, PATCH 409, no price on plan cards). Mutants, each turns its test red: no lock for unreported meters (1), flag ignored (4), unfiltered settings in bulk ingest (1). Money math, units, the 10× ceiling, overage-off behaviour and the no-migration query were re-checked from the first pass, unchanged. NIT: migration/media.get/post still fall back to the stored settings when no billing context is set. Unreachable in practice (the middleware always sets it), but '{}' would fail closed.

@ABB65

ABB65 commented Oct 10, 2026

Copy link
Copy Markdown
Member Author

Re-ONAY (ork2) @1f261c3c, after studio main was merged in (t7: --no-commit + a hooked commit, husky lint-staged ran).

  • Merge check: git show --cc shows 0 combined hunks, so nothing was resolved by hand. Only 2 files were touched by both sides, en.json (migrate_claim.site_binding_* keys) and server/providers/database.ts (MigrateSiteBinding types, 049/050). Main's changes to both are additive, and en.json is valid JSON.
  • test:ci on the merged tree (t7): unit 2273 passed / 1 skipped; integration 517; nuxt 355/355. The nuxt run needed npx contentrain generate first, because the local SDK client is gitignored and was stale. That's a local artefact; no code changed.
  • CI: ci pass, postgres-lineage pass.
  • The prices remain founder copy: the docs#16/iterum#137 rows are in t7's draft, with every number pointing to its constant at file:line.

@ABB65
ABB65 merged commit 1488a66 into main Oct 10, 2026
2 checks passed
@ABB65
ABB65 deleted the feat/billable-cdn-media-overage branch October 10, 2026 09:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant