# Monétisation SE-Ranking — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Syncs SE-Ranking vendus en top-up ($9.99 = 100 syncs), dialog de confirmation du coût avant chaque sync, org interne illimitée, BYO API key en add-on Stripe $9.99/mois.

**Architecture:** Miroir du pattern 3C existant (pack MCP : `mcpCreditPurchases` + `organization.mcpCreditBalance` + fulfillment idempotent onEvent) pour les syncs ; `org_addons` (déjà en place, « billing later » anticipé) pour l'add-on BYO piloté par les événements subscription Stripe ; le cœur conso est un décrément atomique sans verrou.

**Tech Stack:** Next.js 16 Server Actions, Drizzle/Postgres, @better-auth/stripe (onEvent), bun test.

**Spec (à lire d'abord)** : `docs/superpowers/specs/2026-07-14-seranking-monetization-design.md` — décisions produit et cas limites y sont normatifs.

## Global Constraints

- Branche `feature/seranking-monetization` (active). Conventional Commits. JAMAIS main/prod.
- Pack : **`seranking_pack_100`**, $9.99 (999 cents), one-off, **100 syncs**. Add-on : **`addon_seranking_byok`**, $9.99/mois (999 cents, `interval: "month"`). Addon slug `org_addons` : **`"seranking_byok"`**.
- 1 sync = 1 requête bulk (≤ 5000 kw). Consommation : décrément atomique AVANT chaque requête API ; jamais de refund (requête partie = sync dû).
- Ordre de résolution d'accès : `unlimited` > `byok` (add-on actif ET clé renseignée) > `metered`. Add-on sans clé = metered. JAMAIS de fallback clé plateforme pour une org byok en erreur.
- Gates : achat pack/add-on = `isOrgBillingManager` (owner-only, comme le pack MCP) ; saisie de la clé BYO = owner/admin (même logique que `canLinkGsc`).
- i18n : toute nouvelle string UI passe par les 4 catalogues `messages/{en,fr,es,de}.json`.
- Tests DB via le script npm `test` (DATABASE_URL forcé sur seo_insight_test) ; DI, pas de mock.module (sauf le stub pass-through next/cache déjà pratiqué).
- Gate par task : tests ciblés + `bunx tsc --noEmit` + `bunx biome check` sur les fichiers touchés. Gate finale : `bun run test:all`.
- Fichiers miroirs : quand une step dit « miroir de X », LIS X d'abord et copie sa structure exacte (imports, gardes, commentaires adaptés) — les deltas listés sont exhaustifs.

---

### Task 1: Schéma + migration + seed org illimitée

**Files:**
- Modify: schéma Drizzle — colonnes sur `organization` (fichier où vivent `mcpCreditBalance`/`seatsLimit` : `src/lib/db/auth-schema.ts`) + table `serankingSyncPurchases` (à côté de `mcpCreditPurchases` — cherche sa définition, miroir exact).
- Create: `drizzle/0063_*.sql` via `bun run db:generate` (modèle : `drizzle/0033_famous_scarecrow.sql`).
- Create: `scripts/seed-seranking-unlimited.ts`.

**Interfaces (produit pour les tasks suivantes):**
- `organization.serankingSyncBalance: integer NOT NULL DEFAULT 0`
- `organization.serankingUnlimited: boolean NOT NULL DEFAULT false`
- `organization.serankingApiKey: text | null`
- Table `serankingSyncPurchases { id, orgId (FK cascade), stripeSessionId UNIQUE, syncs: integer, amountCents: integer, createdAt }` + index org.

- [ ] **Step 1:** Ajouter les 3 colonnes au modèle `organization` (même style/commentaires que `mcpCreditBalance`) et la table `serankingSyncPurchases` (miroir champ-à-champ de `mcpCreditPurchases`, `credits`→`syncs`).
- [ ] **Step 2:** `bun run db:generate` → vérifier que le SQL généré ne contient QUE ces changements (3 ALTER + 1 CREATE + index/FK). Sinon, nettoyer le drift avant de continuer.
- [ ] **Step 3:** Appliquer : `bun run db:migrate` (dev) PUIS sur la DB de test (`DATABASE_URL=postgresql://humperdoo@localhost:5432/seo_insight_test bun run db:migrate` — vérifie la forme exacte utilisée par les migrations précédentes dans le ledger/scripts).
- [ ] **Step 4:** `scripts/seed-seranking-unlimited.ts` : `UPDATE organization SET seranking_unlimited = true WHERE id = '0668c152-17e1-4cee-8fca-f3f0a91136df'` (org eleva-seo, garde anti test-DB comme les scripts voisins) ; l'exécuter sur dev ; logguer le nombre de lignes touchées (doit être 1).
- [ ] **Step 5:** Test rapide d'intégrité (dans le fichier de test de la Task 2, pas besoin d'un fichier dédié ici) + `bunx tsc --noEmit`.
- [ ] **Step 6:** Commit `feat(seranking): schéma monétisation — solde syncs, unlimited, clé BYO, achats (migration 0063)`.

