# Design — Organisations, rôles & billing Stripe

> Date : 2026-05-25
> Statut : design validé, en attente du plan d'implémentation
> Scope : introduire un modèle multi-tenant (organisations + rôles) et brancher le billing Stripe sur les tiers du pricing (`docs/pricing/pricing-proposal.md`).

## 1. Objectif & non-objectifs

### 1.1 Objectif

Faire passer eleva-speed d'un modèle single-user à un modèle multi-tenant avec :

- Une **organisation** par compte payeur (toujours présente, même sur Free).
- Trois **rôles** au sein d'une org : `owner`, `admin`, `member`.
- Un **rôle plateforme** orthogonal pour le staff eleva-speed (`user.role = "admin"`, déjà géré par Better Auth).
- Un **billing Stripe** branché sur les plans Starter / Pro / Business du pricing existant, avec Free et Enterprise gérés sans subscription Stripe self-serve.

### 1.2 Non-objectifs (out of scope v1)

- Add-ons facturés (blocs de tests supplémentaires, seats supplémentaires, white-label PDF).
- SSO SAML / SCIM (Enterprise — manuel).
- Audit log persistant.
- Trial period gratuit sur Pro/Business.
- Email digest mensuel "usage report".
- Multi-currency (EUR uniquement v1).
- Métering Stripe d'usage en temps réel (mode quota strict app-side).
- Self-serve plan change via Customer Portal Stripe (désactivé tant que pas testé).

## 2. Décisions structurantes

| #   | Décision                                                                                                                                | Alternative écartée                                                                       |
| --- | --------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
| 1   | **Org pour tout le monde dès le signup** (Personal Org auto-créée).                                                                     | Org seulement à partir de Pro → code path double, migration complexe.                     |
| 2   | **3 rôles : Owner / Admin / Member** (Better Auth standard). Owner unique par org, gère le billing.                                     | 2 rôles seulement → casse l'argumentaire RBAC du tier Business.                           |
| 3   | **Stripe flat tier** (1 Product / tier, monthly+annual) + quotas enforced app-side.                                                     | Per-seat natif ou metered billing → overkill v1, pas aligné avec pricing actuel.          |
| 4   | **Stripe Sandbox standard** (test mode, `sk_test_*`, Stripe CLI listen).                                                                | Stripe Sandbox accounts isolés → pas justifié en solo dev.                                |
| 5   | **Stripe Checkout hosted** + Customer Portal hosted.                                                                                    | Stripe Elements → trop de code pour zéro valeur v1.                                       |
| 6   | **Migration : auto-create Personal Org pour users existants** + backfill `organizationId` sur `sites` et `siteTests`.                   | Wipe DB → perd les comptes de test.                                                       |
| 7   | **Admin staff = vue read-only** des orgs (liste + plan + status + MRR). Pas d'override manuel, pas de CRUD Products Stripe depuis l'UI. | Override manuel / UI Products → ajoute un plan_manual hors-Stripe à gérer, ROI faible v1. |

## 3. Architecture globale

Trois couches qui s'empilent.

### 3.1 Identité (Better Auth)

- `user` (existant). Conserve `role` du plugin `admin()` pour le staff eleva-speed.
- `session`, `account`, `verification` (inchangés, sauf ajout `session.active_organization_id`).

### 3.2 Multi-tenant (Better Auth `organization` plugin)

- `organization` — une org par compte payeur. Tout signup en crée une (Personal Org).
- `member` — table de jointure user ↔ org avec `role` `owner` | `admin` | `member`.
- `invitation` — invitations en attente.

### 3.3 Billing (custom, préfixé `es_`)

- `es_org_subscription` — état billing répliqué depuis Stripe webhooks (plan, status, period_end, IDs Stripe). 1 row par org. Source de vérité côté app.
- `es_stripe_event` — idempotence des webhooks (PK = event.id).

### 3.4 Flow de signup → onboarding

1. User signup (Better Auth) → `databaseHooks.user.create.after` :
   1. Insère un row `leads` (existant).
   2. Crée une `organization` (`name = "Personal — {email}"`, `slug = nanoid()`).
   3. Crée un `member` (role `owner`).
   4. Insère un row `es_org_subscription` (plan `free`, `stripe_customer_id` créé en lazy).
      _(`stripe_customer_id` reste null à ce stade — créé en lazy au premier accès `/billing`.)_
