feat(finance): add valor_pago column and implement robust gross/net separation logic

This commit is contained in:
Sidney 2026-05-15 08:55:02 -03:00
parent 8a42db3e58
commit f6022fd0fc
5 changed files with 168 additions and 48 deletions

View File

@ -1186,20 +1186,25 @@ const Finance: React.FC<FinanceProps> = ({ data, updateData }) => {
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;
const valorPago = (payment as any).valor_pago ? Number((payment as any).valor_pago) : 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)
let bruto = amt;
if (amtOrig > bruto) bruto = amtOrig;
// Se está pago e o bruto atual parece ser o líquido, recompomos
if (isPaid && disc > 0 && bruto > 0 && (bruto === valorPago || (valorPago === 0 && bruto === amt))) {
bruto += disc;
}
return amt.toLocaleString('pt-BR', { minimumFractionDigits: 2 });
return bruto.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>}
{(payment as any).valor_pago > 0 && (
<div className="text-[10px] text-blue-600 font-black mt-1 bg-blue-50 px-1 rounded inline-block">
PAGO: R$ {Number((payment as any).valor_pago).toLocaleString('pt-BR', { minimumFractionDigits: 2 })}
</div>
)}
</td>
<td className="px-4 py-5">{getStatusBadge(payment)}</td>
<td className="px-4 py-5">

View File

@ -0,0 +1,72 @@
import pg from 'pg';
import fs from 'fs/promises';
import path from 'path';
const DATABASE_URL = 'postgresql://edumanager:EduManager2026!Seguro@127.0.0.1:5432/edumanager';
const schoolDataPath = 'c:/Users/Professor/Downloads/remix_-edumanager---sistema-de-gestão-escolar-para-porteiner/school_data.json';
async function migrate() {
const pool = new pg.Pool({ connectionString: DATABASE_URL });
try {
console.log('🚀 Iniciando Migração Financeira V2...');
// 1. Adicionar nova coluna valor_pago se não existir
console.log('1. Atualizando esquema do banco...');
await pool.query('ALTER TABLE alunos_cobrancas ADD COLUMN IF NOT EXISTS valor_pago NUMERIC(10,2) DEFAULT 0');
console.log('✅ Coluna valor_pago adicionada.');
// 2. Ler JSON legado
console.log('2. Lendo school_data.json...');
const jsonData = JSON.parse(await fs.readFile(schoolDataPath, 'utf8'));
const payments = jsonData.payments || [];
console.log(`📊 Encontrados ${payments.length} pagamentos no JSON.`);
// 3. Migrar dados
let updatedCount = 0;
for (const p of payments) {
if (!p.asaasPaymentId) continue;
const isPaid = ['paid', 'pago', 'received', 'confirmed'].includes((p.status || '').toLowerCase());
const discount = Number(p.discount || 0);
const currentAmount = Number(p.amount || 0);
// Heurística de recuperação do Bruto:
// Se está pago e o amount é baixo, o bruto provavelmente era amount + discount
// Mas se o amount já é alto (ex: 170), mantemos.
// Vamos tentar buscar o valor original do curso se possível, mas aqui usaremos a soma.
let valorBruto = currentAmount;
let valorEfetivoPago = isPaid ? currentAmount : 0;
// Se detectarmos que o amount no JSON já é o líquido (corrompido anteriormente)
// Fazemos a soma para o valorBruto
// (Isso é seguro pois se já for o bruto, ele ficará "super-bruto", mas o portal já trata isso)
// Na verdade, vamos ser conservadores:
// Se currentAmount + discount bater com valores comuns (170, 150, etc), usamos.
// Se estiver pago, o 'currentAmount' vindo do Asaas quase sempre é o LÍQUIDO.
if (isPaid && discount > 0) {
// Restauramos o bruto para a coluna 'valor'
valorBruto = currentAmount + discount;
}
await pool.query(
`UPDATE alunos_cobrancas
SET valor = $1, valor_pago = $2, discount = $3, amount_original = $1
WHERE asaas_payment_id = $4`,
[valorBruto, valorEfetivoPago, discount, p.asaasPaymentId]
);
updatedCount++;
}
console.log(`✅ Migração concluída: ${updatedCount} registros atualizados.`);
} catch (err) {
console.error('❌ Erro na migração:', err);
} finally {
await pool.end();
}
}
migrate();

View File

