A diretoria aprovou a tabela: acima de cem unidades, cinco por cento; acima de seiscentas, doze. Bonito no slide. Na prática, cada vendedor guarda a faixa na cabeça de um jeito, arredonda para cima quando quer fechar o mês, e no fim do trimestre você tem três descontos diferentes para o mesmo volume, todos digitados à mão na Oportunidade. A política existe. O que não existe é o lugar onde ela é aplicada sozinha.
Para aplicar desconto por volume no Salesforce sem digitar à mão, você guarda as faixas de
quantidade em um Custom Metadata Type e coloca uma automação antes de salvar na
OpportunityLineItem que lê a quantidade da linha e preenche o campo Discount.
A faixa vira configuração que o admin edita e o cabeçalho é derivado das linhas.
Por que deixar o vendedor digitar o desconto quebra a política
O problema do desconto na unha não é o vendedor mal-intencionado, é a ausência de um ponto único onde a regra vale. Quando o percentual é um campo em branco esperando ser preenchido, ele vira negociação interna: um vendedor aplica os cinco por cento da faixa, outro estica para sete porque o cliente reclamou, um terceiro esquece e manda sem desconto nenhum. Nenhum deles está errado sozinho, porque não há nada no sistema dizendo qual é o certo. A tabela aprovada mora num PDF que ninguém abre na hora de fechar.
O efeito aparece na margem. Você projeta a receita líquida assumindo a política oficial, e o realizado vem abaixo porque metade das oportunidades saiu com desconto acima da faixa. Quando o financeiro cruza os números, a diferença já foi assinada pelo cliente. Colocar a regra dentro do CRM não é sobre controlar o vendedor, é sobre a empresa ter uma resposta só para a pergunta "qual desconto essa quantidade recebe".
Onde o desconto mora: na linha, não no cabeçalho
O primeiro erro de modelagem é achar que o desconto é um atributo da Oportunidade. Não é. A Oportunidade
guarda o cabeçalho (cliente, estágio, valor total), e cada produto vendido é um registro de
OpportunityLineItem, filho, com sua própria quantidade, preço e o campo Discount
em percentual. O desconto por volume é, por natureza, uma decisão por linha: você comprou seiscentas
válvulas, essa linha ganha a faixa de seiscentas; comprou oitenta conexões, aquela linha fica na faixa de
baixo. São descontos diferentes na mesma Oportunidade.
A consulta que a automação usa para reagir às linhas alteradas roda no filho, e roda respeitando o que o usuário pode ver:
List<OpportunityLineItem> linhas = [
SELECT Id, Quantity, UnitPrice, Discount, Product2.Family
FROM OpportunityLineItem
WHERE OpportunityId = :oppId
WITH USER_MODE
];
Esse WITH USER_MODE não é decoração: a classe que mexe em dados de negociação respeita CRUD e
FLS do usuário logado, como qualquer código que devolve ou grava registro de tela. O porquê disso, e o que
o Apex ignora por padrão quando você não pede, eu destrinchei no post sobre
segurança em Apex com CRUD, FLS e sharing. Guarde a
regra do modelo antes de seguir: o desconto vive na linha, e o número do cabeçalho é reflexo dela, nunca o
contrário.
As faixas viram configuração, não código
Se você escreve if (qtd >= 100) desconto = 5; dentro do Apex, cravou a política de comercial
no código. Toda vez que a diretoria mexer na tabela, e ela mexe a cada campanha, você precisa de um deploy.
A saída é tirar os números do código e colocá-los em um Custom Metadata Type, digamos
Faixa_Desconto__mdt, com três campos: quantidade mínima, percentual e, se a política variar
por linha de produto, a família de produto a que a faixa se aplica.
| Registro (mdt) | Qtd. mínima | Desconto | Família |
|---|---|---|---|
| Padrao_100 | 100 | 5% | (todas) |
| Padrao_300 | 300 | 8% | (todas) |
| Padrao_600 | 600 | 12% | (todas) |
Com as faixas em metadado, mudar a política é editar um registro em Setup: sem deploy, sem release, sem abrir chamado para o desenvolvedor. O administrador de comercial passa a ser dono da tabela, que é onde ela deveria estar. A automação só faz uma coisa: pega a quantidade da linha, procura a maior faixa cuja quantidade mínima seja menor ou igual a ela, e aplica o percentual daquela faixa. Em Apex, o coração do cálculo é este laço, com as faixas ordenadas da menor para a maior quantidade:
Decimal descontoDaFaixa(Integer qtd, List<Faixa_Desconto__mdt> faixas) {
Decimal aplicado = 0;
for (Faixa_Desconto__mdt f : faixas) { // ordenadas por Qtd_Minima__c asc
if (qtd >= f.Qtd_Minima__c) {
aplicado = f.Desconto__c; // a última que passa é a maior faixa
}
}
return aplicado;
}
Repare que o laço não para na primeira faixa que bate: ele continua e vai sobrescrevendo, então termina com o percentual da maior faixa que a quantidade alcança. É um detalhe bobo que, invertido, faz oitocentas unidades receberem os cinco por cento da faixa de cem em vez dos doze por cento da faixa de seiscentas. O bug não estoura, ele só entrega dinheiro a menos de desconto, e ninguém percebe até o cliente reclamar.
A conta feita na frente: três linhas, três faixas
A parte que engana é achar que o desconto do cabeçalho é a média dos percentuais das linhas. Não é. Ele é ponderado pelo valor, e a única forma de acertar é derivá-lo. Veja com os números de uma Oportunidade da Vetra, a distribuidora fictícia que mantenho como demonstração do portfólio, usando as faixas da tabela acima:
| Produto | Qtd | Preço unit. | Bruto | Faixa | Desc. | Líquido |
|---|---|---|---|---|---|---|
| Filtro industrial A | 250 | R$ 50,00 | R$ 12.500 | 100+ | 5% | R$ 11.875 |
| Válvula B | 600 | R$ 120,00 | R$ 72.000 | 600+ | 12% | R$ 63.360 |
| Conexão C | 80 | R$ 12,50 | R$ 1.000 | < 100 | 0% | R$ 1.000 |
| Total | R$ 85.500 | 10,84% | R$ 76.235 |
O desconto do cabeçalho é 10,84 por cento, e não a média simples de 5, 12 e 0 (que daria 5,67). A conta é o desconto total em dinheiro, R$ 9.265, dividido pelo bruto de R$ 85.500. A linha da válvula domina o número porque ela sozinha vale mais que as outras duas somadas, então o desconto agregado puxa para perto dos doze por cento dela. Se você deixasse alguém digitar "8%" no cabeçalho por achatamento, a proposta diria uma coisa e a soma das linhas diria outra. Por isso o agregado é derivado, no mesmo princípio de fonte única que uso para gerar a proposta comercial em PDF direto da Oportunidade: o total mora nas linhas e o cabeçalho é uma projeção.
Flow antes de salvar ou trigger Apex?
Para o caso mais comum, desconto pela quantidade da própria linha, um Flow before-save na
OpportunityLineItem é a ferramenta mais barata: roda antes de gravar, sem DML extra,
e um admin consegue manter. Ele lê Quantity, busca a faixa no Custom Metadata (Flow consulta
registros de CMDT nativamente) e escreve em Discount. Nenhuma linha de Apex, nenhum custo de
governor limit por atualização.
A escolha vira Apex quando a regra deixa de olhar só para a linha. Se a faixa depende da soma de todas as linhas do pedido (comprou R$ 80 mil no total, ganha a faixa cheia, mesmo pulverizado em itens pequenos), um before-save por linha não serve, porque ele não enxerga as irmãs. Aí você precisa de lógica no nível da Oportunidade, disparada quando qualquer linha muda, somando o pedido inteiro e reaplicando o desconto em cada linha. Isso é território de trigger Apex bulkificado, não de Flow por linha. A fronteira entre os dois é a mesma que tracei no post sobre quando usar Flow ou Apex no Salesforce: enquanto a regra couber na linha, Flow; quando ela precisa de contexto de várias linhas, cálculo em lote e ordem de execução previsível, Apex.
O caso da Vetra e a armadilha do cabeçalho sobrescrito
A primeira versão que montei na Vetra tinha um campo Desconto_Aplicado__c no cabeçalho da
Oportunidade, calculado por um rollup a partir das linhas. Funcionava, até eu tentar "ajudar" preenchendo
também um valor manual no mesmo campo em um cenário de teste. O rollup recalculou no salvamento seguinte e
apagou o que eu tinha digitado, sem aviso. Levei um tempo bom desconfiando da automação de linha antes de
cair a ficha: o problema não era a linha, era eu escrevendo em um campo que já tinha um dono, o rollup.
A lição virou regra de projeto: o desconto se seta na linha (OpportunityLineItem.Discount),
nunca no agregado do cabeçalho. O número do cabeçalho é sempre derivado, seja por rollup, seja
recalculado na hora de renderizar. No instante em que dois lugares tentam ser a fonte do mesmo desconto, um
deles vai sobrescrever o outro em silêncio, e você vai gastar a tarde procurando o bug no lugar errado.
Esse princípio de eleger um dono e derivar o resto eu generalizei no post sobre
como definir uma fonte de verdade única no Salesforce,
que trata do caso em que dois campos disputam o mesmo desconto. Fonte única não é purismo de arquitetura,
é o que impede a proposta e o forecast de contarem histórias diferentes.
Quando não vale a pena construir isso
Ser honesto aqui vale mais que empurrar horas. Se a sua empresa já tem Salesforce CPQ, não construa nada: o CPQ traz Discount Schedules nativos, feitos exatamente para desconto por volume e por prazo, com faixas configuráveis na interface e cálculo pronto na cotação. Reinventar isso em Custom Metadata seria trabalho jogado fora. A solução deste post é para quem não tem CPQ e não vai pagar a licença só por causa de uma tabela de faixas.
E se a sua realidade é desconto negociado caso a caso, sem política fixa de volume, talvez você não precise de automação nenhuma: um campo de desconto com uma validation rule travando o teto por perfil já resolve, e é mais barato de manter que uma engine de faixas que ninguém vai alimentar. Construa a regra quando a política existe e é estável o bastante para virar tabela. Se ela muda a cada negociação, o que você tem é exceção, não faixa, e exceção não se automatiza, se governa com limite e alçada.
Perguntas frequentes
Dá para aplicar desconto por volume no Salesforce sem CPQ?
Sim. As faixas de quantidade ficam num Custom Metadata Type e uma automação before-save na
OpportunityLineItem lê a quantidade e preenche o Discount. O CPQ traz
Discount Schedules prontos, mas é produto pago; para faixas simples, Custom Metadata e um
before-save resolvem sem licença extra.
O desconto por volume vai no cabeçalho ou na linha?
Na linha. Cada OpportunityLineItem tem seu Discount, calculado pela faixa da
quantidade dela. O desconto do cabeçalho é derivado (desconto em dinheiro sobre o bruto), nunca um campo
digitado à parte, senão a proposta e o forecast divergem.
A faixa é pela quantidade da linha ou pelo total do pedido?
Depende da política, e muda a arquitetura. Faixa pela quantidade de cada linha cabe num before-save na própria linha. Faixa pela soma do pedido precisa enxergar as linhas irmãs, o que exige Apex no nível da Oportunidade ou um rollup, não um before-save por linha.
Como mudo as faixas sem chamar o desenvolvedor?
Se as faixas moram em registros de Custom Metadata Type, o admin edita quantidade mínima e percentual em Setup, sem deploy. É por isso que a regra vai em metadado e não cravada no Apex ou no Flow: a política de comercial muda toda campanha.