2. User connecté → Better Auth charge `activeOrganizationId` dans la session.
3. Toute query domaine (`sites`, `siteTests`) filtre par `activeOrganizationId`.

### 3.5 Flow d'upgrade

1. Owner clique "Upgrade" dans `/settings/billing` → Server Action `createCheckoutSession`.
2. Lazy-create Stripe Customer si absent.
3. `stripe.checkout.sessions.create` avec `client_reference_id = orgId`, `metadata = { orgId, plan, interval }`.
4. Redirect vers `checkout.stripe.com` (hosted).
5. User paie → Stripe envoie webhook `checkout.session.completed` → handler met à jour `es_org_subscription` avec `plan`, `interval`, `status`, periods, IDs Stripe.
6. User redirigé sur `/settings/billing/success` qui poll `es_org_subscription.plan` jusqu'à voir l'activation (timeout 10s, fallback message).

### 3.6 Sources de vérité

- **Plan / status d'une org en runtime** → `es_org_subscription` (jamais Stripe API).
- **État Stripe canonique** → Stripe DB. La nôtre est une réplication eventuellement consistante (eventually consistent), avec idempotence et webhooks comme source de mise à jour.
- **Products / Prices** → constantes typées dans `lib/billing/plans.ts` + 6 env vars `STRIPE_PRICE_*`. Pas de table.

## 4. Schéma DB

### 4.1 Tables Better Auth `organization` (auto-générées par le plugin)

```ts
// lib/db/schema.ts (additions)

export const organization = pgTable("organization", {
  id: text("id").primaryKey(),
  name: text("name").notNull(),
  slug: text("slug").notNull().unique(),
  logo: text("logo"),
  createdAt: timestamp("created_at", { withTimezone: true, mode: "date" }).notNull().defaultNow(),
  metadata: text("metadata"),
});

export const member = pgTable(
  "member",
  {
    id: text("id").primaryKey(),
    organizationId: text("organization_id")
      .notNull()
      .references(() => organization.id, { onDelete: "cascade" }),
    userId: text("user_id")
      .notNull()
      .references(() => user.id, { onDelete: "cascade" }),
    role: text("role").notNull(), // "owner" | "admin" | "member"
    createdAt: timestamp("created_at", { withTimezone: true, mode: "date" }).notNull().defaultNow(),
  },
  (t) => [index("idx_member_org").on(t.organizationId), index("idx_member_user").on(t.userId)],
);

export const invitation = pgTable(
  "invitation",
  {
    id: text("id").primaryKey(),
    organizationId: text("organization_id")
      .notNull()
      .references(() => organization.id, { onDelete: "cascade" }),
    email: text("email").notNull(),
    role: text("role").notNull(), // "admin" | "member"
    status: text("status").notNull(), // "pending" | "accepted" | "rejected" | "expired"
    inviterId: text("inviter_id")
      .notNull()
      .references(() => user.id),
    expiresAt: timestamp("expires_at", { withTimezone: true, mode: "date" }).notNull(),
  },
  (t) => [index("idx_invitation_org").on(t.organizationId)],
);
```

### 4.2 Extension `session`

Ajout d'une colonne `active_organization_id` (gérée par Better Auth, persiste l'org active de la session courante) :

```ts
export const session = pgTable("session", {
  // ... colonnes existantes ...
  activeOrganizationId: text("active_organization_id"),
});
```

### 4.3 Table `es_org_subscription` (custom)

```ts
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(), // nullable, lazy-created
    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),
  ],
);
```

> **Note** : `stripeCustomerId` est nullable et créé en lazy (cf. §6.6) : que ce soit pour un nouveau signup ou pour le backfill, on insère la row avec `stripe_customer_id = null`. Le Customer Stripe est créé au premier accès `/settings/billing` ou au premier clic upgrade. Évite N appels Stripe au signup et à la migration. Cohérent avec le fait qu'un user Free peut ne jamais cliquer sur billing.

### 4.4 Table `es_stripe_event` (idempotence webhooks)

