# Module — Promotions & remises

> Fiche de conception. Voir le [catalogue des modules](README.md), les [conventions](../notes/conventions.md) et les [décisions (ADR)](../notes/decisions.md).

## 0. État d'implémentation — incrément 1 (2026-07-24) 🚧

**Remise sur la vente** livrée et testée, intégrée au panier.

| Livré ✅ | Cible non encore faite ⬜ |
|---|---|
| `Sale.discountCents` + `POST /sales` accepte `discount: { type: 'percent'\|'amount', value }` | remises par ligne, coupons/codes promo, règles de prix (`PriceRule`) automatiques |
| **Autorité serveur** : recalcul du net TTC + **allocation HT/TVA au prorata**, remise **bornée** à [0, brut] | promotions programmées (périodes), quantités (X achetés = Y offerts) |
| Remise reflétée dans le résumé reporting (`discountCents`) et le ticket | segments clients, fidélité → remise |
| PWA : **bouton Remise** dans le panier (pourcentage ou montant), net affiché, remise sur le ticket (Journal) | gestion des règles en UI |
| Tests `SaleApiTest` (remise % recalculée, montant plafonné) | tests coupons/règles |

Fichiers : `Sale.discountCents`, `SaleController::computeDiscount()`,
`yima-shop/src/app/features/sales/` (modal remise + net).

## Objectif

Définir des **règles de prix** (remises %, montant fixe) et des **coupons** applicables au POS et, à terme, au site e-commerce, avec périodes de validité et conditions (produit, catégorie, panier).

- **Manager** : crée et pilote les promotions et coupons de sa boutique, définit conditions/priorités, publie vers le site.
- **Employé** : bénéficie des promotions **appliquées automatiquement** à l'encaissement et saisit un code coupon présenté par le client (aucune gestion).

## Périmètre

**In (v1)**
- `Promotion` / `PriceRule` : remise **pourcentage** ou **montant fixe** (centimes XOF).
- Conditions : cible **produit**, **catégorie** ou **panier** (montant minimum).
- Périodes de validité (début/fin), activation/désactivation.
- **Coupons** : codes saisissables au POS (usage unique ou multiple, limite).
- Application automatique au POS ([POS / Ventes](pos-ventes.md)) ; règles de **cumul/priorité**.

**Out (v1)**
- Remises « X achetés = Y offerts », paliers dégressifs complexes, cartes cadeaux.
- Ciblage client/segment (dépend du [CRM](clients-crm.md)) → itération.
- Coupons personnalisés par client.

## Entités

Rattachées à `Company` ; `TimestampableEntity` ; **soft-delete Gedmo autorisé** (promotion effaçable) + index `idx_<table>_deleted` ; **UUID public** (exposée API).

- **`Promotion`**
  - `id`, `uuid`, `company` (FK), `name`, `type` (`percentage` | `fixed_amount`), `value` (int : pourcentage en points, ou **centimes XOF**), `scope` (`product` | `category` | `cart`), `active` (bool).
  - `startsAt?`, `endsAt?`, `priority` (int, défaut 0), `stackable` (bool, défaut false), `publishable` (bool, défaut false).
  - `minCartCents?` (condition panier), `deletedAt`, timestamps.
- **`PromotionTarget`** (cibles produit/catégorie) : `promotion` (FK), `product?` / `category?` (FK catalogue).
- **`Coupon`** — *effaçable* (soft-delete + index) : `id`, `uuid`, `company` (FK), `promotion` (FK), `code` (unique par `Company`), `maxRedemptions?`, `redemptionsCount` (int, défaut 0), `active`, timestamps.

> Note : `PriceRule` = la logique de remise portée par `Promotion` (type/valeur/conditions) ; pas d'entité séparée en v1. L'**application** d'une remise sur une vente est tracée côté `SaleLine`/`Sale` (append-only) par le module ventes, pas ici.

Index : `Promotion(company, active, startsAt, endsAt)`, `Coupon(company, code)` unique.

## Permissions

| Code | Description | Rôles par défaut |
|---|---|---|
| `promotion.view` | Consulter promotions et coupons | Manager, Employé |
| `promotion.manage` | Créer/éditer/supprimer, publier | Manager |

Gating : `denyAccessUnlessGranted('promotion.manage', $company)` / `is_granted('promotion.view', company)`. Scope `Company` **imposé serveur**. La **saisie d'un coupon** au POS relève de `pos.sell` (module ventes), pas d'une permission de gestion.

## API

Base `/api/v1`, enveloppe `ApiResponseFormatter`.

