Skip to content

Système de fidélité par points

Guide d'implémentation d'un système de fidélité avec attribution, consommation, gestion du solde et ajustement admin de points. Explique les transactions idempotentes (reference_id), la protection contre le solde négatif et le RBAC admin.

Vue d'ensemble

  • Les utilisateurs peuvent gagner (earn) et dépenser (spend) des points
  • Seul l'admin peut ajuster directement les points (adjust: add/subtract)
  • Le solde est géré en accumulant balance_after des transactions
  • Transactions idempotentes via reference_id (prévention de double attribution/double débit)
  • Prévention de l'attribution frauduleuse en masse par limite de montant (MAX_EARN_PER_TRANSACTION) plutôt que max_uses

Endpoints

MéthodeCheminDescriptionPermissions
GET/users/{userId}/pointsObtenir le soldeSoi-même ou admin
GET/users/{userId}/points/historyObtenir l'historiqueSoi-même ou admin
POST/users/{userId}/points/earnAttribuer des pointsSoi-même ou admin
POST/users/{userId}/points/spendConsommer des pointsSoi-même ou admin
POST/users/{userId}/points/adjustAjuster les pointsAdmin uniquement

Conception de la base de données

sql
CREATE TABLE point_transactions (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    user_id INTEGER NOT NULL,
    type TEXT NOT NULL CHECK (type IN ('earn', 'spend', 'adjust', 'expire')),
    amount INTEGER NOT NULL CHECK (amount > 0),
    balance_after INTEGER NOT NULL CHECK (balance_after >= 0),
    description TEXT NOT NULL,
    reference_id TEXT,
    created_at TEXT NOT NULL,
    FOREIGN KEY (user_id) REFERENCES users(id)
);

La contrainte CHECK balance_after >= 0 prévient un solde négatif au niveau DB. La contrainte CHECK amount > 0 rejette les transactions nulles ou inférieures au niveau DB.

Calcul du solde

php
public function getBalance(int $userId): int
{
    $row = $this->executor->fetchOne(
        'SELECT balance_after FROM point_transactions WHERE user_id = ? ORDER BY id DESC LIMIT 1',
        [$userId]
    );
    return $row !== null ? (int) $row['balance_after'] : 0;
}

balance_after de la dernière transaction est le solde actuel. Pas de table séparée pour le solde, donc l'historique des transactions est la source unique de vérité (SSOT).

Transactions idempotentes (reference_id)

php
if ($referenceId !== null) {
    $existing = $this->repository->findByReferenceId($targetUserId, $referenceId);
    if ($existing !== null) {
        return $this->responseFactory->create($this->formatTransaction($existing), 200);
    }
}
// ... traitement de la nouvelle transaction

Si reference_id existe déjà, retourner la transaction existante (200). Prévient le double crédit et le double débit.

Vérification du solde lors de la consommation

php
$balance = $this->repository->getBalance($targetUserId);
if ($balance < $amount) {
    return $this->responseFactory->create([
        'error' => 'insufficient points',
        'balance' => $balance,
        'required' => $amount,
    ], 422);
}
$balanceAfter = $balance - $amount;  // toujours >= 0

Vérification du solde au niveau applicatif, défense en profondeur avec la contrainte CHECK en DB.

Ajustement admin (adjust)

php
// adjust_type : 'add' (par défaut) ou 'subtract'
if ($adjustType === 'subtract') {
    if ($balance < $amount) { return 422 'insufficient points for adjustment'; }
    $balanceAfter = $balance - $amount;
} else {
    $balanceAfter = $balance + $amount;
}
$this->repository->addTransaction($userId, 'adjust', $amount, $balanceAfter, $description, null, $now);

Contrôle de limite (MAX_EARN_PER_TRANSACTION)

php
private const int MAX_EARN_PER_TRANSACTION = 10000;

if ($amount > self::MAX_EARN_PER_TRANSACTION) {
    return $this->responseFactory->create([
        'error' => 'amount exceeds maximum per transaction',
        'max' => self::MAX_EARN_PER_TRANSACTION,
    ], 422);
}

Prévient les attaques d'attribution frauduleuse de nombreux points en une seule transaction.

Contrôle d'accès

Le solde et l'historique ne peuvent être consultés que par le propriétaire. L'admin peut consulter et opérer sur tous les utilisateurs.

php
if ($targetUserId !== $actorId && !$this->isAdmin($request)) {
    return $this->responseFactory->create(['error' => 'access denied'], 403);
}

earn/spend n'opèrent que sur ses propres points (impossible d'augmenter les points d'un autre).

Exemples de réponse

POST /users/2/points/earn

json
{
  "id": 1,
  "user_id": 2,
  "type": "earn",
  "amount": 100,
  "balance_after": 100,
  "description": "Purchase reward",
  "reference_id": "order-123",
  "created_at": "2026-05-21T..."
}

GET /users/2/points/history

json
{
  "user_id": 2,
  "balance": 70,
  "transactions": [
    {"id": 2, "type": "spend", "amount": 30, "balance_after": 70, ...},
    {"id": 1, "type": "earn", "amount": 100, "balance_after": 100, ...}
  ]
}

Publié sous licence MIT.