feat: implement robust valor_pago architecture and financial sync hardening for Portal and Manager

This commit is contained in:
Sidney 2026-05-15 09:49:07 -03:00
parent f6022fd0fc
commit ed52d6a2fa
7 changed files with 172 additions and 164 deletions

View File

@ -387,6 +387,10 @@ const Finance: React.FC<FinanceProps> = ({ data, updateData }) => {
payments: sorted,
studentId: sorted[0].studentId,
totalAmount: sorted.reduce((sum, p) => sum + Number(p.amount), 0),
totalReceived: sorted.reduce((sum, p) => {
const isPaid = ['paid', 'pago', 'received', 'confirmed'].includes((p.status || '').toLowerCase());
return sum + (isPaid ? (Number((p as any).valor_pago) || (Number(p.amount) - (Number(p.discount) || 0))) : 0);
}, 0),
totalInstallments: sorted[0].totalInstallments || sorted.length,
description: sorted[0].description?.split(' (')[0] || 'Parcelamento',
dueDate: sorted[0].dueDate
@ -1055,6 +1059,9 @@ const Finance: React.FC<FinanceProps> = ({ data, updateData }) => {
</td>
<td className="px-6 py-5">
<div className="font-black text-slate-900">R$ {group.totalAmount.toLocaleString('pt-BR', { minimumFractionDigits: 2 })}</div>
{group.totalReceived > 0 && (
<div className="text-[10px] text-blue-600 font-black">PAGO: R$ {group.totalReceived.toLocaleString('pt-BR', { minimumFractionDigits: 2 })}</div>
)}
<div className="text-[10px] text-slate-500 font-medium">Total do Carnê</div>
</td>
<td className="px-6 py-5">
@ -1402,7 +1409,22 @@ const Finance: React.FC<FinanceProps> = ({ data, updateData }) => {
{p.installmentNumber && <div className="text-[9px] text-slate-400">{p.installmentNumber}/{p.totalInstallments}</div>}
</td>
<td className="px-4 py-3">{new Date(p.dueDate + 'T12:00:00Z').toLocaleDateString('pt-BR', { timeZone: 'America/Sao_Paulo' })}</td>
<td className="px-4 py-3">R$ {p.amount.toFixed(2)}</td>
<td className="px-4 py-3">
<div className="font-bold text-slate-700">
R$ {(() => {
const isPaid = ['paid', 'pago', 'received', 'confirmed'].includes((p.status || '').toLowerCase());
const valorPago = (p as any).valor_pago ? Number((p as any).valor_pago) : 0;
const bruto = Math.max(Number(p.amount), Number((p as any).amount_original || 0));
if (isPaid && valorPago > 0) return valorPago.toFixed(2);
if (isPaid && p.discount > 0) return (bruto - p.discount).toFixed(2);
return bruto.toFixed(2);
})()}
</div>
{['paid', 'pago', 'received', 'confirmed'].includes((p.status || '').toLowerCase()) && (p as any).valor_pago > 0 && (
<div className="text-[9px] text-indigo-500 font-bold">Líquido Recebido</div>
)}
</td>
<td className="px-4 py-3">{getStatusBadge(p)}</td>
<td className="px-4 py-3 text-right flex justify-end gap-2">
{p.asaasPaymentId && (

View File

@ -0,0 +1,41 @@
const fs = require('fs');
const path = require('path');
const { Pool } = require('pg');
const pool = new Pool({
connectionString: 'postgresql://edumanager:EduManager2026!Seguro@localhost:5432/edumanager'
});
async function massSync() {
console.log('--- Iniciando Sincronização em Massa (SQL -> JSON) ---');
try {
const { rows: dbPayments } = await pool.query('SELECT asaas_payment_id, valor_pago FROM alunos_cobrancas WHERE valor_pago > 0');
console.log(`Encontrados ${dbPayments.length} pagamentos com valor_pago no SQL.`);
const jsonPath = 'C:/Users/Professor/Downloads/remix_-edumanager---sistema-de-gestão-escolar-para-porteiner/school_data.json';
const data = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
let updatedCount = 0;
dbPayments.forEach(dbP => {
const pIdx = data.payments.findIndex(p => p.asaasPaymentId === dbP.asaas_payment_id);
if (pIdx !== -1) {
data.payments[pIdx].valor_pago = Number(dbP.valor_pago);
updatedCount++;
}
});
data.lastUpdated = new Date().toISOString();
fs.writeFileSync(jsonPath, JSON.stringify(data, null, 2));
console.log(`--- Sincronização Concluída ---`);
console.log(`JSON atualizado com ${updatedCount} valores reais de pagamento.`);
} catch (err) {
console.error('Erro na sincronização:', err);
} finally {
process.exit();
}
}
massSync();

View File

@ -0,0 +1,32 @@
const fs = require('fs');
const path = require('path');
const { Pool } = require('pg');
const pool = new Pool({
connectionString: 'postgresql://edumanager:EduManager2026!Seguro@localhost:5432/edumanager'
});
async function debug() {
const studentId = '311709fb-68ab-4168-8684-887b5ec2d731';
console.log('--- SQL DATA (Napoleão) ---');
const { rows } = await pool.query('SELECT asaas_payment_id, valor, amount_original, valor_pago, vencimento, status FROM alunos_cobrancas WHERE aluno_id = $1', [studentId]);
console.table(rows);
console.log('--- JSON DATA (Napoleão) ---');
const jsonPath = 'C:/Users/Professor/Downloads/remix_-edumanager---sistema-de-gestão-escolar-para-porteiner/school_data.json';
const data = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
const payments = data.payments.filter(p => p.studentId === studentId);
console.table(payments.map(p => ({
asaasPaymentId: p.asaasPaymentId,
amount: p.amount,
discount: p.discount,
dueDate: p.dueDate,
status: p.status
})));
process.exit();
}
debug();

View File

@ -1,89 +0,0 @@
import pg from 'pg';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const pool = new pg.Pool({
connectionString: process.env.DATABASE_URL || 'postgresql://edumanager:edumanager2024@localhost:5432/edumanager'
});
async function debug() {
try {
// 1. Buscar todos os alunos
const alunos = await pool.query('SELECT id, nome FROM alunos LIMIT 20');
console.log('\n=== ALUNOS CADASTRADOS ===');
alunos.rows.forEach(a => console.log(` ${a.id} | ${a.nome}`));
// 2. Buscar TODAS as cobranças do SQL
const cobrancas = await pool.query(`
SELECT asaas_payment_id, aluno_id, status,
TO_CHAR(vencimento, 'YYYY-MM-DD') as vencimento,
valor, amount_original, discount, description, type,
TO_CHAR(data_pagamento, 'YYYY-MM-DD') as data_pagamento,
transaction_receipt_url
FROM alunos_cobrancas
ORDER BY aluno_id, vencimento ASC
`);
console.log(`\n=== COBRANÇAS NO SQL (${cobrancas.rows.length} total) ===`);
const byStudent = {};
cobrancas.rows.forEach(c => {
if (!byStudent[c.aluno_id]) byStudent[c.aluno_id] = [];
byStudent[c.aluno_id].push(c);
});
for (const [alunoId, payments] of Object.entries(byStudent)) {
const aluno = alunos.rows.find(a => a.id === alunoId);
console.log(`\n 📌 ${aluno?.nome || alunoId} (${payments.length} cobranças):`);
payments.forEach(p => {
const status = p.status?.toUpperCase();
const icon = status === 'PAGO' ? '✅' : status === 'PENDENTE' ? '⏳' : status === 'ATRASADO' ? '🔴' : '❓';
console.log(` ${icon} ${p.asaas_payment_id} | ${p.vencimento} | R$${Number(p.valor).toFixed(2)} | Status: ${p.status} | Pago em: ${p.data_pagamento || '-'} | Desc: ${p.description || '-'} | amount_original: ${p.amount_original || '-'}`);
});
}
// 3. Buscar do JSON
const dataPath = path.join(__dirname, '..', 'data', 'school_data.json');
if (fs.existsSync(dataPath)) {
const data = JSON.parse(fs.readFileSync(dataPath, 'utf-8'));
const jsonPayments = data.payments || [];
console.log(`\n=== COBRANÇAS NO JSON (${jsonPayments.length} total) ===`);
const jsonByStudent = {};
jsonPayments.forEach(p => {
if (!jsonByStudent[p.studentId]) jsonByStudent[p.studentId] = [];
jsonByStudent[p.studentId].push(p);
});
for (const [studentId, payments] of Object.entries(jsonByStudent)) {
const aluno = alunos.rows.find(a => a.id === studentId);
console.log(`\n 📌 ${aluno?.nome || studentId} (${payments.length} cobranças no JSON):`);
payments.forEach(p => {
const inSql = cobrancas.rows.find(c => c.asaas_payment_id === p.asaasPaymentId);
console.log(` ${inSql ? '🔗' : '⚠️'} ${p.asaasPaymentId || 'SEM_ID'} | ${p.dueDate} | R$${p.amount} | Status: ${p.status} | Desc: ${p.description || '-'} | discount: ${p.discount || 0} | ${inSql ? 'Existe no SQL' : 'SÓ NO JSON!'}`);
});
}
// 4. Verificar cobranças que existem no SQL mas NÃO no JSON
console.log('\n=== COBRANÇAS QUE EXISTEM NO SQL MAS NÃO NO JSON ===');
let orphanCount = 0;
cobrancas.rows.forEach(c => {
const inJson = jsonPayments.find(p => p.asaasPaymentId === c.asaas_payment_id);
if (!inJson) {
orphanCount++;
console.log(` ⚠️ ${c.asaas_payment_id} | Aluno: ${c.aluno_id} | ${c.vencimento} | R$${Number(c.valor).toFixed(2)} | Status: ${c.status}`);
}
});
if (orphanCount === 0) console.log(' ✅ Nenhuma — tudo sincronizado.');
}
} catch (err) {
console.error('Erro:', err);
} finally {
await pool.end();
}
}
debug();

View File

@ -802,10 +802,16 @@ app.post('/api/webhook_asaas', async (req, res) => {
break;
case 'PAYMENT_UPDATED':
updateData = {
valor: payload.payment.value,
vencimento: payload.payment.dueDate,
link_boleto: payload.payment.bankSlipUrl || payload.payment.link || null
};
// Alerta no Sino (Admin)
createAdminNotification(
'📝 Cobrança Alterada',
`A cobrança de ${targetName} foi atualizada no Asaas.`,
`A cobrança de ${targetName} foi atualizada no Asaas para R$ ${Number(payload.payment.value).toFixed(2)}.`,
{ type: 'finance', status: 'updated', paymentId: asaasPaymentId }
);
if (payload.event === 'PAYMENT_UPDATED') sendEvolutionMessage(asaasPaymentId, 'PAYMENT_UPDATED');
@ -866,6 +872,7 @@ app.post('/api/webhook_asaas', async (req, res) => {
...p,
status: newStatus,
amount: shouldUpdateAmount ? updateData.valor : p.amount,
valor_pago: updateData.valor_pago || p.valor_pago || 0,
dueDate: updateData.vencimento || p.dueDate,
paidDate: updateData.data_pagamento || p.paidDate
};
@ -1578,40 +1585,49 @@ async function syncPaymentsWithAsaasAPI() {
const valorNum = Number(payment.value);
// A. Atualiza SQL (Silencioso)
// A. Atualiza SQL (Prioridade Máxima)
const receivedValue = (internalStatus === 'paid') ? valorNum : 0;
await pool.query(`
INSERT INTO alunos_cobrancas (asaas_payment_id, valor, vencimento, status, data_pagamento)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (asaas_payment_id) DO UPDATE SET status = EXCLUDED.status, data_pagamento = EXCLUDED.data_pagamento
`, [payment.id, valorNum, payment.dueDate, internalStatus, payment.confirmedDate || payment.paymentDate]).catch(() => {});
INSERT INTO alunos_cobrancas (asaas_payment_id, valor, vencimento, status, data_pagamento, valor_pago, amount_original)
VALUES ($1, $2, $3, $4, $5, $6, $2)
ON CONFLICT (asaas_payment_id) DO UPDATE SET
status = EXCLUDED.status,
data_pagamento = EXCLUDED.data_pagamento,
valor_pago = GREATEST(alunos_cobrancas.valor_pago, EXCLUDED.valor_pago),
valor = GREATEST(alunos_cobrancas.valor, EXCLUDED.valor)
`, [payment.id, valorNum, payment.dueDate, internalStatus, payment.confirmedDate || payment.paymentDate, receivedValue]).catch(() => {});
// B. Atualiza JSON
const pIdx = appData.payments.findIndex(p => p.asaasPaymentId === payment.id);
if (pIdx !== -1) {
const newStatus = jsonStatusMap[internalStatus];
const p = appData.payments[pIdx];
let changed = false;
if (appData.payments[pIdx].status !== newStatus) {
appData.payments[pIdx].status = newStatus;
if (p.status !== newStatus) {
p.status = newStatus;
changed = true;
}
// [Bugfix Crítico]: Não sobrescrever o valor BRUTO com o valor LÍQUIDO (descontado) do Asaas
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 currentAmount = Number(p.amount || 0);
const currentDiscount = Number(p.discount || 0);
const isNetValueOverwrite = valorNum < currentAmount && Math.abs((currentAmount - currentDiscount) - valorNum) < 0.01;
if (appData.payments[pIdx].amount !== valorNum && !isNetValueOverwrite) {
appData.payments[pIdx].amount = valorNum;
if (p.amount !== valorNum && !isNetValueOverwrite) {
p.amount = valorNum;
changed = true;
}
// Adicionar valor_pago ao JSON para o Manager ler
if (receivedValue > 0 && Number(p.valor_pago || 0) !== receivedValue) {
p.valor_pago = receivedValue;
changed = true;
}
const newPaidDate = payment.confirmedDate || payment.paymentDate;
if (newPaidDate && appData.payments[pIdx].paidDate !== newPaidDate) {
appData.payments[pIdx].paidDate = newPaidDate;
if (newPaidDate && p.paidDate !== newPaidDate) {
p.paidDate = newPaidDate;
changed = true;
}
@ -1648,9 +1664,19 @@ async function syncRelationalToJsonPayments() {
statusStr === 'atrasado' ? 'overdue' :
statusStr === 'cancelado' ? 'cancelled' : 'pending';
if (p.status !== newStatus) {
const hasChanges = p.status !== newStatus ||
Number(p.valor_pago || 0) !== Number(match.valor_pago || 0) ||
Number(p.amount || 0) !== Math.max(Number(match.amount_original || 0), Number(match.valor || 0));
if (hasChanges) {
updatedCount++;
return { ...p, status: newStatus, paidDate: match.data_pagamento || p.paidDate };
return {
...p,
status: newStatus,
paidDate: match.data_pagamento || p.paidDate,
valor_pago: Number(match.valor_pago || 0),
amount: Math.max(Number(match.amount_original || 0), Number(match.valor || 0))
};
}
}
return p;

View File

@ -283,18 +283,22 @@ app.get('/api/portal/financeiro', authMiddleware, async (req, res) => {
const isPaid = ['paid', 'pago', 'received', 'confirmed'].includes(normalizedStatus);
const valorPagoNoSQL = Number(db.valor_pago || 0);
// Tentamos pegar o maior valor disponível como o Bruto
let amountBruto = Number(db.amount_original) || Number(jsonP.amount) || Number(db.valor) || 0;
// [NOVA LÓGICA]: Pegar o MAIOR valor entre todas as fontes para garantir que seja o BRUTO
let amountBruto = Math.max(
Number(db.amount_original || 0),
Number(db.valor || 0),
Number(jsonP.amount || 0)
);
// Se está pago e o amountBruto parece ser o líquido (igual ao pago), recomponha
if (isPaid && discount > 0 && amountBruto > 0 && (amountBruto === valorPagoNoSQL || (valorPagoNoSQL === 0 && amountBruto === Number(db.valor)))) {
// Se o SQL tem o valor_pago correto, e o amountBruto é igual a ele, some o desconto
if (amountBruto === valorPagoNoSQL) {
amountBruto += discount;
} else if (valorPagoNoSQL === 0 && amountBruto === Number(db.valor)) {
// Fallback para quando valor_pago ainda não foi preenchido (primeira vez)
amountBruto += discount;
}
// Se o valor bruto encontrado é igual ao que foi pago, e existe desconto,
// então o que encontramos era na verdade o valor líquido. Recuperamos o bruto somando o desconto.
if (isPaid && discount > 0 && amountBruto > 0) {
if (valorPagoNoSQL > 0 && amountBruto <= valorPagoNoSQL) {
amountBruto = valorPagoNoSQL + discount;
} else if (amountBruto < (amountBruto + discount) && amountBruto === (jsonP.amount || 0)) {
// Se veio do JSON e parece ser o líquido
amountBruto = Number(jsonP.amount) + discount;
}
}
finalPayments.push({
@ -304,7 +308,7 @@ app.get('/api/portal/financeiro', authMiddleware, async (req, res) => {
asaasPaymentUrl: db.asaas_payment_url || jsonP.asaasPaymentUrl || null,
amount: amountBruto,
discount: discount,
valor_pago: valorPagoNoSQL || (isPaid ? Number(db.valor) : 0),
valor_pago: valorPagoNoSQL > 0 ? valorPagoNoSQL : (isPaid ? (amountBruto - discount) : 0),
dueDate: db.vencimento || jsonP.dueDate,
status: normalizedStatus,
paidDate: db.data_pagamento || jsonP.paidDate || null,

View File

@ -152,56 +152,28 @@ export default function Financeiro() {
return (boleto as any)?.link_boleto || null;
};
const getEffectiveValue = (payment: Payment) => {
const baseAmount = payment.amount || 0;
const discount = payment.discount || 0;
const netAmount = baseAmount - discount;
const getDisplayValue = (payment: Payment) => {
const status = normalizeStatus(payment);
const isPaid = status === 'paid';
const valorPago = (payment as any).valor_pago ? Number((payment as any).valor_pago) : 0;
// Try to find matching boleto from Supabase sync
const asaasId = payment.asaasPaymentId || (payment as any).asaas_payment_id;
let boleto = null;
// Se está pago e temos o valor real no banco, mostramos ele SEM CÁLCULOS
if (isPaid && valorPago > 0) return valorPago;
if (asaasId) {
boleto = boletos.find(b => (b as any).asaas_payment_id === asaasId);
}
// Se está pago mas o banco ainda não sincronizou o valor_pago (fallback)
if (isPaid) return payment.amount - (payment.discount || 0);
if (!boleto) {
// Fallback: Match by due date and base amount (allowing for interest/fines)
boleto = boletos.find(b => {
const bVenc = (b as any).vencimento;
const bVal = Number((b as any).valor);
// Exact date match
if (bVenc === payment.dueDate) {
// If value is exactly base or exactly net
if (Math.abs(bVal - baseAmount) < 1 || Math.abs(bVal - netAmount) < 1) return true;
// If it's overdue, the boleto value will be HIGHER than baseAmount
if (status === 'overdue' && bVal > netAmount) return true;
}
return false;
});
}
// If we have a boleto and it is overdue or paid, use current Asaas value
if (boleto && (boleto as any).valor) {
const bValue = Number((boleto as any).valor);
if (status === 'overdue' || status === 'paid') {
return bValue;
}
}
// Default: use the discounted base value (net amount)
return netAmount;
// Se está pendente ou atrasado, mostramos o que falta pagar (Líquido esperado)
return payment.amount - (payment.discount || 0);
};
const totalPending = payments
.filter(p => isPending(p))
.reduce((s, p) => s + getEffectiveValue(p), 0);
.reduce((s, p) => s + getDisplayValue(p), 0);
const totalPaid = payments
.filter(p => isPaid(p))
.reduce((s, p) => s + getEffectiveValue(p), 0);
.reduce((s, p) => s + getDisplayValue(p), 0);
const filters: { key: FilterType; label: string }[] = [
{ key: 'all', label: 'Todos' },
@ -348,7 +320,7 @@ export default function Financeiro() {
fontWeight: 600,
color: normalizeStatus(payment) === 'overdue' ? 'var(--color-danger)' : 'var(--color-primary-light)'
}}>
{formatCurrency(getEffectiveValue(payment))}
{formatCurrency(getDisplayValue(payment))}
</td>
<td data-label="Status">{getStatusBadge(payment)}</td>
<td>
@ -415,7 +387,7 @@ export default function Financeiro() {
</div>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: 'var(--color-text-secondary)' }}>Valor Pago:</span>
<span style={{ fontWeight: 600 }}>{formatCurrency(receiptPayment.amount - (receiptPayment.discount || 0))}</span>
<span style={{ fontWeight: 600 }}>{formatCurrency(getEffectiveValue(receiptPayment))}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: 'var(--color-text-secondary)' }}>Data de Vencimento:</span>