| Méthode | Route | Corps | Permission |
|---|---|---|---|
| GET | `/api/v1/promotions?filter[active]=&page=&limit=` | — | `promotion.view` |
| POST | `/api/v1/promotions` | `{ name, type, value, scope, targets?, startsAt?, endsAt?, priority?, stackable?, minCartCents?, publishable? }` | `promotion.manage` |
| GET | `/api/v1/promotions/{uuid}` | — | `promotion.view` |
| PUT | `/api/v1/promotions/{uuid}` | `{ ...champs éditables }` | `promotion.manage` |
| DELETE | `/api/v1/promotions/{uuid}` | — | `promotion.manage` |
| GET | `/api/v1/coupons?filter[q]=&page=&limit=` | — | `promotion.view` |
| POST | `/api/v1/coupons` | `{ promotionUuid, code, maxRedemptions? }` | `promotion.manage` |
| DELETE | `/api/v1/coupons/{uuid}` | — | `promotion.manage` |
| GET | `/api/v1/promotions/applicable?cart=...&coupon=` | contexte panier | `promotion.view` |

- `/promotions/applicable` : renvoie les remises applicables + montants (centimes XOF) pour un panier donné (utilisé par le POS). Lecture ; le calcul de référence reste **serveur**.
- Validation → 422 (ex. `value` %, code coupon dupliqué → 409).

## Écrans

**Web (Twig, espace Manager)**
- Liste promotions (statut, période, priorité, publiable).
- Formulaire promotion : type, valeur, portée (produit/catégorie/panier), période, priorité, cumul, publication e-commerce.
- Gestion coupons : génération de code, limite d'usage, désactivation.

**PWA (encaissement)**
- Application **automatique** des promotions éligibles au panier (affichage de la remise ligne/panier).
- Champ **coupon** : saisie du code, validation serveur, message clair si invalide/expiré/épuisé.

## Règles métier

- Promotion/coupon appartiennent à **une** boutique ; jamais partagés.
- **Validité** : appliquée seulement si `active` et dans `[startsAt, endsAt]` (bornes incluses, sinon toujours valide si non bornée).
- **Cumul / priorité** :
  - Par défaut `stackable=false` : une seule remise s'applique par ligne/panier.
  - Si plusieurs éligibles, on retient la **priorité** la plus élevée ; à priorité égale, la plus avantageuse pour le client.
  - Les promotions `stackable=true` peuvent se combiner entre elles, dans l'ordre de priorité, la remise finale ne pouvant rendre un prix **négatif** (plancher à 0).
- **Coupons** : usage limité par `maxRedemptions` ; `redemptionsCount` incrémenté à la **validation** de la vente. Ventes append-only ([ADR-006](../notes/decisions.md)) : un avoir/remboursement **décrémente** le compteur via écriture compensatoire, jamais par réécriture.
- Remise `fixed_amount` en **centimes XOF** ; `percentage` appliqué puis arrondi à l'entier centime.
- La remise réellement appliquée est **figée sur la vente** au moment de l'encaissement (le prix vendu ne change pas si la promo évolue ensuite).

## Projection e-commerce

**Répliqué de façon sélective.** Les promotions marquées `publishable=true` sont **projetées** vers la base client pour que **prix/remises soient visibles** sur le site ([ADR-001](../notes/decisions.md), [projection e-commerce](ecommerce-projection.md)).

- Sont exposés : type, valeur, portée, période, conditions publiques. **Non exposés** : coupons internes non destinés au web (sauf coupons e-commerce dédiés, évolution).
- La base client reste **non autoritaire** : elle affiche la promo, le calcul final de référence demeure au centre.
- Périmètre exact des colonnes projetées : [ADR-OPEN-B](../notes/decisions.md).

## Tests

- **Unitaires** : éligibilité (période, portée, `minCartCents`) ; calcul de remise % et fixe (arrondi, plancher 0) ; sélection cumul/priorité ; incrément/décrément coupon.
- **Fonctionnels** : CRUD promotions/coupons ; `/promotions/applicable` sur divers paniers ; coupon dupliqué → 409, expiré/épuisé → message ; application au POS fige la remise.
- **Accès** : `promotion.manage` refusée à l'Employé (403) ; `promotion.view` autorisée ; **isolation boutique** (promo d'une autre `Company` invisible/inapplicable) ; projection ne publie que `publishable=true`.

## Dépendances

- Socle : RBAC/permissions, `EntityManagerService`, `FileLogger`, `ApiResponseFormatter`, scope `Company`.
- [Catalogue produits](catalogue-produits.md) : produits/catégories ciblés.
- [POS / Ventes](pos-ventes.md) : application au panier, figeage de la remise sur la vente, saisie coupon.
- [Caisse & paiements](caisse-paiements.md) : montants encaissés après remise.
- [Projection e-commerce](ecommerce-projection.md) : réplication des promotions publiables.

## Notes / ADR liés

- [ADR-004](../notes/decisions.md) — Montants en centimes XOF (remises fixes).
- [ADR-006](../notes/decisions.md) — Append-only (compteurs coupons via compensation).
- [ADR-001](../notes/decisions.md) — Projection sélective vers la base client.
- [ADR-OPEN-B](../notes/decisions.md) — Périmètre des colonnes projetées.
- Voir aussi : [modèle de données](../02-modele-de-donnees.md), [sécurité](../03-securite.md), [API](../04-api.md), [README projet](../README.md).
