Skip to content

Coupons

Système de codes promotionnels saisis manuellement par l’administration dans la table hxa.coupon. Aucune API d’administration ne les expose — Hydrogen se contente de les consommer quand un utilisateur en fournit un.

  • hxa.coupon (existante, non gérée par Hydrogen) :
    • id VARCHAR(32) — code lisible saisi par l’admin (PK).
    • user_limit INT UNSIGNED — plafond d’utilisations. 0 = illimité.
    • start DATETIME NULL, end DATETIME NULL — fenêtre de validité optionnelle.
    • user_count INT UNSIGNED — compteur courant, bumpé atomiquement par Hydrogen à chaque rédemption.
  • hxa.reward (existante) : catalogue des types de récompense. À ce jour : experience, point. Seul experience est actuellement appliqué ; point est silencieusement ignoré jusqu’à ce que le domaine “points” existe.
  • hxa.coupon_reward (existante) : paramétrage (coupon_id, reward_id, value, badge_id?). Plusieurs rewards par coupon possibles. badge_id est lu mais ignoré côté Hydrogen.
  • hxa.coupon_user (créée par Hydrogen — migration 2026_06_10_160000_create_coupon_user_table.sql) : registre append-only des rédemptions, PK composite (coupon_id, user_id) qui garantit “1 fois par user, à jamais”.

Dans une transaction unique (CouponRedemptionService) :

  1. Lookup du coupon par id. Inexistant → coupon.notFound.
  2. Vérification de start (si renseigné) et end (si renseigné) contre now. Hors fenêtre → coupon.notStarted ou coupon.expired.
  3. INSERT dans coupon_user. Collision PK → coupon.alreadyRedeemed (cet utilisateur a déjà consommé ce coupon).
  4. UPDATE coupon SET user_count = user_count + 1 WHERE id = ? AND (user_limit = 0 OR user_count < user_limit). Si rowCount = 0coupon.userLimitReached. Le check est atomique et race-safe.
  5. Lecture de coupon_reward (joint avec reward) et application :
    • reward.type = 'experience' avec value > 0ExperienceService::award (rejoint la transaction en cours, pas de double-XP possible).
    • reward.type = 'point' → no-op silencieux.
    • Type inconnu → no-op silencieux (forward-compat : un admin peut ajouter un type avant le déploiement du code correspondant).

Toute exception en cours de route déclenche un rollBack complet : pas de demi-rédemption.

CodeSens
coupon.notFoundLe couponId ne correspond à aucune ligne de coupon.
coupon.notStartedstart est dans le futur.
coupon.expiredend est dans le passé.
coupon.userLimitReacheduser_count a atteint user_limit (cap strict).
coupon.alreadyRedeemedCet utilisateur a déjà utilisé ce coupon.
coupon.applyFailedErreur technique inattendue (rollback) — à journaliser côté ops.

L’unique point d’entrée actuel est le champ optionnel couponId de POST /api/auth/register. Toute défaillance est non bloquante : la réponse 201 contient meta.coupon = { applied, couponId, reason?, message?, rewards? }. Le client est libre d’afficher un message d’info ou de proposer une nouvelle saisie post-inscription (endpoint dédié non implémenté à ce jour).