# Phase 6 — Stripe Checkout + Customer Portal + Webhook Implementation Plan

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

**Goal:** Brancher Stripe Checkout pour les upgrades, Customer Portal pour la gestion (cancel/update card/factures), et un webhook handler qui synchronise `es_org_subscription` depuis Stripe. Build `/settings/billing` page + `/settings/billing/success` post-checkout landing. Wire la QuotaExhaustedDialog Phase 5 vers un vrai upgrade flow.

**Architecture:** 1 Server Action `createCheckoutSession(plan, interval)` → Stripe Checkout hosted → redirect Stripe → user paie → webhook `checkout.session.completed` met à jour `es_org_subscription` → user redirigé sur `/settings/billing/success` qui poll DB jusqu'à voir le nouveau plan. Customer Portal pour cancel / update. Webhook handler signé + idempotent (via nouvelle table `es_stripe_event`).

**Tech Stack:** Stripe SDK (déjà installé `^22.1.1`), Stripe API `2026-04-22.dahlia`, Next.js App Router route handler pour `/api/stripe/webhook` (Node.js runtime, raw body), shadcn UI (`Card`, `Dialog`, `Badge`), Drizzle.

**Stripe best practices appliquées :**

- ✅ Checkout Sessions + Billing APIs (subscriptions)
- ✅ **NEVER pass `payment_method_types`** (laisse Stripe dynamic payment methods)
- ✅ Customer Portal hosted pour self-service
- ✅ Webhook signature verification (mandatory)
- ✅ Idempotence via `es_stripe_event` table
- ✅ Stripe API version pinned to `2026-04-22.dahlia`

**Spec source:**

- `docs/superpowers/specs/2026-05-25-org-roles-stripe-billing-design.md` §6 (Stripe complet), §7.6 (UX past_due / cancel)

**Décisions structurantes:**

- **Lazy Customer creation** : premier accès `/settings/billing` ou premier clic "Upgrade" → `ensureStripeCustomer(orgId)` crée le Customer Stripe avec `metadata.orgId`.
- **6 events whitelistés** : `checkout.session.completed`, `customer.subscription.updated`, `customer.subscription.deleted`, `invoice.payment_failed`, `invoice.paid`, `customer.updated`.
- **Pas de Stripe Tax pour MVP** (`automatic_tax: { enabled: env.STRIPE_TAX_ENABLED ?? false }`). À activer côté env quand l'entreprise légale est OK.
- **Customer Portal config** : autoriser cancel + update payment + invoices. Plan changes BLOQUÉS via Portal v1 (sécurité — on garde le control via `/settings/billing`).

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

- `lib/billing/plans.ts` : `BILLABLE_PLANS` avec `stripePriceIds.{month,year}` (Stripe products bootstrap déjà fait — Phase 0).
- `lib/billing/quotas.ts::checkQuota` lit `es_org_subscription.plan`.
- `es_org_subscription` table (Phase 5) avec `stripeCustomerId`, `stripeSubscriptionId`, `stripePriceId`, `status`, `currentPeriodStart`, `currentPeriodEnd`, `cancelAtPeriodEnd`, `canceledAt`.
- `QuotaExhaustedDialog` (Phase 5) avec CTA "Coming soon" → on retire le `disabled` et on wire vers `createCheckoutSession`.

**Hors-scope (différé Phase 7+) :**

- Page `/pricing` publique (Phase 7).
- Admin `/admin/orgs` panel (Phase 7).
- Email warnings 90/100% quota (post-Phase 6 polish).
- Add-ons (blocs tests supplémentaires, etc.).

---

## File Structure

| Path                                                       | Status    | Responsibility                                                                                  |
| ---------------------------------------------------------- | --------- | ----------------------------------------------------------------------------------------------- |
| `lib/db/schema.ts`                                         | modify    | Ajout table `es_stripe_event` (idempotence webhooks).                                           |
| `drizzle/000X_*.sql`                                       | generated | Migration auto.                                                                                 |
| `lib/env.ts`                                               | modify    | Validation `STRIPE_WEBHOOK_SECRET` + `STRIPE_TAX_ENABLED` (optional).                           |
| `lib/billing/stripe.ts`                                    | create    | Singleton Stripe client typé avec API version `2026-04-22.dahlia`.                              |
| `lib/billing/customer.ts`                                  | create    | `ensureStripeCustomer(orgId)` lazy create + cache.                                              |
| `lib/actions/billing.ts`                                   | create    | `createCheckoutSession`, `openCustomerPortal`, `cancelAtPeriodEnd`.                             |
| `lib/billing/webhook.ts`                                   | create    | `handleStripeEvent(event)` dispatch 6 events → update DB.                                       |
| `app/api/stripe/webhook/route.ts`                          | create    | POST handler: signature verify + idempotence + dispatch.                                        |
| `app/[locale]/settings/billing/page.tsx`                   | create    | RSC : current plan banner + PlanGrid + portal button + past_due/cancel banners.                 |
| `app/[locale]/settings/billing/loading.tsx`                | create    | Skeleton.                                                                                       |
| `app/[locale]/settings/billing/error.tsx`                  | create    | i18n error boundary.                                                                            |
| `app/[locale]/settings/billing/success/page.tsx`           | create    | Post-checkout landing : poll `es_org_subscription.plan` 10s, message "Activation in progress…". |
| `app/[locale]/settings/billing/success/success-poller.tsx` | create    | Client component qui poll via Server Action `getCurrentPlan`.                                   |
| `components/billing/plan-card.tsx`                         | create    | 1 tier (name + features + price + CTA "Upgrade" / "Current plan" / "Downgrade").                |
| `components/billing/plan-grid.tsx`                         | create    | Grid 4 cards ('use client', toggle monthly/yearly).                                             |
| `components/billing/current-plan-banner.tsx`               | create    | RSC banner "You're on Pro Monthly — renews 2026-06-25".                                         |
| `components/billing/portal-button.tsx`                     | create    | Client : "Manage subscription" → `openCustomerPortal` Server Action.                            |
| `components/billing/past-due-banner.tsx`                   | create    | Dashboard layout : bandeau rouge si status=past_due.                                            |
| `components/billing/cancel-scheduled-banner.tsx`           | create    | Dashboard layout : bandeau orange si cancelAtPeriodEnd=true.                                    |
| `app/[locale]/dashboard/layout.tsx`                        | modify    | Render PastDueBanner + CancelScheduledBanner conditionnels.                                     |
| `app/[locale]/settings/settings-tabs.tsx`                  | modify    | Retirer le `disabled: true` du tab Billing.                                                     |
| `components/billing/quota-exhausted-dialog.tsx`            | modify    | Remplacer "Coming soon" disabled → bouton actif qui call `createCheckoutSession`.               |
| `messages/{en,fr,es}.json`                                 | modify    | Namespace `Settings.billing.*`.                                                                 |
| `scripts/verify-billing.ts`                                | create    | Smoke test : ensureStripeCustomer + webhook payload dispatch unit.                              |
| `package.json`                                             | modify    | Script `verify:billing`.                                                                        |

---

## Task 1: `es_stripe_event` table for webhook idempotence

