From 27fcbada3519550ab4a50c5cb84a8825244303b4 Mon Sep 17 00:00:00 2001 From: Sidney Date: Thu, 21 May 2026 08:32:22 -0300 Subject: [PATCH] feat(finance): migrate manually created payments to sql-first architecture with local_id support --- manager/components/Finance.tsx | 83 +++++++++++------ manager/server.selfhosted.js | 44 ++++++--- manager/services/database.js | 164 +++++++++++++++++++++++---------- portal/server.selfhosted.js | 36 +++++--- 4 files changed, 224 insertions(+), 103 deletions(-) diff --git a/manager/components/Finance.tsx b/manager/components/Finance.tsx index 052bd6f..9a32e35 100644 --- a/manager/components/Finance.tsx +++ b/manager/components/Finance.tsx @@ -1112,10 +1112,33 @@ const Finance: React.FC = ({ data, updateData }) => { {new Date(payment.dueDate + 'T12:00:00Z').toLocaleDateString('pt-BR', { timeZone: 'America/Sao_Paulo' })} -
R$ {payment.amount.toLocaleString('pt-BR', { minimumFractionDigits: 2 })}
- {!!payment.discount && payment.discount > 0 && ( -
- R$ {payment.discount.toLocaleString('pt-BR', { minimumFractionDigits: 2 })}
- )} + {(() => { + 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 ( +
+ R$ {displayValue.toLocaleString('pt-BR', { minimumFractionDigits: 2 })} +
+ ); + } else { + return ( + <> +
+ R$ {payment.amount.toLocaleString('pt-BR', { minimumFractionDigits: 2 })} +
+ {discount > 0 && ( +
+ - R$ {discount.toLocaleString('pt-BR', { minimumFractionDigits: 2 })} +
+ )} + + ); + } + })()} {getStatusBadge(payment)} @@ -1187,31 +1210,33 @@ const Finance: React.FC = ({ data, updateData }) => { {new Date(payment.dueDate + 'T12:00:00Z').toLocaleDateString('pt-BR', { timeZone: 'America/Sao_Paulo' })} -
- 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 }); - })()} -
- {!!payment.discount && payment.discount > 0 &&
- R$ {payment.discount.toLocaleString('pt-BR', { minimumFractionDigits: 2 })}
} - {(payment as any).valor_pago > 0 && ( -
- PAGO: R$ {Number((payment as any).valor_pago).toLocaleString('pt-BR', { minimumFractionDigits: 2 })} -
- )} + {(() => { + 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 ( +
+ R$ {displayValue.toLocaleString('pt-BR', { minimumFractionDigits: 2 })} +
+ ); + } else { + return ( + <> +
+ R$ {payment.amount.toLocaleString('pt-BR', { minimumFractionDigits: 2 })} +
+ {discount > 0 && ( +
+ - R$ {discount.toLocaleString('pt-BR', { minimumFractionDigits: 2 })} +
+ )} + + ); + } + })()} {getStatusBadge(payment)} diff --git a/manager/server.selfhosted.js b/manager/server.selfhosted.js index 33bfa41..a6d0c2c 100644 --- a/manager/server.selfhosted.js +++ b/manager/server.selfhosted.js @@ -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}); @@ -941,10 +941,10 @@ app.put('/api/admin/cobrancas/:id', async (req, res) => { if (amount_original !== undefined) { updates.push(`amount_original = $${paramIdx++}`); values.push(amount_original); } if (updates.length === 0) return res.status(400).json({ error: 'Nenhum campo para atualizar.' }); - + 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 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 isAsaasPayment = targetAsaasId && targetAsaasId.startsWith('pay_'); - const queryField = isUUID(id) ? 'id' : 'asaas_payment_id'; + 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' : (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' : diff --git a/manager/services/database.js b/manager/services/database.js index dcc8162..a757c19 100644 --- a/manager/services/database.js +++ b/manager/services/database.js @@ -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,52 +568,120 @@ export async function syncJsonToRelationalTables() { valorPago = Number(p.valor_pago || 0) || (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, 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, - 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, - description = COALESCE(EXCLUDED.description, alunos_cobrancas.description), - type = COALESCE(EXCLUDED.type, alunos_cobrancas.type), - discount = COALESCE(EXCLUDED.discount, alunos_cobrancas.discount), - installment_number = COALESCE(EXCLUDED.installment_number, alunos_cobrancas.installment_number), - total_installments = COALESCE(EXCLUDED.total_installments, alunos_cobrancas.total_installments), - 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 = COALESCE(EXCLUDED.amount_original, alunos_cobrancas.amount_original), - 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, - valorBruto, - p.dueDate, - p.bankSlipUrl || p.link || null, - sqlStatus, - p.description || null, - p.type || 'monthly', - discount, - p.installmentNumber || null, - p.totalInstallments || null, - p.contractId || null, - p.asaasPaymentUrl || null, - valorBruto, - p.paidDate || null, - valorPago - ] - ).catch(err => console.warn(`[Sync:Finance] Erro no boleto ${p.asaasPaymentId}:`, err.message)); + if (p.asaasPaymentId) { + // Cobrança vinculada ao Asaas (usa ON CONFLICT em asaas_payment_id) + await client.query( + `INSERT INTO alunos_cobrancas ( + 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, $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), + 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, + description = COALESCE(EXCLUDED.description, alunos_cobrancas.description), + type = COALESCE(EXCLUDED.type, alunos_cobrancas.type), + discount = COALESCE(EXCLUDED.discount, alunos_cobrancas.discount), + installment_number = COALESCE(EXCLUDED.installment_number, alunos_cobrancas.installment_number), + total_installments = COALESCE(EXCLUDED.total_installments, alunos_cobrancas.total_installments), + 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 = COALESCE(EXCLUDED.amount_original, alunos_cobrancas.amount_original), + 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, + p.installment || null, + valorBruto, + p.dueDate, + p.bankSlipUrl || p.link || null, + sqlStatus, + p.description || null, + p.type || 'monthly', + discount, + p.installmentNumber || null, + p.totalInstallments || null, + p.contractId || null, + p.asaasPaymentUrl || null, + valorBruto, + p.paidDate || null, + valorPago + ] + ).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)); + } + } } } diff --git a/portal/server.selfhosted.js b/portal/server.selfhosted.js index 9a0b58f..a795fc0 100644 --- a/portal/server.selfhosted.js +++ b/portal/server.selfhosted.js @@ -248,28 +248,33 @@ 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 jsonP = jsonMap[asaasId] || {}; + const localId = db.local_id; + if (asaasId) seenAsaasIds.add(asaasId); + if (localId) seenLocalIds.add(localId); + + const jsonP = (asaasId ? jsonMap[asaasId] : null) || (localId ? jsonLocalMap[localId] : null) || {}; const dbStatus = (db.status || '').toLowerCase().trim(); const normalizedStatus = statusMap[dbStatus] || 'pending'; - + // Parcela: SQL tem prioridade, depois JSON, depois inferência por grupo let installmentNumber = db.installment_number || jsonP.installmentNumber || null; let totalInstallments = db.total_installments || jsonP.totalInstallments || null; - + if (!installmentNumber && db.asaas_installment_id) { const siblings = dbRows.filter(r => r.asaas_installment_id === db.asaas_installment_id); if (siblings.length > 1) { @@ -277,7 +282,7 @@ app.get('/api/portal/financeiro', authMiddleware, async (req, res) => { installmentNumber = siblings.indexOf(db) + 1; } } - + // [Bugfix Crítico]: Recuperar valor bruto se o Asaas/Webhook salvou apenas o líquido const discount = jsonP.discount !== undefined ? Number(jsonP.discount) : (Number(db.discount) || 0); const isPaid = ['paid', 'pago', 'received', 'confirmed'].includes(normalizedStatus); @@ -289,7 +294,7 @@ app.get('/api/portal/financeiro', authMiddleware, async (req, res) => { Number(db.valor || 0), Number(jsonP.amount || 0) ); - + // 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) { @@ -300,11 +305,11 @@ app.get('/api/portal/financeiro', authMiddleware, async (req, res) => { amountBruto = Number(jsonP.amount) + discount; } } - + 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, @@ -320,16 +325,17 @@ app.get('/api/portal/financeiro', authMiddleware, async (req, res) => { transactionReceiptUrl: db.transaction_receipt_url || jsonP.transactionReceiptUrl || null, }); } - + // 3b. Adicionar pagamentos que existem APENAS no JSON (cobranças manuais/legadas sem asaas) 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(); const normalizedStatus = statusMap[jpStatus] || 'pending'; - + finalPayments.push({ ...jp, status: normalizedStatus,