Phase 1: Add rich columns to alunos_cobrancas and migrate JSON metadata to SQL on boot

This commit is contained in:
Sidney 2026-05-14 21:38:02 -03:00
parent 00351031d1
commit b440023add
3 changed files with 76 additions and 22 deletions

View File

@ -1620,6 +1620,32 @@ async function inicializarAgendamento() {
ALTER TABLE alunos_cobrancas ADD COLUMN last_overdue_warning_at TIMESTAMP WITH TIME ZONE; ALTER TABLE alunos_cobrancas ADD COLUMN last_overdue_warning_at TIMESTAMP WITH TIME ZONE;
END IF; 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 -- 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 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); CREATE UNIQUE INDEX idx_asaas_payment_id_unique ON alunos_cobrancas(asaas_payment_id);

View File

@ -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)) { 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.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( await client.query(
`INSERT INTO alunos_cobrancas ( `INSERT INTO alunos_cobrancas (
aluno_id, asaas_payment_id, asaas_installment_id, installment, aluno_id, asaas_payment_id, asaas_installment_id, installment,
valor, vencimento, link_boleto, status valor, vencimento, link_boleto, status,
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) 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 ON CONFLICT (asaas_payment_id) DO UPDATE SET
aluno_id = EXCLUDED.aluno_id, aluno_id = EXCLUDED.aluno_id,
asaas_installment_id = EXCLUDED.asaas_installment_id, asaas_installment_id = COALESCE(EXCLUDED.asaas_installment_id, alunos_cobrancas.asaas_installment_id),
installment = EXCLUDED.installment, installment = COALESCE(EXCLUDED.installment, alunos_cobrancas.installment),
valor = EXCLUDED.valor, valor = EXCLUDED.valor,
vencimento = EXCLUDED.vencimento, vencimento = EXCLUDED.vencimento,
link_boleto = EXCLUDED.link_boleto, link_boleto = COALESCE(EXCLUDED.link_boleto, alunos_cobrancas.link_boleto),
status = EXCLUDED.status`, 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.studentId,
p.asaasPaymentId, p.asaasPaymentId,
@ -566,7 +582,16 @@ export async function syncJsonToRelationalTables() {
p.amount || 0, p.amount || 0,
p.dueDate, p.dueDate,
p.bankSlipUrl || p.link || null, 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)); ).catch(err => console.warn(`[Sync:Finance] Erro no boleto ${p.asaasPaymentId}:`, err.message));
} }

View File

@ -232,7 +232,9 @@ app.get('/api/portal/financeiro', authMiddleware, async (req, res) => {
`SELECT asaas_payment_id, asaas_installment_id, installment, `SELECT asaas_payment_id, asaas_installment_id, installment,
valor, TO_CHAR(vencimento, 'YYYY-MM-DD') as vencimento, valor, TO_CHAR(vencimento, 'YYYY-MM-DD') as vencimento,
status, TO_CHAR(data_pagamento, 'YYYY-MM-DD') as data_pagamento, 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 FROM alunos_cobrancas
WHERE aluno_id = $1 WHERE aluno_id = $1
ORDER BY vencimento ASC`, 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); 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 schoolData = await getSchoolData();
const jsonPayments = (schoolData.payments || []).filter((p) => p.studentId === req.user.studentId); 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; 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 seenAsaasIds = new Set();
const finalPayments = []; const finalPayments = [];
@ -267,11 +269,10 @@ app.get('/api/portal/financeiro', authMiddleware, async (req, res) => {
const dbStatus = (db.status || '').toLowerCase().trim(); const dbStatus = (db.status || '').toLowerCase().trim();
const normalizedStatus = statusMap[dbStatus] || 'pending'; const normalizedStatus = statusMap[dbStatus] || 'pending';
// Calcular número da parcela se disponível // Parcela: SQL tem prioridade, depois JSON, depois inferência por grupo
let installmentNumber = jsonP.installmentNumber || null; let installmentNumber = db.installment_number || jsonP.installmentNumber || null;
let totalInstallments = jsonP.totalInstallments || 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) { if (!installmentNumber && db.asaas_installment_id) {
const siblings = dbRows.filter(r => r.asaas_installment_id === db.asaas_installment_id); const siblings = dbRows.filter(r => r.asaas_installment_id === db.asaas_installment_id);
if (siblings.length > 1) { 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({ finalPayments.push({
id: jsonP.id || asaasId, id: jsonP.id || asaasId,
studentId: req.user.studentId, studentId: req.user.studentId,
asaasPaymentId: asaasId, asaasPaymentId: asaasId,
asaasPaymentUrl: jsonP.asaasPaymentUrl || null, asaasPaymentUrl: db.asaas_payment_url || jsonP.asaasPaymentUrl || null,
// jsonP.amount = valor BRUTO (ex: 170), db.valor = valor LÍQUIDO do Asaas (ex: 150) amount: amountOriginal,
// Se o JSON tem o bruto, usa ele + desconto separado. Se não, usa SQL (já líquido) sem desconto. discount: discount,
amount: jsonP.amount || Number(db.valor) || 0,
discount: jsonP.amount ? (jsonP.discount || 0) : 0,
dueDate: db.vencimento || jsonP.dueDate, dueDate: db.vencimento || jsonP.dueDate,
status: normalizedStatus, status: normalizedStatus,
paidDate: db.data_pagamento || jsonP.paidDate || null, paidDate: db.data_pagamento || jsonP.paidDate || null,
type: jsonP.type || 'monthly', type: db.type || jsonP.type || 'monthly',
description: jsonP.description || null, description: db.description || jsonP.description || null,
installmentNumber, installmentNumber,
totalInstallments, totalInstallments,
link_boleto: db.link_boleto || jsonP.bankSlipUrl || null, link_boleto: db.link_boleto || jsonP.bankSlipUrl || null,