**Files:**

- Modify: `lib/db/schema.ts`
- Generated: `drizzle/0007_*.sql`

- [ ] **Step 1.1: Add the table to `lib/db/schema.ts`**

After `orgSubscription`, before `/* Sites */`, append:

```ts
/* Webhook idempotence: every Stripe event id we've processed.
   Inserted at the top of the webhook handler with ON CONFLICT DO NOTHING.
   If conflict → we've already seen this event → return 200 early. */
export const stripeEvent = pgTable("es_stripe_event", {
  id: text("id").primaryKey(), // Stripe event.id
  type: text("type").notNull(),
  receivedAt: timestamp("received_at", { withTimezone: true, mode: "date" }).notNull().defaultNow(),
});
```

- [ ] **Step 1.2: Generate + apply migration**

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

- [ ] **Step 1.3: Commit**

```bash
git add lib/db/schema.ts drizzle/0007_*.sql drizzle/meta/
git commit -m "feat(db): add es_stripe_event table for webhook idempotence

PK = Stripe event.id. The webhook handler does INSERT … ON CONFLICT DO
NOTHING at the top; if the row already exists we've already processed
this event and return 200 immediately. Stripe retries failed deliveries
for 3 days, so this guard catches all replays."
```

---

## Task 2: env vars + Stripe client singleton

**Files:**

- Modify: `lib/env.ts`
- Create: `lib/billing/stripe.ts`

- [ ] **Step 2.1: Add env validation**

Read `lib/env.ts`. Add to the Zod schema:

```ts
STRIPE_WEBHOOK_SECRET: z.string().startsWith("whsec_").optional(),
STRIPE_TAX_ENABLED: z.coerce.boolean().optional().default(false),
```

`STRIPE_WEBHOOK_SECRET` is optional in dev (Stripe CLI generates one when you run `stripe listen`; user pastes it into `.env.local`). The webhook handler throws clearly if missing at call time.

- [ ] **Step 2.2: Create `lib/billing/stripe.ts`**

```ts
import Stripe from "stripe";

import { env } from "@/lib/env";

let cachedStripe: Stripe | null = null;

/* Returns a singleton Stripe client pinned to API 2026-04-22.dahlia.
   Lazy so callers that never need Stripe don't crash if the key is
   missing in dev (matches the Resend helper pattern from Phase 4). */
export function getStripe(): Stripe {
  if (cachedStripe) return cachedStripe;
  if (!env.STRIPE_SECRET_KEY) {
    throw new Error("STRIPE_SECRET_KEY is not configured");
  }
  cachedStripe = new Stripe(env.STRIPE_SECRET_KEY, {
    apiVersion: "2026-04-22.dahlia",
    appInfo: { name: "eleva-speed", version: "0.1.0" },
  });
  return cachedStripe;
}
```

- [ ] **Step 2.3: Typecheck + commit**

```bash
pnpm typecheck 2>&1 | tail -3
git add lib/env.ts lib/billing/stripe.ts
git commit -m "feat(billing): lazy Stripe client singleton + STRIPE_WEBHOOK_SECRET env

getStripe() returns a memoized Stripe instance pinned to API
2026-04-22.dahlia. Lazy creation throws if STRIPE_SECRET_KEY missing,
not at module-load (same pattern as Resend in Phase 4). Adds
STRIPE_WEBHOOK_SECRET (optional in dev, Stripe CLI provides whsec_…)
and STRIPE_TAX_ENABLED (default false until business entity decision)."
```

---

## Task 3: `ensureStripeCustomer` lazy helper

**Files:**

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

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

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

import { db } from "@/lib/db";
import { organization, orgSubscription, user, member } from "@/lib/db/schema";
import { getStripe } from "@/lib/billing/stripe";

/* Resolves the org's stripeCustomerId. If absent, creates a Stripe Customer
   with metadata.orgId for later identification in webhooks, then persists
   the id in es_org_subscription. Idempotent: re-runs return the cached id. */
export async function ensureStripeCustomer(organizationId: string): Promise<string> {
  const subs = await db
    .select({
      stripeCustomerId: orgSubscription.stripeCustomerId,
      orgName: organization.name,
    })
    .from(orgSubscription)
    .innerJoin(organization, eq(organization.id, orgSubscription.organizationId))
    .where(eq(orgSubscription.organizationId, organizationId))
    .limit(1);

  if (subs.length === 0) {
    throw new Error(`No subscription row for org ${organizationId}`);
  }
  if (subs[0]!.stripeCustomerId) return subs[0]!.stripeCustomerId;

  // Lookup the owner to use as Customer.email + name.
  const owners = await db
    .select({ email: user.email, name: user.name })
    .from(member)
    .innerJoin(user, eq(user.id, member.userId))
    .where(eq(member.organizationId, organizationId))
    .limit(5);
  const ownerRow = owners[0]; // first member; the owner-of-the-org isn't strictly enforced at this layer

  const customer = await getStripe().customers.create({
    email: ownerRow?.email,
    name: subs[0]!.orgName,
    metadata: { orgId: organizationId },
  });

  await db
    .update(orgSubscription)
    .set({ stripeCustomerId: customer.id, updatedAt: new Date() })
    .where(eq(orgSubscription.organizationId, organizationId));

  return customer.id;
}
```

- [ ] **Step 3.2: Typecheck + commit**

```bash
pnpm typecheck 2>&1 | tail -3
git add lib/billing/customer.ts
git commit -m "feat(billing): ensureStripeCustomer lazy creator with metadata.orgId

Resolves the org's stripeCustomerId from es_org_subscription. If null,
creates a Stripe Customer with email = first member's email, name =
org name, metadata.orgId for webhook lookup. Persists the id. Subsequent
calls return the cached id (no Stripe API call)."
```

---

## Task 4: Billing Server Actions

**Files:**

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

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

```ts
"use server";

import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { z } from "zod";

import { requireOrgMember } from "@/lib/auth-guards";
import { ensureStripeCustomer } from "@/lib/billing/customer";
import { BILLABLE_PLANS, type BillablePlan, type BillingInterval } from "@/lib/billing/plans";
import { getStripe } from "@/lib/billing/stripe";
import { db } from "@/lib/db";
import { orgSubscription } from "@/lib/db/schema";
import { env } from "@/lib/env";

const CheckoutSchema = z.object({
  plan: z.enum(["starter", "pro", "business"]),
  interval: z.enum(["month", "year"]),
});

function getOrigin(): string {
  return env.BETTER_AUTH_URL || "http://localhost:3000";
}

/* Creates a Stripe Checkout Session for the requested plan + interval.
   Owner-only. Returns { url } for client redirect. */
