# Monétisation SE-Ranking — syncs top-up, confirm de coût, BYO key (slice 2)

**Date** : 2026-07-14 · **Branche** : `feature/seranking-monetization`
**Prérequis** : slice 1 enrichissement livrée (spec `2026-07-14-seranking-enrichment-design.md`).

## Économie (mesurée empiriquement le 2026-07-14)

- `/keywords/export` = **100 crédits SE par requête** (≤ 5000 kw), **confirmé
  par mesure** : solde 98 000 → 97 900 pour 1 requête de 3 kw frais. La table
  unit-costs (« keywords GET export 0/10 ») est trompeuse ; la page
  keyword-research a raison.
- Facturation SE **différée** (~5 min) ; les requêtes répétées sur des termes
  déjà interrogés n'ont pas re-facturé pendant les tests — ne PAS bâtir
  là-dessus.
- Coût réel : $540/an ÷ 3M crédits × 100 = **$0.018/requête**.
- ⚠️ Le compte actuel est un **trial 100k crédits** expirant le 2026-07-28
  (`GET /account/subscription` → `units_left`) ; le plan $45/mois annuel (3M)
  reste à souscrire (ops).

## Décisions produit (verrouillées avec l'utilisateur)

1. **Unité vendue : le « sync »** = 1 requête bulk (jusqu'à 5000 kw d'un même
   pays). Pack top-up **100 syncs = $9.99** (marge ×5,5). AUCUN forfait
   n'inclut de syncs — pur top-up.
2. **Confirm léger + toast** : clic Sync → dialog « N syncs · M mots-clés ·
   X pays · solde restant » → Confirmer → toast récap réel.
3. **BYO key : add-on $9.99/mois** (Stripe récurrent). L'org branche SA clé
   SE-Ranking dans Settings→Intégrations → plus aucun décompte de nos syncs,
   limiteur de débit dédié à sa clé.
4. **Org d'elevaseo99@gmail.com illimitée** (org « eleva seo »,
   id `0668c152-17e1-4cee-8fca-f3f0a91136df`) — flag, pas de décompte.
5. Modèles subagents : best-fit (Sonnet par défaut, Fable réservé au cœur
   billing et aux reviews critiques).

## Schéma (1 migration)

- `organization.seranking_sync_balance` integer NOT NULL DEFAULT 0 — solde de
  syncs achetés (miroir de `mcp_credit_balance`).
- `organization.seranking_unlimited` boolean NOT NULL DEFAULT false — bypass
  total du décompte (org interne). Seed dev : org eleva-seo → true (script) ;
  même seed à rejouer en prod (runbook).
- `organization.seranking_api_key` text NULL — clé BYO (visible/éditable
  seulement si l'add-on est actif). Stockage en clair assumé (parité avec les
  autres secrets DB du projet) — noté pour un chantier chiffrement global.
- `seranking_sync_purchases` — miroir exact de `mcp_credit_purchases` :
  `id, org_id (FK cascade), stripe_session_id UNIQUE, syncs int, amount_cents,
  created_at`. L'UNIQUE porte l'idempotence du webhook.

## Modules

### `src/lib/seranking/balance.ts` (cœur billing — nouveau)
- `getSerankingAccess(orgId)` → `{ mode: "unlimited" | "byok" | "metered",
  balance: number, apiKey?: string }` — lit l'org (unlimited, clé BYO +
  add-on actif via `org_addons`), sinon metered avec `seranking_sync_balance`.
  L'add-on SANS clé renseignée = metered (fallback sûr).
- `consumeSync(orgId)` → boolean — décrément atomique SANS verrou :
  `UPDATE organization SET seranking_sync_balance = seranking_sync_balance - 1
  WHERE id = $org AND seranking_sync_balance >= 1 RETURNING id` (0 ligne =
  solde insuffisant). Appelé juste AVANT chaque requête API (jamais de refund :
  une requête partie = un sync dû, y compris si l'API échoue ensuite — assumé,
  documenté).
- `creditSyncs(orgId, n)` — utilisé par le fulfillment.

### `src/lib/seranking/sync.ts` (modifs)
- `syncSerankingForSelection` prend l'accès résolu en amont
  (`access: SerankingAccess`) : mode metered → `consumeSync` avant chaque
  chunk, échec → stop propre avec `SerankingError("no-syncs")` (nouveau kind,
  message FR « Solde de syncs épuisé — recharge depuis Facturation ») en
  conservant le récap partiel déjà enrichi ; mode byok → `bulkKeywordMetrics`
  reçoit la clé org + un limiteur dédié ; mode unlimited → comportement actuel.
- `estimateSync(rows)` (pur, exporté) : réutilise `groupByDb` → `{ keywords,
  requests, skippedCountries }` (requests = Σ ceil(termes du groupe / 5000)).

### `src/lib/seranking/client.ts` (modifs)
- `bulkKeywordMetrics` accepte `apiKey?: string` (défaut plateforme) et
  `limiter?` déjà en place. Limiteurs BYO : `Map<orgId, RateLimiter>`
  module-level (5 RPS chacun — même prudence, clé du client).
