Skip to Content
Gestion des Pays (Countries)

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

ChampTypeDescription
idString (UUID)Identifiant unique.
nameString (unique)Nom du pays (ex: Cameroun).
isoCodeString (unique)Code ISO 2 lettres (ex: CM), stocké en majuscules.
currencyCodeStringCode devise (ex: XAF), stocké en majuscules.
vatRateFloatTaux de TVA en pourcentage (ex: 19.25 = 19,25 %). Défaut 0.
shippingFeeDecimal(10,2)Frais de port forfaitaires appliqués à toute commande livrée dans ce pays. Défaut 0.
isActiveBooleanPays 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.country est un champ texte libre historique, conservé tel quel pour compatibilité avec les adresses créées avant l’introduction du modèle Country. C’est Address.countryId qui fait foi pour tout calcul (TVA, frais de port, filtrage) — voir §4. countryId peut rester null pour une adresse ancienne jamais rattachée à un Country.

  • 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 sur User. null signifie un produit visible dans tous les pays (cas des vendeurs créés avant l’introduction du modèle Country).

  • 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)

EndpointMéthodeRôle RequisDescription / Règle métier
/countriesPOSTADMINCréer un pays. 409 Conflict si le name ou l’isoCode existe déjà.
/countriesGETPublicLister 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/:idOrIsoCodeGETPublicObtenir un pays par son id ou son code ISO (insensible à la casse).
/countries/:idPATCHADMINModifier 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), un Country est toujours désactivé (isActive: false) plutôt que supprimé, car il est référencé par User, Address, Product et PaymentMethod — 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.shippingFee et Order.vatAmount sont des colonnes propres à Order, distinctes de Country. Si un administrateur modifie ensuite le vatRate ou le shippingFee d’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 pour Product.sponsorshipPrice (voir sponsorship.md).

[!NOTE] Si Address.countryId est null (adresse historique jamais rattachée à un Country), la commande n’est pas bloquée : TVA et frais de port valent simplement 0, 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 countryId du 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 countryId est marqué required côté formulaire d’inscription (<select ... required>), mais reste optional cô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 (dans apps/dashboard/app/(dashboard)/countries/) (name, isoCode, currencyCode, vatRate, shippingFee, isActive).
  • Le bouton ToggleCountryButton.tsx (même dossier), qui appelle PATCH /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) — isoCode et currencyCode normalisé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 pays isActive: 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

  1. Ne jamais recalculer une commande passée : Order porte ses propres colonnes currencyCode/shippingFee/vatAmount, jamais une lecture en direct de Country. Toute évolution du module doit préserver ce verrouillage.
  2. Distinguer Address.country (texte) et Address.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.
  3. Toujours normaliser isoCode/currencyCode en majuscules côté service (toUpperCase()) avant écriture et avant comparaison (findOne), pour éviter les doublons cm / CM.
Last updated on