---

### Task 2: `balance.ts` — accès + consommation atomique

**Files:**
- Create: `src/lib/seranking/balance.ts`
- Test: `src/lib/seranking/balance.test.ts` (section DB guardée comme `sync.test.ts`)

**Interfaces:**
- Consumes: colonnes Task 1 ; `isAddonEnabled` (`src/lib/mcp/entitlements.ts` — server-only, cache 30 s tag `orgAddonsTag`).
- Produces:
  ```ts
  export const SERANKING_BYOK_ADDON = "seranking_byok";
  export type SerankingAccess =
    | { mode: "unlimited" }
    | { mode: "byok"; apiKey: string }
    | { mode: "metered"; balance: number };
  export async function getSerankingAccess(orgId: string): Promise<SerankingAccess>;
  /** true = 1 sync décrémenté ; false = solde insuffisant (0 ligne touchée). */
  export async function consumeSync(orgId: string): Promise<boolean>;
  export async function creditSyncs(orgId: string, n: number, executor?): Promise<void>;
  ```

- [ ] **Step 1:** Tests d'abord — DB guardée : (a) org unlimited → mode unlimited même avec balance 0 ; (b) add-on actif + clé → byok ; (c) add-on actif SANS clé → metered ; (d) clé sans add-on → metered ; (e) `consumeSync` décrémente 1 et renvoie true ; à 0 renvoie false et ne passe pas en négatif ; (f) course : 12 `consumeSync` concurrents sur balance=5 → exactement 5 true (pattern 12-racers des tests quota existants) ; (g) `creditSyncs` additionne.
- [ ] **Step 2:** Vérifier l'échec, implémenter :
  - `getSerankingAccess` : 1 SELECT org (`serankingUnlimited`, `serankingSyncBalance`, `serankingApiKey`) ; si unlimited → unlimited ; sinon si `serankingApiKey` non vide ET `await isAddonEnabled(orgId, SERANKING_BYOK_ADDON)` → byok ; sinon metered.
  - `consumeSync` : `UPDATE organization SET seranking_sync_balance = seranking_sync_balance - 1 WHERE id = ${orgId} AND seranking_sync_balance >= 1 RETURNING id` → `rows.length > 0`. Pas de transaction ni advisory lock : l'UPDATE conditionnel EST l'atomicité.
  - `creditSyncs` : `UPDATE ... SET balance = balance + n` (executor optionnel pour la tx du fulfillment).
  - `import "server-only"` (PAS "use server" — même raison qu'entitlements.ts).
- [ ] **Step 3:** Tests verts (`bun run test src/lib/seranking/balance.test.ts`), tsc, biome.
- [ ] **Step 4:** Commit `feat(seranking): balance — accès unlimited/byok/metered + consommation atomique`.

---

### Task 3: sync + client + actions — métering branché

**Files:**
- Modify: `src/lib/seranking/client.ts`, `src/lib/seranking/sync.ts`, `src/lib/seranking/actions.ts`
- Test: compléter `sync.test.ts`, `client.test.ts`, `actions.test.ts`

**Interfaces:**
- Consumes: `SerankingAccess`/`consumeSync` (Task 2).
- Produces (pour la Task 6) :
  ```ts
  // client.ts
  export type SerankingErrorKind = ... | "no-syncs"; // + message FR dans serankingErrorMessage
  // bulkKeywordMetrics gagne opts.apiKey?: string (défaut env plateforme)
  export function limiterForKey(orgId: string): RateLimiter; // Map module-level, 5 RPS chacun
  // sync.ts
  export function estimateSync(rows: SelectedKw[]): { keywords: number; requests: number; skippedCountries: string[] };
  // syncSerankingForSelection gagne un param `access: SerankingAccess`
  // actions.ts
  export type SerankingEstimate = {
    keywords: number; requests: number; skippedCountries: string[];
    access: { mode: "unlimited" | "byok" | "metered"; balance: number };
  };
  export async function estimateSerankingSyncAction(siteId: string, selection: KeywordSelection): Promise<{ ok: true } & SerankingEstimate | { ok: false; error: string }>;
  ```

- [ ] **Step 1:** Tests d'abord :
  - `estimateSync` pur : 3 kw sur 2 pays → requests 2 ; 6000 termes même pays → requests 2 (chunk 5000) ; pays non couvert exclu du compte.
  - sync metered : DI `consume` (le sync doit accepter l'injection — voir Step 2) qui renvoie true×1 puis false → 1er chunk passe, 2e stoppe avec `SerankingError("no-syncs")`, le résultat partiel des chunks réussis est PERDU côté throw mais les writes sont commités (asserte l'état DB) — le message d'erreur doit inclure le compte partiel (`X enrichis avant épuisement`).
  - sync byok : le fetchBulk injecté reçoit `apiKey` de l'accès.
  - client : `opts.apiKey` prioritaire sur env ; `limiterForKey` renvoie la MÊME instance pour le même orgId, une différente pour un autre.
  - action estimate : gates (env off, site étranger, sélection ids vide) + happy path (DI non nécessaire : réutilise le pattern des tests d'actions existants si possible, sinon teste les briques exportées).
- [ ] **Step 2:** Implémenter :
  - client : `apiKey?: string` dans les opts de `bulkKeywordMetrics` (le header utilise `apiKey ?? env.SERANKING_API_KEY`) ; kind `no-syncs` + message FR « Solde de syncs épuisé — recharge depuis Facturation. » ; `limiterForKey` : `Map<string, RateLimiter>` module-level, `createRateLimiter({minIntervalMs: 200})` à la volée.
  - sync : signature `syncSerankingForSelection(siteId, orgId, sel, access, deps?)` ; `deps.consume?: (orgId: string) => Promise<boolean>` (défaut `consumeSync`) ; avant CHAQUE requête API : si `access.mode === "metered"` → `await consume(orgId)` sinon continue ; false → `throw new SerankingError("no-syncs", \`\${enriched} enrichi(s) avant épuisement du solde\`)` ; si byok → `fetchBulk({..., apiKey: access.apiKey, limiter: limiterForKey(orgId)})`. Extraire `estimateSync(rows)` du groupage existant (requests = Σ ceil(size/5000) par groupe).
  - actions : `estimateSerankingSyncAction` (mêmes gates que le sync, résout l'accès + rows via selectionWhere capé 20k + estimateSync) ; `syncSerankingAction` résout l'accès, si metered et balance 0 → `{ok:false, error: "Solde de syncs épuisé — recharge depuis Facturation."}` AVANT tout appel, passe `access` au sync.
- [ ] **Step 3:** Toute la suite seranking verte (`bun run test src/lib/seranking/`), tsc, biome.
- [ ] **Step 4:** Commit `feat(seranking): métering branché — estimation, consommation par requête, clé BYO côté client`.

---

### Task 4: Stripe — prices, checkout pack, fulfillment, add-on subscription

**Files:**
- Modify: `scripts/stripe-bootstrap.ts` (+2 entrées `PlanRow`), `src/lib/billing/pack-actions.ts` (+2 actions), `src/lib/billing/stripe-events.ts` (+2 appels)
- Create: `src/lib/billing/seranking-fulfillment.ts`, `src/lib/billing/seranking-addon.ts`
- Test: `src/lib/billing/seranking-fulfillment.test.ts`, `src/lib/billing/seranking-addon.test.ts`

**Interfaces:**
- Consumes: `creditSyncs` (Task 2), `serankingSyncPurchases` (Task 1), `orgAddons` + `orgAddonsTag`.
- Produces: `fulfillSerankingPack(event)`, `syncSerankingByokAddon(event)`, `createSerankingPackCheckoutAction()`, `createSerankingByokCheckoutAction()` (mêmes shapes de retour que `createMcpPackCheckoutAction` : `{url} | {error}`).

- [ ] **Step 1:** `stripe-bootstrap.ts` : ajouter
  ```ts
  { lookupKey: "seranking_pack_100", productName: "SEO Insight SE-Ranking Syncs", unitAmount: 999 },
  { lookupKey: "addon_seranking_byok", productName: "SEO Insight SE-Ranking BYO Key", unitAmount: 999, interval: "month" },
  ```
  puis l'exécuter (guard sk_test déjà en place) et vérifier la création des 2 prices dans la sortie.
- [ ] **Step 2:** Tests d'abord (miroir des tests pack existants — regarde `pack.test.ts`) :
  - fulfillment : `checkout.session.completed` type `seranking_pack` → +100 syncs + ligne purchase ; retry même session → aucun double crédit ; type mcp_pack → ignoré.
  - addon : event `customer.subscription.created` dont l'item porte le price au lookup `addon_seranking_byok` → row `org_addons (orgId, "seranking_byok")` ; `deleted` → row supprimée ; subscription d'un AUTRE price → ignorée ; `updated` avec `status: "canceled" | "unpaid"` → supprimée aussi. L'orgId se résout depuis la subscription via le même mécanisme que `subscription-sync.ts` (lis-le : referenceId/metadata) — copie ce mécanisme exactement.
- [ ] **Step 3:** Implémenter :
  - `seranking-fulfillment.ts` : miroir de `pack-fulfillment.ts` (type `seranking_pack`, table `serankingSyncPurchases`, `creditSyncs(orgId, syncs, tx)`).
  - `seranking-addon.ts` : filtre `customer.subscription.*` ; détecte le price par `lookup_key` sur les items (expand si nécessaire — regarde comment subscription-sync accède aux items) ; upsert/delete `org_addons` + `updateTag(orgAddonsTag(orgId))`.
  - `stripe-events.ts` : `await fulfillSerankingPack(event); await syncSerankingByokAddon(event);` dans le fan-out.
  - `pack-actions.ts` : `createSerankingPackCheckoutAction` (miroir : lookup `seranking_pack_100`, metadata `{orgId, type: "seranking_pack", syncs: "100"}`, success_url `?pack=seranking-success`) et `createSerankingByokCheckoutAction` (mode `subscription`, lookup `addon_seranking_byok`, metadata orgId, success_url `/settings/integrations?byok=success`). Les deux gardés `isOrgBillingManager` + session fraîche (copie la garde existante).
- [ ] **Step 4:** Tests verts, tsc, biome. ⚠️ Ne PAS tester en cliquant un checkout réel (garde anti-bot Stripe) — `stripe trigger` est le chemin de QA, pas ce task.
- [ ] **Step 5:** Commit `feat(billing): pack syncs SE-Ranking + add-on BYO — prices, checkout, fulfillment idempotent, events subscription`.

---

### Task 5: Clé BYO — actions + section Intégrations

**Files:**
- Create: `src/lib/seranking/integration-actions.ts`, `src/components/settings/seranking-integration.tsx`
- Modify: `src/app/(app)/settings/integrations/page.tsx`
- Test: `src/lib/seranking/integration-actions.test.ts`
- Modify: `messages/{en,fr,es,de}.json`

**Interfaces:**
- Consumes: `createSerankingByokCheckoutAction` (Task 4), `isAddonEnabled`/`SERANKING_BYOK_ADDON`, `canLinkGsc`-style gate.
- Produces:
  ```ts
  export type SerankingIntegrationStatus = {
    canManage: boolean;           // owner|admin
    addonActive: boolean;
    hasKey: boolean;              // clé stockée (jamais la valeur)
    unitsLeft: number | null;     // null si pas de clé / ping non fait
  };
  export async function getSerankingIntegrationStatusAction(): Promise<SerankingIntegrationStatus>;
  export async function saveSerankingApiKeyAction(key: string | null): Promise<{ ok: true; unitsLeft: number | null } | { ok: false; error: string }>;
  ```

- [ ] **Step 1:** Tests d'abord (`integration-actions.test.ts`) : gate refusée (member) ; add-on inactif → erreur « Active d'abord l'add-on » ; clé invalide (ping DI 403) → erreur « Clé SE-Ranking refusée. » ; clé valide → stockée + unitsLeft retourné ; `null` → colonne vidée. Le ping est injecté (`deps.ping?: (key: string) => Promise<number | null>`), défaut = `GET https://api.seranking.com/v1/account/subscription` avec `Authorization: Token <key>` → `subscription_info.units_left`.
- [ ] **Step 2:** Implémenter les 2 actions ("use server") : gate = membre actif owner|admin (même dérivation que `canLinkGsc` — réutilise-la si importable proprement, sinon duplique la logique 3 lignes avec commentaire miroir). La clé n'est JAMAIS renvoyée au client (status expose `hasKey` seulement).
- [ ] **Step 3:** UI `seranking-integration.tsx` (client, miroir structurel de `gsc-integrations.tsx` en plus simple) :
  - Add-on inactif : carte « SE-Ranking — Utilise ta propre clé API » + prix $9.99/mois + bouton S'abonner (→ `createSerankingByokCheckoutAction` → redirect url) — visible seulement `canManage`.
  - Add-on actif : badge Actif · champ password-like pour la clé (placeholder « sk-… » masqué si `hasKey`), boutons Enregistrer/Déconnecter, affichage `unitsLeft` (« Crédits restants sur ton compte : N ») après validation, toasts d'erreur.
  - Rendu dans `settings/integrations/page.tsx` sous la section GSC : `const seranking = await getSerankingIntegrationStatusAction();` → `<SerankingIntegration initial={seranking} />`.
  - i18n : toutes les strings dans les 4 catalogues (namespace `settings.integrations.seranking`).
- [ ] **Step 4:** Tests verts, tsc, biome sur les fichiers touchés.
- [ ] **Step 5:** Commit `feat(seranking): section Intégrations BYO — add-on $9.99/mois, clé validée par ping, unitsLeft`.

---

### Task 6: Confirm dialog partagé (3 surfaces) + carte Facturation

**Files:**
- Create: `src/components/keywords/seranking-sync-dialog.tsx` (dialog + hook `useSerankingSync`)
- Modify: `src/components/keywords/reservoir.tsx`, `src/components/keywords/research/research-container.tsx`, `src/components/keywords/planning/planning-table.tsx` (remplacer les 3 handlers dupliqués par le hook)
- Modify: `src/components/billing/billing-settings.tsx`, `src/app/(app)/settings/billing/page.tsx`
- Modify: `messages/{en,fr,es,de}.json`

**Interfaces:**
- Consumes: `estimateSerankingSyncAction`/`syncSerankingAction` (Task 3), `createSerankingPackCheckoutAction` (Task 4), `organization.serankingSyncBalance` (Task 1).
- Produces:
  ```ts
  // seranking-sync-dialog.tsx
  export function useSerankingSync(siteId: string, opts: {
    onDone: () => void;            // clear sélection + refresh, fourni par la surface
  }): {
    open: (selection: KeywordSelection) => void;  // ouvre le dialog (lance l'estimate)
    dialog: React.ReactNode;                      // à rendre par la surface
    syncing: boolean;
  };
  ```

- [ ] **Step 1:** Construire le dialog : à l'ouverture → `estimateSerankingSyncAction` (spinner) → affiche « **N syncs** · M mots-clés · X pays » + pays non couverts éventuels + ligne selon mode : metered → « Solde : B syncs » (+ bouton « Acheter un pack (100 · $9.99) » → `createSerankingPackCheckoutAction` si B < N, Confirmer disabled) ; unlimited → « Illimité » ; byok → « Via ta clé SE-Ranking ». Confirmer → `syncSerankingAction` → toast récap réel (reprendre le format existant : enrichis/sans données/pays) → `onDone()`. try/catch + toast quota FR : reprendre la logique actuelle des handlers (elle disparaît des 3 surfaces, elle vit ici).
- [ ] **Step 2:** Brancher les 3 surfaces : le handler existant devient `open(selection)` ; supprimer les useTransition/toasts locaux devenus morts (knip mental : zéro code sync résiduel dans les 3 fichiers hors appel au hook).
- [ ] **Step 3:** Carte Facturation : dans `billing-settings.tsx`, carte « SE-Ranking » à côté de la carte MCP : solde `serankingSyncBalance` (prop nouvelle depuis la page, sélectionnée en DB comme `mcpCreditBalance`) + bouton « Acheter 100 syncs — $9.99 » (`createSerankingPackCheckoutAction`) gate `isManager` + toast `?pack=seranking-success`.
- [ ] **Step 4:** i18n 4 catalogues. `bun run test src/components` sans régression, tsc, biome.
- [ ] **Step 5:** Commit `feat(seranking): dialog de confirmation du coût (3 surfaces, hook partagé) + carte solde en Facturation`.

---

### Task 7: Gate finale + roadmap (orchestrateur)

- [ ] `bun run test:all` vert ; `bunx tsc --noEmit` ; biome.
- [ ] Roadmap : entrée ✅ monétisation (pack $9.99/100 syncs, confirm, unlimited interne, BYO $9.99/mois) + ops prod restants (souscrire plan SE $45/mois avant le 2026-07-28 — trial 100k expire —, prices Stripe live via bootstrap, seed unlimited prod).
- [ ] Review finale de branche (fable) + fixes éventuels.
- [ ] Merge ff-only dans dev + push + suppression branche.
- [ ] QA live (orchestrateur, avec l'utilisateur) : dialog sur les 3 surfaces (estimation exacte), org eleva-seo = « Illimité », achat pack via `stripe trigger checkout.session.completed` (metadata seranking_pack) → solde +100, sync consomme, solde 0 → refus + CTA, section Intégrations (souscription add-on via trigger, clé validée par ping réel).

## Vérification finale

1. `bun run test:all` vert.
2. Un sync metered décrémente exactement `requests` syncs ; unlimited/byok ne décrémentent pas.
3. Webhook retry pack : solde +100 UNE fois.
4. Aucun chemin ne fallback sur la clé plateforme pour une org byok.
5. La clé BYO n'est jamais sérialisée vers le client.
