Boutique et récompenses
Le module marketplace permet aux citoyens de dépenser leurs EtherPoints contre des récompenses proposées par les commerçants partenaires, soit en choisissant une offre, soit en achetant un ticket à gratter qui en tire une au hasard, puis aux commerçants de valider en magasin le code obtenu. Les EtherPoints sont des points de fidélité stockés uniquement en base : ils ne circulent pas sur la blockchain. Les offres et les lots de codes sont créés par le module Partenaires.
| Fichier | Contenu |
|---|---|
backend/src/api/marketplace/marketplace.routes.ts |
7 routes |
backend/src/api/marketplace/marketplace.validators.ts |
3 schémas Zod |
backend/src/api/marketplace/marketplace.services.ts |
Catalogue, échange, ticket à gratter, validation en magasin |
backend/src/api/marketplace/marketplace.repo.ts |
RewardCatalog, ClaimedReward |
backend/src/services/ledger.service.ts |
Débit des points et historique |
Le parcours est décrit dans Échange de récompense.
Routes
| Méthode et chemin | Accès | Middlewares | Rôle |
|---|---|---|---|
GET /api/v1/marketplace/catalog |
public | aucun | Offres actives des partenaires actifs |
GET /api/v1/marketplace/rewards/:id |
public | aucun | Détail d'une offre |
GET /api/v1/marketplace/points |
connecté | authenticate |
Solde et 50 derniers mouvements |
POST /api/v1/marketplace/claim |
connecté, âge vérifié | authenticate, requireAgeVerification, idempotency, validate(claimRewardSchema) |
Échange de points contre un code |
POST /api/v1/marketplace/scratch |
connecté, âge vérifié | authenticate, requireAgeVerification, idempotency, validate(scratchTicketSchema) |
Achat d'un ticket à gratter |
GET /api/v1/marketplace/my-codes |
connecté | authenticate |
Codes obtenus, avec QR code |
POST /api/v1/marketplace/redeem |
PARTNER, ADMIN | authenticate, requireRoles('PARTNER', 'ADMIN'), validate(usePromoCodeSchema) |
Validation d'un code en magasin |
Sources des EtherPoints
Le solde User.pointsBalance bouge par ledgerService, qui écrit en même temps une ligne PointTransaction, sauf pour le ticket à gratter, dont la transaction écrit le débit et sa ligne directement. Une contrainte CHECK ("pointsBalance" >= 0) interdit un solde négatif en base.
| Type de mouvement | Montant | Déclencheur |
|---|---|---|
SOCIAL_QUEST |
+25 | création du compte |
REFERRAL |
+100 au parrain, +50 au filleul | création d'un compte avec code de parrainage |
TICKET_PURCHASE |
+50 par ticket | achat (indexeur en onchain, service loterie en simulé) |
GIFT_TICKET |
+75 par ticket offert | cadeau, crédité à l'acheteur |
SOCIAL_QUEST |
+20 à +40 | quêtes sociales |
REWARD_CLAIM |
moins le coût de l'offre | échange dans la boutique |
REWARD_CLAIM |
moins le prix du ticket à gratter | ticket à gratter |
Le type ADMIN_BONUS existe dans LedgerActionType mais aucune route ne l'utilise.
GET /marketplace/catalog
Renvoie les offres isActive dont le partenaire est isActive, triées par coût croissant.
{
"success": true,
"message": "Catalogue des récompenses partenaires récupéré",
"data": {
"catalog": [
{
"id": "019a7c50-1d3e-7b8a-9c0f-2e4d6a8b0c13",
"title": "-15% sur les viennoiseries et pains spéciaux",
"costInPoints": 100,
"totalStock": 100,
"remainingStock": 97,
"stockPercentage": 97,
"isActive": true,
"partner": { "id": "019a7c4f-...", "name": "La Boulangerie du Port d'EtherBay", "logoUrl": "https://assets.etherbay.fr/partners/default.png" }
}
]
}
}stockPercentage vaut round(remainingStock / totalStock x 100), ou 0 si le stock total est nul.
GET /marketplace/rewards/:id
Renvoie l'offre complète avec son partenaire (toutes les colonnes de Partner, dont ownerId), stockPercentage et claimedCount (nombre de codes déjà obtenus). Une offre inexistante renvoie 404 Récompense introuvable dans le catalogue. Une offre désactivée, ou dont le partenaire est suspendu, reste lisible par cette route.
GET /marketplace/points
{
"success": true,
"message": "Solde et historique d'EtherPoints récupérés",
"data": {
"balance": 125,
"history": [
{ "id": "019a7c45-...", "userId": "019a7c3e-...", "amount": 100, "actionType": "TICKET_PURCHASE", "description": "Achat de 2 ticket(s) sur le tour #1", "createdAt": "2026-11-11T13:41:02.512Z" },
{ "id": "019a7c3e-...", "userId": "019a7c3e-...", "amount": 25, "actionType": "SOCIAL_QUEST", "description": "Bonus de bienvenue Onboarding EtherBay", "createdAt": "2026-11-11T13:30:11.204Z" }
]
}
}L'historique est limité aux 50 mouvements les plus récents, sans pagination. Les débits ont un amount négatif.
POST /marketplace/claim
Corps
| Champ | Type | Règles |
|---|---|---|
rewardId |
UUID | obligatoire, message Identifiant de récompense invalide |
Règles
- L'âge du compte doit être vérifié, sinon
403 AGE_VERIFICATION_REQUIRED(voir Utilisateur). - L'offre doit exister et être active : sinon
404 Cette offre n'est plus disponible. L'état du partenaire n'est pas contrôlé ici. - Le stock restant doit être positif : sinon
400 Stock épuisé pour cette récompense partenaire. - Le solde doit couvrir le coût : sinon
400 EtherPoints insuffisants (80 disponibles, 100 requis). - Les points sont débités par
ledgerService.debitPoints(REWARD_CLAIM, descriptionÉchange récompense : <titre> (<partenaire>)). - Le code est pris en tête du lot importé par le partenaire (clé Redis
partner:codes:<rewardId>). Si le lot est vide ou absent, un code est généré : préfixe tiré des quatre premiers caractères du nom du partenaire en majuscules, chaque caractère qui n'est pas une lettre de A à Z étant remplacé parEBAY, puis un tiret et 12 caractères hexadécimaux. « La Boulangerie… » donne par exempleLAEBAYB-3F9A1C07B2DE. - Le stock est décrémenté et le
ClaimedRewardcréé dans une même transaction. - Un QR code PNG en data URL encode
{ claimId, uniqueCode, partner }. - Un email
reward-claimest envoyé si le compte a une adresse.
{
"success": true,
"message": "Récompense débloquée ! Grattez votre carte virtuelle.",
"data": {
"claim": {
"id": "019a7c52-6f0a-7c1d-8e2b-4a6c8e0f2d15",
"uniqueCode": "BOUL-2026-0007",
"isUsed": false,
"claimedAt": "2026-11-11T14:02:44.019Z",
"rewardTitle": "-15% sur les viennoiseries et pains spéciaux",
"partnerName": "La Boulangerie du Port d'EtherBay",
"partnerLogo": "https://assets.etherbay.fr/partners/default.png",
"qrCodeDataUrl": "data:image/png;base64,iVBORw0KGgo...",
"scratchCardReady": true
},
"remainingStock": 96,
"newPointsBalance": 25
}
}scratchCardReady est une constante true : la carte à gratter est une animation du client, le code est attribué dès cette réponse.
Attention
Le débit des points et la décrémentation du stock sont deux transactions distinctes. Si la seconde échoue (stock passé à zéro entre la vérification et l'écriture, ce que la contrainte CHECK transforme en 409 L'opération viole une règle d'intégrité des données), les points sont déjà débités et ne sont pas recrédités. Le code tiré du lot Redis est lui aussi perdu. Utiliser Idempotency-Key évite le cas du double clic, pas celui de deux citoyens sur la dernière unité.
Effets de bord
| Effet | Détail |
|---|---|
PointTransaction |
ligne négative REWARD_CLAIM |
RewardCatalog.remainingStock |
moins 1 |
ClaimedReward |
nouvelle ligne, uniqueCode unique |
| Redis | retrait du premier code du lot partner:codes:<rewardId> |
gabarit reward-claim avec REWARD_TITLE, PARTNER_NAME, PROMO_CODE |
|
| Métrique | business_events_total{event="reward_claimed"} et points_debited_reward_claim |
POST /marketplace/scratch
Le ticket à gratter est acheté en EtherPoints et attribue au hasard une récompense partenaire en stock. L'application mobile révèle ensuite le code en grattant l'écran.
| Champ | Type | Règles |
|---|---|---|
offer |
énumération | violet ou lucky, sinon 400 Offre de ticket à gratter inconnue (violet ou lucky) |
| Offre | Prix | Variable |
|---|---|---|
violet |
50 EtherPoints par défaut | SCRATCH_COST_VIOLET |
lucky |
100 EtherPoints par défaut | SCRATCH_COST_LUCKY |
Règles
- L'âge du compte doit être vérifié (
403 AGE_VERIFICATION_REQUIRED). - Les récompenses candidates sont les offres actives, dont le stock restant est positif et dont le partenaire est actif. S'il n'y en a aucune :
404 Aucune récompense n'est disponible pour le moment dans les tickets à gratter. - Un solde inférieur au prix renvoie
402 EtherPoints insuffisants pour ce ticket à gratter (30 disponibles, 50 requis)., avecerrors: { "code": "INSUFFICIENT_POINTS", "balance", "cost" }. - Le tirage se fait par
crypto.randomIntparmi les offres dont le coût en points est au moins égal au prix du ticket, ou parmi toutes les candidates si aucune n'atteint ce prix. Chaque offre a la même chance, quel que soit son stock. - Le code est obtenu comme pour
claim: lot du partenaire, sinon code généré. Il est pris dans le lot avant la transaction : si la transaction échoue, ce code est perdu. - Une transaction débite le prix seulement si le solde le couvre encore, réserve une unité de stock, crée le
ClaimedRewardet écrit la lignePointTransaction(REWARD_CLAIM, descriptionTicket à gratter <offre> : <titre> (<partenaire>)). Un solde devenu insuffisant entre-temps renvoie 402 ; un stock épuisé entre-temps renvoie409 Cette récompense vient d’être épuisée, veuillez réessayer.et annule le débit. - Le QR code et l'email
reward-claimsont produits comme pourclaim, puis la métriquebusiness_events_total{event="scratch_ticket_bought"}est incrémentée.
La réponse, en 201, contient offer, l'objet claim (même forme que pour claim, avec scratchCardReady: true), remainingStock, pointsSpent et le nouveau solde, présent deux fois sous newBalance et newPointsBalance.
À savoir
Le prix payé ne dépend pas de la valeur de la récompense tirée : une offre qui coûte 300 points dans le catalogue peut sortir d'un ticket violet à 50 points. Le caractère aléatoire de ce mécanisme payant est discuté dans Veille juridique.
GET /marketplace/my-codes
Renvoie codes, la liste des récompenses obtenues par le citoyen, de la plus récente à la plus ancienne : identifiant, uniqueCode, isUsed, claimedAt, titre, coût, partenaire et qrCodeDataUrl. Contrairement à la réponse de claim, ce QR code encode seulement le texte du code, pas l'objet JSON. Les QR codes sont recalculés à chaque appel.
POST /marketplace/redeem
Utilisé par le commerçant, depuis l'espace partenaire, pour marquer un code comme consommé.
| Champ | Type | Règles |
|---|---|---|
uniqueCode |
chaîne | 4 à 64 caractères, espaces retirés aux extrémités, message Code promo requis |
- Le code doit exister : sinon
404 Code promo introuvable. - Un PARTNER ne peut valider que les codes des entreprises dont il est propriétaire (
Partner.ownerId). Pour un code d'une autre entreprise, la réponse est aussi404 Code promo introuvable, pour ne pas révéler l'existence du code. Un ADMIN peut valider tous les codes. - Un code déjà utilisé renvoie
400 Ce code promo a déjà été utilisé en magasin. isUsedpasse àtrue.
{
"success": true,
"message": "Code promo validé en magasin avec succès",
"data": { "redeemed": true, "uniqueCode": "BOUL-2026-0007", "rewardTitle": "-15% sur les viennoiseries et pains spéciaux", "partnerName": "La Boulangerie du Port d'EtherBay", "usedAt": "2026-11-12T09:15:30.441Z" }
}À savoir
La date de validation n'est pas stockée : usedAt est l'heure de la réponse. La mise à jour n'est pas conditionnée à isUsed = false, si bien que deux validations simultanées du même code peuvent toutes deux réussir. Le code est comparé tel quel : la casse compte.
Erreurs du module
| Statut | Message | Route |
|---|---|---|
| 400 | Erreur de validation des données |
claim, scratch, redeem |
| 400 | Stock épuisé pour cette récompense partenaire |
claim |
| 400 | EtherPoints insuffisants (<solde> disponibles, <coût> requis) |
claim |
| 400 | Ce code promo a déjà été utilisé en magasin |
redeem |
| 402 | EtherPoints insuffisants pour ce ticket à gratter ... |
scratch |
| 403 | Vérifiez votre âge pour jouer. |
claim, scratch |
| 403 | Accès refusé (RBAC) : Rôle PARTNER ou ADMIN requis pour cette ressource |
redeem par un USER |
| 404 | Récompense introuvable dans le catalogue |
rewards/:id |
| 404 | Cette offre n'est plus disponible |
claim |
| 404 | Code promo introuvable |
redeem |
| 404 | Aucune récompense n'est disponible pour le moment dans les tickets à gratter. |
scratch |
| 409 | L'opération viole une règle d'intégrité des données (solde ou stock insuffisant) |
claim en concurrence |
| 409 | Cette récompense vient d’être épuisée, veuillez réessayer. |
scratch en concurrence |