```ts
export const stripeEvent = pgTable("es_stripe_event", {
  id: text("id").primaryKey(), // event.id Stripe
  type: text("type").notNull(),
  receivedAt: timestamp("received_at", { withTimezone: true, mode: "date" }).notNull().defaultNow(),
});
```

### 4.5 Migration des tables domaine `sites` + `siteTests`

```ts
export const sites = pgTable(
  "es_site",
  {
    id: text("id").primaryKey(),
    organizationId: text("organization_id")
      .notNull()
      .references(() => organization.id, { onDelete: "cascade" }),
    createdByUserId: text("created_by_user_id") // renommé depuis user_id
      .references(() => user.id, { onDelete: "set null" }),
    // hostname, url, label, audit_cadence, last_audit_at, next_audit_at — inchangés
  },
  (t) => [
    index("idx_site_org").on(t.organizationId), // remplace idx_site_user
    index("idx_site_next_audit").on(t.nextAuditAt),
  ],
);

export const siteTests = pgTable(
  "es_site_test",
  {
    // ... colonnes existantes (métriques Lighthouse, etc.) ...
    organizationId: text("organization_id")
      .notNull()
      .references(() => organization.id, { onDelete: "cascade" }),
    triggeredByUserId: text("triggered_by_user_id") // renommé depuis user_id
      .references(() => user.id, { onDelete: "set null" }),
  },
  (t) => [
    index("idx_site_test_org").on(t.organizationId),
    // idx_site_test_site, idx_site_test_created, idx_site_test_hostname conservés
  ],
);
```

`leads` reste user-based (capture marketing avant org). Aucun changement.

### 4.6 Migration Drizzle (séquence)

1. `CREATE TABLE organization`, `member`, `invitation`, `es_org_subscription`, `es_stripe_event`.
2. `ALTER TABLE session ADD COLUMN active_organization_id text`.
3. `ALTER TABLE es_site ADD COLUMN organization_id text` (nullable transitoire).
4. `ALTER TABLE es_site_test ADD COLUMN organization_id text` (nullable transitoire).
5. **Backfill SQL** (dans une transaction) :
   - Pour chaque `user` existant : créer `organization` (`name = "Personal — {email}"`, `slug = nanoid()`), créer `member` (role `owner`).
   - Créer `es_org_subscription` (plan `free`, `stripe_customer_id = null`). Le Customer Stripe est créé à la demande via `ensureStripeCustomer` au premier accès `/settings/billing`.
   - `UPDATE es_site SET organization_id = ?` via JOIN sur `member`.
   - `UPDATE es_site_test SET organization_id = ?` via JOIN.
6. `ALTER TABLE … ALTER COLUMN organization_id SET NOT NULL`.
7. `RENAME COLUMN user_id TO created_by_user_id` (sites) / `triggered_by_user_id` (siteTests).
8. Drop indexes `idx_site_user`, `idx_site_test_user`. Add nouveaux indexes.

Toute la séquence dans une transaction. Drizzle `pnpm db:migrate` fail-safe.

### 4.7 Données non stockées

- Stripe `Product` IDs et `Price` IDs → constantes `lib/billing/plans.ts` + env vars.
- Pas d'audit log v1.

## 5. RBAC

### 5.1 Deux dimensions de permissions

- **Platform role** (`user.role`) : `null` | `"admin"`.
- **Org role** (`member.role`) : `owner` | `admin` | `member`. Hiérarchie ordinale `owner ≥ admin ≥ member`.

### 5.2 Matrice complète

