# Credit Packs & Public Pricing (3C) — Design Spec

**Date:** 2026-06-12 · **Status:** Approved · **Sub-project:** 3C (after 3A core ✅, 3B quotas ✅)
**Pricing source:** `~/Documents/seo insight/docs/Pricing.md` §3 — packs **25 $ / 25 000 requêtes MCP** (1 $/1 000), soft-block à épuisement du quota, upsell vers le plan supérieur.

## Goal
(1) **Credit packs**: one-off Stripe purchase of 25 000 MCP requests; consumed only AFTER the monthly plan quota is exhausted (soft block becomes hard only at balance 0). (2) **Public pricing page** `/pricing`: anonymous-friendly, monthly/annual toggle, EUR/USD display (nominal parity), plan comparison, CTAs → signup. **Deferred from 3C:** extra-seat purchase (plugin only supports a single `seatPriceId`, monthly — interval mismatch breaks annual Checkout; revisit when the plugin supports interval-matched seat prices or we hand-roll it).

## C1 — Credit packs

- **Stripe price:** extend `scripts/stripe-bootstrap.ts` with a one-time price `pack_mcp_25k` — product "SEO Insight MCP Pack", `unit_amount: 2500` USD (+ `currency_options` EUR 2500), NO `recurring`. Same idempotence + `sk_test_` guard. Run in test mode; prod runbook re-runs live.
- **Data (migration 0033, `when` → `1781340000000`):**
  - `organization.mcpCreditBalance: integer default 0 not null` — the live balance.
  - `mcp_credit_purchases` table: id PK, orgId FK, stripeSessionId **unique** (idempotence), credits int, amountCents int, createdAt — audit log of purchases.
- **Buy flow:** server action `createMcpPackCheckoutAction()` (`src/lib/billing/pack-actions.ts`): session → active org → `isOrgBillingManager` guard → raw `stripe.checkout.sessions.create({ mode: "payment", line_items: [{ price: <pack_mcp_25k id resolved by lookup key>, quantity: 1 }], customer: org.stripeCustomerId ?? undefined, customer_email: fallback, metadata: { orgId, type: "mcp_pack", credits: "25000" }, success_url/cancel_url → /settings/billing })` → return url, client redirects.
- **Fulfillment:** the stripe plugin's `onEvent` hook (added to the plugin config in `src/lib/auth.ts`): on `checkout.session.completed` where `session.mode === "payment"` && `metadata.type === "mcp_pack"` → in a transaction: INSERT INTO mcp_credit_purchases ON CONFLICT (stripeSessionId) DO NOTHING; if inserted, increment `organization.mcpCreditBalance` by metadata.credits. Idempotent by construction.
- **Consumption:** extend the MCP chokepoint (`src/lib/mcp/auth.ts` / `quota.ts`): when monthly usage ≥ limit, check `mcpCreditBalance > 0` → serve AND decrement balance by 1 (fire-and-forget `GREATEST(balance - 1, 0)` update); balance 0 → reject with the existing 403 message. Pure helper `resolveMcpAllowance(usage, limit, credits)` → 'within' | 'credit' | 'blocked' for tests.
- **UI (Settings→Billing):** a "MCP ce mois-ci" card — usage X / limit Y (from `getOrgMonthlyMcpUsage` + `getOrgQuotaLimits`), credit balance, "Acheter un pack — 25 $ / 25 000 requêtes" button (manager-only) → checkout action. i18n keys in `Billing` namespace (×4, parity): `mcpUsageTitle`, `mcpUsage` ("{used} / {limit} requests this month"), `creditBalance` ("{count} credits"), `buyPack`, `packDesc` ("$25 — 25,000 requests"), `packPurchased`.

## C2 — Public pricing page

- **Route:** `src/app/pricing/page.tsx` (+ layout) — PUBLIC, outside `(app)`, no session required (locale resolves via cookie/Accept-Language for anonymous visitors). If a session exists, CTAs deep-link to `/settings/billing`; else to `/signup`.
- **Content:** hero (`Pricing` namespace: `heroTitle`, `heroSubtitle`), monthly/annual toggle (reuse `Billing.monthly/annual/annualSavings`), **currency toggle USD/EUR** (nominal parity per the doc: same number, `Intl.NumberFormat` with the right currency — pure display state), the 4 plan cards from `plans.ts` + Enterprise (mailto), trial banner ("14-day Pro trial — no card"), and a compact comparison table of the 6 limit rows (keywords / projets / sièges / intégrations / lignes backlinks / requêtes MCP) using plans.ts limits (-1 → "Illimité"). Footer note packs (€/$25 per 25k).
- **No new data**; SEO metadata (title/description) on the page; static-friendly (no auth reads besides optional session for CTA targets).
- i18n namespace `Pricing` (×4, parity): heroTitle, heroSubtitle, perMonthEq ("equivalent per month, billed annually"), compare, trialBanner, packsNote, ctaStart, ctaGoToBilling, unlimited.

## Tests (gate `bun run test:all`)
- `resolveMcpAllowance` pure: within / credit when over+balance / blocked at 0; boundary usage==limit && credits>0 → 'credit'.
- Fulfillment idempotence (DB): same stripeSessionId twice → balance incremented once; purchases row unique.
- Pack checkout action: non-manager rejected (DB-gated, seeded member roles).
- Catalog parity (Billing additions + Pricing namespace).
- Live (controller): buy pack with 4242 via Settings→Billing → webhook → balance 25000 in DB + visible in UI; `/pricing` renders logged-out (incognito profile) in EN with working toggles.

## Out of scope
Seat purchase (see top), proration of packs, pack tiers, auto-refill, dunning.
