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
1 change: 1 addition & 0 deletions .contentrain/content/system/error-messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"billing.no_subscription": "No active subscription found. Please subscribe first.",
"billing.overage_locked_subscription": "Overage is not available on your current subscription. Usage stops at your plan limit — contact support to move your subscription to current pricing.",
"billing.overage_locked_trial": "Overage can be turned on once your trial ends ({date}). Until then, usage stops at your plan limit.",
"billing.overage_locked_yearly": "Overage is not available on yearly plans yet. Usage resets every month, and stops at your plan limit until the next reset.",
"billing.overage_not_available": "This limit is a hard cap — extra usage is not sold on this plan.",
"billing.overage_requires_subscription": "Overage billing requires an active subscription with a payment method on file.",
"billing.payment_required": "This workspace's subscription is inactive. The workspace owner needs to update billing to continue.",
Expand Down
1 change: 1 addition & 0 deletions .contentrain/content/system/ui-strings/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@
"billing.overage_locked_subscription": "Not available on your current subscription yet — contact support to update it",
"billing.overage_locked_trial": "Available after your trial ends on {date}",
"billing.overage_locked_trial_undated": "Available after your trial ends",
"billing.overage_locked_yearly": "Not available on yearly plans yet — usage stops at your limit and resets every month",
"billing.overage_not_available": "This limit is a hard cap — extra usage is not sold on this plan.",
"billing.overage_requires_subscription": "Overage billing requires an active subscription with a payment method on file.",
"billing.overage_unit_price": "{price} {unit} past the limit",
Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@

### ⚠️ Upgrade notes

**Migration 046: form and comment quotas take a billing window.**
`046_usage_window_quotas.sql` drops and recreates `create_form_submission_if_allowed` and `create_comment_if_allowed` with two defaulted window parameters (`p_window_start`, `p_window_end`). Run the migration before the new image: the new code always passes the window. The old image still works against the new functions because the parameters default to the calendar month, so rolling back the image alone is safe. Both runners (Supabase and `scripts/migrate-postgres.mjs`) apply it as a normal migration.

**Behaviour change: forms, comments and CDN count over the billing period.**
For subscribed workspaces these three meters now reset with the billing period, like the other quotas, and a yearly plan slices into monthly windows. In the transition month the usage-alert keys change from `YYYY-MM` to the slice start, so one extra usage alert may go out for these meters.

**Fix: yearly overage lock copy.**
A workspace on a yearly plan that tries to enable overage now sees that yearly plans do not bill overage, instead of the trial/not-in-subscription message.

**Policy change: emptying a field makes a content write `bulk_content`.**
A field counts as emptied when it had a value before the change and is empty after it (`''`, `null`, `[]`, `{}` or removed), and that includes sub-fields of objects and fields inside lists of objects. It is read from the branch's before/after, so the save and the Merge button give the same answer. Under the default policy nothing changes: `bulk_content` asks for the same single review. A policy that sets `low_risk_content` to `auto` now holds these writes, and the panel Merge holds them too. A blank optional sub-field that was already empty no longer lifts a save.

Expand Down
8 changes: 4 additions & 4 deletions app/components/organisms/WorkspaceUsagePanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ function overageLockText(lock: NonNullable<UsageCategory['overageLock']>): strin
? t('billing.overage_locked_trial', { date: new Date(lock.until).toLocaleDateString('en-US', { month: 'long', day: 'numeric' }) })
: t('billing.overage_locked_trial_undated')
}
if (lock.reason === 'yearly_plan') return t('billing.overage_locked_yearly')
return t('billing.overage_locked_subscription')
}

Expand All @@ -54,10 +55,9 @@ function formatDate(iso: string): string {
}

