# Billing Quotas & MCP Metering (3B) — Design Spec

**Date:** 2026-06-12 · **Status:** Approved · **Sub-project:** 3B (after 3A core ✅)
**Pricing source:** `~/Documents/seo insight/docs/Pricing.md` — limits already encoded in `src/lib/billing/plans.ts` (PlanLimits).

## Goal
Enforce the per-plan limits at every creation point (keywords, projets/sites, sièges, lignes backlinks) and meter MCP requests **monthly** with a hard quota. Trial = Pro limits with **keywords & mcpMonthly ÷2** (750 / 50k per the doc; other limits = Pro). Fail-closed, typed errors, localized UI feedback.

## Decisions
- **Resolution:** `getOrgQuotaLimits(orgId)` in `src/lib/billing/quotas.ts` — active subscription → `getPlan(plan).limits`; else trialing → Pro limits with keywords/mcpMonthly halved; else expired → all zeros (gate already blocks the app; MCP keys also blocked at zero). Fresh-read (no cookieCache), React `cache()` per request.
- **Enforcement = at creation, count-then-block** (existing data over the limit is never deleted): `assertWithinQuota(orgId, resource, increment)` throws `QuotaError` (typed, carries `resource`, `limit`, `current`) when `current + increment > limit`. `-1` = unlimited.
- **Usage counters** (`src/lib/billing/usage.ts`): keywords = COUNT(keywords JOIN sites ON org) **per org** (not per site); projects = COUNT(sites WHERE org); seats = COUNT(member) + COUNT(invitation pending); backlinkRows = COUNT(backlinks JOIN sites org) + COUNT(domain backlink profile rows if table exists — verify at impl).
- **Enforcement points** (from the metering map):
  - keywords: `addKeywordAction`, `commitKeywordsImportAction` (increment = rows to insert; partial imports NOT allowed — block with explicit error telling how many fit), GSC auto-import (cap silently at remaining quota, log).
  - sites: `createSiteAction`.
  - seats: Better Auth invite path — use the organization plugin's hook/`membershipLimit` if the installed 1.6.17 supports a dynamic form; otherwise a `before`-hook on `/organization/invite-member` in the existing `hooks.before` middleware (where email-guard lives) that resolves the active org and asserts seats. Implementer checks installed types and picks the working mechanism.
  - backlinks: `commitImportAction` (bulk link_sources→backlinks) + domain profile import.
- **MCP monthly metering:** add `requestMonth: text` ("YYYY-MM") + `monthlyCount: integer default 0` to `mcp_api_keys` (migration **0032, `when` → `1781330000000`**). On each MCP call (`src/lib/mcp/auth.ts`): if `requestMonth != currentMonth` → reset monthlyCount to 0/update month, then increment (keep cumulative `requestCount` too). **Quota check:** SUM(monthlyCount) across the org's keys ≥ limit → reject the call with an MCP error ("Monthly request quota exceeded — upgrade or buy a credit pack"). The increment stays fire-and-forget; the CHECK is read-before-serve (cheap indexed SUM).
- **UI feedback:** server actions surface `QuotaError` as a structured `{ error: "quota", resource, limit }` return (match each action's existing error convention); clients toast a localized message + link to `/settings/billing`. New i18n namespace `Quota` (×4 langs, parity): `keywords`, `projects`, `seats`, `backlinkRows`, `mcpMonthly` messages + `upgradeCta`.
- **Super-admin bypass:** none for quotas (god-mode bypasses the billing GATE, not resource limits) — keeps testing honest. (Revisit if annoying.)

## Out of scope
Credit packs consumption (3C extends the MCP check), seat purchase, downgrade-below-usage UX (warning emails), per-site quotas.

## Tests (gate `bun run test:all`)
- Pure: limits resolution (active starter → starter limits; trialing → pro/2 on keywords+mcp only; expired → zeros; -1 unlimited passes any increment).
- `assertWithinQuota` boundary: current+increment == limit passes; > blocks.
- DB: usage counters count cross-site per org; seat counter includes pending invitations.
- MCP: month rollover resets; org at quota → call rejected (route test pattern exists in `src/lib/mcp/route.test.ts`).
- Live (controller): starter org (bastien, 5-project limit) → creating sites beyond 5 blocked with toast; MCP key over a forced quota → MCP error.