export async function createCheckoutSession(input: {
  plan: BillablePlan;
  interval: BillingInterval;
}): Promise<{ url: string }> {
  const { activeOrganizationId } = await requireOrgMember({ minRole: "owner" });
  const { plan, interval } = CheckoutSchema.parse(input);

  const cfg = BILLABLE_PLANS[plan];
  const priceId = cfg.stripePriceIds?.[interval];
  if (!priceId) {
    throw new Error(`No Stripe price configured for ${plan}/${interval}`);
  }

  const customerId = await ensureStripeCustomer(activeOrganizationId);
  const origin = getOrigin();

  const session = await getStripe().checkout.sessions.create({
    mode: "subscription",
    customer: customerId,
    // CRITICAL: do NOT pass payment_method_types — let Stripe pick dynamically.
    line_items: [{ price: priceId, quantity: 1 }],
    client_reference_id: activeOrganizationId,
    metadata: { orgId: activeOrganizationId, plan, interval },
    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 },
  });

  if (!session.url) throw new Error("Stripe did not return a checkout URL");
  return { url: session.url };
}

/* Opens a Customer Portal session for self-service (cancel, update card,
   invoices). Owner-only. */
export async function openCustomerPortal(): Promise<{ url: string }> {
  const { activeOrganizationId } = await requireOrgMember({ minRole: "owner" });
  const customerId = await ensureStripeCustomer(activeOrganizationId);
  const origin = getOrigin();

  const portal = await getStripe().billingPortal.sessions.create({
    customer: customerId,
    return_url: `${origin}/settings/billing`,
  });
  return { url: portal.url };
}

/* Schedules the subscription to cancel at the end of the current period.
   Reactivation = call again with `cancel_at_period_end: false` (or via Portal). */
export async function cancelAtPeriodEnd(input: { cancel: boolean }): Promise<void> {
  const { activeOrganizationId } = await requireOrgMember({ minRole: "owner" });
  const cancel = z.boolean().parse(input.cancel);

  const sub = await db
    .select({ stripeSubscriptionId: orgSubscription.stripeSubscriptionId })
    .from(orgSubscription)
    .where(eq(orgSubscription.organizationId, activeOrganizationId))
    .limit(1);
  const subId = sub[0]?.stripeSubscriptionId;
  if (!subId) throw new Error("No active subscription to cancel");

  await getStripe().subscriptions.update(subId, {
    cancel_at_period_end: cancel,
  });
  revalidatePath("/settings/billing");
  revalidatePath("/", "layout");
}
```

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

```bash
pnpm typecheck 2>&1 | tail -3
git add lib/actions/billing.ts
git commit -m "feat(billing): Server Actions createCheckoutSession + openCustomerPortal + cancelAtPeriodEnd

All owner-only via requireOrgMember. createCheckoutSession deliberately
omits payment_method_types per Stripe best practices (dynamic payment
methods). Includes client_reference_id + metadata.orgId for webhook
lookup. success_url returns to /settings/billing/success?session_id=…
which polls until the webhook updates the plan. cancelAtPeriodEnd
toggles the subscription's cancel_at_period_end flag; users can also
reactivate by passing { cancel: false }."
```

---

## Task 5: Webhook handler

**Files:**

- Create: `lib/billing/webhook.ts`
- Create: `app/api/stripe/webhook/route.ts`

- [ ] **Step 5.1: Create the dispatch logic in `lib/billing/webhook.ts`**

```ts
import Stripe from "stripe";
import { eq } from "drizzle-orm";

import { db } from "@/lib/db";
import { orgSubscription, type Plan, type BillingInterval, type SubStatus } from "@/lib/db/schema";
import { BILLABLE_PLANS } from "@/lib/billing/plans";

/* Reverse lookup: Stripe price ID → our (plan, interval). Built once at
   module load from BILLABLE_PLANS.stripePriceIds. */
const PRICE_ID_TO_PLAN: Map<string, { plan: Plan; interval: BillingInterval }> = (() => {
  const m = new Map<string, { plan: Plan; interval: BillingInterval }>();
  for (const tier of Object.keys(BILLABLE_PLANS) as Array<keyof typeof BILLABLE_PLANS>) {
    const ids = BILLABLE_PLANS[tier].stripePriceIds;
    if (!ids) continue;
    if (ids.month) m.set(ids.month, { plan: tier, interval: "month" });
    if (ids.year) m.set(ids.year, { plan: tier, interval: "year" });
  }
  return m;
})();

export async function handleStripeEvent(event: Stripe.Event): Promise<void> {
  switch (event.type) {
    case "checkout.session.completed":
      await handleCheckoutCompleted(event.data.object as Stripe.Checkout.Session);
      break;
    case "customer.subscription.updated":
    case "customer.subscription.deleted":
      await handleSubChange(event.data.object as Stripe.Subscription);
      break;
    case "invoice.payment_failed":
    case "invoice.paid":
      await handleInvoice(event.data.object as Stripe.Invoice);
      break;
    case "customer.updated":
      // No-op for now (email/name sync not critical v1).
      break;
    default:
      // Ignore unhandled events explicitly.
      break;
  }
}

async function handleCheckoutCompleted(session: Stripe.Checkout.Session): Promise<void> {
  const orgId = (session.client_reference_id || (session.metadata?.orgId as string | undefined)) as
    | string
    | undefined;
  if (!orgId) throw new Error("checkout.session.completed missing orgId");
  if (!session.subscription) throw new Error("checkout.session.completed missing subscription id");

  const subscriptionId =
    typeof session.subscription === "string" ? session.subscription : session.subscription.id;

  // Need to fetch the subscription to get periods + priceId.
  // Stripe.Event.data.object for checkout.session.completed doesn't include
  // the full subscription; we look it up.
  const { getStripe } = await import("@/lib/billing/stripe");
  const sub = await getStripe().subscriptions.retrieve(subscriptionId, {
    expand: ["items.data.price"],
  });

  await applySubscriptionToOrg(orgId, sub);
}

async function handleSubChange(sub: Stripe.Subscription): Promise<void> {
  const customerId = typeof sub.customer === "string" ? sub.customer : sub.customer.id;
  const orgId = await orgIdFromCustomerId(customerId);
  if (!orgId) return; // stale customer, ignore
  await applySubscriptionToOrg(orgId, sub);
}

async function handleInvoice(invoice: Stripe.Invoice): Promise<void> {
  if (!invoice.customer) return;
  const customerId = typeof invoice.customer === "string" ? invoice.customer : invoice.customer.id;
  const orgId = await orgIdFromCustomerId(customerId);
  if (!orgId) return;

  if (invoice.status === "paid") {
    // Refresh status to active in case we were past_due before.
    await db
      .update(orgSubscription)
      .set({ status: "active", updatedAt: new Date() })
      .where(eq(orgSubscription.organizationId, orgId));
  } else if (invoice.status === "open" || invoice.attempt_count > 0) {
    // payment_failed event → mark past_due.
    await db
      .update(orgSubscription)
      .set({ status: "past_due", updatedAt: new Date() })
      .where(eq(orgSubscription.organizationId, orgId));
  }
}