/**
* When this meter goes back to zero. Each meter carries its own date: AI,
* API and MCP follow the billing period, forms, comments and CDN the
* calendar month. One date for all of them was wrong for half of them, and
* two dates with no reason read as a bug, so the label names which one.
* When this meter goes back to zero. A subscribed workspace's meters all
* follow the billing period; one with no subscription counts the calendar
* month. The label names which one, so a date never reads as a bug.
*/
function resetLabel(category: UsageCategory): string | null {
if (category.resetsAt === null) return t('billing.usage_level_note')
Expand Down
2 changes: 1 addition & 1 deletion app/composables/useUsage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export interface UsageCategory {
* predate this meter). The toggle is off and disabled; `until` is when the
* lock lifts on its own, null when it waits for a subscription update.
*/
overageLock?: { reason: 'trialing' | 'not_in_subscription', until: string | null } | null
overageLock?: { reason: 'trialing' | 'not_in_subscription' | 'yearly_plan', until: string | null } | null
overageUnits: number
overageUnitPrice: number
overageAmount: number
Expand Down
4 changes: 3 additions & 1 deletion docs/CDN_EDGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,9 @@ host in the first week.
## Origin limit and meter

The origin's served bytes count against the plan's `cdn.bandwidth_gb`, per
workspace per calendar month. Cache hits never reach the origin and never count.
workspace per usage window: the billing period of a subscribed workspace (monthly slices of a
yearly one), the calendar month otherwise. The CDN keeps one row per UTC day, so the window opens and
closes on whole days. Cache hits never reach the origin and never count.

| Setting | Values | Default |
|---|---|---|
Expand Down
2 changes: 1 addition & 1 deletion docs/FORMS.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ cap). Collection models only. The same block is editable from the model's
| `honeypot` | `false` | Hidden `_hp` field; a filled honeypot is silently accepted and dropped |
| `captcha` | `null` | `'turnstile'` to require a Cloudflare Turnstile token (needs `forms.captcha` + `NUXT_TURNSTILE_SECRET_KEY`) |
| `limits.rateLimitPerIp` | `10` | Submissions per IP per minute |
| `limits.maxPerMonth` | — | Cap for this form in a calendar month, below the workspace plan limit |
| `limits.maxPerMonth` | — | Cap for this form in the billing period (calendar month without a subscription), below the workspace plan limit |
| `autoApprove` | `false` | Create the content entry immediately on submit (needs `forms.auto_approve`) |
| `notifications` | `true` | Email the workspace owner + admins on every submission (needs `forms.notifications`) |
| `successMessage` | — | Returned to the visitor after a successful submit |
Expand Down
2 changes: 2 additions & 0 deletions server/api/billing/webhook/[provider].post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,7 @@ export default defineEventHandler(async (event) => {
account: {
subscription_status: result.subscriptionStatus ?? 'trialing',
trial_ends_at: result.trialEndsAt ?? null,
current_period_start: result.currentPeriodStart ?? null,
current_period_end: result.currentPeriodEnd ?? null,
},
})
Expand Down Expand Up @@ -381,6 +382,7 @@ export default defineEventHandler(async (event) => {
account: {
subscription_status: result.subscriptionStatus ?? null,
trial_ends_at: result.trialEndsAt ?? (existingAccount?.trial_ends_at as string | null) ?? null,
current_period_start: result.currentPeriodStart ?? null,
current_period_end: result.currentPeriodEnd ?? null,
},
})
Expand Down
7 changes: 5 additions & 2 deletions server/api/cdn/v1/[projectId]/[...path].get.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
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 { isMediaSourcePath } from '../../../../utils/media-source'

Expand Down Expand Up @@ -133,7 +134,9 @@ export default defineEventHandler(async (event) => {
'cdn.bandwidth_gb',
(workspace?.overage_settings as Record<string, boolean> | null | undefined) ?? null,
)
const budget = await checkCdnOriginBudget({ workspaceId, limitGb })
// 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 })
if (!budget.allowed) {
setResponseHeader(event, 'Retry-After', budget.retryAfterSeconds)
throw createError({ statusCode: 429, message: errorMessage('cdn.origin_limit_reached', { limit: limitGb }) })
Expand Down Expand Up @@ -197,7 +200,7 @@ export default defineEventHandler(async (event) => {
if (keyId)
setResponseHeader(event, 'X-Contentrain-Key', keyId.substring(0, 8))

void addCdnOriginBytes(workspaceId, result.data.length)
void addCdnOriginBytes(workspaceId, result.data.length, new Date(), usagePeriod)

// Track CDN usage (fire-and-forget, Business+ feature). Keyed requests are
// attributed to the key; keyless public-media requests land in the project's
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { sanitizeString } from '~~/server/utils/sanitize-input'
import { verifyTurnstileToken } from '~~/server/utils/turnstile'
import { getEffectiveLimit } from '~~/server/utils/overage'
import { isUuid } from '~~/shared/utils/uuid'
import { resolveUsagePeriodCached, usageWindowOf } from '~~/server/utils/usage-period'

const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/

Expand Down Expand Up @@ -127,6 +128,7 @@ export default defineEventHandler(async (event) => {
const overageSettings = ctx.workspace.overage_settings as Record<string, boolean> | null
const monthlyLimit = getEffectiveLimit(basePlanLimit, 'comments.per_month', overageSettings)

const usageWindow = usageWindowOf(await resolveUsagePeriodCached(ctx.workspaceId))
const db = useDatabaseProvider()
const outcome = await db.createCommentIfAllowed(ctx.workspaceId, monthlyLimit, {
project_id: projectId,
Expand All @@ -144,7 +146,7 @@ export default defineEventHandler(async (event) => {
source_ip: ip !== 'unknown' ? ip : undefined,
user_agent: getHeader(event, 'user-agent') ?? undefined,
referrer: getHeader(event, 'referer') ?? getHeader(event, 'referrer') ?? undefined,
})
}, usageWindow)

if (!outcome.allowed) {
switch (outcome.reason) {
Expand Down
6 changes: 5 additions & 1 deletion server/api/forms/v1/[projectId]/[modelId]/submit.post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { createContentEngine } from '~~/server/utils/content-engine'
import { generateEntryId } from '@contentrain/types'
import { resolveWorkspaceBilling } from '~~/server/utils/workspace-billing'
import { reportBillingRisk } from '~~/server/utils/alert'
import { resolveUsagePeriodCached, usageWindowOf } from '~~/server/utils/usage-period'

export default defineEventHandler(async (event) => {
const db = useDatabaseProvider()
Expand Down Expand Up @@ -179,6 +180,8 @@ export default defineEventHandler(async (event) => {
const basePlanLimit = getPlanLimit(plan, 'forms.submissions_per_month')
const overageSettings = billing.overageSettings
const monthlyLimit = getEffectiveLimit(basePlanLimit, 'forms.submissions_per_month', overageSettings)
// The window the quota counts over: a subscribed workspace's billing slice, else the calendar month.
const usageWindow = usageWindowOf(await resolveUsagePeriodCached(workspace.id as string))

// Per-model cap from the form config (below the workspace plan limit).
const modelCap = formConfig.limits?.maxPerMonth
Expand All @@ -187,7 +190,7 @@ export default defineEventHandler(async (event) => {
// would let every submission past the cap (AI-15).
let used: number
try {
used = await db.countMonthlySubmissionsForModel(workspace.id as string, projectId, modelId)
used = await db.countMonthlySubmissionsForModel(workspace.id as string, projectId, modelId, usageWindow)
}
catch (err) {
reportBillingRisk(err, { op: 'forms.model-cap-read', workspaceId: workspace.id as string })
Expand All @@ -210,6 +213,7 @@ export default defineEventHandler(async (event) => {
referrer: referrer ?? undefined,
locale,
},
usageWindow,
)

if (!allowed)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ function lockedError(lock: OverageLock) {
? new Date(lock.until).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' })
: 'the end of your trial',
})
: errorMessage('billing.overage_locked_subscription')
: errorMessage(lock.reason === 'yearly_plan' ? 'billing.overage_locked_yearly' : 'billing.overage_locked_subscription')
return createError({ statusCode: 409, message, data: { code: 'overage_locked', reason: lock.reason, until: lock.until } })
}

Expand Down
27 changes: 19 additions & 8 deletions server/providers/database.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { UsageWindow } from '../utils/usage-period'

export type DatabaseRow = Record<string, unknown>

// ─── Domain types ───
Expand Down Expand Up @@ -665,15 +667,20 @@ export interface DatabaseProvider {
projectId?: string
modelId?: string
}) => Promise<number>
countMonthlySubmissions: (workspaceId: string) => Promise<number>
/** This calendar month's submissions for one form model (per-model `limits.maxPerMonth`). */
countMonthlySubmissionsForModel: (workspaceId: string, projectId: string, modelId: string) => Promise<number>
/** Submissions in `window` (a subscribed workspace's billing slice), or this calendar month without one. */
countMonthlySubmissions: (workspaceId: string, window?: UsageWindow) => Promise<number>
/** One form model's submissions in `window`, or this calendar month (per-model `limits.maxPerMonth`). */
countMonthlySubmissionsForModel: (workspaceId: string, projectId: string, modelId: string, window?: UsageWindow) => Promise<number>

/** Atomic: check monthly limit + insert submission. Prevents race conditions. */
/**
* Atomic: check monthly limit + insert submission. Prevents race conditions.
* The limit is counted over `window`, or the calendar month without one.
*/
createFormSubmissionIfAllowed: (
workspaceId: string,
monthlyLimit: number,
submission: FormSubmissionInput,
window?: UsageWindow,
) => Promise<{ allowed: boolean, currentCount: number, submission?: DatabaseRow }>

/**
Expand All @@ -697,6 +704,7 @@ export interface DatabaseProvider {
workspaceId: string,
monthlyLimit: number,
comment: CommentInput & { max_depth: number },
window?: UsageWindow,
) => Promise<{
allowed: boolean
reason?: 'thread_closed' | 'parent_not_found' | 'depth_exceeded' | 'monthly_limit'
Expand Down Expand Up @@ -725,8 +733,8 @@ export interface DatabaseProvider {
workspaceId?: string
projectId?: string
}) => Promise<number>
/** Public (`source = 'web'`) comments this calendar month — the quota meter. */
countMonthlyComments: (workspaceId: string) => Promise<number>
/** Public (`source = 'web'`) comments in `window`, or this calendar month — the quota meter. */
countMonthlyComments: (workspaceId: string, window?: UsageWindow) => Promise<number>
/** Pending/approved/spam/rejected counts for a project (optionally one model). */
countCommentsByStatus: (projectId: string, modelId?: string) => Promise<Record<CommentStatus, number>>
/** WordPress import — one transaction, idempotent on source id; see `import_comments`. */
Expand Down Expand Up @@ -1000,8 +1008,11 @@ export interface DatabaseProvider {
getWorkspaceMonthlyAIUsage: (workspaceId: string, month: string, source?: 'studio' | 'byoa') => Promise<number>
/** Sum API message count (source=api) across all API keys in workspace for a month. */
getWorkspaceMonthlyAPIUsage: (workspaceId: string, month: string) => Promise<number>
/** Sum CDN bandwidth bytes across all projects in workspace for a month. */
getWorkspaceMonthlyCDNBandwidth: (workspaceId: string, month: string) => Promise<number>
/**
* Sum CDN bandwidth bytes across all projects in workspace for a month, or for `window`
* when given (whole UTC days: the day `from` falls on through the day before `to`'s).
*/
getWorkspaceMonthlyCDNBandwidth: (workspaceId: string, month: string, window?: UsageWindow) => Promise<number>
/**
* CDN bytes served per workspace on one UTC day (`YYYY-MM-DD`), summed
* over every project and key. Workspaces with no usage that day are left
Expand Down
15 changes: 9 additions & 6 deletions server/providers/postgres-db/comments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export function commentMethods(): CommentMethods {
}
},

async createCommentIfAllowed(workspaceId, monthlyLimit, comment) {
async createCommentIfAllowed(workspaceId, monthlyLimit, comment, window) {
let result: {
allowed: boolean
reason?: 'thread_closed' | 'parent_not_found' | 'depth_exceeded' | 'monthly_limit'
Expand All @@ -82,7 +82,9 @@ export function commentMethods(): CommentMethods {
p_status => ${comment.status ?? 'pending'},
p_source_ip => ${comment.source_ip ?? null},
p_user_agent => ${comment.user_agent ?? null},
p_referrer => ${comment.referrer ?? null}
p_referrer => ${comment.referrer ?? null},
p_window_start => ${window?.from ?? null},
p_window_end => ${window?.to ?? null}
) AS result
`.execute(getAdmin())

Expand Down Expand Up @@ -243,17 +245,18 @@ export function commentMethods(): CommentMethods {
}
},

async countMonthlyComments(workspaceId) {
async countMonthlyComments(workspaceId, window) {
const now = new Date()
const monthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1))

const row = await getAdmin()
let query = getAdmin()
.selectFrom('comments')
.select(eb => eb.fn.countAll().as('count'))
.where('workspace_id', '=', workspaceId)
.where('source', '=', 'web')
.where('created_at', '>=', monthStart.toISOString())
.executeTakeFirst()
.where('created_at', '>=', window?.from ?? monthStart.toISOString())
if (window) query = query.where('created_at', '<', window.to)
const row = await query.executeTakeFirst()

return Number(row?.count ?? 0)
},
Expand Down
Loading
Loading