feat(finance): migrate manually created payments to sql-first architecture with local_id support

This commit is contained in:
Sidney 2026-05-21 08:32:22 -03:00
parent a9f8559462
commit 27fcbada35
4 changed files with 224 additions and 103 deletions

View File

@ -1112,10 +1112,33 @@ const Finance: React.FC<FinanceProps> = ({ data, updateData }) => {
{new Date(payment.dueDate + 'T12:00:00Z').toLocaleDateString('pt-BR', { timeZone: 'America/Sao_Paulo' })} {new Date(payment.dueDate + 'T12:00:00Z').toLocaleDateString('pt-BR', { timeZone: 'America/Sao_Paulo' })}
</td> </td>
<td className="px-4 py-4"> <td className="px-4 py-4">
<div className="font-bold text-slate-700 text-sm">R$ {payment.amount.toLocaleString('pt-BR', { minimumFractionDigits: 2 })}</div> {(() => {
{!!payment.discount && payment.discount > 0 && ( const isPaid = ['paid', 'pago', 'received', 'confirmed'].includes((payment.status || '').toLowerCase());
<div className="text-[10px] text-emerald-600 font-bold">- R$ {payment.discount.toLocaleString('pt-BR', { minimumFractionDigits: 2 })}</div> const valorPago = Number((payment as any).valor_pago || 0);
const discount = Number(payment.discount || 0);
if (isPaid) {
const displayValue = valorPago > 0 ? valorPago : (payment.amount - discount);
return (
<div className="font-bold text-slate-700 text-sm">
R$ {displayValue.toLocaleString('pt-BR', { minimumFractionDigits: 2 })}
</div>
);
} else {
return (
<>
<div className="font-bold text-slate-700 text-sm">
R$ {payment.amount.toLocaleString('pt-BR', { minimumFractionDigits: 2 })}
</div>
{discount > 0 && (
<div className="text-[10px] text-emerald-600 font-bold">
- R$ {discount.toLocaleString('pt-BR', { minimumFractionDigits: 2 })}
</div>
)} )}
</>
);
}
})()}
</td> </td>
<td className="px-4 py-4">{getStatusBadge(payment)}</td> <td className="px-4 py-4">{getStatusBadge(payment)}</td>
<td className="px-4 py-4"> <td className="px-4 py-4">
@ -1187,31 +1210,33 @@ 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">
{(() => {
const isPaid = ['paid', 'pago', 'received', 'confirmed'].includes((payment.status || '').toLowerCase());
const valorPago = Number((payment as any).valor_pago || 0);
const discount = Number(payment.discount || 0);
if (isPaid) {
const displayValue = valorPago > 0 ? valorPago : (payment.amount - discount);
return (
<div className="font-black text-slate-900 text-sm"> <div className="font-black text-slate-900 text-sm">
R$ {(() => { R$ {displayValue.toLocaleString('pt-BR', { minimumFractionDigits: 2 })}
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';
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;
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 bruto.toLocaleString('pt-BR', { minimumFractionDigits: 2 });
})()}
</div> </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 && ( } else {
<div className="text-[10px] text-blue-600 font-black mt-1 bg-blue-50 px-1 rounded inline-block"> return (
PAGO: R$ {Number((payment as any).valor_pago).toLocaleString('pt-BR', { minimumFractionDigits: 2 })} <>
<div className="font-black text-slate-900 text-sm">
R$ {payment.amount.toLocaleString('pt-BR', { minimumFractionDigits: 2 })}
</div>
{discount > 0 && (
<div className="text-[10px] text-emerald-600 font-bold">
- R$ {discount.toLocaleString('pt-BR', { minimumFractionDigits: 2 })}
</div> </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

@ -906,7 +906,7 @@ app.delete('/api/admin/cobrancas', async (req, res) => {
try { try {
const { ids } = req.body; const { ids } = req.body;
if (!Array.isArray(ids)) return res.status(400).end(); if (!Array.isArray(ids)) return res.status(400).end();
await pool.query('DELETE FROM alunos_cobrancas WHERE asaas_payment_id = ANY($1)', [ids]); await pool.query('DELETE FROM alunos_cobrancas WHERE asaas_payment_id = ANY($1) OR local_id = ANY($1)', [ids]);
res.json({ success: true }); res.json({ success: true });
} catch(e) { } catch(e) {
res.status(500).json({error: e.message}); res.status(500).json({error: e.message});
@ -915,7 +915,7 @@ app.delete('/api/admin/cobrancas', async (req, res) => {
app.delete('/api/admin/cobrancas/:id', async (req, res) => { app.delete('/api/admin/cobrancas/:id', async (req, res) => {
try { try {
await pool.query('DELETE FROM alunos_cobrancas WHERE asaas_payment_id = $1', [req.params.id]); await pool.query('DELETE FROM alunos_cobrancas WHERE asaas_payment_id = $1 OR local_id = $1', [req.params.id]);
res.json({ success: true }); res.json({ success: true });
} catch(e) { } catch(e) {
res.status(500).json({error: e.message}); res.status(500).json({error: e.message});
@ -944,7 +944,7 @@ app.put('/api/admin/cobrancas/:id', async (req, res) => {
values.push(req.params.id); values.push(req.params.id);
await pool.query( await pool.query(
`UPDATE alunos_cobrancas SET ${updates.join(', ')} WHERE asaas_payment_id = $${paramIdx}`, `UPDATE alunos_cobrancas SET ${updates.join(', ')} WHERE asaas_payment_id = $${paramIdx} OR local_id = $${paramIdx}`,
values values
); );
@ -1234,6 +1234,13 @@ app.post('/api/excluir_cobranca', async (req, res) => {
const { id } = req.body; const { id } = req.body;
if (!id) return res.status(400).json({ error: 'ID não fornecido' }); if (!id) return res.status(400).json({ error: 'ID não fornecido' });
const isManual = id.startsWith('pay-');
if (isManual) {
await pool.query('DELETE FROM alunos_cobrancas WHERE local_id = $1', [id]);
return res.status(200).json({ message: 'Excluído na base local' });
}
const parcelas = await getCobrancasByOrQuery(id); const parcelas = await getCobrancasByOrQuery(id);
let isSinglePayment = id.startsWith('pay_'); let isSinglePayment = id.startsWith('pay_');
@ -1243,11 +1250,13 @@ app.post('/api/excluir_cobranca', async (req, res) => {
if (resp.ok) { if (resp.ok) {
addLog('Asaas', 'Exclusão Parcelamento OK', { id: asaasTargetId }); addLog('Asaas', 'Exclusão Parcelamento OK', { id: asaasTargetId });
} }
await pool.query('DELETE FROM alunos_cobrancas WHERE asaas_installment_id = $1', [asaasTargetId]);
} else { } else {
const resp = await fetch(`${ASAAS_BASE_URL}/v3/payments/${id}`, { method: 'DELETE', headers: { 'access_token': process.env.ASAAS_API_KEY } }); const resp = await fetch(`${ASAAS_BASE_URL}/v3/payments/${id}`, { method: 'DELETE', headers: { 'access_token': process.env.ASAAS_API_KEY } });
if (!resp.ok) { const e = await resp.json().catch(() => ({})); return res.status(400).json({ error: e.errors?.[0]?.description || 'Falha Asaas' }); } if (!resp.ok) { const e = await resp.json().catch(() => ({})); return res.status(400).json({ error: e.errors?.[0]?.description || 'Falha Asaas' }); }
addLog('Asaas', 'Exclusão Cobrança OK', { id }); addLog('Asaas', 'Exclusão Cobrança OK', { id });
await pool.query('DELETE FROM alunos_cobrancas WHERE asaas_payment_id = $1', [id]);
} }
return res.status(200).json({ message: 'Excluído no Asaas e na base local' }); return res.status(200).json({ message: 'Excluído no Asaas e na base local' });
@ -1309,17 +1318,24 @@ app.put('/api/cobrancas/:id', async (req, res) => {
if (parcelas.length > 0 && parcelas[0].asaas_payment_id) targetAsaasId = parcelas[0].asaas_payment_id; if (parcelas.length > 0 && parcelas[0].asaas_payment_id) targetAsaasId = parcelas[0].asaas_payment_id;
} }
const isAsaasPayment = targetAsaasId && targetAsaasId.startsWith('pay_');
if (isAsaasPayment) {
const aResp = await fetch(`${ASAAS_BASE_URL}/v3/payments/${targetAsaasId}`, { const aResp = await fetch(`${ASAAS_BASE_URL}/v3/payments/${targetAsaasId}`, {
method: 'POST', headers: { 'Content-Type': 'application/json', 'access_token': process.env.ASAAS_API_KEY }, method: 'POST', headers: { 'Content-Type': 'application/json', 'access_token': process.env.ASAAS_API_KEY },
body: JSON.stringify({ value: valor, dueDate: vencimento }) body: JSON.stringify({ value: valor, dueDate: vencimento })
}); });
if (!aResp.ok) { const err = await aResp.json().catch(() => ({})); return res.status(400).json({ error: err.errors?.[0]?.description || 'Erro Asaas' }); } if (!aResp.ok) { const err = await aResp.json().catch(() => ({})); return res.status(400).json({ error: err.errors?.[0]?.description || 'Erro Asaas' }); }
}
const queryField = isUUID(id) ? 'id' : 'asaas_payment_id'; const queryField = isUUID(id) ? 'id' : (id.startsWith('pay_') ? 'asaas_payment_id' : 'local_id');
await updateCobrancaByField(queryField, id, { valor, vencimento }); await updateCobrancaByField(queryField, id, { valor, vencimento });
res.json({ message: 'Editado com sucesso' }); res.json({ message: 'Editado com sucesso' });
} catch (e) { res.status(500).json({ error: 'Erro interno.' }); } } catch (e) {
console.error('[cobrancas:PUT] Erro:', e);
res.status(500).json({ error: 'Erro interno.' });
}
}); });
app.get('/api/alunos/:id/carne', async (req, res) => { app.get('/api/alunos/:id/carne', async (req, res) => {
@ -1797,7 +1813,11 @@ async function syncRelationalToJsonPayments() {
if (!appData || !appData.payments) return; if (!appData || !appData.payments) return;
const updatedPayments = appData.payments.map(p => { const updatedPayments = appData.payments.map(p => {
const match = cloudPayments.find(cp => cp.asaas_payment_id === p.asaasPaymentId); const match = cloudPayments.find(cp => {
if (p.asaasPaymentId && cp.asaas_payment_id === p.asaasPaymentId) return true;
if (p.id && cp.local_id === p.id) return true;
return false;
});
if (match) { if (match) {
const statusStr = (match.status || '').toLowerCase(); const statusStr = (match.status || '').toLowerCase();
const newStatus = statusStr === 'pago' ? 'paid' : const newStatus = statusStr === 'pago' ? 'paid' :

View File

@ -412,6 +412,8 @@ export async function syncJsonToRelationalTables() {
// 0. Garantir esquema atualizado // 0. Garantir esquema atualizado
await client.query('ALTER TABLE alunos_cobrancas ADD COLUMN IF NOT EXISTS valor_pago NUMERIC(10,2) DEFAULT 0'); await client.query('ALTER TABLE alunos_cobrancas ADD COLUMN IF NOT EXISTS valor_pago NUMERIC(10,2) DEFAULT 0');
await client.query('ALTER TABLE alunos_cobrancas ADD COLUMN IF NOT EXISTS local_id VARCHAR(255)');
await client.query('CREATE INDEX IF NOT EXISTS idx_cobrancas_local_id ON alunos_cobrancas(local_id)');
// 1. Sincronizar Perfil da Escola (Configurações) // 1. Sincronizar Perfil da Escola (Configurações)
if (data.periods && Array.isArray(data.periods)) { if (data.periods && Array.isArray(data.periods)) {
@ -546,7 +548,7 @@ export async function syncJsonToRelationalTables() {
// 8. Sincronizar Cobranças (Financeiro) — com campos ricos para migração SQL-First // 8. Sincronizar Cobranças (Financeiro) — com campos ricos para migração SQL-First
if (data.payments && Array.isArray(data.payments)) { if (data.payments && Array.isArray(data.payments)) {
for (const p of data.payments) { for (const p of data.payments) {
if (!p.asaasPaymentId || !p.studentId) continue; if (!p.studentId || !p.id) continue;
// Normalizar status para o padrão SQL (maiúsculas) // Normalizar status para o padrão SQL (maiúsculas)
const rawStatus = (p.status || 'pending').toLowerCase(); const rawStatus = (p.status || 'pending').toLowerCase();
@ -566,14 +568,17 @@ export async function syncJsonToRelationalTables() {
valorPago = Number(p.valor_pago || 0) || (amount - discount); valorPago = Number(p.valor_pago || 0) || (amount - discount);
} }
if (p.asaasPaymentId) {
// Cobrança vinculada ao Asaas (usa ON CONFLICT em asaas_payment_id)
await client.query( await client.query(
`INSERT INTO alunos_cobrancas ( `INSERT INTO alunos_cobrancas (
aluno_id, asaas_payment_id, asaas_installment_id, installment, local_id, aluno_id, asaas_payment_id, asaas_installment_id, installment,
valor, vencimento, link_boleto, status, valor, vencimento, link_boleto, status,
description, type, discount, installment_number, total_installments, description, type, discount, installment_number, total_installments,
contract_id, asaas_payment_url, amount_original, data_pagamento, valor_pago 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) ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)
ON CONFLICT (asaas_payment_id) DO UPDATE SET ON CONFLICT (asaas_payment_id) DO UPDATE SET
local_id = COALESCE(EXCLUDED.local_id, alunos_cobrancas.local_id),
aluno_id = EXCLUDED.aluno_id, aluno_id = EXCLUDED.aluno_id,
asaas_installment_id = COALESCE(EXCLUDED.asaas_installment_id, alunos_cobrancas.asaas_installment_id), asaas_installment_id = COALESCE(EXCLUDED.asaas_installment_id, alunos_cobrancas.asaas_installment_id),
installment = COALESCE(EXCLUDED.installment, alunos_cobrancas.installment), installment = COALESCE(EXCLUDED.installment, alunos_cobrancas.installment),
@ -592,6 +597,7 @@ export async function syncJsonToRelationalTables() {
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`, valor_pago = EXCLUDED.valor_pago`,
[ [
p.id,
p.studentId, p.studentId,
p.asaasPaymentId, p.asaasPaymentId,
p.asaasInstallmentId || p.installmentId || null, p.asaasInstallmentId || p.installmentId || null,
@ -611,7 +617,71 @@ export async function syncJsonToRelationalTables() {
p.paidDate || null, p.paidDate || null,
valorPago valorPago
] ]
).catch(err => console.warn(`[Sync:Finance] Erro no boleto ${p.asaasPaymentId}:`, err.message)); ).catch(err => console.warn(`[Sync:Finance] Erro no boleto Asaas ${p.asaasPaymentId}:`, err.message));
} else {
// Cobrança manual (sem asaasPaymentId)
// Verificamos por local_id para evitar duplicação
const existing = await client.query('SELECT id FROM alunos_cobrancas WHERE local_id = $1', [p.id]);
if (existing.rows.length > 0) {
await client.query(
`UPDATE alunos_cobrancas SET
aluno_id = $1,
valor = $2,
vencimento = $3,
status = $4,
description = $5,
type = $6,
discount = $7,
installment_number = $8,
total_installments = $9,
contract_id = $10,
amount_original = $11,
data_pagamento = $12,
valor_pago = $13
WHERE local_id = $14`,
[
p.studentId,
valorBruto,
p.dueDate,
sqlStatus,
p.description || null,
p.type || 'monthly',
discount,
p.installmentNumber || null,
p.totalInstallments || null,
p.contractId || null,
valorBruto,
p.paidDate || null,
valorPago,
p.id
]
).catch(err => console.warn(`[Sync:Finance] Erro ao atualizar boleto manual ${p.id}:`, err.message));
} else {
await client.query(
`INSERT INTO alunos_cobrancas (
local_id, aluno_id, valor, vencimento, status,
description, type, discount, installment_number, total_installments,
contract_id, amount_original, data_pagamento, valor_pago
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)`,
[
p.id,
p.studentId,
valorBruto,
p.dueDate,
sqlStatus,
p.description || null,
p.type || 'monthly',
discount,
p.installmentNumber || null,
p.totalInstallments || null,
p.contractId || null,
valorBruto,
p.paidDate || null,
valorPago
]
).catch(err => console.warn(`[Sync:Finance] Erro ao inserir boleto manual ${p.id}:`, err.message));
}
}
} }
} }

View File

@ -248,21 +248,26 @@ app.get('/api/portal/financeiro', authMiddleware, async (req, res) => {
// Criar mapa rápido do JSON por asaasPaymentId para lookup // Criar mapa rápido do JSON por asaasPaymentId para lookup
const jsonMap = {}; const jsonMap = {};
const jsonLocalMap = {}; // Novo mapa por ID local do JSON
for (const jp of jsonPayments) { for (const jp of jsonPayments) {
const key = jp.asaasPaymentId || jp.asaas_payment_id; const key = jp.asaasPaymentId || jp.asaas_payment_id;
if (key) jsonMap[key] = jp; if (key) jsonMap[key] = jp;
if (jp.id) jsonLocalMap[jp.id] = jp;
} }
// 3. CONSTRUIR LISTA FINAL: SQL como base, enriquecido com JSON quando SQL não tem o campo // 3. CONSTRUIR LISTA FINAL: SQL como base, enriquecido com JSON quando SQL não tem o campo
const seenAsaasIds = new Set(); const seenAsaasIds = new Set();
const seenLocalIds = new Set(); // Evitar duplicar itens adicionados por local_id
const finalPayments = []; const finalPayments = [];
// 3a. Iterar sobre registros do SQL (fonte da verdade) // 3a. Iterar sobre registros do SQL (fonte da verdade)
for (const db of dbRows) { for (const db of dbRows) {
const asaasId = db.asaas_payment_id; const asaasId = db.asaas_payment_id;
seenAsaasIds.add(asaasId); const localId = db.local_id;
if (asaasId) seenAsaasIds.add(asaasId);
if (localId) seenLocalIds.add(localId);
const jsonP = jsonMap[asaasId] || {}; const jsonP = (asaasId ? jsonMap[asaasId] : null) || (localId ? jsonLocalMap[localId] : null) || {};
const dbStatus = (db.status || '').toLowerCase().trim(); const dbStatus = (db.status || '').toLowerCase().trim();
const normalizedStatus = statusMap[dbStatus] || 'pending'; const normalizedStatus = statusMap[dbStatus] || 'pending';
@ -302,9 +307,9 @@ app.get('/api/portal/financeiro', authMiddleware, async (req, res) => {
} }
finalPayments.push({ finalPayments.push({
id: jsonP.id || asaasId, id: localId || jsonP.id || asaasId,
studentId: req.user.studentId, studentId: req.user.studentId,
asaasPaymentId: asaasId, asaasPaymentId: asaasId || null,
asaasPaymentUrl: db.asaas_payment_url || jsonP.asaasPaymentUrl || null, asaasPaymentUrl: db.asaas_payment_url || jsonP.asaasPaymentUrl || null,
amount: amountBruto, amount: amountBruto,
discount: discount, discount: discount,
@ -325,6 +330,7 @@ app.get('/api/portal/financeiro', authMiddleware, async (req, res) => {
for (const jp of jsonPayments) { for (const jp of jsonPayments) {
const key = jp.asaasPaymentId || jp.asaas_payment_id; const key = jp.asaasPaymentId || jp.asaas_payment_id;
if (key && seenAsaasIds.has(key)) continue; // já processado if (key && seenAsaasIds.has(key)) continue; // já processado
if (jp.id && seenLocalIds.has(jp.id)) continue; // já processado via local_id
if (!key && !jp.id) continue; // registro inválido if (!key && !jp.id) continue; // registro inválido
const jpStatus = (jp.status || '').toLowerCase().trim(); const jpStatus = (jp.status || '').toLowerCase().trim();