async function applySubscriptionToOrg(orgId: string, sub: Stripe.Subscription): Promise<void> {
  // Extract price id from the first item.
  const item = sub.items.data[0];
  const priceId = item?.price?.id;
  const mapped = priceId ? PRICE_ID_TO_PLAN.get(priceId) : undefined;
  const plan: Plan = mapped?.plan ?? "free";
  const interval: BillingInterval | null = mapped?.interval ?? null;

  const isCanceled = sub.status === "canceled";
  const status: SubStatus = (sub.status as SubStatus) ?? "active";

  // Stripe subscription period dates are at the item level in 2026-04-22 (canonical).
  const periodStart = item?.current_period_start
    ? new Date(item.current_period_start * 1000)
    : null;
  const periodEnd = item?.current_period_end ? new Date(item.current_period_end * 1000) : null;
  const canceledAt = sub.canceled_at ? new Date(sub.canceled_at * 1000) : null;

  await db
    .update(orgSubscription)
    .set({
      plan: isCanceled ? "free" : plan,
      interval: isCanceled ? null : interval,
      stripeSubscriptionId: sub.id,
      stripePriceId: priceId ?? null,
      status,
      currentPeriodStart: periodStart,
      currentPeriodEnd: periodEnd,
      cancelAtPeriodEnd: sub.cancel_at_period_end,
      canceledAt,
      updatedAt: new Date(),
    })
    .where(eq(orgSubscription.organizationId, orgId));
}

async function orgIdFromCustomerId(customerId: string): Promise<string | null> {
  const rows = await db
    .select({ orgId: orgSubscription.organizationId })
    .from(orgSubscription)
    .where(eq(orgSubscription.stripeCustomerId, customerId))
    .limit(1);
  return rows[0]?.orgId ?? null;
}
```

If `item.current_period_start` doesn't exist on the new API version's `Subscription.Item` type, fall back to `sub.current_period_start` (older shape) or expand the API call differently. Check `node_modules/stripe/types/SubscriptionItems.d.ts` to confirm.

- [ ] **Step 5.2: Create `app/api/stripe/webhook/route.ts`**

```ts
import { eq } from "drizzle-orm";
import { NextResponse } from "next/server";

import { getStripe } from "@/lib/billing/stripe";
import { handleStripeEvent } from "@/lib/billing/webhook";
import { db } from "@/lib/db";
import { stripeEvent } from "@/lib/db/schema";
import { env } from "@/lib/env";

export const runtime = "nodejs";

export async function POST(req: Request) {
  if (!env.STRIPE_WEBHOOK_SECRET) {
    console.error("[stripe-webhook] STRIPE_WEBHOOK_SECRET not configured");
    return NextResponse.json({ error: "Webhook secret not configured" }, { status: 503 });
  }

  const sig = req.headers.get("stripe-signature");
  if (!sig) return NextResponse.json({ error: "Missing stripe-signature" }, { status: 401 });

  const rawBody = await req.text();
  let event;
  try {
    event = getStripe().webhooks.constructEvent(rawBody, sig, env.STRIPE_WEBHOOK_SECRET);
  } catch (err) {
    console.error("[stripe-webhook] signature verification failed:", err);
    return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
  }

  // Idempotence: insert event.id, skip processing if conflict.
  const inserted = await db
    .insert(stripeEvent)
    .values({ id: event.id, type: event.type })
    .onConflictDoNothing({ target: stripeEvent.id })
    .returning({ id: stripeEvent.id });
  if (inserted.length === 0) {
    return NextResponse.json({ status: "duplicate" }, { status: 200 });
  }

  try {
    await handleStripeEvent(event);
    return NextResponse.json({ status: "ok" }, { status: 200 });
  } catch (err) {
    console.error("[stripe-webhook] handler error for", event.type, err);
    // Roll back the event-id insert so Stripe retries can re-process.
    await db.delete(stripeEvent).where(eq(stripeEvent.id, event.id));
    return NextResponse.json({ error: "Handler failed" }, { status: 500 });
  }
}
```

- [ ] **Step 5.3: Typecheck + commit**

```bash
pnpm typecheck 2>&1 | tail -3
git add lib/billing/webhook.ts app/api/stripe/webhook/route.ts
git commit -m "feat(billing): /api/stripe/webhook handler with signature + idempotence + 6 events

POST runs under Node.js runtime (raw body required for signature verify).
Flow: signature verify → insert event.id (ON CONFLICT DO NOTHING) →
dispatch to handleStripeEvent → 200. On dispatch failure, delete the
event row so Stripe retries (3-day window) can re-process.

Handled events:
- checkout.session.completed → fetch subscription, apply to org
- customer.subscription.updated/deleted → re-sync plan, status, periods
- invoice.payment_failed → status=past_due
- invoice.paid → status=active (recovery from past_due)
- customer.updated → noop v1

Price ID → plan mapping built at module load from BILLABLE_PLANS."
```

---

## Task 6: i18n keys for Settings.billing

**Files:**

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

- [ ] **Step 6.1: Add `Settings.billing` namespace**

For `messages/en.json` (inside the existing `Settings` namespace, add a `billing` sub-namespace alongside `profile`, `organization`, `team`):

```json
"billing": {
  "title": "Billing",
  "description": "Manage your subscription and view invoices.",
  "currentPlan": "Current plan",
  "renewsOn": "Renews {date}",
  "endsOn": "Ends {date}",
  "freeBadge": "Free tier",
  "monthly": "Monthly",
  "yearly": "Yearly (save 17%)",
  "intervalToggle": "Billing interval",
  "ctaUpgrade": "Upgrade to {tier}",
  "ctaCurrent": "Current plan",
  "ctaDowngrade": "Switch to {tier}",
  "ctaContactSales": "Contact sales",
  "managePortal": "Manage subscription",
  "managePortalDescription": "Update payment method, view invoices, or cancel subscription via the Stripe portal.",
  "cancelInline": "Cancel at period end",
  "reactivate": "Reactivate subscription",
  "checkoutError": "Could not start checkout. Please try again.",
  "portalError": "Could not open subscription portal. Please try again.",
  "successTitle": "Welcome to {plan}",
  "successDescription": "Your subscription is being activated. This usually takes a few seconds.",
  "successPolling": "Activating…",
  "successTimeout": "Activation is taking longer than expected. Refresh the page in a moment — your subscription will appear here.",
  "pastDueTitle": "Payment failed",
  "pastDueDescription": "Your last invoice could not be paid. Update your payment method to keep your subscription active.",
  "cancelScheduledTitle": "Subscription ending soon",
  "cancelScheduledDescription": "Your {plan} plan ends on {date}. You'll be moved to Free.",
  "tierFeatures": {
    "tests": "{n} tests / month",
    "sites": "{n} sites monitored",
    "seats": "{n} team members",
    "retentionDays": "{n} day retention"
  }
}
```

For `messages/fr.json` (matching keys, French):

```json
"billing": {
  "title": "Facturation",
  "description": "Gérez votre abonnement et consultez vos factures.",
  "currentPlan": "Plan actuel",
  "renewsOn": "Renouvellement le {date}",
  "endsOn": "Se termine le {date}",
  "freeBadge": "Tier gratuit",
  "monthly": "Mensuel",
  "yearly": "Annuel (-17%)",
  "intervalToggle": "Intervalle de facturation",
  "ctaUpgrade": "Passer à {tier}",
  "ctaCurrent": "Plan actuel",
  "ctaDowngrade": "Basculer vers {tier}",
  "ctaContactSales": "Contacter les ventes",
  "managePortal": "Gérer l'abonnement",
  "managePortalDescription": "Mettre à jour la méthode de paiement, consulter les factures ou annuler via le portail Stripe.",
  "cancelInline": "Annuler à la fin de la période",
  "reactivate": "Réactiver l'abonnement",
  "checkoutError": "Impossible de démarrer le paiement. Veuillez réessayer.",
  "portalError": "Impossible d'ouvrir le portail d'abonnement. Veuillez réessayer.",
  "successTitle": "Bienvenue dans {plan}",
  "successDescription": "Votre abonnement est en cours d'activation. Cela prend généralement quelques secondes.",
  "successPolling": "Activation…",
  "successTimeout": "L'activation prend plus de temps que prévu. Rafraîchissez la page dans un instant — votre abonnement apparaîtra ici.",
  "pastDueTitle": "Paiement échoué",
  "pastDueDescription": "Votre dernière facture n'a pas pu être payée. Mettez à jour votre méthode de paiement pour maintenir votre abonnement actif.",
  "cancelScheduledTitle": "Abonnement bientôt terminé",
  "cancelScheduledDescription": "Votre plan {plan} se termine le {date}. Vous serez basculé sur Free.",
  "tierFeatures": {
    "tests": "{n} tests / mois",
    "sites": "{n} sites monitorés",
    "seats": "{n} membres d'équipe",
    "retentionDays": "Rétention {n} jours"
  }
}
```

For `messages/es.json` (matching keys, Spanish):

```json
"billing": {
  "title": "Facturación",
  "description": "Administre su suscripción y consulte las facturas.",
  "currentPlan": "Plan actual",
  "renewsOn": "Se renueva el {date}",
  "endsOn": "Termina el {date}",
  "freeBadge": "Tier gratuito",
  "monthly": "Mensual",
  "yearly": "Anual (-17%)",
  "intervalToggle": "Intervalo de facturación",
  "ctaUpgrade": "Actualizar a {tier}",
  "ctaCurrent": "Plan actual",
  "ctaDowngrade": "Cambiar a {tier}",
  "ctaContactSales": "Contactar ventas",
  "managePortal": "Administrar suscripción",
  "managePortalDescription": "Actualice el método de pago, consulte facturas o cancele a través del portal de Stripe.",
  "cancelInline": "Cancelar al final del periodo",
  "reactivate": "Reactivar suscripción",
  "checkoutError": "No se pudo iniciar el pago. Por favor, intente de nuevo.",
  "portalError": "No se pudo abrir el portal de suscripción. Por favor, intente de nuevo.",
  "successTitle": "Bienvenido a {plan}",
  "successDescription": "Su suscripción se está activando. Normalmente toma unos segundos.",
  "successPolling": "Activando…",
  "successTimeout": "La activación está tardando más de lo esperado. Recargue la página en un momento — su suscripción aparecerá aquí.",
  "pastDueTitle": "Pago fallido",
  "pastDueDescription": "Su última factura no se pudo pagar. Actualice su método de pago para mantener su suscripción activa.",
  "cancelScheduledTitle": "Suscripción terminando pronto",
  "cancelScheduledDescription": "Su plan {plan} termina el {date}. Será movido a Free.",
  "tierFeatures": {
    "tests": "{n} pruebas / mes",
    "sites": "{n} sitios monitorizados",
    "seats": "{n} miembros del equipo",
    "retentionDays": "Retención {n} días"
  }
}
```

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

```bash
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 "feat(i18n): add Settings.billing namespace (EN/FR/ES)"
```

---

## Task 7: UI components for billing

**Files:**

- Create: `components/billing/plan-card.tsx`
- Create: `components/billing/plan-grid.tsx`
- Create: `components/billing/current-plan-banner.tsx`
- Create: `components/billing/portal-button.tsx`
- Create: `components/billing/past-due-banner.tsx`
- Create: `components/billing/cancel-scheduled-banner.tsx`

### 7.1 — `plan-card.tsx`

```tsx
"use client";

import { useTransition } from "react";

import { useTranslations } from "next-intl";
import { toast } from "sonner";

import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { createCheckoutSession } from "@/lib/actions/billing";
import type { BillablePlan, BillingInterval } from "@/lib/billing/plans";

export type PlanCardProps = {
  tier: "free" | BillablePlan;
  name: string;
  priceMonthly: number;
  priceYearly: number;
  interval: BillingInterval;
  features: { label: string }[];
  currentPlan: "free" | BillablePlan;
  isHighlighted?: boolean;
};

export function PlanCard({
  tier,
  name,
  priceMonthly,
  priceYearly,
  interval,
  features,
  currentPlan,
  isHighlighted,
}: PlanCardProps) {
  const t = useTranslations("Settings.billing");
  const [isPending, startTransition] = useTransition();
  const isCurrent = tier === currentPlan;
  const price = interval === "year" ? priceYearly : priceMonthly;

  function onClick() {
    if (tier === "free" || isCurrent) return;
    startTransition(async () => {
      try {
        const { url } = await createCheckoutSession({ plan: tier as BillablePlan, interval });
        window.location.href = url;
      } catch {
        toast.error(t("checkoutError"));
      }
    });
  }

  const ctaLabel = isCurrent
    ? t("ctaCurrent")
    : tier === "free"
      ? t("ctaCurrent")
      : t("ctaUpgrade", { tier: name });

  return (
    <div
      className={cn(
        "flex flex-col gap-4 rounded-lg border p-6",
        isHighlighted && "border-primary ring-1 ring-primary/30",
        isCurrent && "bg-muted/30",
      )}
    >
      <div className="flex flex-col gap-1">
        <h3 className="text-lg font-semibold">{name}</h3>
        <div className="flex items-baseline gap-1">
          {tier === "free" ? (
            <span className="text-3xl font-bold">€0</span>
          ) : (
            <>
              <span className="text-3xl font-bold">€{price}</span>
              <span className="text-sm text-muted-foreground">
                /{interval === "year" ? "yr" : "mo"}
              </span>
            </>
          )}
        </div>
      </div>
      <ul className="flex flex-col gap-1 text-sm text-muted-foreground">
        {features.map((f, i) => (
          <li key={i}>• {f.label}</li>
        ))}
      </ul>
      <Button
        type="button"
        onClick={onClick}
        disabled={isCurrent || tier === "free" || isPending}
        variant={isHighlighted && !isCurrent ? "default" : "outline"}
        className="mt-auto"
      >
        {ctaLabel}
      </Button>
    </div>
  );
}
```

### 7.2 — `plan-grid.tsx`

```tsx
"use client";

import { useState } from "react";

import { useTranslations } from "next-intl";

import { BILLABLE_PLANS, FREE_LIMITS } from "@/lib/billing/plans";
import type { BillablePlan, BillingInterval } from "@/lib/billing/plans";
import { PlanCard } from "./plan-card";

export type PlanGridProps = {
  currentPlan: "free" | BillablePlan;
};