| Action                               | Member | Admin | Owner | Platform admin |
| ------------------------------------ | ------ | ----- | ----- | -------------- |
| Voir sites de l'org                  | ✅     | ✅    | ✅    | ✅ (any)       |
| Lancer un test on-demand             | ✅     | ✅    | ✅    | ✅             |
| Voir l'historique des tests          | ✅     | ✅    | ✅    | ✅             |
| Ajouter / éditer / supprimer un site | ❌     | ✅    | ✅    | ✅             |
| Configurer scheduled audits          | ❌     | ✅    | ✅    | ✅             |
| Voir la liste des membres            | ✅     | ✅    | ✅    | ✅             |
| Inviter un nouveau membre            | ❌     | ✅    | ✅    | ✅             |
| Changer le rôle (sauf owner)         | ❌     | ✅    | ✅    | ✅             |
| Retirer un membre (sauf owner)       | ❌     | ✅    | ✅    | ✅             |
| Transférer l'ownership               | ❌     | ❌    | ✅    | ❌             |
| Voir le plan + factures              | ❌     | ❌    | ✅    | ✅ (read-only) |
| Upgrade / downgrade / cancel         | ❌     | ❌    | ✅    | ❌             |
| Mettre à jour méthode de paiement    | ❌     | ❌    | ✅    | ❌             |
| Éditer settings de l'org             | ❌     | ✅    | ✅    | ✅             |
| Supprimer l'org                      | ❌     | ❌    | ✅    | ✅             |
| Lister toutes les orgs               | ❌     | ❌    | ❌    | ✅             |
| Voir `/admin/users`, `/admin/leads`  | ❌     | ❌    | ❌    | ✅             |
| Impersonate un user                  | ❌     | ❌    | ❌    | ✅             |
| Bannir un user                       | ❌     | ❌    | ❌    | ✅             |

### 5.3 Guards (`lib/auth-guards.ts`)

Trois fonctions composables :

```ts
// 1. requireSession(): { session, user } — redirect /login si absent.
// 2. requirePlatformAdmin(): { session, user } — étend l'existant requireAdmin().
//    Redirect /dashboard si user.role !== "admin".
// 3. requireOrgMember(opts?: { minRole?: OrgRole }):
//    { session, user, org, role } — redirect /onboarding si pas de membership.
//    Throw 403 (page error.tsx) si role < minRole.
```

### 5.4 Defense in depth (3 niveaux)

1. **Layout / page** : guard top du Server Component → redirige avant render.
2. **Server Action** : re-check le guard à l'intérieur → vraie barrière sécurité.
3. **UI conditionnelle** : cacher boutons selon `role` → UX uniquement.

### 5.5 Quotas (séparés des permissions)

```ts
// lib/billing/quotas.ts
export const PLAN_LIMITS = {
  free: { testsPerMonth: 15, sites: 1, seats: 1, retentionDays: 7 },
  starter: { testsPerMonth: 100, sites: 3, seats: 1, retentionDays: 30 },
  pro: { testsPerMonth: 500, sites: 15, seats: 3, retentionDays: 180 },
  business: { testsPerMonth: 2000, sites: 50, seats: 10, retentionDays: 365 },
  // enterprise: override depuis organization.metadata
} as const;

export async function checkQuota(
  orgId: string,
  kind: "tests" | "sites" | "seats",
): Promise<{ ok: boolean; used: number; limit: number }>;
```

Tous les checks tapent `es_org_subscription.plan` puis comptent dans la DB (count des `sites`, count des `siteTests` sur 30 jours glissants, count des `member`). Aucun appel Stripe en runtime.

## 6. Stripe

### 6.1 Products & Prices (sandbox)

Créés via `scripts/stripe-bootstrap.ts` (idempotent, re-runnable, lit `lib/billing/plans.ts`) :

| Tier       | Product                        | Price monthly | Price yearly |
| ---------- | ------------------------------ | ------------- | ------------ |
| Free       | — (pas de Product)             | —             | —            |
| Starter    | `prod_starter`                 | 19 EUR        | 190 EUR      |
| Pro        | `prod_pro`                     | 49 EUR        | 490 EUR      |
| Business   | `prod_business`                | 149 EUR       | 1490 EUR     |
| Enterprise | créé manuellement à la demande | custom        | custom       |

Le script écrit les 6 price IDs résultants dans `.env.local`.

### 6.2 `lib/billing/plans.ts`

```ts
export const PLANS = {
  free: {
    name: "Free",
    priceEur: 0,
    stripePriceIds: null,
  },
  starter: {
    name: "Starter",
    priceEur: { month: 19, year: 190 },
    stripePriceIds: {
      month: env.STRIPE_PRICE_STARTER_MONTHLY,
      year: env.STRIPE_PRICE_STARTER_YEARLY,
    },
  },
  pro: {
    /* idem */
  },
  business: {
    /* idem */
  },
} as const satisfies Record<Plan, PlanConfig>;

export const PRICE_ID_TO_PLAN: Record<string, { plan: Plan; interval: BillingInterval }> =
  buildPriceLookup(PLANS);
```

