Files
Momento/docs/referral-system-design.md
Antigravity 96e7902f01
Some checks failed
CI / Lint, Unit Tests & Build (push) Failing after 1m22s
CI / Deploy production (on server) (push) Has been skipped
feat: publication IA (magazine/brief/essay) + fixes critique
Publication IA:
- 4 templates (magazine, brief, essay, simple) avec CSS riche
- Rewrite IA (article/exercises/tutorial/reference/mixed)
- Modération avec timeout 12s + fallback safe
- Quotas publish_enhance par tier (basic=2, pro=15, business=100)
- Détection contenu stale (hash)
- Migration DB publishedContent/publishedTemplate/publishedSourceHash

Fixes:
- cheerio v1.2: Element -> AnyNode (domhandler), decodeEntities cast
- _isShared ajouté au type Note (champ virtuel serveur)
- callout colors PDF export: extraction fonction pure testable
- admin/published: guard note.userId null
- Cmd+S fonctionne en mode dialog (pas seulement fullPage)

i18n:
- 23 clés publish* traduites dans les 15 locales
- Extension Web Clipper: 13 locales mise à jour

Tests:
- callout-colors.test.ts (6 tests)
- note-visible-in-view.test.ts (5 tests)
- entitlements.test.ts + byok-entitlements.test.ts: mock usageLog + unstubAllEnvs
- 199/199 tests passent

Tracker: user-stories.md sync avec sprint-status.yaml
2026-06-28 07:32:57 +00:00

8.7 KiB

Memento — Spécification Technique & Design du Système de Parrainage (Referral)

Ce document présente l'architecture complète d'un système de parrainage (referral) natif pour Memento, intégré à notre base de données PostgreSQL (Prisma) et à Stripe.


1. Objectifs du Système

  1. Viralité (PLG) : Encourager les utilisateurs existants à partager Memento pour acquérir de nouveaux clients sans budget publicitaire (CAC proche de 0).
  2. Double Récompense (Win-Win) :
    • Le Filleul (Invité) obtient une réduction immédiate lors de son premier abonnement (ex. -10 % sur son abonnement PRO).
    • Le Parrain (Hôte) obtient une récompense lors du premier paiement de son filleul (ex. 1 mois gratuit appliqué directement sur sa prochaine facture Stripe, ou +100 crédits IA récurrents).
  3. Simplicité & Automatisation : Le parrainage doit se faire par lien unique et être validé automatiquement via notre webhook Stripe actuel.

2. Architecture de Données (Prisma)

Pour suivre les relations de parrainage et distribuer les récompenses de manière sécurisée et sans doublons, nous proposons d'étendre le schéma Prisma actuel.

// memento-note/prisma/schema.prisma

model User {
  // ... champs existants ...
  
  // Système de Parrainage
  referralCode   String       @unique // Code unique généré pour l'utilisateur (ex: "SEPEHR50")
  referredById   String?      // ID du parrain qui l'a invité
  referredBy     User?        @relation("UserReferrals", fields: [referredById], references: [id])
  referrals      User[]       @relation("UserReferrals")
  
  referralRewards ReferralReward[] // Historique des récompenses distribuées
}

model ReferralReward {
  id          String   @id @default(cuid())
  userId      String   // Le parrain récompensé
  refereeId   String   // Le filleul qui a déclenché la récompense
  rewardType  String   // "FREE_MONTH" (Stripe) ou "AI_CREDITS" (Redis)
  status      String   @default("PENDING") // "PENDING", "COMPLETED", "FAILED"
  stripeTxId  String?  // ID de la transaction de crédit Stripe si applicable
  createdAt   DateTime @default(now())
  
  user        User     @relation(fields: [userId], references: [id], onDelete: Cascade)
}

3. Le Parcours Utilisateur pas à pas

3.1 Génération et Partage du Lien

  1. Chaque utilisateur inscrit reçoit automatiquement un code unique lors de la création de son compte (généré à partir de son nom + 4 chiffres uniques, ou un cuid court).
  2. Un nouvel onglet "Parrainage" est ajouté dans /settings/billing :
    • Affiche son code promo de parrainage : MOMENTO-SEPEHR-1234
    • Affiche son lien d'invitation unique : https://memento-note.com/signup?ref=MOMENTO-SEPEHR-1234
    • Affiche un bouton de partage rapide (LinkedIn, X, Email, Clipboard).
  1. Le filleul clique sur le lien https://memento-note.com/signup?ref=MOMENTO-SEPEHR-1234.
  2. Le frontend intercepte le paramètre ref dans l'URL et le stocke de manière persistante dans un cookie ou dans le localStorage (durée : 30 jours).
  3. Lorsque le filleul valide son formulaire d'inscription :
    • Le serveur lit le code promo de parrainage dans la requête.
    • S'il est valide, il associe le referredById du nouveau compte User à l'ID du parrain.