export function PlanGrid({ currentPlan }: PlanGridProps) {
  const t = useTranslations("Settings.billing");
  const tFeat = useTranslations("Settings.billing.tierFeatures");
  const [interval, setInterval] = useState<BillingInterval>("month");

  return (
    <div className="flex flex-col gap-4">
      <div className="flex items-center gap-2">
        <button
          type="button"
          onClick={() => setInterval("month")}
          className={`rounded-md px-3 py-1 text-sm ${interval === "month" ? "bg-primary text-primary-foreground" : "bg-muted"}`}
        >
          {t("monthly")}
        </button>
        <button
          type="button"
          onClick={() => setInterval("year")}
          className={`rounded-md px-3 py-1 text-sm ${interval === "year" ? "bg-primary text-primary-foreground" : "bg-muted"}`}
        >
          {t("yearly")}
        </button>
      </div>
      <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
        <PlanCard
          tier="free"
          name="Free"
          priceMonthly={0}
          priceYearly={0}
          interval={interval}
          features={[
            { label: tFeat("tests", { n: FREE_LIMITS.testsPerMonth }) },
            { label: tFeat("sites", { n: FREE_LIMITS.sites }) },
            { label: tFeat("seats", { n: FREE_LIMITS.seats }) },
            { label: tFeat("retentionDays", { n: FREE_LIMITS.retentionDays }) },
          ]}
          currentPlan={currentPlan}
        />
        {(["starter", "pro", "business"] as BillablePlan[]).map((tier) => {
          const cfg = BILLABLE_PLANS[tier];
          return (
            <PlanCard
              key={tier}
              tier={tier}
              name={cfg.name}
              priceMonthly={cfg.priceEur.month}
              priceYearly={cfg.priceEur.year}
              interval={interval}
              features={[
                { label: tFeat("tests", { n: cfg.limits.testsPerMonth }) },
                { label: tFeat("sites", { n: cfg.limits.sites }) },
                { label: tFeat("seats", { n: cfg.limits.seats }) },
                { label: tFeat("retentionDays", { n: cfg.limits.retentionDays }) },
              ]}
              currentPlan={currentPlan}
              isHighlighted={tier === "pro"}
            />
          );
        })}
      </div>
    </div>
  );
}
```

### 7.3 — `current-plan-banner.tsx`

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

export async function CurrentPlanBanner({
  plan,
  interval,
  currentPeriodEnd,
  cancelAtPeriodEnd,
  status,
}: {
  plan: string;
  interval: "month" | "year" | null;
  currentPeriodEnd: Date | null;
  cancelAtPeriodEnd: boolean;
  status: string;
}) {
  const t = await getTranslations("Settings.billing");
  const locale = await getLocale();
  const dateLocale = locale === "fr" ? "fr-FR" : locale === "es" ? "es-ES" : "en-US";
  const fmt = new Intl.DateTimeFormat(dateLocale, { dateStyle: "medium" });

  return (
    <div className="flex flex-col gap-1 rounded-lg border bg-muted/30 p-4">
      <div className="text-xs uppercase tracking-wider text-muted-foreground">
        {t("currentPlan")}
      </div>
      <div className="flex flex-wrap items-baseline gap-2">
        <span className="text-xl font-semibold capitalize">{plan}</span>
        {interval && (
          <span className="text-sm text-muted-foreground">
            {interval === "year" ? t("yearly") : t("monthly")}
          </span>
        )}
        {currentPeriodEnd && plan !== "free" && (
          <span className="ml-auto text-sm text-muted-foreground">
            {cancelAtPeriodEnd
              ? t("endsOn", { date: fmt.format(currentPeriodEnd) })
              : t("renewsOn", { date: fmt.format(currentPeriodEnd) })}
          </span>
        )}
      </div>
    </div>
  );
}
```

### 7.4 — `portal-button.tsx`

```tsx
"use client";

import { useTransition } from "react";

import { useTranslations } from "next-intl";
import { toast } from "sonner";

import { Button } from "@/components/ui/button";
import { openCustomerPortal } from "@/lib/actions/billing";

export function PortalButton({ disabled }: { disabled?: boolean }) {
  const t = useTranslations("Settings.billing");
  const [isPending, startTransition] = useTransition();

  function onClick() {
    startTransition(async () => {
      try {
        const { url } = await openCustomerPortal();
        window.location.href = url;
      } catch {
        toast.error(t("portalError"));
      }
    });
  }

  return (
    <Button type="button" variant="outline" onClick={onClick} disabled={disabled || isPending}>
      {t("managePortal")}
    </Button>
  );
}
```

### 7.5 — `past-due-banner.tsx`

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

import { PortalButton } from "./portal-button";

export async function PastDueBanner() {
  const t = await getTranslations("Settings.billing");
  return (
    <div className="flex items-center justify-between gap-4 rounded-lg border border-destructive/40 bg-destructive/10 p-4">
      <div className="flex flex-col gap-1">
        <div className="text-sm font-semibold text-destructive">{t("pastDueTitle")}</div>
        <div className="text-xs text-muted-foreground">{t("pastDueDescription")}</div>
      </div>
      <PortalButton />
    </div>
  );
}
```

### 7.6 — `cancel-scheduled-banner.tsx`

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

export async function CancelScheduledBanner({ plan, endsAt }: { plan: string; endsAt: Date }) {
  const t = await getTranslations("Settings.billing");
  const locale = await getLocale();
  const dateLocale = locale === "fr" ? "fr-FR" : locale === "es" ? "es-ES" : "en-US";
  const fmt = new Intl.DateTimeFormat(dateLocale, { dateStyle: "medium" });

  return (
    <div className="flex flex-col gap-1 rounded-lg border border-amber-500/40 bg-amber-500/10 p-4">
      <div className="text-sm font-semibold text-amber-700 dark:text-amber-300">
        {t("cancelScheduledTitle")}
      </div>
      <div className="text-xs text-muted-foreground">
        {t("cancelScheduledDescription", { plan, date: fmt.format(endsAt) })}
      </div>
    </div>
  );
}
```

- [ ] **Step 7.7: Typecheck + commit**

```bash
pnpm typecheck 2>&1 | tail -3
git add components/billing/
git commit -m "feat(billing): UI components (PlanCard, PlanGrid, banners, PortalButton)

PlanCard with feature list + price + CTA per tier (Free / Starter / Pro
[highlighted] / Business). PlanGrid with monthly/yearly toggle. CurrentPlanBanner
shows plan name + interval + renew/end date. PortalButton redirects via
openCustomerPortal Server Action. PastDueBanner (red, includes portal CTA)
+ CancelScheduledBanner (amber, dates-aware)."
```

---

## Task 8: `/settings/billing` page + success poller

**Files:**

- Create: `app/[locale]/settings/billing/page.tsx`
- Create: `app/[locale]/settings/billing/loading.tsx`
- Create: `app/[locale]/settings/billing/error.tsx`
- Create: `app/[locale]/settings/billing/success/page.tsx`
- Create: `app/[locale]/settings/billing/success/success-poller.tsx`
- Modify: `app/[locale]/settings/settings-tabs.tsx` (remove `disabled: true` on billing)

### 8.1 — `page.tsx`

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

