diff --git a/GEMINI.md b/GEMINI.md index 97b9611..604b109 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -82,6 +82,5 @@ 48. **Pré-Matrícula - Folder Promocional Dinâmico (Banner), Rascunho e Duplicatas**: O banner de promoção (flyer) deve ser gerenciado de forma 100% dinâmica como um campo personalizado na lista de campos do formulário (tipo `banner`). Isso permite que o administrador altere a ordem de exibição do flyer em relação aos demais campos e o exclua/adicione livremente. Se o formulário estiver em modo rascunho (`draft`), a página pública oculta as turmas e exibe uma mensagem elegante de vagas esgotadas. Duplicatas são bloqueadas no backend. 49. **Pré-Matrícula - Soft Delete de Campos e Preservação de Histórico**: É estritamente proibido realizar exclusões permanentes (hard-delete) de campos personalizados do formulário de pré-matrícula para evitar a exibição de códigos UUID orfãos na lista de inscrições e na exportação CSV de matrículas antigas. A exclusão de campos deve utilizar Soft Delete (`ativo = false`), e o frontend deve utilizar um mapeador inteligente de fallbacks para recuperar rótulos legados e campos inativos. 50. **Pré-Matrícula - Máscaras de Entrada e Single Event Binding**: Para evitar comportamentos instáveis em teclados móveis (como Gboard no Android ou Autoresumos no iOS), as máscaras de campos estruturados (telefone, CPF, CEP) devem ser gerenciadas por um único escopo de eventos global (`document.addEventListener`). É estritamente proibido realizar vinculações duplas (Double Binding) com ouvintes locais diretos concorrentes nos elementos dinâmicos, para evitar a perda de foco, cursor saltando, ou substituição indesejada de caracteres digitados. A máscara de telefone deve diferenciar e suportar dinamicamente tanto telefones fixos (10 dígitos) quanto celulares (11 dígitos). - - - +51. **Encargos Financeiros e Simulação Asaas**: Se nenhuma taxa de juro ou multa estiver cadastrada no curso do aluno, o fallback padrão é obrigatoriamente 0% (sem encargos). Em simulações de atraso, o desconto perde a validade automaticamente, calculando multa/juros sobre o valor bruto original. Se o vencimento cair em finais de semana, a contagem de encargos é postergada em um dia útil (considerando segunda-feira em dia, aplicando juros apenas a partir de terça-feira), refletido em paridade absoluta entre Manager e Portal. +52. **Simulação de Encargos no Dashboard do Portal**: O dashboard principal do Portal do Aluno (`portal/src/pages/Dashboard.tsx`) deve calcular e refletir em tempo real os juros, multas e perda de desconto para parcelas em atraso, mantendo paridade absoluta com os valores exibidos na aba Financeiro e prevenindo divergências visuais. diff --git a/MEMORY.md b/MEMORY.md index e9b7099..291fb21 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -90,4 +90,12 @@ Nesta sessão de trabalho, realizamos o "core" da transição do sistema EduMana - **Contratos, Certificados e Aulas**: As rotas de contratos (`/api/portal/contratos`), certificados (`/api/portal/certificados`) e grade de aulas (`/api/portal/aulas`) agora operam de forma nativa e pura por meio de queries SQL no PostgreSQL. - **Remoção de Código Morto**: Os métodos auxiliares de compatibilidade híbrida `getSchoolData()` e `saveSchoolData()` foram totalmente eliminados do servidor do portal. +### Fase 11: Padronização de Encargos Financeiros e Simulação Asaas +- **Regra de Postergação de Fins de Semana**: Implementada lógica que detecta se a data de vencimento da parcela recai em um final de semana (sábado ou domingo), estendendo a tolerância para segunda-feira e começando a calcular juros e multa apenas a partir de terça-feira. +- **Perda Automática de Desconto**: Se a parcela está atrasada (overdue), o desconto é considerado expirado. Os cálculos de multa (lateFee) e juros (interest) incidem sobre o valor bruto original (gross amount), e o desconto deixa de ser exibido/subtraído nas telas de simulação. +- **Remoção de Taxas Padrão Ocultas**: Caso o curso do aluno não possua as taxas de multa/juros configuradas no banco, o sistema agora assume 0% de encargos (antes assumia fixo 2% de multa e 1% de juros), respeitando estritamente o que foi cadastrado. +- **Sincronização de Visões**: Aplicado de forma idêntica no painel Manager (tanto na tabela geral quanto no histórico de cobranças por aluno) e no Portal do Aluno para correspondência visual absoluta. +### Fase 12: Simulação de Encargos no Dashboard do Portal do Aluno (SQL-First) +- **Correção do Dashboard do Portal**: Atualizado o painel principal do Portal do Aluno (`portal/src/pages/Dashboard.tsx`) para embutir as funções de simulação matemática de juros/multa de atraso e expiração de desconto. O dashboard agora reflete exatamente o mesmo valor com encargos calculado na página Financeiro, unificando os totais pendentes e os valores de parcelas vencidas do card "Próximo Vencimento". +- **Declaração de Tipos**: Atualizada a interface `Payment` em `portal/src/types.ts` para opcionalmente expor as propriedades `lateFee`, `interest` e `valor_pago`, garantindo a validação estrita do compilador TypeScript e prevenindo quebras de tela (White Screen). diff --git a/manager/server.selfhosted.js b/manager/server.selfhosted.js index 30c4eca..2b87dbd 100644 --- a/manager/server.selfhosted.js +++ b/manager/server.selfhosted.js @@ -48,7 +48,7 @@ import { getProvas, getQuestoesDaProva, insertProva, updateProva, deleteProva, syncQuestoesProva, getConfiguracoes, insertConfiguracao, updateConfiguracao, deleteConfiguracao } from './services/database.js'; -import { uploadLogo as uploadLogoToStorage, uploadCarne as uploadCarneToStorage, uploadReceipt as uploadReceiptToStorage, getMinioStats, s3Client, getBucketObjects, deleteMinioObject, uploadFile } from './services/storage.js'; +import { uploadLogo as uploadLogoToStorage, uploadCarne as uploadCarneToStorage, uploadReceipt as uploadReceiptToStorage, getMinioStats, s3Client, getBucketObjects, deleteMinioObject, uploadFile, getFileBuffer } from './services/storage.js'; import { GetObjectCommand } from '@aws-sdk/client-s3'; const __filename = fileURLToPath(import.meta.url); @@ -1691,6 +1691,17 @@ async function sendEvolutionMessage(asaasPaymentId, eventType, fallbackValorArg const pngBuffer = await sharp(buffer).png().toBuffer(); logoDataUrl = `data:image/png;base64,${pngBuffer.toString('base64')}`; } + } else if (logoUrl.startsWith('/storage/') || (!logoUrl.startsWith('http') && !logoUrl.startsWith('data:image'))) { + const cleanUrl = logoUrl.startsWith('/storage/') ? logoUrl : `/storage/${logoUrl}`; + const parts = cleanUrl.split('/').filter(Boolean); + if (parts.length >= 3) { + const bucket = parts[1]; + const key = parts.slice(2).join('/'); + const buffer = await getFileBuffer(bucket, key); + const pngBuffer = await sharp(buffer).png().toBuffer(); + logoFormat = 'PNG'; + logoDataUrl = `data:image/png;base64,${pngBuffer.toString('base64')}`; + } } } } catch (e) { diff --git a/manager/services/storage.js b/manager/services/storage.js index 99b2e25..4390f78 100644 --- a/manager/services/storage.js +++ b/manager/services/storage.js @@ -185,6 +185,25 @@ export async function deleteMinioObject(bucketName, key) { } } +/** + * Recupera o buffer de um arquivo do MinIO + */ +export async function getFileBuffer(bucket, key) { + const command = new GetObjectCommand({ Bucket: bucket, Key: key }); + const response = await s3Client.send(command); + + if (response.Body && typeof response.Body.transformToByteArray === 'function') { + const uint8Array = await response.Body.transformToByteArray(); + return Buffer.from(uint8Array); + } + + const chunks = []; + for await (const chunk of response.Body) { + chunks.push(chunk); + } + return Buffer.concat(chunks); +} + export { s3Client }; // Inicialização proativa de buckets essenciais diff --git a/portal/src/pages/Dashboard.tsx b/portal/src/pages/Dashboard.tsx index 9673ccf..4b940e2 100644 --- a/portal/src/pages/Dashboard.tsx +++ b/portal/src/pages/Dashboard.tsx @@ -64,9 +64,53 @@ export default function Dashboard() { ); } - const pendingPayments = data?.payments?.filter(p => p.status === 'pending' || p.status === 'overdue') || []; - const overduePayments = data?.payments?.filter(p => p.status === 'overdue') || []; - const totalPending = pendingPayments.reduce((s, p) => s + (p.amount - (p.discount || 0)), 0); + const normalizeStatus = (payment: Payment) => { + const s = payment.status?.toLowerCase(); + if (['paid', 'received', 'confirmed', 'pago'].includes(s)) return 'paid'; + if (['cancelled', 'cancelado'].includes(s)) return 'cancelled'; + if (['overdue', 'atrasado', 'atrasada', 'vencido'].includes(s)) return 'overdue'; + return 'pending'; + }; + + const getDisplayValue = (payment: Payment) => { + const status = normalizeStatus(payment); + const isPaid = status === 'paid'; + const valorPago = (payment as any).valor_pago ? Number((payment as any).valor_pago) : 0; + + if (isPaid && valorPago > 0) return valorPago; + if (isPaid) return payment.amount - (payment.discount || 0); + + if (status === 'overdue' && payment.dueDate) { + const dd = new Date(payment.dueDate + 'T00:00:00'); + const tt = new Date(); tt.setHours(0,0,0,0); + + let vencimentoEfetivo = new Date(dd); + const diaSemana = dd.getDay(); + if (diaSemana === 6) { + vencimentoEfetivo.setDate(dd.getDate() + 2); + } else if (diaSemana === 0) { + vencimentoEfetivo.setDate(dd.getDate() + 1); + } + + if (tt > vencimentoEfetivo) { + const baseAmount = payment.amount; + const dDays = Math.ceil((tt.getTime() - dd.getTime()) / 86400000); + const fineAmt = baseAmount * ((payment.lateFee ?? 0) / 100); + const interestRate = payment.interest ?? 0; + const intAmt = baseAmount * ((interestRate / 30) / 100) * dDays; + return baseAmount + fineAmt + intAmt; + } + } + + return payment.amount - (payment.discount || 0); + }; + + const pendingPayments = data?.payments?.filter(p => { + const s = normalizeStatus(p); + return s === 'pending' || s === 'overdue'; + }) || []; + const overduePayments = data?.payments?.filter(p => normalizeStatus(p) === 'overdue') || []; + const totalPending = pendingPayments.reduce((s, p) => s + getDisplayValue(p), 0); // Synchronized Frequency Calculation (Matches Frequencia.tsx & Manager) let presencesCount = 0; @@ -461,7 +505,7 @@ export default function Dashboard() { {nextDue ? ( <>
Vence em {formatDate(nextDue.dueDate)} diff --git a/portal/src/types.ts b/portal/src/types.ts index 9392715..fd3864b 100644 --- a/portal/src/types.ts +++ b/portal/src/types.ts @@ -44,6 +44,9 @@ export interface Payment { asaasPaymentId?: string; asaasPaymentUrl?: string; transactionReceiptUrl?: string; + lateFee?: number; + interest?: number; + valor_pago?: number; } export interface Grade {