Les 6 price IDs sont validés au boot par Zod dans `lib/env.ts`. Crash fail-fast si absents.

### 6.3 Flow d'upgrade détaillé

```
Owner clique "Upgrade to Pro Monthly" sur /settings/billing
   ↓
Server Action createCheckoutSession({ plan: "pro", interval: "month" })
   ├─ requireOrgMember({ minRole: "owner" })
   ├─ ensureStripeCustomer(org)
   ├─ stripe.checkout.sessions.create({
   │     mode: "subscription",
   │     customer: stripeCustomerId,
   │     line_items: [{ price: PLANS.pro.stripePriceIds.month, quantity: 1 }],
   │     client_reference_id: orgId,
   │     metadata: { orgId, plan: "pro", interval: "month" },
   │     success_url: "{origin}/settings/billing/success?session_id={CHECKOUT_SESSION_ID}",
   │     cancel_url:  "{origin}/settings/billing",
   │     allow_promotion_codes: true,
   │     billing_address_collection: "required",
   │     automatic_tax: { enabled: env.STRIPE_TAX_ENABLED },  // décidé en pré-requis
   │  })
   └─ redirect côté client vers session.url
       ↓
Stripe Checkout (hosted) — user paie
       ↓
Redirect vers /settings/billing/success
   └─ Page poll es_org_subscription.plan, affiche "Activation in progress…" puis "Welcome to Pro 🎉"
```

**Important** : `/settings/billing/success` ne touche **pas** la DB. La mise à jour vient exclusivement du webhook.

### 6.4 Customer Portal (gestion subscription)

Pour cancel / update card / voir factures / changer de plan : Server Action `openCustomerPortal()` → redirect vers `billingPortal.sessions.create({ customer, return_url })`. Bouton "Manage subscription" dans `/settings/billing`.

Configuré une fois côté Stripe Dashboard pour autoriser : cancel subscription, update payment method, update billing info, voir invoices. **Bloquer plan changes** v1 (sécurité).

### 6.5 Webhook handler

Route : `app/api/stripe/webhook/route.ts` (POST, Node.js runtime).

**Sécurité** :

- Vérif signature avec `stripe.webhooks.constructEvent(rawBody, signature, env.STRIPE_WEBHOOK_SECRET)`.
- 401 si signature invalide ou manquante.

**Whitelist d'événements** :

| Event                           | Action sur `es_org_subscription`                                                                                                   |
| ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `checkout.session.completed`    | Lookup org via `client_reference_id`. Set `stripeSubscriptionId`, `stripePriceId`, `plan`, `interval`, `status = active`, periods. |
| `customer.subscription.updated` | Re-sync `plan`, `interval`, `status`, periods, `cancelAtPeriodEnd`. Couvre upgrade/downgrade et reprise après échec.               |
| `customer.subscription.deleted` | `status = "canceled"`, `plan = "free"`, clear `stripeSubscriptionId`/`stripePriceId`.                                              |
| `invoice.payment_failed`        | `status = "past_due"`. UI affiche bandeau rouge.                                                                                   |
| `invoice.paid`                  | Re-set `status = "active"` si on était `past_due`.                                                                                 |
| `customer.updated`              | Re-sync email/name (low priority).                                                                                                 |

**Idempotence** : insert dans `es_stripe_event` (PK = event.id) avec `ON CONFLICT DO NOTHING`. Si déjà reçu → 200 OK et skip.

**Invalidation de cache** : après chaque update réussi, `updateTag(\`org-\${orgId}-billing\`)` pour invalider les sections Cache Components qui dépendent du plan.

### 6.6 Lazy Stripe Customer creation

```ts
// lib/billing/customer.ts
export async function ensureStripeCustomer(org: Organization): Promise<string> {
  const sub = await db.query.orgSubscription.findFirst({
    where: eq(orgSubscription.organizationId, org.id),
  });
  if (sub?.stripeCustomerId) return sub.stripeCustomerId;

  const customer = await stripe.customers.create({
    email: ownerEmail,
    name: org.name,
    metadata: { orgId: org.id },
  });
  await db
    .update(orgSubscription)
    .set({ stripeCustomerId: customer.id, updatedAt: new Date() })
    .where(eq(orgSubscription.organizationId, org.id));
  return customer.id;
}
```

