diff --git a/manager/components/Finance.tsx b/manager/components/Finance.tsx index 6e92478..9beafb0 100644 --- a/manager/components/Finance.tsx +++ b/manager/components/Finance.tsx @@ -1186,20 +1186,25 @@ const Finance: React.FC = ({ 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 }); })()} {!!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 })} +
+ )} {getStatusBadge(payment)} diff --git a/manager/scratch/migrate_valor_pago.js b/manager/scratch/migrate_valor_pago.js new file mode 100644 index 0000000..2945332 --- /dev/null +++ b/manager/scratch/migrate_valor_pago.js @@ -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(); diff --git a/manager/server.selfhosted.js b/manager/server.selfhosted.js index 231c003..ce3127a 100644 --- a/manager/server.selfhosted.js +++ b/manager/server.selfhosted.js @@ -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; } diff --git a/manager/services/database.js b/manager/services/database.js index 5ba4bfd..cbba17d 100644 --- a/manager/services/database.js +++ b/manager/services/database.js @@ -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)); } diff --git a/portal/server.selfhosted.js b/portal/server.selfhosted.js index 31b10f1..aeb01a7 100644 --- a/portal/server.selfhosted.js +++ b/portal/server.selfhosted.js @@ -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 }; });