# Phase 5 — `es_org_subscription` + Quotas Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Introduire la table `es_org_subscription` (source of truth app-side du plan + status), définir les `PLAN_LIMITS` par tier, brancher un `checkQuota(orgId, kind)` qui enforce les quotas dans les Server Actions critiques (`inviteMember`, ajout de site, lancement de test), et exposer l'usage courant dans l'UI via `<UsageMeter>`. **Pas de paiement Stripe ici — Phase 6.**

**Architecture:** 1 nouvelle table `es_org_subscription` synchronisée par les webhooks Stripe (Phase 6) ; pour l'instant toutes les orgs ont `plan = "free"`. Le `checkQuota` lit `PLAN_LIMITS[plan]` puis count en DB (sites pour l'org, tests sur le mois calendaire courant, seats actifs). Quand un user dépasse, on lève une erreur typée + l'UI affiche un modal contextuel "Upgrade to Starter" (Phase 6 wirera le Checkout).

**Tech Stack:** Drizzle ORM (migration), Postgres, Better Auth (signup hook), Zod, next-intl, shadcn (`Dialog`, `Progress`).

**Décisions structurantes (taken to keep momentum, can be revisited at Phase 6 design)**

- **Hard cap** sur Free **et** sur tiers payants (refus net, modal upgrade). Simple, prévisible. La nuance "soft cap + overage" peut arriver post-MVP.
- **Quota reset = calendrier (1er du mois)** pour tous les tiers Free/Starter. Pour Pro/Business : aligné sur `current_period_start` de Stripe quand Phase 6 lande ; **en attendant**, on utilise aussi le calendrier (lisible, prévisible, aligné avec le mental model "test mensuel").
- **Pas de cron de warning email** — check synchrone à chaque action consommant du quota, avec flag `last_quota_warning_sent_at` (différé à Phase 6 quand le webhook + Resend orchestrera la partie billing-events).
- **UX upgrade = modal contextuel** dans Phase 5 (avec CTA "Coming in Phase 6" pour le moment). Pas de page `/pricing` dédiée ici — viendra avec Phase 6 / 7.

**Spec source:**

- `docs/superpowers/specs/2026-05-25-org-roles-stripe-billing-design.md` §4.3 (`es_org_subscription` schema), §4.4 (`es_stripe_event` — différé Phase 6), §5.5 (PLAN_LIMITS + checkQuota), §7.6 (post-downgrade UX — partial).

**Hors-scope (différé Phase 6) :**

- Stripe webhook handler + `es_stripe_event` table (Phase 6 setup).
- `stripe_customer_id` création (lazy, premier accès `/settings/billing` — Phase 6).
- `/settings/billing` page (Phase 6).
- Email d'avertissement à 90/100% (Phase 6).
- Page `/pricing` (Phase 7).

**Dépendances déjà en place :**

- `lib/billing/plans.ts` exporte `BILLABLE_PLANS`, `FREE_LIMITS`, `Plan` type, `BillingInterval` type (depuis le Stripe products bootstrap).
- `lib/actions/team.ts::inviteMember` — où on enforce le quota seats.
- `app/api/sites/route.ts::POST` — où on enforce le quota sites.
- `app/api/sites/[siteId]/test/route.ts::POST` — où on enforce le quota testsPerMonth.
- `requireOrgMember()` qui résout `{ activeOrganizationId, role }`.

---

## File Structure

| Path                                            | Status           | Responsibility                                                                                                                                         |
| ----------------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `lib/db/schema.ts`                              | modify           | Ajout `es_org_subscription` table (sans `es_stripe_event` — Phase 6).                                                                                  |
| `drizzle/000X_*.sql`                            | generated+manual | Migration + backfill (1 row par org avec plan=free, stripe_customer_id=null).                                                                          |
| `lib/billing/plans.ts`                          | modify           | Ajout `PLAN_LIMITS: Record<Plan, Limits>` (Free + Billable). Aligne avec FREE_LIMITS + BILLABLE_PLANS.limits déjà présents.                            |
| `lib/billing/quotas.ts`                         | create           | `QuotaKind` type, `checkQuota(orgId, kind)`, `getOrgPlan(orgId)`, `getCurrentMonthStart()`. Helper `assertQuota` qui throw `QuotaExceededError` typée. |
| `lib/auth.ts`                                   | modify           | Hook signup: insère un row `es_org_subscription` (plan=free, stripe_customer_id=null) à la fin du `databaseHooks.user.create.after`.                   |
| `scripts/backfill-subscriptions.ts`             | create           | One-shot idempotent: pour chaque org sans row `es_org_subscription`, insère un row plan=free.                                                          |
| `lib/actions/team.ts`                           | modify           | `inviteMember` check `seats` quota avant invitation.                                                                                                   |
| `app/api/sites/route.ts`                        | modify           | POST check `sites` quota avant insertion.                                                                                                              |
| `app/api/sites/[siteId]/test/route.ts`          | modify           | POST check `testsPerMonth` quota avant lancement.                                                                                                      |
| `lib/data/usage.ts`                             | create           | `getOrgUsage(orgId)` : retourne `{ tests, sites, seats }` (counts) + `{ plan, limits }`.                                                               |
| `components/billing/usage-meter.tsx`            | create           | `<UsageMeter label, used, limit />` shadcn-like progress bar avec couleur jaune > 70%, rouge > 90%.                                                    |
| `components/billing/quota-exhausted-dialog.tsx` | create           | Modal blocking : "You've hit your {plan} tier limit for {kind}. Upgrade to {suggestedTier}".                                                           |
| `app/[locale]/dashboard/layout.tsx`             | modify           | Charge `getOrgUsage` + passe à un `<UsageSection>` (Server Component) qui rend 3 UsageMeters en haut du dashboard.                                     |
| `components/dashboard/usage-section.tsx`        | create           | Rend les 3 UsageMeters (tests, sites, seats).                                                                                                          |
| `messages/{en,fr,es}.json`                      | modify           | Namespace `Quota.*` + `Billing.usage.*`.                                                                                                               |
| `scripts/verify-quotas.ts`                      | create           | Smoke test : signup, créer 15 tests, vérifier que le 16e throw.                                                                                        |
| `package.json`                                  | modify           | Script `verify:quotas` + `db:backfill-subscriptions`.                                                                                                  |

