Apparence
Pricing & taxe
Le pricing applique la mécanique des pipelines au prix d'un produit et aux totaux d'un panier. Cette page détaille la décomposition du prix, la taxe et la devise, puis la commande figée.
Le prix d'un produit
Un prix traverse une chaîne de PriceModifier (voir pipelines), sur un PriceContext :
php
final class PriceContext
{
public function __construct(
public readonly Product $product,
public readonly int $quantity,
public readonly ?Authenticatable $customer,
public Money $price, // prix HT — muté en chaîne
public array $trace = [], // audit des transformations
) {}
}La fabrique PriceContext::forProduct($product, $quantity = 1, $customer = null) pose la base : le price_te du produit, en devise de la boutique (shop.default_currency_iso), ou zéro si le produit n'a pas de prix.
Les maillons raisonnent en HT. Le pipeline (PricePipeline::priceFor()) rend un objet Price décomposé. Il contient base (prix catalogue HT brut, capturé avant la traversée), subtotalExclTax (après modifiers), taxRate et trace :
php
$price->exclTax(); // HT (Money), après modifiers
$price->inclTax(); // TTC (Money)
$price->tax(); // montant de taxe seul
$price->exclTax(false); // le prix de base, avant modifiers (idem inclTax/tax)Taxe & devise : maillons cœur de fin de chaîne
La taxe est résolue après tous les modifiers, par le TaxResolver (remplaçable), à partir de la règle de taxe du produit et de la zone fiscale courante (CurrentAreaResolver). Vous n'appliquez jamais la taxe dans un modifier.
php
public function resolve(?TaxRule $taxRule, Area $area): TaxRate;Le défaut (DefaultTaxResolver) prend le taux propre à la zone (pivot area_tax_tax_rule), sinon la taxe par défaut de la règle, sinon TaxRate::zero(). Le pipeline ne résout la zone que si le produit porte une règle de taxe. Le résultat est un value object TaxRate : un pourcentage exact (BigDecimal, construit par TaxRate::ofPercent()) avec une méthode applyTo(Money), qui rend le TTC arrondi à l'échelle de la devise (HALF_UP). Si le taux est nul (isZero()), elle rend le montant tel quel.
Le résolveur par défaut garde en mémoire chaque taux par règle chargée et par zone, dans une WeakMap indexée sur l'instance de TaxRule. Une liste de produits qui partagent la même règle, préchargée, ne l'interroge donc qu'une fois par zone. Une règle rechargée repart de zéro. En revanche, une instance déjà résolue garde son taux même si ses taxes changent ensuite.
Ce pivot est posé par la fiche de la règle de taxe, au moyen d'un hook de sauvegarde plutôt que dans un contrôleur. Le hook SyncTaxRuleAreas (déclaré en saved(TaxRule::class, …)) lit les valeurs taxes, une taxe par zone, et synchronise la relation. Une zone laissée vide sort de la règle : le résolveur y retombe alors sur la taxe par défaut, sinon sur TaxRate::zero(), auquel cas le TTC vaut le HT. Comme pour tout hook, une clé taxes absente ne modifie rien.
Tout est calculé en Brick\Money\Money, de façon exacte, jamais en float. L'arrondi à l'échelle de la devise n'a lieu qu'aux frontières d'affichage.
La devise n'est pas dans le PriceContext : les maillons calculent en devise de référence de la boutique, et la conversion n'a lieu qu'à l'affichage, via CurrencyService. Ce service expose :
getCurrentCurrency();price(), qui convertit puis formate ;format();convert(), qui multiplie par l'exchange_ratede la devise ;asMoneyCurrency(), qui rend le code ISO s'il est connu debrick, sinon une devise « marchand » d'échelle 2.
price() et format() acceptent un Money comme un float : le Money n'est ramené au scalaire qu'à cette frontière, jamais avant.
La devise courante vient du CurrentCurrencyResolver. Par défaut, c'est le cookie shop.cookies.currency, sinon shop.default_currency_iso. Elle n'est résolue qu'une fois par instance, et l'instance est scopée par requête (jamais un static partagé entre process). Le changement de devise émet CurrencyChanged.
Rendre un document dans une devise imposée
Hors requête, par exemple pour un mail de commande en queue, on lie CurrentCurrencyResolver à un FixedCurrentCurrencyResolver construit avec la devise voulue. Le document se formate alors dans la devise de la commande, pas dans celle du contexte courant.
Les taux de change sont rafraîchis par ExchangeRateService::updateCurrenciesExchangeRates(). Pour chaque service déclaré sur les devises (colonne exchange_rate_service), elle appelle updateAllExchangeRates(). Un fournisseur tiers étend ExchangeRateAPIService et implémente updateExchangeRate(Currency). La classe abstraite fournit updateAllExchangeRates(), qui boucle sur les devises du service. Un fournisseur peut la surcharger pour faire un appel groupé : c'est ce que fait l'implémentation fournie, ERAPIExchangeRateAPIService.
Les totaux
Le panier affiche des lignes contribuées par des TotalsModifier (livraison, remise…), agrégées en Money : ligne « produits » + lignes contribuées = ligne « total ». Voir pipelines — les totaux. Le point d'entrée est le TotalsBuilder :
php
$totals = app(TotalsBuilder::class)->forCart($cart);
$totals = app(TotalsBuilder::class)->forOrder($order, currency: true); // colonnes en devise client
$totals->lines; // list<TotalsLine> : key, label, exclTax, inclTax
$totals->total; // la ligne « total »
$totals->tax(); // taxe globale (TTC − HT sur le total)Chaque maillon reçoit un TotalsContext qui contient le panier (subject) et les lignes déjà posées. Il peut donc répondre à ce qui le précède, sans jamais réécrire la ligne d'un autre : il en pose la contrepartie, et le total reste la somme.
php
public function lines(TotalsContext $context): array
{
$shipping = $context->line('shipping'); // ?TotalsLine, null si personne ne l'a posée
return $shipping !== null
? [new TotalsLine('free_shipping', __('Franco de port'), $shipping->exclTax->negated(), $shipping->inclTax->negated())]
: [];
}Panier vivant vs commande figée
- Panier : les modifiers s'exécutent, les prix sont recalculés en direct, et le total est la somme des lignes.
- Commande : au placement, les lignes sont figées dans
price_components(JSON). Elles y sont stockées en devise de référence (te/ti) et en devise du client (currency_te/currency_ti). Elles sont relues telles quelles, enMoneyconstruit sur la devise rendue parCurrencyService::asMoneyCurrency(). La ligne « produits » et le grand total, eux, sont lus en colonnes viaOrderPriceService. Les modifiers ne tournent pas : une commande se relit sans aucun package installé, et supprimer un transporteur après coup ne change pas une commande passée.
Points d'extension de ce domaine
| Je veux… | Point d'extension |
|---|---|
| modifier un prix | PriceModifier + PriceModifierRegistry |
| ajouter une ligne de totaux | TotalsModifier + TotalsModifierRegistry |
| changer le calcul de taxe | TaxResolver |
| compléter ce qu'écrit l'enregistrement d'une règle de taxe | SaveHookRegistry |
| changer la zone fiscale courante | CurrentAreaResolver |
| changer la devise courante | CurrentCurrencyResolver |
| changer le formatage ou la conversion | CurrencyService |
| réagir à un changement de devise | événement CurrencyChanged |
| brancher une source de taux de change | ExchangeRateAPIService |
| remplacer l'orchestration entière | PricePipeline / TotalsBuilder |
| réordonner les maillons | config/pricing.php (priorities, totals_priorities) |
Voir aussi
- Les pipelines : la mécanique et les exemples complets.
- Les résolveurs : taxe, zone, devise.
- Les registres, dont les hooks de sauvegarde.