---
title: "Coupons"
description: "Système de codes promotionnels saisis manuellement par l'administration dans la table hxa.coupon. Aucune API d'administration ne les expose — Hydrogen se c…"
---

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.

## Schéma des tables

- `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".

## Règles de rédemption

Dans une **transaction unique** ([CouponRedemptionService](../src/Domain/Coupon/CouponRedemptionService.php)) :

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 = 0` → `coupon.userLimitReached`. Le check est atomique et race-safe.
5. Lecture de `coupon_reward` (joint avec `reward`) et application :
   - `reward.type = 'experience'` avec `value > 0` → [ExperienceService::award](../src/Domain/User/ExperienceService.php) (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.

## Codes de résultat

| Code                        | Sens                                                              |
| --------------------------- | ----------------------------------------------------------------- |
| `coupon.notFound`           | Le `couponId` ne correspond à aucune ligne de `coupon`.           |
| `coupon.notStarted`         | `start` est dans le futur.                                        |
| `coupon.expired`            | `end` est dans le passé.                                          |
| `coupon.userLimitReached`   | `user_count` a atteint `user_limit` (cap strict).                 |
| `coupon.alreadyRedeemed`    | Cet utilisateur a déjà utilisé ce coupon.                         |
| `coupon.applyFailed`        | Erreur technique inattendue (rollback) — à journaliser côté ops.  |

## Utilisation à l'inscription

L'unique point d'entrée actuel est le champ optionnel `couponId` de [POST /api/auth/register](#post-apiauthregister). 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).

---
