WordPress
10 juin 2026

Octroi de mer sur PrestaShop : industrialiser le calcul OM / OMR / TVA locale pour les expéditions DOM-TOM

Container ship with colorful stacked cargo containers sailing at sunset over calm water

Expédier depuis la métropole vers la Martinique, la Guadeloupe ou La Réunion ajoute trois lignes sur la facture : octroi de mer, octroi de mer régional, TVA locale. La croyance générale, qu’on retrouve dans à peu près tout ce qui s’écrit sur le sujet, c’est que PrestaShop ne sait pas faire et qu’il faut passer par un module.

C’est faux. PrestaShop empile plusieurs taxes depuis longtemps, et il sait le faire de deux manières différentes. Le vrai problème est ailleurs, et il est plus ennuyeux : configuré avec les réglages qui paraissent les plus naturels, le moteur natif sort des montants trop élevés d’environ 0,8 point. Sans erreur, sans log, sans rien qui alerte.

Voici ce que fait réellement le core, où se cache l’écart, comment le diagnostiquer en une requête, et à partir de quel moment un module devient légitime.

Ce que vous allez apprendre :

  • Pourquoi la configuration « intuitive » de trois taxes empilées surfacture le client
  • La ligne du core qui tronque silencieusement une pile de taxes
  • Comment rejouer la sélection de taxes du core en SQL pour une adresse donnée
  • Pourquoi les DOM ne forment pas une zone dans PrestaShop, contrairement à ce qu’on croit
  • Le point d’extension officiel pour remplacer le moteur de taxes proprement

Le symptôme : une facture juste à 0,8 % près

Personne ne conteste 77 centimes d’écart sur une commande à 100 € HT. Le client paie, le tunnel se déroule normalement de bout en bout, aucun contrôle ne se déclenche. C’est la comptabilité qui finit par tiquer, un trimestre plus tard, quand la ventilation des taxes collectées sur les DOM ne tombe pas juste.

C’est le profil de panne le plus coûteux qui soit : une erreur trop petite pour déclencher un signalement, trop constante pour passer pour du bruit, et qui grossit tant qu’elle n’est pas vue. Sur 250 000 € HT de chiffre d’affaires expédié vers les DOM, l’écart représente environ 1 900 € facturés en trop à des clients qui n’ont rien demandé.

La fausse piste : « le moteur natif ne sait pas empiler trois taxes »

C’est la première chose qu’on a vérifiée, et c’est inexact. Le calcul de taxes de PrestaShop est porté par TaxCalculator, qui accepte un tableau de taxes et une méthode de composition. Deux méthodes existent, et elles sont dans le core depuis des années :

// classes/tax/TaxCalculator.php (PrestaShop 9.0.0)
public const COMBINE_METHOD = 1;
public const ONE_AFTER_ANOTHER_METHOD = 2;

La différence entre les deux se lit directement dans getTotalRate(), ligne 105 du même fichier :

public function getTotalRate()
{
    $taxes = 0;
    if ($this->computation_method == TaxCalculator::ONE_AFTER_ANOTHER_METHOD) {
        $taxes = 1;
        foreach ($this->taxes as $tax) {
            $taxes *= (1 + (abs($tax->rate) / 100));
        }
        $taxes = $taxes - 1;
        $taxes = $taxes * 100;
    } else {
        foreach ($this->taxes as $tax) {
            $taxes += abs($tax->rate);
        }
    }

    return (float) $taxes;
}

Combine additionne les taux : les trois taxes portent sur la même base. One after another les compose en cascade : chaque taxe s’applique sur le résultat de la précédente. Dans le back-office, ce réglage s’appelle « Behavior » sur chaque règle de taxe, et son aide contextuelle est explicite : Combine: Combine taxes (e.g.: 10% + 5% = 15%) face à One after another: Apply taxes one after another (e.g.: 100 + 10% => 110 + 5% = 115.5).

Le moteur natif sait donc parfaitement empiler l’OM, l’OMR et la TVA. Encore faut-il choisir la bonne méthode, et c’est là que ça se joue.

Ce que dit la loi, et pourquoi la cascade est fausse

Trois taxes qui s’ajoutent les unes aux autres, l’intuition dit « cascade ». Le réflexe est d’autant plus fort que le vocabulaire courant parle de taxes « qui s’empilent ». La loi dit exactement l’inverse, et elle le dit en trois articles de la loi n° 2004-639 du 2 juillet 2004 :

  • Article 9 : l’assiette de l’octroi de mer est la valeur en douane à l’importation, ou le prix hors TVA pour les livraisons locales.
  • Article 37 : l’octroi de mer régional a la même assiette que l’octroi de mer.
  • Article 45 : l’octroi de mer et l’octroi de mer régional ne sont pas compris dans la base d’imposition de la TVA.