import { requireOrgMember } from "@/lib/auth-guards";
import { db } from "@/lib/db";
import { orgSubscription } from "@/lib/db/schema";
import { CancelScheduledBanner } from "@/components/billing/cancel-scheduled-banner";
import { CurrentPlanBanner } from "@/components/billing/current-plan-banner";
import { PastDueBanner } from "@/components/billing/past-due-banner";
import { PlanGrid } from "@/components/billing/plan-grid";
import { PortalButton } from "@/components/billing/portal-button";

export default async function BillingSettingsPage() {
  const { activeOrganizationId } = await requireOrgMember({ minRole: "owner" });
  const t = await getTranslations("Settings.billing");

  const [sub] = await db
    .select()
    .from(orgSubscription)
    .where(eq(orgSubscription.organizationId, activeOrganizationId))
    .limit(1);
  if (!sub) throw new Error("No subscription row for active org");

  const isPaying = sub.plan !== "free";
  const isPastDue = sub.status === "past_due";

  return (
    <section className="flex flex-col gap-6">
      <div className="flex flex-col gap-1">
        <h2 className="text-xl font-semibold">{t("title")}</h2>
        <p className="text-sm text-muted-foreground">{t("description")}</p>
      </div>

      {isPastDue && <PastDueBanner />}
      {sub.cancelAtPeriodEnd && sub.currentPeriodEnd && (
        <CancelScheduledBanner plan={sub.plan} endsAt={sub.currentPeriodEnd} />
      )}

      <CurrentPlanBanner
        plan={sub.plan}
        interval={sub.interval}
        currentPeriodEnd={sub.currentPeriodEnd}
        cancelAtPeriodEnd={sub.cancelAtPeriodEnd}
        status={sub.status}
      />

      <PlanGrid currentPlan={sub.plan as "free" | "starter" | "pro" | "business"} />

      {isPaying && (
        <div className="flex flex-col gap-2 rounded-lg border p-4">
          <h3 className="text-sm font-semibold">{t("managePortal")}</h3>
          <p className="text-xs text-muted-foreground">{t("managePortalDescription")}</p>
          <PortalButton />
        </div>
      )}
    </section>
  );
}
```

### 8.2 — `loading.tsx`

```tsx
import { Skeleton } from "@/components/ui/skeleton";

export default function Loading() {
  return (
    <section className="flex flex-col gap-6">
      <Skeleton className="h-16 w-full" />
      <Skeleton className="h-20 w-full" />
      <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
        <Skeleton className="h-64 w-full" />
        <Skeleton className="h-64 w-full" />
        <Skeleton className="h-64 w-full" />
        <Skeleton className="h-64 w-full" />
      </div>
    </section>
  );
}
```

### 8.3 — `error.tsx`

```tsx
"use client";

import { useTranslations } from "next-intl";

import { Button } from "@/components/ui/button";

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  const t = useTranslations("Common");
  return (
    <main className="mx-auto flex max-w-md flex-col gap-4 px-4 py-12 text-center">
      <h1 className="text-xl font-semibold">{t("errorTitle")}</h1>
      <p className="text-sm text-muted-foreground">{error.message}</p>
      <Button type="button" onClick={reset}>
        {t("errorRetry")}
      </Button>
    </main>
  );
}
```

### 8.4 — `success/page.tsx`

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

import { requireOrgMember } from "@/lib/auth-guards";

import { SuccessPoller } from "./success-poller";

export default async function BillingSuccessPage() {
  const { activeOrganizationId } = await requireOrgMember({ minRole: "owner" });
  const t = await getTranslations("Settings.billing");

  return (
    <section className="mx-auto flex max-w-md flex-col gap-6 px-4 py-12 text-center">
      <h1 className="text-2xl font-semibold">{t("successTitle", { plan: "Pro" })}</h1>
      <p className="text-sm text-muted-foreground">{t("successDescription")}</p>
      <SuccessPoller organizationId={activeOrganizationId} />
    </section>
  );
}
```

### 8.5 — `success/success-poller.tsx`

```tsx
"use client";

import { useEffect, useState } from "react";

import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";

import { getCurrentPlanForOrg } from "@/lib/actions/billing";

export function SuccessPoller({ organizationId }: { organizationId: string }) {
  const t = useTranslations("Settings.billing");
  const router = useRouter();
  const [phase, setPhase] = useState<"polling" | "timeout">("polling");

  useEffect(() => {
    let cancelled = false;
    let attempts = 0;
    async function poll() {
      while (!cancelled && attempts < 10) {
        attempts++;
        const plan = await getCurrentPlanForOrg(organizationId).catch(() => "free");
        if (plan !== "free") {
          router.push("/settings/billing");
          return;
        }
        await new Promise((r) => setTimeout(r, 1000));
      }
      if (!cancelled) setPhase("timeout");
    }
    void poll();
    return () => {
      cancelled = true;
    };
  }, [organizationId, router]);

  return (
    <p className="text-sm text-muted-foreground">
      {phase === "polling" ? t("successPolling") : t("successTimeout")}
    </p>
  );
}
```

Note: `getCurrentPlanForOrg` is a small new Server Action — add it to `lib/actions/billing.ts`:

```ts
export async function getCurrentPlanForOrg(organizationId: string): Promise<string> {
  // Polled by the success page. No role check needed — the polling user
  // can only see their own org's plan via this action (the orgId is
  // captured server-side at the success page level via requireOrgMember).
  // We DO sanity-check: the org must match the caller's active org.
  const { activeOrganizationId } = await requireOrgMember();
  if (activeOrganizationId !== organizationId) {
    throw new Error("Forbidden");
  }
  const { db } = await import("@/lib/db");
  const { orgSubscription } = await import("@/lib/db/schema");
  const { eq } = await import("drizzle-orm");
  const [sub] = await db
    .select({ plan: orgSubscription.plan })
    .from(orgSubscription)
    .where(eq(orgSubscription.organizationId, organizationId))
    .limit(1);
  return sub?.plan ?? "free";
}
```

### 8.6 — Modify `settings-tabs.tsx`

Read `app/[locale]/settings/settings-tabs.tsx`. In the `TABS` array, REMOVE the `disabled: true` from the billing entry:

```ts
{ key: "billing", href: "/settings/billing" },  // (was disabled: true)
```

Also remove the conditional rendering branch that showed "Coming soon" — the standard rendering path now applies.

- [ ] **Step 8.7: Typecheck + commit**

```bash
pnpm typecheck 2>&1 | tail -3
git add 'app/[locale]/settings/' lib/actions/billing.ts
git commit -m "feat(settings/billing): /settings/billing page + /settings/billing/success poller

Page (owner-only): shows current plan banner, PlanGrid for upgrade choices,
past_due / cancel_scheduled conditional banners, and a Manage Portal section
once user is on a paid plan.

Success page poll loop (10s, 1s interval) reads es_org_subscription.plan
via a new getCurrentPlanForOrg Server Action; when it flips off 'free',
redirects to /settings/billing. Falls back to a timeout message if the
webhook is slow.

Settings tabs: removed 'disabled: true' from billing — the tab is now live."
```

---

## Task 9: Past-due / cancel banners in dashboard layout

**Files:**

- Modify: `app/[locale]/dashboard/layout.tsx`

- [ ] **Step 9.1: Render the two conditional banners above main content**

