Gestion des Pays (Countries)
Ce document présente le module de gestion des pays (apps/api/src/countries) : la donnée de référence qui pilote la devise, la TVA et les frais de port appliqués à chaque commande, ainsi que la disponibilité des moyens de paiement et du catalogue par pays.
1. Vue d’ensemble
Country est une table de configuration transverse dont dépendent quatre autres modèles : User (pays de l’acheteur/vendeur à l’inscription), Address (pays de livraison, qui détermine la TVA et les frais de port d’une commande), Product (pays du vendeur, dénormalisé pour filtrer le catalogue) et PaymentMethod (restriction géographique d’un moyen de paiement).
Le module reste volontairement simple : un pays n’est jamais supprimé, seulement désactivé (isActive: false), car trop d’autres tables le référencent pour garantir une suppression sans orphelins. C’est la même logique que pour les catégories de produits, en plus strict (pas de DELETE du tout ici, contrairement à DELETE /categories/:id).
2. Modèle de Données et Relations
Country
| Champ | Type | Description |
|---|---|---|
id | String (UUID) | Identifiant unique. |
name | String (unique) | Nom du pays (ex: Cameroun). |
isoCode | String (unique) | Code ISO 2 lettres (ex: CM), stocké en majuscules. |
currencyCode | String | Code devise (ex: XAF), stocké en majuscules. |
vatRate | Float | Taux de TVA en pourcentage (ex: 19.25 = 19,25 %). Défaut 0. |
shippingFee | Decimal(10,2) | Frais de port forfaitaires appliqués à toute commande livrée dans ce pays. Défaut 0. |
isActive | Boolean | Pays désactivable plutôt que supprimable (défaut true). |
Modèles qui référencent Country via countryId
-
User.countryId(nullable) — pays choisi par l’utilisateur à l’inscription (acheteur ou vendeur). Sert à pré-remplir/filtrer côté vendeur. -
Address.countryId(nullable) +Address.country(texte libre, obligatoire) :[!IMPORTANT]
Address.countryest un champ texte libre historique, conservé tel quel pour compatibilité avec les adresses créées avant l’introduction du modèleCountry. C’estAddress.countryIdqui fait foi pour tout calcul (TVA, frais de port, filtrage) — voir §4.countryIdpeut resternullpour une adresse ancienne jamais rattachée à unCountry. -
Product.countryId(nullable) — dénormalisé depuis le pays du vendeur au moment de la création du produit, pour filtrer le catalogue par pays sans jointure supplémentaire surUser.nullsignifie un produit visible dans tous les pays (cas des vendeurs créés avant l’introduction du modèleCountry). -
PaymentMethod.countryId(nullable) :null= moyen de paiement disponible dans tous les pays (ex: CinetPay, qui couvre plusieurs pays) ; renseigné = réservé aux acheteurs de ce pays précis (ex: un futur “Orange Money Cameroun”).
3. Rôles et Autorisations (RBAC)
| Endpoint | Méthode | Rôle Requis | Description / Règle métier |
|---|---|---|---|
/countries | POST | ADMIN | Créer un pays. 409 Conflict si le name ou l’isoCode existe déjà. |
/countries | GET | Public | Lister les pays actifs (isActive: true). Le paramètre ?includeInactive=true renvoie aussi les pays désactivés — sans authentification, car aucune donnée sensible n’est exposée par un pays inactif. |
/countries/:idOrIsoCode | GET | Public | Obtenir un pays par son id ou son code ISO (insensible à la casse). |
/countries/:id | PATCH | ADMIN | Modifier un pays (mise à jour partielle) ; sert aussi à l’activer/désactiver. |
[!NOTE] Il n’existe aucun endpoint
DELETE /countries/:id. Contrairement àProductCategory(qui peut être supprimée si elle est vide), unCountryest toujours désactivé (isActive: false) plutôt que supprimé, car il est référencé parUser,Address,ProductetPaymentMethod— une suppression casserait ces relations historiques.
Le dashboard admin (apps/dashboard/app/(dashboard)/countries/) matérialise cette règle : la page liste ne propose qu’un bouton Activer/Désactiver (ToggleCountryButton.tsx) et Modifier, jamais de suppression.
4. Impact sur les Commandes (VAT et Frais de Port)
Le calcul du total d’une commande est effectué dans orders.service.ts à partir du pays de l’adresse de livraison choisie :
Concrètement (extrait de orders.service.ts) :
const shippingCountry = address.countryId
? await tx.country.findUnique({ where: { id: address.countryId } })
: null;
const shippingFee = shippingCountry ? Number(shippingCountry.shippingFee) : 0;
const vatAmount = shippingCountry
? totalAmount * (shippingCountry.vatRate / 100)
: 0;
const grandTotal = totalAmount + shippingFee + vatAmount;[!IMPORTANT] Les montants sont figés (« lockés ») sur la commande au moment de sa création.
Order.currencyCode,Order.shippingFeeetOrder.vatAmountsont des colonnes propres àOrder, distinctes deCountry. Si un administrateur modifie ensuite levatRateou leshippingFeed’un pays, les commandes déjà passées ne sont jamais recalculées — seules les commandes futures utilisent le nouveau taux. C’est la même philosophie de verrouillage que pourProduct.sponsorshipPrice(voir sponsorship.md).
[!NOTE] Si
Address.countryIdestnull(adresse historique jamais rattachée à unCountry), la commande n’est pas bloquée : TVA et frais de port valent simplement0, plutôt que de lever une erreur.
5. Rôle dans l’inscription (Store)
À l’inscription sur le Store (apps/store/app/auth/page.tsx + actions.ts), l’acheteur choisit son pays dans une liste déroulante alimentée par GET /countries (pays actifs uniquement) :
- Le composant précharge la liste au montage et présélectionne par défaut le pays dont
isoCode === 'CM'(Cameroun) s’il existe. - Le champ
countryIddu formulaire est transmis àPOST /auth/register, qui le relie à l’utilisateur créé (country: { connect: { id: dto.countryId } }dans auth.service.ts). - Ce pays devient ensuite le pays par défaut de la première adresse de livraison de l’acheteur, ce qui détermine à son tour la devise/TVA/frais de port de ses futures commandes (voir §4).
[!NOTE] Le champ
countryIdest marquérequiredcôté formulaire d’inscription (<select ... required>), mais resteoptionalcôté DTO API (CreateUserDto.countryId?). Un compte peut donc exister sans pays rattaché si l’inscription passe par un autre canal que ce formulaire.
6. Interface d’Administration (Dashboard)
La page apps/dashboard/app/(dashboard)/countries/page.tsx (réservée au rôle ADMIN, avec redirection sinon) affiche un tableau des pays (nom, code ISO, devise, TVA, frais de port, statut) avec, pour chaque ligne :
- Un lien Modifier vers
/countries/[id]/edit, qui réutilise le composant partagéCountryForm.tsx(dansapps/dashboard/app/(dashboard)/countries/) (name,isoCode,currencyCode,vatRate,shippingFee,isActive). - Le bouton
ToggleCountryButton.tsx(même dossier), qui appellePATCH /countries/:id { isActive: !isActive }sans repasser par le formulaire complet.
La liste (GET /countries?includeInactive=true) inclut volontairement les pays désactivés côté admin, pour permettre de les réactiver — alors que le Store et l’inscription n’appellent jamais ce paramètre et ne voient donc que les pays actifs.
7. Données de référence (seed)
Le script seed.ts initialise un unique pays par défaut via upsert (idempotent, ne duplique pas à chaque exécution) :
const cameroun = await prisma.country.upsert({
where: { isoCode: 'CM' },
update: { name: 'Cameroun', currencyCode: 'XAF', vatRate: 19.25, shippingFee: 0, isActive: true },
create: { name: 'Cameroun', isoCode: 'CM', currencyCode: 'XAF', vatRate: 19.25, shippingFee: 0, isActive: true },
});Ce pays sert ensuite d’ancrage pour deux moyens de paiement seedés en isActive: false (MTN Mobile Money, Orange Money) avec countryId: cameroun.id — restreints au Cameroun le jour où ils seraient réactivés, contrairement à CinetPay et au paiement à la livraison (cash), seedés avec countryId: null (disponibles partout).
8. Guide des Endpoints et Exemples
Création d’un Pays (POST /countries)
- Accès : Admin uniquement (Header
Authorization: Bearer <token_admin>). - Requête :
{ "name": "Côte d'Ivoire", "isoCode": "ci", "currencyCode": "xof", "vatRate": 18, "shippingFee": 1500, "isActive": true } - Réponse (
201 Created) —isoCodeetcurrencyCodenormalisés en majuscules :{ "id": "6f1a2b3c-4d5e-6f7a-8b9c-0d1e2f3a4b5c", "name": "Côte d'Ivoire", "isoCode": "CI", "currencyCode": "XOF", "vatRate": 18, "shippingFee": "1500.00", "isActive": true, "createdAt": "2026-06-23T10:00:00.000Z", "updatedAt": "2026-06-23T10:00:00.000Z" } - Erreur (
409 Conflict) si le nom ou le code ISO existent déjà :{ "statusCode": 409, "message": "Un pays avec ce nom ou ce code ISO existe déjà." }
Lecture publique (GET /countries)
- Accès : Public.
GET /countries→ uniquement les paysisActive: true, triés par nom.GET /countries?includeInactive=true→ tous les pays, y compris désactivés.
Désactivation (PATCH /countries/:id)
- Accès : Admin uniquement.
- Requête (mise à jour partielle) :
{ "isActive": false } - Réponse (
200 OK) :{ "id": "6f1a2b3c-4d5e-6f7a-8b9c-0d1e2f3a4b5c", "name": "Côte d'Ivoire", "isoCode": "CI", "currencyCode": "XOF", "vatRate": 18, "shippingFee": "1500.00", "isActive": false, "createdAt": "2026-06-23T10:00:00.000Z", "updatedAt": "2026-06-24T08:30:00.000Z" }
9. Bonnes Pratiques de Développement
- Ne jamais recalculer une commande passée :
Orderporte ses propres colonnescurrencyCode/shippingFee/vatAmount, jamais une lecture en direct deCountry. Toute évolution du module doit préserver ce verrouillage. - Distinguer
Address.country(texte) etAddress.countryId(relation) : n’utilisez jamais le champ texte pour un calcul métier ou un filtre — il n’est conservé que pour l’affichage rétro-compatible des adresses anciennes. - Toujours normaliser
isoCode/currencyCodeen majuscules côté service (toUpperCase()) avant écriture et avant comparaison (findOne), pour éviter les doublonscm/CM.