# Seat Flow — Option A (guard à l'acceptation + notifications) — Design Spec

**Date:** 2026-06-12 · **Status:** Approved (Option A, mode goal) · **Branch:** feature/seat-flow
**Context:** invitation sending is already quota-blocked (3B). The gap: an invitation sent while a seat was free can be ACCEPTED later when the org is full (seat taken, downgrade, trial expired) — today the staff hits a silent wall and the admin learns nothing.

## Goal
Close the invite→accept window: (1) admins see seat usage continuously; (2) acceptance re-checks seats+billing and, when blocked, leaves the invitation pending, still creates the staff's own account, shows them a clear screen, and (3) emails the org's owner/admins a "upgrade to welcome them" nudge (deduped); (4) when the plan is upgraded, blocked invitees are emailed that a seat is now free.

## Slices

### S1 — Seats gauge (Settings→Équipe)
Server page fetches `countOrgSeats(orgId)` + `getOrgQuotaLimits(orgId).seats` → team page header shows « {used}/{limit} sièges » (limit -1 → « illimité ») + when full: CTA « Augmenter le plan » → `/settings/billing`, and the existing invite button disabled with a hint. Banner when ≥1 pending invitation is seat-blocked (see S2 flag): « {email} attend une place — augmente ton plan pour l'accueillir. » Strings hardcoded FR (team page not yet i18n-migrated — consistent with the page).

### S2 — Accept-time guard + staff screen + admin email
- Migration **0034** (`when` → `1781350000000`): `invitation.seatBlockNotifiedAt: timestamp` (nullable) — dedup flag, set when the block email is sent.
- Better Auth `hooks.before` on **`/organization/accept-invitation`** (mirror the invite-member hook): resolve the invitation → org → `getOrgBillingStatus(orgId)` + seats check (`assertWithinQuota(orgId, "seats", 1)`).
  - Blocked (quota OR org expired): throw `APIError("FORBIDDEN", { message, code: "SEAT_BLOCKED" })`; fire-and-forget: if `seatBlockNotifiedAt` is null → send the admin email + stamp the column. Invitation stays `pending` (not consumed).
- **Admin email** (src/lib/email.ts pattern, FR like existing templates): to org owner + admins — « {email} a tenté de rejoindre {org}, mais il n'y a plus de siège disponible sur le plan {plan}. Augmente ton plan pour l'accueillir. » CTA → `{APP_URL}/settings/billing`.
- **Staff screen**: `accept-invitation.tsx` handles the SEAT_BLOCKED error distinctly (not the generic error): dedicated state — i18n `Auth` keys (×4 catalogs): `seatBlockedTitle` (« Pas de siège disponible »), `seatBlockedBody` (« L'espace {org} n'a plus de siège disponible sur son forfait actuel. Le propriétaire a été prévenu — tu recevras un email dès qu'une place se libère. »), `seatBlockedMeanwhile` (« En attendant, ton propre espace de travail t'attend. ») + CTA to the app (their personal org + trial exist — account creation is unaffected, it happens before acceptance).

### S3 — Close the loop on upgrade
In the stripe plugin's `onEvent` (extend the existing `fulfillMcpPack` wiring → a small dispatcher): on `customer.subscription.updated`/`created` for a referenceId org → compute free seats (`limits.seats - countOrgSeats(orgId)`, -1 = ∞); if > 0, select pending invitations with `seatBlockNotifiedAt IS NOT NULL` (oldest first, capped at free seats) → email each invitee « Une place s'est libérée dans {org} — clique pour rejoindre » with the accept link (`{APP_URL}/accept-invitation/{id}`) → reset `seatBlockNotifiedAt` to null (so a re-block can re-notify the admin). Pure helper `selectInviteesToUnblock(freeSeats, blockedInvitations)` for tests.

## Tests (gate `bun run test:all`)
- Guard: DB-gated — full org (solo plan seeded) + pending invitation → accept hook path throws SEAT_BLOCKED; invitation still pending; `seatBlockNotifiedAt` stamped once (second attempt: no double email — assert via the stamp, email send mocked/injected).
- Expired org → same block.
- `selectInviteesToUnblock` pure: caps at free seats, oldest first, -1 unlimited → all.
- Gauge math: used/limit display logic (pure helper if extracted).
- Catalog parity with the new Auth keys.

## Out of scope
Option B (seat_requests table / formal waiting list), auto-join on upgrade (we email, the human clicks), domain whitelisting (sub-project 4), team page full i18n migration.

## Notes
- Email sends are gated on `env.emailEnabled` (Resend) like every other mail — without a key, the block screen still works; emails just don't go out (dev note).
- The webhook-driven S3 requires working webhooks (sandbox `stripe listen --api-key` issue — user-side runbook); code ships regardless, fires in prod.
