From b440023adda5b7cd6447daabbbbab086dd67af10 Mon Sep 17 00:00:00 2001 From: Sidney Date: Thu, 14 May 2026 21:38:02 -0300 Subject: [PATCH] Phase 1: Add rich columns to alunos_cobrancas and migrate JSON metadata to SQL on boot --- manager/server.selfhosted.js | 26 +++++++++++++++++++++++ manager/services/database.js | 41 +++++++++++++++++++++++++++++------- portal/server.selfhosted.js | 31 +++++++++++++++------------ 3 files changed, 76 insertions(+), 22 deletions(-) diff --git a/manager/server.selfhosted.js b/manager/server.selfhosted.js index 19fdd1a..23dac01 100644 --- a/manager/server.selfhosted.js +++ b/manager/server.selfhosted.js @@ -1620,6 +1620,32 @@ async function inicializarAgendamento() { ALTER TABLE alunos_cobrancas ADD COLUMN last_overdue_warning_at TIMESTAMP WITH TIME ZONE; END IF; + -- ===== FASE 1: Colunas ricas para migração financeira SQL-First ===== + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='alunos_cobrancas' AND column_name='description') THEN + ALTER TABLE alunos_cobrancas ADD COLUMN description TEXT; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='alunos_cobrancas' AND column_name='type') THEN + ALTER TABLE alunos_cobrancas ADD COLUMN type TEXT DEFAULT 'monthly'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='alunos_cobrancas' AND column_name='discount') THEN + ALTER TABLE alunos_cobrancas ADD COLUMN discount NUMERIC(10,2) DEFAULT 0; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='alunos_cobrancas' AND column_name='installment_number') THEN + ALTER TABLE alunos_cobrancas ADD COLUMN installment_number INTEGER; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='alunos_cobrancas' AND column_name='total_installments') THEN + ALTER TABLE alunos_cobrancas ADD COLUMN total_installments INTEGER; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='alunos_cobrancas' AND column_name='contract_id') THEN + ALTER TABLE alunos_cobrancas ADD COLUMN contract_id TEXT; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='alunos_cobrancas' AND column_name='asaas_payment_url') THEN + ALTER TABLE alunos_cobrancas ADD COLUMN asaas_payment_url TEXT; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='alunos_cobrancas' AND column_name='amount_original') THEN + ALTER TABLE alunos_cobrancas ADD COLUMN amount_original NUMERIC(10,2); + END IF; + -- Garantir índice de unicidade para o UPSERT funcionar IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE tablename = 'alunos_cobrancas' AND indexname = 'idx_asaas_payment_id_unique') THEN CREATE UNIQUE INDEX idx_asaas_payment_id_unique ON alunos_cobrancas(asaas_payment_id); diff --git a/manager/services/database.js b/manager/services/database.js index f6541f1..7ad77cc 100644 --- a/manager/services/database.js +++ b/manager/services/database.js @@ -540,24 +540,40 @@ export async function syncJsonToRelationalTables() { } } - // 8. Sincronizar Cobranças (Financeiro) + // 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; + // Normalizar status para o padrão SQL (maiúsculas) + const rawStatus = (p.status || 'pending').toLowerCase(); + const statusMap = { 'paid': 'PAGO', 'received': 'PAGO', 'confirmed': 'PAGO', 'overdue': 'ATRASADO', 'cancelled': 'CANCELADO' }; + const sqlStatus = statusMap[rawStatus] || 'PENDENTE'; + await client.query( `INSERT INTO alunos_cobrancas ( aluno_id, asaas_payment_id, asaas_installment_id, installment, - valor, vencimento, link_boleto, status - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + 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) ON CONFLICT (asaas_payment_id) DO UPDATE SET aluno_id = EXCLUDED.aluno_id, - asaas_installment_id = EXCLUDED.asaas_installment_id, - installment = EXCLUDED.installment, + 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 = EXCLUDED.link_boleto, - status = EXCLUDED.status`, + 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)`, [ p.studentId, p.asaasPaymentId, @@ -566,7 +582,16 @@ export async function syncJsonToRelationalTables() { p.amount || 0, p.dueDate, p.bankSlipUrl || p.link || null, - (p.status || 'PENDENTE').toUpperCase() + sqlStatus, + p.description || null, + p.type || 'monthly', + p.discount || 0, + p.installmentNumber || null, + p.totalInstallments || null, + p.contractId || null, + p.asaasPaymentUrl || null, + p.amount || null, + p.paidDate || null ] ).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 11ec6f8..51b0f5b 100644 --- a/portal/server.selfhosted.js +++ b/portal/server.selfhosted.js @@ -232,7 +232,9 @@ app.get('/api/portal/financeiro', authMiddleware, async (req, res) => { `SELECT asaas_payment_id, asaas_installment_id, installment, valor, TO_CHAR(vencimento, 'YYYY-MM-DD') as vencimento, status, TO_CHAR(data_pagamento, 'YYYY-MM-DD') as data_pagamento, - link_boleto, link_carne, transaction_receipt_url + link_boleto, link_carne, transaction_receipt_url, + description, type, discount, installment_number, total_installments, + contract_id, asaas_payment_url, amount_original FROM alunos_cobrancas WHERE aluno_id = $1 ORDER BY vencimento ASC`, @@ -243,7 +245,7 @@ app.get('/api/portal/financeiro', authMiddleware, async (req, res) => { console.error('Financeiro: erro ao buscar do PostgreSQL -', dbErr.message); } - // 2. FONTE SECUNDÁRIA: JSON (school_data.payments) — metadados complementares + // 2. FONTE SECUNDÁRIA: JSON (school_data.payments) — fallback para dados que ainda não migraram const schoolData = await getSchoolData(); const jsonPayments = (schoolData.payments || []).filter((p) => p.studentId === req.user.studentId); @@ -254,7 +256,7 @@ app.get('/api/portal/financeiro', authMiddleware, async (req, res) => { if (key) jsonMap[key] = jp; } - // 3. CONSTRUIR LISTA FINAL: SQL como base, enriquecido com metadados do JSON + // 3. CONSTRUIR LISTA FINAL: SQL como base, enriquecido com JSON quando SQL não tem o campo const seenAsaasIds = new Set(); const finalPayments = []; @@ -267,11 +269,10 @@ app.get('/api/portal/financeiro', authMiddleware, async (req, res) => { const dbStatus = (db.status || '').toLowerCase().trim(); const normalizedStatus = statusMap[dbStatus] || 'pending'; - // Calcular número da parcela se disponível - let installmentNumber = jsonP.installmentNumber || null; - let totalInstallments = jsonP.totalInstallments || null; + // 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; - // Se não temos info de parcela no JSON, tentar inferir do installment group if (!installmentNumber && db.asaas_installment_id) { const siblings = dbRows.filter(r => r.asaas_installment_id === db.asaas_installment_id); if (siblings.length > 1) { @@ -280,20 +281,22 @@ app.get('/api/portal/financeiro', authMiddleware, async (req, res) => { } } + // amount_original = valor bruto (ex: 170), db.valor = valor líquido Asaas (ex: 150) + const amountOriginal = Number(db.amount_original) || jsonP.amount || Number(db.valor) || 0; + const discount = Number(db.discount) || (jsonP.amount ? (jsonP.discount || 0) : 0); + finalPayments.push({ id: jsonP.id || asaasId, studentId: req.user.studentId, asaasPaymentId: asaasId, - asaasPaymentUrl: jsonP.asaasPaymentUrl || null, - // jsonP.amount = valor BRUTO (ex: 170), db.valor = valor LÍQUIDO do Asaas (ex: 150) - // Se o JSON tem o bruto, usa ele + desconto separado. Se não, usa SQL (já líquido) sem desconto. - amount: jsonP.amount || Number(db.valor) || 0, - discount: jsonP.amount ? (jsonP.discount || 0) : 0, + asaasPaymentUrl: db.asaas_payment_url || jsonP.asaasPaymentUrl || null, + amount: amountOriginal, + discount: discount, dueDate: db.vencimento || jsonP.dueDate, status: normalizedStatus, paidDate: db.data_pagamento || jsonP.paidDate || null, - type: jsonP.type || 'monthly', - description: jsonP.description || null, + type: db.type || jsonP.type || 'monthly', + description: db.description || jsonP.description || null, installmentNumber, totalInstallments, link_boleto: db.link_boleto || jsonP.bankSlipUrl || null,