---

## Task 1: Schema — `es_org_subscription` table

**Files:**

- Modify: `lib/db/schema.ts`

- [ ] **Step 1.1: Add types + table after `invitation`, before `Sites`**

In `lib/db/schema.ts`, after the `invitation` block and before the `/* ----- Sites ----- */` comment, append:

```ts
/* ------------------------ Billing — Subscription ------------------------ */
/* Source of truth app-side for an org's current plan + Stripe state.
   Synchronized by Phase 6 webhooks. For now (Phase 5), every row has
   plan="free" + stripe_customer_id=null. */

export type Plan = "free" | "starter" | "pro" | "business" | "enterprise";
export type BillingInterval = "month" | "year";
export type SubStatus =
  | "active"
  | "trialing"
  | "past_due"
  | "canceled"
  | "incomplete"
  | "incomplete_expired"
  | "unpaid";

export const orgSubscription = pgTable(
  "es_org_subscription",
  {
    id: text("id").primaryKey(),
    organizationId: text("organization_id")
      .notNull()
      .unique()
      .references(() => organization.id, { onDelete: "cascade" }),

    plan: text("plan").$type<Plan>().notNull().default("free"),
    interval: text("interval").$type<BillingInterval>(),

    stripeCustomerId: text("stripe_customer_id").unique(),
    stripeSubscriptionId: text("stripe_subscription_id").unique(),
    stripePriceId: text("stripe_price_id"),

    status: text("status").$type<SubStatus>().notNull().default("active"),
    currentPeriodStart: timestamp("current_period_start", {
      withTimezone: true,
      mode: "date",
    }),
    currentPeriodEnd: timestamp("current_period_end", {
      withTimezone: true,
      mode: "date",
    }),
    cancelAtPeriodEnd: boolean("cancel_at_period_end").notNull().default(false),
    canceledAt: timestamp("canceled_at", { withTimezone: true, mode: "date" }),

    createdAt: timestamp("created_at", { withTimezone: true, mode: "date" }).notNull().defaultNow(),
    updatedAt: timestamp("updated_at", { withTimezone: true, mode: "date" }).notNull().defaultNow(),
  },
  (t) => [
    index("idx_sub_stripe_customer").on(t.stripeCustomerId),
    index("idx_sub_stripe_sub").on(t.stripeSubscriptionId),
  ],
);
```

- [ ] **Step 1.2: Typecheck**

```bash
cd /Users/humperdoo/Github/eleva-speed
pnpm typecheck 2>&1 | tail -5
```

Expected: 0 errors.

---

## Task 2: Migration + backfill

**Files:**

- Generated: `drizzle/000X_*.sql`
- Edit: same file (add backfill SQL at the end)

- [ ] **Step 2.1: Generate migration**

```bash
cd /Users/humperdoo/Github/eleva-speed
pnpm db:generate
```

A new `drizzle/0006_*.sql` should be created with the `CREATE TABLE es_org_subscription` + the 2 indexes.

- [ ] **Step 2.2: Append backfill SQL inside the migration**

At the END of the generated `drizzle/0006_*.sql`, append:

```sql

-- Backfill: insert one es_org_subscription row per organization that doesn't
-- already have one. Default plan=free, stripe_customer_id=null (lazy-created
-- on first /settings/billing access — Phase 6). id is a nanoid-style string;
-- we use Postgres's gen_random_uuid()::text wrapped to look nanoid-ish.
INSERT INTO "es_org_subscription" ("id", "organization_id", "plan", "status")
SELECT
  -- Use a stable derived id so re-running is safe. nanoid-style is fine but
  -- not available in pure SQL; use the org id prefixed by 'sub_' which is
  -- unique by construction (organization_id is unique on this table too).
  'sub_' || o."id",
  o."id",
  'free',
  'active'
FROM "organization" o
WHERE NOT EXISTS (
  SELECT 1 FROM "es_org_subscription" s WHERE s."organization_id" = o."id"
);
```

- [ ] **Step 2.3: Apply migration**

```bash
pnpm db:migrate 2>&1 | tail -10
```

Expected: `[✓] migrations applied successfully!`.

- [ ] **Step 2.4: Verify state**

```bash
PGPASSWORD="$(grep DATABASE_URL .env.local | sed -E 's|.*://[^:]+:([^@]+)@.*|\1|')" psql -h 127.0.0.1 -p 5432 -U elevaspeed -d elevaspeed -c \
  "SELECT (SELECT COUNT(*) FROM organization) AS orgs, (SELECT COUNT(*) FROM es_org_subscription) AS subs, (SELECT COUNT(*) FROM es_org_subscription WHERE plan='free') AS free_subs;"
```

Don't print the password. Expected: `orgs = subs = free_subs` (every org has a row, all plan=free).

- [ ] **Step 2.5: Commit**

```bash
git add lib/db/schema.ts drizzle/0006_*.sql drizzle/meta/
git commit -m "feat(db): add es_org_subscription table + backfill plan=free for existing orgs

Source of truth app-side for org plan + Stripe state. Source of truth for
Stripe canonical state remains Stripe DB; this table is the replicated
projection used by checkQuota and billing UI. Phase 6 webhooks will
synchronize it. Phase 5 starts every org on plan=free, stripe_customer_id
left null (lazy-created at first /settings/billing access).

Backfill SQL inside the migration inserts one row per existing org via
WHERE NOT EXISTS so the migration is replay-safe (no-op if a row already
exists)."
```

---

## Task 3: Add `PLAN_LIMITS` to `lib/billing/plans.ts`

**Files:**

