fix(finance): implement gross amount recovery and protection against net value overwrites in portal and manager

This commit is contained in:
Sidney 2026-05-15 08:33:59 -03:00
parent cf1ad968ca
commit 8a42db3e58
4 changed files with 70 additions and 12 deletions

View File

@ -1180,8 +1180,26 @@ const Finance: React.FC<FinanceProps> = ({ data, updateData }) => {
</td> </td>
<td className="px-4 py-5 text-slate-600 text-sm">{new Date(payment.dueDate + 'T12:00:00Z').toLocaleDateString('pt-BR', { timeZone: 'America/Sao_Paulo' })}</td> <td className="px-4 py-5 text-slate-600 text-sm">{new Date(payment.dueDate + 'T12:00:00Z').toLocaleDateString('pt-BR', { timeZone: 'America/Sao_Paulo' })}</td>
<td className="px-4 py-5"> <td className="px-4 py-5">
<div className="font-black text-slate-900 text-sm">R$ {payment.amount.toLocaleString('pt-BR', { minimumFractionDigits: 2 })}</div> <div className="font-black text-slate-900 text-sm">
{!!payment.discount && payment.discount > 0 && <div className="text-[10px] text-emerald-600 font-bold">- R$ {payment.discount.toFixed(2)}</div>} R$ {(() => {
const amt = Number(payment.amount);
const disc = Number(payment.discount || 0);
const status = (payment.status || '').toLowerCase();
const isPaid = status === 'paid' || status === 'pago' || status === 'received' || status === 'confirmed';
// Se está pago e temos desconto, e o valor original é maior ou não existe, tentamos recuperar o bruto
// No manager, as cobranças do SQL (filteredPayments) costumam ter amount_original
const amtOrig = (payment as any).amount_original ? Number((payment as any).amount_original) : 0;
if (isPaid && disc > 0) {
if (amtOrig > amt) return amtOrig.toLocaleString('pt-BR', { minimumFractionDigits: 2 });
// Se não tem amtOrig mas o valor atual é suspeito de ser líquido (não implementado aqui para evitar falso positivo,
// mas o sync de backend já deve ter protegido o amount_original agora)
}
return amt.toLocaleString('pt-BR', { minimumFractionDigits: 2 });
})()}
</div>
{!!payment.discount && payment.discount > 0 && <div className="text-[10px] text-emerald-600 font-bold">- R$ {payment.discount.toLocaleString('pt-BR', { minimumFractionDigits: 2 })}</div>}
</td> </td>
<td className="px-4 py-5">{getStatusBadge(payment)}</td> <td className="px-4 py-5">{getStatusBadge(payment)}</td>
<td className="px-4 py-5"> <td className="px-4 py-5">

View File

@ -1572,8 +1572,15 @@ async function syncPaymentsWithAsaasAPI() {
changed = true; changed = true;
} }
// SEMPRE atualiza o valor e a data para garantir fidelidade ao Asaas // [Bugfix Crítico]: Não sobrescrever o valor BRUTO com o valor LÍQUIDO (descontado) do Asaas
if (appData.payments[pIdx].amount !== valorNum) { const currentAmount = Number(appData.payments[pIdx].amount || 0);
const currentDiscount = Number(appData.payments[pIdx].discount || 0);
// Se o valor vindo do Asaas for menor que o atual E a diferença bater com o desconto, ignoramos o update do valor
// para preservar o valor bruto original no display do portal/gerenciador.
const isNetValueOverwrite = valorNum < currentAmount && Math.abs((currentAmount - currentDiscount) - valorNum) < 0.01;
if (appData.payments[pIdx].amount !== valorNum && !isNetValueOverwrite) {
appData.payments[pIdx].amount = valorNum; appData.payments[pIdx].amount = valorNum;
changed = true; changed = true;
} }

View File