- Nouveau kind d'erreur `no-syncs` dans `SerankingErrorKind` + message FR.

### `src/lib/seranking/actions.ts` (modifs + nouveau)
- `estimateSerankingSyncAction(siteId, selection)` → `{ keywords, requests,
  skippedCountries, access: { mode, balance } }` — mêmes gates que le sync
  (env, tenancy, sélection vide). Sert le dialog de confirmation.
- `syncSerankingAction` : résout l'accès, refuse d'emblée si metered et
  balance = 0 (« Achète un pack depuis Facturation ») ; passe l'accès au sync.
- `saveSerankingApiKeyAction(key: string | null)` : owner/admin org — même
  gate que le link GSC (`canLinkGsc()` de `src/lib/gsc/owner.ts`, ou son
  équivalent générique owner/admin), exige l'add-on actif, **valide la clé par un ping**
  `GET /account/subscription` (401/403 → erreur « Clé refusée ») et renvoie
  `units_left` pour affichage. `null` = déconnexion.

### Stripe (miroir 3C + add-on récurrent)
- `scripts/stripe-bootstrap.ts` : + price one-off **`seranking_pack_100`**
  ($9.99, metadata type) + price récurrent mensuel **`addon_seranking_byok`**
  ($9.99/mois).
- `src/lib/billing/pack-actions.ts` : + checkout du pack seranking (mode
  payment, metadata `{type: "seranking_pack", orgId, syncs: 100}`) — même
  garde anti-bot que le pack MCP.
- `src/lib/billing/seranking-fulfillment.ts` (nouveau, miroir
  pack-fulfillment.ts) : `checkout.session.completed` + type seranking_pack →
  insert purchase (idempotent UNIQUE) + `creditSyncs`.
- Add-on BYO : action de souscription (checkout mode subscription, lookup
  `addon_seranking_byok`) ; le dispatcher `stripe-events` gère
  `customer.subscription.created|updated|deleted` portant ce price → upsert /
  delete `org_addons (orgId, "seranking_byok")`. Résiliation → row supprimée →
  l'org retombe en metered (sa clé reste stockée mais inutilisée, masquée UI).

### UI

1. **Dialog de confirmation** (partagé, 1 composant + 1 hook
   `useSerankingSync(siteId)` consommé par les 3 surfaces — réservoir,
   recherche, planning — pour tuer la triplication actuelle des handlers) :
   ouvre au clic → `estimateSerankingSyncAction` → « Ce sync = **N syncs** ·
   M mots-clés · X pays [· pays non couverts : …] » + ligne solde selon mode
   (metered : « Solde : B syncs » + CTA « Acheter un pack » si B < N ;
   unlimited : « Illimité » ; byok : « Votre clé SE-Ranking ») → Confirmer
   (disabled si metered et B < N) → sync → toast récap réel existant.
2. **Facturation** : carte pack « SE-Ranking — 100 syncs · $9.99 » à côté du
   pack MCP (même composant/pattern), affiche le solde courant.
3. **Settings→Intégrations** : section « SE-Ranking » — état de l'add-on BYO
   ($9.99/mois, bouton S'abonner → checkout ; actif → badge + Gérer via portal) ;
   si actif : champ clé API (save/validate/disconnect, affiche `units_left`
   du compte client après validation).

## Cas limites

| Cas | Comportement |
|---|---|
| Metered, solde 0 | Dialog : CTA achat, bouton Confirmer disabled ; l'action refuse aussi côté serveur. |
| Solde épuisé MID-sync (multi-chunks) | Chunks payés déjà commités ; stop + toast « X enrichis · solde épuisé » (récap partiel). |
| BYO clé invalide/expirée au runtime | SerankingError auth → toast « Ta clé SE-Ranking est refusée — vérifie-la dans Intégrations ». Aucun fallback sur la clé plateforme (jamais à nos frais). |
| Add-on résilié avec clé encore stockée | Metered à nouveau ; champ clé masqué mais conservé (réactivation sans re-saisie). |
| Org unlimited | Aucun décompte, dialog affiche « Illimité », aucune consommation loggée en balance (le log `[seranking]` reste). |
| Webhook retry pack | UNIQUE stripe_session_id → idempotent (0 double crédit). |
| Estimation ≠ réel (pays sautés) | L'estimation exclut les groupes sautés (post-groupByDb) — l'affichage du dialog EST le nombre facturé (au chunk près). |

## Tests

- balance.ts : consume atomique (0 quand insuffisant, 12-racers style), accès
  unlimited/byok/metered, addon sans clé = metered.
- estimateSync pur (groupes/chunks/sautés). Fulfillment idempotent (retry même
  session). Actions : gates, refus solde 0, validation clé (ping DI).
- Client : apiKey override + limiteur par org (DI).
- UI : pas de tests navigateur automatisés — QA live.
- Gate : `bun run test:all` vert.

## Hors périmètre

- Découverte (similar/related — 10 crédits/record, metering per-record viendra
  avec elle). Chiffrement des secrets DB (chantier global). Auto-recharge du
  solde. Reporting conso par site/user. Prod : souscription du plan SE $45/mois
  + seed unlimited + prices Stripe live (runbook ops).
