ajuste na regra de criação do movimento estorno e quitação de crédito considerando estorno

This commit is contained in:
Diego Freitas 2026-08-08 14:36:33 -03:00
parent f085f2623d
commit 65634edcdf
5 changed files with 120 additions and 31 deletions

View File

@ -147,7 +147,13 @@ async function criar(req, res) {
return res.status(201).json({
ok: true,
message: 'Crédito quitado com sucesso.',
data: resultado.quitacao,
data: {
...(resultado.quitacao || {}),
contasQuitadas: resultado.contasQuitadas,
valorQuitado: resultado.valorQuitado,
impactoSaldo: resultado.impactoSaldo || [],
},
// Mantidos no topo por compatibilidade com consumidores antigos.
contasQuitadas: resultado.contasQuitadas,
valorQuitado: resultado.valorQuitado,
impactoSaldo: resultado.impactoSaldo || [],
@ -238,4 +244,4 @@ module.exports = {
criar,
listarHistorico,
buscarPorId,
};
};

View File

@ -112,6 +112,19 @@ function idsUnicos(ids = []) {
)];
}
function contaEhSaidaAberta(conta) {
return conta?.movimento === 'Saida' && conta?.status === 'A pagar';
}
function contaEhEstornoRecebido(conta) {
return (conta?.movimento === 'Entrada' || conta?.movimento === 'Estorno') && conta?.status === 'Recebido';
}
function valorContaNaQuitacao(conta) {
const valor = Number(conta?.valor || 0);
return contaEhEstornoRecebido(conta) ? -valor : valor;
}
async function buscarBancoParaUpdate(connection, idbancos, idUsuario) {
const [rows] = await connection.query(
`
@ -205,10 +218,18 @@ async function listarBancosPagamento(idUsuario) {
function montarWhereContasPreview(filtros = {}) {
const where = [
'cp.deleted_at IS NULL',
"cp.movimento = 'Saida'",
"cp.status = 'A pagar'",
`(
(cp.movimento = 'Saida' AND cp.status = 'A pagar')
OR
(cp.movimento IN ('Entrada', 'Estorno') AND cp.status = 'Recebido')
)`,
'cp.idbancos = ?',
'b.idusuarios = ?',
`NOT EXISTS (
SELECT 1
FROM contasquitadasref cqr
WHERE cqr.idcontasapagar = cp.idcontasapagar
)`,
];
const params = [
@ -305,6 +326,11 @@ async function previewQuitacao(idUsuario, filtros = {}) {
[...params, limite, offset]
);
const contas = rows.map((conta) => ({
...conta,
valor_quitacao: arredondarMoeda(valorContaNaQuitacao(conta)),
}));
const [countRows] = await pool.query(
`
SELECT COUNT(*) AS total
@ -318,7 +344,12 @@ async function previewQuitacao(idUsuario, filtros = {}) {
`
SELECT
COUNT(*) AS quantidade,
COALESCE(SUM(cp.valor), 0) AS valorTotal,
COALESCE(SUM(
CASE
WHEN cp.movimento IN ('Entrada', 'Estorno') AND cp.status = 'Recebido' THEN -cp.valor
ELSE cp.valor
END
), 0) AS valorTotal,
MIN(cp.datavencimento) AS primeiroVencimento,
MAX(cp.datavencimento) AS ultimoVencimento
${FROM_CONTA_JOIN}
@ -333,7 +364,7 @@ async function previewQuitacao(idUsuario, filtros = {}) {
return {
bancoCredito,
contas: rows,
contas,
pagination: {
total,
limite,
@ -343,8 +374,11 @@ async function previewQuitacao(idUsuario, filtros = {}) {
},
summary: {
quantidade: Number(resumo.quantidade || 0),
totalAberto: arredondarMoeda(resumo.valorTotal || 0),
valorTotal: arredondarMoeda(resumo.valorTotal || 0),
saldoAtualCredito: arredondarMoeda(bancoCredito.saldo || 0),
saldoCreditoAtual: arredondarMoeda(bancoCredito.saldo || 0),
saldoAposQuitacaoTotal: arredondarMoeda(Number(bancoCredito.saldo || 0) - Number(resumo.valorTotal || 0)),
saldoCreditoAposQuitacaoTotal: arredondarMoeda(Number(bancoCredito.saldo || 0) - Number(resumo.valorTotal || 0)),
primeiroVencimento: resumo.primeiroVencimento || null,
ultimoVencimento: resumo.ultimoVencimento || null,
@ -366,6 +400,11 @@ async function buscarContasSelecionadasParaUpdate(connection, ids, idBancoCredit
WHERE cp.idcontasapagar IN (${placeholders})
AND cp.idbancos = ?
AND b.idusuarios = ?
AND NOT EXISTS (
SELECT 1
FROM contasquitadasref cqr
WHERE cqr.idcontasapagar = cp.idcontasapagar
)
FOR UPDATE
`,
[
@ -475,16 +514,16 @@ async function criarQuitacaoCredito(idUsuario, dados = {}) {
throw criarErro(`A conta ${conta.idcontasapagar} não pertence ao banco de crédito selecionado.`);
}
if (conta.movimento !== 'Saida') {
throw criarErro(`A conta ${conta.idcontasapagar} não é uma saída de crédito.`);
}
if (conta.status !== 'A pagar') {
throw criarErro(`A conta ${conta.idcontasapagar} não está em aberto para pagamento.`);
if (!contaEhSaidaAberta(conta) && !contaEhEstornoRecebido(conta)) {
throw criarErro(
`A conta ${conta.idcontasapagar} não é uma saída em aberto nem um estorno recebido.`
);
}
}
const valorTotal = arredondarMoeda(contasAntes.reduce((total, conta) => total + Number(conta.valor || 0), 0));
const valorTotal = arredondarMoeda(
contasAntes.reduce((total, conta) => total + valorContaNaQuitacao(conta), 0)
);
if (valorTotal <= 0) {
throw criarErro('O valor total da quitação deve ser maior que zero.');
@ -555,6 +594,8 @@ async function criarQuitacaoCredito(idUsuario, dados = {}) {
idbancos_p = ?,
update_date = NOW()
WHERE idcontasapagar IN (${placeholders})
AND movimento = 'Saida'
AND status = 'A pagar'
`,
[
dataQuitacao,
@ -837,4 +878,4 @@ module.exports = {
criarQuitacaoCredito,
listarHistorico,
buscarQuitacaoPorId,
};
};

View File

@ -59,7 +59,12 @@ type FormSectionProps = {
};
function hojeISO() {
return new Date().toISOString().slice(0, 10);
const hoje = new Date();
const ano = hoje.getFullYear();
const mes = String(hoje.getMonth() + 1).padStart(2, '0');
const dia = String(hoje.getDate()).padStart(2, '0');
return `${ano}-${mes}-${dia}`;
}
function calcularVencimentoCreditoProximoMes(diaVencimento: number, dataBaseISO?: string) {
@ -79,13 +84,11 @@ function calcularVencimentoCreditoProximoMes(diaVencimento: number, dataBaseISO?
const diaSeguro = Math.min(Number(diaVencimento), ultimoDiaProximoMes);
return new Date(
proximoMes.getFullYear(),
proximoMes.getMonth(),
diaSeguro
)
.toISOString()
.slice(0, 10);
const anoVencimento = proximoMes.getFullYear();
const mesVencimento = String(proximoMes.getMonth() + 1).padStart(2, '0');
const diaVencimentoFormatado = String(diaSeguro).padStart(2, '0');
return `${anoVencimento}-${mesVencimento}-${diaVencimentoFormatado}`;
}
function toNumberOrNull(value: string): number | null {
@ -287,6 +290,10 @@ export function MovimentoForm({ mode, initialData }: MovimentoFormProps) {
return ['Recebido', 'A receber'];
}
if (movimento === 'Estorno') {
return ['Recebido'];
}
if (movimento === 'Saida' || movimento === 'Sangria') {
return ['Pago', 'A pagar'];
}
@ -393,7 +400,7 @@ export function MovimentoForm({ mode, initialData }: MovimentoFormProps) {
useEffect(() => {
if (isEdit) return;
if (movimento !== 'Saida') return;
if (movimento !== 'Saida' && movimento !== 'Estorno') return;
if (!bancoSelecionado) return;
if (Number(bancoSelecionado.debito) !== 0) return;
if (!bancoSelecionado.dia_vencimento) return;
@ -405,9 +412,15 @@ export function MovimentoForm({ mode, initialData }: MovimentoFormProps) {
setDataVencimento(novaDataVencimento);
if (status === 'Pago') {
if (movimento === 'Saida' && status === 'Pago') {
setStatus('A pagar');
setDataBaixa('');
return;
}
if (movimento === 'Estorno') {
setStatus('Recebido');
setDataBaixa(dataEntrada || hojeISO());
}
}, [
isEdit,

View File

@ -102,6 +102,23 @@ function impactoColor(valorDelta: number) {
return valorDelta >= 0 ? 'success.main' : 'error.main';
}
function valorContaQuitacao(conta: ContaCreditoQuitacao) {
const ehEstornoRecebido =
(conta.movimento === 'Entrada' || conta.movimento === 'Estorno') &&
conta.status === 'Recebido';
return Number(
conta.valor_quitacao ??
(ehEstornoRecebido
? -Number(conta.valor || 0)
: Number(conta.valor || 0))
);
}
function contaEhEstorno(conta: ContaCreditoQuitacao) {
return valorContaQuitacao(conta) < 0;
}
export function QuitacoesCreditoPage() {
const [bancosCredito, setBancosCredito] = useState<BancoQuitacao[]>([]);
const [bancosPagamento, setBancosPagamento] = useState<BancoQuitacao[]>([]);
@ -142,7 +159,7 @@ export function QuitacoesCreditoPage() {
}, [contas, idsSelecionados]);
const totalSelecionado = useMemo(() => {
return contasSelecionadas.reduce((total, conta) => total + Number(conta.valor || 0), 0);
return contasSelecionadas.reduce((total, conta) => total + valorContaQuitacao(conta), 0);
}, [contasSelecionadas]);
const todasSelecionadas = contas.length > 0 && idsSelecionados.length === contas.length;
@ -627,8 +644,12 @@ export function QuitacoesCreditoPage() {
sx={{ margin: 0 }}
/>
<Typography fontWeight={950} whiteSpace="nowrap">
{formatarValor(conta.valor)}
<Typography
fontWeight={950}
whiteSpace="nowrap"
color={contaEhEstorno(conta) ? 'success.main' : 'inherit'}
>
{formatarValor(valorContaQuitacao(conta))}
</Typography>
</Stack>
@ -639,6 +660,9 @@ export function QuitacoesCreditoPage() {
variant="outlined"
color={statusChipColor(conta.status) as any}
/>
{contaEhEstorno(conta) && (
<Chip label="Estorno" size="small" color="success" variant="outlined" />
)}
<Chip
label={`${conta.parcela}/${conta.parcelas}`}
size="small"
@ -726,8 +750,11 @@ export function QuitacoesCreditoPage() {
<TableCell>{formatarData(conta.datavencimento)}</TableCell>
<TableCell align="right">
<Typography fontWeight={950}>
{formatarValor(conta.valor)}
<Typography
fontWeight={950}
color={contaEhEstorno(conta) ? 'success.main' : 'inherit'}
>
{formatarValor(valorContaQuitacao(conta))}
</Typography>
</TableCell>
@ -967,4 +994,4 @@ export function QuitacoesCreditoPage() {
</Stack>
</Box>
);
}
}

View File

@ -15,6 +15,8 @@ export type ContaCreditoQuitacao = {
datavencimento: string | null;
databaixa: string | null;
valor: number;
/** Valor com sinal usado na quitação: saídas positivas e estornos negativos. */
valor_quitacao?: number;
parcela: number;
parcelas: number;
status: string;
@ -107,4 +109,4 @@ export type ApiResponse<T> = {
ok: boolean;
message?: string;
data: T;
};
};