Autrement dit, les trois taxes portent toutes sur la même base hors taxe. Aucune ne s’applique sur le résultat d’une autre. C’est la définition exacte de COMBINE_METHOD, et l’exact contraire de ce que fait ONE_AFTER_ANOTHER_METHOD.

Prenons un produit taxé à 5 % d’octroi de mer en Martinique, avec un OMR à 2,5 % (le plafond pour la Guadeloupe, la Martinique, Mayotte et La Réunion, contre 5 % en Guyane) et la TVA locale à 8,5 % :

Combine (conforme)          5 + 2,5 + 8,5           = 16,0000 %
One after another (faux)    1,05 x 1,025 x 1,085 - 1 = 16,7731 %

100,00 € HT  ->  116,00 € attendu
             ->  116,77 € facturé
                 0,77 € de trop, soit 0,77 point d'écart

L’écart est faible en valeur absolue, ce qui est précisément ce qui le rend durable. Il ne franchit jamais le seuil qui provoquerait une réclamation client, et il ne se voit pas non plus sur une commande isolée relue à la main.

Le second échec silencieux : une ligne qui tronque la pile

Le deuxième piège est plus vicieux, parce qu’il ne dépend pas d’un réglage visible mais de l’ordre dans lequel le core lit les règles. Voici la sélection réelle, dans classes/tax/TaxRulesTaxManager.php :

