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 soldeRate-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
×tamp=...
&transaction_id=<uuid>
&user_id=...
&signature=<ecdsa>
&key_id=3335741209Stats 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) :
| Champ | Défaut | Description |
|---|---|---|
creditsEnabled | false | Active le système de crédits |
creditsOnSignup | 0 | Crédits offerts à l'inscription |
creditsDailyEnabled | false | Top-up quotidien via Trigger.dev |
creditsDailyAmount | 10 | Crédits par jour |
creditsDailyActiveWithinDays | 30 | Éligibilité (actif dans les N derniers jours) |
adsEnabled | false | Active le système de pubs récompensées |
adRewardCredits | 10 | Crédits par pub regardée |
adRewardDailyLimit | 5 | Max 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
| Route | Rôle |
|---|---|
/dashboard/credit-packs | CRUD des packs achetables |
/dashboard/admin/credits | Octroi 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 :
transactionIdstocké dansCreditTransaction.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 :
adRewardDailyLimitvé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
| Fichier | Rôle |
|---|---|
components/ui/RewardedAdButton.tsx | Bouton 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.tsx | Route Expo Router → EarnCreditsScreen |
lib/useAdsEnabled.ts | Hook 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.0Piè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/5224354917Ce que tu peux vérifier avec ces IDs :
- La pub s'affiche bien dans l'app
onEarnedRewardse déclenche après visionnage completPOST /api/user/credits/ad-rewardest appelé et retourne{ ok: true, earned, credits }- Le solde user augmente dans
/dashboard/usersou en DB
Une fois l'app approuvée sur AdMob, remplace ces IDs par tes vrais IDs dans les .env.
Allez plus loin
- Plans tarifaires — lier les packs de crédits à des plans
- Parrainage — crédits octroyés au parrain
- Rate limiting —
withRateLimit()utilisé sur les endpoints ads - Webhook idempotence — même pattern appliqué aux SSV