- Modify: `lib/billing/plans.ts`

- [ ] **Step 3.1: Add `PLAN_LIMITS` export**

Read `lib/billing/plans.ts`. The file already has `FREE_LIMITS` (Phase 1 setup) and `BILLABLE_PLANS[tier].limits`. Add a flat `PLAN_LIMITS: Record<Plan, Limits>` that consolidates both for `checkQuota`'s consumption.

Append (after existing exports):

```ts
import type { Plan as DbPlan } from "@/lib/db/schema";

export type QuotaLimits = {
  testsPerMonth: number;
  sites: number;
  seats: number;
  retentionDays: number;
};

/* Consolidated per-tier limits. Free comes from FREE_LIMITS, Starter/Pro/
   Business from BILLABLE_PLANS[tier].limits, Enterprise is a sentinel of
   Infinity for now (admin-issued, no enforced quota). */
export const PLAN_LIMITS: Record<DbPlan, QuotaLimits> = {
  free: FREE_LIMITS,
  starter: BILLABLE_PLANS.starter.limits,
  pro: BILLABLE_PLANS.pro.limits,
  business: BILLABLE_PLANS.business.limits,
  enterprise: {
    testsPerMonth: Number.POSITIVE_INFINITY,
    sites: Number.POSITIVE_INFINITY,
    seats: Number.POSITIVE_INFINITY,
    retentionDays: 730, // 2 years per spec §pricing-proposal
  },
};
```

The `import type` is necessary because `Plan` is defined in `lib/db/schema.ts` (Task 1) and we want the source of truth there, not duplicated. If `lib/billing/plans.ts` already declares a local `Plan` type, REMOVE that local declaration and re-export from `lib/db/schema.ts` to keep one canonical type.

- [ ] **Step 3.2: Typecheck**

```bash
pnpm typecheck 2>&1 | tail -5
```

Expected: 0 errors.

- [ ] **Step 3.3: Commit**

```bash
git add lib/billing/plans.ts
git commit -m "feat(billing): add consolidated PLAN_LIMITS for checkQuota

Unifies FREE_LIMITS + BILLABLE_PLANS[*].limits into a single
Record<Plan, QuotaLimits> consumed by lib/billing/quotas.ts.
Enterprise is sentinel-Infinity (admin-issued, no enforced quota)."
```

---

## Task 4: `lib/billing/quotas.ts` — checkQuota helper

**Files:**

- Create: `lib/billing/quotas.ts`

- [ ] **Step 4.1: Create the helper**

```ts
import { and, count, eq, gte, isNotNull } from "drizzle-orm";

import { db } from "@/lib/db";
import { member, orgSubscription, sites, siteTests } from "@/lib/db/schema";
import type { Plan } from "@/lib/db/schema";
import { PLAN_LIMITS, type QuotaLimits } from "@/lib/billing/plans";

export type QuotaKind = "tests" | "sites" | "seats";

export type QuotaResult = {
  kind: QuotaKind;
  used: number;
  limit: number;
  ok: boolean;
  plan: Plan;
  /* Soft thresholds for UI nudges. percent === used / limit. */
  percent: number;
};

export class QuotaExceededError extends Error {
  constructor(public readonly result: QuotaResult) {
    super(
      `Quota exceeded for ${result.kind} on plan ${result.plan}: ${result.used}/${result.limit}`,
    );
    this.name = "QuotaExceededError";
  }
}

/* Resolves the active plan for an org. Falls back to "free" if no row
   exists (defensive — Phase 5 backfill should have populated every org). */
export async function getOrgPlan(organizationId: string): Promise<Plan> {
  const rows = await db
    .select({ plan: orgSubscription.plan })
    .from(orgSubscription)
    .where(eq(orgSubscription.organizationId, organizationId))
    .limit(1);
  return rows[0]?.plan ?? "free";
}

/* Returns the first day of the current calendar month (UTC). Used as
   the start anchor for testsPerMonth counting. */
export function getCurrentMonthStart(): Date {
  const now = new Date();
  return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1, 0, 0, 0));
}

/* Counts the consumed quota for a given kind. Cheap point-lookups against
   org-scoped indexes. */
async function countUsage(organizationId: string, kind: QuotaKind): Promise<number> {
  if (kind === "sites") {
    const rows = await db
      .select({ n: count() })
      .from(sites)
      .where(eq(sites.organizationId, organizationId));
    return rows[0]?.n ?? 0;
  }
  if (kind === "seats") {
    const rows = await db
      .select({ n: count() })
      .from(member)
      .where(eq(member.organizationId, organizationId));
    return rows[0]?.n ?? 0;
  }
  // tests: count only tests of the current calendar month, attributed to this org.
  const monthStart = getCurrentMonthStart();
  const rows = await db
    .select({ n: count() })
    .from(siteTests)
    .where(
      and(
        eq(siteTests.organizationId, organizationId),
        gte(siteTests.createdAt, monthStart.toISOString()),
        isNotNull(siteTests.organizationId),
      ),
    );
  return rows[0]?.n ?? 0;
}

export async function checkQuota(organizationId: string, kind: QuotaKind): Promise<QuotaResult> {
  const plan = await getOrgPlan(organizationId);
  const limits: QuotaLimits = PLAN_LIMITS[plan];
  const limitKey: keyof QuotaLimits =
    kind === "tests" ? "testsPerMonth" : kind === "sites" ? "sites" : "seats";
  const limit = limits[limitKey];
  const used = await countUsage(organizationId, kind);
  const percent = limit === Number.POSITIVE_INFINITY ? 0 : used / Math.max(limit, 1);
  return {
    kind,
    used,
    limit,
    ok: used < limit,
    plan,
    percent,
  };
}

/* Throws QuotaExceededError if the limit would be hit by consuming +1 unit.
   Use this in Server Actions and API routes BEFORE creating the resource. */
export async function assertQuota(organizationId: string, kind: QuotaKind): Promise<QuotaResult> {
  const result = await checkQuota(organizationId, kind);
  if (!result.ok) {
    throw new QuotaExceededError(result);
  }
  return result;
}
```

