diff --git a/.contentrain/content/system/error-messages/en.json b/.contentrain/content/system/error-messages/en.json index 26b01c8a..2201ed13 100644 --- a/.contentrain/content/system/error-messages/en.json +++ b/.contentrain/content/system/error-messages/en.json @@ -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.", diff --git a/.contentrain/content/system/ui-strings/en.json b/.contentrain/content/system/ui-strings/en.json index 00c78ad7..4faa5904 100644 --- a/.contentrain/content/system/ui-strings/en.json +++ b/.contentrain/content/system/ui-strings/en.json @@ -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", diff --git a/CHANGELOG.md b/CHANGELOG.md index 368bffad..4c9df2ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/app/components/organisms/WorkspaceUsagePanel.vue b/app/components/organisms/WorkspaceUsagePanel.vue index a2073add..26eca5fe 100644 --- a/app/components/organisms/WorkspaceUsagePanel.vue +++ b/app/components/organisms/WorkspaceUsagePanel.vue @@ -30,6 +30,7 @@ function overageLockText(lock: NonNullable): 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') } @@ -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') diff --git a/app/composables/useUsage.ts b/app/composables/useUsage.ts index 77aeaae8..e5afdf5e 100644 --- a/app/composables/useUsage.ts +++ b/app/composables/useUsage.ts @@ -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 diff --git a/docs/CDN_EDGE.md b/docs/CDN_EDGE.md index 07e0206d..9127a8bc 100644 --- a/docs/CDN_EDGE.md +++ b/docs/CDN_EDGE.md @@ -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 | |---|---|---| diff --git a/docs/FORMS.md b/docs/FORMS.md index 5345814d..3dcab6db 100644 --- a/docs/FORMS.md +++ b/docs/FORMS.md @@ -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 | diff --git a/server/api/billing/webhook/[provider].post.ts b/server/api/billing/webhook/[provider].post.ts index 88128f5b..d1278004 100644 --- a/server/api/billing/webhook/[provider].post.ts +++ b/server/api/billing/webhook/[provider].post.ts @@ -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, }, }) @@ -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, }, }) diff --git a/server/api/cdn/v1/[projectId]/[...path].get.ts b/server/api/cdn/v1/[projectId]/[...path].get.ts index 77a68cec..8304c4f5 100644 --- a/server/api/cdn/v1/[projectId]/[...path].get.ts +++ b/server/api/cdn/v1/[projectId]/[...path].get.ts @@ -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' @@ -133,7 +134,9 @@ export default defineEventHandler(async (event) => { 'cdn.bandwidth_gb', (workspace?.overage_settings as Record | 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 }) }) @@ -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 diff --git a/server/api/comments/v1/[projectId]/[modelId]/[entryId].post.ts b/server/api/comments/v1/[projectId]/[modelId]/[entryId].post.ts index 18b95292..c56d3e42 100644 --- a/server/api/comments/v1/[projectId]/[modelId]/[entryId].post.ts +++ b/server/api/comments/v1/[projectId]/[modelId]/[entryId].post.ts @@ -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@]+$/ @@ -127,6 +128,7 @@ export default defineEventHandler(async (event) => { const overageSettings = ctx.workspace.overage_settings as Record | 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, @@ -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) { diff --git a/server/api/forms/v1/[projectId]/[modelId]/submit.post.ts b/server/api/forms/v1/[projectId]/[modelId]/submit.post.ts index 1618d6fe..f86a08a1 100644 --- a/server/api/forms/v1/[projectId]/[modelId]/submit.post.ts +++ b/server/api/forms/v1/[projectId]/[modelId]/submit.post.ts @@ -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() @@ -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 @@ -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 }) @@ -210,6 +213,7 @@ export default defineEventHandler(async (event) => { referrer: referrer ?? undefined, locale, }, + usageWindow, ) if (!allowed) diff --git a/server/api/workspaces/[workspaceId]/overage-settings.patch.ts b/server/api/workspaces/[workspaceId]/overage-settings.patch.ts index a678957d..fa76b490 100644 --- a/server/api/workspaces/[workspaceId]/overage-settings.patch.ts +++ b/server/api/workspaces/[workspaceId]/overage-settings.patch.ts @@ -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 } }) } diff --git a/server/providers/database.ts b/server/providers/database.ts index 894710e3..4b098317 100644 --- a/server/providers/database.ts +++ b/server/providers/database.ts @@ -1,3 +1,5 @@ +import type { UsageWindow } from '../utils/usage-period' + export type DatabaseRow = Record // ─── Domain types ─── @@ -665,15 +667,20 @@ export interface DatabaseProvider { projectId?: string modelId?: string }) => Promise - countMonthlySubmissions: (workspaceId: string) => Promise - /** This calendar month's submissions for one form model (per-model `limits.maxPerMonth`). */ - countMonthlySubmissionsForModel: (workspaceId: string, projectId: string, modelId: string) => Promise + /** Submissions in `window` (a subscribed workspace's billing slice), or this calendar month without one. */ + countMonthlySubmissions: (workspaceId: string, window?: UsageWindow) => Promise + /** 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 - /** 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 }> /** @@ -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' @@ -725,8 +733,8 @@ export interface DatabaseProvider { workspaceId?: string projectId?: string }) => Promise - /** Public (`source = 'web'`) comments this calendar month — the quota meter. */ - countMonthlyComments: (workspaceId: string) => Promise + /** Public (`source = 'web'`) comments in `window`, or this calendar month — the quota meter. */ + countMonthlyComments: (workspaceId: string, window?: UsageWindow) => Promise /** Pending/approved/spam/rejected counts for a project (optionally one model). */ countCommentsByStatus: (projectId: string, modelId?: string) => Promise> /** WordPress import — one transaction, idempotent on source id; see `import_comments`. */ @@ -1000,8 +1008,11 @@ export interface DatabaseProvider { getWorkspaceMonthlyAIUsage: (workspaceId: string, month: string, source?: 'studio' | 'byoa') => Promise /** Sum API message count (source=api) across all API keys in workspace for a month. */ getWorkspaceMonthlyAPIUsage: (workspaceId: string, month: string) => Promise - /** Sum CDN bandwidth bytes across all projects in workspace for a month. */ - getWorkspaceMonthlyCDNBandwidth: (workspaceId: string, month: string) => Promise + /** + * 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 /** * 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 diff --git a/server/providers/postgres-db/comments.ts b/server/providers/postgres-db/comments.ts index 5a8ea507..b866109b 100644 --- a/server/providers/postgres-db/comments.ts +++ b/server/providers/postgres-db/comments.ts @@ -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' @@ -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()) @@ -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) }, diff --git a/server/providers/postgres-db/forms.ts b/server/providers/postgres-db/forms.ts index 365fcf7e..e694db05 100644 --- a/server/providers/postgres-db/forms.ts +++ b/server/providers/postgres-db/forms.ts @@ -166,32 +166,34 @@ export function formMethods(): FormMethods { } }, - async countMonthlySubmissions(workspaceId) { + async countMonthlySubmissions(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('form_submissions') .select(eb => eb.fn.countAll().as('count')) .where('workspace_id', '=', workspaceId) - .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) }, - async countMonthlySubmissionsForModel(workspaceId, projectId, modelId) { + async countMonthlySubmissionsForModel(workspaceId, projectId, modelId, window) { const now = new Date() const monthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)) - const row = await getAdmin() + let query = getAdmin() .selectFrom('form_submissions') .select(eb => eb.fn.countAll().as('count')) .where('workspace_id', '=', workspaceId) .where('project_id', '=', projectId) .where('model_id', '=', modelId) - .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) }, @@ -230,7 +232,7 @@ export function formMethods(): FormMethods { } }, - async createFormSubmissionIfAllowed(workspaceId, monthlyLimit, submission) { + async createFormSubmissionIfAllowed(workspaceId, monthlyLimit, submission, window) { let result: { allowed: boolean, current_count: number, submission?: Record } try { const outcome = await sql<{ result: typeof result }>` @@ -244,7 +246,9 @@ export function formMethods(): FormMethods { p_source_ip => ${submission.source_ip ?? null}, p_user_agent => ${submission.user_agent ?? null}, p_referrer => ${submission.referrer ?? null}, - p_locale => ${submission.locale ?? 'en'} + p_locale => ${submission.locale ?? 'en'}, + p_window_start => ${window?.from ?? null}, + p_window_end => ${window?.to ?? null} ) AS result `.execute(getAdmin()) diff --git a/server/providers/postgres-db/usage.ts b/server/providers/postgres-db/usage.ts index 86fe7617..31dac7cf 100644 --- a/server/providers/postgres-db/usage.ts +++ b/server/providers/postgres-db/usage.ts @@ -7,6 +7,7 @@ * error would let a quota path through (AI-15). */ import type { DatabaseProvider } from '../database' +import { cdnDayWindow } from '../../utils/usage-period' import { getAdmin } from './helpers' type UsageMethods = Pick< @@ -44,13 +45,10 @@ export function usageMethods(): UsageMethods { return Number(row?.total ?? 0) }, - async getWorkspaceMonthlyCDNBandwidth(workspaceId, month) { - // Same month-window computation as the Supabase impl, one join instead + async getWorkspaceMonthlyCDNBandwidth(workspaceId, month, window) { + // Same day-window computation as the Supabase impl, one join instead // of its two round-trips (identical semantics). - const monthStart = `${month}-01` - const nextMonth = new Date(`${month}-01`) - nextMonth.setMonth(nextMonth.getMonth() + 1) - const monthEnd = nextMonth.toISOString().substring(0, 10) + const { monthStart, monthEnd } = cdnDayWindow(month, window) const row = await getAdmin() .selectFrom('cdn_usage as cu') diff --git a/server/providers/supabase-db/comments.ts b/server/providers/supabase-db/comments.ts index bc4ac1ac..8a6c419f 100644 --- a/server/providers/supabase-db/comments.ts +++ b/server/providers/supabase-db/comments.ts @@ -53,7 +53,7 @@ export function commentMethods(): CommentMethods { return data as DatabaseRow }, - async createCommentIfAllowed(workspaceId, monthlyLimit, comment) { + async createCommentIfAllowed(workspaceId, monthlyLimit, comment, window) { const { data, error } = await getAdmin().rpc('create_comment_if_allowed', { p_workspace_id: workspaceId, p_monthly_limit: monthlyLimit, @@ -71,6 +71,8 @@ export function commentMethods(): CommentMethods { p_source_ip: comment.source_ip ?? null, p_user_agent: comment.user_agent ?? null, p_referrer: comment.referrer ?? null, + p_window_start: window?.from ?? null, + p_window_end: window?.to ?? null, }) if (error) { @@ -207,16 +209,18 @@ export function commentMethods(): CommentMethods { return data?.length ?? 0 }, - async countMonthlyComments(workspaceId) { + async countMonthlyComments(workspaceId, window) { const now = new Date() const monthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)) - const { count, error } = await getAdmin() + let query = getAdmin() .from('comments') .select('*', { count: 'exact', head: true }) .eq('workspace_id', workspaceId) .eq('source', 'web') - .gte('created_at', monthStart.toISOString()) + .gte('created_at', window?.from ?? monthStart.toISOString()) + if (window) query = query.lt('created_at', window.to) + const { count, error } = await query if (error) throw createError({ statusCode: 500, message: error.message }) return count ?? 0 diff --git a/server/providers/supabase-db/forms.ts b/server/providers/supabase-db/forms.ts index a4789976..2094ddb1 100644 --- a/server/providers/supabase-db/forms.ts +++ b/server/providers/supabase-db/forms.ts @@ -123,31 +123,35 @@ export function formMethods(): FormMethods { return data?.length ?? 0 }, - async countMonthlySubmissions(workspaceId) { + async countMonthlySubmissions(workspaceId, window) { const now = new Date() const monthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)) - const { count, error } = await getAdmin() + let query = getAdmin() .from('form_submissions') .select('*', { count: 'exact', head: true }) .eq('workspace_id', workspaceId) - .gte('created_at', monthStart.toISOString()) + .gte('created_at', window?.from ?? monthStart.toISOString()) + if (window) query = query.lt('created_at', window.to) + const { count, error } = await query if (error) throw createError({ statusCode: 500, message: error.message }) return count ?? 0 }, - async countMonthlySubmissionsForModel(workspaceId, projectId, modelId) { + async countMonthlySubmissionsForModel(workspaceId, projectId, modelId, window) { const now = new Date() const monthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)) - const { count, error } = await getAdmin() + let query = getAdmin() .from('form_submissions') .select('*', { count: 'exact', head: true }) .eq('workspace_id', workspaceId) .eq('project_id', projectId) .eq('model_id', modelId) - .gte('created_at', monthStart.toISOString()) + .gte('created_at', window?.from ?? monthStart.toISOString()) + if (window) query = query.lt('created_at', window.to) + const { count, error } = await query if (error) throw createError({ statusCode: 500, message: error.message }) return count ?? 0 @@ -177,7 +181,7 @@ export function formMethods(): FormMethods { .map(p => ({ userId: p.id, email: p.email as string })) }, - async createFormSubmissionIfAllowed(workspaceId, monthlyLimit, submission) { + async createFormSubmissionIfAllowed(workspaceId, monthlyLimit, submission, window) { const admin = getAdmin() const { data, error } = await admin.rpc('create_form_submission_if_allowed', { p_workspace_id: workspaceId, @@ -190,6 +194,8 @@ export function formMethods(): FormMethods { p_user_agent: submission.user_agent ?? null, p_referrer: submission.referrer ?? null, p_locale: submission.locale ?? 'en', + p_window_start: window?.from ?? null, + p_window_end: window?.to ?? null, }) if (error) { diff --git a/server/providers/supabase-db/usage.ts b/server/providers/supabase-db/usage.ts index a5f6a3ad..667269fb 100644 --- a/server/providers/supabase-db/usage.ts +++ b/server/providers/supabase-db/usage.ts @@ -9,6 +9,7 @@ * than reading as 0, which would let a quota path through (AI-15). */ import type { DatabaseProvider } from '../database' +import { cdnDayWindow } from '../../utils/usage-period' import { getAdmin } from './helpers' type UsageMethods = Pick< @@ -52,7 +53,7 @@ export function usageMethods(): UsageMethods { ) }, - async getWorkspaceMonthlyCDNBandwidth(workspaceId, month) { + async getWorkspaceMonthlyCDNBandwidth(workspaceId, month, window) { const admin = getAdmin() const { data: projects, error } = await admin @@ -65,10 +66,7 @@ export function usageMethods(): UsageMethods { const projectIds = projects.map((p: Record) => p.id as string) - const monthStart = `${month}-01` - const nextMonth = new Date(`${month}-01`) - nextMonth.setMonth(nextMonth.getMonth() + 1) - const monthEnd = nextMonth.toISOString().substring(0, 10) + const { monthStart, monthEnd } = cdnDayWindow(month, window) const { data, error: usageError } = await admin .from('cdn_usage') diff --git a/server/utils/cdn-origin-budget.ts b/server/utils/cdn-origin-budget.ts index c6a12182..dc2a74e1 100644 --- a/server/utils/cdn-origin-budget.ts +++ b/server/utils/cdn-origin-budget.ts @@ -6,16 +6,16 @@ * origin, so counting bytes here counts exactly that — and a cached site * rarely comes near its limit. * - * Counter: Redis `INCRBY` per workspace per calendar month (the CDN is - * counted per calendar month, `usage-period.ts`), seeded from the durable - * `cdn_usage` total when the key is missing (restart, eviction). In-memory + * Counter: Redis `INCRBY` per workspace per usage window (`usage-period.ts`: the + * billing slice of a subscribed workspace, else the calendar month), seeded from the + * durable `cdn_usage` total when the key is missing (restart, eviction). In-memory * without Redis. The counter can trail the durable total by in-flight * requests; it only decides when to refuse. * * 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 month resets, unless + * answers 429 with Retry-After until the window resets, unless * overage is on (`getEffectiveLimit`). * - `observe` — count and log, never refuse (self-hosters, operators). * - `off` — neither count nor refuse. @@ -23,6 +23,8 @@ import { CDN_ORIGIN_HARD_STOP_RATIO } from '../../shared/utils/cdn-limit' import { useDatabaseProvider } from './providers' import { getRedis } from './redis' +import { usagePeriodFrom, usageWindowOf } from './usage-period' +import type { UsagePeriod } from './usage-period' export type CdnOriginLimitMode = 'off' | 'observe' | 'enforce' @@ -37,34 +39,36 @@ export function cdnOriginLimitMode(): CdnOriginLimitMode { return raw === 'off' || raw === 'observe' ? raw : 'enforce' } -function monthKey(now: Date): string { - return now.toISOString().substring(0, 7) +/** The window a request is counted in; the calendar month when the caller names none. */ +function windowOf(now: Date, period?: UsagePeriod): UsagePeriod { + return period ?? usagePeriodFrom(null, now) } function counterKey(workspaceId: string, month: string): string { return `cdnorigin:${workspaceId}:${month}` } -/** Seconds until the first instant of next calendar month (UTC). */ -export function secondsUntilMonthReset(now: Date = new Date()): number { - const next = Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1) +/** Seconds until the window resets: next calendar month (UTC), or the billing slice's end. */ +export function secondsUntilMonthReset(now: Date = new Date(), period?: UsagePeriod): number { + const next = period ? new Date(period.resetsAt).getTime() : Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1) return Math.max(1, Math.ceil((next - now.getTime()) / 1000)) } -async function readUsedBytes(workspaceId: string, now: Date): Promise { - const month = monthKey(now) +async function readUsedBytes(workspaceId: string, now: Date, period?: UsagePeriod): Promise { + const window = windowOf(now, period) + const month = window.key const key = counterKey(workspaceId, month) const redis = getRedis() if (redis) { const cached = await redis.get(key).catch(() => null) if (cached !== null) return Number(cached) || 0 - const seed = await useDatabaseProvider().getWorkspaceMonthlyCDNBandwidth(workspaceId, month) + const seed = await useDatabaseProvider().getWorkspaceMonthlyCDNBandwidth(workspaceId, month, usageWindowOf(window)) await redis.set(key, String(seed), 'EX', KEY_TTL_SECONDS, 'NX').catch(() => null) return seed } const cached = memoryCounters.get(key) if (cached !== undefined) return cached - const seed = await useDatabaseProvider().getWorkspaceMonthlyCDNBandwidth(workspaceId, month) + const seed = await useDatabaseProvider().getWorkspaceMonthlyCDNBandwidth(workspaceId, month, usageWindowOf(window)) memoryCounters.set(key, seed) return seed } @@ -78,20 +82,22 @@ export async function checkCdnOriginBudget(input: { workspaceId: string limitGb: number now?: Date + /** The workspace's usage window; absent = calendar month. */ + period?: UsagePeriod }): Promise<{ allowed: true } | { allowed: false, retryAfterSeconds: number }> { const mode = cdnOriginLimitMode() if (mode === 'off' || !Number.isFinite(input.limitGb)) return { allowed: true } const now = input.now ?? new Date() let used: number try { - used = await readUsedBytes(input.workspaceId, now) + used = await readUsedBytes(input.workspaceId, now, input.period) } catch { return { allowed: true } } if (used < input.limitGb * GIB) return { allowed: true } - const logKey = `${input.workspaceId}:${monthKey(now)}` + const logKey = `${input.workspaceId}:${windowOf(now, input.period).key}` if (!loggedOver.has(logKey)) { loggedOver.add(logKey) // eslint-disable-next-line no-console -- ops signal; the owner is told by the usage alert @@ -99,13 +105,13 @@ export async function checkCdnOriginBudget(input: { } // 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 } - return { allowed: false, retryAfterSeconds: secondsUntilMonthReset(now) } + return { allowed: false, retryAfterSeconds: secondsUntilMonthReset(now, input.period) } } /** Add served bytes to the workspace's counter. Fire-and-forget. */ -export async function addCdnOriginBytes(workspaceId: string, bytes: number, now: Date = new Date()): Promise { +export async function addCdnOriginBytes(workspaceId: string, bytes: number, now: Date = new Date(), period?: UsagePeriod): Promise { if (cdnOriginLimitMode() === 'off' || bytes <= 0) return - const key = counterKey(workspaceId, monthKey(now)) + const key = counterKey(workspaceId, windowOf(now, period).key) const redis = getRedis() if (redis) { // A missing key is seeded on the next check; INCRBY on it now would diff --git a/server/utils/overage-lock.ts b/server/utils/overage-lock.ts index b23c5b58..58088fd2 100644 --- a/server/utils/overage-lock.ts +++ b/server/utils/overage-lock.ts @@ -32,7 +32,7 @@ import { OVERAGE_SETTINGS_KEYS } from '../../shared/utils/license' import { USAGE_METERS, USAGE_METER_LIST } from '../../shared/utils/usage-meters' import { CURRENT_CREDIT_UNIT, creditTermsFor, creditUnitFromMeters } from '../../shared/utils/credit-unit' -export type OverageLockReason = 'trialing' | 'not_in_subscription' +export type OverageLockReason = 'trialing' | 'not_in_subscription' | 'yearly_plan' export interface OverageLock { reason: OverageLockReason @@ -44,6 +44,7 @@ export interface OverageLock { export interface OverageLockAccount { subscription_status?: string | null trial_ends_at?: string | Date | null + current_period_start?: string | Date | null current_period_end?: string | Date | null plugin_metadata?: unknown } @@ -62,6 +63,23 @@ function toIso(value: string | Date | null | undefined): string | null { return Number.isNaN(date.getTime()) ? null : date.toISOString() } +/** + * A billing period longer than a month: a yearly subscription. + * + * The provider invoices metered usage on the subscription's own cycle, so a + * yearly subscription would bill overage once a year — not what a yearly plan + * promises (usage and overage are monthly). Until overage is billed monthly + * on a yearly plan, usage stops at the plan limit; this is told to the + * customer as what it is, instead of "contact support to update it". + */ +export function isYearlyPeriod(account: Pick): boolean { + const start = toIso(account.current_period_start) + const end = toIso(account.current_period_end) + if (!start || !end) return false + // Anything past a 31-day month; a yearly period is 365 days. + return new Date(end).getTime() - new Date(start).getTime() > 35 * 24 * 60 * 60 * 1000 +} + /** The meters the subscription prices, or null when none were recorded. */ export function readBillableMeters(pluginMetadata: unknown): string[] | null { if (!pluginMetadata || typeof pluginMetadata !== 'object') return null @@ -85,11 +103,12 @@ export function resolveOverageLocks(account: OverageLockAccount | null | undefin // 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 + const reason: OverageLockReason = isYearlyPeriod(account) ? 'yearly_plan' : 'not_in_subscription' for (const key of OVERAGE_SETTINGS_KEYS) { const meter = key === USAGE_METERS.AI_MESSAGES.settingsKey ? creditMeters.ai : key === USAGE_METERS.API_MESSAGES.settingsKey ? creditMeters.api : METER_NAME_BY_SETTINGS_KEY[key] - if (!meter || !billable.includes(meter)) locks[key] = { reason: 'not_in_subscription', until: null } + if (!meter || !billable.includes(meter)) locks[key] = { reason, until: null } } return locks } diff --git a/server/utils/usage-period.ts b/server/utils/usage-period.ts index 6dd99b6a..6bd7985d 100644 --- a/server/utils/usage-period.ts +++ b/server/utils/usage-period.ts @@ -17,13 +17,17 @@ * one is ten characters, the other seven — so historical rows keep their * own key and nothing needs rewriting. * - * Scope: this covers the three quota pools keyed by that column — AI - * credits (`agent_usage`), API credits (`api_message_usage`) and MCP - * calls (`mcp_cloud_usage` / `mcp_oauth_usage`). Form submissions, - * comments and CDN bandwidth are still counted per calendar month - * because their rows are written by date-range aggregators rather than - * by a period key; aligning those is a larger change and is tracked - * separately. + * Scope: every counter that resets. AI credits (`agent_usage`), API credits + * (`api_message_usage`) and MCP calls (`mcp_cloud_usage` / `mcp_oauth_usage`) are + * keyed by the window's key. Form submissions, comments and CDN origin transfer + * are counted from rows by date range, so they are read over the window's + * `[startsAt, resetsAt)` (`usageWindowOf`) instead of the calendar month; a + * workspace with no billing period keeps the calendar month for them too. The CDN + * keeps one row per UTC day, so its window opens and closes on whole days. + * + * Why they must follow the window: the payment provider invoices overage on the + * subscription's own cycle, so a counter that resets on the 1st while the invoice + * closes on the 21st shows the owner one number and bills another. */ export type UsagePeriodSource = 'billing' | 'calendar' @@ -97,6 +101,34 @@ function sliceEnd(start: Date, billingEnd: Date | null): Date { return monthly } +/** The span a row-counted meter is read over: `[from, to)` as ISO instants. */ +export interface UsageWindow { + from: string + to: string +} + +/** + * The window to read form, comment and CDN counts over, or undefined for the + * calendar month (a workspace with no billing period — the readers' default). + */ +export function usageWindowOf(period: UsagePeriod): UsageWindow | undefined { + return period.source === 'billing' ? { from: period.startsAt, to: period.resetsAt } : undefined +} + +/** + * The UTC days a CDN read covers, as `[monthStart, monthEnd)` `YYYY-MM-DD` strings. + * + * `cdn_usage` keeps one row per day, so a billing window opens on the day + * `from` falls on and closes before the day `to` falls on: each day belongs to + * exactly one slice. Without a window, `month` (`YYYY-MM`) is the calendar month. + */ +export function cdnDayWindow(month: string, window?: UsageWindow): { monthStart: string, monthEnd: string } { + if (window) return { monthStart: window.from.substring(0, 10), monthEnd: window.to.substring(0, 10) } + const next = new Date(`${month}-01`) + next.setMonth(next.getMonth() + 1) + return { monthStart: `${month}-01`, monthEnd: next.toISOString().substring(0, 10) } +} + function calendarPeriod(now: Date): UsagePeriod { const start = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)) const end = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1)) @@ -170,3 +202,32 @@ export async function resolveUsagePeriod(workspaceId: string, now: Date = new Da return calendarPeriod(now) } } + +/** How long a workspace's payment account is remembered by `resolveUsagePeriodCached`. */ +const PERIOD_ACCOUNT_TTL_MS = 60_000 +const accountCache = new Map() + +/** + * `resolveUsagePeriod` for hot paths (the CDN origin answers every request). + * + * Only the account row is remembered, for a minute; the window is worked out + * from it at `now` each call, so a slice boundary is never held past its time. + * A failed read is not remembered and degrades to the calendar month. + */ +export async function resolveUsagePeriodCached(workspaceId: string, now: Date = new Date()): Promise { + const hit = accountCache.get(workspaceId) + if (hit && now.getTime() - hit.at < PERIOD_ACCOUNT_TTL_MS && now.getTime() >= hit.at) return usagePeriodFrom(hit.account, now) + try { + const account = await useDatabaseProvider().getActivePaymentAccount(workspaceId) as UsagePeriodAccount | null + accountCache.set(workspaceId, { account, at: now.getTime() }) + return usagePeriodFrom(account, now) + } + catch { + return calendarPeriod(now) + } +} + +/** Test helper. */ +export function __resetUsagePeriodCache(): void { + accountCache.clear() +} diff --git a/server/utils/workspace-usage.ts b/server/utils/workspace-usage.ts index 741b5109..21441e17 100644 --- a/server/utils/workspace-usage.ts +++ b/server/utils/workspace-usage.ts @@ -6,11 +6,12 @@ * reached" mean; two copies of this arithmetic would drift the same way the * receipt code once did. Both call this. * - * Each meter carries its own counting window. AI credits, API credits and MCP - * calls follow the billing period; form submissions, comments and CDN - * bandwidth are still counted per calendar month (see `usage-period.ts`). - * Showing one "Resets" date for all of them told a customer billed from the - * 15th that forms reset on the 15th when they reset on the 1st. + * Each meter carries its own counting window, and for a subscribed workspace + * they are all the billing period's (`usage-period.ts`): the provider invoices + * overage on the subscription's cycle, so a counter that reset on the 1st while + * the invoice closes on the 15th would show one number and bill another. A + * workspace with no subscription counts every meter by calendar month. Storage + * 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 @@ -30,7 +31,7 @@ import type { DatabaseProvider } from '../providers/database' import { calculateOverageUnits, isOverageSellable } from './overage' import type { OverageLock } from './overage-lock' import { reportBillingRisk } from './alert' -import { usagePeriodFrom } from './usage-period' +import { usagePeriodFrom, usageWindowOf } from './usage-period' import type { UsagePeriod } from './usage-period' export interface WorkspaceUsageCategory { @@ -107,10 +108,12 @@ export async function computeWorkspaceUsage(db: UsageReader, input: { const { workspaceId, plan, overageSettings, period } = input const terms = creditTermsFor(input.creditUnit ?? CURRENT_CREDIT_UNIT) const now = input.now ?? new Date() - // Forms, comments and CDN keep the calendar month: their rows are written - // by date-range aggregators, and the CDN reader expands a `YYYY-MM` key into - // a month window — a `YYYY-MM-DD` key would report zero. + // Forms, comments and CDN are counted from rows by date range, so they are + // read over the period's window (`usageWindowOf`), not keyed by it. Without a + // billing period the window is undefined and the readers use the calendar month. const calendar = usagePeriodFrom(null, now) + const rowPeriod = period.source === 'billing' ? period : calendar + const window = usageWindowOf(period) const reads = await Promise.allSettled([ db.getWorkspaceMonthlyAIUsage(workspaceId, period.key), @@ -118,10 +121,10 @@ export async function computeWorkspaceUsage(db: UsageReader, input: { // never counted in them (migration 030, chat route metering guard). db.getWorkspaceMonthlyAIUsage(workspaceId, period.key, 'byoa'), db.getWorkspaceMonthlyAPIUsage(workspaceId, period.key), - db.countMonthlySubmissions(workspaceId), - db.getWorkspaceMonthlyCDNBandwidth(workspaceId, calendar.key), + db.countMonthlySubmissions(workspaceId, window), + db.getWorkspaceMonthlyCDNBandwidth(workspaceId, calendar.key, window), db.getWorkspaceMonthlyMcpCloudUsage(workspaceId, period.key), - db.countMonthlyComments(workspaceId), + db.countMonthlyComments(workspaceId, window), ]) const failed = reads.find((r): r is PromiseRejectedResult => r.status === 'rejected') if (failed && input.readErrors !== 'unavailable') throw failed.reason @@ -138,9 +141,9 @@ export async function computeWorkspaceUsage(db: UsageReader, input: { const meters: Array<{ key: string, limitKey: string, name: string, current: number | null, unit: string, window: UsagePeriod | null }> = [ { key: 'ai_messages', limitKey: 'ai.messages_per_month', name: 'AI Credits', current: aiUsage ?? null, unit: 'credits', window: period }, - { key: 'form_submissions', limitKey: 'forms.submissions_per_month', name: 'Form Submissions', current: formSubmissions ?? null, unit: 'submissions', window: calendar }, - { key: 'comments', limitKey: 'comments.per_month', name: 'Comments', current: comments ?? null, unit: 'comments', window: calendar }, - { key: 'cdn_bandwidth', limitKey: 'cdn.bandwidth_gb', name: 'CDN Bandwidth', current: cdnBandwidthBytes == null ? null : cdnBandwidthBytes / GB, unit: 'GB', window: calendar }, + { key: 'form_submissions', limitKey: 'forms.submissions_per_month', name: 'Form Submissions', current: formSubmissions ?? null, unit: 'submissions', window: rowPeriod }, + { key: 'comments', limitKey: 'comments.per_month', name: 'Comments', current: comments ?? null, unit: 'comments', window: rowPeriod }, + { key: 'cdn_bandwidth', limitKey: 'cdn.bandwidth_gb', name: 'CDN Bandwidth', current: cdnBandwidthBytes == null ? null : cdnBandwidthBytes / GB, unit: 'GB', window: rowPeriod }, // Storage is a level, not a rate: it does not reset and is not projected. { key: 'media_storage', limitKey: 'media.storage_gb', name: 'Media Storage', current: input.storageBytes / GB, unit: 'GB', window: null }, { key: 'api_messages', limitKey: 'api.messages_per_month', name: 'API Credits', current: apiUsage ?? null, unit: 'credits', window: period }, diff --git a/supabase/migrations/046_usage_window_quotas.sql b/supabase/migrations/046_usage_window_quotas.sql new file mode 100644 index 00000000..5b29681e --- /dev/null +++ b/supabase/migrations/046_usage_window_quotas.sql @@ -0,0 +1,156 @@ +-- 046: form and comment quotas count over the billing window. +-- +-- A subscribed workspace's AI, API and MCP quotas reset on its billing anniversary +-- (`usage-period.ts`), but forms and comments still counted the calendar month: the +-- screen and the payment provider's invoice (which closes on the anniversary) then +-- disagreed about how much a customer had used. The two atomic submit functions take +-- the window as `[p_window_start, p_window_end)`; both NULL keeps the calendar month, +-- which is what a workspace with no billing period (free, self-hosted) still gets. +-- +-- Adding parameters creates an overload rather than replacing the function, and a call +-- by name would then be ambiguous, so the old signatures are dropped first. The new +-- parameters are defaulted: a caller that does not pass them behaves exactly as before. + +DROP FUNCTION public.create_form_submission_if_allowed(uuid, integer, uuid, text, jsonb, text, inet, text, text, text); + +CREATE FUNCTION public.create_form_submission_if_allowed(p_workspace_id uuid, p_monthly_limit integer, p_project_id uuid, p_model_id text, p_data jsonb, p_status text DEFAULT 'pending'::text, p_source_ip inet DEFAULT NULL::inet, p_user_agent text DEFAULT NULL::text, p_referrer text DEFAULT NULL::text, p_locale text DEFAULT 'en'::text, p_window_start timestamp with time zone DEFAULT NULL::timestamp with time zone, p_window_end timestamp with time zone DEFAULT NULL::timestamp with time zone) RETURNS jsonb + LANGUAGE plpgsql SECURITY DEFINER + SET search_path TO '' + AS $$ +DECLARE + v_count INTEGER; + v_submission public.form_submissions; + v_start TIMESTAMPTZ := COALESCE(p_window_start, date_trunc('month', now())); + v_end TIMESTAMPTZ := COALESCE(p_window_end, date_trunc('month', now()) + interval '1 month'); +BEGIN + -- Serialize concurrent submissions for same workspace + PERFORM pg_advisory_xact_lock( + hashtext('fs:' || p_workspace_id::text) + ); + + -- Count the window's submissions: the billing slice when the caller passes one, + -- else the calendar month. + SELECT COUNT(*) INTO v_count + FROM public.form_submissions + WHERE workspace_id = p_workspace_id + AND created_at >= v_start + AND created_at < v_end; + + -- Reject if at or over limit + IF v_count >= p_monthly_limit THEN + RETURN jsonb_build_object('allowed', false, 'current_count', v_count); + END IF; + + -- Insert the submission + INSERT INTO public.form_submissions ( + project_id, workspace_id, model_id, data, status, + source_ip, user_agent, referrer, locale + ) + VALUES ( + p_project_id, p_workspace_id, p_model_id, p_data, p_status, + p_source_ip, p_user_agent, p_referrer, p_locale + ) + RETURNING * INTO v_submission; + + RETURN jsonb_build_object( + 'allowed', true, + 'current_count', v_count + 1, + 'submission', to_jsonb(v_submission) + ); +END; +$$; + +DROP FUNCTION public.create_comment_if_allowed(uuid, integer, uuid, text, text, text, uuid, integer, text, text, text, text, text, inet, text, text); + +CREATE FUNCTION public.create_comment_if_allowed( + p_workspace_id uuid, + p_monthly_limit integer, + p_project_id uuid, + p_model_id text, + p_entry_id text, + p_locale text, + p_parent_id uuid, + p_max_depth integer, + p_author_name text, + p_author_email text, + p_author_url text, + p_body text, + p_status text DEFAULT 'pending'::text, + p_source_ip inet DEFAULT NULL::inet, + p_user_agent text DEFAULT NULL::text, + p_referrer text DEFAULT NULL::text, + p_window_start timestamp with time zone DEFAULT NULL::timestamp with time zone, + p_window_end timestamp with time zone DEFAULT NULL::timestamp with time zone +) RETURNS jsonb + LANGUAGE plpgsql SECURITY DEFINER + SET search_path TO '' + AS $$ +DECLARE + v_count INTEGER; + v_parent public.comments; + v_comment public.comments; + v_start TIMESTAMPTZ := COALESCE(p_window_start, date_trunc('month', now())); + v_end TIMESTAMPTZ := COALESCE(p_window_end, date_trunc('month', now()) + interval '1 month'); +BEGIN + -- Serialize concurrent submissions for the same workspace. + PERFORM pg_advisory_xact_lock( + hashtext('cm:' || p_workspace_id::text) + ); + + IF EXISTS ( + SELECT 1 FROM public.comment_threads t + WHERE t.project_id = p_project_id + AND t.model_id = p_model_id + AND t.entry_id = p_entry_id + AND t.locale = p_locale + AND t.closed_at IS NOT NULL + ) THEN + RETURN jsonb_build_object('allowed', false, 'reason', 'thread_closed'); + END IF; + + IF p_parent_id IS NOT NULL THEN + SELECT * INTO v_parent + FROM public.comments + WHERE id = p_parent_id + AND project_id = p_project_id + AND model_id = p_model_id + AND entry_id = p_entry_id + AND locale = p_locale; + IF NOT FOUND OR v_parent.status <> 'approved' THEN + RETURN jsonb_build_object('allowed', false, 'reason', 'parent_not_found'); + END IF; + IF v_parent.depth + 1 > p_max_depth THEN + RETURN jsonb_build_object('allowed', false, 'reason', 'depth_exceeded'); + END IF; + END IF; + + SELECT COUNT(*) INTO v_count + FROM public.comments + WHERE workspace_id = p_workspace_id + AND source = 'web' + AND created_at >= v_start + AND created_at < v_end; + + IF v_count >= p_monthly_limit THEN + RETURN jsonb_build_object('allowed', false, 'reason', 'monthly_limit', 'current_count', v_count); + END IF; + + INSERT INTO public.comments ( + project_id, workspace_id, model_id, entry_id, locale, parent_id, + author_name, author_email, author_url, body, status, source, + source_ip, user_agent, referrer + ) + VALUES ( + p_project_id, p_workspace_id, p_model_id, p_entry_id, p_locale, p_parent_id, + p_author_name, p_author_email, p_author_url, p_body, p_status, 'web', + p_source_ip, p_user_agent, p_referrer + ) + RETURNING * INTO v_comment; + + RETURN jsonb_build_object( + 'allowed', true, + 'current_count', v_count + 1, + 'comment', to_jsonb(v_comment) + ); +END; +$$; diff --git a/tests/contract/comments.contract.test.ts b/tests/contract/comments.contract.test.ts index 750319b6..9502a741 100644 --- a/tests/contract/comments.contract.test.ts +++ b/tests/contract/comments.contract.test.ts @@ -83,6 +83,40 @@ describe('postgres-db comments (contract)', () => { expect(pub.replies.map(r => r.id)).toEqual([reply.comment!.id]) }) + it('counts public comments over a billing window, import and studio rows aside', async () => { + const owner = await seedUser('comments-window') + try { + const project = await sql<{ id: string }>` + INSERT INTO public.projects (workspace_id, repo_full_name) + VALUES (${owner.workspaceId}, 'contentrain/comments-window-fixture') RETURNING id + `.execute(getDb()) + const pid = project.rows[0]!.id + for (const row of [ + { at: '2026-02-10T10:00:00Z', source: 'web' }, // in + { at: '2026-02-20T10:00:00Z', source: 'web' }, // in + { at: '2026-02-20T11:00:00Z', source: 'import' }, // never counted + { at: '2026-02-02T10:00:00Z', source: 'web' }, // before the slice + { at: '2026-03-12T10:00:00Z', source: 'web' }, // after it + ]) { + await sql` + INSERT INTO public.comments (project_id, workspace_id, model_id, entry_id, locale, root_id, author_name, body, source, created_at) + VALUES (${pid}, ${owner.workspaceId}, 'posts', 'entry-w', 'en', gen_random_uuid(), 'Ada', 'hi', ${row.source}, ${row.at}) + `.execute(getDb()) + } + const window = { from: '2026-02-05T00:00:00.000Z', to: '2026-03-05T00:00:00.000Z' } + expect(await methods.countMonthlyComments(owner.workspaceId, window)).toBe(2) + + const comment = { project_id: pid, workspace_id: owner.workspaceId, model_id: 'posts', entry_id: 'entry-new', locale: 'en', author_name: 'Bob', body: 'new', max_depth: 2 } + expect(await methods.createCommentIfAllowed(owner.workspaceId, 2, comment, window)) + .toMatchObject({ allowed: false, reason: 'monthly_limit', currentCount: 2 }) + expect(await methods.createCommentIfAllowed(owner.workspaceId, 3, comment, window)) + .toMatchObject({ allowed: true, currentCount: 3 }) + } + finally { + await deleteSeededUser(owner.userId) + } + }) + it('thread close blocks public submit; reopen allows it; moderation listing + counts + status stamps', async () => { const closed = await methods.setCommentThreadClosed(projectId, user.workspaceId, KEY, true, user.userId) expect(closed.closed_at).not.toBeNull() diff --git a/tests/contract/forms.contract.test.ts b/tests/contract/forms.contract.test.ts index 28148d1f..32ad2e57 100644 --- a/tests/contract/forms.contract.test.ts +++ b/tests/contract/forms.contract.test.ts @@ -110,6 +110,39 @@ describe('postgres-db forms (contract)', () => { expect(denied.currentCount).toBe(current + 1) }) + it('counts and caps submissions over a billing window instead of the calendar month', async () => { + const owner = await seedUser('forms-window') + try { + const project = await sql<{ id: string }>` + INSERT INTO public.projects (workspace_id, repo_full_name) + VALUES (${owner.workspaceId}, 'contentrain/forms-window-fixture') RETURNING id + `.execute(getDb()) + const pid = project.rows[0]!.id + // Two inside the slice, one just before it, one after it. All rows are ordinary submissions. + for (const at of ['2026-02-10T10:00:00Z', '2026-02-20T10:00:00Z', '2026-02-02T10:00:00Z', '2026-03-12T10:00:00Z']) { + await sql` + INSERT INTO public.form_submissions (project_id, workspace_id, model_id, data, created_at) + VALUES (${pid}, ${owner.workspaceId}, ${modelId}, '{}'::jsonb, ${at}) + `.execute(getDb()) + } + const window = { from: '2026-02-05T00:00:00.000Z', to: '2026-03-05T00:00:00.000Z' } + expect(await methods.countMonthlySubmissions(owner.workspaceId, window)).toBe(2) + expect(await methods.countMonthlySubmissionsForModel(owner.workspaceId, pid, modelId, window)).toBe(2) + + const submission = { project_id: pid, model_id: modelId, data: { via: 'window' } } + // The cap is counted over the window: 2 in it, so a limit of 2 is full... + const denied = await methods.createFormSubmissionIfAllowed(owner.workspaceId, 2, submission, window) + expect(denied).toMatchObject({ allowed: false, currentCount: 2 }) + // ...and one more fits under 3. The row it writes (now) is outside this past window, as it should be: + // the window is the caller's to choose, the function only counts inside it. + const granted = await methods.createFormSubmissionIfAllowed(owner.workspaceId, 3, submission, window) + expect(granted).toMatchObject({ allowed: true, currentCount: 3 }) + } + finally { + await deleteSeededUser(owner.userId) + } + }) + it('per-model monthly count and notification recipients (owner + accepted admins with emails)', async () => { const before = await methods.countMonthlySubmissionsForModel(user.workspaceId, projectId, 'newsletter-signup') await methods.createFormSubmission({ diff --git a/tests/contract/usage.contract.test.ts b/tests/contract/usage.contract.test.ts index ee08658b..5abaa29c 100644 --- a/tests/contract/usage.contract.test.ts +++ b/tests/contract/usage.contract.test.ts @@ -89,6 +89,24 @@ describe('postgres-db usage (contract)', () => { expect(await methods.getWorkspaceMonthlyCDNBandwidth(other.workspaceId, MONTH)).toBe(0) }) + it('sums CDN bandwidth over a billing window of whole UTC days, each day in exactly one slice', async () => { + const MID = '2026-05' + for (const row of [ + { start: '2026-05-20', bytes: 10 }, // before the slice + { start: '2026-05-21', bytes: 100 }, // the day the slice opens on: in + { start: '2026-06-20', bytes: 1000 }, // last day: in + { start: '2026-06-21', bytes: 10_000 }, // the day the next slice opens on: out + ]) { + await sql` + INSERT INTO public.cdn_usage (project_id, period_start, bandwidth_bytes) + VALUES (${projectId}, ${row.start}, ${row.bytes}) + `.execute(getDb()) + } + const window = { from: '2026-05-21T14:00:00.000Z', to: '2026-06-21T14:00:00.000Z' } + expect(await methods.getWorkspaceMonthlyCDNBandwidth(user.workspaceId, MID, window)).toBe(1100) + expect(await methods.getWorkspaceMonthlyCDNBandwidth(other.workspaceId, MID, window)).toBe(0) + }) + it('sums one day of CDN bytes per workspace across its projects, leaving out empty workspaces', async () => { const second = await sql<{ id: string }>` INSERT INTO public.projects (workspace_id, repo_full_name) diff --git a/tests/integration/comment-routes.integration.test.ts b/tests/integration/comment-routes.integration.test.ts index 9d8cac5b..9baac9b2 100644 --- a/tests/integration/comment-routes.integration.test.ts +++ b/tests/integration/comment-routes.integration.test.ts @@ -198,7 +198,7 @@ describe('public comment routes', () => { body: 'hello xworld', status: 'pending', source_ip: '198.51.100.7', - })) + }), undefined) }) }) @@ -580,7 +580,7 @@ describe('public comment routes — validation, captcha and query hygiene', () = }, async ({ request }) => { const response = await post(request, { author: { name: 'Anon' }, body: 'deep reply', parentId: approvedRoot.id }) expect(await response.json()).toEqual({ success: false, errors: [{ field: 'parentId', message: 'comments.depth_exceeded' }] }) - expect(createCommentIfAllowed).toHaveBeenCalledWith(WORKSPACE, 1000, expect.objectContaining({ author_email: null, parent_id: approvedRoot.id })) + expect(createCommentIfAllowed).toHaveBeenCalledWith(WORKSPACE, 1000, expect.objectContaining({ author_email: null, parent_id: approvedRoot.id }), undefined) }) }) diff --git a/tests/integration/form-routes.integration.test.ts b/tests/integration/form-routes.integration.test.ts index 53e7e46e..23d62de0 100644 --- a/tests/integration/form-routes.integration.test.ts +++ b/tests/integration/form-routes.integration.test.ts @@ -156,7 +156,7 @@ describe('public form routes', () => { model_id: 'contact', data: { name: 'Ada', email: 'ada@example.com' }, locale: 'tr', - })) + }), undefined) // Notification is fire-and-forget — give it a tick. await new Promise(resolve => setTimeout(resolve, 10)) diff --git a/tests/integration/overage-settings.integration.test.ts b/tests/integration/overage-settings.integration.test.ts index c2ab469f..972d2f97 100644 --- a/tests/integration/overage-settings.integration.test.ts +++ b/tests/integration/overage-settings.integration.test.ts @@ -228,6 +228,22 @@ describe('overage settings API', () => { expect(updateWorkspace).not.toHaveBeenCalled() }) + it('says yearly plans have no overage yet, not "contact support"', async () => { + mockDb({ paymentAccount: { + current_period_start: '2026-10-04T00:00:00.000Z', + current_period_end: '2027-10-04T00:00:00.000Z', + plugin_metadata: { billable_meters: [] }, + } }) + vi.stubGlobal('readBody', vi.fn().mockResolvedValue({ ai_messages: true })) + + const handler = (await import('../../server/api/workspaces/[workspaceId]/overage-settings.patch.ts')).default + await expect(handler({} as never)).rejects.toMatchObject({ + statusCode: 409, + data: { code: 'overage_locked', reason: 'yearly_plan' }, + }) + expect(updateWorkspace).not.toHaveBeenCalled() + }) + it('allows overage on a meter the subscription prices', async () => { mockDb({ paymentAccount: { plugin_metadata: { billable_meters: ['ai_messages', 'api_messages', 'mcp_calls', 'form_submissions'] } } }) vi.stubGlobal('readBody', vi.fn().mockResolvedValue({ mcp_calls: true })) diff --git a/tests/integration/public-api-fixtures.integration.test.ts b/tests/integration/public-api-fixtures.integration.test.ts index 3f834aac..cf77dbd3 100644 --- a/tests/integration/public-api-fixtures.integration.test.ts +++ b/tests/integration/public-api-fixtures.integration.test.ts @@ -194,7 +194,7 @@ describe('public API fixtures — forms', () => { model_id: 'contact', locale: 'en', data: body.data, - })) + }), undefined) }) }) @@ -327,7 +327,7 @@ describe('public API fixtures — comments', () => { body: 'Great post!', parent_id: null, status: expected.status, - })) + }), undefined) }) } diff --git a/tests/integration/starter-form-contract.integration.test.ts b/tests/integration/starter-form-contract.integration.test.ts index 512aafe9..878b29f9 100644 --- a/tests/integration/starter-form-contract.integration.test.ts +++ b/tests/integration/starter-form-contract.integration.test.ts @@ -147,7 +147,7 @@ describe('delivered site ↔ Studio forms API', () => { expect(createFormSubmissionIfAllowed).toHaveBeenCalledWith(WORKSPACE, expect.anything(), expect.objectContaining({ model_id: 'contact', data: { name: 'Ada', email: 'ada@example.com', message: 'Hello' }, - })) + }), undefined) }) }) diff --git a/tests/integration/usage-routes.integration.test.ts b/tests/integration/usage-routes.integration.test.ts index 1b30da09..69c50913 100644 --- a/tests/integration/usage-routes.integration.test.ts +++ b/tests/integration/usage-routes.integration.test.ts @@ -268,25 +268,25 @@ describe('usage API — what the billing screen may claim (BR-12)', () => { expect(ai.overageUnitPrice).toBe(0.08) }) - it('gives each meter its own reset date: billing-period meters on the 15th, calendar meters on the 1st', async () => { + it('resets every meter of a subscribed workspace with its billing period, on the 15th', async () => { const result = await run(db()) const by = (key: string) => result.categories.find((c: { key: string }) => c.key === key) expect(by('ai_messages').resetsAt).toBe('2026-10-15T00:00:00.000Z') expect(by('mcp_calls').resetsAt).toBe('2026-10-15T00:00:00.000Z') - expect(by('form_submissions').resetsAt).toBe('2026-10-01T00:00:00.000Z') - expect(by('comments').resetsAt).toBe('2026-10-01T00:00:00.000Z') + expect(by('form_submissions').resetsAt).toBe('2026-10-15T00:00:00.000Z') + expect(by('comments').resetsAt).toBe('2026-10-15T00:00:00.000Z') + expect(by('cdn_bandwidth').resetsAt).toBe('2026-10-15T00:00:00.000Z') expect(by('media_storage').resetsAt).toBeNull() }) - it('projects only enabled overage, each meter across its own window', async () => { - // Forms: 3100 by the 23rd of a 30-day calendar month → ~4043 → 1043 over at $0.01. + it('projects only enabled overage, across the billing window', async () => { + // Forms: 3100 after 8.5 days of the 30-day billing window (15 Sep → 15 Oct) → ~10941 → ~7941 over at $0.01. const result = await run(db({ getWorkspaceMemberRole: vi.fn().mockResolvedValue('owner'), getWorkspaceForUser: vi.fn().mockResolvedValue({ id: 'ws-1', plan: 'pro', overage_settings: { form_submissions: true }, media_storage_bytes: 0 }), })) expect(result.totalOverageAmount).toBe(1) // 100 over × $0.01 - expect(result.projectedOverageAmount).toBeGreaterThan(9) - expect(result.projectedOverageAmount).toBeLessThan(12) + expect(result.projectedOverageAmount).toBeCloseTo(79.41, 1) }) /** `requireRole` as the providers implement it: a role outside the list is a thrown 403, never null. */ diff --git a/tests/unit/cdn-origin-budget.test.ts b/tests/unit/cdn-origin-budget.test.ts index a7a6f7af..5f3cae5a 100644 --- a/tests/unit/cdn-origin-budget.test.ts +++ b/tests/unit/cdn-origin-budget.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { usagePeriodFrom } from '../../server/utils/usage-period' const db = vi.hoisted(() => ({ getWorkspaceMonthlyCDNBandwidth: vi.fn() })) vi.mock('../../server/utils/providers', () => ({ useDatabaseProvider: () => db })) @@ -63,4 +64,20 @@ describe('CDN origin budget', () => { expect(await checkCdnOriginBudget({ workspaceId: 'ws', limitGb: 2, now: NOW })).toEqual({ allowed: true }) expect(await checkCdnOriginBudget({ workspaceId: 'ws2', limitGb: Infinity, now: NOW })).toEqual({ allowed: true }) }) + + it('counts a subscribed workspace over its billing slice and retries when the slice resets', async () => { + const { checkCdnOriginBudget } = await load('enforce') + const period = usagePeriodFrom({ subscription_status: 'active', current_period_start: '2026-09-21T00:00:00Z', current_period_end: '2026-10-21T00:00:00Z' }, NOW) + db.getWorkspaceMonthlyCDNBandwidth.mockResolvedValue(2.5 * GIB) + const result = await checkCdnOriginBudget({ workspaceId: 'ws', limitGb: 2, now: NOW, period }) + expect(db.getWorkspaceMonthlyCDNBandwidth).toHaveBeenCalledWith('ws', '2026-09-21', { from: '2026-09-21T00:00:00.000Z', to: '2026-10-21T00:00:00.000Z' }) + // 2026-10-21T00:00Z is 27.5 days after NOW. + expect(result).toEqual({ allowed: false, retryAfterSeconds: 27.5 * 24 * 3600 }) + }) + + it('reads the calendar month, with no window, when the workspace has no billing period', async () => { + const { checkCdnOriginBudget } = await load('enforce') + await checkCdnOriginBudget({ workspaceId: 'ws', limitGb: 2, now: NOW, period: usagePeriodFrom(null, NOW) }) + expect(db.getWorkspaceMonthlyCDNBandwidth).toHaveBeenCalledWith('ws', '2026-09', undefined) + }) }) diff --git a/tests/unit/overage-lock.test.ts b/tests/unit/overage-lock.test.ts index d295d43c..62589460 100644 --- a/tests/unit/overage-lock.test.ts +++ b/tests/unit/overage-lock.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { reconcileOverageLock, resolveOverageLocks, withoutLockedOverage } from '../../server/utils/overage-lock' +import { isYearlyPeriod, reconcileOverageLock, resolveOverageLocks, withoutLockedOverage } from '../../server/utils/overage-lock' // What a Polar subscription created before the credit meters carries, and // what one on current prices carries (`scripts/polar-sync.ts`). @@ -50,6 +50,28 @@ describe('resolveOverageLocks', () => { expect(noApi.api_messages).toEqual({ reason: 'not_in_subscription', until: null }) }) + it('names a yearly plan as the reason, not a subscription support can update', () => { + // A yearly subscription (a Migrate bundle) prices no meter: Polar would bill its overage once a year. + const locks = resolveOverageLocks({ + subscription_status: 'active', + current_period_start: '2026-10-04T00:00:00.000Z', + current_period_end: '2027-10-04T00:00:00.000Z', + plugin_metadata: { billable_meters: [] }, + }) + expect(locks.ai_messages).toEqual({ reason: 'yearly_plan', until: null }) + 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', () => { + const locks = resolveOverageLocks({ + subscription_status: 'active', + current_period_start: '2026-10-04T00:00:00.000Z', + current_period_end: '2026-11-04T00:00:00.000Z', + plugin_metadata: { billable_meters: LEGACY_PRICES }, + }) + 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({}) @@ -135,3 +157,12 @@ describe('reconcileOverageLock', () => { expect(result.pluginMetadata).toEqual({ polar_note: 'x', billable_meters: CURRENT_PRICES }) }) }) + +describe('isYearlyPeriod', () => { + it('is true past a month and false for a month or an unknown period', () => { + expect(isYearlyPeriod({ current_period_start: '2026-10-04T00:00:00Z', current_period_end: '2027-10-04T00:00:00Z' })).toBe(true) + expect(isYearlyPeriod({ current_period_start: '2026-01-31T00:00:00Z', current_period_end: '2026-03-03T00:00:00Z' })).toBe(false) + expect(isYearlyPeriod({ current_period_start: null, current_period_end: '2027-10-04T00:00:00Z' })).toBe(false) + expect(isYearlyPeriod({})).toBe(false) + }) +}) diff --git a/tests/unit/usage-period-cached.test.ts b/tests/unit/usage-period-cached.test.ts new file mode 100644 index 00000000..ef334b47 --- /dev/null +++ b/tests/unit/usage-period-cached.test.ts @@ -0,0 +1,38 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { __resetUsagePeriodCache, resolveUsagePeriodCached } from '../../server/utils/usage-period' + +const getActivePaymentAccount = vi.fn() + +const ACCOUNT = { subscription_status: 'active', current_period_start: '2026-09-21T00:00:00Z', current_period_end: '2026-10-21T00:00:00Z' } +const at = (iso: string) => new Date(iso) + +describe('resolveUsagePeriodCached', () => { + beforeEach(() => { + __resetUsagePeriodCache() + getActivePaymentAccount.mockReset().mockResolvedValue(ACCOUNT) + vi.stubGlobal('useDatabaseProvider', () => ({ getActivePaymentAccount })) + }) + + it('reads the account once a minute, not once a request', async () => { + await resolveUsagePeriodCached('ws', at('2026-09-25T10:00:00Z')) + await resolveUsagePeriodCached('ws', at('2026-09-25T10:00:30Z')) + expect(getActivePaymentAccount).toHaveBeenCalledTimes(1) + await resolveUsagePeriodCached('ws', at('2026-09-25T10:01:30Z')) + expect(getActivePaymentAccount).toHaveBeenCalledTimes(2) + }) + + it('works the window out at each call\'s own time, so a slice boundary is never held past', async () => { + const before = await resolveUsagePeriodCached('ws', at('2026-10-20T23:59:30Z')) + const after = await resolveUsagePeriodCached('ws', at('2026-10-21T00:00:10Z')) + expect(before.key).toBe('2026-09-21') + // The cached account is the old period; the roll-forward in usagePeriodFrom opens the next slice. + expect(after.key).toBe('2026-10-21') + expect(getActivePaymentAccount).toHaveBeenCalledTimes(1) + }) + + it('degrades to the calendar month on a failed read, and does not remember the failure', async () => { + getActivePaymentAccount.mockRejectedValueOnce(new Error('down')) + expect((await resolveUsagePeriodCached('ws', at('2026-09-25T10:00:00Z'))).source).toBe('calendar') + expect((await resolveUsagePeriodCached('ws', at('2026-09-25T10:00:01Z'))).source).toBe('billing') + }) +}) diff --git a/tests/unit/usage-period.test.ts b/tests/unit/usage-period.test.ts index 22f190a2..485c4767 100644 --- a/tests/unit/usage-period.test.ts +++ b/tests/unit/usage-period.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { addMonthsClamped, usagePeriodFrom } from '../../server/utils/usage-period' +import { addMonthsClamped, cdnDayWindow, usagePeriodFrom, usageWindowOf } from '../../server/utils/usage-period' /** * The quota window used to be the calendar month while the invoice ran @@ -138,4 +138,31 @@ describe('usage period', () => { expect(billing.key).toBe('2026-09-01') expect(billing.key).not.toBe('2026-09') }) + + describe('the window row-counted meters are read over', () => { + const account = { subscription_status: 'active', current_period_start: '2026-09-21T14:00:00Z', current_period_end: '2026-10-21T14:00:00Z' } + + it('is the billing slice for a subscribed workspace and nothing for the calendar month', () => { + expect(usageWindowOf(usagePeriodFrom(account, at('2026-10-02T00:00:00Z')))) + .toEqual({ from: '2026-09-21T14:00:00.000Z', to: '2026-10-21T14:00:00.000Z' }) + expect(usageWindowOf(usagePeriodFrom(null, at('2026-10-02T00:00:00Z')))).toBeUndefined() + }) + + it('is one monthly slice of a yearly period, not the whole year', () => { + const yearly = { subscription_status: 'active', current_period_start: '2026-10-04T00:00:00Z', current_period_end: '2027-10-04T00:00:00Z' } + expect(usageWindowOf(usagePeriodFrom(yearly, at('2027-01-20T00:00:00Z')))) + .toEqual({ from: '2027-01-04T00:00:00.000Z', to: '2027-02-04T00:00:00.000Z' }) + }) + + it('gives the CDN whole UTC days that each belong to exactly one slice', () => { + expect(cdnDayWindow('2026-09', { from: '2026-09-21T14:00:00.000Z', to: '2026-10-21T14:00:00.000Z' })) + .toEqual({ monthStart: '2026-09-21', monthEnd: '2026-10-21' }) + // The next slice opens on the day this one closes before. + expect(cdnDayWindow('2026-10', { from: '2026-10-21T14:00:00.000Z', to: '2026-11-21T14:00:00.000Z' }).monthStart).toBe('2026-10-21') + }) + + it('falls back to the calendar month without a window', () => { + expect(cdnDayWindow('2026-12')).toEqual({ monthStart: '2026-12-01', monthEnd: '2027-01-01' }) + }) + }) }) diff --git a/tests/unit/workspace-usage-unavailable.test.ts b/tests/unit/workspace-usage-unavailable.test.ts index a87b918a..8bc5f75e 100644 --- a/tests/unit/workspace-usage-unavailable.test.ts +++ b/tests/unit/workspace-usage-unavailable.test.ts @@ -60,14 +60,32 @@ describe('computeWorkspaceUsage with a failed read', () => { }) describe('computeWorkspaceUsage reset dates', () => { - it('names what each meter resets with, so two dates on one screen read as intended', async () => { - const period = usagePeriodFrom({ subscription_status: 'active', current_period_start: '2026-09-10T00:00:00Z', current_period_end: '2026-10-10T00:00:00Z' }, NOW) + const account = { subscription_status: 'active', current_period_start: '2026-09-10T00:00:00Z', current_period_end: '2026-10-10T00:00:00Z' } + + it('resets every meter with the billing period, so the screen and the invoice close together', async () => { + const period = usagePeriodFrom(account, NOW) const usage = await computeWorkspaceUsage(db() as never, { ...input, period, storageBytes: 1 }) - const reset = Object.fromEntries(usage.categories.map(c => [c.key, [c.resetBasis, c.resetsAt]])) - for (const key of ['ai_messages', 'api_messages', 'mcp_calls']) - expect(reset[key]).toEqual(['billing', '2026-10-10T00:00:00.000Z']) - for (const key of ['form_submissions', 'comments', 'cdn_bandwidth']) - expect(reset[key]).toEqual(['calendar', '2026-10-01T00:00:00.000Z']) - expect(reset.media_storage).toEqual([null, null]) + const reset = Object.fromEntries(usage.categories.map(c => [c.key, [c.resetBasis, c.resetsAt, c.periodKey]])) + for (const key of ['ai_messages', 'api_messages', 'mcp_calls', 'form_submissions', 'comments', 'cdn_bandwidth']) + expect(reset[key]).toEqual(['billing', '2026-10-10T00:00:00.000Z', '2026-09-10']) + expect(reset.media_storage).toEqual([null, null, '2026-09']) + }) + + it('reads the row-counted meters over the billing window', async () => { + const reader = db() + await computeWorkspaceUsage(reader as never, { ...input, period: usagePeriodFrom(account, NOW) }) + const window = { from: '2026-09-10T00:00:00.000Z', to: '2026-10-10T00:00:00.000Z' } + expect(reader.countMonthlySubmissions).toHaveBeenCalledWith('ws-1', window) + expect(reader.countMonthlyComments).toHaveBeenCalledWith('ws-1', window) + expect(reader.getWorkspaceMonthlyCDNBandwidth).toHaveBeenCalledWith('ws-1', '2026-09', window) + }) + + it('keeps the calendar month for a workspace with no billing period', async () => { + const reader = db() + const usage = await computeWorkspaceUsage(reader as never, { ...input, period: usagePeriodFrom(null, NOW) }) + expect(reader.countMonthlySubmissions).toHaveBeenCalledWith('ws-1', undefined) + expect(reader.getWorkspaceMonthlyCDNBandwidth).toHaveBeenCalledWith('ws-1', '2026-09', undefined) + const forms = usage.categories.find(c => c.key === 'form_submissions')! + expect([forms.resetBasis, forms.resetsAt, forms.periodKey]).toEqual(['calendar', '2026-10-01T00:00:00.000Z', '2026-09']) }) })