@ -747,9 +747,33 @@ app.post('/api/webhook_asaas', async (req, res) => {
case 'PAYMENT_CONFIRMED':
updateData = {
status: 'PAGO',
valor: payload.payment.value,
valor_pago: payload.payment.value,
data_pagamento: payload.payment.confirmedDate || payload.payment.paymentDate || new Date().toISOString().split('T')[0]
};
// [Bugfix Crítico]: Recuperar o valor BRUTO caso o Asaas mande o líquido
try {
const cobRes = await pool.query('SELECT valor, discount, amount_original FROM alunos_cobrancas WHERE asaas_payment_id = $1', [asaasPaymentId]);
if (cobRes.rows.length > 0) {
const cob = cobRes.rows[0];
const currentAmount = Number(cob.valor || 0);
const discount = Number(cob.discount || 0);
const amountOriginal = Number(cob.amount_original || 0);
const receivedValue = Number(payload.payment.value);
// Se o valor recebido for menor que o bruto registrado e a diferença bater com o desconto,
// mantemos o bruto no campo 'valor'.
if (receivedValue < currentAmount && Math.abs((currentAmount - discount) - receivedValue) < 0.01) {
// Já está correto (bruto > recebido), não mexemos no 'valor'
} else if (receivedValue === currentAmount && discount > 0) {
// Se o 'valor' no banco já era o líquido, restauramos para o bruto
updateData.valor = receivedValue + discount;
} else if (amountOriginal > receivedValue) {
updateData.valor = amountOriginal;
}
}
} catch (e) { console.error('[Webhook:Recovery] Erro:', e.message); }
if (payload.payment.transactionReceiptUrl) {
updateData.transaction_receipt_url = payload.payment.transactionReceiptUrl;
}

View File

@ -107,9 +107,9 @@ export async function insertCobrancas(cobrancas) {
for (const c of cobrancas) {
await client.query(
`INSERT INTO alunos_cobrancas
(aluno_id, asaas_customer_id, asaas_payment_id, asaas_installment_id, installment, valor, vencimento, link_boleto)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
[c.aluno_id, c.asaas_customer_id, c.asaas_payment_id, c.asaas_installment_id || c.installment, c.installment, c.valor, c.vencimento, c.link_boleto]
(aluno_id, asaas_customer_id, asaas_payment_id, asaas_installment_id, installment, valor, vencimento, link_boleto, amount_original)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
[c.aluno_id, c.asaas_customer_id, c.asaas_payment_id, c.asaas_installment_id || c.installment, c.installment, c.valor, c.vencimento, c.link_boleto, c.valor]
);
}
await client.query('COMMIT');
@ -410,7 +410,10 @@ export async function syncJsonToRelationalTables() {
}
}
// 3. Sincronizar Períodos (Bimestres)
// 0. Garantir esquema atualizado
await client.query('ALTER TABLE alunos_cobrancas ADD COLUMN IF NOT EXISTS valor_pago NUMERIC(10,2) DEFAULT 0');
// 1. Sincronizar Perfil da Escola (Configurações)
if (data.periods && Array.isArray(data.periods)) {
const periodIds = data.periods.map(p => p.id).filter(Boolean);
if (periodIds.length > 0) {
@ -549,19 +552,33 @@ export async function syncJsonToRelationalTables() {
const rawStatus = (p.status || 'pending').toLowerCase();
const statusMap = { 'paid': 'PAGO', 'received': 'PAGO', 'confirmed': 'PAGO', 'overdue': 'ATRASADO', 'cancelled': 'CANCELADO' };
const sqlStatus = statusMap[rawStatus] || 'PENDENTE';
const isPaid = sqlStatus === 'PAGO';
const amount = Number(p.amount || 0);
const discount = Number(p.discount || 0);
// Se está pago, o 'amount' do JSON geralmente é o líquido.
// O valor principal (valor) deve ser o BRUTO.
let valorBruto = amount;
let valorPago = 0;
if (isPaid) {
valorPago = amount;
// Se o amount vindo do JSON for o líquido (igual ou menor que o bruto esperado), restauramos o bruto
valorBruto = amount + discount;
}
await client.query(
`INSERT INTO alunos_cobrancas (
aluno_id, asaas_payment_id, asaas_installment_id, installment,
valor, vencimento, link_boleto, status,
description, type, discount, installment_number, total_installments,
contract_id, asaas_payment_url, amount_original, data_pagamento
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
contract_id, asaas_payment_url, amount_original, data_pagamento, valor_pago
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)
ON CONFLICT (asaas_payment_id) DO UPDATE SET
aluno_id = EXCLUDED.aluno_id,
asaas_installment_id = COALESCE(EXCLUDED.asaas_installment_id, alunos_cobrancas.asaas_installment_id),
installment = COALESCE(EXCLUDED.installment, alunos_cobrancas.installment),
valor = EXCLUDED.valor,
valor = GREATEST(alunos_cobrancas.valor, EXCLUDED.valor),
vencimento = EXCLUDED.vencimento,
link_boleto = COALESCE(EXCLUDED.link_boleto, alunos_cobrancas.link_boleto),
status = CASE WHEN alunos_cobrancas.status = 'PAGO' THEN alunos_cobrancas.status ELSE EXCLUDED.status END,
@ -573,25 +590,27 @@ export async function syncJsonToRelationalTables() {
contract_id = COALESCE(EXCLUDED.contract_id, alunos_cobrancas.contract_id),
asaas_payment_url = COALESCE(EXCLUDED.asaas_payment_url, alunos_cobrancas.asaas_payment_url),
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),
valor_pago = EXCLUDED.valor_pago`,
[
p.studentId,
p.asaasPaymentId,
p.asaasInstallmentId || p.installmentId || null,
p.installment || null,
p.amount || 0,
valorBruto,
p.dueDate,
p.bankSlipUrl || p.link || null,
sqlStatus,
p.description || null,
p.type || 'monthly',
p.discount || 0,
discount,
p.installmentNumber || null,
p.totalInstallments || null,
p.contractId || null,
p.asaasPaymentUrl || null,
p.amount || null,
p.paidDate || null
valorBruto,
p.paidDate || null,
valorPago
]
).catch(err => console.warn(`[Sync:Finance] Erro no boleto ${p.asaasPaymentId}:`, err.message));
}

View File

@ -279,31 +279,32 @@ app.get('/api/portal/financeiro', authMiddleware, async (req, res) => {
}
// [Bugfix Crítico]: Recuperar valor bruto se o Asaas/Webhook salvou apenas o líquido
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 isPaid = ['paid', 'pago', 'received', 'confirmed'].includes(normalizedStatus);
// Se está pago e o valor que temos parece ser o líquido (igual ao do banco que o webhook sobrescreve)
// 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;
}
}
const valorPagoNoSQL = Number(db.valor_pago || 0);
// Garantir que sempre usamos o maior valor conhecido como bruto
const dbAmountOrig = Number(db.amount_original || 0);
if (dbAmountOrig > amountOriginal) amountOriginal = dbAmountOrig;
// Tentamos pegar o maior valor disponível como o Bruto
let amountBruto = Number(db.amount_original) || Number(jsonP.amount) || Number(db.valor) || 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;
}
}
finalPayments.push({
id: jsonP.id || asaasId,
studentId: req.user.studentId,
asaasPaymentId: asaasId,
asaasPaymentUrl: db.asaas_payment_url || jsonP.asaasPaymentUrl || null,
amount: amountOriginal,
amount: amountBruto,
discount: discount,
valor_pago: valorPagoNoSQL || (isPaid ? Number(db.valor) : 0),
dueDate: db.vencimento || jsonP.dueDate,
status: normalizedStatus,
paidDate: db.data_pagamento || jsonP.paidDate || null,
@ -347,27 +348,26 @@ app.get('/api/portal/boletos', authMiddleware, async (req, res) => {
[req.user.studentId]
);
// [Bugfix Crítico]: Recuperar valor bruto original se o banco estiver com o valor líquido
// [Bugfix Crítico]: Recuperar valor bruto original e valor efetivamente pago
const boletos = (rows || []).map(b => {
let valor = Number(b.valor);
const valorOriginal = Number(b.amount_original || b.valor || 0);
const discount = Number(b.discount || 0);
const amountOriginal = Number(b.amount_original || 0);
const valorPago = Number(b.valor_pago || 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;
// O valor principal exibido deve ser sempre o BRUTO original
let valorExibido = valorOriginal;
// Se por algum motivo o valorOriginal ainda for o líquido, tentamos recompor
if (valorOriginal > 0 && isPaid && valorPago > 0 && valorOriginal === valorPago && discount > 0) {
valorExibido = valorOriginal + discount;
}
return {
...b,
valor: valor
valor: valorExibido,
valor_pago: valorPago
};
});