- [ ] **Step 4.2: Typecheck + lint**

```bash
pnpm typecheck 2>&1 | tail -5
pnpm lint lib/billing/quotas.ts 2>&1 | tail -3
```

Expected: 0 errors.

- [ ] **Step 4.3: Commit**

```bash
git add lib/billing/quotas.ts
git commit -m "feat(billing): add checkQuota + assertQuota + QuotaExceededError

Reads the active plan from es_org_subscription, counts the consumed
quota (sites, seats, or tests-this-calendar-month), and returns the
state. assertQuota throws QuotaExceededError if the next +1 would
exceed; consumed by Server Actions and API routes. Tests are counted
from the 1st of the current UTC calendar month (calendar reset cadence
decision)."
```

---

## Task 5: Update signup hook to insert es_org_subscription row

**Files:**

- Modify: `lib/auth.ts`

- [ ] **Step 5.1: Add the subscription insert to `databaseHooks.user.create.after`**

Read `lib/auth.ts`. The `after` hook currently calls `createPersonalOrg` + inserts a `leads` row. Add a third step (after the Personal Org creation, before/after the lead insert is fine):

```ts
// Personal Org — every signup gets one (Phase 1).
const { orgId } = await createPersonalOrg({ id: data.id, email: data.email, name: data.name });

// Subscription stub — every org starts on Free, no Stripe customer yet (Phase 5).
const { orgSubscription } = schema;
await db
  .insert(orgSubscription)
  .values({
    id: `sub_${orgId}`,
    organizationId: orgId,
    plan: "free",
    status: "active",
  })
  .onConflictDoNothing({ target: orgSubscription.organizationId });

// Marketing lead capture (existing behavior).
await db.insert(schema.leads).values({
  id: nanoid(),
  email: data.email,
  source: "signup",
  consent: false,
  userId: data.id,
});
```

The `onConflictDoNothing` makes the hook re-entrant safe in case the backfill already inserted the row (unlikely but defensive).

- [ ] **Step 5.2: Verify `createPersonalOrg` returns `{ orgId }`**

Read `lib/onboarding.ts`. If `createPersonalOrg` already returns `{ orgId }`, no change. If it returns just the org id string, update the hook to capture it correctly. If it doesn't return anything yet, modify it to return `{ orgId: string }`.

(Per Phase 1 it already returns `{ orgId }` — confirm.)

- [ ] **Step 5.3: Typecheck**

```bash
pnpm typecheck 2>&1 | tail -5
```

Expected: clean.

- [ ] **Step 5.4: Commit**

```bash
git add lib/auth.ts
git commit -m "feat(auth): insert es_org_subscription row at signup (plan=free)

Every new signup now gets a Personal Org (Phase 1) AND a corresponding
es_org_subscription row (Phase 5) so checkQuota always finds a plan to
read. The signup hook is now: validate pro-email → create user →
create Personal Org → create subscription stub (free, no Stripe customer)
→ insert lead row. ON CONFLICT DO NOTHING on the subscription insert
makes the hook re-entrant safe against the backfill."
```

---

## Task 6: Backfill script for any orphan orgs

**Files:**

- Create: `scripts/backfill-subscriptions.ts`
- Modify: `package.json`

- [ ] **Step 6.1: Create the script**

```ts
import { eq, isNull } from "drizzle-orm";

import { db } from "../lib/db";
import { organization, orgSubscription } from "../lib/db/schema";

async function main() {
  // Two selects + diff in memory (same pattern as backfill-orgs).
  const allOrgs = await db.select({ id: organization.id }).from(organization);
  const allSubs = await db
    .select({ organizationId: orgSubscription.organizationId })
    .from(orgSubscription);
  const orgsWithSub = new Set(allSubs.map((s) => s.organizationId));
  const orphans = allOrgs.filter((o) => !orgsWithSub.has(o.id));

  console.log(`→ Found ${orphans.length} org(s) without a subscription row`);

  for (const o of orphans) {
    await db
      .insert(orgSubscription)
      .values({
        id: `sub_${o.id}`,
        organizationId: o.id,
        plan: "free",
        status: "active",
      })
      .onConflictDoNothing({ target: orgSubscription.organizationId });
    console.log(`  ✓ ${o.id} → free`);
  }

  console.log(`✅ backfill-subscriptions done (${orphans.length} processed)`);
  process.exit(0);
}

main().catch((e) => {
  console.error(`❌ backfill-subscriptions FAILED:`, e instanceof Error ? e.message : e);
  process.exit(1);
});
```

- [ ] **Step 6.2: Add `db:backfill-subscriptions` to `package.json`**

Right after `db:backfill-orgs`:

```json
"db:backfill-subscriptions": "tsx --env-file-if-exists=.env.local --env-file-if-exists=.env scripts/backfill-subscriptions.ts",
```

- [ ] **Step 6.3: Run + verify idempotent**

```bash
pnpm db:backfill-subscriptions
pnpm db:backfill-subscriptions  # second run should report 0
```

- [ ] **Step 6.4: Commit**

```bash
git add scripts/backfill-subscriptions.ts package.json
git commit -m "feat(db): idempotent backfill script for es_org_subscription on existing orgs

Mirrors scripts/backfill-orgs.ts shape: 2 selects + in-memory diff +
insert per orphan with ON CONFLICT DO NOTHING. Re-runs are no-ops."
```

---

## Task 7: Enforce quotas in mutation Server Actions

**Files:**

- Modify: `lib/actions/team.ts` (inviteMember)
- Modify: `app/api/sites/route.ts` (POST)
- Modify: `app/api/sites/[siteId]/test/route.ts` (POST)

- [ ] **Step 7.1: `lib/actions/team.ts::inviteMember`**

Just after `requireOrgMember({ minRole: "admin" })` and before `auth.api.createInvitation`, add:

```ts
import { assertQuota } from "@/lib/billing/quotas";
// ...
const { activeOrganizationId } = await requireOrgMember({ minRole: "admin" });
const parsed = InviteSchema.parse(input);
await assertQuota(activeOrganizationId, "seats"); // throws QuotaExceededError if exhausted
```

Note: counting `seats` = currently-active member rows. Inviting doesn't immediately bump that count (the user has to accept), but a stricter UX is to count pending invitations toward the quota too. For Phase 5 we keep it simple: count only members, not pending invitations. To check the pending count if you want stricter UX later, modify `countUsage` for "seats".

- [ ] **Step 7.2: `app/api/sites/route.ts::POST`**

Just after the auth + org-id checks, before the URL resolve, add:

```ts
import { assertQuota, QuotaExceededError } from "@/lib/billing/quotas";
// ...
try {
  await assertQuota(orgId, "sites");
} catch (err) {
  if (err instanceof QuotaExceededError) {
    return NextResponse.json(
      {
        error: "QUOTA_EXCEEDED",
        kind: err.result.kind,
        used: err.result.used,
        limit: err.result.limit,
        plan: err.result.plan,
      },
      { status: 403 },
    );
  }
  throw err;
}
```

- [ ] **Step 7.3: `app/api/sites/[siteId]/test/route.ts::POST`**

