Documentation

Crédits & publicités

Système de crédits gagnés via pubs AdMob, achats de packs, top-up quotidien et gestion admin.

Les crédits sont une monnaie interne que les users peuvent accumuler en regardant des publicités (AdMob), en achetant des packs via Stripe, ou en recevant des octrois automatiques (inscription, parrainage, top-up quotidien).

Modèle Prisma

model User {
  credits Int @default(0)  // solde courant
}

model CreditTransaction {
  id            String   @id @default(cuid())
  userId        String
  amount        Int      // positif = gain, négatif = dépense
  reason        String   // "ad_reward" | "admin_grant" | "daily_topup"
                         // | "referral_referrer" | "signup_bonus" | "pack_purchase"
  adminId       String?  // si octroi manuel
  metadata      Json?    // { transactionId, source, ... }
  stripeSyncedAt DateTime? // si synced au Customer Balance Stripe
  createdAt     DateTime @default(now())

  user  User @relation(fields: [userId], references: [id])
}

model CreditPack {
  id            String   @id @default(cuid())
  name          Json     // LocalizedText
  description   Json?    // LocalizedText
  credits       Int
  bonusCredits  Int      @default(0)
  price         Int      // en cents
  currency      String   @default("EUR")
  stripePriceId String?
  highlighted   Boolean  @default(false)
  active        Boolean  @default(true)
  displayOrder  Int      @default(0)
}

Helpers lib/credits.ts

import { spendCredits, addCredits } from "@/lib/credits";

// Dépenser (atomique, vérifie le solde — lève une erreur si insuffisant)
await spendCredits(userId, 50, "feature_usage");

// Ajouter
await addCredits(userId, 10, "ad_reward", {
  metadata: { transactionId: "admob-abc123", source: "mobile" },
});

spendCredits utilise un updateMany conditionnel (credits >= amount) pour éviter les soldes négatifs sans race condition.

Système de publicités (AdMob rewarded ads)

Architecture

App mobile → affiche RewardedAd (AdMob)
           → onEarnedReward()

Path A (direct)   POST /api/user/credits/ad-reward   { transactionId }
Path B (sécurisé) Google SSV → GET /api/ads/ssv?transaction_id=...&signature=...

Les deux paths créditent via addCredits(). Le path B est recommandé en production : Google appelle ton backend directement, l'app mobile ne peut pas falsifier le résultat.

Path A — /api/user/credits/ad-reward

// Corps attendu
{ transactionId: string }

// Réponse
{ credits: number }  // nouveau solde

Rate-limitée à 60 req/min par IP. Idempotente : un même transactionId ne crédite qu'une seule fois (vérifié via CreditTransaction.metadata).

Path B — /api/ads/ssv

Callback appelé par les serveurs Google après visionnage. La signature ECDSA-P256 est vérifiée contre les clés publiques Google (cachées 1 h). Retourne toujours HTTP 200 pour éviter les retries Google même en cas d'erreur interne.

GET /api/ads/ssv
  ?ad_network=5450213213286189855
  &ad_unit=...
  &custom_data=<userId>
  &reward_amount=10
  &reward_item=credits
  &timestamp=...
  &transaction_id=<uuid>
  &user_id=...
  &signature=<ecdsa>
  &key_id=3335741209

Stats user — /api/user/credits/ad-stats

// Réponse
{
  todayAdsWatched: number,     // pubs vues aujourd'hui
  totalAdsWatched: number,     // total historique
  totalCreditsEarned: number,  // crédits gagnés via pubs (lifetime)
  todayCreditsEarned: number,  // crédits gagnés aujourd'hui
}

Configuration SiteConfig

Tout se configure depuis /dashboard/settings (onglet Crédits) :

ChampDéfautDescription
creditsEnabledfalseActive le système de crédits
creditsOnSignup0Crédits offerts à l'inscription
creditsDailyEnabledfalseTop-up quotidien via Trigger.dev
creditsDailyAmount10Crédits par jour
creditsDailyActiveWithinDays30Éligibilité (actif dans les N derniers jours)
adsEnabledfalseActive le système de pubs récompensées
adRewardCredits10Crédits par pub regardée
adRewardDailyLimit5Max pubs par user par jour

Ces valeurs sont exposées au mobile via GET /api/public/site-config.

Top-up quotidien (trigger/daily-credits.ts)

Cron Trigger.dev qui tourne chaque nuit. Il sélectionne les users actifs dans les creditsDailyActiveWithinDays derniers jours (non bloqués, non en attente de suppression) et leur ajoute creditsDailyAmount crédits avec reason: "daily_topup".

Packs de crédits (achat one-time)

Les CreditPack sont des produits Stripe one-time. L'admin les crée dans /dashboard/credit-packs, colle le Stripe Price ID, et le pack apparaît dans la page d'achat user.

bonusCredits permet d'afficher des offres type "achète 500, reçois 550".

Pages admin

RouteRôle
/dashboard/credit-packsCRUD des packs achetables
/dashboard/admin/creditsOctroi en masse (bulk grant)
/dashboard/users/[id]Ajustement manuel pour un user