$rows = Db::getInstance()->executeS('
    SELECT tr.*
    FROM `' . _DB_PREFIX_ . 'tax_rule` tr
    JOIN `' . _DB_PREFIX_ . 'tax_rules_group` trg ON (tr.`id_tax_rules_group` = trg.`id_tax_rules_group`)
    WHERE trg.`active` = 1
    AND tr.`id_country` = ' . (int) $this->address->id_country . '
    AND tr.`id_tax_rules_group` = ' . (int) $this->type . '
    AND tr.`id_state` IN (0, ' . (int) $this->address->id_state . ')
    AND (\'' . pSQL($postcode) . '\' BETWEEN tr.`zipcode_from` AND tr.`zipcode_to`
        OR (tr.`zipcode_to` = 0 AND tr.`zipcode_from` IN(0, \'' . pSQL($postcode) . '\')))
    ORDER BY tr.`zipcode_from` DESC, tr.`zipcode_to` DESC, tr.`id_state` DESC, tr.`id_country` DESC');

$behavior = 0;
$first_row = true;

foreach ($rows as $row) {
    $tax = new Tax((int) $row['id_tax']);
    $taxes[] = $tax;

    // the applied behavior correspond to the most specific rules
    if ($first_row) {
        $behavior = $row['behavior'];
        $first_row = false;
    }

    if ($row['behavior'] == 0) {
        break;
    }
}

Deux comportements à retenir, et aucun des deux ne produit le moindre message :

1. Une seule ligne décide pour toute la pile. Le behavior retenu est celui de la première ligne renvoyée par la requête, c’est-à-dire la règle la plus spécifique au sens de l’ORDER BY. Si vous avez réglé « Combine » sur vos règles OM et OMR mais laissé la TVA sur autre chose, et que c’est la TVA qui remonte en tête, c’est son réglage qui gouverne les trois.

2. Un behavior à 0 tronque la suite. La valeur 0 correspond au libellé « This tax only » du back-office. Dès qu’une règle la porte, la boucle s’arrête net (ligne 127) et toutes les règles suivantes sont ignorées. Concrètement : vous configurez trois taxes, vous en voyez une seule sur la facture, et rien nulle part ne vous dit que les deux autres ont été écartées.

C’est le genre de comportement que le core assume sans le documenter côté back-office : le champ « Behavior » est présenté comme un détail de configuration alors qu’il pilote la totalité du calcul.

Le réflexe de diagnostic : rejouer la sélection en SQL

Plutôt que d’inspecter le back-office écran par écran, le plus rapide est de reproduire exactement la requête du core pour un couple (produit, destination). C’est le seul moyen de voir la pile dans l’ordre où le moteur la lit, avec les behavior tels qu’ils s’appliqueront :

SET @iso     = 'MQ';   -- Martinique
SET @group   = 12;     -- id_tax_rules_group du produit testé
SET @zip     = '97200';

SELECT tr.`id_tax_rule`, t.`rate`, tr.`behavior`,
       tr.`id_state`, tr.`zipcode_from`, tr.`zipcode_to`
FROM `ps_tax_rule` tr
JOIN `ps_tax_rules_group` trg ON trg.`id_tax_rules_group` = tr.`id_tax_rules_group`
JOIN `ps_tax` t ON t.`id_tax` = tr.`id_tax`
WHERE trg.`active` = 1
  AND tr.`id_country` = (SELECT `id_country` FROM `ps_country` WHERE `iso_code` = @iso)
  AND tr.`id_tax_rules_group` = @group
  AND tr.`id_state` IN (0, 0)
  AND (@zip BETWEEN tr.`zipcode_from` AND tr.`zipcode_to`
       OR (tr.`zipcode_to` = 0 AND tr.`zipcode_from` IN (0, @zip)))
ORDER BY tr.`zipcode_from` DESC, tr.`zipcode_to` DESC,
         tr.`id_state` DESC, tr.`id_country` DESC;

La lecture est mécanique : le behavior de la première ligne s’applique à tout, et la première ligne à 0 coupe la pile. Si vous obtenez trois lignes avec behavior = 1, votre configuration est conforme.

Pour valider le résultat plutôt que la configuration, on passe par la fabrique, qui est le point d’entrée réel du calcul :

$address    = new Address((int) $idAddress);
$calculator = TaxManagerFactory::getManager($address, (int) $product->id_tax_rules_group)
    ->getTaxCalculator();

$this->logger->info('Contrôle taxe DOM', [
    'id_product'         => (int) $product->id,
    'iso_pays'           => $address->country,
    'computation_method' => $calculator->computation_method, // 1 = combine, 2 = cascade
    'taux_total'         => $calculator->getTotalRate(),      // 16.0 attendu
    'detail_100_ht'      => $calculator->getTaxesAmount(100.0),
]);

Un computation_method à 2 sur une destination DOM est une anomalie, quelle que soit la suite du calcul. C’est le contrôle à mettre en place en premier, et c’est aussi celui qu’on garde en production sur chaque commande DOM, sous forme structurée plutôt qu’en debug verbeux.

Les DOM ne forment pas une zone PrestaShop

Le raccourci naturel, quand on modélise, c’est de créer une zone « DOM » et d’y accrocher les règles. PrestaShop ne le permet pas, et c’est contre-intuitif parce que les DOM sont bien présents nativement, chacun avec son code ISO. Ils sont simplement répartis dans quatre zones différentes, comme le montrent les données d’installation :

<!-- install-dev/data/xml/country.xml (PrestaShop 9.0.0) -->
<country id="GP" id_zone="Central_America_Antilla" iso_code="GP" ... />
<country id="MQ" id_zone="Central_America_Antilla" iso_code="MQ" ... />
<country id="GF" id_zone="South_America"           iso_code="GF" ... />
<country id="RE" id_zone="Africa"                  iso_code="RE" ... />
<country id="YT" id_zone="Africa"                  iso_code="YT" ... />

La Réunion et Mayotte cohabitent avec l’Afrique du Sud, la Guyane avec le Brésil, la Guadeloupe et la Martinique avec la Jamaïque. Toute règle posée au niveau de la zone déborde donc largement sur des pays qui n’ont rien à voir avec l’octroi de mer. La granularité utilisable est le pays, un par un.

Deuxième conséquence, moins visible : la France métropolitaine est déclarée avec contains_states="0" et le fichier state.xml ne contient aucun état français. Le id_state vaut donc toujours 0 dans la clause AND tr.id_state IN (0, ...), ce qui laisse le code postal comme unique axe de découpage infra-pays. Sur les DOM, l’attribut zip_code_format est vide, ce qui signifie que PrestaShop ne valide pas non plus le format des codes postaux saisis. Si votre modélisation repose sur des plages de codes postaux, personne ne vous préviendra qu’une adresse est mal formée.

Là où le natif s’arrête vraiment : la cardinalité

Une fois le behavior corrigé, le moteur natif calcule juste. Ce qu’il ne sait pas absorber, c’est le volume.

Sur l’axe destination, tout va bien : cinq pays. Sur l’axe produit, PrestaShop n’offre qu’un seul degré de liberté, l’entier id_tax_rules_group porté par la fiche produit. Or les taux d’octroi de mer sont définis par position tarifaire douanière, et les grilles régionales en comptent plusieurs centaines, avec des taux qui diffèrent d’une région à l’autre pour une même position.

Exprimer cela nativement impose donc un groupe de taxes par catégorie tarifaire, et dans chaque groupe une règle par destination. On arrive vite à plusieurs centaines de groupes et plusieurs milliers de règles, à maintenir dans une interface conçue pour en gérer une dizaine. Et il faut recommencer à chaque révision de grille, qui intervient au minimum une fois par an.

C’est cette combinatoire, et elle seule, qui justifie un module. Pas une incapacité du moteur à empiler des taxes.

Les codes HS, la donnée dont tout dépend

Le code HS (Harmonized System) est la nomenclature douanière internationale à 6, 8 ou 10 chiffres qui classe chaque produit. C’est la clé d’entrée des grilles d’octroi de mer. Sans code HS fiable sur chaque référence, aucun calcul automatique n’est défendable.

L’état des lieux au démarrage d’un projet DOM ressemble presque toujours à la même chose : une partie du catalogue renseignée mais au mauvais niveau de précision, une partie pas renseignée du tout, et quelques produits dont le code ne correspond plus à la réalité après évolution de la gamme. La première phase du chantier n’est donc pas technique, c’est un travail de fond sur la donnée produit avec l’équipe du marchand.

Notre règle par défaut : en l’absence de code HS exploitable, on bloque le calcul et on remonte une erreur explicite. L’alternative, appliquer un taux par défaut sur la zone, transfère un risque fiscal au marchand sur des montants que plus personne ne contrôle.

Le point d’extension : le hook taxManager

Reste à savoir où brancher un calcul externe. La réponse est dans TaxManagerFactory, et elle est plus propre que ce qu’on imagine généralement : PrestaShop prévoit explicitement le remplacement de son moteur de taxes.

public static function getManager(Address $address, $type)
{
    $cache_id = TaxManagerFactory::getCacheKey($address) . '-' . $type;
    if (!isset(TaxManagerFactory::$cache_tax_manager[$cache_id])) {
        $tax_manager = TaxManagerFactory::execHookTaxManagerFactory($address, $type);
        if (!($tax_manager instanceof TaxManagerInterface)) {
            $tax_manager = new TaxRulesTaxManager($address, $type);
        }

        TaxManagerFactory::$cache_tax_manager[$cache_id] = $tax_manager;
    }

    return TaxManagerFactory::$cache_tax_manager[$cache_id];
}

Un module qui s’enregistre sur le hook taxManager et implémente hookTaxManager() peut renvoyer son propre TaxManagerInterface. L’interface ne demande que deux méthodes, dont une seule fait le travail :

public function hookTaxManager(array $params)
{
    /** @var Address $address */
    $address = $params['address'];
    $idTaxRulesGroup = (int) $params['params'];

    if (!in_array($address->country_iso, self::ZONES_OCTROI_DE_MER, true)) {
        return false; // on laisse la main au TaxRulesTaxManager natif
    }

    return new OctroiDeMerTaxManager($address, $idTaxRulesGroup, $this->grilleRepository);
}

L’intérêt est que tout le reste suit sans effort. Le panier, le récapitulatif de commande, la facture et les exports comptables passent tous par la même fabrique. On n’a donc pas à se greffer sur trois ou quatre hooks du tunnel en espérant n’en oublier aucun.

Trois pièges à connaître avant de partir dessus, tous vérifiables dans le core :

  • execHookTaxManagerFactory() s’arrête au premier module qui renvoie un manager. Si deux modules écoutent taxManager, le second ne sera jamais appelé, et rien ne le signalera.
  • L’interface impose une méthode statique isAvailableForThisAddress(), mais la fabrique ne l’appelle jamais. La décision « cette adresse me concerne » doit donc être prise dans hookTaxManager() lui-même, comme dans l’exemple ci-dessus. Se reposer sur la méthode de l’interface revient à appliquer son manager sur la boutique entière.
  • Le cache statique $cache_tax_manager est indexé sur l’adresse et le groupe de taxes. Un changement d’adresse en cours de session crée une nouvelle entrée, mais une modification de grille tarifaire en base pendant la même requête ne sera pas reprise.

L’architecture qu’on a retenue

Trois responsabilités séparées, avec la matrice fiscale traitée comme de la donnée et non comme du code :

  • Un repository de grilles qui répond à une seule question : pour ce code HS et cette destination, quels taux d’OM, d’OMR et de TVA, et quelle exonération éventuelle.
  • Un TaxManager qui construit un TaxCalculator en COMBINE_METHOD à partir de ces taux, et qui reste le seul point de contact avec le core.
  • Un module qui enregistre le hook et injecte les deux précédents.

Les exonérations ne sont pas une branche conditionnelle mais une ligne de grille qui renvoie un taux à zéro, avec un indicateur permettant de l’afficher explicitement sur la facture, ce qui est légalement requis. Toute l’intelligence vit dans les données. Quand une région publie sa nouvelle grille, on met à jour un jeu de données et rien d’autre, ce qui rend le sujet compatible avec un contrat de maintenance au lieu d’en faire un chantier annuel.

Ce qu’il faut réunir avant de démarrer

  • Un audit du catalogue : combien de références ont un code HS exploitable, à quel niveau de précision, et qui en valide l’exactitude côté métier.
  • La liste réelle des destinations servies : inutile de modéliser la Polynésie si vous n’expédiez que vers les DROM. Les cinq territoires concernés par l’octroi de mer sont la Guadeloupe, la Guyane, la Martinique, Mayotte et La Réunion.
  • Les grilles à jour de chaque collectivité, et un référent capable de les interpréter.
  • Une convention avec la comptabilité sur la ventilation de l’OM, de l’OMR et de la TVA sur les pièces comptables. À noter que la TVA n’existe pas en Guyane ni à Mayotte, ce qui impose un traitement distinct de celui de la Guadeloupe, de la Martinique et de La Réunion, toutes trois à 8,5 %.

Sans ces quatre éléments, on déconseille de démarrer. Avec, le module se construit proprement.

Notre avis

Ce sujet est presque toujours abordé par le mauvais bout. On lit partout que le moteur natif est incapable de gérer l’octroi de mer, on part directement sur du spécifique, et on ne vérifie jamais le calcul de référence. Résultat : des boutiques qui tournent avec un behavior mal réglé pendant des mois, et un écart qui ne se voit qu’à la régularisation.

La première chose à faire n’est pas de développer, c’est de lancer la requête de diagnostic plus haut sur trois produits représentatifs et trois destinations. Si le computation_method ressort à 2, vous avez déjà trouvé plus que ce que la plupart des audits remontent.

La seconde, c’est de refuser de mettre en production un calcul automatique tant que les codes HS ne sont pas propres. Un module qui sort des montants faux auxquels tout le monde fait confiance est pire que pas de module du tout.

FAQ

PrestaShop sait-il gérer l’octroi de mer nativement ?
Le calcul, oui : TaxCalculator empile plusieurs taxes et sait les additionner sur une base commune, ce qui correspond à ce qu’imposent les articles 9, 37 et 45 de la loi du 2 juillet 2004. Ce que le natif n’absorbe pas, c’est la combinatoire entre plusieurs centaines de positions tarifaires et cinq destinations, ni la révision annuelle des grilles.

Faut-il régler « Combine » ou « One after another » ?
Combine. Les trois taxes portent sur la même base hors taxe : l’OMR a la même assiette que l’octroi de mer (article 37), et ni l’un ni l’autre n’entrent dans la base de la TVA (article 45). Un réglage en cascade surfacture le client d’environ 0,77 point sur un profil 5 % / 2,5 % / 8,5 %.

J’ai configuré trois taxes et une seule apparaît sur la facture.
Une de vos règles porte un behavior à 0, c’est-à-dire « This tax only » dans le back-office. Le core arrête sa boucle de lecture dessus et ignore tout ce qui suit, sans message. La requête de diagnostic plus haut affiche les behavior dans l’ordre exact où le moteur les lit.

Peut-on créer une zone « DOM » pour simplifier les règles ?
Non. Les cinq territoires sont bien présents nativement avec leurs codes ISO, mais répartis dans quatre zones différentes aux côtés du Brésil, de l’Afrique du Sud et de la Jamaïque. Les règles se posent au niveau du pays.

Que faire d’un produit sans code HS ?
Bloquer le calcul et remonter une erreur explicite. Appliquer un taux par défaut fait porter un risque fiscal au marchand sur des montants que personne ne recontrôle.

À quelle fréquence les grilles changent-elles ?
Au minimum une fois par an, selon les décisions des collectivités. C’est la raison pour laquelle la matrice doit vivre dans un jeu de données séparé : la mise à jour annuelle ne touche jamais le code de calcul.

Le taux d’OMR est-il le même partout ?
Non. Il est plafonné à 2,5 % en Guadeloupe, en Martinique, à Mayotte et à La Réunion, et à 5 % en Guyane. La TVA, elle, est à 8,5 % en Guadeloupe, Martinique et à La Réunion, et n’existe pas en Guyane ni à Mayotte.

Vous expédiez vers les DROM et vos calculs de taxes ressemblent à une boîte noire ? Commencez par la requête de diagnostic. Si le résultat vous surprend, parlons de votre boutique.

Nous vous recommandons aussi