Appelée au premier accès `/settings/billing` ou au premier clic upgrade. `stripeCustomerId` nullable couvre uniformément les nouveaux signups et les orgs migrées. Évite N appels Stripe au signup / backfill.

### 6.7 Dev workflow (Stripe CLI)

```bash
# Terminal 1
pnpm dev

# Terminal 2
stripe listen --forward-to localhost:3000/api/stripe/webhook
# Copier le whsec_... → .env.local STRIPE_WEBHOOK_SECRET

# Terminal 3 (tests)
stripe trigger checkout.session.completed
stripe trigger customer.subscription.updated
stripe trigger invoice.payment_failed
```

Cartes de test :

- `4242 4242 4242 4242` — succès
- `4000 0000 0000 0341` — 3DS requis
- `4000 0000 0000 9995` — refus

### 6.8 Variables d'env (ajouts `lib/env.ts`)

```ts
STRIPE_SECRET_KEY:               z.string().startsWith("sk_test_").or(z.string().startsWith("sk_live_")),
STRIPE_WEBHOOK_SECRET:           z.string().startsWith("whsec_"),
STRIPE_PRICE_STARTER_MONTHLY:    z.string().startsWith("price_"),
STRIPE_PRICE_STARTER_YEARLY:     z.string().startsWith("price_"),
STRIPE_PRICE_PRO_MONTHLY:        z.string().startsWith("price_"),
STRIPE_PRICE_PRO_YEARLY:         z.string().startsWith("price_"),
STRIPE_PRICE_BUSINESS_MONTHLY:   z.string().startsWith("price_"),
STRIPE_PRICE_BUSINESS_YEARLY:    z.string().startsWith("price_"),
STRIPE_TAX_ENABLED:              z.coerce.boolean().default(false),
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: z.string().startsWith("pk_").optional(),
```

## 7. Routes & pages

### 7.1 Arborescence

```
app/[locale]/
├── login/                                  [existant, public]
├── signup/                                  [existant, public]
├── onboarding/                              [NEW, requireSession]
├── accept-invitation/[id]/                  [NEW, public]
├── dashboard/                               [existant, requireOrgMember(member)]
│   ├── sites/                               [queries scopées orgId]
│   └── tests/                               [queries scopées orgId]
├── settings/                                [NEW]
│   ├── profile/                             [requireOrgMember(member)]
│   ├── organization/                        [requireOrgMember(admin)]
│   ├── team/                                [requireOrgMember(admin)]
│   └── billing/                             [requireOrgMember(owner)]
│       └── success/
└── admin/                                   [existant, requirePlatformAdmin]
    ├── leads/                               [existant]
    ├── sites/                               [existant]
    ├── tests/                               [existant]
    ├── users/                               [existant]
    └── orgs/                                [NEW]
        └── [orgId]/
```

### 7.2 Routes API

```
app/api/
├── auth/                                    [existant, Better Auth handler — couvre org + invitations]
├── stripe/
│   └── webhook/                             [NEW, POST, public signé]
├── cron/                                    [existant]
├── integrations/                            [existant, à refactor pour orgId]
├── sites/                                   [existant, à refactor]
└── speed/                                   [existant, à refactor]
```

Pas de route REST pour billing actions — Server Actions uniquement.

### 7.3 Server Actions (`lib/actions/`)

```
billing.ts:
  createCheckoutSession(plan, interval)     — owner only
  openCustomerPortal()                       — owner only
  cancelAtPeriodEnd()                        — owner only (raccourci sans portal)

organization.ts:
  updateOrganization(name, logo)             — admin+
  deleteOrganization()                       — owner only (refuse si subscription active)
  transferOwnership(toUserId)                — owner only, atomique
  switchActiveOrganization(orgId)            — member of target org

team.ts:
  inviteMember(email, role)                  — admin+, check seats quota
  revokeInvitation(invitationId)             — admin+
  removeMember(userId)                       — admin+, refuse si target.role === "owner"
  updateMemberRole(userId, role)             — admin+ (promote en owner = transferOwnership only)
```

Chaque action : guard → validation Zod → exécution. Pattern uniforme.

### 7.4 Composants UI

Nouveaux composants sous `components/` :

