Skip to content

How-to : API de rachat de coupon / code de réduction ​

Ce guide montre comment construire un système de rachat de coupons avec des limites d'usage et d'expiration en utilisant NENE2. Pattern démontré par le field trial couponlog (FT218).

Fonctionnalités ​

  • Créer des codes coupon avec un montant de réduction, une limite d'usage et une expiration (admin uniquement)
  • Génération automatique optionnelle de codes aléatoires (bin2hex(random_bytes(6)))
  • Un rachat par utilisateur par coupon (UNIQUE(coupon_id, user_id))
  • Application de la limite d'usage (max_uses)
  • Vérification d'expiration contre l'heure UTC actuelle
  • Listing des rachats réservé aux admins

Schéma ​

sql
CREATE TABLE IF NOT EXISTS coupons (
    id          INTEGER PRIMARY KEY AUTOINCREMENT,
    code        TEXT    NOT NULL UNIQUE,
    discount    INTEGER NOT NULL,
    max_uses    INTEGER NOT NULL DEFAULT 1,
    used_count  INTEGER NOT NULL DEFAULT 0,
    expires_at  TEXT    NOT NULL,
    created_at  TEXT    NOT NULL
);

CREATE TABLE IF NOT EXISTS redemptions (
    id          INTEGER PRIMARY KEY AUTOINCREMENT,
    coupon_id   INTEGER NOT NULL,
    user_id     INTEGER NOT NULL,
    redeemed_at TEXT    NOT NULL,
    UNIQUE (coupon_id, user_id),
    FOREIGN KEY (coupon_id) REFERENCES coupons(id) ON DELETE CASCADE
);

Endpoints ​

MéthodeCheminAuthDescription
POST/couponsAdminCréer un coupon
GET/coupons/{code}PublicObtenir les infos du coupon
POST/coupons/{code}/redeemUtilisateurRacheter le coupon
GET/coupons/{code}/redemptionsAdminLister les rachats

Validation du code ​

Les codes coupon utilisent un pattern strict pour éviter l'injection :

php
/** Code coupon : alphanumérique majuscule, 4–32 caractères */
private const string CODE_PATTERN = '/\A[A-Z0-9]{4,32}\z/';

Le paramètre de chemin est normalisé en majuscules avant validation :

php
private function pathCode(ServerRequestInterface $req): ?string
{
    $params = $req->getAttribute(Router::PARAMETERS_ATTRIBUTE, []);
    $code   = strtoupper(trim($params['code'] ?? ''));
    if (!preg_match(self::CODE_PATTERN, $code)) {
        return null; // → 404
    }
    return $code;
}

Logique de rachat ​

php
/** @return 'ok'|'not_found'|'expired'|'exhausted'|'already_redeemed' */
public function redeem(string $code, int $userId): string
{
    $coupon = $this->findByCode($code);
    if ($coupon === null) return 'not_found';

    // Vérifier l'expiration
    if ($coupon['expires_at'] < $this->now()) return 'expired';

    // Vérifier la limite d'usage
    if ((int) $coupon['used_count'] >= (int) $coupon['max_uses']) return 'exhausted';

    // Vérifier la limite par utilisateur
    $stmt = $this->pdo->prepare(
        'SELECT id FROM redemptions WHERE coupon_id = :cid AND user_id = :uid'
    );
    if ($stmt->fetch() !== false) return 'already_redeemed';

    // Enregistrer + incrémenter le compteur
    $this->pdo->prepare('INSERT INTO redemptions ...')->execute([...]);
    $this->pdo->prepare('UPDATE coupons SET used_count = used_count + 1 WHERE id = :id')
        ->execute([':id' => $coupon['id']]);

    return 'ok';
}

Le gestionnaire de route utilise une expression match pour un branchement propre :

php
return match ($result) {
    'not_found'        => $this->problem(404, 'not-found', 'Coupon not found.'),
    'expired'          => $this->problem(409, 'conflict', 'Coupon has expired.'),
    'exhausted'        => $this->problem(409, 'conflict', 'Coupon usage limit reached.'),
    'already_redeemed' => $this->problem(409, 'conflict', 'You have already redeemed this coupon.'),
    default            => $this->json(['message' => 'Coupon redeemed successfully.']),
};

Codes générés automatiquement ​

Quand aucun code n'est fourni dans le corps de la requête, un code est généré :

php
$code = strtoupper(trim((string) ($body['code'] ?? '')));
if ($code === '') {
    $code = strtoupper(bin2hex(random_bytes(6))); // 12 caractères hex majuscules
}

Patterns de sécurité ​

  • Admin fail-closed : if ($this->adminKey === '') return false; avant hash_equals()
  • Pattern de code : équivalent ctype_digit() pour les codes — regex /\A[A-Z0-9]{4,32}\z/
  • is_int() : vérification de type stricte pour discount et max_uses — rejette les floats
  • Expiration ISO 8601 : validation par regex + comparaison lexicographique (chaînes UTC)
  • Incrément atomique : UPDATE SET used_count = used_count + 1 empêche les race conditions
  • Contrainte UNIQUE : filet de sécurité au niveau de la base de données contre les doublons

Publié sous licence MIT.