feat(finance): migrate manually created payments to sql-first architecture with local_id support
This commit is contained in:
parent
a9f8559462
commit
27fcbada35
|
|
@ -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' })}
|
||||
</td>
|
||||
<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 && (
|
||||
<div className="text-[10px] text-emerald-600 font-bold">- R$ {payment.discount.toLocaleString('pt-BR', { minimumFractionDigits: 2 })}</div>
|
||||
{(() => {
|
||||
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-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 className="px-4 py-4">{getStatusBadge(payment)}</td>
|
||||
<td className="px-4 py-4">
|
||||
|
|
@ -1187,31 +1210,33 @@ const Finance: React.FC<FinanceProps> = ({ data, updateData }) => {
|
|||
</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">
|
||||
{(() => {
|
||||
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">
|
||||
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';
|
||||
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 });
|
||||
})()}
|
||||
R$ {displayValue.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 })}
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
})()}
|
||||
</td>
|
||||
<td className="px-4 py-5">{getStatusBadge(payment)}</td>
|
||||
<td className="px-4 py-5">
|
||||
|
|
|
|||
|
|
@ -906,7 +906,7 @@ app.delete('/api/admin/cobrancas', async (req, res) => {
|
|||
try {
|
||||
const { ids } = req.body;
|
||||
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 });
|
||||
} catch(e) {
|
||||
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) => {
|
||||
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 });
|
||||
} catch(e) {
|
||||
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);
|
||||
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
|
||||
);
|
||||
|
||||
|
|
@ -1234,6 +1234,13 @@ app.post('/api/excluir_cobranca', async (req, res) => {
|
|||
const { id } = req.body;
|
||||
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);
|
||||
let isSinglePayment = id.startsWith('pay_');
|
||||
|
||||
|
|
@ -1243,11 +1250,13 @@ app.post('/api/excluir_cobranca', async (req, res) => {
|
|||
if (resp.ok) {
|
||||
addLog('Asaas', 'Exclusão Parcelamento OK', { id: asaasTargetId });
|
||||
}
|
||||
await pool.query('DELETE FROM alunos_cobrancas WHERE asaas_installment_id = $1', [asaasTargetId]);
|
||||
} else {
|
||||
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' }); }
|
||||
|
||||
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' });
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
const isAsaasPayment = targetAsaasId && targetAsaasId.startsWith('pay_');
|
||||
|
||||
if (isAsaasPayment) {
|
||||
const aResp = await fetch(`${ASAAS_BASE_URL}/v3/payments/${targetAsaasId}`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'access_token': process.env.ASAAS_API_KEY },
|
||||
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' }); }
|
||||
}
|
||||
|
||||
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 });
|
||||
|
||||
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) => {
|
||||
|
|
@ -1797,7 +1813,11 @@ async function syncRelationalToJsonPayments() {
|
|||
if (!appData || !appData.payments) return;
|
||||
|
||||
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) {
|
||||
const statusStr = (match.status || '').toLowerCase();
|
||||
const newStatus = statusStr === 'pago' ? 'paid' :
|
||||
|
|
|
|||
|
|
@ -412,6 +412,8 @@ export async function syncJsonToRelationalTables() {
|
|||
|
||||
// 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 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)
|
||||
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
|
||||
if (data.payments && Array.isArray(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)
|
||||
const rawStatus = (p.status || 'pending').toLowerCase();
|
||||
|
|
@ -566,14 +568,17 @@ export async function syncJsonToRelationalTables() {
|
|||
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(
|
||||
`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,
|
||||
description, type, discount, installment_number, total_installments,
|
||||
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
|
||||
local_id = COALESCE(EXCLUDED.local_id, alunos_cobrancas.local_id),
|
||||
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),
|
||||
|
|
@ -592,6 +597,7 @@ export async function syncJsonToRelationalTables() {
|
|||
data_pagamento = COALESCE(EXCLUDED.data_pagamento, alunos_cobrancas.data_pagamento),
|
||||
valor_pago = EXCLUDED.valor_pago`,
|
||||
[
|
||||
p.id,
|
||||
p.studentId,
|
||||
p.asaasPaymentId,
|
||||
p.asaasInstallmentId || p.installmentId || null,
|
||||
|
|
@ -611,7 +617,71 @@ export async function syncJsonToRelationalTables() {
|
|||
p.paidDate || null,
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -248,21 +248,26 @@ app.get('/api/portal/financeiro', authMiddleware, async (req, res) => {
|
|||
|
||||
// Criar mapa rápido do JSON por asaasPaymentId para lookup
|
||||
const jsonMap = {};
|
||||
const jsonLocalMap = {}; // Novo mapa por ID local do JSON
|
||||
for (const jp of jsonPayments) {
|
||||
const key = jp.asaasPaymentId || jp.asaas_payment_id;
|
||||
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
|
||||
const seenAsaasIds = new Set();
|
||||
const seenLocalIds = new Set(); // Evitar duplicar itens adicionados por local_id
|
||||
const finalPayments = [];
|
||||
|
||||
// 3a. Iterar sobre registros do SQL (fonte da verdade)
|
||||
for (const db of dbRows) {
|
||||
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 normalizedStatus = statusMap[dbStatus] || 'pending';
|
||||
|
||||
|
|
@ -302,9 +307,9 @@ app.get('/api/portal/financeiro', authMiddleware, async (req, res) => {
|
|||
}
|
||||
|
||||
finalPayments.push({
|
||||
id: jsonP.id || asaasId,
|
||||
id: localId || jsonP.id || asaasId,
|
||||
studentId: req.user.studentId,
|
||||
asaasPaymentId: asaasId,
|
||||
asaasPaymentId: asaasId || null,
|
||||
asaasPaymentUrl: db.asaas_payment_url || jsonP.asaasPaymentUrl || null,
|
||||
amount: amountBruto,
|
||||
discount: discount,
|
||||
|
|
@ -325,6 +330,7 @@ app.get('/api/portal/financeiro', authMiddleware, async (req, res) => {
|
|||
for (const jp of jsonPayments) {
|
||||
const key = jp.asaasPaymentId || jp.asaas_payment_id;
|
||||
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
|
||||
|
||||
const jpStatus = (jp.status || '').toLowerCase().trim();
|
||||
|
|
|
|||
Loading…
Reference in New Issue