```
billing/
  plan-card.tsx               — carte d'un plan avec features + CTA
  plan-grid.tsx               — grille 4 cartes ('use client' pour toggle monthly/yearly)
  current-plan-banner.tsx     — "You're on Pro Monthly — renews 2026-06-25"
  upgrade-cta.tsx             — bouton → createCheckoutSession
  portal-button.tsx           — bouton → openCustomerPortal
  usage-meter.tsx             — bar "120 / 500 tests this month"
  past-due-banner.tsx         — bandeau rouge global
  cancel-scheduled-banner.tsx — bandeau orange "Ends on …"

org/
  org-switcher.tsx            — Combobox sidebar ('use client')
  member-row.tsx              — ligne table members
  invite-form.tsx             — react-hook-form ('use client')
  role-badge.tsx              — chip "Owner" / "Admin" / "Member"

admin/orgs/
  orgs-table.tsx              — TanStack Table
  org-detail-card.tsx         — détail org + "Open in Stripe"
```

Server Components par défaut. `'use client'` uniquement sur composants interactifs marqués.

### 7.5 Org switcher

Affiché en haut de la sidebar (`components/dashboard/nav.tsx`) :

- Affiche `org.name + role badge` de l'org active.
- Combobox listant les `member` rows du user.
- Switch → `switchActiveOrganization(orgId)` → update `session.active_organization_id` → revalidate path.
- Item du bas : "+ Create new organization" → `/onboarding`.

### 7.6 UX past_due / cancellation

- `status === "past_due"` → bandeau rouge global au top du dashboard layout. Lien "Update payment method" → portal.
- `cancelAtPeriodEnd === true` → bandeau orange "Your Pro plan ends on 2026-06-25. [Reactivate]".
- `status === "canceled"` → org est sur Free (déjà reflété par `plan = "free"`). Bandeau "Welcome back to Free. [Upgrade]".
- **Seat quota dépassé après downgrade** : on ne fait **pas** d'éviction automatique. Warning persistant "You have 5 members but Starter allows 1. [Upgrade] or [Remove members]". Pas d'invitation possible tant que le quota n'est pas conforme.

### 7.7 Loading / Error / Not-found (règle projet)

Pour chaque nouveau segment significatif : `loading.tsx` + `error.tsx`.

- `settings/billing/`, `settings/team/`, `admin/orgs/`, `accept-invitation/[id]/`, `onboarding/`.
- `not-found.tsx` dédié sous `accept-invitation/[id]/`.

### 7.8 i18n

Nouveaux namespaces dans `messages/{en,fr,es}.json` :

- `billing.*` (noms plans, CTAs, bandeaux past_due)
- `team.*` (invite, roles, remove confirmations)
- `org.*` (switcher, create, settings, transfer)

Aucune nouvelle dépendance.

### 7.9 Stratégie statique / dynamique

| Page                        | Mode    | Justification         |
| --------------------------- | ------- | --------------------- |
| `/login`, `/signup`         | static  | aucune donnée user    |
| `/dashboard/*`              | dynamic | filtré par user/org   |
| `/settings/*`               | dynamic | données org           |
| `/admin/*`                  | dynamic | données globales      |
| `/settings/billing/success` | dynamic | lit Stripe session    |
| `/accept-invitation/[id]`   | dynamic | lit invitation par ID |

Les sections cachables (liste des plans Stripe par exemple) sont isolées avec `'use cache'` + `cacheTag(\`org-\${orgId}-billing\`)`, invalidées par les webhooks via `updateTag`. PPR pour garder la coquille statique.

## 8. Plan d'implémentation (étapes atomiques)

Ordre conçu pour qu'à chaque étape la branche reste shippable.

**Étape 1 — Better Auth `organization` + tables.**
Ajout du plugin, migration tables, hook `user.create.after` pour Personal Org, backfill users existants.

**Étape 2 — Multi-tenant des tables domaine.**
Migration `es_site` + `es_site_test` (ajout `organization_id`, backfill, rename `user_id`). Refactor toutes les queries domaine (dashboard, API integrations, cron).

**Étape 3 — UI org switcher + onboarding.**
`<OrgSwitcher>`, `switchActiveOrganization`, `/onboarding`, `/accept-invitation/[id]`.