Read `app/[locale]/dashboard/layout.tsx`. After resolving `{ session, activeOrganizationId }`, also fetch the subscription row to know if we need to surface a banner:

```tsx
import { eq } from "drizzle-orm";
import { db } from "@/lib/db";
import { orgSubscription } from "@/lib/db/schema";
import { PastDueBanner } from "@/components/billing/past-due-banner";
import { CancelScheduledBanner } from "@/components/billing/cancel-scheduled-banner";

// In the Server Component body:
const [sub] = await db
  .select({
    plan: orgSubscription.plan,
    status: orgSubscription.status,
    cancelAtPeriodEnd: orgSubscription.cancelAtPeriodEnd,
    currentPeriodEnd: orgSubscription.currentPeriodEnd,
  })
  .from(orgSubscription)
  .where(eq(orgSubscription.organizationId, activeOrganizationId))
  .limit(1);

// Render at the top of the main content, above UsageSection:
{
  sub?.status === "past_due" && <PastDueBanner />;
}
{
  sub?.cancelAtPeriodEnd && sub.currentPeriodEnd && (
    <CancelScheduledBanner plan={sub.plan} endsAt={sub.currentPeriodEnd} />
  );
}
```

Place them inside the layout JSX where children are rendered, above the existing `<UsageSection>` Suspense.

- [ ] **Step 9.2: Typecheck + commit**

```bash
pnpm typecheck 2>&1 | tail -3
git add 'app/[locale]/dashboard/layout.tsx'
git commit -m "feat(dashboard): render PastDueBanner + CancelScheduledBanner

Fetches the org subscription state in the layout and surfaces:
- PastDueBanner (red, links to Customer Portal) when status=past_due
- CancelScheduledBanner (amber, shows end date) when cancelAtPeriodEnd=true

Both banners are above the existing UsageSection."
```

---

## Task 10: Wire QuotaExhaustedDialog to actual Checkout

**Files:**

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

- [ ] **Step 10.1: Remove the disabled CTA, replace with actual Checkout call**

Read the file. Replace the disabled "Upgrade — coming soon" button with one that calls `createCheckoutSession({ plan: suggestedTier, interval: "month" })`:

```tsx
"use client";

import { useTransition } from "react";

import { useTranslations } from "next-intl";
import { toast } from "sonner";

import { Button } from "@/components/ui/button";
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
} from "@/components/ui/dialog";
import { createCheckoutSession } from "@/lib/actions/billing";
import type { BillablePlan } from "@/lib/billing/plans";

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

const SUGGESTED_TIER: Record<string, BillablePlan> = {
  free: "starter",
  starter: "pro",
  pro: "business",
  business: "business",
  enterprise: "business",
};

const TIER_LABEL: Record<BillablePlan, string> = {
  starter: "Starter",
  pro: "Pro",
  business: "Business",
};

export function QuotaExhaustedDialog(props: {
  open: boolean;
  onOpenChange: (open: boolean) => void;
  kind: QuotaKind;
  used: number;
  limit: number;
  plan: string;
}) {
  const t = useTranslations("Quota");
  const tBilling = useTranslations("Settings.billing");
  const [isPending, startTransition] = useTransition();
  const suggested = SUGGESTED_TIER[props.plan] ?? "starter";
  const tier = TIER_LABEL[suggested];

  function onUpgrade() {
    startTransition(async () => {
      try {
        const { url } = await createCheckoutSession({ plan: suggested, interval: "month" });
        window.location.href = url;
      } catch {
        toast.error(tBilling("checkoutError"));
      }
    });
  }

  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)} disabled={isPending}>
            {t("close")}
          </Button>
          <Button onClick={onUpgrade} disabled={isPending}>
            {tBilling("ctaUpgrade", { tier })}
          </Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
}
```

- [ ] **Step 10.2: Typecheck + commit**

```bash
pnpm typecheck 2>&1 | tail -3
git add components/billing/quota-exhausted-dialog.tsx
git commit -m "feat(billing): wire QuotaExhaustedDialog to actual Stripe Checkout

Replaces the 'Coming soon' disabled CTA with a real Upgrade button that
calls createCheckoutSession on the suggested tier (free→starter, starter→pro,
pro→business). Defaults to monthly interval; user can switch in /settings/billing
or via the upgrade modal in a later iteration."
```

---

## Task 11: Stripe dev workflow setup notes

**Files:**

- Modify: `docs/planning/pre-prod-checklist.md`

- [ ] **Step 11.1: Add Stripe webhook setup instructions**

Append (or update existing "Stripe" section) in `docs/planning/pre-prod-checklist.md`:

````markdown
### Stripe webhook (dev)

To exercise the webhook handler in dev:

1. Install Stripe CLI: `brew install stripe/stripe-cli/stripe` (or follow https://docs.stripe.com/stripe-cli).
2. Authenticate once: `stripe login`.
3. Forward webhooks to local:
   ```bash
   stripe listen --forward-to localhost:3000/api/stripe/webhook
   ```
````

The CLI prints `Ready! Your webhook signing secret is whsec_…` — paste this into `.env.local` as `STRIPE_WEBHOOK_SECRET`. 4. Trigger events for testing:

```bash
stripe trigger checkout.session.completed
stripe trigger customer.subscription.updated
stripe trigger invoice.payment_failed
```

5. Use test cards on Checkout:
   - `4242 4242 4242 4242` — success
   - `4000 0000 0000 0341` — 3DS required
   - `4000 0000 0000 9995` — decline

````

- [ ] **Step 11.2: Commit**

```bash
git add docs/planning/pre-prod-checklist.md
git commit -m "docs(checklist): add Stripe webhook dev workflow (stripe listen + test triggers)"
````

---

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

- [ ] **Step 12.1: Run all smokes + typecheck + lint**

```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 + commit**

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

- [ ] **Step 12.3: Final code review subagent**

(Controller dispatches separately — see subagent-driven-development skill.)

---

## Critères d'acceptation Phase 6

- ✅ `es_stripe_event` table for idempotence.
- ✅ `lib/billing/stripe.ts` singleton pinned to API `2026-04-22.dahlia`.
- ✅ `ensureStripeCustomer(orgId)` lazy creator with `metadata.orgId`.
- ✅ 3 Server Actions: `createCheckoutSession`, `openCustomerPortal`, `cancelAtPeriodEnd`.
- ✅ `/api/stripe/webhook` route with signature verify + idempotence + 6 events.
- ✅ `/settings/billing` page (owner-only) with PlanGrid + PortalButton + banners.
- ✅ `/settings/billing/success` page polls plan.
- ✅ Dashboard layout renders PastDueBanner / CancelScheduledBanner conditionally.
- ✅ QuotaExhaustedDialog now triggers real Checkout (no more "Coming soon").
- ✅ i18n EN/FR/ES with parity for `Settings.billing.*`.
- ✅ `pnpm typecheck && pnpm lint` clean.
- ✅ `STRIPE_WEBHOOK_SECRET` documented in pre-prod-checklist + env optional.

## Next phase

Phase 7 — `/admin/orgs` panel + `/pricing` public page. Admin view of every org with plan/status/MRR + Stripe-dashboard deep link.