3.3 Achat de l'Abonnement et Déclenchement de la Récompense

  1. Lors du checkout sur le plan Pro, le filleul utilise le code promo Stripe MOMENTO-SEPEHR-1234 (qui est configuré dans votre Stripe Dashboard comme un code promotionnel avec une réduction de 10%).
  2. Une fois le paiement validé, Stripe émet l'événement checkout.session.completed à notre webhook /api/billing/webhook.
  3. Notre helper sync-subscription-from-stripe.ts traite l'activation de l'abonnement :
    • Il vérifie en base de données si le nouvel abonné possède un parrain (referredById !== null).
    • Il vérifie si une récompense pour ce couple Parrain/Filleul a déjà été créée (évite le double-déclenchement).
    • Il déclenche la distribution des récompenses.

4. Implémentation Technique des Récompenses

Option A (Recommandée) : Offrir 1 mois gratuit sur Stripe au Parrain

Pour récompenser le parrain de manière entièrement automatisée, nous utilisons le système de Solde Client (Customer Balance) de Stripe. On ajoute un crédit négatif (ex. -9.90 €) sur le compte client Stripe du parrain. Lors de son prochain renouvellement, Stripe déduira automatiquement ce crédit, ce qui lui offrira son mois gratuit.

// lib/billing/referral.ts

import { stripe } from '@/lib/stripe';
import { prisma } from '@/lib/prisma';

export async function processReferralReward(refereeUserId: string) {
  // 1. Récupérer le filleul et son parrain
  const referee = await prisma.user.findUnique({
    where: { id: refereeUserId },
    include: { subscription: true }
  });

  if (!referee || !referee.referredById) return;

  const parrainId = referee.referredById;

  // 2. Vérifier si la récompense est déjà octroyée
  const existingReward = await prisma.referralReward.findFirst({
    where: { userId: parrainId, refereeId: refereeUserId }
  });
  if (existingReward) return;

  // 3. Récupérer l'abonnement Stripe du parrain
  const parrainSub = await prisma.subscription.findUnique({
    where: { userId: parrainId }
  });

  if (!parrainSub || !parrainSub.stripeCustomerId) {
    // Si le parrain n'est pas encore client Stripe ou n'a pas d'abonnement actif,
    // on peut lui créditer des crédits IA en bonus à la place
    await awardAiCredits(parrainId, 200); // ex: +200 crédits IA
    return;
  }

  try {
    // 4. Créer une transaction de solde Stripe (Créditer son compte de 9,90 €)
    const amountInCents = 990; // Le prix du plan PRO
    const balanceTransaction = await stripe.customers.createBalanceTransaction(
      parrainSub.stripeCustomerId,
      {
        amount: -amountInCents, // Un montant NÉGATIF crédite le client Stripe
        currency: 'eur',
        description: `Récompense de parrainage pour l'invitation de ${referee.email}`,
      }
    );

    // 5. Enregistrer la récompense dans notre base PostgreSQL
    await prisma.referralReward.create({
      data: {
        userId: parrainId,
        refereeId: refereeUserId,
        rewardType: 'FREE_MONTH',
        status: 'COMPLETED',
        stripeTxId: balanceTransaction.id
      }
    });

    console.log(`[Referral] Parrain ${parrainId} récompensé avec succès d'un mois gratuit !`);
  } catch (err) {
    console.error('[Referral] Erreur lors de l\'octroi du crédit Stripe :', err);
    
    await prisma.referralReward.create({
      data: {
        userId: parrainId,
        refereeId: refereeUserId,
        rewardType: 'FREE_MONTH',
        status: 'FAILED'
      }
    });
  }
}

Option B : Offrir des crédits IA (Redis) au Parrain

Si le parrain a un compte gratuit, on peut simplement augmenter son quota de crédits IA ou lui offrir des crédits "boost" à vie.

// lib/billing/referral.ts
import { redis } from '@/lib/redis';

async function awardAiCredits(userId: string, amount: number) {
  // Ajouter des crédits bonus dans Redis pour la période en cours
  const period = getCurrentPeriodKey();
  const key = `usage:${userId}:semantic_search:bonus`;
  await redis.incrby(key, amount);
  
  await prisma.referralReward.create({
    data: {
      userId,
      refereeId: refereeUserId,
      rewardType: 'AI_CREDITS',
      status: 'COMPLETED'
    }
  });
}

5. Intégration Webhook Stripe (Finalisation)

Dans le webhook app/api/billing/webhook/route.ts, lors du traitement de checkout.session.completed, il suffit d'appeler notre fonction de parrainage :

// app/api/billing/webhook/route.ts

if (event.type === 'checkout.session.completed') {
  const session = event.data.object as Stripe.Checkout.Session;
  const userId = session.metadata?.userId;
  
  if (userId) {
    // 1. Synchroniser l'abonnement
    await syncSubscriptionFromStripe(subscription, userId);
    
    // 2. Traiter le parrainage si applicable
    await processReferralReward(userId);
  }
}

6. Plan de Lancement & Coupon Stripe (No-Code)

Pour que ce système fonctionne immédiatement :

  1. Créez un Coupon général de 10% sur Stripe nommé "Parrainage Filleul".
  2. Dans Stripe, créez un Code de Promotion orienté client attaché à ce coupon.
  3. Configurez ce code promo avec le motif suivant : Autoriser les codes de promotion générés par les utilisateurs ou créez un code parrain par défaut.
  4. Dans le code de l'application, nous générons automatiquement le code de parrainage de l'utilisateur lors de son premier partage et l'associons à son compte.