**Étape 4 — Settings org + équipe + RBAC.**
`/settings/profile`, `/settings/organization`, `/settings/team` + Server Actions team, guards formalisés, email d'invitation (Resend).

**Étape 5 — `es_org_subscription` + plans + quotas (sans paiement).**
Table, constantes `PLANS` + `PLAN_LIMITS`, `checkQuota`, `<UsageMeter>`, enforcement dans actions (`inviteMember`, `createSite`, `runTest`). Toutes les orgs sont sur Free.

**Étape 6 — Stripe Checkout + Portal + webhook.**
SDK `stripe`, `scripts/stripe-bootstrap.ts`, `ensureStripeCustomer`, `createCheckoutSession`, `/settings/billing`, `/settings/billing/success`, `/api/stripe/webhook` (signature, idempotence, 6 events), `openCustomerPortal`, bandeaux `past_due` / `cancel_scheduled`.

**Étape 7 — Admin staff `/admin/orgs`.**
Table orgs + plan + status + MRR, page détail avec "Open in Stripe".

## 9. Risques & garde-fous

| Risque                                           | Garde-fou                                                                                                    |
| ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------ |
| **Webhook drop / crash app**                     | Idempotence `es_stripe_event` + Stripe retry 3 jours. Endpoint à monitorer.                                  |
| **Race checkout → webhook** (user revient avant) | Page `/billing/success` poll DB pendant 10s avec fallback message. Pas d'écriture DB depuis success.         |
| **Désync DB ↔ Stripe** (webhook raté)            | Bouton manual "Re-sync from Stripe" dans `/admin/orgs/[orgId]` (peut être ajouté en v1.1 si besoin).         |
| **Mauvais price ID en prod**                     | Validation Zod fail-fast au boot. Script `stripe-bootstrap.ts` idempotent et traçable.                       |
| **Backfill abandonné**                           | Migrations Drizzle wrappées en transaction. Fail = rollback.                                                 |
| **Stripe Customer en double** (race lazy create) | Unique constraint + `ON CONFLICT DO NOTHING` sur l'update post-create.                                       |
| **TVA EU mal configurée**                        | Décision en amont (§10 pré-requis). Test sandbox vérifie facture allemande à 19% TVA.                        |
| **Retrait de l'unique owner**                    | `removeMember` refuse si target.role === "owner". Transfer obligatoire avant.                                |
| **Org supprimée avec subscription active**       | `deleteOrganization` refuse si `status === "active"` avec `stripeSubscriptionId`. Owner doit cancel d'abord. |

## 10. Pré-requis humains (avant code)

- [ ] Compte Stripe créé, sandbox actif (default).
- [ ] `stripe login` depuis le CLI.
- [ ] Décider du nom légal sur factures (Eleva SAS ? EI ?) et le setter dans Stripe → Business.
- [ ] Décider de `STRIPE_TAX_ENABLED` (Stripe Tax auto-collect ou facturation HT).
- [ ] Configurer le Customer Portal côté Stripe Dashboard : autoriser cancel, update payment method, update billing info, voir invoices. **Bloquer** plan changes v1.

## 11. Critères d'acceptation

Le design est ship-ready quand :

1. Un user fraîchement signup arrive sur `/dashboard`, voit sa Personal Org dans le switcher, est sur Free, a 15 tests dispos sur le mois en cours.
2. Un Owner peut upgrade vers Pro Monthly via `/settings/billing`, payer avec `4242 4242 4242 4242`, et voir son plan passer Pro dans les < 10s.
3. Un Owner peut inviter un Admin et un Member par email, qui acceptent l'invitation, et chacun voit la bonne UI selon son rôle.
4. Un Member ne peut **pas** accéder à `/settings/billing` ni `/settings/team` (redirect ou 403).
5. Une carte qui échoue déclenche un bandeau `past_due` ; le portal permet d'updater la carte ; après paiement réussi le bandeau disparaît.
6. Toi (`user.role = "admin"`) vois toutes les orgs dans `/admin/orgs` avec leur plan, status, MRR + lien direct vers le Customer Stripe.
7. `pnpm format:check && pnpm lint && pnpm typecheck && pnpm deadcode` passent.
8. Aucune nouvelle entrée bloquante dans `docs/planning/pre-prod-checklist.md` non résolue.
