Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .contentrain/content/system/plan-features/data.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions .contentrain/content/system/ui-strings/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,9 @@ 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).
# CDN / storage overage is sold only while its meter flag is on; off = hard limit.
# NUXT_CDN_STORAGE_METER=false

# -----------------------------------------------------------------------------
# Email
Expand Down
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down
11 changes: 7 additions & 4 deletions app/components/molecules/UsageAlertBanner.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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. */
Expand All @@ -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))
: [])
Expand All @@ -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
Expand Down
15 changes: 13 additions & 2 deletions app/components/organisms/PlanSelectionModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@ const emit = defineEmits<{
const toast = useToast()
const loading = ref<string | null>(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}`
Expand Down Expand Up @@ -122,13 +129,17 @@ 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, 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 })
}
Expand Down
47 changes: 46 additions & 1 deletion app/components/organisms/WorkspaceUsagePanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down Expand Up @@ -171,6 +194,14 @@ function categoryIcon(key: string): string {
>
{{ unitPriceLabel(category) }}
</span>
<!-- Where it stops even with the switch on (abuse ceiling). -->
<span
v-if="ceilingLabel(category)"
:id="`overage-ceiling-${category.key}`"
class="text-right text-xs text-muted"
>
{{ ceilingLabel(category) }}
</span>
<!-- Why the switch is off and when it can be on. The plan's
included usage is unaffected either way. -->
<span
Expand Down Expand Up @@ -204,16 +235,30 @@ function categoryIcon(key: string): string {
{{ resetLabel(category) }}
</p>

<p v-if="meterNote(category)" class="mt-1 text-xs text-muted">
{{ meterNote(category) }}
</p>

<p
v-if="category.key === 'ai_messages' && (usage.byoaRequests ?? 0) > 0"
class="mt-2 text-xs text-muted"
>
{{ t('billing.usage_byoa_requests', { count: usage.byoaRequests ?? 0 }) }}
</p>

<!-- Overage on, and the abuse ceiling reached: stopped all the same. -->
<div
v-if="atCeiling(category)"
class="mt-2 rounded-md bg-danger-50 px-3 py-2 dark:bg-danger-950/30"
>
<p class="text-xs text-danger-700 dark:text-danger-300">
{{ t('billing.overage_ceiling_reached', { name: category.name, ceiling: formatCeiling(category) }) }}
</p>
</div>

<!-- Limit reached warning (overage disabled) -->
<div
v-if="category.limit > 0 && category.current >= category.limit && !category.overageEnabled"
v-else-if="category.limit > 0 && category.current >= category.limit && !category.overageEnabled"
class="mt-2 rounded-md bg-danger-50 px-3 py-2 dark:bg-danger-950/30"
>
<p class="text-xs text-danger-700 dark:text-danger-300">
Expand Down
2 changes: 2 additions & 0 deletions app/composables/useUsage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
16 changes: 13 additions & 3 deletions docs/CDN_EDGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -103,12 +103,22 @@ 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.
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`), 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

Expand Down
6 changes: 3 additions & 3 deletions docs/PAYMENT_PROVIDERS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
14 changes: 7 additions & 7 deletions server/api/cdn/v1/[projectId]/[...path].get.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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<string, boolean> | null | undefined) ?? null,
)
const overageSettings = (workspace?.overage_settings as Record<string, boolean> | 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 }) })
Expand Down
6 changes: 3 additions & 3 deletions server/api/workspaces/[workspaceId]/overage-settings.patch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,9 @@ function lockedError(lock: OverageLock) {
}

const LIMIT_KEY_BY_SETTINGS_KEY: Record<string, string> = 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]),
])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ export default defineEventHandler(async (event) => {
tree,
plan,
usedBytes: Number(ws?.media_storage_bytes ?? 0),
overageSettings: (ws?.overage_settings as Record<string, boolean> | null) ?? {},
overageSettings: event.context.billing?.overageSettings ?? (ws?.overage_settings as Record<string, boolean> | null) ?? {},
root: found.root,
signedOrigin: await migrationSignedOrigin(workspaceId, project),
}),
Expand Down
Loading
Loading