Same pattern as 7.2 but for `"tests"` quota. Place right after the existing auth/org check + the site lookup (so we don't count a quota check before confirming the user can even use this site).

- [ ] **Step 7.4: Typecheck + lint**

```bash
pnpm typecheck 2>&1 | tail -5
pnpm lint lib/actions/team.ts 'app/api/sites/' 2>&1 | tail -3
```

- [ ] **Step 7.5: Commit**

```bash
git add lib/actions/team.ts app/api/sites/
git commit -m "feat(quotas): enforce seats/sites/tests quotas in mutation paths

- inviteMember (Server Action): assertQuota(seats) before creating the
  invitation. Throws QuotaExceededError caught by the UI.
- POST /api/sites: assertQuota(sites) → 403 QUOTA_EXCEEDED body.
- POST /api/sites/[siteId]/test: assertQuota(tests) → 403 QUOTA_EXCEEDED.

The 403 response body includes { kind, used, limit, plan } so the client
can show a contextual upgrade modal."
```

---

## Task 8: `lib/data/usage.ts` + `<UsageMeter>` + `<UsageSection>`

**Files:**

- Create: `lib/data/usage.ts`
- Create: `components/billing/usage-meter.tsx`
- Create: `components/dashboard/usage-section.tsx`

- [ ] **Step 8.1: Create `lib/data/usage.ts`**

```ts
import { checkQuota, type QuotaResult } from "@/lib/billing/quotas";

export type OrgUsage = {
  tests: QuotaResult;
  sites: QuotaResult;
  seats: QuotaResult;
};

export async function getOrgUsage(organizationId: string): Promise<OrgUsage> {
  const [tests, sites, seats] = await Promise.all([
    checkQuota(organizationId, "tests"),
    checkQuota(organizationId, "sites"),
    checkQuota(organizationId, "seats"),
  ]);
  return { tests, sites, seats };
}
```

- [ ] **Step 8.2: Create `components/billing/usage-meter.tsx`**

```tsx
"use client";

import { cn } from "@/lib/utils";

export type UsageMeterProps = {
  label: string;
  used: number;
  limit: number;
  className?: string;
};

export function UsageMeter({ label, used, limit, className }: UsageMeterProps) {
  const isInfinity = !Number.isFinite(limit);
  const percent = isInfinity ? 0 : Math.min(1, used / Math.max(limit, 1));
  const tone =
    percent >= 1
      ? "bg-destructive"
      : percent >= 0.9
        ? "bg-amber-500"
        : percent >= 0.7
          ? "bg-amber-400"
          : "bg-primary";

  return (
    <div className={cn("flex flex-col gap-1", className)}>
      <div className="flex items-center justify-between text-xs">
        <span className="font-medium text-muted-foreground">{label}</span>
        <span className="tabular-nums text-muted-foreground">
          {used.toLocaleString()} / {isInfinity ? "∞" : limit.toLocaleString()}
        </span>
      </div>
      <div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
        <div
          className={cn("h-full rounded-full transition-[width]", tone)}
          style={{ width: `${(percent * 100).toFixed(1)}%` }}
        />
      </div>
    </div>
  );
}
```

- [ ] **Step 8.3: Create `components/dashboard/usage-section.tsx`**

```tsx
import { getTranslations } from "next-intl/server";

import { UsageMeter } from "@/components/billing/usage-meter";
import { getOrgUsage } from "@/lib/data/usage";

export async function UsageSection({ organizationId }: { organizationId: string }) {
  const usage = await getOrgUsage(organizationId);
  const t = await getTranslations("Billing.usage");
  return (
    <section className="grid gap-4 rounded-lg border p-4 sm:grid-cols-3">
      <UsageMeter label={t("tests")} used={usage.tests.used} limit={usage.tests.limit} />
      <UsageMeter label={t("sites")} used={usage.sites.used} limit={usage.sites.limit} />
      <UsageMeter label={t("seats")} used={usage.seats.used} limit={usage.seats.limit} />
    </section>
  );
}
```

- [ ] **Step 8.4: Add to dashboard layout**

Read `app/[locale]/dashboard/layout.tsx`. After the existing org resolution, render `<UsageSection organizationId={activeOrganizationId} />` somewhere visible (e.g., right under the page header, or inside the main content area). Wrap in `<Suspense>` with a small skeleton fallback so it doesn't block the rest of the page.

```tsx
import { Suspense } from "react";
import { UsageSection } from "@/components/dashboard/usage-section";
import { Skeleton } from "@/components/ui/skeleton";

// Inside the layout's return JSX, somewhere appropriate:
<Suspense fallback={<Skeleton className="h-20 w-full" />}>
  <UsageSection organizationId={activeOrganizationId} />
</Suspense>;
```

If adding it to the layout interferes with existing UI structure, alternatively add it to the dashboard root page (`app/[locale]/dashboard/page.tsx`) above the existing sites list.

- [ ] **Step 8.5: Typecheck + lint**

```bash
pnpm typecheck 2>&1 | tail -3
pnpm lint lib/data/usage.ts components/billing/ components/dashboard/usage-section.tsx 'app/[locale]/dashboard/' 2>&1 | tail -3
```

- [ ] **Step 8.6: Commit**

```bash
git add lib/data/usage.ts components/billing/ components/dashboard/usage-section.tsx 'app/[locale]/dashboard/'
git commit -m "feat(billing): UsageSection + UsageMeter — 3-tile dashboard usage panel

Server Component <UsageSection> fetches tests/sites/seats quotas
concurrently via checkQuota and renders three <UsageMeter> bars
(client component for the progress fill animation). Color tones step
through amber (≥70%, ≥90%) then destructive at 100%. Wrapped in
Suspense so the panel streams in without blocking dashboard render."
```

---

## Task 9: `<QuotaExhaustedDialog>` (client) + integration in failing actions

**Files:**

- Create: `components/billing/quota-exhausted-dialog.tsx`

- [ ] **Step 9.1: Create the dialog**

This is a controlled component that displays when a `QUOTA_EXCEEDED` response (or `QuotaExceededError` toast) fires. Client-only.

```tsx
"use client";

import { useTranslations } from "next-intl";

import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";

export type QuotaExhaustedDialogProps = {
  open: boolean;
  onOpenChange: (open: boolean) => void;
  kind: "tests" | "sites" | "seats";
  used: number;
  limit: number;
  plan: string;
};

const SUGGESTED_TIER: Record<string, string> = {
  free: "Starter",
  starter: "Pro",
  pro: "Business",
  business: "Enterprise",
  enterprise: "Enterprise",
};

export function QuotaExhaustedDialog(props: QuotaExhaustedDialogProps) {
  const t = useTranslations("Quota");
  const tier = SUGGESTED_TIER[props.plan] ?? "Starter";

  return (
    <Dialog open={props.open} onOpenChange={props.onOpenChange}>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>{t(`exhausted.${props.kind}.title`)}</DialogTitle>
          <DialogDescription>
            {t(`exhausted.${props.kind}.description`, {
              used: props.used,
              limit: props.limit,
              plan: props.plan,
              tier,
            })}
          </DialogDescription>
        </DialogHeader>
        <DialogFooter>
          <Button variant="outline" onClick={() => props.onOpenChange(false)}>
            {t("close")}
          </Button>
          <Button disabled className="opacity-60">
            {t("upgradeComingSoon")}
          </Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
}
```

The actual integration into callers (e.g., InviteForm, the AddSite dialog) is one extra step per caller: catch the QuotaExceededError (or a 403 response with `error: "QUOTA_EXCEEDED"`), set local state `quotaDialog = { open: true, ...result }`, and render the dialog conditionally. **Phase 5 wires only ONE caller as a demonstration**: `<InviteForm>` (where the action throws QuotaExceededError directly). The site-add and run-test callers can wire later; for now the 403 surfaces as a generic toast.

- [ ] **Step 9.2: Wire `<InviteForm>` to show the dialog on QuotaExceededError**

Read `app/[locale]/settings/team/invite-form.tsx`. Modify the catch block to:

```tsx
import { useState } from "react";
import { QuotaExhaustedDialog } from "@/components/billing/quota-exhausted-dialog";

// Inside the component:
const [quotaDialog, setQuotaDialog] = useState<null | {
  kind: "seats";
  used: number;
  limit: number;
  plan: string;
}>(null);

// Replace the existing `catch { toast.error(...) }` with:
} catch (err) {
  const msg = err instanceof Error ? err.message : String(err);
  // Heuristic: QuotaExceededError messages start with "Quota exceeded for"
  if (msg.startsWith("Quota exceeded for")) {
    // Parse the message — simpler than re-throwing structured data through
    // Server Actions (which can't transport custom error classes cleanly).
    const m = msg.match(/Quota exceeded for (\w+) on plan (\w+): (\d+)\/(\d+)/);
    if (m) {
      setQuotaDialog({
        kind: m[1] as "seats",
        plan: m[2]!,
        used: Number(m[3]),
        limit: Number(m[4]),
      });
    } else {
      toast.error(t("inviteFailed"));
    }
  } else {
    toast.error(t("inviteFailed"));
  }
}

// In the JSX, render the dialog:
{quotaDialog && (
  <QuotaExhaustedDialog
    open={true}
    onOpenChange={(v) => !v && setQuotaDialog(null)}
    kind={quotaDialog.kind}
    used={quotaDialog.used}
    limit={quotaDialog.limit}
    plan={quotaDialog.plan}
  />
)}
```

Note: parsing the error message string is a pragmatic workaround because Server Actions serialize errors as plain Error objects — custom class fields don't survive the network boundary. A more elegant approach (return-shape with `{ ok: false, error: ... }`) is a future refactor.

- [ ] **Step 9.3: Typecheck + lint + commit**

```bash
pnpm typecheck 2>&1 | tail -3
pnpm lint components/billing/quota-exhausted-dialog.tsx 'app/[locale]/settings/team/invite-form.tsx' 2>&1 | tail -3
git add components/billing/quota-exhausted-dialog.tsx 'app/[locale]/settings/team/invite-form.tsx'
git commit -m "feat(billing): QuotaExhaustedDialog + wire InviteForm to show it on seats exhaustion

Client modal triggered when a quota-consuming action throws
QuotaExceededError. Phase 5 wires only the InviteForm; AddSite +
LaunchTest will wire similarly in Phase 6 alongside the actual
upgrade Checkout flow. Upgrade button is disabled with 'Coming soon'
copy for now."
```

---

## Task 10: i18n keys for Phase 5

**Files:**

- Modify: `messages/{en,fr,es}.json`

- [ ] **Step 10.1: Add `Quota` and `Billing.usage` namespaces**

For `messages/en.json` (append to existing top-level object):

```json
"Quota": {
  "close": "Close",
  "upgradeComingSoon": "Upgrade — coming soon",
  "exhausted": {
    "tests": {
      "title": "Monthly test limit reached",
      "description": "You've used {used} of {limit} tests this month on the {plan} plan. Upgrade to {tier} for more tests."
    },
    "sites": {
      "title": "Site limit reached",
      "description": "You've added {used} of {limit} sites on the {plan} plan. Upgrade to {tier} to add more sites."
    },
    "seats": {
      "title": "Seat limit reached",
      "description": "You have {used} of {limit} seats on the {plan} plan. Upgrade to {tier} to invite more teammates."
    }
  }
},
"Billing": {
  "usage": {
    "tests": "Tests this month",
    "sites": "Sites monitored",
    "seats": "Team members"
  }
}
```

For `messages/fr.json`:

```json
"Quota": {
  "close": "Fermer",
  "upgradeComingSoon": "Upgrade — bientôt disponible",
  "exhausted": {
    "tests": {
      "title": "Limite mensuelle de tests atteinte",
      "description": "Vous avez utilisé {used} sur {limit} tests ce mois-ci sur le plan {plan}. Passez à {tier} pour davantage de tests."
    },
    "sites": {
      "title": "Limite de sites atteinte",
      "description": "Vous avez ajouté {used} sur {limit} sites sur le plan {plan}. Passez à {tier} pour ajouter plus de sites."
    },
    "seats": {
      "title": "Limite de membres atteinte",
      "description": "Vous avez {used} sur {limit} membres sur le plan {plan}. Passez à {tier} pour inviter plus de coéquipiers."
    }
  }
},
"Billing": {
  "usage": {
    "tests": "Tests ce mois-ci",
    "sites": "Sites monitorés",
    "seats": "Membres d'équipe"
  }
}
```

For `messages/es.json`:

```json
"Quota": {
  "close": "Cerrar",
  "upgradeComingSoon": "Upgrade — próximamente",
  "exhausted": {
    "tests": {
      "title": "Límite mensual de pruebas alcanzado",
      "description": "Ha usado {used} de {limit} pruebas este mes en el plan {plan}. Actualice a {tier} para más pruebas."
    },
    "sites": {
      "title": "Límite de sitios alcanzado",
      "description": "Ha añadido {used} de {limit} sitios en el plan {plan}. Actualice a {tier} para añadir más sitios."
    },
    "seats": {
      "title": "Límite de miembros alcanzado",
      "description": "Tiene {used} de {limit} miembros en el plan {plan}. Actualice a {tier} para invitar a más compañeros."
    }
  }
},
"Billing": {
  "usage": {
    "tests": "Pruebas este mes",
    "sites": "Sitios monitorizados",
    "seats": "Miembros del equipo"
  }
}
```

- [ ] **Step 10.2: Verify parity + commit**

```bash
jq . messages/en.json > /dev/null && jq . messages/fr.json > /dev/null && jq . messages/es.json > /dev/null
diff <(jq -r 'paths(scalars) | join(".")' messages/en.json | sort) <(jq -r 'paths(scalars) | join(".")' messages/fr.json | sort)
diff <(jq -r 'paths(scalars) | join(".")' messages/en.json | sort) <(jq -r 'paths(scalars) | join(".")' messages/es.json | sort)
git add messages/
git commit -m "i18n(quota,billing): add Quota.exhausted + Billing.usage namespaces (EN/FR/ES)"
```

---

## Task 11: Smoke test verify-quotas

**Files:**

- Create: `scripts/verify-quotas.ts`
- Modify: `package.json`

- [ ] **Step 11.1: Create the test**

```ts
import { and, eq, gte, like, notExists } from "drizzle-orm";
import { nanoid } from "nanoid";

import { auth } from "../lib/auth";
import { db } from "../lib/db";
import { member, organization, orgSubscription, sites, siteTests, user } from "../lib/db/schema";
import { checkQuota, getCurrentMonthStart, getOrgPlan } from "../lib/billing/quotas";

const EMAIL = `smoke-quotas-${nanoid(8)}@elevaseo.com`;
const NAME = "Smoke Quotas";
const PASSWORD = "SmokeQuotas!2026";

async function main() {
  console.log(`→ Signing up ${EMAIL}`);
  const res = await auth.api.signUpEmail({
    body: { name: NAME, email: EMAIL, password: PASSWORD },
  });
  const userId = res.user.id;

  try {
    // Resolve Personal Org (Phase 1).
    const mem = await db.select().from(member).where(eq(member.userId, userId)).limit(1);
    if (mem.length === 0) throw new Error("Personal org missing");
    const orgId = mem[0]!.organizationId;

    // 1. Plan should be free (signup hook creates the row).
    const plan = await getOrgPlan(orgId);
    if (plan !== "free") throw new Error(`Expected free, got ${plan}`);
    console.log(`✓ Plan: ${plan}`);

    // 2. Sites quota: 0/1 initially.
    const sitesBefore = await checkQuota(orgId, "sites");
    if (sitesBefore.used !== 0 || sitesBefore.limit !== 1) {
      throw new Error(`Sites: expected 0/1, got ${sitesBefore.used}/${sitesBefore.limit}`);
    }
    console.log(`✓ Sites quota: 0/${sitesBefore.limit}`);

    // 3. Add 1 site → 1/1.
    const siteId = nanoid();
    await db.insert(sites).values({
      id: siteId,
      organizationId: orgId,
      createdByUserId: userId,
      hostname: "example-quotas.com",
      url: "https://example-quotas.com/",
      label: null,
      createdAt: new Date().toISOString(),
    });
    const sitesAfter = await checkQuota(orgId, "sites");
    if (sitesAfter.used !== 1 || sitesAfter.ok !== false) {
      throw new Error(
        `Sites: expected 1/1 ok=false, got ${sitesAfter.used}/${sitesAfter.limit} ok=${sitesAfter.ok}`,
      );
    }
    console.log(
      `✓ Sites quota after add: ${sitesAfter.used}/${sitesAfter.limit} ok=${sitesAfter.ok}`,
    );

    // 4. Tests quota: 0/15 initially this month.
    const testsBefore = await checkQuota(orgId, "tests");
    if (testsBefore.limit !== 15) {
      throw new Error(`Tests: expected limit 15, got ${testsBefore.limit}`);
    }
    console.log(`✓ Tests quota: ${testsBefore.used}/${testsBefore.limit}`);

    // 5. Insert 15 dummy tests dated this month → 15/15, ok=false.
    const ids: string[] = [];
    for (let i = 0; i < 15; i++) {
      const id = `test-${nanoid()}`;
      ids.push(id);
      await db.insert(siteTests).values({
        id,
        siteId,
        organizationId: orgId,
        triggeredByUserId: userId,
        hostname: "example-quotas.com",
        url: "https://example-quotas.com/",
        status: "completed",
        createdAt: new Date().toISOString(),
      });
    }
    const testsAfter = await checkQuota(orgId, "tests");
    if (testsAfter.used !== 15 || testsAfter.ok !== false) {
      throw new Error(
        `Tests: expected 15/15 ok=false, got ${testsAfter.used}/${testsAfter.limit} ok=${testsAfter.ok}`,
      );
    }
    console.log(
      `✓ Tests quota after 15: ${testsAfter.used}/${testsAfter.limit} ok=${testsAfter.ok}`,
    );

    // 6. Seats quota: 1/1 (only the owner).
    const seats = await checkQuota(orgId, "seats");
    if (seats.used !== 1 || seats.ok !== false) {
      throw new Error(
        `Seats: expected 1/1 ok=false, got ${seats.used}/${seats.limit} ok=${seats.ok}`,
      );
    }
    console.log(`✓ Seats quota: ${seats.used}/${seats.limit} ok=${seats.ok}`);
  } finally {
    await db.delete(user).where(eq(user.id, userId));
    await db
      .delete(organization)
      .where(
        and(
          like(organization.name, "Personal — smoke-quotas-%"),
          notExists(
            db
              .select({ one: member.id })
              .from(member)
              .where(eq(member.organizationId, organization.id)),
          ),
        ),
      );
    console.log(`→ Cleanup done`);
  }

  console.log(`✅ verify-quotas OK`);
  process.exit(0);
}

main().catch((e) => {
  console.error(`❌ verify-quotas FAILED:`, e instanceof Error ? e.message : e);
  process.exit(1);
});
```

- [ ] **Step 11.2: Register pnpm script**

After `verify:team-invite`:

```json
"verify:quotas": "tsx --env-file-if-exists=.env.local --env-file-if-exists=.env scripts/verify-quotas.ts",
```

- [ ] **Step 11.3: Run + commit**

```bash
pnpm verify:quotas
git add scripts/verify-quotas.ts package.json
git commit -m "test: add smoke test for Free-tier quotas (sites=1, tests=15, seats=1)

Signs up a fresh user, asserts the Free plan stub was created, exercises
checkQuota for sites/tests/seats across thresholds (0→1, 0→15), confirms
ok flips false at the limit. Cleans up in finally{}."
```

---

## Task 12: Final validation + format + final review

- [ ] **Step 12.1: Full validation**

```bash
cd /Users/humperdoo/Github/eleva-speed
pnpm typecheck
pnpm lint
pnpm verify:org-signup
pnpm verify:org-sites
pnpm verify:org-switch
pnpm verify:team-invite
pnpm verify:quotas
diff <(jq -r 'paths(scalars) | join(".")' messages/en.json | sort) <(jq -r 'paths(scalars) | join(".")' messages/fr.json | sort)
diff <(jq -r 'paths(scalars) | join(".")' messages/en.json | sort) <(jq -r 'paths(scalars) | join(".")' messages/es.json | sort)
```

All must pass. Both diffs empty.

- [ ] **Step 12.2: Format**

```bash
pnpm format
git add -A
git commit -m "chore(format): oxfmt pass on Phase 5 new files" 2>&1 || echo "(nothing to commit)"
```

- [ ] **Step 12.3: Manual E2E hint to user**

`! pnpm dev:agent`, log in as bastien, visit `/dashboard` — verify the new usage panel renders 3 meters (Tests, Sites, Seats) at the top. Visit `/settings/team`, try to invite a member when seats are exhausted (manually insert an extra member row in DB, or just observe with the owner-test user which already has full org). The QuotaExhaustedDialog should appear.

---

## Critères d'acceptation Phase 5

- ✅ `es_org_subscription` table created, every org has a row with `plan=free`.
- ✅ Signup hook inserts a stub subscription row.
- ✅ Backfill script idempotent.
- ✅ `checkQuota(orgId, kind)` returns `{ used, limit, ok, plan, percent }`.
- ✅ `assertQuota` throws `QuotaExceededError` enforced in `inviteMember`, POST `/api/sites`, POST `/api/sites/[siteId]/test`.
- ✅ `<UsageMeter>` renders in dashboard with tone steps (amber/destructive).
- ✅ `<QuotaExhaustedDialog>` wired in InviteForm for seats exhaustion.
- ✅ i18n EN/FR/ES with key parity for `Quota.*` + `Billing.usage.*`.
- ✅ Smoke test `verify:quotas` PASS.
- ✅ `pnpm typecheck && pnpm lint` clean.

## Next phase

Phase 6 — Stripe Checkout + Customer Portal + webhook handler + `/settings/billing` page. Wires the actual upgrade flow that this phase's QuotaExhaustedDialog teases.