Le bulk grant supporte 9 types d'audience : tous les users, abonnés payants, free, actifs 30j, inactifs 60j, nouveaux 7j, solde zéro, par tag, par pays.

Anti-fraude

  • Idempotence : transactionId stocké dans CreditTransaction.metadata. Un même ID ne peut créditer qu'une seule fois.
  • Rate-limit : 60 req/min par IP sur l'endpoint direct.
  • Cap journalier : adRewardDailyLimit vérifié en DB avant de créditer.
  • SSV : vérification cryptographique ECDSA-P256 — le mobile ne peut pas déclencher un crédit sans que Google l'ait validé.
  • HTTP 200 systématique à Google : évite les retries qui pourraient double-créditer si l'idempotence n'était pas en place.

Sync Stripe Customer Balance (optionnel)

addCredits() accepte un flag syncToStripe qui pousse la transaction dans le Customer Balance Stripe (crédit négatif = réduction sur prochaine facture). Utile si les crédits remplacent partiellement un abonnement.

Implémentation mobile

Composants & écrans

FichierRôle
components/ui/RewardedAdButton.tsxBouton pub — charge, affiche et crédite. États : idle / loading / watching / rewarding
features/settings/screens/EarnCreditsScreen.tsxÉcran "Gagner des crédits" — solde, bouton, pubs restantes, récompense/pub, stats
app/settings/earn-credits.tsxRoute Expo Router → EarnCreditsScreen
lib/useAdsEnabled.tsHook useAdsConfig() — lit adsEnabled, adRewardCredits, adRewardDailyLimit depuis Zustand

L'écran n'est accessible que si adsEnabled && creditsEnabled (vérification dans ProfileScreen avant d'afficher le menu item).

RewardedAdButton — flux complet

mount → loadAd() → RewardedAd.createForAdRequest(adUnitId)
                 → addAdEventListener(RewardedAdEventType.LOADED, ...)   // pub prête
                 → addAdEventListener(AdEventType.ERROR, ...)            // pub indispo
                 → addAdEventListener(RewardedAdEventType.EARNED_REWARD) // visionnage complet

utilisateur appuie → pub déjà prête ? show() immédiat
                   → sinon : setAdState("loading") → show() dès LOADED

EARNED_REWARD → creditUser() → POST /api/user/credits/ad-reward
                             → toast.success("+N crédits")
                             → onRewarded() → invalidate ["ad-stats", "user-credits", "user-profile"]
                             → loadAd() (précache la suivante)

Version compatible — react-native-google-mobile-ads

Version requise : ^14.11.0 (pas v16.x).

v16.3.3 est incompatible avec RN 0.76.x (Expo SDK 52) — le codegen @react-native/codegen ne reconnaît pas CodegenTypes.UnsafeObject utilisé dans les specs natifs de v16.

npm install react-native-google-mobile-ads@^14.11.0

Piège — événements v14

En v14, les événements sont répartis sur deux enums selon le type :

import { AdEventType, RewardedAdEventType } from "react-native-google-mobile-ads";

// ✅ Correct v14
rewarded.addAdEventListener(RewardedAdEventType.LOADED, () => { ... });
rewarded.addAdEventListener(RewardedAdEventType.EARNED_REWARD, () => { ... });
rewarded.addAdEventListener(AdEventType.ERROR, () => { ... });  // ← AdEventType, pas RewardedAdEventType

// ❌ Plante en v14
rewarded.addAdEventListener(RewardedAdEventType.ERROR, () => { ... });

Piège — AndroidManifest

Le module injecte une meta-data vide pour APPLICATION_ID. Si tu ajoutes manuellement l'App ID dans AndroidManifest.xml, il faut tools:replace pour éviter le conflit de merge :

<meta-data
  android:name="com.google.android.gms.ads.APPLICATION_ID"
  android:value="ca-app-pub-XXXXXXXX~XXXXXXXXXX"
  tools:replace="android:value"/>

Sans ça : Manifest merger failed: Attribute ... is also present at [:react-native-google-mobile-ads].

Tester sans attendre l'examen AdMob

Google fournit des IDs de test officiels qui affichent de vraies fausses pubs et déclenchent le vrai flow onEarnedReward — sans compte AdMob validé, sans examen.

# test/mobile/.env  et  test/web/.env  — déjà pré-remplis
EXPO_PUBLIC_ADMOB_APP_ID=ca-app-pub-3940256099942544~3347511713
EXPO_PUBLIC_ADMOB_REWARDED_AD_UNIT_ID=ca-app-pub-3940256099942544/5224354917

Ce que tu peux vérifier avec ces IDs :

  1. La pub s'affiche bien dans l'app
  2. onEarnedReward se déclenche après visionnage complet
  3. POST /api/user/credits/ad-reward est appelé et retourne { ok: true, earned, credits }
  4. Le solde user augmente dans /dashboard/users ou en DB

Une fois l'app approuvée sur AdMob, remplace ces IDs par tes vrais IDs dans les .env.

Allez plus loin

On this page