Taxa customizada no Magento 2: um totals collector que funciona
Uma taxa de manuseio, uma taxa de embalagem, um acréscimo para pedidos pequenos. Todas parecem uma linha a mais nos totais do carrinho. No Magento essa linha é um totals collector, e uma taxa só está pronta quando aparece corretamente no carrinho, no checkout, no pedido, na invoice, no credit memo e no PDF. A maioria das extensões de taxa que reviso acerta o carrinho e erra uma das outras partes.
Este artigo mostra como funciona um quote totals collector no Magento Open Source e no Adobe Commerce 2.4.8, como escrever um para uma taxa fixa e o que você precisa para levá-la pelo resto do ciclo de vida do pedido.
Como os totais da quote são calculados
Quando o $quote->collectTotals() roda, o Magento\Quote\Model\Quote\TotalsCollector percorre cada endereço da quote e executa todos os collectors declarados nos arquivos sales.xml, ordenados por sort_order. Subtotal, frete, desconto, imposto e grand total são todos collectors. Cada um recebe um objeto Magento\Quote\Model\Quote\Address\Total e escreve valores nele.
Dois detalhes importam para uma taxa customizada:
- Os collectors rodam por endereço. Uma quote normal tem endereço de cobrança e de entrega. Se o seu collector adiciona a taxa nos dois, o cliente paga duas vezes. Adicione só no endereço que tem itens.
- O collector de grand total soma os outros. Quando você chama
setTotalAmount()esetBaseTotalAmount()com o seu code, o collector de grand total inclui o valor. Você não soma no grand total por conta própria.
Declarando o collector no sales.xml
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Sales:etc/sales.xsd">
<section name="quote">
<group name="totals">
<item name="handling_fee" instance="Vendor\Fee\Model\Total\Quote\HandlingFee" sort_order="420"/>
</group>
</section>
</config>
Abra Magento_Quote/etc/sales.xml, Magento_SalesRule/etc/sales.xml e Magento_Tax/etc/sales.xml na sua instalação para ver os sort orders do core. O seu collector precisa rodar antes do grand total. Se ele roda antes ou depois de desconto e imposto depende da regra de negócio: um cupom deve reduzir a taxa, e a taxa é tributável? Decida isso com o lojista antes de escolher o número.
O collector
<?php
declare(strict_types=1);
namespace Vendor\Fee\Model\Total\Quote;
use Magento\Framework\App\Config\ScopeConfigInterface;
use Magento\Framework\Pricing\PriceCurrencyInterface;
use Magento\Quote\Api\Data\ShippingAssignmentInterface;
use Magento\Quote\Model\Quote;
use Magento\Quote\Model\Quote\Address\Total;
use Magento\Quote\Model\Quote\Address\Total\AbstractTotal;
use Magento\Store\Model\ScopeInterface;
class HandlingFee extends AbstractTotal
{
public const CODE = 'handling_fee';
public function __construct(
private readonly ScopeConfigInterface $scopeConfig,
private readonly PriceCurrencyInterface $priceCurrency
) {
$this->setCode(self::CODE);
}
public function collect(Quote $quote, ShippingAssignmentInterface $shippingAssignment, Total $total)
{
parent::collect($quote, $shippingAssignment, $total);
if (!count($shippingAssignment->getItems())) {
return $this;
}
$baseFee = $this->getBaseFee((int) $quote->getStoreId());
if ($baseFee <= 0) {
return $this;
}
$fee = $this->priceCurrency->convert($baseFee, $quote->getStore());
$total->setTotalAmount(self::CODE, $fee);
$total->setBaseTotalAmount(self::CODE, $baseFee);
return $this;
}
public function fetch(Quote $quote, Total $total)
{
$amount = (float) $total->getTotalAmount(self::CODE);
if ($amount <= 0) {
return [];
}
return [
'code' => self::CODE,
'title' => __('Handling fee'),
'value' => $amount,
];
}
private function getBaseFee(int $storeId): float
{
return (float) $this->scopeConfig->getValue(
'vendor_fee/general/amount',
ScopeInterface::SCOPE_STORE,
$storeId
);
}
}
O config path é seu, definido no system.xml do seu módulo. A taxa é configurada na moeda base e convertida para a moeda da quote, então lojas com várias moedas mostram o valor certo.
O parent::collect() zera os valores desse code no total, para que chamadas repetidas de collectTotals() não acumulem a taxa. Nunca guarde o valor em uma propriedade da classe entre as chamadas.
Onde o valor é guardado
O setTotalAmount('handling_fee', ...) também define a chave de dados handling_fee_amount no total, e a versão base define base_handling_fee_amount. Depois da coleta, os dados do total são adicionados ao endereço. Adicione colunas com esses nomes em quote_address e elas são salvas junto com o endereço. Adicione as mesmas colunas em sales_order, sales_invoice e sales_creditmemo, e de preferência também colunas de invoiced e refunded no pedido, como o core faz com o frete. O declarative schema cuida de todas em um único db_schema.xml.
Copiando a taxa para o pedido
Use um observer em sales_model_service_quote_submit_before. Pegue os valores do endereço de entrega, ou do de cobrança em quotes virtuais:
<?php
declare(strict_types=1);
namespace Vendor\Fee\Observer;
use Magento\Framework\Event\Observer;
use Magento\Framework\Event\ObserverInterface;
class CopyFeeToOrder implements ObserverInterface
{
public function execute(Observer $observer): void
{
$quote = $observer->getEvent()->getQuote();
$order = $observer->getEvent()->getOrder();
$address = $quote->isVirtual() ? $quote->getBillingAddress() : $quote->getShippingAddress();
$order->setData('handling_fee_amount', $address->getData('handling_fee_amount'));
$order->setData('base_handling_fee_amount', $address->getData('base_handling_fee_amount'));
}
}
O grand total do pedido já inclui a taxa, porque foi calculado na quote. As novas colunas só guardam o detalhamento.
Mostrando a taxa no carrinho e no checkout
A API de totals devolve um segmento para cada collector cujo fetch() retornou dados. O carrinho e o checkout do Luma ainda precisam de um componente para renderizá-lo:
define([
'Magento_Checkout/js/view/summary/abstract-total',
'Magento_Checkout/js/model/totals'
], function (Component, totals) {
'use strict';
return Component.extend({
defaults: {
template: 'Vendor_Fee/summary/handling-fee',
title: 'Handling fee'
},
getSegment: function () {
return totals.getSegment('handling_fee');
},
isDisplayed: function () {
var segment = this.getSegment();
return segment !== null && Number(segment.value) > 0;
},
getValue: function () {
var segment = this.getSegment();
return this.getFormattedPrice(segment ? segment.value : 0);
}
});
});
O template renderiza uma linha de tabela com title e getValue(), envolvida em uma checagem de isDisplayed(). Registre o componente em block-totals no checkout_cart_index.xml e em checkout > sidebar > summary > totals no checkout_index_index.xml. Copie a estrutura de como o Magento_Tax adiciona as linhas dele, é a referência mais confiável.
Com GraphQL, o tipo prices do carrinho no core não conhece a sua taxa. Você precisa estender o schema e adicionar um resolver se um frontend headless precisar exibi-la.
Invoices e credit memos
Invoices e credit memos têm seus próprios collectors. Declare-os no mesmo sales.xml, nas seções order_invoice e order_creditmemo. Um collector de invoice estende Magento\Sales\Model\Order\Invoice\Total\AbstractTotal e implementa collect(Invoice $invoice). Ele precisa:
- Adicionar a taxa só se ela ainda não foi faturada. Invoices parciais são o bug clássico, em que toda invoice cobra a taxa de novo.
- Definir a taxa na invoice e somá-la ao grand total e ao base grand total da invoice. Aqui, diferente da quote, você soma no grand total por conta própria.
O collector de credit memo faz o mesmo para reembolsos. Decida com o lojista se a taxa é reembolsável. Se você pular esses collectors, o total do pedido inclui a taxa e o total da invoice não. O pedido passa a mostrar um valor em aberto que ninguém consegue cobrar, e os valores pagos e reembolsados nos relatórios deixam de bater com os totais do pedido.
Admin, e-mails e PDF
Os totais de pedido, invoice e credit memo no admin e nos e-mails de vendas são renderizados por blocks de totals. Adicione um block no layout em order_totals, invoice_totals e creditmemo_totals com um método initTotals() que chama addTotal() no parent com um DataObject contendo code, value, base value e label. Use os mesmos layout handles que os e-mails de vendas usam para a taxa aparecer neles também.
O PDF precisa de uma entrada de total no pdf.xml. Veja customização do PDF da invoice para isso.
Taxas condicionais
Taxas reais raramente são fixas. Um acréscimo para pedidos pequenos vale abaixo de um subtotal, uma taxa de embalagem vale para algumas categorias, uma taxa de pagamento depende do método de pagamento escolhido. Toda essa lógica vai dentro do collect(), que tem acesso ao que precisa: os itens do shipping assignment, o subtotal já coletado no total se o seu sort order vier depois do subtotal, e a quote com o seu payment. Mantenha as regras em uma classe de service separada para o collector continuar pequeno e as regras poderem ser testadas sem montar uma quote.
Uma taxa que depende do método de pagamento tem mais uma pegadinha. O collector lê o método salvo na quote, e o checkout do Luma não o salva no momento em que o cliente clica em um radio button. Se a taxa precisa acompanhar o método selecionado, salve o método na quote quando ele mudar e depois recarregue os totais, por exemplo com Magento_Checkout/js/action/get-totals. Se a regra é parecida com o que as cart price rules fazem, veja se uma condição customizada de cart rule resolve com menos código.
Imposto sobre a taxa
Se a taxa é tributável, a abordagem simples deste artigo não basta. A taxa precisa entrar no cálculo de imposto, o que significa trabalhar com os tax collectors e com os quote details items. Isso é um trabalho à parte, e vale confirmar com um contador antes de escrever código.
FAQ
Por que minha taxa customizada é adicionada duas vezes?
O collector roda para cada endereço da quote. Retorne cedo quando o shipping assignment não tem itens, para que só o endereço com itens receba a taxa.
Devo somar a taxa ao grand total no collector da quote?
Não. Use setTotalAmount e setBaseTotalAmount. O collector de grand total do core soma todos os valores de total, então somar por conta própria cobra duas vezes.
Por que o pedido mostra valor em aberto depois de uma invoice total?
Geralmente falta o collector da invoice, então o valor faturado fica menor que o grand total do pedido. Adicione collectors de invoice e de credit memo para a taxa.
Um cupom pode dar desconto na taxa?
Só se você construir assim. A lógica de desconto do core se aplica a itens e, opcionalmente, ao frete. Um desconto sobre uma taxa customizada exige código customizado.
Atendo como freelancer em Magento e Adobe Commerce, incluindo totals customizados e lógica de checkout. Se você precisa de ajuda com isso, fale comigo.
Artigos relacionados
Shipping method customizado no Magento 2: crie um módulo de carrier
Como criar um shipping method customizado no Magento 2.4: config.xml, system.xml, um carrier model com collectRates, regras de frete grátis e APIs externas de tarifa.
Método de pagamento customizado no Magento 2 com a Payment Gateway API
Crie um método de pagamento customizado no Magento 2.4 com a Payment Gateway API: virtual type de facade, config, commands e o renderer JS do checkout, passo a passo.
Guest checkout e contas de cliente no Magento 2 explicados
Como o Magento 2 trata guest checkout e contas de cliente: configuração, criação de conta depois do pedido, vínculo seguro de pedidos de visitante e checagem de e-mail.
Magento 2 Cart Price Rules: como criar uma condição customizada
Crie uma condição customizada nas cart price rules do Magento 2: o evento combine, o model da condition com validate, atributos de promo rule e armadilhas de performance.