@ -572,7 +572,7 @@ export async function syncJsonToRelationalTables() {
total_installments = COALESCE(EXCLUDED.total_installments, alunos_cobrancas.total_installments), total_installments = COALESCE(EXCLUDED.total_installments, alunos_cobrancas.total_installments),
contract_id = COALESCE(EXCLUDED.contract_id, alunos_cobrancas.contract_id), contract_id = COALESCE(EXCLUDED.contract_id, alunos_cobrancas.contract_id),
asaas_payment_url = COALESCE(EXCLUDED.asaas_payment_url, alunos_cobrancas.asaas_payment_url), asaas_payment_url = COALESCE(EXCLUDED.asaas_payment_url, alunos_cobrancas.asaas_payment_url),
amount_original = COALESCE(EXCLUDED.amount_original, alunos_cobrancas.amount_original), amount_original = GREATEST(COALESCE(alunos_cobrancas.amount_original, 0), EXCLUDED.amount_original),
data_pagamento = COALESCE(EXCLUDED.data_pagamento, alunos_cobrancas.data_pagamento)`, data_pagamento = COALESCE(EXCLUDED.data_pagamento, alunos_cobrancas.data_pagamento)`,
[ [
p.studentId, p.studentId,

View File

@ -278,16 +278,24 @@ app.get('/api/portal/financeiro', authMiddleware, async (req, res) => {
} }
} }
// [Bugfix Crítico]: O webhook do Asaas sobrescreveu o JSON e o banco com o valor LÍQUIDO. // [Bugfix Crítico]: Recuperar valor bruto se o Asaas/Webhook salvou apenas o líquido
// Ou seja, jsonP.amount e db.valor estão iguais (ex: 150), mas o correto é 170.
// Se detectarmos essa corrupção (amountOriginal == db.valor) e houver desconto, restauramos o valor bruto.
let amountOriginal = jsonP.amount !== undefined ? Number(jsonP.amount) : (Number(db.amount_original) || Number(db.valor) || 0); let amountOriginal = jsonP.amount !== undefined ? Number(jsonP.amount) : (Number(db.amount_original) || Number(db.valor) || 0);
const discount = jsonP.discount !== undefined ? Number(jsonP.discount) : (Number(db.discount) || 0); const discount = jsonP.discount !== undefined ? Number(jsonP.discount) : (Number(db.discount) || 0);
const isPaid = ['paid', 'pago', 'received', 'confirmed'].includes(normalizedStatus);
// Aplica a recuperação matemática INDEPENDENTE de onde veio (SQL ou JSON) // Se está pago e o valor que temos parece ser o líquido (igual ao do banco que o webhook sobrescreve)
if (amountOriginal > 0 && amountOriginal === Number(db.valor) && discount > 0) { // e temos um desconto registrado, restauramos o bruto para o display.
if (isPaid && discount > 0 && amountOriginal > 0 && (amountOriginal === Number(db.valor) || amountOriginal < (Number(db.valor) + discount))) {
// Se amountOriginal já é o bruto (maior que db.valor), o GREATEST ou a lógica preserva.
// Se for igual, somamos o desconto.
if (amountOriginal === Number(db.valor)) {
amountOriginal += discount; amountOriginal += discount;
} }
}
// Garantir que sempre usamos o maior valor conhecido como bruto
const dbAmountOrig = Number(db.amount_original || 0);
if (dbAmountOrig > amountOriginal) amountOriginal = dbAmountOrig;
finalPayments.push({ finalPayments.push({
id: jsonP.id || asaasId, id: jsonP.id || asaasId,
@ -338,7 +346,32 @@ app.get('/api/portal/boletos', authMiddleware, async (req, res) => {
`SELECT *, TO_CHAR(vencimento, 'YYYY-MM-DD') as vencimento, TO_CHAR(data_pagamento, 'YYYY-MM-DD') as data_pagamento FROM alunos_cobrancas WHERE aluno_id = $1 ORDER BY vencimento ASC`, `SELECT *, TO_CHAR(vencimento, 'YYYY-MM-DD') as vencimento, TO_CHAR(data_pagamento, 'YYYY-MM-DD') as data_pagamento FROM alunos_cobrancas WHERE aluno_id = $1 ORDER BY vencimento ASC`,
[req.user.studentId] [req.user.studentId]
); );
res.json({ boletos: rows || [] });
// [Bugfix Crítico]: Recuperar valor bruto original se o banco estiver com o valor líquido
const boletos = (rows || []).map(b => {
let valor = Number(b.valor);
const discount = Number(b.discount || 0);
const amountOriginal = Number(b.amount_original || 0);
const status = (b.status || '').toLowerCase();
const isPaid = ['paid', 'pago', 'received', 'confirmed', 'recebido'].includes(status);
// Prioridade 1: amount_original explícito
if (amountOriginal > valor) {
valor = amountOriginal;
}
// Prioridade 2: Recomposição matemática se estiver pago e valor == líquido
else if (isPaid && discount > 0 && valor > 0) {
// Se o valor no banco é exatamente o que o webhook salvaria (líquido), recompomos
valor += discount;
}
return {
...b,
valor: valor
};
});
res.json({ boletos });
} catch (err) { } catch (err) {
console.error('Boletos error:', err); console.error('Boletos error:', err);
res.json({ boletos: [] }); res.json({ boletos: [] });