feat(finance): add valor_pago column and implement robust gross/net separation logic
This commit is contained in:
parent
8a42db3e58
commit
f6022fd0fc
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue