Descontos
Última atualização: 14 de julho de 2026
Visão Geral
Existem quatro pontos de entrada de UI para desconto no fluxo de nova venda, mas hoje só um está de fato ligado à persistência. Isso é importante para quem for continuar o trabalho: os outros três já têm toda a interface pronta (inputs, preview de valores, bottom sheet), mas o "Confirmar" ainda não chama nenhum service.
| Ponto de entrada | Componente | Persiste no rascunho? |
|---|---|---|
| Desconto inline por item (linha do produto/carrinho) | ProductDiscountControl | Sim, via saleDraftService.applyDiscount |
| Bottom sheet "Aplicar Desconto por Item" | NewSaleItemDiscountBottomSheet | Não, o botão Confirmar não chama nenhum service (código comentado) |
| Bottom sheet "Desconto Progressivo" | NewSaleProgressiveDiscountBottomSheet | Não, onConfirm é um no-op em new-sale.tsx |
| Bottom sheet "Desconto no Total da Venda" | NewSaleTotalDiscountBottomSheet | Não, onConfirm é um no-op em new-sale.tsx |
// features/new-sale/new-sale.tsx
{controller.progressiveDiscountBottomSheetOpen && (
<NewSaleProgressiveDiscountBottomSheet
baseAmount={controller.cartTotalAmount}
onConfirm={async (_discounts) => {}}
onClose={controller.handleProgressiveDiscountBottomSheetClosed}
/>
)}
{controller.totalSaleDiscountBottomSheetOpen && (
<NewSaleTotalDiscountBottomSheet
baseAmount={controller.cartTotalAmount}
onConfirm={async (_params) => {}}
onClose={controller.handleTotalSaleDiscountBottomSheetClosed}
/>
)}
TODO conhecido no código: desconto progressivo e desconto sobre o total da venda calculam e mostram preview de valores na UI, mas não persistem nada ainda, já que
onConfirmestá vazio. Tambémcontroller.cartTotalAmountusado comobaseAmounté um valor fixo (412.20) definido emnew-sale.controller.ts, não o total real do carrinho. Qualquer trabalho de continuar essas duas telas precisa: (1) trocarcartTotalAmountpor um total real vindo decalculateTotals, e (2) implementar oonConfirmchamando um service de desconto de carrinho ainda a ser criado (hoje só existe desconto por item).
Desconto por item (o caminho que funciona)
O componente ProductDiscountControl (usado inline no card do produto/item do carrinho) é o único caminho de desconto plenamente funcional. Ele suporta dois modos, percentual (%) ou valor fixo (R$), alternáveis por dois botões, e aplica a mudança ao perder o foco do campo:
// product-discount-control.tsx
<TextInput
value={controller.valueInput}
onChangeText={controller.onChangeValueInput}
onBlur={() => {
void controller.applyDiscount();
}}
...
/>
O controller monta o payload (percDesc no modo percentual, vlrDesc no modo valor) e chama o service de desconto do rascunho:
// product-discount-control.controller.ts
const updated = await saleDraftService.applyDiscount({
saleDraftId: businessKey.saleDraftId,
produtoId: businessKey.produtoId,
produtoIdExterno: businessKey.produtoIdExterno,
sequencia: businessKey.sequencia,
percDesc,
vlrDesc,
tipDesc: payload.mode === "currency" ? "V" : "P",
product: product as unknown as Product,
setStateProduct: setProducts,
});
Trocar de modo (% ↔ R$) também dispara a persistência imediatamente, convertendo o valor digitado para o novo modo antes de salvar (changeMode → persistDiscountNow).
O que SaleDraftDiscountService.applyDiscount faz
services/sale/sale-draft/sale-draft-discount/discount.sale-draft.service.ts não escreve no carrinho diretamente. Ele normaliza o desconto e delega para o mesmo caminho de upsert de item usado por qualquer alteração de quantidade ou preço, addOrUpdateItemByQuantity, do SaleDraftCartCoreService, detalhado em Carrinho e Itens:
async applyDiscount(params: {
saleDraftId: string;
produtoId: string;
produtoIdExterno: string;
sequencia: number;
percDesc?: number;
product: Product;
vlrDesc?: number;
disabledValidated?: boolean;
tipDesc?: "V" | "P";
setStateProduct?: Dispatch<SetStateAction<Product[]>>;
}): Promise<SaleDraftItem | null> {
await ensureDatabaseReady();
const normalizedDiscount = normalizeDiscountValues({
percDesc: params.percDesc,
vlrDesc: params.vlrDesc,
tipDesc: params.tipDesc,
});
params.product.percDesc = normalizedDiscount.percDesc;
params.product.vlrDesc = normalizedDiscount.vlrDesc;
params.product.tipDesc = normalizedDiscount.tipDesc;
if (!params?.disabledValidated) {
const productValidate = await validationSaleDraftService.validateProduct({
product: params.product,
validations: {
validateAddition: true,
validateDiscount: true,
validateMinQuantity: false,
validateStock: false,
},
});
params.product = productValidate;
}
return this.addOrUpdateItemByQuantity({
saleDraftId: params.saleDraftId,
produtoId: params.produtoId,
produtoIdExterno: params.produtoIdExterno,
sequencia: params.sequencia,
product: params.product,
setStateProduct: params.setStateProduct,
});
}
Isso significa que aplicar um desconto passa, por consequência, por todo o pipeline de preço do item, recalculando vlrUnit, vlrTotal, estoque e outros campos via calculatePriceByProduct, e pela sincronização de snapshot e notificação de evento. Não é um campo isolado gravado direto no item.
Normalização de valores de desconto
normalizeDiscountValues (em sale-draft-shared/sale-draft.helpers.ts) garante limites consistentes independentemente de onde o desconto venha:
export function normalizeDiscountValues(params: {
percDesc?: number;
vlrDesc?: number;
tipDesc?: "V" | "P";
}): { percDesc?: number; vlrDesc?: number; tipDesc?: "V" | "P" } {
const nextTipDesc = normalizeTipDesc(params.tipDesc);
const hasPercDesc = params.percDesc !== undefined && params.percDesc !== null;
const nextPercDesc = hasPercDesc
? clamp(round(normalizeNumber(params.percDesc), 4), -100, 100)
: undefined;
const hasVlrDesc = params.vlrDesc !== undefined && params.vlrDesc !== null;
const nextVlrDesc = hasVlrDesc
? Math.max(0, round(normalizeNumber(params.vlrDesc), 4))
: undefined;
return { percDesc: nextPercDesc, vlrDesc: nextVlrDesc, tipDesc: nextTipDesc };
}
percDesc(percentual): sempre entre-100e100. Um percentual negativo é permitido e funciona como acréscimo, como visto na validação de desconto máximo abaixo.vlrDesc(valor fixo): sempre>= 0.tipDesc: normalizado para"V"(valor) ou"P"(percentual); qualquer outro valor é descartado (undefined).
Validação de desconto no momento de aplicar
Ao aplicar desconto por item, a validação roda com validateDiscount: true e validateAddition: true, mas sem validar estoque/quantidade mínima (isso já foi validado em outro momento do fluxo de adição do item). Detalhes de como o desconto máximo permitido é verificado, inclusive a ação "Validar Desconto Máximo" do menu de mais opções do carrinho, estão em Validação Comercial.
Armadilhas conhecidas
- Não implemente desconto de item escrevendo direto no repositório. Use sempre
SaleDraftDiscountService.applyDiscount, que já cuida de normalização, validação, upsert, sync e notify. - As bottom sheets de desconto progressivo e desconto total ainda não persistem nada. Não assuma que estão funcionais só porque a UI existe. Confirme sempre lendo o
onConfirmrecebido emnew-sale.tsxantes de construir algo em cima delas. tipDesc: "V"no service corresponde ao modo "moeda" (currency) na UI. Os nomes não são idênticos entre camadas, com"V"/"P"no service e no domínio, e"currency"/"percent"no componente.