commit 6bb1adeef64b68846fa2bf62fa6482e82e6c3893 Author: CnxiFly Dev Date: Sat Dec 13 18:33:14 2025 +0100 feat: IconSelector no modal de categoria + traducao costCenters.costCenter + categorias UTF-8 corrigidas diff --git a/.DIRETRIZES_DESENVOLVIMENTO_v3 b/.DIRETRIZES_DESENVOLVIMENTO_v3 new file mode 100644 index 0000000..7660434 --- /dev/null +++ b/.DIRETRIZES_DESENVOLVIMENTO_v3 @@ -0,0 +1,211 @@ +╔═══════════════════════════════════════════════════════════════════════════════╗ +║ DIRETRIZES DE DESENVOLVIMENTO - v3.0 ║ +║ ║ +║ ⚠️ ESTE ARQUIVO NÃO DEVE SER EDITADO APÓS QUALQUER COMMIT/PUSH ║ +║ ⚠️ Representa o contrato de desenvolvimento desde a versão 1.19.2 ║ +║ ⚠️ Substitui .DIRETRIZES_DESENVOLVIMENTO_v2 (v2.0) ║ +║ ║ +╚═══════════════════════════════════════════════════════════════════════════════╝ + +DATA DE CRIAÇÃO: 10 de Dezembro de 2025 +VERSÃO INICIAL: 1.19.2 +VERSÃO DAS DIRETRIZES: 3.0 +STATUS: ATIVO E IMUTÁVEL + +═══════════════════════════════════════════════════════════════════════════════ + REGRAS DE DESENVOLVIMENTO +═══════════════════════════════════════════════════════════════════════════════ + +─────────────────────────────────────────────────────────────────────────────── +REGRA #1: CONTROLE DE VERSÃO SEMÂNTICO +─────────────────────────────────────────────────────────────────────────────── + +✓ Formato: MAJOR.MINOR.PATCH (exemplo: 1.19.2) +✓ Incrementar versão em CADA commit/push +✓ Manter sincronizado em: VERSION, CHANGELOG.md + +Regra de Incremento: + - MAJOR (X.0.0): Mudanças incompatíveis, redesign completo + - MINOR (0.X.0): Novas funcionalidades + - PATCH (0.0.X): Correções de bugs, ajustes menores + +─────────────────────────────────────────────────────────────────────────────── +REGRA #2: VALIDAÇÃO OBRIGATÓRIA EM PRODUÇÃO +─────────────────────────────────────────────────────────────────────────────── + +✓ TODAS as mudanças devem ser testadas em https://webmoney.cnxifly.com +✓ Workflow obrigatório: + 1. Editar código + 2. Deploy para servidor (./deploy.sh) + 3. Testar no domínio + 4. Commit/push apenas após validação + 5. Só então editar novamente + +✗ PROIBIDO commit sem teste em produção + +─────────────────────────────────────────────────────────────────────────────── +REGRA #3: DOCUMENTAÇÃO ESSENCIAL +─────────────────────────────────────────────────────────────────────────────── + +Arquivos de documentação mantidos (apenas estes): + +| Arquivo | Propósito | Atualizar quando | +|---------|-----------|------------------| +| VERSION | Número da versão | Cada commit | +| CHANGELOG.md | Histórico de mudanças | Cada commit | +| README.md | Visão geral do projeto | Mudanças significativas | +| ESTRUTURA_PROJETO.md | Estrutura técnica | Novos arquivos/endpoints | +| CREDENCIAIS_SERVIDOR.md | Acessos | Mudança de credenciais | +| .DIRETRIZES_DESENVOLVIMENTO_v3 | Este arquivo | NUNCA (criar nova versão) | + +Arquivos de referência (não atualizar frequentemente): +- ESPECIFICACIONES_WEBMONEY.md (especificação original) +- APRENDIZADOS_TECNICOS.md (soluções de problemas) +- ROTEIRO_INSTALACAO_SERVIDOR.md (guia de instalação) +- DKIM_DNS_RECORD.txt (configuração DNS) + +─────────────────────────────────────────────────────────────────────────────── +REGRA #4: SCRIPTS DE DEPLOY +─────────────────────────────────────────────────────────────────────────────── + +✓ SEMPRE usar os scripts de deploy: + + Frontend: cd frontend && ./deploy.sh + Backend: cd backend && ./deploy.sh + +✗ NUNCA enviar arquivos manualmente via scp para diretórios errados +✓ Os scripts garantem o caminho correto: + - Frontend → /var/www/webmoney/frontend/dist + - Backend → /var/www/webmoney/backend + +─────────────────────────────────────────────────────────────────────────────── +REGRA #5: CHECKLIST DE COMMIT +─────────────────────────────────────────────────────────────────────────────── + +Antes de CADA commit: + ☑ VERSION atualizado + ☑ CHANGELOG.md atualizado + ☑ Deploy executado (./deploy.sh) + ☑ Testado em webmoney.cnxifly.com + ☑ Sem erros no console do navegador + ☑ Mensagem de commit descritiva + +─────────────────────────────────────────────────────────────────────────────── +REGRA #6: PROIBIÇÕES EXPLÍCITAS +─────────────────────────────────────────────────────────────────────────────── + +✗ NÃO editar arquivos sem commit anterior +✗ NÃO criar documentação específica de versão (ex: DEPLOY_v1.9.0.md) +✗ NÃO duplicar informação em múltiplos arquivos +✗ NÃO fazer deploy manual (usar scripts) +✗ NÃO commitar sem testar em produção + +═══════════════════════════════════════════════════════════════════════════════ + INFRAESTRUTURA +═══════════════════════════════════════════════════════════════════════════════ + +─────────────────────────────────────────────────────────────────────────────── +SERVIDOR DE PRODUÇÃO +─────────────────────────────────────────────────────────────────────────────── + +IP: 213.165.93.60 +Acesso: sshpass -p 'Master9354' ssh root@213.165.93.60 + +Estrutura: + /var/www/webmoney/ + ├── backend/ # Laravel API + └── frontend/ + └── dist/ # React build (Nginx root) + +─────────────────────────────────────────────────────────────────────────────── +DOMÍNIOS +─────────────────────────────────────────────────────────────────────────────── + +| Subdomínio | Função | +|------------|--------| +| webmoney.cnxifly.com | Aplicação principal | +| phpmyadmin.cnxifly.com | Banco de dados | +| webmail.cnxifly.com | Email | +| mail.cnxifly.com | PostfixAdmin | + +─────────────────────────────────────────────────────────────────────────────── +STACK TECNOLÓGICA +─────────────────────────────────────────────────────────────────────────────── + +| Camada | Tecnologia | +|--------|------------| +| Backend | Laravel 12 + PHP 8.4-FPM | +| Frontend | React 18 + Vite 7 + Bootstrap 5 | +| Banco | MariaDB 11.4 | +| Cache | Redis | +| Servidor | Nginx + SSL (Let's Encrypt) | +| Auth | Laravel Sanctum (Bearer Tokens) | + +═══════════════════════════════════════════════════════════════════════════════ + SEGURANÇA +═══════════════════════════════════════════════════════════════════════════════ + +Implementado em v1.19.0: + +| Recurso | Configuração | +|---------|--------------| +| Rate Limiting | Login: 5/min, Register: 10/hour | +| CORS | Restrito a webmoney.cnxifly.com | +| Token Expiration | 7 dias | +| Cookies | HttpOnly, Secure, SameSite=lax, Encrypt=true | +| Headers | X-XSS-Protection, X-Content-Type-Options, X-Frame-Options, CSP | +| Cookie Consent | Banner LGPD/GDPR | + +═══════════════════════════════════════════════════════════════════════════════ + ESTADO ATUAL +═══════════════════════════════════════════════════════════════════════════════ + +Versão: 1.19.2 +Data: 10 de Dezembro de 2025 +Status: Produção estável + +Funcionalidades: + ✅ Autenticação (login, registro, logout) + ✅ Dashboard (gráficos, análises) + ✅ Contas bancárias (CRUD, multi-moeda) + ✅ Transações (agrupamento por semana, filtros) + ✅ Categorias (175 pré-configuradas, auto-classificação) + ✅ Centros de custo + ✅ Importação de extratos (XLSX, CSV, OFX, PDF) + ✅ Detecção de duplicatas (auto-delete) + ✅ Detecção de transferências + ✅ Contas passivo (financiamentos) + ✅ Multi-idioma (ES, PT-BR, EN) com detecção por país + ✅ Tema dark + ✅ Cookie consent (LGPD/GDPR) + ✅ Segurança hardening + +═══════════════════════════════════════════════════════════════════════════════ + HISTÓRICO DE DIRETRIZES +═══════════════════════════════════════════════════════════════════════════════ + +| Versão | Data | Mudanças | +|--------|------|----------| +| v1.0 | 2025-12-07 | Criação inicial | +| v2.0 | 2025-12-08 | Adicionada REGRA #8 (ESTRUTURA_PROJETO) | +| v3.0 | 2025-12-10 | Simplificação, remoção de redundâncias, estado atual | + +Arquivos de diretrizes: + - .DIRETRIZES_DESENVOLVIMENTO (v1.0 - EXCLUÍDO) + - .DIRETRIZES_DESENVOLVIMENTO_v2 (v2.0 - arquivado) + - .DIRETRIZES_DESENVOLVIMENTO_v3 (v3.0 - ATIVO) + +═══════════════════════════════════════════════════════════════════════════════ + ⚠️ LEMBRETE FINAL +═══════════════════════════════════════════════════════════════════════════════ + +ANTES de editar qualquer arquivo: + 1. ✓ Último commit foi feito? + 2. ✓ VERSION será incrementado? + 3. ✓ CHANGELOG será atualizado? + 4. ✓ Deploy será feito via script? + 5. ✓ Teste em produção será realizado? + +Este documento é IMUTÁVEL. Qualquer mudança requer criar v4.0. + +═══════════════════════════════════════════════════════════════════════════════ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5305fca --- /dev/null +++ b/.gitignore @@ -0,0 +1,51 @@ +# ============================================================================= +# WebMoney - .gitignore +# ============================================================================= + +# Dependencias +node_modules/ +vendor/ + +# Build +frontend/dist/ + +# Environment +.env +.env.local +.env.*.local +backend/.env + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db +desktop.ini + +# Logs +*.log +npm-debug.log* +storage/logs/* +!storage/logs/.gitignore + +# Cache +storage/framework/cache/* +storage/framework/sessions/* +storage/framework/views/* +bootstrap/cache/* +!bootstrap/cache/.gitignore +!storage/framework/cache/.gitignore +!storage/framework/sessions/.gitignore +!storage/framework/views/.gitignore + +# Testing +coverage/ +.phpunit.result.cache + +# Temp +*.tmp +*.temp diff --git a/ANALISE_CONTRATO_PRICE.md b/ANALISE_CONTRATO_PRICE.md new file mode 100644 index 0000000..90f7edc --- /dev/null +++ b/ANALISE_CONTRATO_PRICE.md @@ -0,0 +1,234 @@ +# 📊 Análise Técnica Financeira - Contrato de Empréstimo Pessoal + +## 1. Identificação do Contrato + +| Característica | Valor | +|----------------|-------| +| **Tipo** | Empréstimo Pessoal | +| **Sistema de Amortização** | **PRICE (Tabela Francesa)** | +| **Credor** | WANNA | +| **Período** | 05/06/2025 a 05/04/2030 | +| **Prazo** | 59 meses (~5 anos) | + +--- + +## 2. Valores do Contrato + +| Item | Valor | +|------|-------| +| **Capital Financiado (PV)** | €5.438,90 | +| **Total de Juros** | €1.538,17 | +| **Valor Total do Contrato** | €6.977,07 | +| **Parcela Fixa (PMT)** | €122,00/mês | + +--- + +## 3. Taxas de Juros + +### Taxa Nominal (informada pelo banco): + +| Tipo | Valor | +|------|-------| +| Taxa Mensal | **0,48%** | +| Taxa Anual | **5,75%** | +| CET (Custo Efetivo Total) | **28,28%** | + +### Taxa Efetiva (calculada pela amortização): + +| Tipo | Valor | +|------|-------| +| Taxa Mensal Efetiva | **0,8886%** | +| Taxa Anual Efetiva | **~10,66%** | + +### ⚠️ Diferença entre Taxas + +A **taxa nominal (0,48%)** é menor que a **taxa efetiva (0,89%)**. Isso ocorre porque: + +- A taxa nominal é **PRÉ-FIXADA** e não considera a capitalização composta +- A taxa efetiva é calculada sobre o **saldo devedor real** a cada mês +- O CET de 28,28% inclui todos os custos (juros + taxas + seguros) + +--- + +## 4. Sistema de Amortização PRICE + +### Características + +1. **Parcela Constante** - €122,00 fixos todo mês +2. **Juros Decrescentes** - Calculados sobre saldo devedor que diminui +3. **Amortização Crescente** - A cada mês, mais capital é quitado + +### Fórmula PRICE + +``` +PMT = PV × [i × (1+i)^n] / [(1+i)^n - 1] + +Onde: +- PV = 5.438,90 (valor presente / capital financiado) +- i = 0,008886 (taxa mensal efetiva) +- n = 58 parcelas (exceto a 1ª de carência) +- PMT = 122,00 (parcela mensal) +``` + +### Demonstração Matemática + +``` +PMT = 5438,90 × [0,008886 × (1,008886)^58] / [(1,008886)^58 - 1] +PMT = 5438,90 × [0,008886 × 1,6723] / [1,6723 - 1] +PMT = 5438,90 × [0,01486] / [0,6723] +PMT = 5438,90 × 0,02211 +PMT ≈ 122,00 +``` + +--- + +## 5. Parcela de Carência (#1) + +A primeira parcela é **atípica**: + +| Item | Valor | +|------|-------| +| **Valor da Parcela** | €20,85 | +| **Juros** | €20,85 | +| **Amortização** | €0,00 | +| **Tipo** | Parcela de carência / Juros pro-rata | + +Isso significa que o primeiro pagamento cobre apenas os juros do período entre a liberação do crédito e o início das parcelas normais. O capital não é amortizado nesta parcela. + +--- + +## 6. Evolução do Saldo Devedor + +### Primeiras 10 Parcelas + +| # | Saldo Inicial | Juros | Amortização | Parcela | Saldo Final | +|---|---------------|-------|-------------|---------|-------------| +| 1 | €5.438,90 | €20,85 | €0,00 | €20,85 | €5.438,90 | +| 2 | €5.438,90 | €48,33 | €73,67 | €122,00 | €5.365,23 | +| 3 | €5.365,23 | €47,68 | €74,32 | €122,00 | €5.290,91 | +| 4 | €5.290,91 | €47,01 | €74,99 | €122,00 | €5.215,92 | +| 5 | €5.215,92 | €46,35 | €75,65 | €122,00 | €5.140,27 | +| 6 | €5.140,27 | €45,68 | €76,32 | €122,00 | €5.063,95 | +| 7 | €5.063,95 | €45,00 | €77,00 | €122,00 | €4.986,95 | +| 8 | €4.986,95 | €44,31 | €77,69 | €122,00 | €4.909,26 | +| 9 | €4.909,26 | €43,62 | €78,38 | €122,00 | €4.830,88 | +| 10 | €4.830,88 | €42,93 | €79,07 | €122,00 | €4.751,81 | + +### Observações + +- **Juros diminuem** a cada parcela (€48,33 → €47,68 → €47,01...) +- **Amortização aumenta** a cada parcela (€73,67 → €74,32 → €74,99...) +- **Soma sempre = €122,00** (parcela fixa - característica do sistema PRICE) + +### Gráfico Conceitual + +``` +Composição da Parcela ao Longo do Tempo: + +Início do Contrato: +├─────────── JUROS (40%) ───────────┤├─ AMORTIZAÇÃO (60%) ─┤ + +Meio do Contrato: +├────── JUROS (25%) ──────┤├───── AMORTIZAÇÃO (75%) ─────┤ + +Final do Contrato: +├─ JUROS (5%) ─┤├─────────── AMORTIZAÇÃO (95%) ───────────┤ +``` + +--- + +## 7. Resumo Financeiro + +| Métrica | Fórmula | Valor | +|---------|---------|-------| +| **Custo do Dinheiro** | (Juros / Capital) × 100 | 28,28% | +| **Multiplicador** | Total / Capital | 1,2828x | +| **Juros Médio por Parcela** | Juros Total / Nº Parcelas | €26,07 | +| **Economia se Quitar Antecipado** | Juros Restantes | Variável | + +### Análise de Custo + +Por cada **€1,00** emprestado, você pagará **€1,28** ao final do contrato. + +--- + +## 8. Classificação Contábil + +| Aspecto | Classificação | +|---------|---------------| +| **Tipo de Passivo** | Passivo Não Circulante (longo prazo > 12 meses) | +| **Natureza** | Empréstimo Bancário / Financiamento | +| **Regime** | Competência (juros apropriados mensalmente) | +| **Conta Contábil** | Empréstimos e Financiamentos a Pagar | + +### Lançamentos Contábeis Mensais + +``` +D - Despesa de Juros (Resultado) €XX,XX +D - Empréstimos a Pagar (Passivo) €XX,XX +C - Banco (Ativo) €122,00 +``` + +--- + +## 9. Considerações sobre Sobrepagamentos + +Quando o valor pago é **maior** que o valor da parcela: + +| Situação | Tratamento | +|----------|------------| +| Parcela: €122,00 | Valor contratual | +| Pago: €147,00 | Valor efetivo | +| Diferença: €25,00 | **Cargo extra** (não amortiza capital) | + +O sobrepagamento é registrado como **cargo/taxa adicional** e não reduz o saldo devedor. Representa custos extraordinários como: +- Juros de mora +- Multas por atraso +- Taxas administrativas +- Encargos não previstos no contrato original + +--- + +## 10. Glossário + +| Termo | Definição | +|-------|-----------| +| **PV (Present Value)** | Valor Presente / Capital Financiado | +| **PMT (Payment)** | Valor da Parcela Mensal | +| **i (Interest Rate)** | Taxa de Juros | +| **n (Number)** | Número de Parcelas | +| **CET** | Custo Efetivo Total | +| **Amortização** | Parte da parcela que reduz o principal | +| **PRICE** | Sistema de parcelas fixas (Tabela Francesa) | +| **SAC** | Sistema de Amortização Constante (alternativo) | + +--- + +## 11. Comparativo: PRICE vs SAC + +| Característica | PRICE (Este Contrato) | SAC | +|----------------|----------------------|-----| +| Parcela | Fixa (€122,00) | Decrescente | +| Amortização | Crescente | Constante | +| Juros | Decrescentes | Decrescentes | +| Total de Juros | Maior | Menor | +| Indicado para | Orçamento estável | Maior renda inicial | + +--- + +## 12. Conclusão + +Este é um contrato típico de **crédito pessoal com amortização PRICE**, muito comum em bancos e fintechs europeias. + +### Pontos-chave: + +1. ✅ **Parcela fixa** facilita o planejamento financeiro +2. ⚠️ **Taxa efetiva > Taxa nominal** - comum em contratos bancários +3. 📊 **CET de 28,28%** representa o custo real total do empréstimo +4. 💡 **Quitação antecipada** pode gerar economia significativa de juros + +--- + +*Documento gerado em: 10/12/2025* +*Sistema: WEBMoney v1.23.2* +*Análise baseada nos dados do contrato cadastrado* diff --git a/ANALISE_CONTRATO_PRICE.txt b/ANALISE_CONTRATO_PRICE.txt new file mode 100644 index 0000000..7713c79 --- /dev/null +++ b/ANALISE_CONTRATO_PRICE.txt @@ -0,0 +1,192 @@ +================================================================================ + ANÁLISE TÉCNICA FINANCEIRA - CONTRATO DE EMPRÉSTIMO PESSOAL +================================================================================ + +1. IDENTIFICAÇÃO DO CONTRATO +-------------------------------------------------------------------------------- +Tipo........................: Empréstimo Pessoal +Sistema de Amortização......: PRICE (Tabela Francesa) +Credor......................: WANNA +Período.....................: 05/06/2025 a 05/04/2030 +Prazo.......................: 59 meses (~5 anos) + + +2. VALORES DO CONTRATO +-------------------------------------------------------------------------------- +Capital Financiado (PV).....: €5.438,90 +Total de Juros..............: €1.538,17 +Valor Total do Contrato.....: €6.977,07 +Parcela Fixa (PMT)..........: €122,00/mês + + +3. TAXAS DE JUROS +-------------------------------------------------------------------------------- + +TAXA NOMINAL (informada pelo banco): + Taxa Mensal...............: 0,48% + Taxa Anual................: 5,75% + CET (Custo Efetivo Total).: 28,28% + +TAXA EFETIVA (calculada pela amortização): + Taxa Mensal Efetiva.......: 0,8886% + Taxa Anual Efetiva........: ~10,66% + +OBSERVAÇÃO: +A taxa nominal (0,48%) é menor que a taxa efetiva (0,89%). Isso ocorre porque: +- A taxa nominal é PRÉ-FIXADA e não considera a capitalização composta +- A taxa efetiva é calculada sobre o saldo devedor real a cada mês +- O CET de 28,28% inclui todos os custos (juros + taxas + seguros) + + +4. SISTEMA DE AMORTIZAÇÃO PRICE +-------------------------------------------------------------------------------- + +CARACTERÍSTICAS: +1. Parcela Constante - €122,00 fixos todo mês +2. Juros Decrescentes - Calculados sobre saldo devedor que diminui +3. Amortização Crescente - A cada mês, mais capital é quitado + +FÓRMULA PRICE: + + PMT = PV × [i × (1+i)^n] / [(1+i)^n - 1] + + Onde: + - PV = 5.438,90 (valor presente / capital financiado) + - i = 0,008886 (taxa mensal efetiva) + - n = 58 parcelas (exceto a 1ª de carência) + - PMT = 122,00 (parcela mensal) + +DEMONSTRAÇÃO MATEMÁTICA: + + PMT = 5438,90 × [0,008886 × (1,008886)^58] / [(1,008886)^58 - 1] + PMT = 5438,90 × [0,008886 × 1,6723] / [1,6723 - 1] + PMT = 5438,90 × [0,01486] / [0,6723] + PMT = 5438,90 × 0,02211 + PMT ≈ 122,00 + + +5. PARCELA DE CARÊNCIA (#1) +-------------------------------------------------------------------------------- +Valor da Parcela............: €20,85 +Juros.......................: €20,85 +Amortização.................: €0,00 +Tipo........................: Parcela de carência / Juros pro-rata + +O primeiro pagamento cobre apenas os juros do período entre a liberação do +crédito e o início das parcelas normais. O capital não é amortizado nesta parcela. + + +6. EVOLUÇÃO DO SALDO DEVEDOR +-------------------------------------------------------------------------------- + +PRIMEIRAS 10 PARCELAS: + + # | Saldo Inicial | Juros | Amortiz. | Parcela | Saldo Final +-----+--------------+----------+----------+----------+-------------- + 1 | €5.438,90 | €20,85 | €0,00 | €20,85 | €5.438,90 + 2 | €5.438,90 | €48,33 | €73,67 | €122,00 | €5.365,23 + 3 | €5.365,23 | €47,68 | €74,32 | €122,00 | €5.290,91 + 4 | €5.290,91 | €47,01 | €74,99 | €122,00 | €5.215,92 + 5 | €5.215,92 | €46,35 | €75,65 | €122,00 | €5.140,27 + 6 | €5.140,27 | €45,68 | €76,32 | €122,00 | €5.063,95 + 7 | €5.063,95 | €45,00 | €77,00 | €122,00 | €4.986,95 + 8 | €4.986,95 | €44,31 | €77,69 | €122,00 | €4.909,26 + 9 | €4.909,26 | €43,62 | €78,38 | €122,00 | €4.830,88 + 10 | €4.830,88 | €42,93 | €79,07 | €122,00 | €4.751,81 + +OBSERVAÇÕES: +- Juros diminuem a cada parcela (€48,33 → €47,68 → €47,01...) +- Amortização aumenta a cada parcela (€73,67 → €74,32 → €74,99...) +- Soma sempre = €122,00 (parcela fixa - característica do sistema PRICE) + +GRÁFICO CONCEITUAL - Composição da Parcela ao Longo do Tempo: + + Início: [======== JUROS (40%) ========][=== AMORTIZAÇÃO (60%) ===] + Meio: [==== JUROS (25%) ====][======= AMORTIZAÇÃO (75%) =======] + Final: [= JUROS (5%) =][============ AMORTIZAÇÃO (95%) ============] + + +7. RESUMO FINANCEIRO +-------------------------------------------------------------------------------- +Custo do Dinheiro...........: 28,28% (Juros / Capital × 100) +Multiplicador...............: 1,2828x (Total / Capital) +Juros Médio por Parcela.....: €26,07 (Juros Total / Nº Parcelas) + +ANÁLISE DE CUSTO: +Por cada €1,00 emprestado, você pagará €1,28 ao final do contrato. + + +8. CLASSIFICAÇÃO CONTÁBIL +-------------------------------------------------------------------------------- +Tipo de Passivo.............: Passivo Não Circulante (longo prazo > 12 meses) +Natureza....................: Empréstimo Bancário / Financiamento +Regime......................: Competência (juros apropriados mensalmente) +Conta Contábil..............: Empréstimos e Financiamentos a Pagar + +LANÇAMENTOS CONTÁBEIS MENSAIS: + + D - Despesa de Juros (Resultado) €XX,XX + D - Empréstimos a Pagar (Passivo) €XX,XX + C - Banco (Ativo) €122,00 + + +9. CONSIDERAÇÕES SOBRE SOBREPAGAMENTOS +-------------------------------------------------------------------------------- + +Quando o valor pago é MAIOR que o valor da parcela: + + Parcela contratual........: €122,00 + Valor efetivamente pago...: €147,00 + Diferença (cargo extra)...: €25,00 + +O sobrepagamento é registrado como CARGO/TAXA ADICIONAL e não reduz o saldo +devedor. Representa custos extraordinários como: +- Juros de mora +- Multas por atraso +- Taxas administrativas +- Encargos não previstos no contrato original + + +10. GLOSSÁRIO +-------------------------------------------------------------------------------- +PV (Present Value)...: Valor Presente / Capital Financiado +PMT (Payment)........: Valor da Parcela Mensal +i (Interest Rate)....: Taxa de Juros +n (Number)...........: Número de Parcelas +CET..................: Custo Efetivo Total +Amortização..........: Parte da parcela que reduz o principal +PRICE................: Sistema de parcelas fixas (Tabela Francesa) +SAC..................: Sistema de Amortização Constante (alternativo) + + +11. COMPARATIVO: PRICE vs SAC +-------------------------------------------------------------------------------- + + | PRICE (Este Contrato) | SAC +----------------------+-----------------------------+---------------------- +Parcela | Fixa (€122,00) | Decrescente +Amortização | Crescente | Constante +Juros | Decrescentes | Decrescentes +Total de Juros | Maior | Menor +Indicado para | Orçamento estável | Maior renda inicial + + +12. CONCLUSÃO +-------------------------------------------------------------------------------- + +Este é um contrato típico de CRÉDITO PESSOAL COM AMORTIZAÇÃO PRICE, muito +comum em bancos e fintechs europeias. + +PONTOS-CHAVE: + +[✓] Parcela fixa facilita o planejamento financeiro +[!] Taxa efetiva > Taxa nominal - comum em contratos bancários +[i] CET de 28,28% representa o custo real total do empréstimo +[*] Quitação antecipada pode gerar economia significativa de juros + + +================================================================================ +Documento gerado em: 10/12/2025 +Sistema: WEBMoney v1.23.2 +Análise baseada nos dados do contrato cadastrado +================================================================================ diff --git a/APRENDIZADOS_TECNICOS.md b/APRENDIZADOS_TECNICOS.md new file mode 100644 index 0000000..ff4d94d --- /dev/null +++ b/APRENDIZADOS_TECNICOS.md @@ -0,0 +1,269 @@ +# APRENDIZADOS TÉCNICOS - WEBMONEY + +Este documento registra problemas encontrados e suas soluções para referência futura. + +--- + +## 📋 Índice + +1. [Bootstrap 5 + React: Componentes JS não funcionam](#1-bootstrap-5--react-componentes-js-não-funcionam) +2. [Deploy: Arquivos no diretório errado](#2-deploy-arquivos-no-diretório-errado) + +--- + +## 1. Bootstrap 5 + React: Componentes JS não funcionam + +**Data:** 8 de Dezembro de 2025 +**Versões afetadas:** v1.3.8 - v1.3.11 +**Componentes afetados:** Dropdown, Modal + +### ❌ Problema + +Componentes do Bootstrap 5 que dependem de JavaScript (Dropdown, Modal, Tooltip, Popover, etc.) **não funcionam** corretamente em aplicações React, mesmo importando o Bootstrap JS. + +**Sintomas:** +- Dropdown não abre ao clicar +- Modal não aparece +- `data-bs-toggle="dropdown"` não faz nada +- `window.bootstrap.Modal` não inicializa corretamente + +### 🔍 Causa Raiz + +O Bootstrap 5 JavaScript foi projetado para manipular o DOM diretamente, o que conflita com o Virtual DOM do React: + +1. **Ciclo de vida:** Bootstrap inicializa componentes no `DOMContentLoaded`, mas React renderiza depois +2. **Re-renders:** Quando React re-renderiza, os listeners do Bootstrap são perdidos +3. **Referências:** `new bootstrap.Modal(element)` pode referenciar elementos que React já substituiu + +### ✅ Solução + +**Implementar os componentes usando 100% React puro**, sem depender da API JavaScript do Bootstrap. + +#### Dropdown Controlado (React) + +```jsx +// ❌ ERRADO - Não funciona +
+ + +
+ +// ✅ CORRETO - React controlado +const [isOpen, setIsOpen] = useState(false); +const dropdownRef = useRef(null); + +// Fechar ao clicar fora +useEffect(() => { + const handleClickOutside = (e) => { + if (dropdownRef.current && !dropdownRef.current.contains(e.target)) { + setIsOpen(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); +}, []); + +
+ + +
+``` + +#### Modal Controlado (React) + +```jsx +// ❌ ERRADO - Depende de window.bootstrap +useEffect(() => { + if (window.bootstrap) { + const modal = new window.bootstrap.Modal(modalRef.current); + // Não funciona consistentemente + } +}, []); + +// ✅ CORRETO - React puro +const Modal = ({ show, onHide, title, children, footer }) => { + // Fechar com ESC + useEffect(() => { + const handleKeyDown = (e) => { + if (e.key === 'Escape' && show) onHide(); + }; + if (show) { + document.addEventListener('keydown', handleKeyDown); + document.body.style.overflow = 'hidden'; // Bloqueia scroll + } + return () => { + document.removeEventListener('keydown', handleKeyDown); + document.body.style.overflow = ''; + }; + }, [show, onHide]); + + if (!show) return null; + + return ( + <> +
+
e.target === e.currentTarget && onHide()}> +
+
+
+
{title}
+
+
{children}
+ {footer &&
{footer}
} +
+
+
+ + ); +}; +``` + +### 📝 Regra para o Futuro + +> **NUNCA usar `data-bs-toggle`, `data-bs-dismiss` ou `window.bootstrap` em React.** +> +> Sempre implementar componentes interativos com `useState`, `useRef` e `useEffect`. + +### 🔗 Alternativas + +Se precisar de muitos componentes Bootstrap interativos, considere usar: +- **React-Bootstrap** (https://react-bootstrap.github.io/) +- **Reactstrap** (https://reactstrap.github.io/) + +Estas bibliotecas reimplementam os componentes Bootstrap em React puro. + +--- + +## 2. Deploy: Arquivos no diretório errado + +**Data:** 8 de Dezembro de 2025 +**Versão afetada:** v1.3.10 + +### ❌ Problema + +Após fazer deploy do frontend, o site retornava **404** ou **403 Forbidden**. + +**Sintomas:** +- `curl https://webmoney.cnxifly.com/` retorna 404/403 +- Arquivos existem no servidor mas não são servidos +- JS/CSS não carregam + +### 🔍 Causa Raiz + +Os arquivos foram copiados para o diretório **errado**: + +``` +# Onde os arquivos foram copiados: +/var/www/webmoney/frontend/assets/ + +# Onde o Nginx esperava encontrar: +/var/www/webmoney/frontend/dist/assets/ +``` + +O Nginx estava configurado com: +```nginx +root /var/www/webmoney/frontend/dist; +``` + +### ✅ Solução + +1. **Verificar configuração do Nginx:** +```bash +cat /etc/nginx/sites-available/webmoney-subdomain +# Procurar por: root /var/www/webmoney/frontend/dist; +``` + +2. **Deploy para o caminho correto:** +```bash +# Build do frontend +cd frontend && npm run build + +# Deploy para o caminho CORRETO (inclui /dist/) +scp -r dist/* root@213.165.93.60:/var/www/webmoney/frontend/dist/ +``` + +3. **Ajustar permissões:** +```bash +chown -R www-data:www-data /var/www/webmoney/frontend/dist +``` + +### 📝 Regra para o Futuro + +> **Sempre verificar o `root` do Nginx antes de fazer deploy.** +> +> O caminho de deploy deve corresponder EXATAMENTE ao configurado no Nginx. + +### ✅ Comando de Deploy Correto + +```bash +# Frontend - Build e Deploy +cd /workspaces/webmoney/frontend +npm run build +sshpass -p 'Master9354' scp -r dist/* root@213.165.93.60:/var/www/webmoney/frontend/dist/ + +# Backend - Deploy +sshpass -p 'Master9354' rsync -avz --exclude 'vendor' --exclude '.git' \ + /workspaces/webmoney/backend/ root@213.165.93.60:/var/www/webmoney/backend/ + +# Pós-deploy no servidor +ssh root@213.165.93.60 " + cd /var/www/webmoney/backend + COMPOSER_ALLOW_SUPERUSER=1 composer install --no-dev --optimize-autoloader + cp .env.production .env + php artisan config:cache + php artisan route:cache + chown -R www-data:www-data /var/www/webmoney + systemctl restart php8.4-fpm +" +``` + +--- + +## 📊 Resumo de Problemas Frequentes + +| Problema | Sintoma | Solução Rápida | +|----------|---------|----------------| +| Dropdown não abre | Clique não faz nada | Usar `useState` + `onClick` | +| Modal não aparece | `window.bootstrap` undefined | Implementar modal React puro | +| 404 no frontend | Site não carrega | Verificar `root` do Nginx | +| 403 Forbidden | Permissão negada | `chown -R www-data:www-data` | +| API retorna 404 | Endpoint não encontrado | Verificar se `routes/api.php` foi copiado | +| Cache desatualizado | Mudanças não aparecem | `php artisan config:clear` + hard refresh | + +--- + +## 🔧 Checklist de Debug + +Quando algo não funciona: + +1. **Frontend não carrega:** + - [ ] Verificar se `npm run build` foi executado + - [ ] Verificar se arquivos estão em `/frontend/dist/` + - [ ] Verificar `root` do Nginx + - [ ] Fazer hard refresh (Ctrl+Shift+R) + +2. **Componente React não funciona:** + - [ ] Verificar se não está usando `data-bs-*` + - [ ] Verificar console do browser para erros + - [ ] Implementar com `useState`/`useEffect` + +3. **API retorna erro:** + - [ ] Verificar se `routes/api.php` existe no servidor + - [ ] Verificar se `.env` está configurado + - [ ] Executar `php artisan config:cache` + - [ ] Verificar logs: `tail -f /var/log/nginx/webmoney_*.log` + +--- + +*Documento atualizado em: 8 de Dezembro de 2025 - v1.3.11* diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..fdcf7e9 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,1650 @@ +# CHANGELOG + +Todas as mudanças relevantes neste projeto serão documentadas neste arquivo. +O formato segue [Keep a Changelog](https://keepachangelog.com/pt-BR/). +Este projeto adota [Versionamento Semântico](https://semver.org/pt-BR/). + + +## [1.27.2] - 2025-12-13 + +### Changed +- **Ícone de Tabaco** - Atualizado ícone da subcategoria "Tabaco" em MadridCategoriesSeeder + - De `bi-circle-fill` (genérico) para `bi-cloud-haze2-fill` (fumaça/névoa) + - Ícone mais semântico e representativo para gastos com cigarro/estánco + - Adicionado `bi-cloud-haze2` e `bi-cloud-haze2-fill` ao catálogo de ícones (icons.js) + +### Fixed +- **Sintaxe icons.js** - Corrigida vírgula faltante após `bi-outlet` na categoria housing + + +## [1.27.1] - 2025-12-12 + +### Fixed +- **Traducción faltante** - Agregada clave `common.months` en es.json y en.json + - Dashboard mostraba "12 common.months" en vez de "12 meses" + + +## [1.27.0] - 2025-12-12 + +### Added +- **Widget de Transações Vencidas** - Novo widget no Dashboard mostrando itens em atraso + - Endpoint: `GET /api/dashboard/overdue` - Agrupa por urgência (crítico, alto, médio, baixo) + - Inclui transações, parcelas recorrentes e passivos vencidos + - Clique para navegar diretamente ao item + - Código de cores por nível de urgência + - Traduções em ES, PT-BR e EN + +- **Modais de Confirmação Detalhados** - Melhorados todos os botões de ação na página /recurring + - Modal de Pausar/Reanudar com advertências detalhadas + - Modal de Excluir Template com avisos de irreversibilidade + - Modal de Omitir Cuota (Skip) com explicação de consequências + - Ícones visuais e informações do item afetado em todos os modais + - Traduções completas em ES, PT-BR e EN + +### Fixed +- **Conciliação Incorreta** - Desfeita conciliação acidental de "Alquiler Seguro Sa" primeira parcela + - recurring_instance #71 revertido para status 'pending' + - transaction #2372 desvinculada + + +## [1.26.0] - 2025-12-11 + +### Added +- **Detecção de Reembolsos** - Sistema para detectar pares gasto/reembolso que se anulam + - Novo endpoint: `GET /api/refund-detection` - Detectar pares potenciais + - Novo endpoint: `POST /api/refund-detection/confirm` - Confirmar par de reembolso + - Novo endpoint: `POST /api/refund-detection/confirm-batch` - Confirmar múltiplos pares + - Novo endpoint: `POST /api/refund-detection/ignore` - Ignorar par + - Novo endpoint: `POST /api/refund-detection/undo` - Desfazer confirmação + - Nova migração: campos `is_refund_pair` e `refund_linked_id` na tabela transactions + - Nova tabela: `ignored_refund_pairs` para pares ignorados + - Frontend: Nova página `/refunds` com UI similar à detecção de transferências + - Detecção baseada em: mesmo valor, mesma conta, tipos opostos (débito/crédito) + - Cálculo de confiança com indicadores traduzidos (data, descrição similar, palavras-chave) + - Tolerância configurável de 1-30 dias + - Suporte a seleção em lote com confirmação batch + - Traduções completas em ES, PT-BR e EN + +### Changed +- **Ordenação de Transações** - Lista por semana agora ordena por data efetiva + - Usa `COALESCE(effective_date, planned_date)` para ordenação + - Fallback para data planejada quando não há data efetiva + - Parâmetro `date_field: 'effective_date'` adicionado ao frontend + +### Fixed +- **Indicadores de Reembolso** - Traduções para os motivos de confiança + - `same_amount_same_account` → "Mismo monto, misma cuenta" + - `same_date` → "Misma fecha" + - `refund_keyword` → "Palabra clave de reembolso" + - `medium_description_similarity` → "Descripción similar" + - `same_status` → "Mismo estado" + + +## [1.23.8] - 2025-12-11 + +### Fixed +- **Modais de Confirmação** - Substituídos alertas nativos do navegador por modais customizados + - Modal de confirmação para excluir template + - Modal de confirmação para pular parcela + - Modal de confirmação para cancelar parcela + - Novo componente `ConfirmModal` reutilizável + + +## [1.23.7] - 2025-06-20 + +### Added +- **Edição de Transações Recorrentes** - Possibilidade de editar templates e instâncias individuais + - Novo endpoint: `PUT /api/recurring-instances/{id}` - Editar parcela pendente + - Modal de edição de template com todos os campos (nome, valor, frequência, conta, categoria) + - Modal de edição de parcela com valor planejado, data de vencimento e notas + - Botões de edição na tabela de templates e na tabela de parcelas pendentes + - Botão de edição também no modal de detalhes do template + - Traduções em português, inglês e espanhol + + +## [1.25.0] - 2025-12-10 + +### Added +- **Transações Recorrentes** - Sistema completo de gestão de pagamentos recorrentes + - Novo modelo: `RecurringTemplate` - Templates de recorrência com frequência configurável + - Novo modelo: `RecurringInstance` - Instâncias/parcelas geradas automaticamente + - Novo serviço: `RecurringService` - Lógica de geração e gestão de instâncias + - Nova política: `RecurringTemplatePolicy` - Autorização de acesso + - Endpoints: + - `GET /api/recurring` - Listar templates + - `POST /api/recurring` - Criar template manual + - `POST /api/recurring/from-transaction` - Criar a partir de transação + - `GET/PUT/DELETE /api/recurring/{id}` - CRUD de templates + - `POST /api/recurring/{id}/pause` - Pausar template + - `POST /api/recurring/{id}/resume` - Retomar template + - `GET /api/recurring/{id}/instances` - Listar parcelas + - `GET /api/recurring/pending` - Listar todas pendentes + - `GET /api/recurring/overdue` - Listar vencidas + - `GET /api/recurring/due-soon` - Próximas do vencimento + - `POST /api/recurring-instances/{id}/pay` - Pagar (cria transação) + - `POST /api/recurring-instances/{id}/reconcile` - Conciliar com transação existente + - `POST /api/recurring-instances/{id}/skip` - Pular parcela + - `GET /api/recurring-instances/{id}/candidates` - Buscar transações candidatas + - Frontend: Nova página `/recurring` com tabs Templates e Pendentes + - Modal para criar recorrência a partir de transação existente + - Frequências: diária, semanal, quinzenal, mensal, bimestral, trimestral, semestral, anual + - Ajuste automático de dias em meses curtos (ex: 31 → 28 em fevereiro) + +- **Calendário no Dashboard** - Widget interativo com visão mensal + - Novo endpoint: `GET /api/dashboard/calendar` - Dados do mês + - Novo endpoint: `GET /api/dashboard/calendar-day` - Transações de um dia + - Novo componente: `CalendarWidget.jsx` + - Mostra transações e recorrentes pendentes por dia + - Indicadores visuais (azul=transação, laranja=recorrente) + - Clique no dia mostra detalhes com ações (Pagar/Omitir) + - Resumo mensal de pendentes + +- **Widget Próximos 7 Dias** - Previsão de transações pendentes + - Novo endpoint: `GET /api/dashboard/upcoming` - Transações dos próximos N dias + - Novo componente: `UpcomingWidget.jsx` + - Agrupa por dia com badges (Hoje, Amanhã, X dias) + - Ícones coloridos por tipo (recorrente, transferência, débito, crédito) + - Resumo de ingressos/gastos previstos (exclui transferências) + +- **Menu Reorganizado com Grupos Colapsáveis** + - Grupos: Movimentos (Transações, Recurrentes, Importar, Transferências) e Configuração (Categorias, Centros de Custo) + - Estado persistente de expansão + - Suporte a sidebar colapsado + +- **Campo start_date no Modal de Recorrência** + - Permite definir data inicial diferente da transação original + +### Changed +- Script de deploy backend alterado de `php8.3-fpm` para `php8.4-fpm` +- Layout.jsx refatorado com estrutura de grupos no menu + +### Fixed +- **AuthorizesRequests** - Adicionado trait ao Controller base para resolver erro 500 em ações de autorização +- **Cálculo de ingresos/gastos no calendário** - Transferências agora são excluídas do resumo +- **Duplicação de Layout** - Removido wrapper Layout duplicado em RecurringTransactions.jsx + +### Migrations +- `2025_12_10_100001_create_recurring_templates_table.php` +- `2025_12_10_100002_create_recurring_instances_table.php` +- Adicionado campo `recurring_instance_id` na tabela `transactions` + + +## [1.12.0] - 2025-12-09 + +### Added +- **Categorização em Lote** - Nova funcionalidade para categorizar transações automaticamente + - Novo endpoint: POST `/api/categories/categorize-batch/preview` - Preview das transações que serão categorizadas + - Novo endpoint: POST `/api/categories/categorize-batch` - Executar categorização em lote + - Botão "Categorizar em Lote" na página de Categorias + - Botão "Categorizar" na página de Transações por Semana + - Modal com preview mostrando: total sem categoria, quantas serão categorizadas, keywords ativas + - Categorização baseada em keywords das categorias vs descrição das transações + - Traduções completas em pt-BR, es, en + +- **Detecção de Pagamentos Suspeitos** - Nova aba na página de Duplicatas + - Novo endpoint: GET `/api/duplicate-transactions/suspicious` - Lista pagamentos suspeitos + - Detecta transações com mesmo valor, mesma conta, datas diferentes (até 7 dias) + - Calcula % de similaridade da descrição entre transações + - Sistema de confiança com níveis (high/medium/low) baseado em múltiplos critérios + - Aba "Pagamentos Suspeitos" ao lado de "Duplicatas Exatas" + - Botões para ignorar par ou excluir transação individual + - Traduções completas em pt-BR, es, en + +- **UserSetupService** - Serviço para configuração de novos usuários + - Cria categorias padrão automaticamente no registro + - Cria centro de custo "Principal" automaticamente + - 18 categorias principais com subcategorias e keywords + +### Changed +- Categorias do usuário não são mais bloqueadas como "sistema" + - Removida validação is_system no update() e destroy() do CategoryController + - Todas as categorias do usuário podem ser editadas/excluídas + - MadridCategoriesSeeder agora cria com is_system = false + +### Fixed +- Corrigido problema de reconciliação de passivos não encontrando transações + - Adicionado COALESCE(amount, planned_amount) na busca de transações elegíveis + - Transações com amount NULL agora são encontradas usando planned_amount + + +## [1.11.0] - 2025-12-08 + +### Added +- Funcionalidade de ajuste de saldo de contas + - Novo endpoint: POST `/api/accounts/{id}/adjust-balance` + - Usuário informa o saldo real e sistema calcula o initial_balance automaticamente + - Fórmula: initial_balance = saldo_desejado - créditos + débitos + - Botão de ajuste (icone sliders) na lista de contas + - Modal com explicação e campo para digitar saldo real + - Traduções em pt-BR, es, en + + +## [1.10.1] - 2025-12-08 + +### Changed +- Recálculo automático de saldos ao carregar página de Contas (/accounts) +- Não é mais necessário clicar no botão de recalcular manualmente + + +## [1.10.0] - 2025-12-08 + +### Added +- Agrupamento de transferências entre contas no resumo semanal + - Transferências agora aparecem em seção separada "Transferências entre contas" + - Cada transferência mostra: Conta Origem → Conta Destino com valor + - Transferências não contam mais nos totais de Ingresos/Gastos + - API retorna campo `transfers` com transferências agrupadas em pares + - Campo `transfers_count` no summary indica quantidade de transferências + - Tradução adicionada: "transfers" em pt-BR, es, en + +### Fixed +- Corrigido cálculo de totais quando transações têm `amount: 0` com `status: completed` + - Agora usa `planned_amount` como fallback quando `amount` é zero + - Afeta cálculo de créditos, débitos e total de completed + - Resolve problema de semanas mostrando totais zerados incorretamente + + +## [1.9.1] - 2025-12-08 + +### Fixed +- Corrigido erro SQL "Column 'type' in SELECT is ambiguous" no DuplicateTransactionController +- Corrigido erro SQL "a.name isn't in GROUP BY" removendo JOIN e buscando account_name separadamente +- Corrigido erro React ao renderizar objeto confidence (alterado para confidence?.percentage) +- Corrigido path de deploy do frontend (era /public, correto é /var/www/webmoney/frontend/dist) + + +## [1.9.0] - 2025-12-08 + +### Changed +- Reestruturação completa de detecção de duplicatas (de pares para grupos) + - OLD: Comparava transações 2x2 (pairwise), gerando múltiplos pares para 3+ idênticos + - NEW: Agrupa todas as transações idênticas em um único grupo + - OLD: Estrutura: {transaction1, transaction2} + - NEW: Estrutura: {transactions: [{id, desc, ...}, {...}], count, max_id} + - Permite visualizar todas as transações duplicadas lado a lado + - Identifica claramente qual será mantida (a com maior ID) + +### Added +- Auto-Delete automático de duplicatas + - Novo endpoint: POST `/api/v1/duplicate-transactions/auto-delete` + - Deleta todas as duplicatas mantendo apenas a com maior ID por grupo + - Usa Soft Delete (respeiта `deleted_at`) + - Botão "Auto-Delete em Lote" - para grupos selecionados + - Botão "⚡ Auto-Delete Todos" - para todos os grupos + - Modals de confirmação com avisos de ação irreversível + - Mensagens de sucesso com contador de grupos deletados + - Traduções completas em PT-BR, EN, ES + +### Frontend Updates +- DuplicateTransactions.jsx completamente reescrito + - Suporta novo formato de resposta (grupos ao invés de pares) + - Layout adaptado: 3 colunas para transações (em vez de VS layout) + - Badge "Será mantida" destaca transação com maior ID + - Badge "Grupo: X transações" mostra tamanho do grupo + - Serviço API atualizado com método `autoDelete()` + - Traduções incluem strings para auto-delete + +### Backend Updates +- DuplicateTransactionController::index() refatorizado + - Query principal usa GROUP BY em vez de INNER JOIN + - GROUP_CONCAT(id ORDER BY id DESC) para ordenar por ID + - Retorna array de todas as transações do grupo + - Confiança sempre 100% (grupos = transações idênticas) + - Calcula max_id para indicar qual será mantida + +- Novo método: DuplicateTransactionController::autoDeleteDuplicates() + - Busca grupos com HAVING count > 1 + - Para cada grupo, deleta tudo menos o ID máximo + - UPDATE transactions SET deleted_at = NOW() + - Retorna total de transações deletadas + +### Database +- Nenhuma migração nova necessária + - Campo `deleted_at` já existe (SoftDeletes trait) + - Campo `duplicate_ignored_with` continua funcional + + +## [1.8.1] - 2025-12-08 + +### Fixed +- Seleção em lote: corrigida lógica de exclusão + - Agora exclui a transação com menor ID (ao invés de sempre transaction1) + - Mantém consistência com lógica individual de seleção + - Usa Soft Delete (SoftDeletes do Laravel) + + +## [1.8.0] - 2025-12-08 + +### Added +- Seleção em lote para transações duplicadas + - Checkbox em cada duplicata para marcar/desmarcar + - "Selecionar Todos" na barra de ações + - Contador de duplicatas selecionadas + - Destaque visual (cor azul) para itens selecionados + - Botões: "Ignorar em Lote" e "Excluir em Lote" + - Modals de confirmação para ações em lote + - Mensagens de sucesso com contador (ex: "5 duplicatas ignoradas") + - Funções executam em paralelo com Promise.all() + - Limpa seleções ao recarregar dados + - Traduções em PT-BR, ES e EN + + +## [1.7.4] - 2025-12-08 + +### Fixed +- Removidas mensagens de navegador (window.confirm, window.alert) + - Transações: Cancelar e Excluir agora usam modais de confirmação + - Duplicatas: Excluir agora usa modal de confirmação + - Modal centralizado com botões "Cancelar" e "Confirmar" + - Traduções adicionadas em PT-BR, ES e EN + - Mantém funcionalidade, apenas substituiu padrão de UI + + +## [1.7.3] - 2025-12-08 + +### Fixed +- Corrigida busca por valores em transações + - Usa comparação numérica em vez de LIKE em string + - "147" encontra 147.00 (busca exata + range até 147.99) + - "147.00" encontra apenas 147.00 (busca exata) + - Funciona em ambos os métodos: index e byWeek + + +## [1.7.2] - 2025-12-08 + +### Added +- Nível de confiança na detecção de duplicatas + - Badge colorido com percentual (vermelho: alto, amarelo: médio, azul: baixo) + - Critérios de cálculo: + - Base 50%: mesmo valor, data, conta e tipo + - +15%: mesma categoria + - +10%: mesmo status + - +25%: descrição idêntica ou similar + - Resultados ordenados por nível de confiança (maior primeiro) + - Traduções em PT-BR, ES e EN + + +## [1.7.1] - 2025-12-08 + +### Improved +- Filtro de busca em transações agora também busca por valores + - Busca por planned_amount e amount + - Suporta busca exata e parcial (ex: "150" encontra 150.00, 1500.00) + - Aceita formato com vírgula ou ponto como separador decimal + - Placeholder atualizado para indicar busca por valor + + +## [1.7.0] - 2025-12-08 + +### Added +- Nova funcionalidade: Detecção de Transações Duplicadas + - Página dedicada para visualizar e gerenciar duplicatas potenciais + - Critérios de detecção: mesmo valor, mesma data, mesma conta, mesmo tipo + - Opção de excluir transação duplicada + - Opção de ignorar permanentemente (marcar como não-duplicata) + - Campo `duplicate_ignored_with` na tabela transactions + - Endpoint API: GET /duplicate-transactions + - Endpoint API: DELETE /duplicate-transactions/{id} + - Endpoint API: POST /duplicate-transactions/ignore + - Traduções em PT-BR, ES e EN + - Menu lateral com link para página de duplicatas + + +## [1.6.13] - 2025-12-08 + +### Changed +- Tamanho de fonte 11pt aplicado a modais, toasts e outros componentes: + - Modal: .modal-title, .modal-body, .modal-header, .modal-footer + - Toast: .toast, .toast-body, .toast-header + - Popover/Tooltip: .popover-body, .popover-header, .tooltip-inner + - Outros: .alert, .card-body, .card-text, .card-title + - Listas/Acordion: .list-group-item, .accordion-body, .accordion-button + + +## [1.6.12] - 2025-12-08 + +### Changed +- Tamanho mínimo de fonte ajustado para 11pt (era 12pt) +- Adicionados elementos de formulário à regra de tamanho mínimo: + - input, select, textarea, .form-control, .form-select + - option, .input-group-text +- Garantia de tamanho consistente em todos os campos de formulário + + +## [1.6.11] - 2025-12-08 + +### Changed +- Tamanho mínimo de fonte padronizado para 12pt em todo o sistema + - Body, parágrafos, spans, células, labels, botões, listas + - Textos small/muted também agora usam 12pt mínimo + - Melhor legibilidade em todos os dispositivos + + +## [1.6.10] - 2025-12-08 + +### Fixed +- Corrigida tradução do cabeçalho "Estado" no dropdown de ações + - Alterado de `t('transactions.status')` para `t('transactions.status.label')` + - Erro: "key returned an object instead of string" + + +## [1.6.9] - 2025-12-08 + +### Changed +- Reduzida largura da coluna de ações de 150px para 50px +- Botão de ações mais compacto (link style sem bordas) +- Ícone alterado para três pontos verticais +- Melhor aproveitamento do espaço horizontal na tabela + + +## [1.6.8] - 2025-12-08 + +### Changed +- Aumentado tamanho das fontes no sistema para melhor legibilidade + - Tamanho base: 14px (mínimo 10pt) + - Textos pequenos (small): 12px + - Cabeçalhos ajustados proporcionalmente + - Aplicado globalmente via index.css + + +## [1.6.7] - 2025-12-08 + +### Improved +- Melhorado visual do menu dropdown de ações nas transações + - Botão com borda (btn-outline-secondary) mais visível + - Ícones coloridos para cada ação (verde para completar, amarelo para reverter, etc.) + - Cabeçalhos de seção para agrupar ações de status + - Sombra no menu (shadow-sm) para melhor destaque + - Organização lógica: Status > Ações principais > Excluir + + +## [1.6.6] - 2025-12-08 + +### Changed +- Substituído botões de ação por menu dropdown elegante na página de Transações + - Ícone de três pontos (⋮) para abrir menu de ações + - Menu organizado com ícones e texto descritivo + - Botão excluir separado por linha divisória + - Aplicado em TransactionsByWeek.jsx e Transactions.jsx + +### Improved +- Interface mais limpa e profissional na listagem de transações +- Melhor uso do espaço horizontal na tabela + + +## [1.6.5] - 2025-12-08 + +### Improved +- Ampliada janela de busca de transações elegíveis de ±15 para ±45 dias + - Permite encontrar pagamentos atrasados ou antecipados +- Conciliação agora busca transações de todas as contas correntes +- Corrigida exibição de data nas transações elegíveis (effective_date/planned_date) + +### Removed +- Removido filtro de conta corrente desnecessário no modal de conciliação + + +## [1.6.4] - 2025-12-08 + +### Improved +- Melhorado filtro de transações elegíveis para conciliação + - Agora filtra por valores próximos (±20% do valor da parcela) + - Ordena por similaridade de valor (mais próximo primeiro) + - Retorna diferença percentual para cada transação + - Limita resultados a 30 transações mais relevantes + - Retorna faixa de valor utilizada no filtro + + +## [1.6.3] - 2025-12-08 + +### Added +- Novo endpoint `POST /api/accounts/recalculate-all` para recalcular saldos de todas as contas +- Novo endpoint `POST /api/accounts/{id}/recalculate` para recalcular saldo de uma conta específica +- Método `recalculateBalance()` no model Account +- Botão "Recalcular" na página de Contas para atualizar saldos baseados nas transações +- Traduções para recálculo de saldos (pt-BR, en, es) + +### Fixed +- Correção nos saldos das contas (current_balance) que não atualizavam com transações importadas + + +## [1.6.2] - 2025-12-08 + +### Fixed +- Corrigido erro no endpoint `eligibleTransactions` - coluna `date` não existe +- Atualizado para usar `effective_date` ou `planned_date` (com fallback) +- Corrigido tipo de transação de `expense` para `debit` + + +## [1.6.1] - 2025-12-08 + +### Added - Sistema de Conciliação de Parcelas + +#### Backend (Laravel) + +**Conciliação de Parcelas com Transações** +- ✅ Novo endpoint: `GET /api/liability-accounts/pending-reconciliation` + - Lista todas as parcelas pendentes de conciliação +- ✅ Novo endpoint: `GET /api/liability-accounts/{id}/installments/{id}/eligible-transactions` + - Busca transações elegíveis para vincular a uma parcela + - Filtra por período (+/- 15 dias do vencimento) + - Suporta busca por descrição +- ✅ Novo endpoint: `POST /api/liability-accounts/{id}/installments/{id}/reconcile` + - Vincula parcela a uma transação existente + - Opção de marcar automaticamente como paga +- ✅ Novo endpoint: `DELETE /api/liability-accounts/{id}/installments/{id}/reconcile` + - Remove vínculo entre parcela e transação + +#### Frontend (React) + +**Modal de Conciliação** +- ✅ Novo modal para vincular parcela a transação +- ✅ Lista de transações elegíveis com busca +- ✅ Checkbox para marcar como paga ao conciliar +- ✅ Botões de ação na tabela de parcelas (conciliar/desconciliar) +- ✅ Badge indicando status de conciliação +- ✅ Traduções em pt-BR, en e es + +### Fixed +- ✅ Ordem das rotas no api.php (rotas específicas antes do apiResource) + + +## [1.6.0] - 2025-12-08 + +### Added - Transferências, Divisão, Passivos e Sistema de Importação + +#### Backend (Laravel) + +**Sistema de Transferências entre Contas** +- ✅ Novo endpoint: `POST /api/transactions/transfer` +- ✅ Cria automaticamente par de transações (débito origem + crédito destino) +- ✅ Campos `transfer_pair_id` e `is_transfer` na tabela transactions + +**Efetivação Rápida de Transações** +- ✅ Novo endpoint: `POST /api/transactions/{id}/quick-complete` +- ✅ Efetiva transação pendente com um clique (usa valores planejados) + +**Divisão de Transações** +- ✅ Novo endpoint: `POST /api/transactions/{id}/split` +- ✅ Divide transação em múltiplas categorias +- ✅ Campos `is_split_parent`, `is_split_child`, `parent_transaction_id` +- ✅ Validação: soma das divisões deve igualar valor original + +**Sistema de Passivos (Empréstimos/Financiamentos)** +- ✅ Nova migration: `create_liability_accounts_table` + - Contrato de passivo com valores calculados + - Taxas de juros (mensal, anual, total) + - Progresso de pagamento +- ✅ Nova migration: `create_liability_installments_table` + - Parcelas individuais com composição (capital + juros + taxas) + - Status: pending, paid, partial, overdue, cancelled +- ✅ Novo model: LiabilityAccount e LiabilityInstallment +- ✅ Novo controller: LiabilityAccountController +- ✅ Importação de contratos via Excel +- ✅ Cálculo automático de taxas de juros + +**Sistema de Importação Avançado** +- ✅ Nova migration: `create_import_mappings_table` +- ✅ Nova migration: `add_import_hash_to_transactions_table` +- ✅ Suporte a múltiplos formatos: XLSX, XLS, CSV, OFX, PDF +- ✅ Mapeamento de colunas flexível com concatenação +- ✅ Anti-duplicidade via hash único (data + valor + saldo + descrição) +- ✅ Novos parsers: OfxParser, PdfParser +- ✅ Histórico de importações + +**Correções e Melhorias** +- ✅ Removido campo `balance_after` (saldo sempre calculado dinamicamente) +- ✅ Transações importadas sempre sem categoria (category_id = null) + +#### Frontend (React) + +**Página de Transações por Semana (TransactionsByWeek.jsx)** +- ✅ Botão "Transferência" na barra superior +- ✅ Modal de transferência entre contas +- ✅ Botão de efetivação rápida (⚡) para pendentes +- ✅ Botão de divisão para transações +- ✅ Modal de divisão com múltiplas categorias +- ✅ Validação em tempo real (soma = valor original) + +**Nova Página: Importação de Transações (ImportTransactions.jsx)** +- ✅ Upload com drag & drop +- ✅ Wizard de 4 etapas: Upload → Configurar → Mapear → Resultado +- ✅ Preview dos dados do arquivo +- ✅ Mapeamento visual de colunas +- ✅ Salvamento de mapeamentos para reutilização +- ✅ Histórico de importações + +**Nova Página: Contas Passivo (LiabilityAccounts.jsx)** +- ✅ Cards de resumo por moeda +- ✅ Importação de contratos via Excel +- ✅ Visualização de parcelas com status +- ✅ Barra de progresso de pagamento +- ✅ Taxas de juros calculadas + +**Traduções** +- ✅ Novas chaves em pt-BR, en, es para todas as funcionalidades + +### Technical Details +- Migration: 2025_12_08_170001_add_transfer_and_split_fields_to_transactions +- Migration: 2025_12_08_200001_create_liability_accounts_table +- Migration: 2025_12_08_200002_create_liability_installments_table +- Migration: 2025_12_09_100001_create_import_mappings_table +- Migration: 2025_12_09_150001_add_import_hash_to_transactions_table +- Migration: 2025_12_09_160001_remove_balance_after_from_transactions_table + +--- + +## [1.5.0] - 2025-12-08 + +### Added - Sistema de Transações Completo + +#### Backend (Laravel) +- ✅ **Nova migration: create_transactions_table** + - `id`, `user_id`, `account_id`, `category_id`, `cost_center_id` + - `amount` (valor efetivo), `planned_amount` (valor previsto) + - `type` (credit/debit) + - `description`, `notes`, `reference` + - `effective_date` (data efetiva), `planned_date` (data planejada) + - `status` (pending/completed/cancelled) + - `is_recurring`, `recurring_parent_id` (preparado para recorrências) + - SoftDeletes e índices otimizados + +- ✅ **Novo model: Transaction.php** + - Relacionamentos: user, account, category, costCenter, recurringParent/Children + - Scopes: ofUser, pending, completed, cancelled, credits, debits, inPeriod, etc. + - Atributos computados: finalAmount, finalDate, isOverdue, signedAmount + - Métodos: markAsCompleted(), markAsCancelled(), markAsPending() + +- ✅ **Novo controller: TransactionController.php** + - CRUD completo com filtros avançados + - Endpoints especiais: complete, cancel, revert, duplicate, summary + - Filtros por: conta, categoria, centro de custo, tipo, status, período, busca + +- ✅ **Novas rotas API** + - `GET/POST /api/transactions` - Listar/Criar + - `GET/PUT/DELETE /api/transactions/{id}` - CRUD individual + - `POST /api/transactions/{id}/complete` - Marcar como concluída + - `POST /api/transactions/{id}/cancel` - Cancelar + - `POST /api/transactions/{id}/revert` - Reverter para pendente + - `POST /api/transactions/{id}/duplicate` - Duplicar + - `GET /api/transactions-summary` - Resumo com totais + +#### Frontend (React) +- ✅ **Nova página: Transactions.jsx** + - Cards de resumo: Entradas, Saídas, Saldo, Pendentes + - Indicador de transações atrasadas + - Filtros avançados: conta, categoria, tipo, status, período, busca + - Tabela com todas as transações + - Badge de categoria com cor e ícone + - Ações: editar, duplicar, completar, reverter, cancelar, excluir + - Modal de criação/edição com todos os campos + - Modal de detalhes completo + - Modal de conclusão com valor efetivo e data + +- ✅ **Novo service: transactionService** + - Métodos: getAll, getById, create, update, delete + - complete, cancel, revert, duplicate, getSummary + +- ✅ **Traduções completas** (pt-BR, es, en) + - Todos os labels, mensagens e placeholders + - Tipos: Crédito/Débito + - Status: Pendente/Concluída/Cancelada + +- ✅ **Menu lateral atualizado** + - Novo item "Transações" com ícone bi-arrow-left-right + - Rota /transactions configurada + + +## [1.4.2] - 2025-12-08 + +### Added - Seeder de Categorias para Madrid + +#### Backend (Laravel) +- ✅ **MadridCategoriesSeeder.php** - Seeder com categorias comuns do dia a dia + - **22 categorias principais** organizadas por tipo (expense/income/both) + - **150 subcategorias** detalhadas para classificação precisa + - **825 palavras-chave** em espanhol para categorização automática + +- ✅ **Categorias de Despesa (expense)**: + - 🏠 Moradia (12 subcategorias): aluguel, hipoteca, água, luz, gás, internet... + - 🍽️ Alimentação (8): supermercados Madrid (Mercadona, Carrefour, Lidl...) + - 🍴 Restaurantes e Bares (9): fast food, tapas, comida asiática, kebab... + - 🚗 Transporte (14): metro/EMT, taxi/Cabify, AVE, BiciMAD, carsharing... + - 🛒 Compras (9): roupas (Zara, Mango, Primark), eletrônicos, Amazon... + - 💊 Saúde (9): farmácias, seguros (Sanitas, Adeslas), dentista, ótica... + - 💇 Beleza (7): peluquería, spa, cosméticos (Sephora, Primor)... + - 🎓 Educação (6): universidades Madrid, cursos, idiomas... + - 🎮 Entretenimento (9): streaming, cinema (Yelmo, Cinesa), teatro Gran Vía... + - 🏋️ Fitness (7): gimnasios (Basic-Fit, McFit), padel, running... + - ✈️ Viagens (6): hotéis, Booking, Airbnb, tours... + - 🐕 Pets (5): Tiendanimal, Kiwoko, veterinário... + - 🏦 Financeiro (6): taxas bancárias, impostos, Hacienda... + - 👶 Família (5): guardería, extraescolares, brinquedos... + - 📱 Assinaturas (6): cloud, apps, jornais digitais... + - 🎁 Doações (3): ONGs, crowdfunding, igreja... + - 📦 Outros (6): correios, loteria, lavanderia... + +- ✅ **Categorias de Renda (income)**: + - 💰 Salário e Trabalho (6): nómina, bonus, freelance... + - 📈 Investimentos (5): dividendos, cripto, fundos... + - 🏠 Rendas/Aluguéis (3): aluguel recebido, Airbnb... + - 🎁 Outros Rendimentos (5): reembolsos, Wallapop, ajudas governo... + +- ✅ **Categorias Mistas (both)**: + - 🔄 Transferências (4): entre contas, Bizum, PayPal, Wise/Revolut + + +## [1.4.1] - 2025-12-08 + +### Added - Contas Passivo na Página de Contas + +#### Frontend (React) +- ✅ **Integração de contas passivo na página /accounts** + - Seção dedicada "Contas Passivo (Empréstimos e Dívidas)" + - Cards de resumo combinam totais de contas ativas + passivo + - Linhas clicáveis navegam para página /liabilities + - Barra de progresso visual (parcelas pagas vs total) + - Exibe: credor, saldo devedor, status + + +## [1.4.0] - 2025-12-08 + +### Added - Sistema de Contas Passivo (Empréstimos/Financiamentos) + +#### Backend (Laravel) +- ✅ **Novas migrations** + - `liability_accounts` - Tabela para contratos de passivo com todos os metadados + - `liability_installments` - Tabela para parcelas individuais de cada contrato + +- ✅ **Novos models** + - `LiabilityAccount` - Model com relacionamentos, cálculos de taxas de juros e progresso + - `LiabilityInstallment` - Model com status de parcela e método markAsPaid() + +- ✅ **Novo controller (LiabilityAccountController)** + - CRUD completo para contas passivo + - Importação de arquivos Excel (.xlsx/.xls) + - Parser inteligente de colunas (Pago, Fecha, Cuota, Intereses, Capital, Estado) + - Cálculo automático de taxas extras (multas/atrasos) + - Cálculo de taxas de juros (mensal, anual, total) + - Resumo por moeda + +- ✅ **Novas rotas API** + - `GET/POST /api/liability-accounts` - Listar/Criar contratos + - `GET/PUT/DELETE /api/liability-accounts/{id}` - Ver/Atualizar/Excluir contrato + - `POST /api/liability-accounts/import` - Importar Excel + - `GET /api/liability-accounts/{id}/installments` - Listar parcelas + - `PUT /api/liability-accounts/{accountId}/installments/{installmentId}` - Atualizar parcela + - `GET /api/liability-summary` - Resumo geral + +- ✅ **Dependência adicionada** + - `phpoffice/phpspreadsheet ^5.3` para leitura de Excel + +#### Frontend (React) +- ✅ **Nova página LiabilityAccounts.jsx** + - Cards de resumo por moeda (Dívida Total, Total Pago, Total Pendente, Juros Totais) + - Lista de contratos em formato de cards com: + - Barra de progresso de pagamento + - Taxa de juros mensal + - Parcelas pagas/total + - Saldo devedor + - Modal de importação de Excel com campos para: + - Nome do contrato + - Credor/Banco + - Número do contrato + - Moeda + - Descrição + - Modal de detalhes com: + - Cards de valores (Principal, Juros, Taxas, Total) + - Cards de taxas (Mensal, Anual, Total do contrato) + - Tabela de parcelas com status colorido + - Botão para marcar parcela como paga + - Filtros por status e ativo/inativo + - Confirmação de exclusão + +- ✅ **Serviço API (api.js)** + - `liabilityAccountService` com todos os métodos CRUD e importação + +- ✅ **Navegação** + - Item "Passivos" adicionado ao menu lateral + - Rota `/liabilities` + +- ✅ **Internacionalização (i18n)** + - Traduções completas em pt-BR, es e en + - Seção `liabilities` com todas as strings + +### Features de Contas Passivo +- **Importação de Excel**: Suporte completo ao formato do arquivo ContratoWanna.xlsx +- **Cálculo automático de juros**: Taxa mensal, anual e total do contrato +- **Detecção de taxas extras**: Identifica automaticamente multas e atrasos +- **Progresso visual**: Barra de progresso mostra quanto do principal foi amortizado +- **Status de parcelas**: Pendente, Pago, Parcial, Atrasado, Cancelado +- **Preparado para conciliação**: Campos para vincular transações futuras + +--- + +## [1.3.14] - 2025-12-08 + +### Changed - Saldo Total por Moeda + +#### Frontend (Accounts.jsx) +- ✅ **Saldo total agora exibido separadamente por moeda** + - Função `getTotalsByCurrency()` agrupa saldos por currency + - Cada moeda (BRL, EUR, USD, etc.) exibida com seu próprio total + - Layout responsivo com cards lado a lado + - Verde para saldo positivo, vermelho para negativo + +--- + +## [1.3.13] - 2025-12-08 + +### Fixed - Tradução i18n faltando + +#### Frontend (i18n) +- ✅ **Adicionada chave de tradução `accounts.descriptionPlaceholder`** + - pt-BR: "Observações sobre esta conta (opcional)" + - es: "Observaciones sobre esta cuenta (opcional)" + - en: "Notes about this account (optional)" + +--- + +## [1.3.12] - 2025-12-08 + +### Changed - Layout Desktop Compacto (25% menor) + +#### Frontend (CSS) +- ✅ **Reduzido tamanho geral dos elementos em 25% para desktop** + - Font size base: 16px → 13px (em telas ≥992px) + - Headings reduzidos proporcionalmente (h1: 2.5rem → 1.875rem, etc.) + - Buttons mais compactos (padding reduzido) + - Forms: inputs e labels menores + - Tables: células mais compactas + - Spacing utilities (.p-3, .p-4, .mb-3, etc.) reduzidos + - Modais: tamanhos máximos reduzidos + - Badges e alerts mais compactos + +#### Frontend (Layout.jsx) +- ✅ **Sidebar mais compacta** + - Largura: 250px → 200px + - Colapsada: 70px → 55px + - Fonte do menu reduzida + - Avatar do usuário menor (35px → 28px) + +--- + +## [1.3.11] - 2025-12-08 + +### Fixed - Modal de Confirmação (Excluir Conta) + +#### Frontend (React) +- ✅ **Refatorado componente Modal.jsx para funcionar sem Bootstrap JS** + - Bootstrap 5 JS API não funciona bem com React (problemas de inicialização) + - Implementado modal 100% controlado via React (show/hide via props) + - Adicionado suporte a ESC para fechar (se keyboard=true) + - Adicionado bloqueio de scroll do body quando modal está aberto + - Suporte a backdrop "static" (não fecha ao clicar fora) + - **Botão de excluir conta agora funciona corretamente** + +--- + +## [1.3.10] - 2025-12-08 + +### Fixed - Posicionamento do Dropdown em CostCenters + +#### Frontend (React) +- ✅ **Adicionado posicionamento absoluto ao dropdown** + - `position: relative` no container do dropdown + - `position: absolute`, `right: 0`, `top: 100%`, `zIndex: 1000` no menu + - Dropdown agora aparece corretamente abaixo do botão + +--- + +## [1.3.9] - 2025-12-08 + +### Fixed - Dropdown de Ações em CostCenters + +#### Frontend (React) +- ✅ **Corrigido dropdown de ações nos cards de centros de custo** + - Bootstrap 5 com React não funciona bem com `data-bs-toggle="dropdown"` + - Implementado dropdown controlado via estado React (useState) + - Adicionado `useRef` para detectar cliques fora e fechar dropdown + - Dropdown agora abre/fecha corretamente ao clicar nos três pontinhos + +--- + +## [1.3.8] - 2025-12-08 + +### Fixed - Import Bootstrap JS (não funcionou) + +#### Frontend (React) +- Tentativa de correção adicionando `bootstrap.bundle.min.js` no main.jsx +- **Nota:** Não resolveu o problema - Bootstrap 5 precisa de inicialização manual com React + +--- + +## [1.3.7] - 2025-12-08 + +### Added - Campo is_admin para Usuários + +#### Backend (Laravel) +- ✅ **Campo `is_admin` na tabela users** + - Migration para adicionar coluna `is_admin` (boolean, default false) + - Atualizado modelo User com novo campo, cast e método `isAdmin()` + - Marco (id=2) também definido como admin + +--- + +## [1.3.6] - 2025-12-08 + +### Added - Centro de Custo do Sistema + +#### Backend (Laravel) +- ✅ **Campo `is_system` na tabela cost_centers** + - Migration para adicionar coluna `is_system` (boolean, default false) + - Atualizado modelo CostCenter com novo campo e scope `system()` + +- ✅ **Auto-criação do centro de custo "Geral"** + - Criado automaticamente quando usuário não tem nenhum centro de custo + - Nome: "Geral", Código: "GERAL" + - Para agrupar transações sem centro de custo definido + +- ✅ **Proteção contra edição/exclusão** + - Centros de custo com `is_system=true` não podem ser editados + - Centros de custo com `is_system=true` não podem ser excluídos + - Retorna erro 403 com mensagem apropriada + +#### Frontend (React) +- ✅ **Indicação visual do centro de custo do sistema** + - Ícone de cadeado (🔒) ao lado do nome + - Dropdown de ações oculto para centros de custo do sistema + +- ✅ **Traduções adicionadas (i18n)** + - `costCenters.systemCostCenter` em pt-BR, en, es + +--- + +## [1.3.5] - 2025-12-08 + +### Fixed - Sistema de Traduções i18n + +#### Frontend (React) +- ✅ **Revisão completa do sistema de traduções** + - Accounts.jsx: Todos os textos hardcoded substituídos por `t('...')` + - Categories.jsx: Todos os textos hardcoded substituídos por `t('...')` + - CostCenters.jsx: Todos os textos hardcoded substituídos por `t('...')` + - Dashboard.jsx: Todos os textos hardcoded substituídos por `t('...')` + +- ✅ **Elementos traduzidos:** + - Headers, botões, labels de formulários + - Mensagens de erro e sucesso (toast) + - Placeholders, tooltips, badges de status + - Modais de confirmação de exclusão + - Contadores e filtros + +- ✅ **Arquivos de tradução verificados:** + - pt-BR.json: Completo + - en.json: Completo + - es.json: Completo + +--- + +## [1.3.4] - 2025-12-08 + +### Fixed - Fundo Branco em Tabelas + +#### Frontend (React) +- ✅ **Correção definitiva do fundo branco em tabelas** + - Adicionado `backgroundColor: 'transparent'` inline em todos os elementos de tabela + - ``, ``, ``, ``, `
`, `` todos com estilo inline + - CSS variables do Bootstrap redefinidas com `--bs-table-bg: 'transparent'` + - Corrigido em Accounts.jsx e Dashboard.jsx + +--- + +## [1.3.3] - 2025-12-08 + +### Changed - IconSelector Modal + +#### Frontend (React) +- ✅ **IconSelector refatorado** - Mudança de dropdown para modal + - Modal grande com grid visual de ícones + - Experiência de seleção muito melhorada + - Campo de busca com ícone + - Filtros por categoria em botões + - Ícone selecionado destacado com borda azul + - Fechamento ao clicar fora do modal + - Footer com contagem de ícones e botão cancelar + +- ✅ **Correção de tabelas com tema dark** + - Hover das linhas com fundo azul translúcido + - Variáveis CSS do Bootstrap redefinidas + +--- + +## [1.3.2] - 2025-12-08 + +### Added - Seletor Visual de Ícones + +#### Frontend (React) +- ✅ **IconSelector** - Componente visual para seleção de ícones + - Grid visual com mais de 200 ícones organizados por categoria + - Busca por nome de ícone + - Filtro por categoria (money, bank, card, food, shopping, etc.) + - Suporte para 3 tipos: account, category, costCenter + - Visual consistente com tema dark + +- ✅ **Biblioteca de Ícones Expandida** (`icons.js`) + - **Contas**: 50+ ícones (wallets, banks, cards, currencies, investments) + - **Categorias**: 150+ ícones (food, shopping, housing, transport, health, education, leisure, travel, work, utilities, etc.) + - **Centros de Custo**: 40+ ícones (buildings, projects, departments) + - Organização por subcategorias para facilitar busca + +- ✅ **Correções de Tema Dark** + - Correção de fundos brancos em autocomplete de formulários + - Correção de fundos em checkboxes e radio buttons + - Correção de color picker com tema dark + - Correção de modals, dropdowns, alerts, badges + +#### Arquivos Criados +``` +frontend/src/config/icons.js # Biblioteca de 200+ ícones +frontend/src/components/IconSelector.jsx # Componente visual de seleção +``` + +#### Arquivos Modificados +``` +frontend/src/pages/Accounts.jsx # Integração IconSelector + i18n +frontend/src/pages/Categories.jsx # Integração IconSelector + i18n +frontend/src/pages/CostCenters.jsx # Integração IconSelector + i18n +frontend/src/index.css # Correções tema dark +``` + +--- + +## [1.3.1] - 2025-12-08 + +### Added - Suporte Multi-idioma e Multi-divisa + +#### Frontend (React) +- ✅ **Internacionalização (i18n)** com react-i18next + - 3 idiomas: Español (es), English (en), Português (pt-BR) + - Arquivos de tradução completos em `src/i18n/locales/` + - Detecção automática do idioma do navegador + - Persistência da preferência no localStorage + +- ✅ **Sistema Multi-divisa** + - 15 moedas suportadas: BRL, USD, EUR, GBP, JPY, CNY, ARS, CLP, COP, MXN, PEN, UYU, CHF, CAD, AUD + - Formatação automática por locale + - Componente `CurrencySelector` para seleção + - Componente `CurrencyDisplay` para exibição formatada + +- ✅ **Novos Componentes** + - `LanguageSelector.jsx` - Seletor de idioma com bandeiras + - `CurrencySelector.jsx` - Seletor de moeda + - `useFormatters.js` - Hook para formatação de valores e datas + +#### Arquivos Criados +``` +frontend/src/i18n/ +├── index.js # Configuração i18next +└── locales/ + ├── es.json # Tradução Español (padrão) + ├── en.json # Tradução English + └── pt-BR.json # Tradução Português + +frontend/src/config/ +└── currencies.js # Lista de moedas e formatadores + +frontend/src/hooks/ +├── index.js # Exportações +└── useFormatters.js # Hook de formatação + +frontend/src/components/ +├── LanguageSelector.jsx # Seletor de idioma +└── CurrencySelector.jsx # Seletor de moeda +``` + +### Changed +- `main.jsx` - Adicionado Suspense e import do i18n +- `Layout.jsx` - Adicionado seletor de idioma no header, traduções nos menus +- `Dashboard.jsx` - Atualizado com traduções e formatação de moeda + +### Dependencies Added +- `i18next: ^24.x` +- `react-i18next: ^15.x` +- `i18next-browser-languagedetector: ^8.x` + +### Deployed +- ✅ Frontend atualizado em https://webmoney.cnxifly.com + +--- + +## [1.3.0] - 2025-12-08 + +### Added - Fase 1: Gerenciadores Base + +#### Backend (Laravel) +- ✅ **Gerenciador de Contas** - API completa CRUD + - Tipos: Dinheiro, Conta Corrente, Poupança, Cartão de Crédito, Ativos, Passivos + - Campos: nome, tipo, banco, número da conta, saldo inicial/atual, cor, ícone + - Modelo `Account` com relacionamento ao usuário + - Migration `create_accounts_table` + +- ✅ **Gerenciador de Centros de Custo** - API completa CRUD + - Campos: nome, código, descrição, cor, orçamento + - Sistema de palavras-chave para matching automático em lote + - Modelos `CostCenter` e `CostCenterKeyword` + - Migration `create_cost_centers_table` (com tabela de keywords) + - Endpoints: `/api/cost-centers/{id}/keywords` para gerenciar keywords + +- ✅ **Gerenciador de Categorias** - API completa CRUD + - Tipos: Receita, Despesa, Ambos + - Suporte a hierarquia (categoria pai e subcategorias) + - Sistema de palavras-chave para matching automático em lote + - Modelos `Category` e `CategoryKeyword` + - Migration `create_categories_table` (com tabela de keywords) + - Endpoints: `/api/categories/{id}/keywords` para gerenciar keywords + +#### Frontend (React) +- ✅ **Layout com Sidebar** - Navegação lateral moderna + - Componente `Layout.jsx` reutilizável + - Menu colapsável responsivo + - Ícones Bootstrap Icons + - Tema escuro integrado + +- ✅ **Componentes Reutilizáveis** + - `Modal.jsx` - Modal Bootstrap para formulários (sem uso de alert()) + - `Toast.jsx` - Sistema de notificações com ToastProvider + +- ✅ **Página de Contas** (`Accounts.jsx`) + - Listagem com cards coloridos + - Formulário completo com todos os campos + - CRUD completo via modal + - Formatação de valores em BRL + +- ✅ **Página de Centros de Custo** (`CostCenters.jsx`) + - Listagem com badges de keywords + - Formulário com gerenciamento de keywords + - CRUD completo via modal + +- ✅ **Página de Categorias** (`Categories.jsx`) + - Listagem hierárquica (pai > subcategoria) + - Formulário com seleção de categoria pai + - Gerenciamento de keywords por categoria + - CRUD completo via modal + +- ✅ **Dashboard Atualizado** (`Dashboard.jsx`) + - Cards de resumo (Contas, Categorias, Centros de Custo) + - Links de acesso rápido + - Design moderno com tema escuro + +#### Novos Arquivos Criados +``` +backend/ +├── database/migrations/ +│ ├── 2025_12_08_000001_create_accounts_table.php +│ ├── 2025_12_08_000002_create_cost_centers_table.php +│ └── 2025_12_08_000003_create_categories_table.php +├── app/Models/ +│ ├── Account.php +│ ├── CostCenter.php +│ ├── CostCenterKeyword.php +│ ├── Category.php +│ └── CategoryKeyword.php +└── app/Http/Controllers/Api/ + ├── AccountController.php + ├── CostCenterController.php + └── CategoryController.php + +frontend/src/ +├── components/ +│ ├── Layout.jsx +│ ├── Modal.jsx +│ └── Toast.jsx +└── pages/ + ├── Accounts.jsx + ├── CostCenters.jsx + └── Categories.jsx +``` + +### Changed +- `routes/api.php` - Adicionadas rotas para accounts, cost-centers, categories +- `services/api.js` - Adicionados accountService, costCenterService, categoryService +- `App.jsx` - Novas rotas e ToastProvider +- `Dashboard.jsx` - Reescrito com cards de resumo +- `index.css` - Estilos para sidebar, cards, formulários + +### Database +- 6 novas tabelas criadas: + - `accounts` - Contas financeiras + - `cost_centers` - Centros de custo + - `cost_center_keywords` - Palavras-chave de centros de custo + - `categories` - Categorias (com self-reference para hierarquia) + - `category_keywords` - Palavras-chave de categorias + +### Deployed +- ✅ Backend atualizado em https://webmoney.cnxifly.com/api +- ✅ Frontend atualizado em https://webmoney.cnxifly.com +- ✅ Todas as APIs testadas e funcionando + +--- + +## [1.2.2] - 2025-12-07 + +### Added +- ✅ Página "Em Construção" para cnxifly.com +- ✅ Design moderno com tema escuro azul profissional +- ✅ Links para WEBMoney App e contato +- ✅ Animações suaves (fadeIn, pulse) +- ✅ Layout responsivo para mobile + +### Changed +- Nginx: cnxifly.com agora serve página estática em `/var/www/cnxifly` +- Separação clara: cnxifly.com (institucional) vs webmoney.cnxifly.com (app) + +### Deployed +- ✅ https://cnxifly.com - Página em construção +- ✅ https://www.cnxifly.com - Redirecionamento para cnxifly.com + + +## [1.2.1] - 2025-12-07 + +### Fixed +- ✅ Removido fundo branco (`bg-light`) do Dashboard - agora usa tema escuro +- ✅ Adicionada classe `.dashboard-dark` com gradiente escuro azul +- ✅ Corrigido navbar e footer para usar cores escuras (#1e293b) +- ✅ Tabelas agora têm texto claro (#e2e8f0) em fundo escuro +- ✅ Override de `.bg-light` do Bootstrap para garantir tema escuro +- ✅ Atualizada versão no footer para v1.2.0 + +### Changed +- Dashboard.jsx: Substituído `bg-light` por `dashboard-dark` +- index.css: Adicionados novos estilos para tema escuro completo + +### Deployed +- ✅ Frontend atualizado em https://webmoney.cnxifly.com + + +## [1.2.0] - 2025-12-07 + +### Added +- ✅ Deploy completo do backend Laravel e frontend React para produção +- ✅ Documento `CREDENCIAIS_SERVIDOR.md` com todas as credenciais organizadas +- ✅ Suporte IPv6 em todos os vhosts Nginx (listen [::]:443 ssl) +- ✅ Redis 7.0.15 instalado e configurado +- ✅ PM2 6.0.14 para gerenciamento de processos Node.js + +### Changed +- Atualizado ROTEIRO_INSTALACAO_SERVIDOR.md com versões reais instaladas +- Nginx atualizado para 1.29.3 mainline (era 1.27.x no roteiro) +- PHP atualizado para 8.4.15 +- Composer atualizado para 2.9.2 +- Node.js 22.21.0 LTS + +### Fixed +- ✅ webmail.cnxifly.com agora serve Roundcube corretamente (antes mostrava phpMyAdmin) +- ✅ Corrigido problema de IPv6 nos vhosts que causava resposta incorreta +- ✅ mail.cnxifly.com agora serve exclusivamente PostfixAdmin + +### Validated +- ✅ 7/7 serviços ativos (nginx, mariadb, php8.4-fpm, redis, postfix, dovecot, opendkim) +- ✅ 6/6 sites HTTPS funcionando +- ✅ SSL válido para 6 domínios (expira 2026-03-07) +- ✅ DKIM key OK +- ✅ SPF configurado com hard fail (-all) +- ✅ 4 databases criados (webmoney, postfixadmin, roundcube, phpmyadmin) +- ✅ 2 contas de email (admin@cnxifly.com, test@cnxifly.com) + +## [1.1.5] - 2025-12-07 + +### Added +- phpMyAdmin instalado em `https://phpmyadmin.cnxifly.com` com SSL (Let's Encrypt) +- Usuários administrativos criados (sistema e MariaDB) usando senha única fornecida +- Documento `CREDENCIAIS.md` com todas as credenciais criadas + +### Changed +- Tela de login agora aplica tema escuro/azul conforme especificação (background, cards, botões) + +### Fixed +- Erro de dependências ausentes (MariaDB) para phpMyAdmin + +## [1.1.4] - 2025-12-07 + +### Added +- ✓ Documentação completa do tema escuro/azul profissional em ESPECIFICACIONES +- ✓ Paleta de cores detalhada com códigos hex e variáveis SCSS +- ✓ Certificado SSL expandido para incluir `webmail.cnxifly.com` +- ✓ Configuração Nginx para `webmail.cnxifly.com` com HTTPS +- ✓ Documentação de uso dos subdomínios mail/webmail + +### Changed +- Removida rota `/register` do frontend - usuários NÃO podem se auto-registrar +- Login sem link para registro (apenas admins criam usuários) +- Subdominios documentados: `mail.cnxifly.com` = admin email, `webmail.cnxifly.com` = usuários finais + +### Fixed +- ❌ ERR_CERT_COMMON_NAME_INVALID em `webmail.cnxifly.com` → ✅ Certificado SSL válido +- Let's Encrypt agora inclui 6 domínios: cnxifly.com, www, webmoney, mail, webmail, ftp + +### Security +- Certificado wildcard Let's Encrypt válido até 2026-03-07 +- TLS 1.2/1.3 configurado em webmail.cnxifly.com +- Redirecionamento automático HTTP → HTTPS + +--- + +## [1.1.3] - 2025-12-07 + +### Added +- ✓ Frontend React completo com Vite + React Router + Bootstrap 5 +- ✓ Tela de Login (`/login`) com validação e feedback de erros +- ✓ Tela de Registro (`/register`) com confirmação de senha +- ✓ Dashboard (`/dashboard`) protegido com informações do usuário +- ✓ Sistema de autenticação com Context API e localStorage +- ✓ Interceptor Axios para token automático em todas as requisições +- ✓ ProtectedRoute para rotas que requerem autenticação +- ✓ Deploy script para frontend (`frontend/deploy.sh`) +- ✓ Configuração Nginx ajustada para React SPA + Laravel API + +### Changed +- Nginx agora serve React SPA em `/` e Laravel API em `/api` +- Index do Nginx mudado de `index.php` para `index.html` (React) + +### Validation +- ✅ Frontend acessível em `https://webmoney.cnxifly.com` +- ✅ Login/Register funcionando com backend Laravel +- ✅ Dashboard mostra informações do usuário autenticado +- ✅ Logout revoga token e redireciona para login +- ✅ API `/api/*` respondendo corretamente via Nginx + +### Tech Stack +- React 18 + Vite (rolldown-vite experimental) +- React Router DOM para navegação +- Axios para requisições HTTP +- Bootstrap 5 + Bootstrap Icons +- Laravel Sanctum (backend) + +--- + +## [1.1.2] - 2025-12-07 + +### Added +- ✓ Registro DMARC publicado em `_dmarc.cnxifly.com` com política `p=quarantine`, alinhamento estrito (adkim/aspf=s) e coleta de relatórios (rua/ruf para postmaster@cnxifly.com) +- ✓ Cabeçalhos anti-spam adicionados aos emails de boas-vindas: `List-Unsubscribe` (mailto) e `List-Unsubscribe-Post: List-Unsubscribe=One-Click` + +### Changed +- Fluxo de envio de email configurado para validar SPF/DKIM/DMARC end-to-end após publicação do DMARC + +### Validation +- ✅ SPF=pass, DKIM=pass, DMARC=pass em entrega real para Gmail (forward iCloud) com chegada em INBOX +- ✅ Endpoint `POST /api/email/send-test` respondendo 200 e entregando com autenticações válidas + +### Notes +- Política DMARC pode ser elevada para `p=reject` após estabilização +- Manter aquecimento do domínio com baixo volume antes de campanhas + +--- + +## [1.0.5] - 2025-12-07 + +### Added +- ✓ Certificado SSL Let's Encrypt expandido para incluir mail.cnxifly.com e ftp.cnxifly.com +- ✓ Virtual host Nginx para mail.cnxifly.com com HTTPS e HTTP/2 +- ✓ Virtual host Nginx para ftp.cnxifly.com com HTTPS e HTTP/2 +- ✓ Registro DNS DKIM (default._domainkey.cnxifly.com) adicionado e validado + +### Fixed +- ✓ Certificado SSL agora cobre TODOS os domínios: cnxifly.com, www, webmoney, mail, ftp +- ✓ Postfix e vsftpd recarregados com certificados atualizados +- ✓ Todos os 5 domínios agora respondem HTTPS 200 com HTTP/2 + +### Configuration +- Certificado SSL: válido até 2026-03-07 (5 domínios) +- Nginx: 4 sites habilitados (cnxifly.com, webmoney, mail, ftp) +- DKIM DNS: TXT record publicado e propagado +- Security headers: HSTS, nosniff, XFO, XSS-Protection em todos os vhosts + +### Validation +- ✅ 5/5 domínios com HTTPS funcional +- ✅ 6/6 serviços ativos (nginx, postfix, opendkim, vsftpd, php-fpm, postgresql) +- ✅ 18/18 regras de firewall UFW configuradas +- ✅ DKIM DNS propagado e validado via dig +- ✅ Certificado SSL multi-domínio ativo + +### Notes +- **INFRAESTRUTURA 100% COMPLETA** - Todas as correções aplicadas +- Todos os certificados SSL configurados e válidos +- Email anti-spam funcional com DKIM publicado em DNS +- Pronto para desenvolvimento de aplicação Laravel + React + +--- + +## [1.0.4] - 2025-12-07 + +### Added +- ✓ Servidor de Email Postfix 3.8.6 instalado e configurado com TLS +- ✓ OpenDKIM 2.11.0 instalado com chaves DKIM geradas para cnxifly.com +- ✓ Integração Postfix + OpenDKIM via milter (porta 8891) +- ✓ Contas de email criadas: admin@cnxifly.com, no-reply@cnxifly.com, support@cnxifly.com +- ✓ Aliases de email configurados em /etc/aliases +- ✓ vsftpd 3.0.5 instalado com suporte SSL/TLS usando certificados Let's Encrypt +- ✓ FTP passivo configurado (portas 40000-50000) +- ✓ Virtual host Nginx dedicado para webmoney.cnxifly.com com PHP-FPM +- ✓ Diretório /var/www/webmoney/public criado para aplicação Laravel +- ✓ PHP-FPM 8.3 ativo e integrado com Nginx +- ✓ Portas adicionais abertas no UFW: 25, 587, 465 (SMTP), 21, 20 (FTP), 40000-50000 (FTP passivo) + +### Configuration +- Postfix: myhostname=mail.cnxifly.com, TLS ativo, DKIM signing habilitado +- OpenDKIM: selector=default, domain=cnxifly.com, chave RSA 2048-bit +- vsftpd: SSL ativo, write_enable=YES, pasv_mode ativo +- Nginx webmoney: root=/var/www/webmoney/public, PHP-FPM via socket, HTTPS only + +### Security +- Anti-spam: DKIM assinatura digital em emails enviados +- Registro DNS TXT necessário: default._domainkey.cnxifly.com (ver chave pública gerada) +- FTP com SSL/TLS obrigatório +- 18 regras ativas no UFW (IPv4 + IPv6) + +### Notes +- ✅ Servidor 100% pronto conforme ESPECIFICACIONES_WEBMONEY.md +- Email SMTP disponível para envio de notificações da aplicação +- FTP disponível para deploy manual de arquivos (usar FTPS) +- Laravel pode ser instalado em /var/www/webmoney +- React build pode ser servido via Nginx (SPA mode) + +--- + +## [1.0.3] - 2025-12-07 + +### Added +- ✓ Firewall UFW ativado permitindo apenas 22/80/443 (IPv4 e IPv6) +- ✓ Instalação e configuração do Nginx com gzip, charset UTF-8 e cabeçalhos de segurança (HSTS, nosniff, XFO, XSS-Protection, Referrer-Policy, Permissions-Policy) +- ✓ Certificados SSL Let’s Encrypt emitidos para cnxifly.com, www.cnxifly.com e webmoney.cnxifly.com com redirecionamento HTTPS +- ✓ Banco PostgreSQL `webmoney` criado com ENCODING `UTF8` e LOCALE `es_ES.UTF-8` +- ✓ Usuário PostgreSQL `webmoney_user` criado com senha forte e privilégios na base `webmoney` +- ✓ Apache desabilitado para liberar portas web e Nginx habilitado em 80/443 + +### Notes +- Certbot agendado para renovação automática +- Root web atual: `/var/www/html` (placeholder; pronto para app) +- Manter porta 22 aberta garante acesso via workspace/local conforme solicitado + +--- + +## [1.0.2] - 2025-12-07 + +### Added +- ✓ Configuración obligatoria: Timezone Europe/Madrid +- ✓ Configuración obligatoria: Encoding UTF-8 en todo el sistema +- ✓ Instrucciones de configuración del sistema (timedatectl, locale) +- ✓ Configuración de Timezone y Encoding en Base de Datos: + - PostgreSQL: client_encoding = 'UTF8' + - MySQL/MariaDB: utf8mb4 + utf8mb4_unicode_ci +- ✓ Configuración de Encoding en Laravel: + - config/app.php: timezone y locale + - config/database.php: opciones de conexión UTF-8 +- ✓ Configuración de Encoding en Frontend (React/Vite): + - .env variables para timezone + - date-fns con locale es_ES + - i18n con español como idioma por defecto +- ✓ Configuración de Encoding en Servidores Web: + - Nginx: charset utf-8 headers + - Apache: AddDefaultCharset utf-8 +- ✓ Verificación de configuración (comandos de testing) +- ✓ Sección de seguridad ampliada con timezone/encoding + +### Configuration +- Timezone: Europe/Madrid (WET/WEST, UTC±0/±1) +- Encoding: UTF-8 (obligatorio en todos los servicios) +- Locale: es_ES.UTF-8 +- Aplicaciones: PostgreSQL, MySQL, Laravel, React, Nginx, Apache + +### Notes +- DEBE configurarse ANTES de instalar servicios +- Afecta: BD, Web Server, PHP, Node, Emails, Archivos +- Verificaciones incluidas para confirmar configuración +- Tablas de configuração por serviço + +--- + +## [1.0.1] - 2025-12-07 + +## [1.1.2] - 2025-12-07 + +### Added +- ✓ Fluxo padrão documentado: desenvolver localmente, executar `backend/deploy.sh` e validar diretamente em `https://webmoney.cnxifly.com` +- ✓ Sample de email anti-spam (`EMAIL_SAMPLE.md`) com HTML e texto, usando From/Reply-To e headers de opt-out +- ✓ PAINEL_CONTROLE atualizado com status real de SPF/DKIM/DMARC e versão corrente + +### Changed +- README detalha entregabilidade, DMARC e passo a passo de validação no domínio + +### Validation +- 🛈 Mudança apenas documental; nenhum binário ou configuração de produção alterado + + +### Added +- ✓ Documento de Especificaciones del Proyecto (ESPECIFICACIONES_WEBMONEY.md) +- ✓ Definición completa del proyecto WEBMoney +- ✓ Configuración de dominios y subdomínios +- ✓ Stack tecnológico detallado (React + Laravel) +- ✓ Base de datos: PostgreSQL/MySQL recomendado +- ✓ Servidor web: Nginx/Apache configuración +- ✓ Servidor de email: Postfix + Dovecot +- ✓ FTP/SFTP: vsftpd +- ✓ SSL/TLS: Let's Encrypt +- ✓ Listado completo de funcionalidades previstas (18 grupos) +- ✓ Requisitos UI/UX: + - 3 idiomas (Español, Inglés, Portugués Brasil) + - Tema oscuro profissional + - Modales Bootstrap (sin alert() del navegador) + - Responsivo para móvil, tablet, desktop +- ✓ Matriz de tareas por fases +- ✓ Cronograma de implementação +- ✓ Estructura de directorios recomendada +- ✓ Convenciones de código (JS/React e Laravel/PHP) +- ✓ Variables de entorno templates +- ✓ Checklist de segurança (10 items) +- ✓ Targets de performance (Web Vitals) + +### Specifications +- Proyecto: WEBMoney - Gerenciador Financeiro Pessoal WEB +- Proveedor: ConneXiFly +- Servidor: 213.165.93.60 (Ubuntu 24.04.3 LTS) +- Dominio: cnxifly.com +- Subdominio: webmoney.cnxifly.com +- Email: @cnxifly.com +- Estado: En Desarrollo + +### Development Notes +- Instalación comando a comando (sin scripts automáticos) +- Documentación completa requerida +- Desarrollo incremental (solo lo solicitado cuando se solicite) +- Validación en domínio requerida para cada cambio +- Prohibido: alertas del navegador (alert, confirm, prompt) +- Usar: Bootstrap Modals y Toast en su lugar + +--- + +## [1.0.0] - 2025-12-07 + +### Added +- ✓ Auditoria Inicial Completa do servidor 213.165.93.60 +- ✓ Auditoria de domínio cnxifly.com e subdomínios +- ✓ Documentação: AUDITORIA_INICIAL_CNXIFLY.md +- ✓ Sistema de Controle de Versão (SemVer 1.0.0) +- ✓ Arquivo de Diretrizes de Desenvolvimento (IMUTÁVEL) +- ✓ Arquivo de Changelog (este arquivo) +- ✓ Arquivo de Versão (VERSION) +- ✓ Conexão SSH automática via sshpass +- ✓ Coleta de informações de DNS, nameservers, zona DNS +- ✓ Descoberta de subdomínios (www, mail, ftp) +- ✓ Análise de registros SPF, MX, AAAA, SOA +- ✓ Verificação de processos e portas abertas +- ✓ Recomendações de segurança e próximas ações + +### Infrastructure +- Servidor: 213.165.93.60 (Ubuntu 24.04.3 LTS, Kernel 6.8.0-88-generic) +- IPv4: 213.165.93.60 +- IPv6: 2001:ba0:268:ab00::1 +- Domínio: cnxifly.com +- Nameservers: UI-DNS (ns1028.ui-dns.biz, ns1114.ui-dns.com, ns1016.ui-dns.org, ns1119.ui-dns.de) + +### Status do Servidor v1.0.0 +- [x] Conectividade verificada +- [x] DNS funcional (systemd-resolved) +- [x] Serviço SSH ativo +- [x] Firewall apropriadamente configurado +- [x] Sem serviços adicionais instalados (pronto para configuração) + +### Métodos Utilizados +- sshpass 1.09-1 para automação SSH +- dig (BIND 9) para consultas DNS +- systemd-resolved para gerenciamento DNS local +- ss para análise de portas abertas +- Enumeração manual de subdomínios comuns + +### Documentação +- Criado arquivo AUDITORIA_INICIAL_CNXIFLY.md com: + - Informações do servidor e rede + - Configuração DNS detalhada + - Mapeamento de domínio e subdomínios + - Registros DNS (A, AAAA, MX, TXT, NS, SOA) + - Análise de segurança + - Recomendações de próximas ações + - Ferramentas e métodos utilizados + +### Security Notes +- ⚠️ SPF com softfail (~all) - considerar migração para hard fail (-all) +- ⚠️ Sem DNSSEC configurado +- ⚠️ Sem DKIM detectado +- ✓ DNS não exposto para resolução pública +- ✓ Apenas SSH em porta 22 (acesso controlado) + +--- + +## Notas Importantes + +### Directives de Desenvolvimento +Este projeto segue as DIRETRIZES_DESENVOLVIMENTO estabelecidas em v1.0.0: +- Controle de versão incremental para CADA commit/push +- Validação de mudanças através do domínio (cnxifly.com) +- Nenhum arquivo pendente pode ser editado sem commit prévio +- Documentação obrigatória em CHANGELOG.md + +### Próximas Ações Recomendadas (v1.1.0+) +- [ ] Configurar DNSSEC para o domínio +- [ ] Implementar DKIM para emails +- [ ] Revisar e atualizar SPF (softfail → hard fail) +- [ ] Instalar certificados SSL/TLS +- [ ] Configurar servidor web (nginx/Apache) +- [ ] Implementar monitoramento DNS +- [ ] Autenticação SSH via chaves públicas +- [ ] Configurar HTTPS em subdomínios +- [ ] Implementar backup de zona DNS + +--- + +**Formato de Data:** ISO 8601 (YYYY-MM-DD) +**Mantido por:** GitHub Copilot +**Servidor:** 213.165.93.60 (cnxifly.com) diff --git a/CONFIGURACION_LOCAL.md b/CONFIGURACION_LOCAL.md new file mode 100644 index 0000000..a60c71a --- /dev/null +++ b/CONFIGURACION_LOCAL.md @@ -0,0 +1,159 @@ +# 🖥️ CONFIGURACION DEL AMBIENTE DE DESARROLLO LOCAL + +**Sistema Operativo:** Windows +**Ultima actualizacion:** 13 de Diciembre de 2025 + +--- + +## 📦 Software Instalado + +| Software | Version | Comando de Verificacion | +|----------|---------|-------------------------| +| **Node.js** | 24.12.0 LTS | `node --version` | +| **npm** | 11.6.2 | `npm --version` | +| **PHP** | 8.4.15 | `php --version` | +| **Composer** | 2.9.2 | `composer --version` | +| **Git** | 2.52.0 | `git --version` | +| **SSH** | OpenSSH 9.5 | `ssh -V` | +| **PuTTY** | 0.83.0 | Para plink/pscp | + +--- + +## 🚀 Scripts Automatizados (Basados en Diretrizes v3.0) + +``` +scripts/ +├── status.ps1 # Ver estado del proyecto +├── dev.ps1 # Iniciar servidores de desarrollo +├── quick-deploy.ps1 # Deploy rapido sin commit +├── release.ps1 # Workflow completo automatizado +└── setup-ssh.ps1 # Configurar SSH sin password (una vez) +``` + +### Workflow Completo (release.ps1) + +El script `release.ps1` automatiza TODO el proceso segun las directrices: + +1. ✅ Incrementa VERSION automaticamente +2. ✅ Actualiza CHANGELOG.md +3. ✅ Hace deploy al servidor +4. ✅ Abre navegador para test +5. ✅ Espera confirmacion del usuario +6. ✅ Hace commit y push + +```powershell +# Correccion de bug (patch: 1.27.2 -> 1.27.3) +.\scripts\release.ps1 -VersionType patch -ChangeDescription "Corrigido bug X" -ChangeType Fixed + +# Nueva funcionalidad (minor: 1.27.2 -> 1.28.0) +.\scripts\release.ps1 -VersionType minor -ChangeDescription "Nuevo widget Y" -ChangeType Added + +# Deploy solo frontend +.\scripts\release.ps1 -VersionType patch -ChangeDescription "Ajuste CSS" -Deploy frontend + +# Solo documentacion (sin deploy) +.\scripts\release.ps1 -VersionType patch -ChangeDescription "Actualizar docs" -Deploy none +``` + +### Quick Deploy (Desarrollo) + +Para iteraciones rapidas durante desarrollo: + +```powershell +# Deploy solo frontend +.\scripts\quick-deploy.ps1 -Target frontend + +# Deploy solo backend +.\scripts\quick-deploy.ps1 -Target backend + +# Deploy ambos +.\scripts\quick-deploy.ps1 -Target both +``` + +--- + +## 🔧 Configuracion Inicial + +### 1. Primera vez - Configurar SSH sin password + +```powershell +.\scripts\setup-ssh.ps1 +``` + +Esto copia tu clave publica al servidor. Despues podras conectar sin password. + +### 2. Verificar estado + +```powershell +.\scripts\status.ps1 +``` + +### 3. Iniciar desarrollo local + +```powershell +.\scripts\dev.ps1 +``` + +--- + +## 🌐 URLs + +### Desarrollo Local +| Servicio | URL | +|----------|-----| +| Frontend | http://localhost:5173 | +| Backend API | http://localhost:8000/api | + +### Produccion +| Servicio | URL | +|----------|-----| +| Aplicacion | https://webmoney.cnxifly.com | +| API | https://webmoney.cnxifly.com/api | +| phpMyAdmin | https://phpmyadmin.cnxifly.com | +| Webmail | https://webmail.cnxifly.com | + +--- + +## ⚙️ Extensiones PHP Habilitadas + +- openssl, curl, mbstring, fileinfo +- pdo_mysql, zip, gd, intl, exif, sodium + +El archivo `php.ini` esta en: +``` +C:\Users\marco\AppData\Local\Microsoft\WinGet\Packages\PHP.PHP.8.4_Microsoft.Winget.Source_8wekyb3d8bbwe\php.ini +``` + +--- + +## 📝 Directrices de Desarrollo (v3.0) + +El workflow esta completamente automatizado. Las reglas principales son: + +1. **Versionamento Semantico**: Cada commit incrementa la version +2. **Test Obligatorio**: Siempre testar en produccion antes de commit +3. **Scripts de Deploy**: Nunca hacer deploy manual +4. **Documentacion**: VERSION y CHANGELOG siempre actualizados + +Todo esto es manejado automaticamente por `.\scripts\release.ps1` + +--- + +## 🆘 Solucion de Problemas + +### PHP no encuentra extensiones +```powershell +php -i | Select-String "extension_dir" +``` + +### Error de permisos en Laravel (OneDrive) +```powershell +attrib -R "backend\bootstrap\cache" /S /D +attrib -R "backend\storage" /S /D +``` + +### Git no configurado +```powershell +git config --global user.name "Tu Nombre" +git config --global user.email "tu@email.com" +``` diff --git a/CREDENCIAIS_SERVIDOR.md b/CREDENCIAIS_SERVIDOR.md new file mode 100644 index 0000000..fd77b0d --- /dev/null +++ b/CREDENCIAIS_SERVIDOR.md @@ -0,0 +1,205 @@ +# 🔐 CREDENCIAIS DO SERVIDOR - CNXIFLY.COM + +> **⚠️ DOCUMENTO CONFIDENCIAL - NÃO COMPARTILHAR** +> +> Última atualização: 07 de Dezembro de 2025 + +--- + +## 📡 ACESSO SSH + +| Campo | Valor | +|-------|-------| +| **Host** | `213.165.93.60` | +| **Usuário** | `root` | +| **Senha** | `Master9354` | +| **Porta** | `22` | + +```bash +ssh root@213.165.93.60 +``` + +--- + +## 🌐 URLs E CREDENCIAIS + +### Sites Públicos (sem autenticação) + +| URL | Descrição | +|-----|-----------| +| https://cnxifly.com | Site principal (Frontend) | +| https://www.cnxifly.com | Site principal (www) | +| https://webmoney.cnxifly.com | WEBMoney App | + +### Painéis de Administração + +| URL | Descrição | Usuário | Senha | +|-----|-----------|---------|-------| +| https://phpmyadmin.cnxifly.com | phpMyAdmin (Banco de Dados) | `root` | `M@ster9354` | +| https://mail.cnxifly.com | PostfixAdmin (Admin Email) | `admin@cnxifly.com` | `M@ster9354` | +| https://webmail.cnxifly.com | Roundcube (Webmail) | `marco@cnxifly.com` | `M@ster9354` | + +### WEBMoney App (Login Sistema) + +| URL | Usuário | Senha | Descrição | +|-----|---------|-------|-----------| +| https://webmoney.cnxifly.com | `admin@cnxifly.com` | `M@ster9354` | Administrador | +| https://webmoney.cnxifly.com | `marco@cnxifly.com` | `M@ster9354` | Usuário Marco | + +--- + +## 🗄️ BANCO DE DADOS (MariaDB 11.4.9) + +### Acesso Root + +| Campo | Valor | +|-------|-------| +| **Host** | `localhost` ou `127.0.0.1` | +| **Porta** | `3306` | +| **Usuário** | `root` | +| **Senha** | `M@ster9354` | + +```bash +mysql -u root -p'M@ster9354' +``` + +### Databases e Usuários + +| Database | Usuário | Senha | Descrição | +|----------|---------|-------|-----------| +| `webmoney` | `webmoney` | `M@ster9354` | Aplicação principal | +| `postfixadmin` | `postfixadmin` | `M@ster9354` | Gerenciamento de emails | +| `roundcube` | `roundcube` | `M@ster9354` | Webmail | +| `phpmyadmin` | `phpmyadmin` | `M@ster9354` | Controle phpMyAdmin | + +--- + +## 📧 SERVIDOR DE EMAIL + +### Configuração SMTP/IMAP + +| Campo | Valor | +|-------|-------| +| **Servidor** | `mail.cnxifly.com` | +| **SMTP Porta** | `587` (STARTTLS) | +| **SMTPS Porta** | `465` (SSL/TLS) | +| **IMAP Porta** | `993` (SSL/TLS) | +| **Autenticação** | Necessária | + +### Contas de Email + +| Email | Senha | Descrição | +|-------|-------|-----------| +| `admin@cnxifly.com` | `M@ster9354` | Administrador PostfixAdmin | +| `marco@cnxifly.com` | `M@ster9354` | Email pessoal Marco | +| `no-reply@cnxifly.com` | `M@ster9354` | Notificações do sistema | + +### Configuração Cliente de Email + +``` +Servidor de entrada (IMAP): + Host: mail.cnxifly.com + Porta: 993 + Segurança: SSL/TLS + +Servidor de saída (SMTP): + Host: mail.cnxifly.com + Porta: 587 + Segurança: STARTTLS +``` + +--- + +## 🔧 API ENDPOINTS + +### Base URL +``` +https://webmoney.cnxifly.com/api +``` + +### Endpoints Disponíveis + +| Método | Endpoint | Descrição | +|--------|----------|-----------| +| `POST` | `/api/login` | Autenticação | +| `POST` | `/api/register` | Registro de usuário | +| `POST` | `/api/logout` | Logout | +| `GET` | `/api/me` | Dados do usuário autenticado | +| `GET` | `/api/user` | Informações do usuário | +| `GET` | `/api/email/anti-spam-info` | Informações anti-spam | +| `POST` | `/api/email/send-test` | Enviar email de teste | + +### Health Check +``` +GET https://cnxifly.com/up +``` + +--- + +## 🛡️ CERTIFICADO SSL + +| Campo | Valor | +|-------|-------| +| **Emissor** | Let's Encrypt | +| **Válido até** | 07 de Março de 2026 | +| **Domínios cobertos** | cnxifly.com, www, mail, webmail, phpmyadmin, webmoney | + +--- + +## 📁 ESTRUTURA DE DIRETÓRIOS + +``` +/var/www/webmoney/ +├── backend/ # Laravel API +├── frontend/ # Vue/React App +└── public/ # Arquivos públicos + +/var/www/postfixadmin/ # PostfixAdmin +/var/www/roundcube/ # Roundcube Webmail +/var/www/phpmyadmin/ # phpMyAdmin +``` + +--- + +## 🔄 SERVIÇOS DO SISTEMA + +| Serviço | Comando de Status | +|---------|-------------------| +| Nginx | `systemctl status nginx` | +| PHP-FPM | `systemctl status php8.4-fpm` | +| MariaDB | `systemctl status mariadb` | +| Redis | `systemctl status redis-server` | +| Postfix | `systemctl status postfix` | +| Dovecot | `systemctl status dovecot` | +| OpenDKIM | `systemctl status opendkim` | + +### Reiniciar Todos os Serviços +```bash +systemctl restart nginx php8.4-fpm mariadb redis-server postfix dovecot opendkim +``` + +--- + +## 📝 NOTAS IMPORTANTES + +1. **Backup**: Realizar backup regular do banco de dados e arquivos +2. **SSL**: Certificado renova automaticamente via Certbot +3. **Firewall**: UFW ativo com portas 22, 25, 80, 143, 443, 465, 587, 993 +4. **Logs**: + - Nginx: `/var/log/nginx/` + - PHP: `/var/log/php8.4-fpm.log` + - Mail: `/var/log/mail.log` + - Laravel: `/var/www/webmoney/backend/storage/logs/` + +--- + +## 🆘 SUPORTE + +Em caso de problemas, verificar: +1. Status dos serviços: `systemctl status [serviço]` +2. Logs de erro: `tail -f /var/log/nginx/error.log` +3. Conectividade: `curl -I https://cnxifly.com` + +--- + +> **Gerado automaticamente em 07/12/2025** diff --git a/DKIM_DNS_RECORD.txt b/DKIM_DNS_RECORD.txt new file mode 100644 index 0000000..ca5d72f --- /dev/null +++ b/DKIM_DNS_RECORD.txt @@ -0,0 +1,36 @@ +═══════════════════════════════════════════════════════════════════════════ + REGISTRO DNS TXT PARA DKIM - OBRIGATÓRIO ADICIONAR NO PAINEL DNS +═══════════════════════════════════════════════════════════════════════════ + +Nome do Registro: default._domainkey.cnxifly.com +Tipo: TXT +Valor (copiar exatamente como está abaixo, em uma única linha): + +v=DKIM1; h=sha256; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuZkPiR5AbcAQvJaVMrTTxLaT1Afr3Nc7szQllGDLDrpK+qH/fj5lPSPzhOsGhfYqpGZB9EXMZqgv2BvGP6ANKjzh+CQVIvZt1msKGRHCz0kx5Rt/Wc5pBLzAlos91LBf0SN/PWgMHtqsjSKqhrFQ4VHSIv3fvVZ/WztUXRbCQHMk5FF9P+DRUVsYo5tCzHE15WcopKd5QYAPtt3L/UbY6VsoijBBnvFTrp2Huq35W6jQHwvrngwNWHVcNEWMQFeOJy8uNx3AswVHsFWTs6UykABQ+8xIcv5ejX7ZxDy0puXcL26BSFy7yncHjcbeK/M9dqscReJpuenRmluon6w34QIDAQAB + +TTL: 3600 (ou padrão) + +═══════════════════════════════════════════════════════════════════════════ + +COMO ADICIONAR: + +1. Acesse o painel DNS do seu registrador (UI-DNS ou onde gerencia cnxifly.com) +2. Crie um novo registro TXT +3. Nome/Host: default._domainkey.cnxifly.com (ou apenas "default._domainkey" se o painel adiciona o domínio automaticamente) +4. Valor: Cole exatamente o texto acima começando com "v=DKIM1..." +5. Salve e aguarde propagação DNS (pode levar até 24h, geralmente 1-2h) + +TESTAR APÓS PROPAGAÇÃO: +dig TXT default._domainkey.cnxifly.com +short + +Deve retornar a chave pública. + +═══════════════════════════════════════════════════════════════════════════ + +REGISTRO SPF ATUAL (já configurado no DNS): +v=spf1 mx a ip4:213.165.93.60 ~all + +RECOMENDAÇÃO: Alterar para hard fail (-all) em vez de softfail (~all) +Novo valor SPF sugerido: v=spf1 mx a ip4:213.165.93.60 -all + +═══════════════════════════════════════════════════════════════════════════ diff --git a/ESPECIFICACIONES_WEBMONEY.md b/ESPECIFICACIONES_WEBMONEY.md new file mode 100644 index 0000000..d88f4b5 --- /dev/null +++ b/ESPECIFICACIONES_WEBMONEY.md @@ -0,0 +1,1139 @@ +# 📋 ESPECIFICACIONES DEL PROYECTO + +**Proyecto:** WEBMoney - Gerenciador Financeiro Pessoal WEB +**Proveedor:** ConneXiFly +**Versión Especificación:** 1.0.0 +**Fecha Creación:** 7 de Dezembro de 2025 +**Estado:** En Desarrollo + +--- + +## 📑 TABLA DE CONTENIDOS + +1. [Información General del Proyecto](#información-general-del-proyecto) +2. [Dominios y Configuración](#dominios-y-configuración) +3. [Infraestructura del Servidor](#infraestructura-del-servidor) +4. [Stack Tecnológico](#stack-tecnológico) +5. [Funcionalidades Previstas](#funcionalidades-previstas) +6. [Requisitos de UI/UX](#requisitos-de-uiux) +7. [Notas Importantes de Desarrollo](#notas-importantes-de-desarrollo) +8. [Matriz de Tareas](#matriz-de-tareas) +9. [Cronograma de Implementación](#cronograma-de-implementación) + +--- + +## 📊 INFORMACIÓN GENERAL DEL PROYECTO + +### Descripción +**WEBMoney** es un gerenciador financiero personal basado en web que permite a los usuarios: +- Gestionar sus finanzas personales +- Registrar ingresos y gastos +- Generar reportes y análisis gráficos +- Exportar datos en múltiples formatos +- Acceder desde cualquier dispositivo (web y mobile) + +### Objetivos Principales +- ✓ Proporcionar herramienta intuitiva de gestión financiera +- ✓ Soporte multiidioma (3 idiomas) +- ✓ Interfaz moderna, responsiva y accesible +- ✓ Seguridad de datos y privacidad +- ✓ Integración con documentos (recibos, comprobantes) +- ✓ Análisis visual mediante gráficos + +### Público Objetivo +- Usuarios individuales +- Pequeñas empresas +- Freelancers +- Emprendedores + +--- + +## 🌐 DOMINIOS Y CONFIGURACIÓN + +### Dominios Principales + +| Item | Valor | Propósito | +|------|-------|----------| +| **Dominio Institucional** | cnxifly.com | Portal principal ConneXiFly | +| **Subdominio WEBMoney** | webmoney.cnxifly.com | Aplicación WEBMoney | +| **Email** | @cnxifly.com | Comunicación y notificaciones | +| **DNS Primario** | ns1028.ui-dns.biz | UI-DNS | +| **DNS Secundario** | ns1114.ui-dns.com | UI-DNS | +| **DNS Terciario** | ns1016.ui-dns.org | UI-DNS | +| **DNS Cuaternario** | ns1119.ui-dns.de | UI-DNS | + +### Certificados SSL +- **Requerido:** Certificados SSL válidos para cnxifly.com +- **Tipo:** Certificado wildcard (*.cnxifly.com) recomendado +- **Proveedor:** Preferentemente Let's Encrypt +- **Renovación:** Automática (certificados Let's Encrypt) + +### Subdominios Configurados +``` +cnxifly.com → 213.165.93.60 +www.cnxifly.com → 213.165.93.60 +webmoney.cnxifly.com → 213.165.93.60 (WEBMoney App) +mail.cnxifly.com → 213.165.93.60 (Panel Admin Email - Roundcube/PostfixAdmin) +webmail.cnxifly.com → 213.165.93.60 (Webmail Usuarios - Roundcube) +ftp.cnxifly.com → 213.165.93.60 (FTP/SFTP) +api.cnxifly.com → 213.165.93.60 (API - Futuro) +``` + +**Nota sobre subdominios de email:** +- **mail.cnxifly.com**: Panel administrativo para gestionar cuentas de email, dominios, alias (PostfixAdmin + Roundcube admin) +- **webmail.cnxifly.com**: Webmail para usuarios finales leer/enviar emails (Roundcube) + +--- + +## 🖥️ INFRAESTRUCTURA DEL SERVIDOR + +### Detalles del Servidor + +| Característica | Especificación | +|---|---| +| **IP Público (IPv4)** | 213.165.93.60 | +| **IP Público (IPv6)** | 2001:ba0:268:ab00::1 | +| **Sistema Operativo** | Linux Ubuntu 24.04.3 LTS | +| **Kernel** | 6.8.0-88-generic | +| **Arquitectura** | x86_64 (64-bit) | +| **Usuario Root** | root | +| **Contraseña Root** | Master9354 | +| **Conexión SSH** | sshpass (automática con contraseña) | +| **Firewall** | UFW (Uncomplicated Firewall) | +| **DNS Local** | systemd-resolved | +| **Timezone** | Europe/Madrid (WET/WEST) | +| **Encoding** | UTF-8 (todo el sistema) | + +### Puertos a Configurar + +| Puerto | Protocolo | Servicio | Estado | +|--------|-----------|---------|--------| +| 22 | SSH | Acceso remoto | ✓ Activo | +| 80 | HTTP | Web (redireccionar a 443) | Pendiente | +| 443 | HTTPS | Web seguro | Pendiente | +| 25/465/587 | SMTP | Correo electrónico | Pendiente | +| 143/993 | IMAP | Acceso correo | Pendiente | +| 21 | FTP | Transferencia de archivos | Pendiente | +| 22 | SFTP | SFTP (SSH) | Activo | +| 53 | DNS | Resolver DNS | Local only | + +### Requisitos de Instalación + +**Sin scripts automáticos - Instalación manual comando a comando** + +#### Sistema Base +- [ ] Actualizar sistema: `apt-get update && apt-get upgrade` +- [ ] Instalar build-essential: `apt-get install build-essential` +- [ ] Instalar curl, wget: `apt-get install curl wget` +- [ ] Instalar git: `apt-get install git` + +#### Configuración de Timezone y Encoding (OBLIGATORIO) + +**Timezone: Europe/Madrid** + +```bash +# Verificar timezone actual +timedatectl + +# Configurar timezone a Madrid +timedatectl set-timezone Europe/Madrid + +# Verificar la configuración +timedatectl +# Salida esperada: Time zone: Europe/Madrid (WET/WEST, UTC±0/±1) +``` + +**Encoding UTF-8 (OBLIGATORIO EN TODO EL SISTEMA)** + +```bash +# Verificar locale actual +locale + +# Instalar paquetes de locales +apt-get install locales + +# Configurar UTF-8 para Madrid (es_ES) +locale-gen es_ES.UTF-8 +update-locale LANG=es_ES.UTF-8 + +# Verificar la configuración +locale +# Salida esperada: LANG=es_ES.UTF-8, LC_ALL=es_ES.UTF-8 + +# Actualizar variables de entorno +export LC_ALL=es_ES.UTF-8 +export LANG=es_ES.UTF-8 +``` + +**Archivos a Modificar:** + +```bash +# /etc/environment +# Agregar al final: +LANG=es_ES.UTF-8 +LC_ALL=es_ES.UTF-8 +LC_CTYPE=es_ES.UTF-8 +LC_COLLATE=es_ES.UTF-8 +LC_NUMERIC=es_ES.UTF-8 +LC_TIME=es_ES.UTF-8 +LC_MONETARY=es_ES.UTF-8 +LC_MESSAGES=es_ES.UTF-8 +LC_PAPER=es_ES.UTF-8 +LC_NAME=es_ES.UTF-8 +LC_ADDRESS=es_ES.UTF-8 +LC_TELEPHONE=es_ES.UTF-8 +LC_MEASUREMENT=es_ES.UTF-8 +LC_IDENTIFICATION=es_ES.UTF-8 + +# /etc/locale.gen +# Descomentar: +# es_ES.UTF-8 UTF-8 +es_ES.UTF-8 UTF-8 + +# /etc/default/locale +# Configurar: +LANG="es_ES.UTF-8" +LANGUAGE="es_ES:es:en" +LC_ALL="es_ES.UTF-8" +``` + +**Verificación Final:** + +```bash +# Ver zona horaria actual +timedatectl +# Debe mostrar: Time zone: Europe/Madrid (WET/WEST, UTC±0) + +# Ver encoding de terminal +echo $LANG +# Debe mostrar: es_ES.UTF-8 + +# Ver todas las variables de locale +locale +# Todas deben estar en UTF-8 +``` + +⚠️ **IMPORTANTE:** Estas configuraciones deben aplicarse PRIMERO, antes de instalar cualquier servicio (BD, web, email, etc.). Algunos servicios como PostgreSQL y MySQL heredarán estas configuraciones. + +--- + +#### Sistema Base Continuación +- [ ] Configurar Timezone: Europe/Madrid +- [ ] Configurar Encoding: UTF-8 (todo el sistema) +- [ ] Actualizar sistema: `apt-get update && apt-get upgrade` +- [ ] Instalar build-essential: `apt-get install build-essential` +- [ ] Instalar curl, wget: `apt-get install curl wget` +- [ ] Instalar git: `apt-get install git` + +--- + +## 💻 STACK TECNOLÓGICO + +### Frontend + +**Tecnología:** React (última versión estable) + +``` +React: ^18.3.0 (o versión más reciente) +ReactDOM: ^18.3.0 +React Router: ^6.x (para navegación) +Axios: ^1.6.x (para peticiones HTTP) +Bootstrap: ^5.3.0 (CSS Framework) +Bootstrap Icons: ^1.11.x +Sass/SCSS: Para estilos personalizados +Chart.js: ^4.4.x (gráficos) +React-ChartJS-2: ^5.2.x (componente React) +Date-fns: Para manipulación de fechas +``` + +**Build Tools:** +- Vite (recomendado) o Create React App +- Node.js: ^18.0.0 o superior +- npm o yarn para gestión de dependencias + +### Backend + +**Tecnología:** Laravel (última versión estable) + +``` +Laravel: ^11.0 (o versión más reciente) +PHP: ^8.2.0 +Composer: Última versión (gestor de dependencias) +``` + +**Dependencias Laravel Principales:** +- laravel/framework +- laravel/tinker +- laravel/passport (autenticación OAuth) +- laravel/sanctum (tokens API) +- maatwebsite/excel (importar/exportar) +- intervention/image (procesamiento de imágenes) +- guzzlehttp/guzzle (peticiones HTTP) +- doctrine/dbal (migraciones avanzadas) + +### Base de Datos + +**Motor:** Seleccionar entre: +- **PostgreSQL 15+** (recomendado para datos financieros) +- **MySQL 8.0+** (alternativa compatible) +- **MariaDB 10.6+** (alternativa a MySQL) + +**Características Requeridas:** +- Soporte de transacciones ACID +- Integridad referencial +- Triggers para auditoría +- Backups automáticos + +**CONFIGURACIÓN OBLIGATORIA: UTF-8 en todas las conexiones** + +```sql +-- PostgreSQL +-- En postgresql.conf: +client_encoding = 'UTF8' + +-- Crear base de datos con UTF-8: +CREATE DATABASE webmoney ENCODING 'UTF8' LOCALE 'es_ES.UTF-8'; + +-- MySQL/MariaDB +-- En my.cnf o my.ini, agregar en [mysqld]: +character-set-server = utf8mb4 +collation-server = utf8mb4_unicode_ci + +-- Crear base de datos: +CREATE DATABASE webmoney CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- Verificar encoding: +SHOW VARIABLES LIKE 'character%'; +SHOW VARIABLES LIKE 'collation%'; +``` + +**Verificación de Encoding en BD:** + +```sql +-- PostgreSQL +SHOW client_encoding; +SHOW server_encoding; + +-- MySQL/MariaDB +SHOW VARIABLES LIKE 'character_set_client'; +SHOW VARIABLES LIKE 'character_set_database'; +``` + +### Servidor Web + +**Opciones:** + +#### Opción 1: Nginx (Recomendado) +``` +nginx: 1.24.x o superior +- Alto rendimiento +- Bajo consumo de memoria +- Ideal para aplicaciones modernas +``` + +#### Opción 2: Apache +``` +apache2: 2.4.58 o superior +- Mayor compatibilidad +- Módulos adicionales disponibles +- Mod rewrite para URLs limpias +``` + +**Configuración Requerida:** +- Gzip compression activado +- Cache headers adecuados +- CORS configurado +- Rate limiting para API + +### Servidor de Email + +**Configuración:** +``` +Servidor SMTP: Postfix o Exim +Servidor IMAP: Dovecot + +Cuentas de email: +├─ admin@cnxifly.com (administrador) +├─ no-reply@cnxifly.com (notificaciones automáticas) +├─ support@cnxifly.com (soporte técnico) +└─ Cuentas centralizadas compartidas + +Contraseña Base: M@ster93542026 +(Cambiar para cada cuenta con incremento numérico) + +Almacenamiento: Mínimo 10GB +Quota por usuario: 5GB +``` + +### SSL/TLS + +**Certificados:** +- Let's Encrypt (gratuito, renovable automáticamente) +- Wildcard para *.cnxifly.com +- Renovación automática mediante Certbot + +**Ciphers Recomendados:** +``` +TLS 1.2 mínimo +TLS 1.3 preferido +Ciphers modernos (excluir RC4, DES, SSLv3) +``` + +### FTP/SFTP + +**Servidor FTP:** +``` +vsftpd (Very Secure FTP Daemon) +- Soporte SSL/TLS +- Usuarios chroot +- Rate limiting +- Logging completo +``` + +**Acceso SFTP:** +- SSH en puerto 22 +- Usuarios con shell restringido +- Directorios home aislados + +--- + +## 🚀 FUNCIONALIDADES PREVISTAS + +### Funcionalidades Core (MVP - Minimum Viable Product) + +#### 1. Autenticación y Gestión de Usuarios +- [x] Login/Logout +- [x] Recuperación de contraseña +- [x] Perfil de usuario +- [x] Cambio de contraseña +- [x] Autenticación de dos factores (opcional) +- [ ] Panel administrativo para crear usuarios (SOLO ADMINS) + +**⚠️ IMPORTANTE:** Los usuarios NO pueden auto-registrarse. Solo administradores pueden crear nuevas cuentas mediante panel administrativo. + +#### 2. Gestión de Cuentas Bancarias +- [ ] Crear múltiples cuentas +- [ ] Editar cuentas +- [ ] Eliminar cuentas +- [ ] Saldo inicial y actual +- [ ] Tipos de cuenta (Ahorro, Corriente, Crédito) + +#### 3. Registro de Transacciones +- [ ] Crear ingreso +- [ ] Crear gasto +- [ ] Crear transferencia entre cuentas +- [ ] Editar transacción +- [ ] Eliminar transacción (con registro de auditoría) +- [ ] Búsqueda y filtrado de transacciones +- [ ] Categorización de gastos + +#### 4. Categorías de Gastos +- [ ] Categorías predefinidas +- [ ] Crear categorías personalizadas +- [ ] Editar categorías +- [ ] Subcategorías +- [ ] Colores e iconos personalizados + +#### 5. Fotos de Recibos +- [ ] Cargar foto de recibo +- [ ] Asociar con transacción +- [ ] Compresión automática +- [ ] Galería de recibos +- [ ] Búsqueda por fecha y categoría + +#### 6. Reportes y Análisis +- [ ] Resumen de ingresos/gastos (mes, año) +- [ ] Gráficos de gastos por categoría +- [ ] Evolución de saldo en el tiempo +- [ ] Comparativa mes a mes +- [ ] Alertas de gasto por categoría +- [ ] Proyecciones de ahorro + +#### 7. Importación/Exportación +- [ ] Importar transacciones desde XLS/XLSX +- [ ] Importar transacciones desde CSV +- [ ] Exportar a XLS/XLSX +- [ ] Exportar a CSV +- [ ] Exportar a PDF (reportes) +- [ ] Generación de PDF con gráficos + +#### 8. Notificaciones y Alertas +- [ ] Alertas de gasto por categoría +- [ ] Alertas de saldo bajo +- [ ] Recordatorios de transacciones recurrentes +- [ ] Notificaciones por email +- [ ] Notificaciones en la aplicación + +### Funcionalidades Futuras (v2.0+) +- Transacciones recurrentes/automáticas +- Presupuestos personalizados +- Metas de ahorro +- Integración con bancos (API) +- Sincronización multi-dispositivo +- Análisis predictivo (IA) +- Compartir cuentas (multi-usuario) +- Análisis de inversiones + +--- + +## 🎨 REQUISITOS DE UI/UX + +### Idiomas Soportados + +**3 Idiomas Principales:** +1. **Español** (es) - Idioma principal +2. **Inglés** (en) - Alcance internacional +3. **Portugués Brasileño** (pt-BR) - Mercado brasileño + +**Implementación:** +- i18n (Internacionalization) con librería i18next o react-intl +- Cambio de idioma en tiempo real +- Almacenamiento de preferencia de idioma +- Traducción de todos los textos UI, errores y mensajes + +### Sistema de Alertas y Notificaciones + +**Prohibición Explícita:** +``` +❌ NO se pueden usar alertas del navegador: + - alert() + - confirm() + - prompt() + +✅ USAR SIEMPRE: + - Modales Bootstrap (Bootstrap Modal) + - Toast/Notificaciones (Toast desde Bootstrap 5) + - Notificaciones de aplicación personalizadas +``` + +**Modales Bootstrap:** +```javascript +// Ejemplo de modal para confirmación +const modal = new bootstrap.Modal(document.getElementById('confirmModal')); +modal.show(); + +// Ejemplo de toast para notificaciones +const toast = new bootstrap.Toast(document.getElementById('notificationToast')); +toast.show(); +``` + +**Tipos de Notificaciones:** +- Success (Verde): Operaciones completadas +- Error (Rojo): Operaciones fallidas +- Warning (Amarillo): Advertencias +- Info (Azul): Información general +- Confirmación: Modal con botones + +### Tema Visual + +**Estilo:** Oscuro, profesional, moderno, basado en azul + +**Características:** +- Modo oscuro por defecto (dark mode) +- Base azul profesional para aplicación financiera +- Alto contraste para accesibilidad +- Diseño moderno y minimalista +- Paleta de colores: + ``` + Primario: #1E40AF (Azul profesional - base principal) + Primario Oscuro: #1E3A8A (Azul más oscuro para hover/active) + Primario Claro: #3B82F6 (Azul claro para highlights) + + Secundario: #10B981 (Verde esmeralda para ingresos/positivo) + Peligro: #EF4444 (Rojo para gastos/alertas/negativo) + Advertencia: #F59E0B (Amarillo ámbar para advertencias) + Información: #06B6D4 (Cyan para información) + + Fondo Principal: #0F172A (Slate 900 - casi negro) + Fondo Secundario: #1E293B (Slate 800 - paneles/cards) + Fondo Terciario: #334155 (Slate 700 - elementos elevados) + + Texto Principal: #F1F5F9 (Slate 100 - alto contraste) + Texto Secundario: #CBD5E1 (Slate 300 - texto menos importante) + Texto Terciario: #94A3B8 (Slate 400 - placeholders/disabled) + + Bordes: #334155 (Slate 700 - sutiles) + Sombras: rgba(0, 0, 0, 0.3) (suaves) + ``` + +**Implementación CSS/SCSS:** +```scss +// Variables de tema oscuro azul +$primary: #1E40AF; +$primary-dark: #1E3A8A; +$primary-light: #3B82F6; + +$secondary: #10B981; +$danger: #EF4444; +$warning: #F59E0B; +$info: #06B6D4; + +$bg-primary: #0F172A; +$bg-secondary: #1E293B; +$bg-tertiary: #334155; + +$text-primary: #F1F5F9; +$text-secondary: #CBD5E1; +$text-tertiary: #94A3B8; + +$border-color: #334155; +$shadow: rgba(0, 0, 0, 0.3); + +// Bootstrap override (en frontend/src/styles/_variables.scss) +$body-bg: $bg-primary; +$body-color: $text-primary; +$theme-colors: ( + "primary": $primary, + "secondary": $secondary, + "success": $secondary, + "danger": $danger, + "warning": $warning, + "info": $info, + "dark": $bg-secondary, + "light": $text-tertiary +); +``` + +**Tipografía:** +- Fuente: Inter, Segoe UI, Roboto, o equivalente sans-serif moderna +- Peso: 400 (regular), 500 (medium), 600 (semibold), 700 (bold) +- Tamaño base: 16px +- Línea: 1.5 +- Suavizado: -webkit-font-smoothing: antialiased + +**Efectos Visuales:** +- Transiciones suaves: 150-300ms ease +- Border radius: 0.5rem (8px) para cards/botones +- Sombras sutiles: box-shadow con blur 10-20px +- Gradientes opcionales en headers: linear-gradient(135deg, #1E40AF, #1E3A8A) + +### Responsividad + +**Breakpoints:** +```scss +$xs: 0px // Móvil pequeño +$sm: 576px // Móvil +$md: 768px // Tablet +$lg: 992px // Desktop +$xl: 1200px // Desktop grande +$xxl: 1400px // Ultra ancho +``` + +**Requisitos Específicos:** +- [ ] Funcional en móviles (iPhone, Android) +- [ ] Funcional en tablets (iPad, Galaxy Tab) +- [ ] Funcional en desktops +- [ ] Funcional en navegadores modernos (Chrome, Firefox, Safari, Edge) +- [ ] Orientación vertical y horizontal +- [ ] Touch events para móviles +- [ ] Gestos (swipe, pinch) opcionales + +### Accesibilidad (WCAG 2.1) + +**Requisitos Mínimos:** +- [ ] Contraste de color: Ratio 4.5:1 para texto +- [ ] Navegación por teclado: TAB, ENTER, ESC +- [ ] Screen readers: Alt text en imágenes +- [ ] Etiquetas HTML semánticas +- [ ] Atributos ARIA donde sea necesario +- [ ] Validación de formularios accesible + +### Componentes Visuales + +**Componentes Bootstrap Requeridos:** +- Navbar/Header +- Sidebar/Menu +- Cards +- Modales +- Formularios +- Tabla de datos +- Gráficos (Chart.js) +- Toast/Alertas +- Badges/Labels +- Buttons +- Dropdowns +- Accordion +- Pagination +- Progress bars + +--- + +## 📝 NOTAS IMPORTANTES DE DESARROLLO + +### Principios Generales + +1. **Instalación Manual** + - Cada componente se instala comando a comando + - No se utilizan scripts automáticos bash + - Cada paso debe ser documentado + - Permitir validación entre pasos + +2. **Documentación Completa** + - Documentar toda la configuración realizada + - Incluir comandos exactos utilizados + - Explicar el propósito de cada paso + - Mantener guía de instalación actualizada + +3. **Documentación de Código** + - Comentarios en código importante + - JSDoc para funciones JavaScript + - PHPDoc para funciones Laravel + - README en cada módulo/carpeta + - Guía de arquitectura + +4. **Desarrollo Incremental** + - Desarrollar SOLO lo solicitado + - CUANDO se solicite específicamente + - No anticipar características no solicitadas + - Validar con usuario antes de desarrollar + +5. **Testing** + - Tests unitarios para lógica crítica + - Tests de integración para APIs + - Tests de UI para componentes + - Coverage mínimo: 80% + +### Estructura de Directorios Recomendada + +``` +/webmoney +├── /backend (Laravel) +│ ├── app/ +│ │ ├── Http/ +│ │ │ ├── Controllers/ +│ │ │ ├── Middleware/ +│ │ │ └── Requests/ +│ │ ├── Models/ +│ │ ├── Services/ +│ │ └── Repositories/ +│ ├── database/ +│ │ ├── migrations/ +│ │ ├── seeders/ +│ │ └── factories/ +│ ├── routes/ +│ ├── config/ +│ ├── tests/ +│ ├── .env (no versionar) +│ ├── composer.json +│ └── artisan +│ +├── /frontend (React) +│ ├── src/ +│ │ ├── components/ +│ │ ├── pages/ +│ │ ├── services/ +│ │ ├── hooks/ +│ │ ├── contexts/ +│ │ ├── styles/ +│ │ ├── utils/ +│ │ ├── App.jsx +│ │ └── main.jsx +│ ├── public/ +│ ├── tests/ +│ ├── package.json +│ ├── vite.config.js +│ └── .env (no versionar) +│ +├── /docs (Documentación) +│ ├── INSTALACION.md +│ ├── API.md +│ ├── ARQUITECTURA.md +│ ├── USUARIOS_PRUEBA.md +│ └── GUIA_USUARIO.md +│ +├── /scripts (Scripts de utilidad) +│ ├── backup.sh +│ ├── deploy.sh +│ └── test.sh +│ +├── ESPECIFICACIONES_WEBMONEY.md (este archivo) +├── README.md +├── CHANGELOG.md +└── VERSION +``` + +### Convenciones de Código + +**JavaScript/React:** +```javascript +// CamelCase para variables y funciones +const userName = "John"; +function calculateTotal() { } + +// PascalCase para componentes +function UserProfile() { } + +// UPPER_SNAKE_CASE para constantes +const MAX_LOGIN_ATTEMPTS = 5; + +// Usar arrow functions +const handler = () => { }; + +// Destructuring +const { name, email } = user; +``` + +**Laravel/PHP:** +```php +// CamelCase para métodos +public function getUserTransactions() { } + +// CamelCase para propiedades +private $transactionService; + +// PascalCase para clases +class TransactionController extends Controller { } + +// UPPER_SNAKE_CASE para constantes +const DEFAULT_PAGE_SIZE = 20; + +// Usar type hints +public function store(TransactionRequest $request): JsonResponse { } +``` + +### Variables de Entorno + +**Backend (.env):** +``` +APP_NAME=WEBMoney +APP_ENV=production +APP_DEBUG=false +APP_URL=https://webmoney.cnxifly.com + +DB_CONNECTION=pgsql +DB_HOST=localhost +DB_PORT=5432 +DB_DATABASE=webmoney +DB_USERNAME=webmoney_user +DB_PASSWORD=XXXXX + +MAIL_MAILER=smtp +MAIL_HOST=localhost +MAIL_PORT=587 +MAIL_USERNAME=no-reply@cnxifly.com +MAIL_PASSWORD=XXXXX +MAIL_ENCRYPTION=tls +MAIL_FROM_ADDRESS=no-reply@cnxifly.com + +JWT_SECRET=XXXXX +SESSION_DRIVER=cookie + +# Timezone y Encoding +APP_TIMEZONE=Europe/Madrid +APP_LOCALE=es +FALLBACK_LOCALE=es +APP_ENCODING=UTF-8 +``` + +**Laravel config/app.php:** +```php +'timezone' => 'Europe/Madrid', +'locale' => 'es', +'fallback_locale' => 'es', +'faker_locale' => 'es_ES', +``` + +**Laravel config/database.php (PostgreSQL):** +```php +'pgsql' => [ + 'driver' => 'pgsql', + 'host' => env('DB_HOST', 'localhost'), + 'port' => env('DB_PORT', '5432'), + 'database' => env('DB_DATABASE', 'webmoney'), + 'username' => env('DB_USERNAME', 'postgres'), + 'password' => env('DB_PASSWORD', ''), + 'charset' => 'utf8', + 'prefix' => '', + 'schema' => 'public', + 'sslmode' => 'prefer', + // Conexión UTF-8 + 'options' => [ + 'client_encoding' => 'UTF8', + ], +], +``` + +**Laravel config/database.php (MySQL):** +```php +'mysql' => [ + 'driver' => 'mysql', + 'host' => env('DB_HOST', 'localhost'), + 'port' => env('DB_PORT', '3306'), + 'database' => env('DB_DATABASE', 'webmoney'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'unix_socket' => env('DB_SOCKET', ''), + 'charset' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'prefix' => '', + 'prefix_indexes' => true, + 'strict' => true, + 'engine' => null, + 'modes' => [ + 'STRICT_TRANS_TABLES', + 'ERROR_FOR_DIVISION_BY_ZERO', + 'NO_ENGINE_SUBSTITUTION', + ], +] +``` + +**Frontend (.env):** +``` +VITE_API_URL=https://webmoney.cnxifly.com/api +VITE_APP_NAME=WEBMoney +VITE_DEFAULT_LANGUAGE=es +VITE_TIMEZONE=Europe/Madrid +VITE_ENCODING=UTF-8 +``` + +**Frontend - vite.config.js:** +```javascript +export default { + // ... otras config + define: { + __TIMEZONE__: JSON.stringify('Europe/Madrid'), + __LOCALE__: JSON.stringify('es-ES'), + __ENCODING__: JSON.stringify('UTF-8'), + } +} +``` + +**React - Configuración de i18n:** +```javascript +// src/i18n/config.js +import i18n from 'i18next'; + +i18n.init({ + lng: 'es', + fallbackLng: 'es', + defaultNS: 'translation', + // Configuración de timezone + interpolation: { + escapeValue: false, + formatSeparators: ['{', '}'], + }, + resources: { + es: { translation: { /* ... */ } }, + en: { translation: { /* ... */ } }, + 'pt-BR': { translation: { /* ... */ } }, + } +}); + +// Configurar date-fns con timezone de Madrid +import { setDefaultOptions } from 'date-fns'; +import { es } from 'date-fns/locale'; + +setDefaultOptions({ locale: es }); +``` + +**Nginx - Headers UTF-8:** +```nginx +# En nginx.conf o bloque server: +charset utf-8; +charset_types text/html text/xml text/plain text/css text/javascript application/json application/javascript; + +# Headers adicionales para UTF-8 +add_header Content-Type "text/html; charset=utf-8"; +``` + +**Apache - Headers UTF-8:** +```apache +# En .htaccess o apache config: +AddDefaultCharset utf-8 + +# Headers específicos +Header set Content-Type "text/html; charset=utf-8" + +# Para archivos específicos + + Header set Content-Type "text/html; charset=utf-8" + +``` + +### Seguridad + +**Requisitos Obligatorios:** +- [ ] HTTPS en todas las conexiones +- [ ] CSRF protection activada +- [ ] SQL injection prevention (prepared statements) +- [ ] XSS protection (sanitización de inputs) +- [ ] Rate limiting en APIs +- [ ] CORS configurado correctamente +- [ ] Validación de inputs en frontend y backend +- [ ] Hashing de contraseñas (bcrypt, argon2) +- [ ] Logs de auditoría para transacciones +- [ ] Encriptación de datos sensibles +- [ ] Headers de seguridad (CSP, X-Frame-Options, etc.) + +### Configuración del Servidor - Timezone y Encoding + +**⚠️ CRÍTICO: DEBE configurarse ANTES de instalar cualquier servicio** + +**Timezone: Europe/Madrid (WET/WEST)** +- Zona horaria: GMT+0 (invierno), GMT+1 (verano) +- Todos los timestamps deben estar en este timezone +- Las fechas en la BD deben guardar en UTC pero mostrar en Madrid +- Los logs del servidor deben usar Madrid + +**Encoding: UTF-8 en TODO el sistema** +- Archivos del servidor: UTF-8 sin BOM +- Base de datos: UTF-8/utf8mb4 +- Frontend: UTF-8 +- Comunicación HTTP: UTF-8 +- Emails: UTF-8 +- Archivos CSV/Excel exportados: UTF-8 + +**Verificación de Configuración (post-instalación):** +```bash +# Timezone +timedatectl status +# Esperado: Time zone: Europe/Madrid (WET/WEST, UTC±0/±1) + +# Sistema locale +locale +# Esperado: LANG=es_ES.UTF-8, LC_ALL=es_ES.UTF-8 + +# Base de datos +# PostgreSQL: SHOW server_encoding; -> UTF8 +# MySQL: SHOW VARIABLES LIKE 'character_set%'; -> utf8mb4 + +# Archivos PHP +file /path/to/file.php +# Esperado: ASCII text (o UTF-8 text) + +# Headers HTTP +curl -I https://webmoney.cnxifly.com +# Esperado: Content-Type: text/html; charset=utf-8 +``` + +### Performance + +**Targets:** +- [ ] Time to First Byte (TTFB): < 200ms +- [ ] First Contentful Paint (FCP): < 1.5s +- [ ] Largest Contentful Paint (LCP): < 2.5s +- [ ] Cumulative Layout Shift (CLS): < 0.1 +- [ ] Bundle size: < 500KB (gzipped) +- [ ] API response time: < 500ms + +--- + +## 📊 MATRIZ DE TAREAS + +### Fase 1: Infraestructura (v1.0.0) +| Tarea | Estado | Responsable | Fecha Estimada | +|-------|--------|-------------|---| +| Auditoria inicial servidor | ✅ Completado | Copilot | 2025-12-07 | +| Instalación SO base | ⏳ Pendiente | - | - | +| Instalación MySQL/PostgreSQL | ⏳ Pendiente | - | - | +| Instalación Nginx/Apache | ⏳ Pendiente | - | - | +| Instalación Node.js | ⏳ Pendiente | - | - | +| Instalación PHP y Composer | ⏳ Pendiente | - | - | +| Configuración SSL/TLS | ⏳ Pendiente | - | - | +| Configuración servidor email | ⏳ Pendiente | - | - | +| Configuración FTP/SFTP | ⏳ Pendiente | - | - | +| Configuración firewall | ⏳ Pendiente | - | - | + +### Fase 2: Backend (v1.1.0) +| Tarea | Estado | Responsable | Fecha Estimada | +|-------|--------|-------------|---| +| Setup proyecto Laravel | ⏳ Pendiente | - | - | +| Modelos y Migraciones | ⏳ Pendiente | - | - | +| Autenticación y Usuarios | ⏳ Pendiente | - | - | +| CRUD Cuentas Bancarias | ⏳ Pendiente | - | - | +| CRUD Transacciones | ⏳ Pendiente | - | - | +| API REST | ⏳ Pendiente | - | - | +| Tests unitarios | ⏳ Pendiente | - | - | + +### Fase 3: Frontend (v1.2.0) +| Tarea | Estado | Responsable | Fecha Estimada | +|-------|--------|-------------|---| +| Setup proyecto React | ⏳ Pendiente | - | - | +| Autenticación (Login) | ⏳ Pendiente | - | - | +| Dashboard principal | ⏳ Pendiente | - | - | +| Gestión de cuentas | ⏳ Pendiente | - | - | +| Registro de transacciones | ⏳ Pendiente | - | - | +| Reportes y gráficos | ⏳ Pendiente | - | - | +| Importación/Exportación | ⏳ Pendiente | - | - | +| Internacionalización (i18n) | ⏳ Pendiente | - | - | + +### Fase 4: Integración (v1.3.0) +| Tarea | Estado | Responsable | Fecha Estimada | +|-------|--------|-------------|---| +| Integración Frontend-Backend | ⏳ Pendiente | - | - | +| Testing integral | ⏳ Pendiente | - | - | +| Optimización performance | ⏳ Pendiente | - | - | +| Deploy a producción | ⏳ Pendiente | - | - | +| Documentación final | ⏳ Pendiente | - | - | + +--- + +## 📅 CRONOGRAMA DE IMPLEMENTACIÓN + +### Timeline Propuesto + +``` +Semana 1-2: Infraestructura y Setup + ├─ Instalación sistemas base + ├─ Configuración BD y web server + └─ Configuración SSL y email + +Semana 3-4: Backend + ├─ Proyecto Laravel base + ├─ Modelos y migraciones + └─ APIs iniciales + +Semana 5-6: Frontend + ├─ Proyecto React base + ├─ Componentes y páginas + └─ Integración con APIs + +Semana 7: Testing e integración + ├─ Tests completos + ├─ Bugs y ajustes + └─ Optimización + +Semana 8: Deploy y documentación + ├─ Deploy a producción + ├─ Documentación final + └─ Capacitación usuarios +``` + +### Hitos (Milestones) + +- **v1.0.0** (2025-12-07): Auditoria y especificaciones +- **v1.1.0** (TBD): Infraestructura completa +- **v1.2.0** (TBD): Backend funcional +- **v1.3.0** (TBD): Frontend integrado +- **v2.0.0** (TBD): MVP lanzado en producción + +--- + +## 📞 CONTACTO Y REFERENCIAS + +**Servidor Principal:** +- URL: https://webmoney.cnxifly.com +- IP: 213.165.93.60 +- SSH: root@213.165.93.60 + +**Dominio:** +- cnxifly.com +- Registrador: UI-DNS +- Nameservers: ns1028.ui-dns.biz, ns1114.ui-dns.com, ns1016.ui-dns.org, ns1119.ui-dns.de + +**Documentación Relacionada:** +- AUDITORIA_INICIAL_CNXIFLY.md - Auditoria del servidor +- PAINEL_CONTROLE.txt - Dashboard del proyecto +- .DIRETRIZES_DESENVOLVIMENTO - Normas de desarrollo + +--- + +**Documento creado:** 7 de Dezembro de 2025 +**Versión:** 1.0.0 +**Estado:** En Desarrollo +**Última actualización:** 7 de Dezembro de 2025 + +--- + +> 📌 Este documento es la especificación viva del proyecto. Se actualizará con cada versión. +> Consultar CHANGELOG.md para historia de cambios. diff --git a/ESTRUTURA_PROJETO.md b/ESTRUTURA_PROJETO.md new file mode 100644 index 0000000..3ef193c --- /dev/null +++ b/ESTRUTURA_PROJETO.md @@ -0,0 +1,331 @@ +# ESTRUTURA DO PROJETO WEBMONEY + +**Versão:** 1.27.1 +**Última atualização:** 13 de Dezembro de 2025 + +--- + +## 📁 Estrutura Geral + +``` +webmoney/ +├── backend/ # Laravel 12 API +├── frontend/ # React 18 SPA +├── VERSION # Versão atual +├── CHANGELOG.md # Histórico de versões +├── README.md # Documentação principal +├── ESTRUTURA_PROJETO.md # Este arquivo +├── CREDENCIAIS_SERVIDOR.md # Acessos (confidencial) +├── ESPECIFICACIONES_WEBMONEY.md # Especificação funcional +├── APRENDIZADOS_TECNICOS.md # Soluções de problemas +└── .DIRETRIZES_DESENVOLVIMENTO_v3 # Regras de dev +``` + +--- + +## 🔧 Backend (Laravel 12) + +``` +backend/ +├── app/ +│ ├── Http/ +│ │ ├── Controllers/Api/ +│ │ │ ├── AccountController.php +│ │ │ ├── AuthController.php +│ │ │ ├── CategoryController.php +│ │ │ ├── CostCenterController.php +│ │ │ ├── DashboardController.php +│ │ │ ├── EmailTestController.php +│ │ │ ├── ImportController.php +│ │ │ ├── LiabilityAccountController.php +│ │ │ ├── RecurringTemplateController.php # Transações recorrentes +│ │ │ ├── TransactionController.php +│ │ │ └── TransferDetectionController.php # Inclui RefundDetection +│ │ │ +│ │ └── Middleware/ +│ │ └── SecurityHeaders.php # Headers de segurança +│ │ +│ ├── Models/ +│ │ ├── Account.php +│ │ ├── Category.php +│ │ ├── CategoryKeyword.php +│ │ ├── CostCenter.php +│ │ ├── CostCenterKeyword.php +│ │ ├── ImportLog.php +│ │ ├── ImportMapping.php +│ │ ├── LiabilityAccount.php +│ │ ├── LiabilityInstallment.php +│ │ ├── RecurringInstance.php # Parcelas de recorrência +│ │ ├── RecurringTemplate.php # Templates de recorrência +│ │ ├── Transaction.php +│ │ └── User.php +│ │ +│ ├── Services/ +│ │ ├── Import/ +│ │ │ ├── ImportService.php +│ │ │ ├── ExcelParser.php +│ │ │ ├── CsvParser.php +│ │ │ ├── OfxParser.php +│ │ │ └── PdfParser.php +│ │ └── RecurringService.php # Lógica de recorrências +│ │ +│ ├── Policies/ +│ │ └── RecurringTemplatePolicy.php # Autorização +│ │ +│ └── Providers/ +│ └── AppServiceProvider.php # Rate limiting config +│ +├── config/ +│ ├── cors.php # CORS configuration +│ ├── sanctum.php # Token expiration +│ └── session.php # Cookie settings +│ +├── database/migrations/ # 30+ migrations +│ +├── routes/ +│ └── api.php # Todas as rotas da API +│ +└── deploy.sh # Script de deploy +``` + +--- + +## 🎨 Frontend (React 18) + +``` +frontend/ +├── src/ +│ ├── components/ +│ │ ├── CookieConsent.jsx # Banner LGPD/GDPR +│ │ ├── CreateRecurrenceModal.jsx # Modal criar recorrência +│ │ ├── CurrencySelector.jsx +│ │ ├── Footer.jsx # Rodapé +│ │ ├── IconSelector.jsx +│ │ ├── LanguageSelector.jsx +│ │ ├── Layout.jsx # Menu com grupos colapsáveis +│ │ ├── Modal.jsx +│ │ ├── ProtectedRoute.jsx +│ │ ├── Toast.jsx +│ │ └── dashboard/ +│ │ ├── CalendarWidget.jsx # Calendário interativo +│ │ ├── CashflowChart.jsx # Gráfico fluxo de caixa +│ │ ├── OverdueWidget.jsx # Widget de vencidos +│ │ ├── OverpaymentsAnalysis.jsx # Análise sobrepagamentos +│ │ ├── PaymentVariancesChart.jsx # Variações de pagamento +│ │ └── UpcomingWidget.jsx # Próximos 7 dias +│ │ +│ ├── pages/ +│ │ ├── Accounts.jsx +│ │ ├── Categories.jsx +│ │ ├── CostCenters.jsx +│ │ ├── Dashboard.jsx # Inclui todos os widgets +│ │ ├── ImportTransactions.jsx +│ │ ├── LiabilityAccounts.jsx +│ │ ├── Login.jsx +│ │ ├── RecurringTransactions.jsx # Página de recorrentes +│ │ ├── RefundDetection.jsx # Detecção de reembolsos +│ │ ├── Register.jsx +│ │ ├── TransactionsByWeek.jsx +│ │ └── TransferDetection.jsx +│ │ +│ ├── services/ +│ │ └── api.js # Axios + todos services +│ │ +│ ├── i18n/ +│ │ ├── index.js # Config i18next + detecção país +│ │ └── locales/ +│ │ ├── es.json +│ │ ├── en.json +│ │ └── pt-BR.json +│ │ +│ ├── context/ +│ │ └── AuthContext.jsx +│ │ +│ └── App.jsx +│ +├── dist/ # Build de produção +└── deploy.sh # Script de deploy +``` + +--- + +## 🔗 Endpoints da API + +### Autenticação +| Método | Endpoint | Descrição | +|--------|----------|-----------| +| POST | `/api/register` | Criar conta | +| POST | `/api/login` | Login (retorna token) | +| POST | `/api/logout` | Logout | +| GET | `/api/user` | Usuário autenticado | + +### Contas Bancárias +| Método | Endpoint | Descrição | +|--------|----------|-----------| +| GET | `/api/accounts` | Listar contas | +| POST | `/api/accounts` | Criar conta | +| PUT | `/api/accounts/{id}` | Atualizar conta | +| DELETE | `/api/accounts/{id}` | Excluir conta | +| POST | `/api/accounts/{id}/recalculate-balance` | Recalcular saldo | + +### Transações +| Método | Endpoint | Descrição | +|--------|----------|-----------| +| GET | `/api/transactions` | Listar (com filtros) | +| GET | `/api/transactions/by-week` | Agrupadas por semana | +| POST | `/api/transactions` | Criar | +| PUT | `/api/transactions/{id}` | Atualizar | +| DELETE | `/api/transactions/{id}` | Excluir | +| POST | `/api/transactions/{id}/toggle-status` | Alternar status | +| POST | `/api/transactions/{id}/split` | Dividir transação | + +### Dashboard +| Método | Endpoint | Descrição | +|--------|----------|-----------| +| GET | `/api/dashboard/stats` | Estatísticas gerais | +| GET | `/api/dashboard/cashflow` | Fluxo de caixa mensal | +| GET | `/api/dashboard/overpayments` | Análise de sobrepagamentos | +| GET | `/api/dashboard/calendar` | Dados do calendário (mês) | +| GET | `/api/dashboard/calendar-day` | Transações de um dia | +| GET | `/api/dashboard/upcoming` | Próximos N dias pendentes | +| GET | `/api/dashboard/overdue` | Transações vencidas pendentes | + +### Transações Recorrentes +| Método | Endpoint | Descrição | +|--------|----------|-----------| +| GET | `/api/recurring` | Listar templates | +| POST | `/api/recurring` | Criar template | +| POST | `/api/recurring/from-transaction` | Criar de transação | +| GET | `/api/recurring/{id}` | Detalhe do template | +| PUT | `/api/recurring/{id}` | Atualizar template | +| DELETE | `/api/recurring/{id}` | Excluir template | +| POST | `/api/recurring/{id}/pause` | Pausar template | +| POST | `/api/recurring/{id}/resume` | Retomar template | +| GET | `/api/recurring/{id}/instances` | Listar parcelas | +| GET | `/api/recurring/pending` | Todas pendentes | +| GET | `/api/recurring/overdue` | Vencidas | +| GET | `/api/recurring/due-soon` | Próximas do vencimento | +| GET | `/api/recurring/frequencies` | Frequências disponíveis | +| POST | `/api/recurring-instances/{id}/pay` | Pagar (cria transação) | +| POST | `/api/recurring-instances/{id}/reconcile` | Conciliar | +| POST | `/api/recurring-instances/{id}/skip` | Pular | +| POST | `/api/recurring-instances/{id}/cancel` | Cancelar | +| GET | `/api/recurring-instances/{id}/candidates` | Transações candidatas | + +### Duplicatas +| Método | Endpoint | Descrição | +|--------|----------|-----------| +| GET | `/api/duplicates` | Listar grupos | +| POST | `/api/duplicates/{id}/ignore` | Ignorar par | +| DELETE | `/api/duplicates/{id}/auto-delete` | Auto-delete grupo | +| POST | `/api/duplicates/batch-ignore` | Ignorar em lote | +| DELETE | `/api/duplicates/batch-auto-delete` | Auto-delete em lote | + +### Transferências +| Método | Endpoint | Descrição | +|--------|----------|-----------| +| GET | `/api/transfers/potential` | Transferências potenciais | +| POST | `/api/transfers/confirm` | Confirmar transferência | +| POST | `/api/transfers/ignore` | Ignorar par | + +### Detecção de Reembolsos +| Método | Endpoint | Descrição | +|--------|----------|-----------| +| GET | `/api/refund-detection/potential` | Pares despesa/reembolso potenciais | +| POST | `/api/refund-detection/confirm` | Confirmar par de reembolso | +| POST | `/api/refund-detection/ignore` | Ignorar par | + +### Importação +| Método | Endpoint | Descrição | +|--------|----------|-----------| +| POST | `/api/import/parse` | Parsear arquivo | +| POST | `/api/import/execute` | Executar importação | + +--- + +## ��️ Banco de Dados + +### Tabelas Principais + +| Tabela | Descrição | +|--------|-----------| +| `users` | Usuários do sistema | +| `accounts` | Contas bancárias | +| `transactions` | Transações financeiras | +| `categories` | Categorias (175 pré-configuradas) | +| `category_keywords` | Keywords para auto-classificação | +| `cost_centers` | Centros de custo | +| `cost_center_keywords` | Keywords de centros de custo | +| `liability_accounts` | Contas passivo (financiamentos) | +| `liability_installments` | Parcelas de financiamentos | +| `recurring_templates` | Templates de transações recorrentes | +| `recurring_instances` | Parcelas/instâncias de recorrências | +| `import_logs` | Log de importações | +| `import_mappings` | Mapeamentos salvos | +| `ignored_duplicate_pairs` | Pares de duplicatas ignorados | +| `ignored_transfer_pairs` | Pares de transferências ignorados | +| `ignored_refund_pairs` | Pares de reembolso ignorados | + +### Campos Especiais de Transações + +| Campo | Tipo | Descrição | +|-------|------|-----------| +| `is_transfer` | boolean | É uma transferência | +| `transfer_pair_id` | int | ID da transação par | +| `is_refund_pair` | boolean | É parte de par reembolso | +| `refund_linked_id` | int | ID da transação vinculada (reembolso) | +| `is_split` | boolean | Foi dividida | +| `split_from_id` | int | ID da transação original | +| `recurring_instance_id` | int | ID da instância recorrente vinculada | +| `import_hash` | string | Hash SHA-256 para duplicidade | +| `original_description` | string | Descrição original do banco | + +--- + +## 🖥️ Servidor de Produção + +| Item | Valor | +|------|-------| +| **IP** | 213.165.93.60 | +| **Domínio** | webmoney.cnxifly.com | +| **Backend** | /var/www/webmoney/backend | +| **Frontend** | /var/www/webmoney/frontend/dist | +| **PHP** | 8.4-FPM | +| **Web Server** | Nginx | +| **SSL** | Let's Encrypt | +| **Sessões** | Redis | + +--- + +## 🔐 Segurança Implementada + +| Recurso | Configuração | +|---------|--------------| +| **Rate Limiting** | Login: 5/min, Register: 10/hour, API: 60/min | +| **CORS** | Restrito a webmoney.cnxifly.com | +| **Token Expiration** | 7 dias | +| **Cookies** | HttpOnly, Secure, SameSite=lax, Encrypt=true | +| **Headers** | X-XSS-Protection, X-Content-Type-Options, X-Frame-Options, CSP | + +--- + +## 📝 Comandos Úteis + +### Deploy +```bash +# Frontend +cd frontend && ./deploy.sh + +# Backend +cd backend && ./deploy.sh +``` + +### Conectar ao Servidor +```bash +sshpass -p 'Master9354' ssh root@213.165.93.60 +``` + +### Artisan no Servidor +```bash +cd /var/www/webmoney/backend && php artisan [comando] +``` diff --git a/README.md b/README.md new file mode 100644 index 0000000..55b456b --- /dev/null +++ b/README.md @@ -0,0 +1,114 @@ +# WEBMoney - ConneXiFly + +**Versão atual:** `1.27.1` +**Última atualização:** 13 de Dezembro de 2025 + +## 🌐 Visão Geral + +Sistema de gestão financeira pessoal com interface moderna e funcionalidades avançadas. + +| Componente | URL | +|------------|-----| +| **Aplicação** | https://webmoney.cnxifly.com | +| **API Backend** | https://webmoney.cnxifly.com/api | +| **phpMyAdmin** | https://phpmyadmin.cnxifly.com | +| **Webmail** | https://webmail.cnxifly.com | + +## 🛠️ Stack Tecnológica + +| Camada | Tecnologia | +|--------|------------| +| **Backend** | Laravel 12 + PHP 8.4 | +| **Frontend** | React 18 + Vite 7 + Bootstrap 5 | +| **Banco de Dados** | MariaDB 11.4 | +| **Cache/Sessões** | Redis | +| **Servidor** | Nginx + SSL (Let's Encrypt) | +| **Autenticação** | Laravel Sanctum (Bearer Tokens) | + +## ✅ Funcionalidades + +### Core +- 🔐 **Autenticação** - Login/registro com tokens seguros +- 📊 **Dashboard** - Gráficos de fluxo de caixa, calendário, próximos vencimentos e vencidos +- 💰 **Contas Bancárias** - CRUD com ícones, cores e multi-moeda +- 📁 **Categorias** - 175 categorias com auto-classificação por keywords +- 🏢 **Centros de Custo** - Organização por projetos/departamentos +- 💳 **Transações** - Agrupamento por semana, filtros avançados + +### Avançado +- 📥 **Importação de Extratos** - XLSX, XLS, CSV, OFX, PDF +- 🔍 **Detecção de Duplicatas** - Identificação inteligente com auto-delete +- 🔄 **Detecção de Transferências** - Identifica movimentações entre contas +- 💸 **Detecção de Reembolsos** - Identifica pares gasto/devolução que se anulam +- 🏦 **Contas Passivo** - Financiamentos e empréstimos com parcelas +- 🔁 **Transações Recorrentes** - Templates com geração automática de parcelas + +### Interface +- 🌙 **Tema Dark** - Interface profissional escura +- 🌍 **Multi-idioma** - ES, PT-BR, EN (detecção automática por país) +- 💱 **Multi-moeda** - 15 moedas suportadas +- 📱 **Responsivo** - Desktop e mobile +- 🍪 **Cookie Consent** - Banner LGPD/GDPR compliance + +### Segurança (v1.19.0) +- 🛡️ **Rate Limiting** - 5 tentativas de login/min +- 🔒 **Security Headers** - XSS, CSP, HSTS +- 🍪 **Cookie Hardening** - HttpOnly, Secure, SameSite +- 🌐 **CORS** - Restrito ao domínio de produção + +## 📦 Estrutura do Projeto + +``` +webmoney/ +├── backend/ # Laravel API +│ ├── app/ +│ │ ├── Http/Controllers/Api/ # Endpoints +│ │ ├── Models/ # Eloquent Models +│ │ └── Services/ # Lógica de negócio +│ ├── database/migrations/ # Schema do banco +│ └── routes/api.php # Rotas da API +│ +├── frontend/ # React SPA +│ ├── src/ +│ │ ├── components/ # Componentes reutilizáveis +│ │ ├── pages/ # Páginas da aplicação +│ │ ├── services/ # Chamadas API +│ │ └── i18n/ # Traduções +│ └── dist/ # Build de produção +│ +├── VERSION # Versão atual +├── CHANGELOG.md # Histórico de mudanças +└── ESTRUTURA_PROJETO.md # Documentação técnica detalhada +``` + +## 🚀 Deploy + +### Frontend +```bash +cd frontend && ./deploy.sh +``` + +### Backend +```bash +cd backend && ./deploy.sh +``` + +## 📖 Documentação + +| Arquivo | Descrição | +|---------|-----------| +| `CHANGELOG.md` | Histórico completo de versões | +| `ESTRUTURA_PROJETO.md` | Estrutura detalhada, endpoints, banco | +| `CREDENCIAIS_SERVIDOR.md` | Acessos ao servidor (confidencial) | +| `ESPECIFICACIONES_WEBMONEY.md` | Especificação funcional original | +| `.DIRETRIZES_DESENVOLVIMENTO_v2` | Regras de desenvolvimento | + +## 🔗 Links Úteis + +- **Servidor:** 213.165.93.60 +- **Domínio:** cnxifly.com +- **Repositório:** github.com/marcoitaloesp-ai/webmoney + +--- + +© 2025 ConneXiFly - WEBMoney diff --git a/ROTEIRO_INSTALACAO_SERVIDOR.md b/ROTEIRO_INSTALACAO_SERVIDOR.md new file mode 100644 index 0000000..525a72f --- /dev/null +++ b/ROTEIRO_INSTALACAO_SERVIDOR.md @@ -0,0 +1,2633 @@ +# 🚀 ROTEIRO DE INSTALAÇÃO DO SERVIDOR - WEBMoney/ConneXiFly + +**Versão:** 2.0.0 +**Data:** 7 de Dezembro de 2025 +**Servidor:** 213.165.93.60 +**Domínio:** cnxifly.com +**Senha Padrão:** `M@ster9354` + +> ⚠️ **IMPORTANTE:** Este roteiro deve ser executado **COMANDO A COMANDO** manualmente. +> Não usar scripts automatizados. Verificar cada passo antes de prosseguir. + +--- + +## 📦 VERSÕES INSTALADAS (Dezembro 2025) + +| Software | Versão | Fonte | +|----------|--------|-------| +| Ubuntu Server | 24.04.3 LTS | ISO oficial | +| Nginx | 1.29.3 (mainline) | Repo oficial Nginx | +| PHP | 8.4.15 | PPA ondrej/php | +| MariaDB | 11.4.9 LTS | Repo oficial MariaDB | +| Node.js | 22.21.0 LTS | NodeSource | +| PM2 | 6.0.14 | npm | +| Redis | 7.0.15 | Repo Ubuntu | +| Postfix | 3.8.6 | Repo Ubuntu | +| Dovecot | 2.3.21 | Repo Ubuntu | +| OpenDKIM | 2.11.x | Repo Ubuntu | +| Certbot | 5.2.1 | Snap | +| Composer | 2.9.2 | getcomposer.org | +| PostfixAdmin | 3.3.x | GitHub | +| Roundcube | 1.6.11 | GitHub | +| phpMyAdmin | 5.2.x | Repo Ubuntu | + +--- + +## 📋 ÍNDICE + +1. [Pré-requisitos e Preparação](#1-pré-requisitos-e-preparação) +2. [Fase 1: Sistema Base](#2-fase-1-sistema-base-15-min) +3. [Fase 2: Firewall e Segurança](#3-fase-2-firewall-e-segurança-5-min) +4. [Fase 3: MariaDB 11.4 LTS](#4-fase-3-mariadb-114-lts-10-min) +5. [Fase 4: PHP 8.4](#5-fase-4-php-84-10-min) +6. [Fase 5: Nginx Mainline](#6-fase-5-nginx-mainline-10-min) +7. [Fase 6: SSL/Let's Encrypt](#7-fase-6-ssllets-encrypt-5-min) +8. [Fase 7: Postfix + Dovecot + OpenDKIM](#8-fase-7-postfix--dovecot--opendkim-25-min) +9. [Fase 8: PostfixAdmin + Roundcube + phpMyAdmin](#9-fase-8-postfixadmin--roundcube--phpmyadmin-20-min) +10. [Fase 9: Node.js 22 LTS](#10-fase-9-nodejs-22-lts-5-min) +11. [Fase 10: Deploy WEBMoney](#11-fase-10-deploy-webmoney-15-min) +12. [Fase 11: Otimização de Desempenho](#12-fase-11-otimização-de-desempenho-20-min) +13. [Fase 12: Validação Final](#13-fase-12-validação-final) +14. [Comandos de Verificação](#14-comandos-de-verificação) +15. [Referência Rápida](#15-referência-rápida) + +--- + +## 1. PRÉ-REQUISITOS E PREPARAÇÃO + +### Informações do Projeto +| Item | Valor | +|------|-------| +| IP do Servidor | 213.165.93.60 | +| IPv6 | 2001:ba0:268:ab00::1 | +| Domínio Principal | cnxifly.com | +| Subdomínios | webmoney, mail, webmail, phpmyadmin | +| Timezone | Europe/Madrid | +| Locale | es_ES.UTF-8 | +| Senha Única | M@ster9354 | + +### Estrutura de Subdomínios Final +``` +cnxifly.com → Redirect para webmoney +www.cnxifly.com → Redirect para webmoney +webmoney.cnxifly.com → Aplicação WEBMoney (Laravel+React) +mail.cnxifly.com → PostfixAdmin (admin email) +webmail.cnxifly.com → Roundcube (webmail usuários) +phpmyadmin.cnxifly.com → phpMyAdmin (admin BD) +``` + +### DNS Necessários (verificar antes de começar) +```bash +# No painel DNS do registrador (UI-DNS), garantir: +dig cnxifly.com A +short # → 213.165.93.60 +dig mail.cnxifly.com A +short # → 213.165.93.60 +dig webmoney.cnxifly.com A +short # → 213.165.93.60 +dig webmail.cnxifly.com A +short # → 213.165.93.60 +dig phpmyadmin.cnxifly.com A +short # → 213.165.93.60 +dig cnxifly.com MX +short # → 10 mail.cnxifly.com +dig cnxifly.com TXT +short # → v=spf1 mx a ip4:213.165.93.60 -all +``` + +--- + +## 2. FASE 1: SISTEMA BASE (~15 min) + +### 2.1 Conectar ao Servidor +```bash +ssh root@213.165.93.60 +``` +Senha: `Master9354` + +### 2.2 Verificar Versão do Sistema +```bash +cat /etc/os-release +``` +Deve mostrar: Ubuntu 24.04.x LTS + +### 2.3 Atualizar Sistema Completamente +```bash +apt update +``` + +```bash +apt upgrade -y +``` + +```bash +apt dist-upgrade -y +``` + +```bash +apt autoremove -y +``` + +### 2.4 Instalar Pacotes Essenciais +```bash +apt install -y software-properties-common apt-transport-https ca-certificates curl wget gnupg lsb-release git unzip zip htop net-tools dnsutils vim nano +``` + +### 2.5 Configurar Timezone (CRÍTICO - FAZER PRIMEIRO) +```bash +timedatectl set-timezone Europe/Madrid +``` + +Verificar: +```bash +timedatectl +``` +Deve mostrar: `Time zone: Europe/Madrid` + +### 2.6 Configurar Locale UTF-8 (CRÍTICO - FAZER PRIMEIRO) +```bash +apt install -y locales +``` + +```bash +sed -i '/es_ES.UTF-8/s/^# //g' /etc/locale.gen +``` + +```bash +locale-gen es_ES.UTF-8 +``` + +```bash +update-locale LANG=es_ES.UTF-8 LC_ALL=es_ES.UTF-8 +``` + +Aplicar imediatamente na sessão atual: +```bash +export LANG=es_ES.UTF-8 +export LC_ALL=es_ES.UTF-8 +``` + +Verificar: +```bash +locale +``` + +### 2.7 Criar arquivo /etc/environment +```bash +cat > /etc/environment << 'EOF' +LANG=es_ES.UTF-8 +LC_ALL=es_ES.UTF-8 +LC_CTYPE=es_ES.UTF-8 +PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" +EOF +``` + +### 2.8 Configurar Hostname +```bash +hostnamectl set-hostname mail.cnxifly.com +``` + +```bash +echo "213.165.93.60 mail.cnxifly.com mail" >> /etc/hosts +``` + +Verificar: +```bash +hostname -f +``` +Deve mostrar: `mail.cnxifly.com` + +### 2.9 Reiniciar para Aplicar Todas as Configurações +```bash +reboot +``` + +Aguardar 30 segundos e reconectar: +```bash +ssh root@213.165.93.60 +``` + +--- + +## 3. FASE 2: FIREWALL E SEGURANÇA (~5 min) + +### 3.1 Configurar UFW +```bash +apt install -y ufw + +# Regras básicas +ufw default deny incoming +ufw default allow outgoing + +# SSH +ufw allow 22/tcp + +# HTTP/HTTPS +ufw allow 80/tcp +ufw allow 443/tcp + +# Email +ufw allow 25/tcp # SMTP +ufw allow 465/tcp # SMTPS +ufw allow 587/tcp # Submission +ufw allow 143/tcp # IMAP +ufw allow 993/tcp # IMAPS + +# Ativar +ufw --force enable +ufw status verbose +``` + +### 3.2 Criar Usuário Admin (opcional mas recomendado) +```bash +adduser admin +# Senha: M@ster9354 +usermod -aG sudo admin +``` + +--- + +## 4. FASE 3: MARIADB 11.4 LTS (~10 min) + +### 4.1 Adicionar Repositório Oficial MariaDB 11.4 +```bash +curl -fsSL https://mariadb.org/mariadb_release_signing_key.pgp | gpg --dearmor -o /usr/share/keyrings/mariadb-keyring.gpg +``` + +```bash +echo "deb [signed-by=/usr/share/keyrings/mariadb-keyring.gpg] https://dlm.mariadb.com/repo/mariadb-server/11.4/repo/ubuntu noble main" > /etc/apt/sources.list.d/mariadb.list +``` + +```bash +apt update +``` + +### 4.2 Instalar MariaDB 11.4 +```bash +apt install -y mariadb-server mariadb-client +``` + +Verificar versão: +```bash +mariadb --version +``` +Deve mostrar: `mariadb Ver 15.1 Distrib 11.4.x` + +### 4.3 Habilitar e Iniciar +```bash +systemctl enable mariadb +``` + +```bash +systemctl start mariadb +``` + +```bash +systemctl status mariadb +``` + +### 4.4 Configurar Charset UTF-8 e Performance +```bash +cat > /etc/mysql/mariadb.conf.d/99-custom.cnf << 'EOF' +[mysqld] +# Charset +character-set-server = utf8mb4 +collation-server = utf8mb4_unicode_ci +skip-character-set-client-handshake + +# InnoDB Performance +innodb_buffer_pool_size = 256M +innodb_log_file_size = 64M +innodb_flush_log_at_trx_commit = 2 +innodb_flush_method = O_DIRECT + +# Query Cache (deprecated but still useful) +query_cache_type = 1 +query_cache_size = 32M +query_cache_limit = 2M + +# Connections +max_connections = 100 +wait_timeout = 600 +interactive_timeout = 600 + +# Temp tables +tmp_table_size = 64M +max_heap_table_size = 64M + +# Logging +slow_query_log = 1 +slow_query_log_file = /var/log/mysql/slow.log +long_query_time = 2 + +[client] +default-character-set = utf8mb4 + +[mysql] +default-character-set = utf8mb4 +EOF +``` + +```bash +systemctl restart mariadb +``` + +### 4.5 Executar Segurança Inicial +```bash +mariadb-secure-installation +``` + +Responder: +- Enter current password for root: (pressionar Enter, está vazio) +- Switch to unix_socket authentication: **n** +- Change the root password: **Y** → Digite: `M@ster9354` +- Remove anonymous users: **Y** +- Disallow root login remotely: **Y** +- Remove test database: **Y** +- Reload privilege tables: **Y** + +### 4.6 Criar Usuários e Bancos +```bash +mariadb -u root -p'M@ster9354' +``` + +Dentro do MariaDB, executar cada comando: +```sql +-- Usuário admin geral +CREATE USER 'admin'@'localhost' IDENTIFIED BY 'M@ster9354'; +GRANT ALL PRIVILEGES ON *.* TO 'admin'@'localhost' WITH GRANT OPTION; + +-- Banco WEBMoney +CREATE DATABASE webmoney CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +CREATE USER 'webmoney'@'localhost' IDENTIFIED BY 'M@ster9354'; +GRANT ALL PRIVILEGES ON webmoney.* TO 'webmoney'@'localhost'; + +-- Banco PostfixAdmin +CREATE DATABASE postfixadmin CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +CREATE USER 'postfixadmin'@'localhost' IDENTIFIED BY 'M@ster9354'; +GRANT ALL PRIVILEGES ON postfixadmin.* TO 'postfixadmin'@'localhost'; + +-- Banco Roundcube +CREATE DATABASE roundcube CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +CREATE USER 'roundcube'@'localhost' IDENTIFIED BY 'M@ster9354'; +GRANT ALL PRIVILEGES ON roundcube.* TO 'roundcube'@'localhost'; + +-- Banco phpMyAdmin +CREATE DATABASE phpmyadmin CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +CREATE USER 'phpmyadmin'@'localhost' IDENTIFIED BY 'M@ster9354'; +GRANT ALL PRIVILEGES ON phpmyadmin.* TO 'phpmyadmin'@'localhost'; + +FLUSH PRIVILEGES; +EXIT; +``` + +### 4.7 Verificar +```bash +mariadb -u admin -p'M@ster9354' -e "SHOW DATABASES;" +``` + +```bash +mariadb -u admin -p'M@ster9354' -e "SHOW VARIABLES LIKE 'character%';" +``` + +```bash +mariadb -u admin -p'M@ster9354' -e "SELECT VERSION();" +``` + +--- + +## 5. FASE 4: PHP 8.4 (~10 min) + +### 5.1 Adicionar Repositório PHP (ondrej/php) +```bash +add-apt-repository -y ppa:ondrej/php +``` + +```bash +apt update +``` + +### 5.2 Instalar PHP 8.4 e Extensões +```bash +apt install -y php8.4-fpm php8.4-cli php8.4-common +``` + +```bash +apt install -y php8.4-mysql php8.4-pgsql php8.4-sqlite3 +``` + +```bash +apt install -y php8.4-curl php8.4-gd php8.4-mbstring php8.4-xml php8.4-zip +``` + +```bash +apt install -y php8.4-bcmath php8.4-intl php8.4-soap php8.4-imap +``` + +```bash +apt install -y php8.4-ldap php8.4-imagick php8.4-redis php8.4-opcache +``` + +Verificar versão: +```bash +php -v +``` +Deve mostrar: `PHP 8.4.x` + +### 5.3 Configurar PHP-FPM (php.ini) +```bash +sed -i 's/;date.timezone =/date.timezone = Europe\/Madrid/' /etc/php/8.4/fpm/php.ini +``` + +```bash +sed -i 's/upload_max_filesize = 2M/upload_max_filesize = 64M/' /etc/php/8.4/fpm/php.ini +``` + +```bash +sed -i 's/post_max_size = 8M/post_max_size = 64M/' /etc/php/8.4/fpm/php.ini +``` + +```bash +sed -i 's/memory_limit = 128M/memory_limit = 256M/' /etc/php/8.4/fpm/php.ini +``` + +```bash +sed -i 's/max_execution_time = 30/max_execution_time = 300/' /etc/php/8.4/fpm/php.ini +``` + +```bash +sed -i 's/;max_input_vars = 1000/max_input_vars = 5000/' /etc/php/8.4/fpm/php.ini +``` + +### 5.4 Configurar PHP CLI +```bash +sed -i 's/;date.timezone =/date.timezone = Europe\/Madrid/' /etc/php/8.4/cli/php.ini +``` + +### 5.5 Configurar OPcache para Performance +```bash +cat > /etc/php/8.4/mods-available/opcache-custom.ini << 'EOF' +opcache.enable=1 +opcache.memory_consumption=128 +opcache.interned_strings_buffer=16 +opcache.max_accelerated_files=10000 +opcache.revalidate_freq=2 +opcache.save_comments=1 +opcache.enable_cli=0 +opcache.jit=1255 +opcache.jit_buffer_size=128M +EOF +``` + +```bash +ln -sf /etc/php/8.4/mods-available/opcache-custom.ini /etc/php/8.4/fpm/conf.d/99-opcache-custom.ini +``` + +### 5.6 Configurar PHP-FPM Pool para Performance +```bash +sed -i 's/pm = dynamic/pm = ondemand/' /etc/php/8.4/fpm/pool.d/www.conf +``` + +```bash +sed -i 's/pm.max_children = 5/pm.max_children = 20/' /etc/php/8.4/fpm/pool.d/www.conf +``` + +```bash +sed -i 's/;pm.process_idle_timeout = 10s/pm.process_idle_timeout = 10s/' /etc/php/8.4/fpm/pool.d/www.conf +``` + +### 5.7 Habilitar e Reiniciar PHP-FPM +```bash +systemctl enable php8.4-fpm +``` + +```bash +systemctl restart php8.4-fpm +``` + +```bash +systemctl status php8.4-fpm +``` + +### 5.8 Instalar Composer (Última Versão) +```bash +curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer +``` + +Verificar: +```bash +composer --version +``` +Deve mostrar: `Composer version 2.8.x` + +--- + +## 6. FASE 5: NGINX MAINLINE (~10 min) + +### 6.1 Remover Nginx do Repo Ubuntu (se existir) +```bash +apt remove --purge -y nginx nginx-common nginx-full 2>/dev/null || true +``` + +### 6.2 Adicionar Repositório Oficial Nginx (Mainline) +```bash +curl -fsSL https://nginx.org/keys/nginx_signing.key | gpg --dearmor -o /usr/share/keyrings/nginx-keyring.gpg +``` + +```bash +echo "deb [signed-by=/usr/share/keyrings/nginx-keyring.gpg] http://nginx.org/packages/mainline/ubuntu noble nginx" > /etc/apt/sources.list.d/nginx.list +``` + +```bash +apt update +``` + +### 6.3 Instalar Nginx Mainline +```bash +apt install -y nginx +``` + +Verificar versão: +```bash +nginx -v +``` +Deve mostrar: `nginx version: nginx/1.27.x` + +### 6.4 Criar Estrutura de Diretórios +```bash +mkdir -p /etc/nginx/sites-available +``` + +```bash +mkdir -p /etc/nginx/sites-enabled +``` + +```bash +mkdir -p /var/www/webmoney/public +``` + +```bash +mkdir -p /var/www/html +``` + +```bash +chown -R www-data:www-data /var/www +``` + +### 6.5 Configuração Base Nginx (Otimizada) +```bash +cat > /etc/nginx/nginx.conf << 'EOF' +user www-data; +worker_processes auto; +worker_rlimit_nofile 65535; +pid /run/nginx.pid; +error_log /var/log/nginx/error.log warn; + +events { + worker_connections 4096; + multi_accept on; + use epoll; +} + +http { + # Basic + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + keepalive_requests 1000; + types_hash_max_size 2048; + server_tokens off; + + # MIME + include /etc/nginx/mime.types; + default_type application/octet-stream; + + # Charset UTF-8 + charset utf-8; + charset_types text/html text/xml text/plain text/css text/javascript application/json application/javascript; + + # SSL Global Settings + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305; + ssl_prefer_server_ciphers off; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 1d; + ssl_session_tickets off; + ssl_stapling on; + ssl_stapling_verify on; + resolver 8.8.8.8 8.8.4.4 valid=300s; + resolver_timeout 5s; + + # Gzip Compression + gzip on; + gzip_vary on; + gzip_proxied any; + gzip_comp_level 5; + gzip_min_length 256; + gzip_types + application/atom+xml + application/javascript + application/json + application/ld+json + application/manifest+json + application/rss+xml + application/vnd.geo+json + application/vnd.ms-fontobject + application/x-font-ttf + application/x-web-app-manifest+json + application/xhtml+xml + application/xml + font/opentype + image/bmp + image/svg+xml + image/x-icon + text/cache-manifest + text/css + text/plain + text/vcard + text/vnd.rim.location.xloc + text/vtt + text/x-component + text/x-cross-domain-policy + text/xml; + + # Buffers + client_body_buffer_size 10K; + client_header_buffer_size 1k; + client_max_body_size 64M; + large_client_header_buffers 4 32k; + + # Timeouts + client_body_timeout 12; + client_header_timeout 12; + send_timeout 10; + + # FastCGI Cache + fastcgi_cache_path /var/cache/nginx levels=1:2 keys_zone=FASTCGI:100m inactive=60m; + fastcgi_cache_key "$scheme$request_method$host$request_uri"; + + # Open File Cache + open_file_cache max=10000 inactive=20s; + open_file_cache_valid 30s; + open_file_cache_min_uses 2; + open_file_cache_errors on; + + # Logging + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for" ' + 'rt=$request_time uct="$upstream_connect_time" ' + 'uht="$upstream_header_time" urt="$upstream_response_time"'; + access_log /var/log/nginx/access.log main; + + # Rate Limiting + limit_req_zone $binary_remote_addr zone=one:10m rate=10r/s; + limit_conn_zone $binary_remote_addr zone=addr:10m; + + # Includes + include /etc/nginx/conf.d/*.conf; + include /etc/nginx/sites-enabled/*; +} +EOF +``` + +### 6.6 Criar Diretório de Cache +```bash +mkdir -p /var/cache/nginx +``` + +```bash +chown -R www-data:www-data /var/cache/nginx +``` + +### 6.7 Criar Vhost Temporário (para Certbot) +```bash +cat > /etc/nginx/sites-available/temp-http.conf << 'EOF' +server { + listen 80; + listen [::]:80; + server_name cnxifly.com www.cnxifly.com webmoney.cnxifly.com mail.cnxifly.com webmail.cnxifly.com phpmyadmin.cnxifly.com; + root /var/www/html; + + location /.well-known/acme-challenge/ { + root /var/www/html; + } + + location / { + return 200 'Server ready for SSL'; + add_header Content-Type text/plain; + } +} +EOF +``` + +```bash +ln -sf /etc/nginx/sites-available/temp-http.conf /etc/nginx/sites-enabled/ +``` + +### 6.8 Testar e Iniciar Nginx +```bash +nginx -t +``` + +```bash +systemctl enable nginx +``` + +```bash +systemctl start nginx +``` + +```bash +systemctl status nginx +``` + +--- + +## 7. FASE 6: SSL/LET'S ENCRYPT (~5 min) + +### 7.1 Instalar Certbot via Snap (Última Versão) +```bash +apt install -y snapd +``` + +```bash +snap install core +``` + +```bash +snap refresh core +``` + +```bash +snap install --classic certbot +``` + +```bash +ln -sf /snap/bin/certbot /usr/bin/certbot +``` + +Verificar versão: +```bash +certbot --version +``` + +### 7.2 Obter Certificado (todos os domínios) +```bash +certbot certonly --webroot -w /var/www/html \ + -d cnxifly.com \ + -d www.cnxifly.com \ + -d webmoney.cnxifly.com \ + -d mail.cnxifly.com \ + -d webmail.cnxifly.com \ + -d phpmyadmin.cnxifly.com \ + --email admin@cnxifly.com \ + --agree-tos \ + --no-eff-email +``` + +### 7.3 Verificar Certificado +```bash +certbot certificates +``` + +### 7.4 Configurar Renovação Automática +```bash +systemctl enable snap.certbot.renew.timer +``` + +```bash +systemctl start snap.certbot.renew.timer +``` + +### 7.5 Testar Renovação +```bash +certbot renew --dry-run +``` + +### 7.6 Criar Hook para Reload do Nginx +```bash +cat > /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh << 'EOF' +#!/bin/bash +systemctl reload nginx +EOF +``` + +```bash +chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh +``` + +### 7.7 Remover Vhost Temporário +```bash +rm /etc/nginx/sites-enabled/temp-http.conf +``` + +--- + +## 8. FASE 7: POSTFIX + DOVECOT + OPENDKIM (~25 min) + +### 8.1 Pré-configurar Postfix (evitar prompts) +```bash +debconf-set-selections <<< "postfix postfix/mailname string cnxifly.com" +``` + +```bash +debconf-set-selections <<< "postfix postfix/main_mailer_type string 'Internet Site'" +``` + +### 8.2 Instalar Postfix +```bash +apt install -y postfix postfix-mysql +``` + +Verificar versão: +```bash +postconf mail_version +``` + +### 8.3 Instalar Dovecot +```bash +apt install -y dovecot-core dovecot-imapd dovecot-lmtpd dovecot-mysql dovecot-pop3d +``` + +Verificar versão: +```bash +dovecot --version +``` + +### 8.4 Instalar OpenDKIM +```bash +apt install -y opendkim opendkim-tools +``` + +Verificar versão: +```bash +opendkim -V +``` + +### 8.5 Configurar OpenDKIM + +Criar diretórios: +```bash +mkdir -p /etc/opendkim/keys/cnxifly.com +``` + +Gerar chaves DKIM (2048 bits): +```bash +opendkim-genkey -b 2048 -d cnxifly.com -D /etc/opendkim/keys/cnxifly.com -s default -v +``` + +Ajustar permissões: +```bash +chown -R opendkim:opendkim /etc/opendkim +``` + +```bash +chmod 600 /etc/opendkim/keys/cnxifly.com/default.private +``` + +Configurar opendkim.conf: +```bash +cat > /etc/opendkim.conf << 'EOF' +Syslog yes +SyslogSuccess yes +LogWhy yes +Canonicalization relaxed/simple +Mode sv +SubDomains no +OversignHeaders From +AutoRestart yes +AutoRestartRate 10/1M +Background yes +DNSTimeout 5 +SignatureAlgorithm rsa-sha256 + +KeyTable /etc/opendkim/key.table +SigningTable refile:/etc/opendkim/signing.table +ExternalIgnoreList /etc/opendkim/trusted.hosts +InternalHosts /etc/opendkim/trusted.hosts + +Socket local:/var/spool/postfix/opendkim/opendkim.sock +PidFile /run/opendkim/opendkim.pid +UMask 007 +UserID opendkim +EOF +``` + +Criar key.table: +```bash +cat > /etc/opendkim/key.table << 'EOF' +default._domainkey.cnxifly.com cnxifly.com:default:/etc/opendkim/keys/cnxifly.com/default.private +EOF +``` + +Criar signing.table: +```bash +cat > /etc/opendkim/signing.table << 'EOF' +*@cnxifly.com default._domainkey.cnxifly.com +EOF +``` + +Criar trusted.hosts: +```bash +cat > /etc/opendkim/trusted.hosts << 'EOF' +127.0.0.1 +localhost +cnxifly.com +mail.cnxifly.com +EOF +``` + +Criar diretório do socket: +```bash +mkdir -p /var/spool/postfix/opendkim +``` + +```bash +chown opendkim:postfix /var/spool/postfix/opendkim +``` + +```bash +chmod 750 /var/spool/postfix/opendkim +``` + +```bash +usermod -aG opendkim postfix +``` + +Iniciar OpenDKIM: +```bash +systemctl enable opendkim +``` + +```bash +systemctl restart opendkim +``` + +```bash +systemctl status opendkim +``` + +### 8.6 Mostrar Chave DKIM para DNS +```bash +echo "============================================" +echo "ADICIONAR ESTE REGISTRO TXT NO DNS:" +echo "============================================" +echo "Nome: default._domainkey.cnxifly.com" +echo "Tipo: TXT" +echo "Valor:" +cat /etc/opendkim/keys/cnxifly.com/default.txt +echo "============================================" +``` + +> ⚠️ **IMPORTANTE:** Copie o conteúdo e adicione no painel DNS AGORA. A propagação pode levar até 24h. + +### 8.7 Configurar Postfix main.cf +```bash +cat > /etc/postfix/main.cf << 'EOF' +# Basic +smtpd_banner = $myhostname ESMTP +biff = no +append_dot_mydomain = no +readme_directory = no +compatibility_level = 3.6 + +# Hostname +myhostname = mail.cnxifly.com +mydomain = cnxifly.com +myorigin = $mydomain +mydestination = localhost +mynetworks = 127.0.0.0/8 [::ffff:127.0.0.0]/104 [::1]/128 + +# Virtual mailbox +virtual_mailbox_domains = mysql:/etc/postfix/mysql-virtual-mailbox-domains.cf +virtual_mailbox_maps = mysql:/etc/postfix/mysql-virtual-mailbox-maps.cf +virtual_alias_maps = mysql:/etc/postfix/mysql-virtual-alias-maps.cf +virtual_transport = lmtp:unix:private/dovecot-lmtp + +# TLS Incoming +smtpd_tls_cert_file = /etc/letsencrypt/live/cnxifly.com/fullchain.pem +smtpd_tls_key_file = /etc/letsencrypt/live/cnxifly.com/privkey.pem +smtpd_tls_security_level = may +smtpd_tls_protocols = !SSLv2, !SSLv3, !TLSv1, !TLSv1.1 +smtpd_tls_mandatory_protocols = !SSLv2, !SSLv3, !TLSv1, !TLSv1.1 + +# TLS Outgoing +smtp_tls_security_level = may +smtp_tls_protocols = !SSLv2, !SSLv3, !TLSv1, !TLSv1.1 + +# SASL Auth +smtpd_sasl_type = dovecot +smtpd_sasl_path = private/auth +smtpd_sasl_auth_enable = yes +smtpd_sasl_security_options = noanonymous +smtpd_sasl_local_domain = $myhostname + +# Restrictions +smtpd_recipient_restrictions = permit_mynetworks, permit_sasl_authenticated, reject_unauth_destination +smtpd_relay_restrictions = permit_mynetworks, permit_sasl_authenticated, reject_unauth_destination + +# OpenDKIM +milter_default_action = accept +milter_protocol = 6 +smtpd_milters = unix:/var/spool/postfix/opendkim/opendkim.sock +non_smtpd_milters = $smtpd_milters + +# Limits +mailbox_size_limit = 0 +message_size_limit = 52428800 +recipient_delimiter = + + +# Queue +maximal_queue_lifetime = 1d +bounce_queue_lifetime = 1d +EOF +``` + +### 8.8 Configurar Postfix MySQL Maps + +Criar mysql-virtual-mailbox-domains.cf: +```bash +cat > /etc/postfix/mysql-virtual-mailbox-domains.cf << 'EOF' +user = postfixadmin +password = M@ster9354 +hosts = localhost +dbname = postfixadmin +query = SELECT domain FROM domain WHERE domain='%s' AND active = '1' +EOF +``` + +Criar mysql-virtual-mailbox-maps.cf: +```bash +cat > /etc/postfix/mysql-virtual-mailbox-maps.cf << 'EOF' +user = postfixadmin +password = M@ster9354 +hosts = localhost +dbname = postfixadmin +query = SELECT maildir FROM mailbox WHERE username='%s' AND active = '1' +EOF +``` + +Criar mysql-virtual-alias-maps.cf: +```bash +cat > /etc/postfix/mysql-virtual-alias-maps.cf << 'EOF' +user = postfixadmin +password = M@ster9354 +hosts = localhost +dbname = postfixadmin +query = SELECT goto FROM alias WHERE address='%s' AND active = '1' +EOF +``` + +Ajustar permissões: +```bash +chmod 640 /etc/postfix/mysql-*.cf +``` + +```bash +chown root:postfix /etc/postfix/mysql-*.cf +``` + +### 8.9 Configurar Postfix master.cf (submission/smtps) +```bash +cat >> /etc/postfix/master.cf << 'EOF' + +submission inet n - y - - smtpd + -o syslog_name=postfix/submission + -o smtpd_tls_security_level=encrypt + -o smtpd_sasl_auth_enable=yes + -o smtpd_tls_auth_only=yes + -o smtpd_reject_unlisted_recipient=no + -o smtpd_recipient_restrictions=permit_sasl_authenticated,reject + -o milter_macro_daemon_name=ORIGINATING + +smtps inet n - y - - smtpd + -o syslog_name=postfix/smtps + -o smtpd_tls_wrappermode=yes + -o smtpd_sasl_auth_enable=yes + -o smtpd_reject_unlisted_recipient=no + -o smtpd_recipient_restrictions=permit_sasl_authenticated,reject + -o milter_macro_daemon_name=ORIGINATING +EOF +``` + +### 8.10 Configurar Dovecot + +Criar usuário e diretório de mail: +```bash +mkdir -p /var/mail/vhosts/cnxifly.com +``` + +```bash +groupadd -g 5000 vmail +``` + +```bash +useradd -g vmail -u 5000 vmail -d /var/mail/vhosts -s /usr/sbin/nologin +``` + +```bash +chown -R vmail:vmail /var/mail/vhosts +``` + +Configurar dovecot.conf: +```bash +cat > /etc/dovecot/dovecot.conf << 'EOF' +protocols = imap lmtp pop3 +listen = *, :: +dict { +} +!include conf.d/*.conf +EOF +``` + +Configurar 10-auth.conf: +```bash +cat > /etc/dovecot/conf.d/10-auth.conf << 'EOF' +disable_plaintext_auth = yes +auth_mechanisms = plain login +!include auth-sql.conf.ext +EOF +``` + +Configurar 10-mail.conf: +```bash +cat > /etc/dovecot/conf.d/10-mail.conf << 'EOF' +mail_location = maildir:/var/mail/vhosts/%d/%n +mail_uid = vmail +mail_gid = vmail +mail_privileged_group = vmail +first_valid_uid = 5000 +last_valid_uid = 5000 +EOF +``` + +Configurar 10-master.conf: +```bash +cat > /etc/dovecot/conf.d/10-master.conf << 'EOF' +service imap-login { + inet_listener imap { + port = 143 + } + inet_listener imaps { + port = 993 + ssl = yes + } +} + +service pop3-login { + inet_listener pop3 { + port = 110 + } + inet_listener pop3s { + port = 995 + ssl = yes + } +} + +service lmtp { + unix_listener /var/spool/postfix/private/dovecot-lmtp { + mode = 0600 + user = postfix + group = postfix + } +} + +service auth { + unix_listener /var/spool/postfix/private/auth { + mode = 0666 + user = postfix + group = postfix + } + unix_listener auth-userdb { + mode = 0600 + user = vmail + } + user = dovecot +} + +service auth-worker { + user = vmail +} + +service stats { + unix_listener stats-reader { + user = vmail + group = vmail + mode = 0660 + } + unix_listener stats-writer { + user = vmail + group = vmail + mode = 0660 + } +} +EOF +``` + +Configurar 10-ssl.conf: +```bash +cat > /etc/dovecot/conf.d/10-ssl.conf << 'EOF' +ssl = required +ssl_cert = /etc/dovecot/conf.d/auth-sql.conf.ext << 'EOF' +passdb { + driver = sql + args = /etc/dovecot/dovecot-sql.conf.ext +} +userdb { + driver = sql + args = /etc/dovecot/dovecot-sql.conf.ext +} +EOF +``` + +Configurar dovecot-sql.conf.ext: +```bash +cat > /etc/dovecot/dovecot-sql.conf.ext << 'EOF' +driver = mysql +connect = host=localhost dbname=postfixadmin user=postfixadmin password=M@ster9354 +default_pass_scheme = SHA512-CRYPT +password_query = SELECT username AS user, password FROM mailbox WHERE username = '%u' AND active = '1' +user_query = SELECT CONCAT('/var/mail/vhosts/', maildir) AS home, 5000 AS uid, 5000 AS gid FROM mailbox WHERE username = '%u' AND active = '1' +iterate_query = SELECT username AS user FROM mailbox WHERE active = '1' +EOF +``` + +Ajustar permissões: +```bash +chmod 600 /etc/dovecot/dovecot-sql.conf.ext +``` + +```bash +chown root:root /etc/dovecot/dovecot-sql.conf.ext +``` + +Permitir www-data usar doveadm (para PostfixAdmin): +```bash +usermod -aG dovecot www-data +``` + +### 8.11 Iniciar Serviços de Email +```bash +systemctl enable dovecot +``` + +```bash +systemctl restart dovecot +``` + +```bash +systemctl status dovecot +``` + +```bash +systemctl restart postfix +``` + +```bash +systemctl status postfix +``` + +--- + +## 9. FASE 8: POSTFIXADMIN + ROUNDCUBE + PHPMYADMIN (~20 min) + +### 9.1 Instalar PostfixAdmin +```bash +apt install -y postfixadmin +``` + +Durante a instalação, se aparecer prompt do dbconfig: +- Configure database: **Yes** +- Database type: **mysql** +- Password: **M@ster9354** + +Criar link simbólico: +```bash +ln -sf /usr/share/postfixadmin/public /var/www/postfixadmin +``` + +### 9.2 Configurar PostfixAdmin + +Gerar hash da senha de setup: +```bash +SETUP_HASH=$(php -r "echo password_hash('M@ster9354', PASSWORD_BCRYPT);") +echo $SETUP_HASH +``` + +Criar config.local.php: +```bash +cat > /etc/postfixadmin/config.local.php << 'EOFPHP' + 'admin@cnxifly.com', + 'hostmaster' => 'admin@cnxifly.com', + 'postmaster' => 'admin@cnxifly.com', + 'webmaster' => 'admin@cnxifly.com' +); + +$CONF['domain_path'] = 'YES'; +$CONF['domain_in_mailbox'] = 'NO'; + +$CONF['forgotten_admin_password_reset'] = false; +$CONF['alias_domain'] = 'YES'; + +$CONF['password_validation'] = array( + '/^.{8,}$/' => 'password_too_short 8' +); + +$CONF['default_language'] = 'es'; +EOFPHP +``` + +Agora adicionar o hash de setup (copie o valor de $SETUP_HASH): +```bash +echo "\$CONF['setup_password'] = '$(php -r "echo password_hash('M@ster9354', PASSWORD_BCRYPT);")'; " >> /etc/postfixadmin/config.local.php +``` + +Ajustar permissões: +```bash +chown root:www-data /etc/postfixadmin/config.local.php +``` + +```bash +chmod 640 /etc/postfixadmin/config.local.php +``` + +### 9.3 Instalar Roundcube +```bash +apt install -y roundcube roundcube-mysql roundcube-plugins +``` + +Durante a instalação, se aparecer prompt do dbconfig: +- Configure database: **Yes** +- Database type: **mysql** +- Password: **M@ster9354** + +### 9.4 Configurar Roundcube + +Gerar chave aleatória: +```bash +DES_KEY=$(openssl rand -base64 24) +echo "DES_KEY gerada: $DES_KEY" +``` + +Criar configuração (substituir DES_KEY pelo valor gerado): +```bash +cat > /etc/roundcube/config.inc.php << 'EOF' + array( + 'verify_peer' => false, + 'verify_peer_name' => false, + ), +); + +// SMTP +$config['smtp_server'] = 'localhost'; +$config['smtp_port'] = 587; +$config['smtp_user'] = '%u'; +$config['smtp_pass'] = '%p'; +$config['smtp_auth_type'] = 'PLAIN'; +$config['smtp_conn_options'] = array( + 'ssl' => array( + 'verify_peer' => false, + 'verify_peer_name' => false, + ), +); + +// General +$config['product_name'] = 'WEBMoney Mail'; +$config['skin'] = 'elastic'; +$config['language'] = 'es_ES'; +$config['timezone'] = 'Europe/Madrid'; + +// Plugins +$config['plugins'] = array( + 'archive', + 'zipdownload', + 'password', +); + +// Misc +$config['draft_autosave'] = 60; +$config['enable_spellcheck'] = false; +$config['mail_pagesize'] = 50; +$config['addressbook_pagesize'] = 50; +EOF +``` + +Adicionar DES_KEY (substitua pelo valor gerado): +```bash +echo "\$config['des_key'] = '$(openssl rand -base64 24)';" >> /etc/roundcube/config.inc.php +``` + +Ajustar permissões: +```bash +chown root:www-data /etc/roundcube/config.inc.php +``` + +```bash +chmod 640 /etc/roundcube/config.inc.php +``` + +### 9.5 Instalar phpMyAdmin + +Pré-configurar para evitar prompts: +```bash +debconf-set-selections <<< "phpmyadmin phpmyadmin/dbconfig-install boolean true" +``` + +```bash +debconf-set-selections <<< "phpmyadmin phpmyadmin/mysql/admin-pass password M@ster9354" +``` + +```bash +debconf-set-selections <<< "phpmyadmin phpmyadmin/mysql/app-pass password M@ster9354" +``` + +```bash +debconf-set-selections <<< "phpmyadmin phpmyadmin/reconfigure-webserver multiselect none" +``` + +```bash +apt install -y phpmyadmin +``` + +### 9.6 Configurar phpMyAdmin + +Gerar blowfish_secret: +```bash +BLOWFISH=$(openssl rand -base64 32) +echo "Blowfish Secret: $BLOWFISH" +``` + +Criar configuração: +```bash +cat > /etc/phpmyadmin/config.inc.php << 'EOF' +> /etc/phpmyadmin/config.inc.php +``` + +Importar tabelas de controle: +```bash +mysql -u phpmyadmin -p'M@ster9354' phpmyadmin < /usr/share/phpmyadmin/sql/create_tables.sql 2>/dev/null || true +``` + +### 9.7 Criar Vhosts Nginx Finais + +**webmoney.cnxifly.com (Aplicação principal):** +```bash +cat > /etc/nginx/sites-available/webmoney.cnxifly.com << 'EOF' +server { + listen 80; + listen [::]:80; + server_name webmoney.cnxifly.com cnxifly.com www.cnxifly.com; + return 301 https://webmoney.cnxifly.com$request_uri; +} + +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name webmoney.cnxifly.com cnxifly.com www.cnxifly.com; + + root /var/www/webmoney/public; + index index.php index.html; + + ssl_certificate /etc/letsencrypt/live/cnxifly.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/cnxifly.com/privkey.pem; + + # Security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + + location / { + try_files $uri $uri/ /index.php?$query_string; + } + + location ~ \.php$ { + fastcgi_pass unix:/run/php/php8.4-fpm.sock; + fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; + include fastcgi_params; + fastcgi_hide_header X-Powered-By; + } + + location ~ /\.(?!well-known).* { + deny all; + } +} +EOF +``` + +**mail.cnxifly.com (PostfixAdmin):** +```bash +cat > /etc/nginx/sites-available/mail.cnxifly.com << 'EOF' +server { + listen 80; + listen [::]:80; + server_name mail.cnxifly.com; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name mail.cnxifly.com; + + root /usr/share/postfixadmin/public; + index index.php; + + ssl_certificate /etc/letsencrypt/live/cnxifly.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/cnxifly.com/privkey.pem; + + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + + location / { + try_files $uri $uri/ /index.php?$query_string; + } + + location ~ \.php$ { + fastcgi_pass unix:/run/php/php8.4-fpm.sock; + fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; + include fastcgi_params; + } + + location ~ /\.(?!well-known).* { + deny all; + } +} +EOF +``` + +**webmail.cnxifly.com (Roundcube):** +```bash +cat > /etc/nginx/sites-available/webmail.cnxifly.com << 'EOF' +server { + listen 80; + listen [::]:80; + server_name webmail.cnxifly.com; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name webmail.cnxifly.com; + + root /usr/share/roundcube; + index index.php; + + ssl_certificate /etc/letsencrypt/live/cnxifly.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/cnxifly.com/privkey.pem; + + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + + location / { + try_files $uri $uri/ /index.php?$query_string; + } + + location ~ \.php$ { + fastcgi_pass unix:/run/php/php8.4-fpm.sock; + fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; + include fastcgi_params; + } + + location ~ /\.(?!well-known).* { + deny all; + } + + location ~ ^/(config|temp|logs)/ { + deny all; + } +} +EOF +``` + +**phpmyadmin.cnxifly.com:** +```bash +cat > /etc/nginx/sites-available/phpmyadmin.cnxifly.com << 'EOF' +server { + listen 80; + listen [::]:80; + server_name phpmyadmin.cnxifly.com; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name phpmyadmin.cnxifly.com; + + root /usr/share/phpmyadmin; + index index.php; + + ssl_certificate /etc/letsencrypt/live/cnxifly.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/cnxifly.com/privkey.pem; + + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + + location / { + try_files $uri $uri/ /index.php?$query_string; + } + + location ~ \.php$ { + fastcgi_pass unix:/run/php/php8.4-fpm.sock; + fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; + include fastcgi_params; + } + + location ~ /\.(?!well-known).* { + deny all; + } +} +EOF +``` + +### 9.8 Ativar Sites Nginx + +```bash +ln -sf /etc/nginx/sites-available/webmoney.cnxifly.com /etc/nginx/sites-enabled/ +``` + +```bash +ln -sf /etc/nginx/sites-available/mail.cnxifly.com /etc/nginx/sites-enabled/ +``` + +```bash +ln -sf /etc/nginx/sites-available/webmail.cnxifly.com /etc/nginx/sites-enabled/ +``` + +```bash +ln -sf /etc/nginx/sites-available/phpmyadmin.cnxifly.com /etc/nginx/sites-enabled/ +``` + +Testar e recarregar Nginx: +```bash +nginx -t +``` + +```bash +systemctl reload nginx +``` + +### 9.9 Criar Admin PostfixAdmin + +Primeiro, acessar setup.php para criar tabelas: +```bash +curl -s https://mail.cnxifly.com/setup.php > /dev/null +``` + +Criar admin via SQL (mais confiável): +```bash +ADMIN_HASH=$(doveadm pw -s SHA512-CRYPT -p 'M@ster9354') +echo "Hash gerado: $ADMIN_HASH" +``` + +```bash +mysql -u postfixadmin -p'M@ster9354' postfixadmin << EOSQL +INSERT INTO admin (username, password, superadmin, created, modified, active) +VALUES ('admin@cnxifly.com', '$(doveadm pw -s SHA512-CRYPT -p "M@ster9354")', 1, NOW(), NOW(), 1) +ON DUPLICATE KEY UPDATE password='$(doveadm pw -s SHA512-CRYPT -p "M@ster9354")', active=1; +EOSQL +``` + +Desativar setup.php por segurança: +```bash +mv /usr/share/postfixadmin/public/setup.php /usr/share/postfixadmin/public/setup.php.disabled 2>/dev/null || true +``` + +--- + +## 10. FASE 9: NODE.JS 22 LTS + DEPLOY WEBMONEY (~15 min) + +### 10.1 Instalar Node.js 22 LTS (Última Versão LTS) + +Adicionar repositório oficial NodeSource: +```bash +curl -fsSL https://deb.nodesource.com/setup_22.x | bash - +``` + +Instalar Node.js: +```bash +apt install -y nodejs +``` + +Verificar versões: +```bash +node --version +``` +Deve mostrar: `v22.x.x` + +```bash +npm --version +``` + +### 10.2 Preparar Estrutura de Diretórios +```bash +mkdir -p /var/www/webmoney/public +``` + +```bash +mkdir -p /var/www/webmoney/storage/logs +``` + +```bash +mkdir -p /var/www/webmoney/storage/framework/{sessions,views,cache} +``` + +```bash +mkdir -p /var/www/webmoney/bootstrap/cache +``` + +```bash +chown -R www-data:www-data /var/www/webmoney +``` + +### 10.3 Deploy Backend (Laravel) + +**Na máquina de desenvolvimento:** + +Criar .env.production: +```bash +cd /workspaces/webmoney/backend +``` + +```bash +cat > .env.production << 'EOF' +APP_NAME=WEBMoney +APP_ENV=production +APP_KEY= +APP_DEBUG=false +APP_TIMEZONE=Europe/Madrid +APP_URL=https://webmoney.cnxifly.com + +DB_CONNECTION=mysql +DB_HOST=127.0.0.1 +DB_PORT=3306 +DB_DATABASE=webmoney +DB_USERNAME=webmoney +DB_PASSWORD=M@ster9354 + +MAIL_MAILER=smtp +MAIL_HOST=localhost +MAIL_PORT=587 +MAIL_USERNAME=no-reply@cnxifly.com +MAIL_PASSWORD=M@ster9354 +MAIL_ENCRYPTION=tls +MAIL_FROM_ADDRESS=no-reply@cnxifly.com +MAIL_FROM_NAME="${APP_NAME}" +EOF +``` + +Enviar arquivos para o servidor: +```bash +rsync -avz --exclude='.git' --exclude='node_modules' --exclude='vendor' \ + ./ root@213.165.93.60:/var/www/webmoney/ +``` + +**No servidor (via SSH):** +```bash +ssh root@213.165.93.60 +``` + +```bash +cd /var/www/webmoney +``` + +```bash +cp .env.production .env +``` + +```bash +composer install --no-dev --optimize-autoloader +``` + +```bash +php artisan key:generate +``` + +```bash +php artisan migrate --force +``` + +```bash +php artisan config:cache +``` + +```bash +php artisan route:cache +``` + +```bash +php artisan view:cache +``` + +```bash +chown -R www-data:www-data /var/www/webmoney +``` + +```bash +chmod -R 755 /var/www/webmoney +``` + +```bash +chmod -R 775 /var/www/webmoney/storage +``` + +```bash +chmod -R 775 /var/www/webmoney/bootstrap/cache +``` + +### 10.4 Deploy Frontend (React + Vite) + +**Na máquina de desenvolvimento:** +```bash +cd /workspaces/webmoney/frontend +``` + +```bash +npm install +``` + +```bash +npm run build +``` + +Enviar build para o servidor: +```bash +rsync -avz ./dist/ root@213.165.93.60:/var/www/webmoney/public/ +``` + +**No servidor:** +```bash +chown -R www-data:www-data /var/www/webmoney/public +``` + +--- + +## 11. FASE 10: VALIDAÇÃO FINAL (~5 min) + +### 11.1 Criar Domínio e Mailbox no PostfixAdmin + +Acessar PostfixAdmin: +``` +URL: https://mail.cnxifly.com/login.php +Login: admin@cnxifly.com +Senha: M@ster9354 +``` + +1. **Criar Domínio:** + - Menu: "Lista de Domínios" → "Novo Domínio" + - Domínio: `cnxifly.com` + - Caixas de Correio: 100 + - Aliases: 100 + - Guardar + +2. **Criar Mailboxes:** + - Menu: "Lista Virtual" → "Adicionar Caixa de Correio" + - `admin@cnxifly.com` / M@ster9354 + - `no-reply@cnxifly.com` / M@ster9354 + - `support@cnxifly.com` / M@ster9354 + +### 11.2 Testar Envio de Email +```bash +echo "Teste de email do servidor" | mail -s "Teste SMTP $(date)" admin@cnxifly.com +``` + +### 11.3 Verificar Status dos Serviços +```bash +systemctl status nginx +``` + +```bash +systemctl status php8.4-fpm +``` + +```bash +systemctl status mariadb +``` + +```bash +systemctl status postfix +``` + +```bash +systemctl status dovecot +``` + +```bash +systemctl status opendkim +``` + +### 11.4 Testar Configurações +```bash +nginx -t +``` + +```bash +postfix check +``` + +```bash +doveconf -n | head -20 +``` + +### 11.5 Verificar Portas Abertas +```bash +ss -tlnp | grep -E ':(22|25|80|143|443|465|587|993)' +``` + +### 11.6 Testar DKIM +```bash +opendkim-testkey -d cnxifly.com -s default -vvv +``` + +### 11.7 Verificar DNS +```bash +dig cnxifly.com MX +short +``` + +```bash +dig cnxifly.com TXT +short +``` + +```bash +dig default._domainkey.cnxifly.com TXT +short +``` + +```bash +dig _dmarc.cnxifly.com TXT +short +``` + +### 11.8 Checklist Final + +| Verificação | Status | +|-------------|--------| +| https://webmoney.cnxifly.com carrega | [ ] | +| https://mail.cnxifly.com login funciona | [ ] | +| https://webmail.cnxifly.com login funciona | [ ] | +| https://phpmyadmin.cnxifly.com login funciona | [ ] | +| SSL válido em todos os subdomínios | [ ] | +| Email enviado chega no inbox (não spam) | [ ] | +| DKIM/SPF/DMARC pass | [ ] | + +--- + +## 12. FASE 11: OTIMIZAÇÃO DE DESEMPENHO 🚀 (~20 min) + +Esta é a fase final de otimização para máximo desempenho do servidor. + +### 12.1 Otimização do Kernel (sysctl.conf) + +```bash +cat >> /etc/sysctl.conf << 'EOF' + +# ============================================ +# WEBMONEY SERVER PERFORMANCE TUNING +# ============================================ + +# Network Performance +net.core.somaxconn = 65535 +net.core.netdev_max_backlog = 65535 +net.ipv4.tcp_max_syn_backlog = 65535 +net.ipv4.ip_local_port_range = 1024 65535 +net.ipv4.tcp_tw_reuse = 1 +net.ipv4.tcp_fin_timeout = 15 +net.ipv4.tcp_keepalive_time = 300 +net.ipv4.tcp_keepalive_probes = 5 +net.ipv4.tcp_keepalive_intvl = 15 + +# TCP Buffer Sizes +net.core.rmem_default = 262144 +net.core.rmem_max = 16777216 +net.core.wmem_default = 262144 +net.core.wmem_max = 16777216 +net.ipv4.tcp_rmem = 4096 262144 16777216 +net.ipv4.tcp_wmem = 4096 262144 16777216 + +# Enable TCP Fast Open +net.ipv4.tcp_fastopen = 3 + +# File Descriptors +fs.file-max = 2097152 +fs.nr_open = 2097152 + +# Virtual Memory +vm.swappiness = 10 +vm.dirty_ratio = 60 +vm.dirty_background_ratio = 5 + +# Security (já ativo por padrão mas bom garantir) +net.ipv4.conf.default.rp_filter = 1 +net.ipv4.conf.all.rp_filter = 1 +net.ipv4.icmp_echo_ignore_broadcasts = 1 +EOF +``` + +Aplicar configurações: +```bash +sysctl -p +``` + +### 12.2 Aumentar Limites de Arquivos Abertos + +```bash +cat >> /etc/security/limits.conf << 'EOF' + +# WEBMONEY Performance Limits +* soft nofile 65535 +* hard nofile 65535 +* soft nproc 65535 +* hard nproc 65535 +www-data soft nofile 65535 +www-data hard nofile 65535 +mysql soft nofile 65535 +mysql hard nofile 65535 +EOF +``` + +### 12.3 Otimização do PHP-FPM (Pool Dedicado) + +Criar pool otimizado para produção: +```bash +cat > /etc/php/8.4/fpm/pool.d/webmoney.conf << 'EOF' +[webmoney] +user = www-data +group = www-data + +listen = /run/php/php8.4-fpm-webmoney.sock +listen.owner = www-data +listen.group = www-data +listen.mode = 0660 + +; Process Manager - ondemand para economia de recursos +pm = ondemand +pm.max_children = 50 +pm.process_idle_timeout = 10s +pm.max_requests = 500 + +; Status e Logs +pm.status_path = /status +ping.path = /ping +ping.response = pong + +; Slow log para debug +slowlog = /var/log/php8.4-fpm-webmoney-slow.log +request_slowlog_timeout = 5s + +; Limites +request_terminate_timeout = 300s +rlimit_files = 65535 +rlimit_core = 0 + +; Environment +env[HOSTNAME] = $HOSTNAME +env[PATH] = /usr/local/bin:/usr/bin:/bin +env[TMP] = /tmp +env[TMPDIR] = /tmp +env[TEMP] = /tmp + +; PHP Settings +php_admin_value[error_log] = /var/log/php8.4-fpm-webmoney-error.log +php_admin_flag[log_errors] = on +php_admin_value[memory_limit] = 256M +php_value[session.save_handler] = files +php_value[session.save_path] = /var/lib/php/sessions +EOF +``` + +### 12.4 Otimização Avançada do OPcache + +```bash +cat > /etc/php/8.4/mods-available/opcache-production.ini << 'EOF' +; OPcache Production Settings +opcache.enable=1 +opcache.enable_cli=0 +opcache.memory_consumption=256 +opcache.interned_strings_buffer=32 +opcache.max_accelerated_files=20000 +opcache.max_wasted_percentage=10 +opcache.revalidate_freq=0 +opcache.validate_timestamps=0 +opcache.save_comments=1 +opcache.fast_shutdown=1 +opcache.file_cache=/tmp/opcache +opcache.file_cache_only=0 +opcache.file_cache_consistency_checks=1 + +; JIT (PHP 8+) +opcache.jit=1255 +opcache.jit_buffer_size=256M +EOF +``` + +Criar diretório de cache: +```bash +mkdir -p /tmp/opcache +``` + +```bash +chown www-data:www-data /tmp/opcache +``` + +Ativar configuração: +```bash +rm -f /etc/php/8.4/fpm/conf.d/99-opcache-custom.ini +``` + +```bash +ln -sf /etc/php/8.4/mods-available/opcache-production.ini /etc/php/8.4/fpm/conf.d/99-opcache-production.ini +``` + +### 12.5 Otimização do MariaDB + +```bash +cat > /etc/mysql/mariadb.conf.d/99-performance.cnf << 'EOF' +[mysqld] +# InnoDB Settings +innodb_buffer_pool_size = 512M +innodb_buffer_pool_instances = 4 +innodb_log_file_size = 128M +innodb_log_buffer_size = 16M +innodb_flush_log_at_trx_commit = 2 +innodb_flush_method = O_DIRECT +innodb_file_per_table = 1 +innodb_io_capacity = 2000 +innodb_io_capacity_max = 4000 +innodb_read_io_threads = 4 +innodb_write_io_threads = 4 + +# Query Cache (deprecated in MySQL 8, still works in MariaDB) +query_cache_type = 1 +query_cache_size = 64M +query_cache_limit = 2M + +# Connections +max_connections = 200 +max_connect_errors = 100000 +wait_timeout = 600 +interactive_timeout = 600 + +# Buffers +join_buffer_size = 4M +sort_buffer_size = 4M +read_buffer_size = 2M +read_rnd_buffer_size = 2M + +# Table Cache +table_open_cache = 4000 +table_definition_cache = 2000 + +# Temp Tables +tmp_table_size = 64M +max_heap_table_size = 64M + +# Binary Log (para replicação futura) +# log_bin = mysql-bin +# binlog_format = ROW +# expire_logs_days = 7 + +# Slow Query Log +slow_query_log = 1 +slow_query_log_file = /var/log/mysql/slow.log +long_query_time = 2 + +# Thread Pool +thread_handling = pool-of-threads +thread_pool_size = 4 +EOF +``` + +### 12.6 Otimização do Nginx (Produção) + +Atualizar nginx.conf com configurações de produção: +```bash +cat > /etc/nginx/conf.d/performance.conf << 'EOF' +# Additional Performance Settings + +# Proxy Cache +proxy_cache_path /var/cache/nginx/proxy levels=1:2 keys_zone=PROXY:10m inactive=60m max_size=1g; + +# Static File Caching +map $sent_http_content_type $expires { + default off; + text/html epoch; + text/css 1M; + application/javascript 1M; + ~image/ 1M; + ~font/ 1M; + application/font-woff 1M; + application/font-woff2 1M; +} + +# Brotli Compression (se disponível) +# brotli on; +# brotli_comp_level 6; +# brotli_types text/plain text/css application/json application/javascript text/xml application/xml; +EOF +``` + +Criar diretório de cache do proxy: +```bash +mkdir -p /var/cache/nginx/proxy +``` + +```bash +chown -R www-data:www-data /var/cache/nginx +``` + +### 12.7 Configurar Cache de Sessão PHP com Arquivos + +```bash +mkdir -p /var/lib/php/sessions +``` + +```bash +chown -R www-data:www-data /var/lib/php/sessions +``` + +```bash +chmod 1733 /var/lib/php/sessions +``` + +### 12.8 Instalar e Configurar Redis (Cache Opcional) + +Instalar Redis: +```bash +apt install -y redis-server +``` + +Configurar Redis: +```bash +sed -i 's/^# maxmemory .*/maxmemory 128mb/' /etc/redis/redis.conf +``` + +```bash +sed -i 's/^# maxmemory-policy .*/maxmemory-policy allkeys-lru/' /etc/redis/redis.conf +``` + +Habilitar e iniciar: +```bash +systemctl enable redis-server +``` + +```bash +systemctl start redis-server +``` + +Verificar: +```bash +redis-cli ping +``` +Deve retornar: `PONG` + +### 12.9 Atualizar Vhosts para Usar Pool Dedicado + +Atualizar webmoney.cnxifly.com para usar o novo pool: +```bash +sed -i 's|php8.4-fpm.sock|php8.4-fpm-webmoney.sock|g' /etc/nginx/sites-available/webmoney.cnxifly.com +``` + +### 12.10 Reiniciar Todos os Serviços + +```bash +systemctl restart php8.4-fpm +``` + +```bash +systemctl restart mariadb +``` + +```bash +systemctl restart nginx +``` + +```bash +systemctl restart redis-server +``` + +### 12.11 Verificar Otimizações + +Verificar OPcache: +```bash +php -i | grep -i opcache +``` + +Verificar limites de arquivo: +```bash +ulimit -n +``` + +Verificar conexões MariaDB: +```bash +mysql -u root -p'M@ster9354' -e "SHOW VARIABLES LIKE 'max_connections';" +``` + +Verificar buffer pool: +```bash +mysql -u root -p'M@ster9354' -e "SHOW VARIABLES LIKE 'innodb_buffer_pool_size';" +``` + +### 12.12 Benchmark Simples + +Testar resposta do servidor: +```bash +curl -w "@-" -o /dev/null -s "https://webmoney.cnxifly.com" << 'EOF' + time_namelookup: %{time_namelookup}s\n + time_connect: %{time_connect}s\n + time_appconnect: %{time_appconnect}s\n + time_pretransfer: %{time_pretransfer}s\n + time_redirect: %{time_redirect}s\n + time_starttransfer: %{time_starttransfer}s\n + ----------\n + time_total: %{time_total}s\n +EOF +``` + +--- + +## 13. COMANDOS DE VERIFICAÇÃO E MONITORAMENTO + +### Status dos Serviços +```bash +systemctl status nginx php8.4-fpm mariadb postfix dovecot opendkim redis-server +``` + +### Testar Configurações +```bash +nginx -t +``` + +```bash +postfix check +``` + +```bash +doveconf -n +``` + +### Verificar Portas +```bash +ss -tlnp | grep -E ':(22|25|80|143|443|465|587|993|6379)' +``` + +### Testar DKIM +```bash +opendkim-testkey -d cnxifly.com -s default -vvv +``` + +### Verificar DNS +```bash +dig cnxifly.com MX +short +``` + +```bash +dig cnxifly.com TXT +short +``` + +```bash +dig default._domainkey.cnxifly.com TXT +short +``` + +```bash +dig _dmarc.cnxifly.com TXT +short +``` + +### Monitorar Logs em Tempo Real +```bash +tail -f /var/log/nginx/error.log +``` + +```bash +tail -f /var/log/mail.log +``` + +```bash +tail -f /var/log/php8.4-fpm-webmoney-error.log +``` + +```bash +tail -f /var/log/mysql/slow.log +``` + +### Testar Email via Terminal +```bash +echo "Teste de configuração SMTP" | mail -s "Teste SMTP" admin@cnxifly.com +``` + +### Verificar Uso de Recursos +```bash +htop +``` + +```bash +free -h +``` + +```bash +df -h +``` + +--- + +## 14. REFERÊNCIA RÁPIDA + +### Credenciais Unificadas + +| Serviço | Usuário | Senha | +|---------|---------|-------| +| SSH root | root | Master9354 | +| SSH admin | admin | M@ster9354 | +| MariaDB root | root | M@ster9354 | +| MariaDB admin | admin | M@ster9354 | +| PostfixAdmin | admin@cnxifly.com | M@ster9354 | +| phpMyAdmin | admin | M@ster9354 | +| Email (criar) | admin@cnxifly.com | M@ster9354 | + +### URLs Finais + +| Serviço | URL | +|---------|-----| +| WEBMoney App | https://webmoney.cnxifly.com | +| PostfixAdmin | https://mail.cnxifly.com | +| Roundcube | https://webmail.cnxifly.com | +| phpMyAdmin | https://phpmyadmin.cnxifly.com | +| API | https://webmoney.cnxifly.com/api | + +### Arquivos de Configuração Importantes + +| Arquivo | Propósito | +|---------|-----------| +| /etc/nginx/nginx.conf | Nginx principal | +| /etc/nginx/sites-available/* | Vhosts Nginx | +| /etc/php/8.4/fpm/pool.d/webmoney.conf | PHP-FPM Pool | +| /etc/php/8.4/mods-available/opcache-production.ini | OPcache | +| /etc/mysql/mariadb.conf.d/99-performance.cnf | MariaDB tuning | +| /etc/postfix/main.cf | Postfix config | +| /etc/dovecot/dovecot.conf | Dovecot config | +| /etc/opendkim.conf | DKIM config | +| /etc/postfixadmin/config.local.php | PostfixAdmin | +| /etc/roundcube/config.inc.php | Roundcube | +| /var/www/webmoney/.env | Laravel env | + +### Versões Instaladas + +| Software | Versão | +|----------|--------| +| Ubuntu | 24.04 LTS | +| PHP | 8.4.x | +| MariaDB | 11.4 LTS | +| Nginx | 1.27.x (Mainline) | +| Node.js | 22.x LTS | +| Composer | 2.8.x | +| Postfix | 3.9.x | +| Dovecot | 2.3.x | +| Redis | 7.x | + +### Registro DNS DKIM (adicionar no painel) +``` +Nome: default._domainkey.cnxifly.com +Tipo: TXT +Valor: [Usar o conteúdo de /etc/opendkim/keys/cnxifly.com/default.txt] +``` + +### Registro DNS DMARC (adicionar no painel) +``` +Nome: _dmarc.cnxifly.com +Tipo: TXT +Valor: v=DMARC1; p=quarantine; rua=mailto:postmaster@cnxifly.com; ruf=mailto:postmaster@cnxifly.com; fo=1; sp=quarantine; adkim=s; aspf=s; pct=100 +``` + +### Registro DNS SPF (adicionar no painel) +``` +Nome: cnxifly.com (ou @) +Tipo: TXT +Valor: v=spf1 mx a ip4:213.165.93.60 -all +``` + +--- + +## 15. TROUBLESHOOTING COMUM + +### Nginx não inicia +```bash +nginx -t +journalctl -xeu nginx +``` + +### PHP-FPM erro de socket +```bash +ls -la /run/php/ +systemctl restart php8.4-fpm +``` + +### MariaDB não aceita conexão +```bash +systemctl status mariadb +journalctl -xeu mariadb +mysql -u root -p'M@ster9354' -e "SELECT 1;" +``` + +### Email não enviado +```bash +tail -50 /var/log/mail.log +postqueue -p +postfix check +``` + +### Dovecot não autentica +```bash +doveconf -n | grep auth +tail -50 /var/log/mail.log | grep dovecot +``` + +### SSL não funciona +```bash +certbot certificates +nginx -t +openssl s_client -connect webmoney.cnxifly.com:443 -servername webmoney.cnxifly.com +``` + +### Laravel erro 500 +```bash +tail -50 /var/www/webmoney/storage/logs/laravel.log +php artisan config:clear +php artisan cache:clear +chown -R www-data:www-data /var/www/webmoney/storage +``` + +--- + +**Tempo Total Estimado:** ~120 minutos (com otimização de desempenho) + +**Autor:** GitHub Copilot +**Data:** 7 de Dezembro de 2025 +**Versão:** 2.0.0 - Manual Completo com Otimização diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..457f038 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +1.27.2 diff --git a/backend/.editorconfig b/backend/.editorconfig new file mode 100644 index 0000000..a186cd2 --- /dev/null +++ b/backend/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[*.{yml,yaml}] +indent_size = 2 + +[compose.yaml] +indent_size = 4 diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..c0660ea --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,65 @@ +APP_NAME=Laravel +APP_ENV=local +APP_KEY= +APP_DEBUG=true +APP_URL=http://localhost + +APP_LOCALE=en +APP_FALLBACK_LOCALE=en +APP_FAKER_LOCALE=en_US + +APP_MAINTENANCE_DRIVER=file +# APP_MAINTENANCE_STORE=database + +# PHP_CLI_SERVER_WORKERS=4 + +BCRYPT_ROUNDS=12 + +LOG_CHANNEL=stack +LOG_STACK=single +LOG_DEPRECATIONS_CHANNEL=null +LOG_LEVEL=debug + +DB_CONNECTION=sqlite +# DB_HOST=127.0.0.1 +# DB_PORT=3306 +# DB_DATABASE=laravel +# DB_USERNAME=root +# DB_PASSWORD= + +SESSION_DRIVER=database +SESSION_LIFETIME=120 +SESSION_ENCRYPT=false +SESSION_PATH=/ +SESSION_DOMAIN=null + +BROADCAST_CONNECTION=log +FILESYSTEM_DISK=local +QUEUE_CONNECTION=database + +CACHE_STORE=database +# CACHE_PREFIX= + +MEMCACHED_HOST=127.0.0.1 + +REDIS_CLIENT=phpredis +REDIS_HOST=127.0.0.1 +REDIS_PASSWORD=null +REDIS_PORT=6379 + +MAIL_MAILER=log +MAIL_SCHEME=null +MAIL_HOST=127.0.0.1 +MAIL_PORT=2525 +MAIL_USERNAME=null +MAIL_PASSWORD=null +MAIL_FROM_ADDRESS="hello@example.com" +MAIL_FROM_NAME="${APP_NAME}" + +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +AWS_DEFAULT_REGION=us-east-1 +AWS_BUCKET= +AWS_USE_PATH_STYLE_ENDPOINT=false + +VITE_APP_NAME="${APP_NAME}" diff --git a/backend/.gitattributes b/backend/.gitattributes new file mode 100644 index 0000000..fcb21d3 --- /dev/null +++ b/backend/.gitattributes @@ -0,0 +1,11 @@ +* text=auto eol=lf + +*.blade.php diff=html +*.css diff=css +*.html diff=html +*.md diff=markdown +*.php diff=php + +/.github export-ignore +CHANGELOG.md export-ignore +.styleci.yml export-ignore diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..b71b1ea --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,24 @@ +*.log +.DS_Store +.env +.env.backup +.env.production +.phpactor.json +.phpunit.result.cache +/.fleet +/.idea +/.nova +/.phpunit.cache +/.vscode +/.zed +/auth.json +/node_modules +/public/build +/public/hot +/public/storage +/storage/*.key +/storage/pail +/vendor +Homestead.json +Homestead.yaml +Thumbs.db diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..0165a77 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,59 @@ +

Laravel Logo

+ +

+Build Status +Total Downloads +Latest Stable Version +License +

+ +## About Laravel + +Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as: + +- [Simple, fast routing engine](https://laravel.com/docs/routing). +- [Powerful dependency injection container](https://laravel.com/docs/container). +- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage. +- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent). +- Database agnostic [schema migrations](https://laravel.com/docs/migrations). +- [Robust background job processing](https://laravel.com/docs/queues). +- [Real-time event broadcasting](https://laravel.com/docs/broadcasting). + +Laravel is accessible, powerful, and provides tools required for large, robust applications. + +## Learning Laravel + +Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework. You can also check out [Laravel Learn](https://laravel.com/learn), where you will be guided through building a modern Laravel application. + +If you don't feel like reading, [Laracasts](https://laracasts.com) can help. Laracasts contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library. + +## Laravel Sponsors + +We would like to extend our thanks to the following sponsors for funding Laravel development. If you are interested in becoming a sponsor, please visit the [Laravel Partners program](https://partners.laravel.com). + +### Premium Partners + +- **[Vehikl](https://vehikl.com)** +- **[Tighten Co.](https://tighten.co)** +- **[Kirschbaum Development Group](https://kirschbaumdevelopment.com)** +- **[64 Robots](https://64robots.com)** +- **[Curotec](https://www.curotec.com/services/technologies/laravel)** +- **[DevSquad](https://devsquad.com/hire-laravel-developers)** +- **[Redberry](https://redberry.international/laravel-development)** +- **[Active Logic](https://activelogic.com)** + +## Contributing + +Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions). + +## Code of Conduct + +In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct). + +## Security Vulnerabilities + +If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed. + +## License + +The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT). diff --git a/backend/app/Http/Controllers/Api/AccountController.php b/backend/app/Http/Controllers/Api/AccountController.php new file mode 100644 index 0000000..ded9c23 --- /dev/null +++ b/backend/app/Http/Controllers/Api/AccountController.php @@ -0,0 +1,261 @@ +has('type')) { + $query->where('type', $request->type); + } + + if ($request->has('is_active')) { + $query->where('is_active', $request->boolean('is_active')); + } + + $accounts = $query->orderBy('name')->get(); + + return response()->json([ + 'success' => true, + 'data' => $accounts, + 'types' => Account::TYPES, + ]); + } + + /** + * Criar nova conta + */ + public function store(Request $request): JsonResponse + { + $validated = $request->validate([ + 'name' => 'required|string|max:100', + 'type' => ['required', Rule::in(array_keys(Account::TYPES))], + 'bank_name' => 'nullable|string|max:100', + 'account_number' => 'nullable|string|max:50', + 'initial_balance' => 'nullable|numeric', + 'credit_limit' => 'nullable|numeric', + 'currency' => 'nullable|string|size:3', + 'color' => 'nullable|string|max:7', + 'icon' => 'nullable|string|max:50', + 'description' => 'nullable|string', + 'is_active' => 'nullable|boolean', + 'include_in_total' => 'nullable|boolean', + ]); + + $validated['user_id'] = Auth::id(); + $validated['current_balance'] = $validated['initial_balance'] ?? 0; + + $account = Account::create($validated); + + return response()->json([ + 'success' => true, + 'message' => 'Conta criada com sucesso', + 'data' => $account, + ], 201); + } + + /** + * Exibir uma conta específica + */ + public function show(int $id): JsonResponse + { + $account = Account::where('user_id', Auth::id())->findOrFail($id); + + return response()->json([ + 'success' => true, + 'data' => $account, + ]); + } + + /** + * Atualizar uma conta + */ + public function update(Request $request, int $id): JsonResponse + { + $account = Account::where('user_id', Auth::id())->findOrFail($id); + + $validated = $request->validate([ + 'name' => 'sometimes|required|string|max:100', + 'type' => ['sometimes', 'required', Rule::in(array_keys(Account::TYPES))], + 'bank_name' => 'nullable|string|max:100', + 'account_number' => 'nullable|string|max:50', + 'initial_balance' => 'nullable|numeric', + 'credit_limit' => 'nullable|numeric', + 'currency' => 'nullable|string|size:3', + 'color' => 'nullable|string|max:7', + 'icon' => 'nullable|string|max:50', + 'description' => 'nullable|string', + 'is_active' => 'nullable|boolean', + 'include_in_total' => 'nullable|boolean', + ]); + + $account->update($validated); + + return response()->json([ + 'success' => true, + 'message' => 'Conta atualizada com sucesso', + 'data' => $account->fresh(), + ]); + } + + /** + * Deletar uma conta (soft delete) + */ + public function destroy(int $id): JsonResponse + { + $account = Account::where('user_id', Auth::id())->findOrFail($id); + $account->delete(); + + return response()->json([ + 'success' => true, + 'message' => 'Conta excluída com sucesso', + ]); + } + + /** + * Retorna o saldo total de todas as contas + */ + public function totalBalance(): JsonResponse + { + $accounts = Account::where('user_id', Auth::id()) + ->active() + ->includeInTotal() + ->get(); + + $total = 0; + foreach ($accounts as $account) { + // Para passivos e cartões de crédito, o saldo é negativo + if ($account->isCreditAccount()) { + $total += (float) $account->current_balance; // já é negativo + } else { + $total += (float) $account->current_balance; + } + } + + return response()->json([ + 'success' => true, + 'data' => [ + 'total_balance' => $total, + 'accounts_count' => $accounts->count(), + ], + ]); + } + + /** + * Recalcular saldos de todas as contas do usuário + */ + public function recalculateBalances(): JsonResponse + { + $accounts = Account::where('user_id', Auth::id())->get(); + + $results = []; + foreach ($accounts as $account) { + $oldBalance = (float) $account->current_balance; + $newBalance = $account->recalculateBalance(); + + $results[] = [ + 'id' => $account->id, + 'name' => $account->name, + 'old_balance' => $oldBalance, + 'new_balance' => $newBalance, + 'difference' => $newBalance - $oldBalance, + ]; + } + + return response()->json([ + 'success' => true, + 'message' => 'Saldos recalculados com sucesso', + 'data' => $results, + ]); + } + + /** + * Recalcular saldo de uma conta específica + */ + public function recalculateBalance(int $id): JsonResponse + { + $account = Account::where('user_id', Auth::id())->findOrFail($id); + + $oldBalance = (float) $account->current_balance; + $newBalance = $account->recalculateBalance(); + + return response()->json([ + 'success' => true, + 'message' => 'Saldo recalculado com sucesso', + 'data' => [ + 'id' => $account->id, + 'name' => $account->name, + 'old_balance' => $oldBalance, + 'new_balance' => $newBalance, + 'difference' => $newBalance - $oldBalance, + ], + ]); + } + + /** + * Ajustar saldo de uma conta definindo o saldo real atual + * Calcula e atualiza o initial_balance para que: initial_balance + créditos - débitos = saldo_desejado + */ + public function adjustBalance(Request $request, int $id): JsonResponse + { + $validated = $request->validate([ + 'target_balance' => 'required|numeric', + ]); + + $account = Account::where('user_id', Auth::id())->findOrFail($id); + + $targetBalance = (float) $validated['target_balance']; + $oldInitialBalance = (float) $account->initial_balance; + $oldCurrentBalance = (float) $account->current_balance; + + // Calcular soma de transações + $credits = (float) $account->transactions() + ->where('type', 'credit') + ->where('status', 'completed') + ->sum('amount'); + + $debits = (float) $account->transactions() + ->where('type', 'debit') + ->where('status', 'completed') + ->sum('amount'); + + // Calcular novo initial_balance: target = initial + credits - debits + // initial = target - credits + debits + $newInitialBalance = $targetBalance - $credits + $debits; + + // Atualizar conta + $account->update([ + 'initial_balance' => $newInitialBalance, + 'current_balance' => $targetBalance, + ]); + + return response()->json([ + 'success' => true, + 'message' => 'Saldo ajustado com sucesso', + 'data' => [ + 'id' => $account->id, + 'name' => $account->name, + 'old_initial_balance' => $oldInitialBalance, + 'new_initial_balance' => $newInitialBalance, + 'old_current_balance' => $oldCurrentBalance, + 'new_current_balance' => $targetBalance, + 'credits_sum' => $credits, + 'debits_sum' => $debits, + ], + ]); + } +} diff --git a/backend/app/Http/Controllers/Api/AuthController.php b/backend/app/Http/Controllers/Api/AuthController.php new file mode 100644 index 0000000..1a153fd --- /dev/null +++ b/backend/app/Http/Controllers/Api/AuthController.php @@ -0,0 +1,183 @@ +all(), [ + 'name' => 'required|string|max:255', + 'email' => 'required|string|email|max:255|unique:users', + 'password' => 'required|string|min:8|confirmed', + ], [ + 'name.required' => 'El nombre es obligatorio', + 'email.required' => 'El email es obligatorio', + 'email.email' => 'El email debe ser válido', + 'email.unique' => 'Este email ya está registrado', + 'password.required' => 'La contraseña es obligatoria', + 'password.min' => 'La contraseña debe tener al menos 8 caracteres', + 'password.confirmed' => 'Las contraseñas no coinciden', + ]); + + if ($validator->fails()) { + return response()->json([ + 'success' => false, + 'message' => 'Error de validación', + 'errors' => $validator->errors() + ], 422); + } + + $user = User::create([ + 'name' => $request->name, + 'email' => $request->email, + 'password' => Hash::make($request->password), + ]); + + // Criar categorias e dados padrão para o novo usuário + $setupService = new UserSetupService(); + $setupService->setupNewUser($user->id); + + $token = $user->createToken('auth-token')->plainTextToken; + + return response()->json([ + 'success' => true, + 'message' => 'Usuario registrado exitosamente', + 'data' => [ + 'user' => [ + 'id' => $user->id, + 'name' => $user->name, + 'email' => $user->email, + ], + 'token' => $token, + ] + ], 201); + + } catch (\Exception $e) { + return response()->json([ + 'success' => false, + 'message' => 'Error al registrar usuario', + 'error' => $e->getMessage() + ], 500); + } + } + + /** + * Login user + */ + public function login(Request $request): JsonResponse + { + try { + $validator = Validator::make($request->all(), [ + 'email' => 'required|email', + 'password' => 'required', + ], [ + 'email.required' => 'El email es obligatorio', + 'email.email' => 'El email debe ser válido', + 'password.required' => 'La contraseña es obligatoria', + ]); + + if ($validator->fails()) { + return response()->json([ + 'success' => false, + 'message' => 'Error de validación', + 'errors' => $validator->errors() + ], 422); + } + + if (!Auth::attempt($request->only('email', 'password'))) { + return response()->json([ + 'success' => false, + 'message' => 'Credenciales incorrectas' + ], 401); + } + + $user = User::where('email', $request->email)->first(); + $token = $user->createToken('auth-token')->plainTextToken; + + return response()->json([ + 'success' => true, + 'message' => 'Inicio de sesión exitoso', + 'data' => [ + 'user' => [ + 'id' => $user->id, + 'name' => $user->name, + 'email' => $user->email, + ], + 'token' => $token, + ] + ], 200); + + } catch (\Exception $e) { + return response()->json([ + 'success' => false, + 'message' => 'Error al iniciar sesión', + 'error' => $e->getMessage() + ], 500); + } + } + + /** + * Logout user (revoke token) + */ + public function logout(Request $request): JsonResponse + { + try { + $request->user()->currentAccessToken()->delete(); + + return response()->json([ + 'success' => true, + 'message' => 'Sesión cerrada exitosamente' + ], 200); + + } catch (\Exception $e) { + return response()->json([ + 'success' => false, + 'message' => 'Error al cerrar sesión', + 'error' => $e->getMessage() + ], 500); + } + } + + /** + * Get authenticated user + */ + public function me(Request $request): JsonResponse + { + try { + $user = $request->user(); + + return response()->json([ + 'success' => true, + 'data' => [ + 'user' => [ + 'id' => $user->id, + 'name' => $user->name, + 'email' => $user->email, + ] + ] + ], 200); + + } catch (\Exception $e) { + return response()->json([ + 'success' => false, + 'message' => 'Error al obtener datos del usuario', + 'error' => $e->getMessage() + ], 500); + } + } +} + diff --git a/backend/app/Http/Controllers/Api/CategoryController.php b/backend/app/Http/Controllers/Api/CategoryController.php new file mode 100644 index 0000000..4ae1d91 --- /dev/null +++ b/backend/app/Http/Controllers/Api/CategoryController.php @@ -0,0 +1,730 @@ +with(['keywords', 'children.keywords']); + + if ($request->has('type')) { + $query->ofType($request->type); + } + + if ($request->has('is_active')) { + $query->where('is_active', $request->boolean('is_active')); + } + + // Se flat=true, retorna todas as categorias + if ($request->boolean('flat')) { + $categories = $query->orderBy('name')->get(); + } else { + // Retorna apenas categorias raiz com filhas aninhadas + $categories = $query->root()->orderBy('order')->orderBy('name')->get(); + } + + return response()->json([ + 'success' => true, + 'data' => $categories, + 'types' => Category::TYPES, + ]); + } + + /** + * Criar nova categoria + */ + public function store(Request $request): JsonResponse + { + $validated = $request->validate([ + 'name' => 'required|string|max:100', + 'parent_id' => 'nullable|exists:categories,id', + 'type' => ['nullable', Rule::in(array_keys(Category::TYPES))], + 'description' => 'nullable|string', + 'color' => 'nullable|string|max:7', + 'icon' => 'nullable|string|max:50', + 'order' => 'nullable|integer', + 'is_active' => 'nullable|boolean', + 'keywords' => 'nullable|array', + 'keywords.*' => 'string|max:100', + ]); + + // Verificar se parent_id pertence ao usuário + if (!empty($validated['parent_id'])) { + $parent = Category::where('user_id', Auth::id()) + ->findOrFail($validated['parent_id']); + // Herdar tipo do pai se não especificado + if (empty($validated['type'])) { + $validated['type'] = $parent->type; + } + } + + $keywords = $validated['keywords'] ?? []; + unset($validated['keywords']); + + $validated['user_id'] = Auth::id(); + $validated['type'] = $validated['type'] ?? Category::TYPE_EXPENSE; + + DB::beginTransaction(); + try { + $category = Category::create($validated); + + // Adicionar palavras-chave + foreach ($keywords as $keyword) { + $category->keywords()->create([ + 'keyword' => trim($keyword), + 'is_case_sensitive' => false, + 'is_active' => true, + ]); + } + + DB::commit(); + + return response()->json([ + 'success' => true, + 'message' => 'Categoria criada com sucesso', + 'data' => $category->load(['keywords', 'parent', 'children']), + ], 201); + + } catch (\Exception $e) { + DB::rollBack(); + return response()->json([ + 'success' => false, + 'message' => 'Erro ao criar categoria: ' . $e->getMessage(), + ], 500); + } + } + + /** + * Exibir uma categoria específica + */ + public function show(int $id): JsonResponse + { + $category = Category::where('user_id', Auth::id()) + ->with(['keywords', 'parent', 'children.keywords']) + ->findOrFail($id); + + return response()->json([ + 'success' => true, + 'data' => $category, + ]); + } + + /** + * Atualizar uma categoria + */ + public function update(Request $request, int $id): JsonResponse + { + $category = Category::where('user_id', Auth::id())->findOrFail($id); + + $validated = $request->validate([ + 'name' => 'sometimes|required|string|max:100', + 'parent_id' => 'nullable|exists:categories,id', + 'type' => ['nullable', Rule::in(array_keys(Category::TYPES))], + 'description' => 'nullable|string', + 'color' => 'nullable|string|max:7', + 'icon' => 'nullable|string|max:50', + 'order' => 'nullable|integer', + 'is_active' => 'nullable|boolean', + 'keywords' => 'nullable|array', + 'keywords.*' => 'string|max:100', + ]); + + // Verificar se parent_id pertence ao usuário e não é a própria categoria + if (!empty($validated['parent_id'])) { + if ($validated['parent_id'] == $id) { + return response()->json([ + 'success' => false, + 'message' => 'Uma categoria não pode ser pai de si mesma', + ], 422); + } + Category::where('user_id', Auth::id())->findOrFail($validated['parent_id']); + } + + $keywords = $validated['keywords'] ?? null; + unset($validated['keywords']); + + DB::beginTransaction(); + try { + $category->update($validated); + + // Se keywords foram fornecidas, sincronizar + if ($keywords !== null) { + $category->keywords()->delete(); + foreach ($keywords as $keyword) { + $category->keywords()->create([ + 'keyword' => trim($keyword), + 'is_case_sensitive' => false, + 'is_active' => true, + ]); + } + } + + DB::commit(); + + return response()->json([ + 'success' => true, + 'message' => 'Categoria atualizada com sucesso', + 'data' => $category->fresh()->load(['keywords', 'parent', 'children']), + ]); + + } catch (\Exception $e) { + DB::rollBack(); + return response()->json([ + 'success' => false, + 'message' => 'Erro ao atualizar categoria: ' . $e->getMessage(), + ], 500); + } + } + + /** + * Deletar uma categoria (soft delete) + */ + public function destroy(int $id): JsonResponse + { + $category = Category::where('user_id', Auth::id())->findOrFail($id); + + // Se tem filhas, mover para nível raiz + if ($category->children()->exists()) { + $category->children()->update(['parent_id' => null]); + } + + $category->keywords()->delete(); + $category->delete(); + + return response()->json([ + 'success' => true, + 'message' => 'Categoria excluída com sucesso', + ]); + } + + /** + * Adicionar palavra-chave a uma categoria + */ + public function addKeyword(Request $request, int $id): JsonResponse + { + $category = Category::where('user_id', Auth::id())->findOrFail($id); + + $validated = $request->validate([ + 'keyword' => 'required|string|max:100', + 'is_case_sensitive' => 'nullable|boolean', + ]); + + $keyword = $category->keywords()->create([ + 'keyword' => trim($validated['keyword']), + 'is_case_sensitive' => $validated['is_case_sensitive'] ?? false, + 'is_active' => true, + ]); + + return response()->json([ + 'success' => true, + 'message' => 'Palavra-chave adicionada com sucesso', + 'data' => $keyword, + ], 201); + } + + /** + * Remover palavra-chave de uma categoria + */ + public function removeKeyword(int $id, int $keywordId): JsonResponse + { + $category = Category::where('user_id', Auth::id())->findOrFail($id); + $keyword = $category->keywords()->findOrFail($keywordId); + $keyword->delete(); + + return response()->json([ + 'success' => true, + 'message' => 'Palavra-chave removida com sucesso', + ]); + } + + /** + * Encontrar categoria por texto (usando palavras-chave) + */ + public function matchByText(Request $request): JsonResponse + { + $validated = $request->validate([ + 'text' => 'required|string', + 'type' => ['nullable', Rule::in(array_keys(Category::TYPES))], + ]); + + $text = $validated['text']; + $textLower = strtolower($text); + + $query = CategoryKeyword::whereHas('category', function ($query) use ($validated) { + $query->where('user_id', Auth::id())->where('is_active', true); + if (!empty($validated['type'])) { + $query->ofType($validated['type']); + } + }) + ->where('is_active', true) + ->with('category'); + + $keywords = $query->get(); + + $matches = []; + foreach ($keywords as $keyword) { + $searchText = $keyword->is_case_sensitive ? $text : $textLower; + $searchKeyword = $keyword->is_case_sensitive ? $keyword->keyword : strtolower($keyword->keyword); + + if (str_contains($searchText, $searchKeyword)) { + $matches[] = [ + 'category' => $keyword->category, + 'matched_keyword' => $keyword->keyword, + ]; + } + } + + return response()->json([ + 'success' => true, + 'data' => $matches, + ]); + } + + /** + * Reordenar categorias + */ + public function reorder(Request $request): JsonResponse + { + $validated = $request->validate([ + 'orders' => 'required|array', + 'orders.*.id' => 'required|exists:categories,id', + 'orders.*.order' => 'required|integer', + ]); + + DB::beginTransaction(); + try { + foreach ($validated['orders'] as $orderData) { + Category::where('user_id', Auth::id()) + ->where('id', $orderData['id']) + ->update(['order' => $orderData['order']]); + } + DB::commit(); + + return response()->json([ + 'success' => true, + 'message' => 'Ordem atualizada com sucesso', + ]); + + } catch (\Exception $e) { + DB::rollBack(); + return response()->json([ + 'success' => false, + 'message' => 'Erro ao reordenar categorias: ' . $e->getMessage(), + ], 500); + } + } + + /** + * Categorizar transações em lote baseado em palavras-chave + */ + public function categorizeBatch(Request $request): JsonResponse + { + $validated = $request->validate([ + 'only_uncategorized' => 'nullable|boolean', + 'transaction_ids' => 'nullable|array', + 'transaction_ids.*' => 'integer|exists:transactions,id', + ]); + + $onlyUncategorized = $validated['only_uncategorized'] ?? true; + $transactionIds = $validated['transaction_ids'] ?? null; + + // Buscar todas as categorias com keywords do usuário + $categories = Category::where('user_id', Auth::id()) + ->whereNotNull('parent_id') // Apenas subcategorias + ->with('keywords') + ->where('is_active', true) + ->get(); + + // Construir mapa de keywords -> categoria + $keywordMap = []; + foreach ($categories as $category) { + foreach ($category->keywords as $keyword) { + if ($keyword->is_active) { + $keywordMap[strtoupper($keyword->keyword)] = $category->id; + } + } + } + + if (empty($keywordMap)) { + return response()->json([ + 'success' => false, + 'message' => 'Nenhuma palavra-chave configurada nas categorias', + 'data' => [ + 'categorized' => 0, + 'skipped' => 0, + ] + ]); + } + + // Buscar transações para categorizar + $query = \App\Models\Transaction::where('user_id', Auth::id()); + + if ($onlyUncategorized) { + $query->whereNull('category_id'); + } + + if ($transactionIds) { + $query->whereIn('id', $transactionIds); + } + + $transactions = $query->get(); + + $categorized = 0; + $skipped = 0; + + foreach ($transactions as $transaction) { + // Usar description ou original_description + $text = strtoupper($transaction->original_description ?? $transaction->description ?? ''); + + $matched = false; + foreach ($keywordMap as $keyword => $categoryId) { + if (str_contains($text, $keyword)) { + $transaction->category_id = $categoryId; + $transaction->save(); + $categorized++; + $matched = true; + break; // Usar primeira keyword que bater + } + } + + if (!$matched) { + $skipped++; + } + } + + return response()->json([ + 'success' => true, + 'message' => "Categorização em lote concluída", + 'data' => [ + 'categorized' => $categorized, + 'skipped' => $skipped, + 'total_keywords' => count($keywordMap), + ] + ]); + } + + /** + * Preview da categorização em lote (sem salvar) + */ + public function categorizeBatchPreview(Request $request): JsonResponse + { + $validated = $request->validate([ + 'only_uncategorized' => 'nullable|boolean', + 'preview_limit' => 'nullable|integer|min:1|max:100', + 'filters' => 'nullable|array', + 'filters.account_id' => 'nullable|integer', + 'filters.category_id' => 'nullable|integer', + 'filters.cost_center_id' => 'nullable|integer', + 'filters.type' => 'nullable|string|in:credit,debit', + 'filters.status' => 'nullable|string|in:pending,completed,cancelled', + 'filters.start_date' => 'nullable|date', + 'filters.end_date' => 'nullable|date', + 'filters.search' => 'nullable|string|max:255', + ]); + + $onlyUncategorized = $validated['only_uncategorized'] ?? true; + $previewLimit = $validated['preview_limit'] ?? 50; + $filters = $validated['filters'] ?? []; + + // Buscar todas as categorias com keywords do usuário + $categories = Category::where('user_id', Auth::id()) + ->whereNotNull('parent_id') + ->with(['keywords', 'parent']) + ->where('is_active', true) + ->get(); + + // Construir mapa de keywords -> categoria + $keywordMap = []; + $categoryNames = []; + foreach ($categories as $category) { + $categoryNames[$category->id] = ($category->parent ? $category->parent->name . ' > ' : '') . $category->name; + foreach ($category->keywords as $keyword) { + if ($keyword->is_active) { + $keywordMap[strtoupper($keyword->keyword)] = [ + 'category_id' => $category->id, + 'category_name' => $categoryNames[$category->id], + 'keyword' => $keyword->keyword, + ]; + } + } + } + + // Buscar transações com filtros aplicados + $query = \App\Models\Transaction::where('user_id', Auth::id()); + + if ($onlyUncategorized) { + $query->whereNull('category_id'); + } + + // Aplicar filtros + if (!empty($filters['account_id'])) { + $query->where('account_id', $filters['account_id']); + } + if (!empty($filters['category_id'])) { + $query->where('category_id', $filters['category_id']); + } + if (!empty($filters['cost_center_id'])) { + $query->where('cost_center_id', $filters['cost_center_id']); + } + if (!empty($filters['type'])) { + $query->where('type', $filters['type']); + } + if (!empty($filters['status'])) { + $query->where('status', $filters['status']); + } + if (!empty($filters['start_date'])) { + $query->where(function ($q) use ($filters) { + $q->where('effective_date', '>=', $filters['start_date']) + ->orWhere(function ($q2) use ($filters) { + $q2->whereNull('effective_date') + ->where('planned_date', '>=', $filters['start_date']); + }); + }); + } + if (!empty($filters['end_date'])) { + $query->where(function ($q) use ($filters) { + $q->where('effective_date', '<=', $filters['end_date']) + ->orWhere(function ($q2) use ($filters) { + $q2->whereNull('effective_date') + ->where('planned_date', '<=', $filters['end_date']); + }); + }); + } + if (!empty($filters['search'])) { + $search = $filters['search']; + $query->where(function ($q) use ($search) { + $q->where('description', 'like', "%{$search}%") + ->orWhere('original_description', 'like', "%{$search}%") + ->orWhere('reference', 'like', "%{$search}%") + ->orWhere('notes', 'like', "%{$search}%"); + }); + } + + $allTransactions = $query->get(); + + $preview = []; + $wouldCategorize = 0; + $wouldSkip = 0; + $transactionIds = []; + + foreach ($allTransactions as $transaction) { + $text = strtoupper($transaction->original_description ?? $transaction->description ?? ''); + + $matched = null; + foreach ($keywordMap as $keyword => $info) { + if (str_contains($text, $keyword)) { + $matched = $info; + break; + } + } + + if ($matched) { + $wouldCategorize++; + $transactionIds[] = $transaction->id; + // Só adiciona preview até o limite + if (count($preview) < $previewLimit) { + $preview[] = [ + 'transaction_id' => $transaction->id, + 'description' => $transaction->description, + 'amount' => $transaction->amount ?? $transaction->planned_amount, + 'matched_keyword' => $matched['keyword'], + 'category_id' => $matched['category_id'], + 'category_name' => $matched['category_name'], + ]; + } + } else { + $wouldSkip++; + } + } + + // Contar total sem categoria (com filtros) + $totalUncategorized = $allTransactions->whereNull('category_id')->count(); + + return response()->json([ + 'success' => true, + 'data' => [ + 'preview' => $preview, + 'would_categorize' => $wouldCategorize, + 'would_skip' => $wouldSkip, + 'total_uncategorized' => $totalUncategorized, + 'total_keywords' => count($keywordMap), + 'total_filtered' => $allTransactions->count(), + 'transaction_ids' => $transactionIds, + ] + ]); + } + + /** + * Categorização em lote manual - aplicar categoria/centro de custo selecionados + */ + public function categorizeBatchManual(Request $request): JsonResponse + { + $validated = $request->validate([ + 'category_id' => 'nullable|integer|exists:categories,id', + 'cost_center_id' => 'nullable|integer|exists:cost_centers,id', + 'filters' => 'nullable|array', + 'filters.account_id' => 'nullable|integer', + 'filters.type' => 'nullable|string|in:credit,debit', + 'filters.status' => 'nullable|string|in:pending,completed,cancelled', + 'filters.start_date' => 'nullable|date', + 'filters.end_date' => 'nullable|date', + 'filters.search' => 'nullable|string|max:255', + 'add_keyword' => 'nullable|boolean', + ]); + + $categoryId = $validated['category_id'] ?? null; + $costCenterId = $validated['cost_center_id'] ?? null; + $filters = $validated['filters'] ?? []; + $addKeyword = $validated['add_keyword'] ?? false; + + // Verificar se pelo menos uma opção foi selecionada + if (!$categoryId && !$costCenterId) { + return response()->json([ + 'success' => false, + 'message' => 'Selecione pelo menos uma categoria ou centro de custo', + ], 400); + } + + // Verificar se a categoria pertence ao usuário + if ($categoryId) { + $category = Category::where('id', $categoryId) + ->where('user_id', Auth::id()) + ->first(); + if (!$category) { + return response()->json([ + 'success' => false, + 'message' => 'Categoria não encontrada', + ], 404); + } + } + + // Verificar se o centro de custo pertence ao usuário + if ($costCenterId) { + $costCenter = \App\Models\CostCenter::where('id', $costCenterId) + ->where('user_id', Auth::id()) + ->first(); + if (!$costCenter) { + return response()->json([ + 'success' => false, + 'message' => 'Centro de custo não encontrado', + ], 404); + } + } + + // Construir query com filtros + $query = \App\Models\Transaction::where('user_id', Auth::id()); + + if (!empty($filters['account_id'])) { + $query->where('account_id', $filters['account_id']); + } + if (!empty($filters['type'])) { + $query->where('type', $filters['type']); + } + if (!empty($filters['status'])) { + $query->where('status', $filters['status']); + } + if (!empty($filters['start_date'])) { + $query->where(function ($q) use ($filters) { + $q->where('effective_date', '>=', $filters['start_date']) + ->orWhere(function ($q2) use ($filters) { + $q2->whereNull('effective_date') + ->where('planned_date', '>=', $filters['start_date']); + }); + }); + } + if (!empty($filters['end_date'])) { + $query->where(function ($q) use ($filters) { + $q->where('effective_date', '<=', $filters['end_date']) + ->orWhere(function ($q2) use ($filters) { + $q2->whereNull('effective_date') + ->where('planned_date', '<=', $filters['end_date']); + }); + }); + } + if (!empty($filters['search'])) { + $search = $filters['search']; + $query->where(function ($q) use ($search) { + $q->where('description', 'like', "%{$search}%") + ->orWhere('original_description', 'like', "%{$search}%") + ->orWhere('reference', 'like', "%{$search}%") + ->orWhere('notes', 'like', "%{$search}%"); + }); + } + + // Atualizar transações + $updateData = []; + if ($categoryId) { + $updateData['category_id'] = $categoryId; + } + if ($costCenterId) { + $updateData['cost_center_id'] = $costCenterId; + } + + $updated = $query->update($updateData); + + // Adicionar keyword se solicitado e houver termo de busca + $keywordAdded = false; + $keywordText = null; + if ($addKeyword && !empty($filters['search']) && strlen(trim($filters['search'])) >= 2) { + $keywordText = trim($filters['search']); + + // Adicionar keyword à categoria (se selecionada) + if ($categoryId) { + $existingCategoryKeyword = \App\Models\CategoryKeyword::where('category_id', $categoryId) + ->whereRaw('UPPER(keyword) = ?', [strtoupper($keywordText)]) + ->first(); + + if (!$existingCategoryKeyword) { + \App\Models\CategoryKeyword::create([ + 'category_id' => $categoryId, + 'keyword' => $keywordText, + 'is_case_sensitive' => false, + 'is_active' => true, + ]); + $keywordAdded = true; + } + } + + // Adicionar keyword ao centro de custo (se selecionado) + if ($costCenterId) { + $existingCostCenterKeyword = \App\Models\CostCenterKeyword::where('cost_center_id', $costCenterId) + ->whereRaw('UPPER(keyword) = ?', [strtoupper($keywordText)]) + ->first(); + + if (!$existingCostCenterKeyword) { + \App\Models\CostCenterKeyword::create([ + 'cost_center_id' => $costCenterId, + 'keyword' => $keywordText, + 'is_case_sensitive' => false, + 'is_active' => true, + ]); + $keywordAdded = true; + } + } + } + + return response()->json([ + 'success' => true, + 'message' => 'Categorização em lote concluída', + 'data' => [ + 'updated' => $updated, + 'keyword_added' => $keywordAdded, + 'keyword_text' => $keywordText, + ] + ]); + } +} diff --git a/backend/app/Http/Controllers/Api/CostCenterController.php b/backend/app/Http/Controllers/Api/CostCenterController.php new file mode 100644 index 0000000..8944d2b --- /dev/null +++ b/backend/app/Http/Controllers/Api/CostCenterController.php @@ -0,0 +1,289 @@ +where('is_system', true) + ->exists(); + + if (!$hasSystemCostCenter) { + CostCenter::create([ + 'user_id' => $userId, + 'name' => 'Geral', + 'code' => 'GERAL', + 'description' => 'Centro de custo padrão para transações não categorizadas', + 'color' => '#6c757d', + 'icon' => 'FaFolder', + 'is_active' => true, + 'is_system' => true, + ]); + } + + $query = CostCenter::where('user_id', $userId) + ->with('keywords'); + + if ($request->has('is_active')) { + $query->where('is_active', $request->boolean('is_active')); + } + + // Ordenar com centro de custo do sistema primeiro, depois por nome + $costCenters = $query->orderByDesc('is_system')->orderBy('name')->get(); + + return response()->json([ + 'success' => true, + 'data' => $costCenters, + ]); + } + + /** + * Criar novo centro de custo + */ + public function store(Request $request): JsonResponse + { + $validated = $request->validate([ + 'name' => 'required|string|max:100', + 'code' => 'nullable|string|max:20|unique:cost_centers,code,NULL,id,user_id,' . Auth::id(), + 'description' => 'nullable|string', + 'color' => 'nullable|string|max:7', + 'icon' => 'nullable|string|max:50', + 'is_active' => 'nullable|boolean', + 'keywords' => 'nullable|array', + 'keywords.*' => 'string|max:100', + ]); + + $keywords = $validated['keywords'] ?? []; + unset($validated['keywords']); + + $validated['user_id'] = Auth::id(); + + DB::beginTransaction(); + try { + $costCenter = CostCenter::create($validated); + + // Adicionar palavras-chave + foreach ($keywords as $keyword) { + $costCenter->keywords()->create([ + 'keyword' => trim($keyword), + 'is_case_sensitive' => false, + 'is_active' => true, + ]); + } + + DB::commit(); + + return response()->json([ + 'success' => true, + 'message' => 'Centro de custo criado com sucesso', + 'data' => $costCenter->load('keywords'), + ], 201); + + } catch (\Exception $e) { + DB::rollBack(); + return response()->json([ + 'success' => false, + 'message' => 'Erro ao criar centro de custo: ' . $e->getMessage(), + ], 500); + } + } + + /** + * Exibir um centro de custo específico + */ + public function show(int $id): JsonResponse + { + $costCenter = CostCenter::where('user_id', Auth::id()) + ->with('keywords') + ->findOrFail($id); + + return response()->json([ + 'success' => true, + 'data' => $costCenter, + ]); + } + + /** + * Atualizar um centro de custo + */ + public function update(Request $request, int $id): JsonResponse + { + $costCenter = CostCenter::where('user_id', Auth::id())->findOrFail($id); + + // Impedir edição de centro de custo do sistema + if ($costCenter->is_system) { + return response()->json([ + 'success' => false, + 'message' => 'O centro de custo do sistema não pode ser editado', + ], 403); + } + + $validated = $request->validate([ + 'name' => 'sometimes|required|string|max:100', + 'code' => 'nullable|string|max:20|unique:cost_centers,code,' . $id . ',id,user_id,' . Auth::id(), + 'description' => 'nullable|string', + 'color' => 'nullable|string|max:7', + 'icon' => 'nullable|string|max:50', + 'is_active' => 'nullable|boolean', + 'keywords' => 'nullable|array', + 'keywords.*' => 'string|max:100', + ]); + + $keywords = $validated['keywords'] ?? null; + unset($validated['keywords']); + + DB::beginTransaction(); + try { + $costCenter->update($validated); + + // Se keywords foram fornecidas, sincronizar + if ($keywords !== null) { + // Remover antigas + $costCenter->keywords()->delete(); + + // Adicionar novas + foreach ($keywords as $keyword) { + $costCenter->keywords()->create([ + 'keyword' => trim($keyword), + 'is_case_sensitive' => false, + 'is_active' => true, + ]); + } + } + + DB::commit(); + + return response()->json([ + 'success' => true, + 'message' => 'Centro de custo atualizado com sucesso', + 'data' => $costCenter->fresh()->load('keywords'), + ]); + + } catch (\Exception $e) { + DB::rollBack(); + return response()->json([ + 'success' => false, + 'message' => 'Erro ao atualizar centro de custo: ' . $e->getMessage(), + ], 500); + } + } + + /** + * Deletar um centro de custo (soft delete) + */ + public function destroy(int $id): JsonResponse + { + $costCenter = CostCenter::where('user_id', Auth::id())->findOrFail($id); + + // Impedir exclusão de centro de custo do sistema + if ($costCenter->is_system) { + return response()->json([ + 'success' => false, + 'message' => 'O centro de custo do sistema não pode ser excluído', + ], 403); + } + + $costCenter->keywords()->delete(); + $costCenter->delete(); + + return response()->json([ + 'success' => true, + 'message' => 'Centro de custo excluído com sucesso', + ]); + } + + /** + * Adicionar palavra-chave a um centro de custo + */ + public function addKeyword(Request $request, int $id): JsonResponse + { + $costCenter = CostCenter::where('user_id', Auth::id())->findOrFail($id); + + $validated = $request->validate([ + 'keyword' => 'required|string|max:100', + 'is_case_sensitive' => 'nullable|boolean', + ]); + + $keyword = $costCenter->keywords()->create([ + 'keyword' => trim($validated['keyword']), + 'is_case_sensitive' => $validated['is_case_sensitive'] ?? false, + 'is_active' => true, + ]); + + return response()->json([ + 'success' => true, + 'message' => 'Palavra-chave adicionada com sucesso', + 'data' => $keyword, + ], 201); + } + + /** + * Remover palavra-chave de um centro de custo + */ + public function removeKeyword(int $id, int $keywordId): JsonResponse + { + $costCenter = CostCenter::where('user_id', Auth::id())->findOrFail($id); + $keyword = $costCenter->keywords()->findOrFail($keywordId); + $keyword->delete(); + + return response()->json([ + 'success' => true, + 'message' => 'Palavra-chave removida com sucesso', + ]); + } + + /** + * Encontrar centro de custo por texto (usando palavras-chave) + */ + public function matchByText(Request $request): JsonResponse + { + $validated = $request->validate([ + 'text' => 'required|string', + ]); + + $text = $validated['text']; + $textLower = strtolower($text); + + $keywords = CostCenterKeyword::whereHas('costCenter', function ($query) { + $query->where('user_id', Auth::id())->where('is_active', true); + }) + ->where('is_active', true) + ->with('costCenter') + ->get(); + + $matches = []; + foreach ($keywords as $keyword) { + $searchText = $keyword->is_case_sensitive ? $text : $textLower; + $searchKeyword = $keyword->is_case_sensitive ? $keyword->keyword : strtolower($keyword->keyword); + + if (str_contains($searchText, $searchKeyword)) { + $matches[] = [ + 'cost_center' => $keyword->costCenter, + 'matched_keyword' => $keyword->keyword, + ]; + } + } + + return response()->json([ + 'success' => true, + 'data' => $matches, + ]); + } +} diff --git a/backend/app/Http/Controllers/Api/DashboardController.php b/backend/app/Http/Controllers/Api/DashboardController.php new file mode 100644 index 0000000..3cd425c --- /dev/null +++ b/backend/app/Http/Controllers/Api/DashboardController.php @@ -0,0 +1,885 @@ +user()->id; + + // Parâmetros de período (padrão: últimos 12 meses) + $months = min((int) $request->get('months', 12), 24); // máximo 24 meses + $endDate = Carbon::parse($request->get('end_date', now()->endOfMonth())); + $startDate = $endDate->copy()->subMonths($months - 1)->startOfMonth(); + + // Buscar dados mensais de transações completed (excluindo transferências) + // Usar strftime para SQLite ou DATE_FORMAT para MySQL + $driver = DB::connection()->getDriverName(); + $monthExpression = $driver === 'sqlite' + ? "strftime('%Y-%m', effective_date)" + : "DATE_FORMAT(effective_date, '%Y-%m')"; + + $monthlyData = Transaction::ofUser($userId) + ->completed() + ->where('is_transfer', false) // Ignorar transferências entre contas + ->whereBetween('effective_date', [$startDate, $endDate]) + ->select( + DB::raw("$monthExpression as month"), + DB::raw("SUM(CASE WHEN type = 'credit' THEN amount ELSE 0 END) as income"), + DB::raw("SUM(CASE WHEN type = 'debit' THEN amount ELSE 0 END) as expense") + ) + ->groupBy('month') + ->orderBy('month') + ->get() + ->keyBy('month'); + + // Criar array com todos os meses (mesmo sem dados) + $result = []; + $current = $startDate->copy(); + $cumulativeBalance = 0; + + // Calcular saldo inicial (antes do período) - excluindo transferências + $initialBalance = Transaction::ofUser($userId) + ->completed() + ->where('is_transfer', false) + ->where('effective_date', '<', $startDate) + ->selectRaw("SUM(CASE WHEN type = 'credit' THEN amount ELSE -amount END) as balance") + ->value('balance') ?? 0; + + $cumulativeBalance = (float) $initialBalance; + + while ($current <= $endDate) { + $monthKey = $current->format('Y-m'); + $data = $monthlyData->get($monthKey); + + $income = (float) ($data->income ?? 0); + $expense = (float) ($data->expense ?? 0); + $balance = $income - $expense; + $cumulativeBalance += $balance; + + $result[] = [ + 'month' => $monthKey, + 'month_label' => $current->translatedFormat('M/Y'), + 'income' => $income, + 'expense' => $expense, + 'balance' => $balance, + 'cumulative_balance' => $cumulativeBalance, + ]; + + $current->addMonth(); + } + + // Totais do período + $totals = [ + 'income' => array_sum(array_column($result, 'income')), + 'expense' => array_sum(array_column($result, 'expense')), + 'balance' => array_sum(array_column($result, 'balance')), + 'average_income' => count($result) > 0 ? array_sum(array_column($result, 'income')) / count($result) : 0, + 'average_expense' => count($result) > 0 ? array_sum(array_column($result, 'expense')) / count($result) : 0, + ]; + + return response()->json([ + 'period' => [ + 'start' => $startDate->format('Y-m-d'), + 'end' => $endDate->format('Y-m-d'), + 'months' => $months, + ], + 'data' => $result, + 'totals' => $totals, + ]); + } + + /** + * Resumo geral do dashboard + * Ignora transações marcadas como transferências entre contas + */ + public function summary(Request $request): JsonResponse + { + $userId = $request->user()->id; + + // Saldo total das contas + $totalBalance = Account::where('user_id', $userId) + ->where('is_active', true) + ->where('include_in_total', true) + ->sum('current_balance'); + + // Transações do mês atual (excluindo transferências) + $currentMonth = [ + now()->startOfMonth()->format('Y-m-d'), + now()->endOfMonth()->format('Y-m-d') + ]; + + $monthlyStats = Transaction::ofUser($userId) + ->completed() + ->where('is_transfer', false) // Ignorar transferências entre contas + ->whereBetween('effective_date', $currentMonth) + ->select( + DB::raw("SUM(CASE WHEN type = 'credit' THEN amount ELSE 0 END) as income"), + DB::raw("SUM(CASE WHEN type = 'debit' THEN amount ELSE 0 END) as expense"), + DB::raw("COUNT(*) as transactions_count") + ) + ->first(); + + // Pendentes (excluindo transferências) + $pending = Transaction::ofUser($userId) + ->pending() + ->where('is_transfer', false) + ->select( + DB::raw("SUM(CASE WHEN type = 'credit' THEN planned_amount ELSE 0 END) as income"), + DB::raw("SUM(CASE WHEN type = 'debit' THEN planned_amount ELSE 0 END) as expense"), + DB::raw("COUNT(*) as count") + ) + ->first(); + + // Atrasadas (vencidas) - excluindo transferências + $overdue = Transaction::ofUser($userId) + ->pending() + ->where('is_transfer', false) + ->where('planned_date', '<', now()->startOfDay()) + ->select( + DB::raw("SUM(planned_amount) as total"), + DB::raw("COUNT(*) as count") + ) + ->first(); + + return response()->json([ + 'total_balance' => (float) $totalBalance, + 'current_month' => [ + 'income' => (float) ($monthlyStats->income ?? 0), + 'expense' => (float) ($monthlyStats->expense ?? 0), + 'balance' => (float) (($monthlyStats->income ?? 0) - ($monthlyStats->expense ?? 0)), + 'transactions_count' => (int) ($monthlyStats->transactions_count ?? 0), + ], + 'pending' => [ + 'income' => (float) ($pending->income ?? 0), + 'expense' => (float) ($pending->expense ?? 0), + 'count' => (int) ($pending->count ?? 0), + ], + 'overdue' => [ + 'total' => (float) ($overdue->total ?? 0), + 'count' => (int) ($overdue->count ?? 0), + ], + ]); + } + + /** + * Despesas por categoria (últimos N meses) + * Ignora transações marcadas como transferências entre contas + */ + public function expensesByCategory(Request $request): JsonResponse + { + $userId = $request->user()->id; + $months = min((int) $request->get('months', 3), 12); + + $startDate = now()->subMonths($months - 1)->startOfMonth(); + $endDate = now()->endOfMonth(); + + $data = Transaction::ofUser($userId) + ->completed() + ->where('is_transfer', false) // Ignorar transferências entre contas + ->where('type', 'debit') + ->whereBetween('effective_date', [$startDate, $endDate]) + ->whereNotNull('category_id') + ->select( + 'category_id', + DB::raw('SUM(amount) as total'), + DB::raw('COUNT(*) as count') + ) + ->groupBy('category_id') + ->with('category:id,name,color,icon') + ->orderByDesc('total') + ->limit(10) + ->get(); + + $total = $data->sum('total'); + + $result = $data->map(function ($item) use ($total) { + return [ + 'category_id' => $item->category_id, + 'category_name' => $item->category->name ?? 'Sem categoria', + 'category_color' => $item->category->color ?? '#6b7280', + 'category_icon' => $item->category->icon ?? 'bi-tag', + 'total' => (float) $item->total, + 'count' => (int) $item->count, + 'percentage' => $total > 0 ? round(($item->total / $total) * 100, 1) : 0, + ]; + }); + + return response()->json([ + 'period' => [ + 'start' => $startDate->format('Y-m-d'), + 'end' => $endDate->format('Y-m-d'), + 'months' => $months, + ], + 'data' => $result, + 'total' => (float) $total, + ]); + } + + /** + * Receitas por categoria (últimos N meses) + * Ignora transações marcadas como transferências entre contas + */ + public function incomeByCategory(Request $request): JsonResponse + { + $userId = $request->user()->id; + $months = min((int) $request->get('months', 3), 12); + + $startDate = now()->subMonths($months - 1)->startOfMonth(); + $endDate = now()->endOfMonth(); + + $data = Transaction::ofUser($userId) + ->completed() + ->where('is_transfer', false) // Ignorar transferências entre contas + ->where('type', 'credit') + ->whereBetween('effective_date', [$startDate, $endDate]) + ->whereNotNull('category_id') + ->select( + 'category_id', + DB::raw('SUM(amount) as total'), + DB::raw('COUNT(*) as count') + ) + ->groupBy('category_id') + ->with('category:id,name,color,icon') + ->orderByDesc('total') + ->limit(10) + ->get(); + + $total = $data->sum('total'); + + $result = $data->map(function ($item) use ($total) { + return [ + 'category_id' => $item->category_id, + 'category_name' => $item->category->name ?? 'Sem categoria', + 'category_color' => $item->category->color ?? '#6b7280', + 'category_icon' => $item->category->icon ?? 'bi-tag', + 'total' => (float) $item->total, + 'count' => (int) $item->count, + 'percentage' => $total > 0 ? round(($item->total / $total) * 100, 1) : 0, + ]; + }); + + return response()->json([ + 'period' => [ + 'start' => $startDate->format('Y-m-d'), + 'end' => $endDate->format('Y-m-d'), + 'months' => $months, + ], + 'data' => $result, + 'total' => (float) $total, + ]); + } + + /** + * Análise de diferenças entre valores planejados e efetivos + * Mostra sobrepagamentos e subpagamentos + */ + public function paymentVariances(Request $request): JsonResponse + { + $userId = $request->user()->id; + $months = min((int) $request->get('months', 3), 12); + + $startDate = now()->subMonths($months - 1)->startOfMonth(); + $endDate = now()->endOfMonth(); + + // Buscar transações completed com diferença entre planned_amount e amount + $transactions = Transaction::ofUser($userId) + ->completed() + ->where('is_transfer', false) + ->whereBetween('effective_date', [$startDate, $endDate]) + ->whereRaw('ABS(amount - planned_amount) > 0.01') // Diferença maior que 1 centavo + ->with(['category:id,name,color,icon', 'account:id,name']) + ->orderByRaw('ABS(amount - planned_amount) DESC') + ->limit(50) + ->get(); + + $result = $transactions->map(function ($t) { + $variance = $t->amount - $t->planned_amount; + $variancePercent = $t->planned_amount > 0 + ? round(($variance / $t->planned_amount) * 100, 2) + : 0; + + // Calcular dias de atraso (diferença entre effective_date e planned_date) + $delayDays = null; + if ($t->planned_date && $t->effective_date) { + $delayDays = $t->planned_date->diffInDays($t->effective_date, false); + // Positivo = pago depois do planejado (atrasado) + // Negativo = pago antes do planejado (adiantado) + } + + return [ + 'id' => $t->id, + 'description' => $t->description, + 'type' => $t->type, + 'planned_amount' => (float) $t->planned_amount, + 'actual_amount' => (float) $t->amount, + 'variance' => (float) $variance, + 'variance_percent' => $variancePercent, + 'effective_date' => $t->effective_date->format('Y-m-d'), + 'planned_date' => $t->planned_date ? $t->planned_date->format('Y-m-d') : null, + 'delay_days' => $delayDays, + 'category' => $t->category ? [ + 'id' => $t->category->id, + 'name' => $t->category->name, + 'color' => $t->category->color, + ] : null, + 'account' => $t->account ? [ + 'id' => $t->account->id, + 'name' => $t->account->name, + ] : null, + ]; + }); + + // Calcular totais + $overpayments = $result->filter(fn($t) => $t['variance'] > 0); + $underpayments = $result->filter(fn($t) => $t['variance'] < 0); + + // Agrupar por mês para o gráfico + $byMonth = $transactions->groupBy(function ($t) { + return $t->effective_date->format('Y-m'); + })->map(function ($items, $month) { + $over = $items->filter(fn($t) => $t->amount > $t->planned_amount) + ->sum(fn($t) => $t->amount - $t->planned_amount); + $under = $items->filter(fn($t) => $t->amount < $t->planned_amount) + ->sum(fn($t) => $t->planned_amount - $t->amount); + + return [ + 'month' => $month, + 'overpayment' => round($over, 2), + 'underpayment' => round($under, 2), + 'net' => round($over - $under, 2), + 'count' => $items->count(), + ]; + })->sortKeys()->values(); + + return response()->json([ + 'period' => [ + 'start' => $startDate->format('Y-m-d'), + 'end' => $endDate->format('Y-m-d'), + 'months' => $months, + ], + 'summary' => [ + 'total_overpayment' => round($overpayments->sum('variance'), 2), + 'total_underpayment' => round(abs($underpayments->sum('variance')), 2), + 'net_variance' => round($result->sum('variance'), 2), + 'overpayment_count' => $overpayments->count(), + 'underpayment_count' => $underpayments->count(), + ], + 'by_month' => $byMonth, + 'transactions' => $result, + ]); + } + + /** + * Dados do calendário para o dashboard + * Retorna transações e instâncias recorrentes pendentes por data + */ + public function calendar(Request $request): JsonResponse + { + $userId = $request->user()->id; + + // Período: mês atual por padrão, ou o mês especificado + $year = (int) $request->get('year', now()->year); + $month = (int) $request->get('month', now()->month); + + $startDate = Carbon::create($year, $month, 1)->startOfMonth(); + $endDate = $startDate->copy()->endOfMonth(); + + // Buscar transações do período + $transactions = Transaction::ofUser($userId) + ->whereBetween('effective_date', [$startDate, $endDate]) + ->with(['account:id,name,currency', 'category:id,name,color,icon']) + ->orderBy('effective_date') + ->get() + ->map(function ($t) { + return [ + 'id' => $t->id, + 'type' => 'transaction', + 'date' => $t->effective_date->format('Y-m-d'), + 'description' => $t->description, + 'amount' => (float) $t->amount, + 'transaction_type' => $t->type, + 'status' => $t->status, + 'is_transfer' => $t->is_transfer, + 'account' => $t->account ? [ + 'id' => $t->account->id, + 'name' => $t->account->name, + 'currency' => $t->account->currency, + ] : null, + 'category' => $t->category ? [ + 'id' => $t->category->id, + 'name' => $t->category->name, + 'color' => $t->category->color, + 'icon' => $t->category->icon, + ] : null, + ]; + }); + + // Buscar instâncias recorrentes pendentes do período + $recurringInstances = RecurringInstance::where('user_id', $userId) + ->whereBetween('due_date', [$startDate, $endDate]) + ->where('status', 'pending') + ->whereNull('transaction_id') // Não reconciliadas + ->with(['template:id,name,type,planned_amount,account_id,category_id', 'template.account:id,name,currency', 'template.category:id,name,color,icon']) + ->orderBy('due_date') + ->get() + ->map(function ($ri) { + return [ + 'id' => $ri->id, + 'type' => 'recurring', + 'date' => $ri->due_date->format('Y-m-d'), + 'description' => $ri->template->name ?? 'Recorrência', + 'amount' => (float) $ri->planned_amount, + 'transaction_type' => $ri->template->type ?? 'debit', + 'status' => $ri->status, + 'occurrence_number' => $ri->occurrence_number, + 'template_id' => $ri->recurring_template_id, + 'account' => $ri->template && $ri->template->account ? [ + 'id' => $ri->template->account->id, + 'name' => $ri->template->account->name, + 'currency' => $ri->template->account->currency, + ] : null, + 'category' => $ri->template && $ri->template->category ? [ + 'id' => $ri->template->category->id, + 'name' => $ri->template->category->name, + 'color' => $ri->template->category->color, + 'icon' => $ri->template->category->icon, + ] : null, + ]; + }); + + // Combinar e agrupar por data + $allItems = $transactions->concat($recurringInstances); + + $byDate = $allItems->groupBy('date')->map(function ($items, $date) { + return [ + 'date' => $date, + 'items' => $items->values(), + 'total_credit' => $items->where('transaction_type', 'credit')->sum('amount'), + 'total_debit' => $items->where('transaction_type', 'debit')->sum('amount'), + 'has_transactions' => $items->where('type', 'transaction')->count() > 0, + 'has_recurring' => $items->where('type', 'recurring')->count() > 0, + 'pending_count' => $items->whereIn('status', ['pending', 'scheduled'])->count(), + ]; + })->values(); + + // Resumo do mês (excluindo transferências entre contas) + $nonTransferTransactions = $transactions->where('is_transfer', false); + $summary = [ + 'transactions_count' => $nonTransferTransactions->count(), + 'recurring_count' => $recurringInstances->count(), + 'total_income' => $nonTransferTransactions->where('transaction_type', 'credit')->sum('amount'), + 'total_expense' => $nonTransferTransactions->where('transaction_type', 'debit')->sum('amount'), + 'pending_recurring' => $recurringInstances->count(), + 'pending_recurring_amount' => $recurringInstances->sum('amount'), + ]; + + return response()->json([ + 'period' => [ + 'year' => $year, + 'month' => $month, + 'start' => $startDate->format('Y-m-d'), + 'end' => $endDate->format('Y-m-d'), + ], + 'by_date' => $byDate, + 'summary' => $summary, + ]); + } + + /** + * Transações e recorrências de um dia específico + */ + public function calendarDay(Request $request): JsonResponse + { + $userId = $request->user()->id; + $date = Carbon::parse($request->get('date', now()->format('Y-m-d'))); + + // Buscar transações do dia + $transactions = Transaction::ofUser($userId) + ->whereDate('effective_date', $date) + ->with(['account:id,name,currency', 'category:id,name,color,icon']) + ->orderBy('effective_date') + ->orderBy('created_at') + ->get() + ->map(function ($t) { + return [ + 'id' => $t->id, + 'type' => 'transaction', + 'date' => $t->effective_date->format('Y-m-d'), + 'description' => $t->description, + 'amount' => (float) $t->amount, + 'transaction_type' => $t->type, + 'status' => $t->status, + 'is_transfer' => $t->is_transfer, + 'notes' => $t->notes, + 'account' => $t->account ? [ + 'id' => $t->account->id, + 'name' => $t->account->name, + 'currency' => $t->account->currency, + ] : null, + 'category' => $t->category ? [ + 'id' => $t->category->id, + 'name' => $t->category->name, + 'color' => $t->category->color, + 'icon' => $t->category->icon, + ] : null, + ]; + }); + + // Buscar instâncias recorrentes pendentes do dia + $recurringInstances = RecurringInstance::where('user_id', $userId) + ->whereDate('due_date', $date) + ->where('status', 'pending') + ->whereNull('transaction_id') + ->with(['template:id,name,type,planned_amount,account_id,category_id,description,transaction_description', 'template.account:id,name,currency', 'template.category:id,name,color,icon']) + ->orderBy('due_date') + ->get() + ->map(function ($ri) { + return [ + 'id' => $ri->id, + 'type' => 'recurring', + 'date' => $ri->due_date->format('Y-m-d'), + 'description' => $ri->template->name ?? 'Recorrência', + 'amount' => (float) $ri->planned_amount, + 'transaction_type' => $ri->template->type ?? 'debit', + 'status' => $ri->status, + 'occurrence_number' => $ri->occurrence_number, + 'template_id' => $ri->recurring_template_id, + 'notes' => $ri->template->description ?? null, + 'account' => $ri->template && $ri->template->account ? [ + 'id' => $ri->template->account->id, + 'name' => $ri->template->account->name, + 'currency' => $ri->template->account->currency, + ] : null, + 'category' => $ri->template && $ri->template->category ? [ + 'id' => $ri->template->category->id, + 'name' => $ri->template->category->name, + 'color' => $ri->template->category->color, + 'icon' => $ri->template->category->icon, + ] : null, + ]; + }); + + // Combinar + $allItems = $transactions->concat($recurringInstances); + + // Para o resumo, excluir transferências entre contas + $nonTransferItems = $allItems->filter(fn($item) => !($item['is_transfer'] ?? false)); + + return response()->json([ + 'date' => $date->format('Y-m-d'), + 'date_formatted' => $date->translatedFormat('l, d F Y'), + 'items' => $allItems->values(), + 'summary' => [ + 'transactions_count' => $transactions->count(), + 'recurring_count' => $recurringInstances->count(), + 'total_credit' => $nonTransferItems->where('transaction_type', 'credit')->sum('amount'), + 'total_debit' => $nonTransferItems->where('transaction_type', 'debit')->sum('amount'), + ], + ]); + } + + /** + * Transações pendentes dos próximos dias (incluindo hoje) + */ + public function upcomingTransactions(Request $request): JsonResponse + { + $userId = $request->user()->id; + $days = min((int) $request->get('days', 7), 30); // máximo 30 dias + + $startDate = now()->startOfDay(); + $endDate = now()->addDays($days - 1)->endOfDay(); + + // Buscar transações pendentes do período + $transactions = Transaction::ofUser($userId) + ->whereIn('status', ['pending', 'scheduled']) + ->whereBetween('effective_date', [$startDate, $endDate]) + ->with(['account:id,name,currency', 'category:id,name,color,icon']) + ->orderBy('effective_date') + ->orderBy('created_at') + ->get() + ->map(function ($t) { + return [ + 'id' => $t->id, + 'type' => 'transaction', + 'date' => $t->effective_date->format('Y-m-d'), + 'date_formatted' => $t->effective_date->translatedFormat('D, d M'), + 'description' => $t->description, + 'amount' => (float) $t->amount, + 'transaction_type' => $t->type, + 'status' => $t->status, + 'is_transfer' => $t->is_transfer, + 'days_until' => (int) now()->startOfDay()->diffInDays($t->effective_date, false), + 'account' => $t->account ? [ + 'id' => $t->account->id, + 'name' => $t->account->name, + 'currency' => $t->account->currency, + ] : null, + 'category' => $t->category ? [ + 'id' => $t->category->id, + 'name' => $t->category->name, + 'color' => $t->category->color, + 'icon' => $t->category->icon, + ] : null, + ]; + }); + + // Buscar instâncias recorrentes pendentes do período + $recurringInstances = RecurringInstance::where('user_id', $userId) + ->where('status', 'pending') + ->whereNull('transaction_id') + ->whereBetween('due_date', [$startDate, $endDate]) + ->with(['template:id,name,type,planned_amount,account_id,category_id', 'template.account:id,name,currency', 'template.category:id,name,color,icon']) + ->orderBy('due_date') + ->get() + ->map(function ($ri) { + return [ + 'id' => $ri->id, + 'type' => 'recurring', + 'date' => $ri->due_date->format('Y-m-d'), + 'date_formatted' => $ri->due_date->translatedFormat('D, d M'), + 'description' => $ri->template->name ?? 'Recorrência', + 'amount' => (float) $ri->planned_amount, + 'transaction_type' => $ri->template->type ?? 'debit', + 'status' => $ri->status, + 'occurrence_number' => $ri->occurrence_number, + 'template_id' => $ri->recurring_template_id, + 'days_until' => (int) now()->startOfDay()->diffInDays($ri->due_date, false), + 'account' => $ri->template && $ri->template->account ? [ + 'id' => $ri->template->account->id, + 'name' => $ri->template->account->name, + 'currency' => $ri->template->account->currency, + ] : null, + 'category' => $ri->template && $ri->template->category ? [ + 'id' => $ri->template->category->id, + 'name' => $ri->template->category->name, + 'color' => $ri->template->category->color, + 'icon' => $ri->template->category->icon, + ] : null, + ]; + }); + + // Combinar e ordenar por data + $allItems = $transactions->concat($recurringInstances) + ->sortBy('date') + ->values(); + + // Agrupar por data + $byDate = $allItems->groupBy('date')->map(function ($items, $date) { + $carbonDate = Carbon::parse($date); + $daysUntil = (int) now()->startOfDay()->diffInDays($carbonDate, false); + + return [ + 'date' => $date, + 'date_formatted' => $carbonDate->translatedFormat('l, d M'), + 'days_until' => $daysUntil, + 'is_today' => $daysUntil === 0, + 'items' => $items->values(), + 'total_credit' => $items->where('transaction_type', 'credit')->where('is_transfer', '!==', true)->sum('amount'), + 'total_debit' => $items->where('transaction_type', 'debit')->where('is_transfer', '!==', true)->sum('amount'), + ]; + })->values(); + + // Totais gerais (excluindo transferências) + $nonTransferItems = $allItems->filter(fn($item) => !($item['is_transfer'] ?? false)); + $summary = [ + 'total_items' => $allItems->count(), + 'transactions_count' => $transactions->count(), + 'recurring_count' => $recurringInstances->count(), + 'total_credit' => $nonTransferItems->where('transaction_type', 'credit')->sum('amount'), + 'total_debit' => $nonTransferItems->where('transaction_type', 'debit')->sum('amount'), + ]; + + return response()->json([ + 'period' => [ + 'start' => $startDate->format('Y-m-d'), + 'end' => $endDate->format('Y-m-d'), + 'days' => $days, + ], + 'by_date' => $byDate, + 'items' => $allItems, + 'summary' => $summary, + ]); + } + + /** + * Transações em atraso (vencidas e não pagas) + */ + public function overdueTransactions(Request $request): JsonResponse + { + $userId = $request->user()->id; + $limit = min((int) $request->get('limit', 50), 100); // máximo 100 + + $today = now()->startOfDay(); + + // Buscar transações pendentes com data planejada no passado + $transactions = Transaction::ofUser($userId) + ->whereIn('status', ['pending', 'scheduled']) + ->where('is_transfer', false) + ->where('planned_date', '<', $today) + ->with(['account:id,name,currency', 'category:id,name,color,icon']) + ->orderBy('planned_date') + ->limit($limit) + ->get() + ->map(function ($t) use ($today) { + $plannedDate = Carbon::parse($t->planned_date); + $daysOverdue = (int) $plannedDate->diffInDays($today); + + return [ + 'id' => $t->id, + 'type' => 'transaction', + 'planned_date' => $t->planned_date->format('Y-m-d'), + 'planned_date_formatted' => $t->planned_date->translatedFormat('D, d M Y'), + 'description' => $t->description, + 'amount' => (float) ($t->planned_amount ?? $t->amount), + 'transaction_type' => $t->type, + 'status' => $t->status, + 'days_overdue' => $daysOverdue, + 'account' => $t->account ? [ + 'id' => $t->account->id, + 'name' => $t->account->name, + 'currency' => $t->account->currency, + ] : null, + 'category' => $t->category ? [ + 'id' => $t->category->id, + 'name' => $t->category->name, + 'color' => $t->category->color, + 'icon' => $t->category->icon, + ] : null, + ]; + }); + + // Buscar instâncias recorrentes em atraso + $recurringInstances = RecurringInstance::where('user_id', $userId) + ->where('status', 'pending') + ->whereNull('transaction_id') + ->where('due_date', '<', $today) + ->with(['template:id,name,type,planned_amount,account_id,category_id', 'template.account:id,name,currency', 'template.category:id,name,color,icon']) + ->orderBy('due_date') + ->limit($limit) + ->get() + ->map(function ($ri) use ($today) { + $dueDate = Carbon::parse($ri->due_date); + $daysOverdue = (int) $dueDate->diffInDays($today); + + return [ + 'id' => $ri->id, + 'type' => 'recurring', + 'planned_date' => $ri->due_date->format('Y-m-d'), + 'planned_date_formatted' => $ri->due_date->translatedFormat('D, d M Y'), + 'description' => $ri->template->name ?? 'Recorrência', + 'amount' => (float) $ri->planned_amount, + 'transaction_type' => $ri->template->type ?? 'debit', + 'status' => $ri->status, + 'occurrence_number' => $ri->occurrence_number, + 'template_id' => $ri->recurring_template_id, + 'days_overdue' => $daysOverdue, + 'account' => $ri->template && $ri->template->account ? [ + 'id' => $ri->template->account->id, + 'name' => $ri->template->account->name, + 'currency' => $ri->template->account->currency, + ] : null, + 'category' => $ri->template && $ri->template->category ? [ + 'id' => $ri->template->category->id, + 'name' => $ri->template->category->name, + 'color' => $ri->template->category->color, + 'icon' => $ri->template->category->icon, + ] : null, + ]; + }); + + // Buscar parcelas de passivo em atraso + $liabilityInstallments = LiabilityInstallment::whereHas('liabilityAccount', function ($query) use ($userId) { + $query->where('user_id', $userId); + }) + ->where('status', 'pending') + ->where('due_date', '<', $today) + ->with(['liabilityAccount:id,name,creditor,currency']) + ->orderBy('due_date') + ->limit($limit) + ->get() + ->map(function ($li) use ($today) { + $dueDate = Carbon::parse($li->due_date); + $daysOverdue = (int) $dueDate->diffInDays($today); + + return [ + 'id' => $li->id, + 'type' => 'liability', + 'planned_date' => $li->due_date->format('Y-m-d'), + 'planned_date_formatted' => $li->due_date->translatedFormat('D, d M Y'), + 'description' => $li->liabilityAccount->name . ' - Parcela ' . $li->installment_number, + 'amount' => (float) $li->amount, + 'transaction_type' => 'debit', + 'status' => $li->status, + 'installment_number' => $li->installment_number, + 'liability_account_id' => $li->liability_account_id, + 'creditor' => $li->liabilityAccount->creditor, + 'days_overdue' => $daysOverdue, + 'account' => null, + 'category' => null, + ]; + }); + + // Combinar e ordenar por dias em atraso (mais antigo primeiro) + $allItems = $transactions->concat($recurringInstances)->concat($liabilityInstallments) + ->sortByDesc('days_overdue') + ->values(); + + // Agrupar por faixa de atraso + $byRange = collect([ + ['key' => 'critical', 'min' => 30, 'max' => PHP_INT_MAX, 'label' => '> 30 dias'], + ['key' => 'high', 'min' => 15, 'max' => 29, 'label' => '15-30 dias'], + ['key' => 'medium', 'min' => 7, 'max' => 14, 'label' => '7-14 dias'], + ['key' => 'low', 'min' => 1, 'max' => 6, 'label' => '1-6 dias'], + ])->map(function ($range) use ($allItems) { + $items = $allItems->filter(function ($item) use ($range) { + return $item['days_overdue'] >= $range['min'] && $item['days_overdue'] <= $range['max']; + })->values(); + + return [ + 'key' => $range['key'], + 'label' => $range['label'], + 'min_days' => $range['min'], + 'max_days' => $range['max'] === PHP_INT_MAX ? null : $range['max'], + 'items' => $items, + 'count' => $items->count(), + 'total' => $items->sum('amount'), + ]; + })->filter(fn($range) => $range['count'] > 0)->values(); + + // Totais gerais + $summary = [ + 'total_items' => $allItems->count(), + 'transactions_count' => $transactions->count(), + 'recurring_count' => $recurringInstances->count(), + 'liability_count' => $liabilityInstallments->count(), + 'total_amount' => $allItems->sum('amount'), + 'total_credit' => $allItems->where('transaction_type', 'credit')->sum('amount'), + 'total_debit' => $allItems->where('transaction_type', 'debit')->sum('amount'), + 'oldest_date' => $allItems->isNotEmpty() ? $allItems->first()['planned_date'] : null, + 'max_days_overdue' => $allItems->isNotEmpty() ? $allItems->first()['days_overdue'] : 0, + ]; + + return response()->json([ + 'by_range' => $byRange, + 'items' => $allItems->take($limit), + 'summary' => $summary, + ]); + } +} diff --git a/backend/app/Http/Controllers/Api/EmailTestController.php b/backend/app/Http/Controllers/Api/EmailTestController.php new file mode 100644 index 0000000..4104ab3 --- /dev/null +++ b/backend/app/Http/Controllers/Api/EmailTestController.php @@ -0,0 +1,123 @@ +all(), [ + 'to_email' => 'required|email', + 'to_name' => 'required|string|max:255', + ], [ + 'to_email.required' => 'El email destino es obligatorio', + 'to_email.email' => 'El email debe ser válido', + 'to_name.required' => 'El nombre es obligatorio', + ]); + + if ($validator->fails()) { + return response()->json([ + 'success' => false, + 'message' => 'Error de validación', + 'errors' => $validator->errors() + ], 422); + } + + // Enviar email de bienvenida + Mail::to($request->to_email) + ->send(new WelcomeEmail($request->to_name, $request->to_email)); + + return response()->json([ + 'success' => true, + 'message' => 'Email enviado exitosamente', + 'data' => [ + 'to' => $request->to_email, + 'subject' => '¡Bienvenido a WEBMoney! Tu cuenta ha sido creada', + 'from' => 'no-reply@cnxifly.com', + 'reply_to' => 'support@cnxifly.com', + 'features' => [ + 'DKIM signature' => 'Enabled (OpenDKIM)', + 'SPF record' => 'Configured in DNS', + 'HTML + Text' => 'Both versions included', + 'Reply-To' => 'Configured to support@cnxifly.com', + 'Professional design' => 'Mobile-responsive template', + ] + ] + ], 200); + + } catch (\Exception $e) { + return response()->json([ + 'success' => false, + 'message' => 'Error al enviar email', + 'error' => $e->getMessage() + ], 500); + } + } + + /** + * Obter informações sobre configuração anti-spam + */ + public function getAntiSpamInfo(): JsonResponse + { + return response()->json([ + 'success' => true, + 'data' => [ + 'server' => [ + 'mail_server' => 'mail.cnxifly.com', + 'smtp_port' => 587, + 'encryption' => 'TLS', + 'from_address' => 'no-reply@cnxifly.com', + 'reply_to' => 'support@cnxifly.com', + ], + 'anti_spam_features' => [ + 'DKIM' => [ + 'status' => 'Enabled', + 'selector' => 'default', + 'algorithm' => 'RSA-SHA256', + 'key_size' => '2048 bits', + 'dns_record' => 'default._domainkey.cnxifly.com', + ], + 'SPF' => [ + 'status' => 'Configured', + 'policy' => 'v=spf1 mx a ip4:213.165.93.60 ~all', + 'recommendation' => 'Consider changing ~all to -all for stricter validation', + ], + 'DMARC' => [ + 'status' => 'Not configured yet', + 'recommendation' => 'Configure DMARC record for better deliverability', + ], + ], + 'email_best_practices' => [ + 'HTML + Plain Text' => 'Both versions provided', + 'Responsive Design' => 'Mobile-friendly template', + 'Unsubscribe Link' => 'To be implemented for marketing emails', + 'Professional From Name' => 'WEBMoney - ConneXiFly', + 'Reply-To Address' => 'Separate support email configured', + 'No Spam Words' => 'Content reviewed for spam triggers', + ], + 'testing' => [ + 'test_email' => 'marcoitaloesp@gmail.com', + 'reason' => 'Gmail has strictest spam filters - if it passes Gmail, it will pass most other providers', + ], + ] + ], 200); + } +} diff --git a/backend/app/Http/Controllers/Api/ImportController.php b/backend/app/Http/Controllers/Api/ImportController.php new file mode 100644 index 0000000..7907528 --- /dev/null +++ b/backend/app/Http/Controllers/Api/ImportController.php @@ -0,0 +1,401 @@ +importService = $importService; + } + + /** + * Upload file and get preview + */ + public function upload(Request $request): JsonResponse + { + $validator = Validator::make($request->all(), [ + 'file' => 'required|file|max:10240', // 10MB max + ]); + + if ($validator->fails()) { + return response()->json([ + 'success' => false, + 'message' => 'Validation failed', + 'errors' => $validator->errors(), + ], 422); + } + + $file = $request->file('file'); + $extension = strtolower($file->getClientOriginalExtension()); + + // Verificar extensão permitida + if (!in_array($extension, ImportMapping::FILE_TYPES)) { + return response()->json([ + 'success' => false, + 'message' => "Unsupported file type: $extension. Allowed: " . implode(', ', ImportMapping::FILE_TYPES), + ], 422); + } + + // Salvar arquivo temporariamente + $filename = Str::uuid() . '.' . $extension; + $path = $file->storeAs('imports/temp', $filename); + $fullPath = Storage::path($path); + + try { + // Obter preview + $preview = $this->importService->getPreview($fullPath, 15); + + return response()->json([ + 'success' => true, + 'data' => [ + 'temp_file' => $filename, + 'original_name' => $file->getClientOriginalName(), + 'file_type' => $extension, + 'size' => $file->getSize(), + 'preview' => $preview, + ], + ]); + + } catch (\Exception $e) { + // Limpar arquivo em caso de erro + Storage::delete($path); + + return response()->json([ + 'success' => false, + 'message' => 'Error processing file: ' . $e->getMessage(), + ], 500); + } + } + + /** + * Get headers from file with specific row + */ + public function getHeaders(Request $request): JsonResponse + { + $validator = Validator::make($request->all(), [ + 'temp_file' => 'required|string', + 'header_row' => 'required|integer|min:0', + ]); + + if ($validator->fails()) { + return response()->json([ + 'success' => false, + 'errors' => $validator->errors(), + ], 422); + } + + $fullPath = Storage::path('imports/temp/' . $request->temp_file); + + if (!file_exists($fullPath)) { + return response()->json([ + 'success' => false, + 'message' => 'Temporary file not found. Please upload again.', + ], 404); + } + + try { + $headers = $this->importService->getHeaders($fullPath, [ + 'header_row' => $request->header_row, + ]); + + // Sugerir mapeamentos + $suggestions = $this->importService->suggestMapping($headers); + + return response()->json([ + 'success' => true, + 'data' => [ + 'headers' => $headers, + 'suggestions' => $suggestions, + ], + ]); + + } catch (\Exception $e) { + return response()->json([ + 'success' => false, + 'message' => 'Error reading headers: ' . $e->getMessage(), + ], 500); + } + } + + /** + * Process import with mapping + */ + public function import(Request $request): JsonResponse + { + $validator = Validator::make($request->all(), [ + 'temp_file' => 'required|string', + 'mapping_id' => 'nullable|exists:import_mappings,id', + 'column_mappings' => 'required_without:mapping_id|array', + 'header_row' => 'required_without:mapping_id|integer|min:0', + 'data_start_row' => 'required_without:mapping_id|integer|min:0', + 'date_format' => 'nullable|string', + 'decimal_separator' => 'nullable|string|max:1', + 'thousands_separator' => 'nullable|string|max:1', + 'account_id' => 'nullable|exists:accounts,id', + 'category_id' => 'nullable|exists:categories,id', + 'cost_center_id' => 'nullable|exists:cost_centers,id', + 'save_mapping' => 'nullable|boolean', + 'mapping_name' => 'required_if:save_mapping,true|nullable|string|max:255', + 'bank_name' => 'nullable|string|max:100', + ]); + + if ($validator->fails()) { + return response()->json([ + 'success' => false, + 'errors' => $validator->errors(), + ], 422); + } + + $fullPath = Storage::path('imports/temp/' . $request->temp_file); + + if (!file_exists($fullPath)) { + return response()->json([ + 'success' => false, + 'message' => 'Temporary file not found. Please upload again.', + ], 404); + } + + $userId = auth()->id(); + + try { + // Usar mapeamento existente ou criar novo + if ($request->mapping_id) { + $mapping = ImportMapping::where('user_id', $userId) + ->findOrFail($request->mapping_id); + } else { + $extension = pathinfo($request->temp_file, PATHINFO_EXTENSION); + + $mappingData = [ + 'user_id' => $userId, + 'name' => $request->mapping_name ?? 'Importação ' . now()->format('d/m/Y H:i'), + 'bank_name' => $request->bank_name, + 'file_type' => $extension, + 'header_row' => $request->header_row, + 'data_start_row' => $request->data_start_row, + 'date_format' => $request->date_format ?? 'd/m/Y', + 'decimal_separator' => $request->decimal_separator ?? ',', + 'thousands_separator' => $request->thousands_separator ?? '.', + 'column_mappings' => $request->column_mappings, + 'default_account_id' => $request->account_id, + 'default_category_id' => $request->category_id, + 'default_cost_center_id' => $request->cost_center_id, + 'is_active' => $request->save_mapping ?? false, + ]; + + if ($request->save_mapping) { + $mapping = ImportMapping::create($mappingData); + } else { + $mapping = new ImportMapping($mappingData); + // Não definir ID para mapeamento temporário - será tratado no ImportService + } + } + + // Executar importação + $importLog = $this->importService->importTransactions( + $fullPath, + $mapping, + $userId, + $request->account_id, + $request->category_id, + $request->cost_center_id + ); + + // Limpar arquivo temporário + Storage::delete('imports/temp/' . $request->temp_file); + + return response()->json([ + 'success' => true, + 'message' => "Importação concluída: {$importLog->imported_rows} transações importadas", + 'data' => [ + 'import_log' => $importLog, + 'mapping_saved' => $request->save_mapping && isset($mapping->id) && $mapping->id > 0, + ], + ]); + + } catch (\Exception $e) { + return response()->json([ + 'success' => false, + 'message' => 'Import failed: ' . $e->getMessage(), + ], 500); + } + } + + /** + * List saved mappings + */ + public function mappings(Request $request): JsonResponse + { + $mappings = ImportMapping::where('user_id', auth()->id()) + ->where('is_active', true) + ->with(['defaultAccount', 'defaultCategory', 'defaultCostCenter']) + ->orderBy('name') + ->get(); + + return response()->json([ + 'success' => true, + 'data' => $mappings, + ]); + } + + /** + * Get single mapping + */ + public function getMapping(int $id): JsonResponse + { + $mapping = ImportMapping::where('user_id', auth()->id()) + ->with(['defaultAccount', 'defaultCategory', 'defaultCostCenter']) + ->findOrFail($id); + + return response()->json([ + 'success' => true, + 'data' => $mapping, + ]); + } + + /** + * Update mapping + */ + public function updateMapping(Request $request, int $id): JsonResponse + { + $mapping = ImportMapping::where('user_id', auth()->id()) + ->findOrFail($id); + + $validator = Validator::make($request->all(), [ + 'name' => 'sometimes|string|max:255', + 'bank_name' => 'nullable|string|max:100', + 'header_row' => 'sometimes|integer|min:0', + 'data_start_row' => 'sometimes|integer|min:0', + 'date_format' => 'sometimes|string', + 'decimal_separator' => 'sometimes|string|max:1', + 'thousands_separator' => 'sometimes|string|max:1', + 'column_mappings' => 'sometimes|array', + 'default_account_id' => 'nullable|exists:accounts,id', + 'default_category_id' => 'nullable|exists:categories,id', + 'default_cost_center_id' => 'nullable|exists:cost_centers,id', + 'is_active' => 'sometimes|boolean', + ]); + + if ($validator->fails()) { + return response()->json([ + 'success' => false, + 'errors' => $validator->errors(), + ], 422); + } + + $mapping->update($request->all()); + + return response()->json([ + 'success' => true, + 'data' => $mapping->fresh(), + ]); + } + + /** + * Delete mapping + */ + public function deleteMapping(int $id): JsonResponse + { + $mapping = ImportMapping::where('user_id', auth()->id()) + ->findOrFail($id); + + $mapping->delete(); + + return response()->json([ + 'success' => true, + 'message' => 'Mapping deleted successfully', + ]); + } + + /** + * Get available bank presets + */ + public function presets(): JsonResponse + { + $presets = $this->importService->getAvailablePresets(); + + return response()->json([ + 'success' => true, + 'data' => $presets, + ]); + } + + /** + * Create mapping from preset + */ + public function createFromPreset(Request $request): JsonResponse + { + $validator = Validator::make($request->all(), [ + 'preset' => 'required|string', + ]); + + if ($validator->fails()) { + return response()->json([ + 'success' => false, + 'errors' => $validator->errors(), + ], 422); + } + + try { + $mapping = $this->importService->createBankPreset( + $request->preset, + auth()->id() + ); + + return response()->json([ + 'success' => true, + 'data' => $mapping, + ]); + + } catch (\Exception $e) { + return response()->json([ + 'success' => false, + 'message' => $e->getMessage(), + ], 400); + } + } + + /** + * Get import history + */ + public function history(Request $request): JsonResponse + { + $logs = ImportLog::where('user_id', auth()->id()) + ->with('importMapping') + ->orderBy('created_at', 'desc') + ->limit(50) + ->get(); + + return response()->json([ + 'success' => true, + 'data' => $logs, + ]); + } + + /** + * Get mappable fields info + */ + public function fields(): JsonResponse + { + return response()->json([ + 'success' => true, + 'data' => [ + 'fields' => ImportMapping::MAPPABLE_FIELDS, + 'date_formats' => ImportMapping::DATE_FORMATS, + 'file_types' => ImportMapping::FILE_TYPES, + ], + ]); + } +} diff --git a/backend/app/Http/Controllers/Api/LiabilityAccountController.php b/backend/app/Http/Controllers/Api/LiabilityAccountController.php new file mode 100644 index 0000000..ad4c4e3 --- /dev/null +++ b/backend/app/Http/Controllers/Api/LiabilityAccountController.php @@ -0,0 +1,686 @@ +with(['installments' => function ($q) { + $q->orderBy('installment_number'); + }]); + + // Filtros opcionais + if ($request->has('status')) { + $query->where('status', $request->status); + } + + if ($request->has('is_active')) { + $query->where('is_active', $request->boolean('is_active')); + } + + $accounts = $query->orderBy('name')->get(); + + // Calcular resumo + $summary = [ + 'total_principal' => $accounts->sum('principal_amount'), + 'total_paid' => $accounts->sum('total_paid'), + 'total_pending' => $accounts->sum('total_pending'), + 'total_interest' => $accounts->sum('total_interest'), + 'total_fees' => $accounts->sum('total_fees'), + 'contracts_count' => $accounts->count(), + 'active_contracts' => $accounts->where('status', 'active')->count(), + ]; + + return response()->json([ + 'success' => true, + 'data' => $accounts, + 'summary' => $summary, + 'statuses' => LiabilityAccount::STATUSES, + ]); + } + + /** + * Criar nova conta passivo manualmente + */ + public function store(Request $request): JsonResponse + { + $validated = $request->validate([ + 'name' => 'required|string|max:150', + 'contract_number' => 'nullable|string|max:100', + 'creditor' => 'nullable|string|max:150', + 'description' => 'nullable|string', + 'principal_amount' => 'required|numeric|min:0', + 'currency' => 'nullable|string|size:3', + 'color' => 'nullable|string|max:7', + 'icon' => 'nullable|string|max:50', + 'start_date' => 'nullable|date', + ]); + + $validated['user_id'] = Auth::id(); + $validated['total_pending'] = $validated['principal_amount']; + $validated['status'] = LiabilityAccount::STATUS_ACTIVE; + + $account = LiabilityAccount::create($validated); + + return response()->json([ + 'success' => true, + 'message' => 'Conta passivo criada com sucesso', + 'data' => $account, + ], 201); + } + + /** + * Exibir uma conta passivo específica com todas as parcelas + */ + public function show(int $id): JsonResponse + { + $account = LiabilityAccount::where('user_id', Auth::id()) + ->with(['installments' => function ($q) { + $q->orderBy('installment_number'); + }]) + ->findOrFail($id); + + return response()->json([ + 'success' => true, + 'data' => $account, + ]); + } + + /** + * Atualizar uma conta passivo + */ + public function update(Request $request, int $id): JsonResponse + { + $account = LiabilityAccount::where('user_id', Auth::id())->findOrFail($id); + + $validated = $request->validate([ + 'name' => 'sometimes|required|string|max:150', + 'contract_number' => 'nullable|string|max:100', + 'creditor' => 'nullable|string|max:150', + 'description' => 'nullable|string', + 'currency' => 'nullable|string|size:3', + 'color' => 'nullable|string|max:7', + 'icon' => 'nullable|string|max:50', + 'status' => ['sometimes', Rule::in(array_keys(LiabilityAccount::STATUSES))], + 'is_active' => 'nullable|boolean', + ]); + + $account->update($validated); + + return response()->json([ + 'success' => true, + 'message' => 'Conta passivo atualizada com sucesso', + 'data' => $account->fresh(), + ]); + } + + /** + * Excluir uma conta passivo + */ + public function destroy(int $id): JsonResponse + { + $account = LiabilityAccount::where('user_id', Auth::id())->findOrFail($id); + $account->delete(); + + return response()->json([ + 'success' => true, + 'message' => 'Conta passivo excluída com sucesso', + ]); + } + + /** + * Importar contrato de arquivo Excel + */ + public function import(Request $request): JsonResponse + { + $request->validate([ + 'file' => 'required|file|mimes:xlsx,xls', + 'name' => 'required|string|max:150', + 'creditor' => 'nullable|string|max:150', + 'contract_number' => 'nullable|string|max:100', + 'currency' => 'nullable|string|size:3', + 'description' => 'nullable|string', + ]); + + try { + DB::beginTransaction(); + + // Criar conta passivo + $liabilityAccount = LiabilityAccount::create([ + 'user_id' => Auth::id(), + 'name' => $request->name, + 'creditor' => $request->creditor, + 'contract_number' => $request->contract_number, + 'currency' => $request->currency ?? 'EUR', + 'description' => $request->description, + 'principal_amount' => 0, // Será calculado + 'status' => LiabilityAccount::STATUS_ACTIVE, + ]); + + // Processar arquivo Excel + $file = $request->file('file'); + $spreadsheet = IOFactory::load($file->getPathname()); + $worksheet = $spreadsheet->getActiveSheet(); + $rows = $worksheet->toArray(); + + // Pular cabeçalho + $header = array_shift($rows); + + // Mapear colunas (baseado no formato do arquivo exemplo) + // Colunas: Pago, Fecha, Cuota, Intereses, Capital, Estado + $columnMap = $this->mapColumns($header); + + $installments = []; + foreach ($rows as $row) { + if (empty($row[$columnMap['installment_number']])) { + continue; + } + + $installmentNumber = (int) $row[$columnMap['installment_number']]; + $dueDate = $this->parseDate($row[$columnMap['due_date']]); + $installmentAmount = $this->parseAmount($row[$columnMap['installment_amount']]); + $interestAmount = $this->parseAmount($row[$columnMap['interest_amount']]); + $principalAmount = $this->parseAmount($row[$columnMap['principal_amount']]); + $status = $this->parseStatus($row[$columnMap['status']]); + + // Calcular taxa extra (se cuota > capital + juros) + $normalAmount = $principalAmount + $interestAmount; + $feeAmount = max(0, $installmentAmount - $normalAmount); + + $installments[] = [ + 'liability_account_id' => $liabilityAccount->id, + 'installment_number' => $installmentNumber, + 'due_date' => $dueDate, + 'installment_amount' => $installmentAmount, + 'principal_amount' => $principalAmount, + 'interest_amount' => $interestAmount, + 'fee_amount' => $feeAmount, + 'status' => $status, + 'paid_amount' => $status === 'paid' ? $installmentAmount : 0, + 'paid_date' => $status === 'paid' ? $dueDate : null, + 'created_at' => now(), + 'updated_at' => now(), + ]; + } + + // Inserir parcelas + LiabilityInstallment::insert($installments); + + // Recalcular totais + $liabilityAccount->recalculateTotals(); + + DB::commit(); + + // Recarregar com parcelas + $liabilityAccount = LiabilityAccount::with('installments')->find($liabilityAccount->id); + + return response()->json([ + 'success' => true, + 'message' => 'Contrato importado com sucesso', + 'data' => $liabilityAccount, + 'imported_installments' => count($installments), + ], 201); + + } catch (\Exception $e) { + DB::rollBack(); + return response()->json([ + 'success' => false, + 'message' => 'Erro ao importar arquivo: ' . $e->getMessage(), + ], 422); + } + } + + /** + * Mapear colunas do Excel para campos do sistema + */ + private function mapColumns(array $header): array + { + $map = [ + 'installment_number' => 0, // Pago (número da parcela) + 'due_date' => 1, // Fecha + 'installment_amount' => 2, // Cuota + 'interest_amount' => 3, // Intereses + 'principal_amount' => 4, // Capital + 'status' => 5, // Estado + ]; + + // Tentar mapear automaticamente baseado nos nomes das colunas + foreach ($header as $index => $columnName) { + $columnName = strtolower(trim($columnName)); + + if (in_array($columnName, ['pago', 'numero', 'nº', 'n', 'parcela', 'installment'])) { + $map['installment_number'] = $index; + } elseif (in_array($columnName, ['fecha', 'date', 'data', 'vencimiento', 'due_date'])) { + $map['due_date'] = $index; + } elseif (in_array($columnName, ['cuota', 'quota', 'valor', 'amount', 'installment_amount'])) { + $map['installment_amount'] = $index; + } elseif (in_array($columnName, ['intereses', 'interest', 'juros'])) { + $map['interest_amount'] = $index; + } elseif (in_array($columnName, ['capital', 'principal', 'amortización', 'amortizacion'])) { + $map['principal_amount'] = $index; + } elseif (in_array($columnName, ['estado', 'status', 'situação', 'situacion'])) { + $map['status'] = $index; + } + } + + return $map; + } + + /** + * Converter string de data para formato válido + */ + private function parseDate($value): string + { + if ($value instanceof \DateTime) { + return $value->format('Y-m-d'); + } + + // Se for número (Excel serial date) + if (is_numeric($value)) { + $date = \PhpOffice\PhpSpreadsheet\Shared\Date::excelToDateTimeObject($value); + return $date->format('Y-m-d'); + } + + // Tentar parsear como string + try { + return date('Y-m-d', strtotime($value)); + } catch (\Exception $e) { + return date('Y-m-d'); + } + } + + /** + * Converter string de valor monetário para float + */ + private function parseAmount($value): float + { + if (is_numeric($value)) { + return (float) $value; + } + + // Remover símbolos de moeda e espaços + $value = preg_replace('/[€$R\s]/', '', $value); + + // Converter vírgula para ponto (formato europeu) + $value = str_replace(',', '.', $value); + + // Remover pontos de milhar + if (substr_count($value, '.') > 1) { + $parts = explode('.', $value); + $last = array_pop($parts); + $value = implode('', $parts) . '.' . $last; + } + + return (float) $value; + } + + /** + * Converter status do Excel para status do sistema + */ + private function parseStatus($value): string + { + $value = strtolower(trim($value)); + + $paidStatuses = ['abonado', 'paid', 'pago', 'pagado', 'liquidado']; + $pendingStatuses = ['pendiente', 'pending', 'pendente', 'a pagar']; + $overdueStatuses = ['atrasado', 'overdue', 'vencido', 'mora']; + + if (in_array($value, $paidStatuses)) { + return LiabilityInstallment::STATUS_PAID; + } + if (in_array($value, $overdueStatuses)) { + return LiabilityInstallment::STATUS_OVERDUE; + } + return LiabilityInstallment::STATUS_PENDING; + } + + /** + * Obter parcelas de uma conta passivo + */ + public function installments(int $id): JsonResponse + { + $account = LiabilityAccount::where('user_id', Auth::id())->findOrFail($id); + + $installments = $account->installments() + ->orderBy('installment_number') + ->get(); + + return response()->json([ + 'success' => true, + 'data' => $installments, + 'statuses' => LiabilityInstallment::STATUSES, + ]); + } + + /** + * Atualizar status de uma parcela + */ + public function updateInstallment(Request $request, int $accountId, int $installmentId): JsonResponse + { + $account = LiabilityAccount::where('user_id', Auth::id())->findOrFail($accountId); + $installment = LiabilityInstallment::where('liability_account_id', $account->id) + ->findOrFail($installmentId); + + $validated = $request->validate([ + 'status' => ['sometimes', Rule::in(array_keys(LiabilityInstallment::STATUSES))], + 'paid_amount' => 'nullable|numeric|min:0', + 'paid_date' => 'nullable|date', + 'payment_account_id' => 'nullable|exists:accounts,id', + 'notes' => 'nullable|string', + ]); + + // Se marcar como pago + if (isset($validated['status']) && $validated['status'] === 'paid') { + $installment->markAsPaid( + $validated['paid_amount'] ?? null, + isset($validated['paid_date']) ? new \DateTime($validated['paid_date']) : null, + $validated['payment_account_id'] ?? null + ); + } else { + $installment->update($validated); + $account->recalculateTotals(); + } + + return response()->json([ + 'success' => true, + 'message' => 'Parcela atualizada com sucesso', + 'data' => $installment->fresh(), + ]); + } + + /** + * Obter resumo de todas as contas passivo + */ + public function summary(): JsonResponse + { + $accounts = LiabilityAccount::where('user_id', Auth::id()) + ->where('is_active', true) + ->get(); + + // Agrupar por moeda + $byCurrency = $accounts->groupBy('currency')->map(function ($group) { + return [ + 'total_principal' => $group->sum('principal_amount'), + 'total_paid' => $group->sum('total_paid'), + 'total_pending' => $group->sum('total_pending'), + 'total_interest' => $group->sum('total_interest'), + 'remaining_balance' => $group->sum('remaining_balance'), + 'contracts_count' => $group->count(), + ]; + }); + + // Próximas parcelas a vencer + $upcomingInstallments = LiabilityInstallment::whereHas('liabilityAccount', function ($q) { + $q->where('user_id', Auth::id())->where('is_active', true); + }) + ->where('status', 'pending') + ->where('due_date', '>=', now()) + ->where('due_date', '<=', now()->addDays(30)) + ->with('liabilityAccount:id,name,currency') + ->orderBy('due_date') + ->limit(10) + ->get(); + + // Parcelas atrasadas + $overdueInstallments = LiabilityInstallment::whereHas('liabilityAccount', function ($q) { + $q->where('user_id', Auth::id())->where('is_active', true); + }) + ->where('status', '!=', 'paid') + ->where('due_date', '<', now()) + ->with('liabilityAccount:id,name,currency') + ->orderBy('due_date') + ->get(); + + return response()->json([ + 'success' => true, + 'data' => [ + 'by_currency' => $byCurrency, + 'upcoming_installments' => $upcomingInstallments, + 'overdue_installments' => $overdueInstallments, + 'overdue_count' => $overdueInstallments->count(), + ], + ]); + } + + /** + * Conciliar uma parcela com uma transação existente + * + * Vincula uma parcela de conta passivo a uma transação já registrada + */ + public function reconcile(Request $request, int $accountId, int $installmentId): JsonResponse + { + $account = LiabilityAccount::where('user_id', Auth::id())->findOrFail($accountId); + $installment = LiabilityInstallment::where('liability_account_id', $account->id) + ->findOrFail($installmentId); + + $validated = $request->validate([ + 'transaction_id' => 'required|exists:transactions,id', + 'mark_as_paid' => 'nullable|boolean', + ]); + + // Verificar se a transação pertence ao usuário + $transaction = \App\Models\Transaction::where('user_id', Auth::id()) + ->findOrFail($validated['transaction_id']); + + try { + DB::beginTransaction(); + + // Atualizar parcela com referência à transação + $installment->reconciled_transaction_id = $transaction->id; + $installment->payment_account_id = $transaction->account_id; + + // Opcionalmente marcar como paga + if ($request->boolean('mark_as_paid', true)) { + $installment->status = LiabilityInstallment::STATUS_PAID; + $installment->paid_amount = abs($transaction->amount); + $installment->paid_date = $transaction->date; + } + + $installment->save(); + + // Recalcular totais da conta passivo + $account->recalculateTotals(); + + DB::commit(); + + return response()->json([ + 'success' => true, + 'message' => 'Parcela conciliada com sucesso', + 'data' => $installment->fresh()->load('liabilityAccount'), + ]); + + } catch (\Exception $e) { + DB::rollBack(); + return response()->json([ + 'success' => false, + 'message' => 'Erro ao conciliar: ' . $e->getMessage(), + ], 422); + } + } + + /** + * Remover conciliação de uma parcela + */ + public function unreconcile(int $accountId, int $installmentId): JsonResponse + { + $account = LiabilityAccount::where('user_id', Auth::id())->findOrFail($accountId); + $installment = LiabilityInstallment::where('liability_account_id', $account->id) + ->findOrFail($installmentId); + + if (!$installment->reconciled_transaction_id) { + return response()->json([ + 'success' => false, + 'message' => 'Parcela não está conciliada', + ], 422); + } + + try { + DB::beginTransaction(); + + // Calcular o sobrepagamento que foi registrado (paid_amount - installment_amount) + $paidAmount = (float) $installment->paid_amount; + $plannedAmount = (float) $installment->installment_amount; + $overpaymentToRemove = max(0, $paidAmount - $plannedAmount); + + // Remover referência à transação + $installment->reconciled_transaction_id = null; + $installment->status = LiabilityInstallment::STATUS_PENDING; + $installment->paid_amount = 0; + $installment->paid_date = null; + + // Remover o cargo extra (sobrepagamento) que foi adicionado na conciliação + if ($overpaymentToRemove > 0 && $installment->fee_amount >= $overpaymentToRemove) { + $installment->fee_amount = $installment->fee_amount - $overpaymentToRemove; + } + + $installment->save(); + + // Recalcular totais + $account->recalculateTotals(); + + DB::commit(); + + return response()->json([ + 'success' => true, + 'message' => 'Conciliação removida com sucesso', + 'data' => $installment->fresh(), + 'fee_removed' => $overpaymentToRemove > 0 ? $overpaymentToRemove : null, + ]); + + } catch (\Exception $e) { + DB::rollBack(); + return response()->json([ + 'success' => false, + 'message' => 'Erro ao remover conciliação: ' . $e->getMessage(), + ], 422); + } + } + + /** + * Buscar transações elegíveis para conciliação + * + * Retorna transações que podem ser vinculadas a uma parcela + * Ordenadas por similaridade de valor com a parcela + */ + public function eligibleTransactions(Request $request, int $accountId, int $installmentId): JsonResponse + { + $account = LiabilityAccount::where('user_id', Auth::id())->findOrFail($accountId); + $installment = LiabilityInstallment::where('liability_account_id', $account->id) + ->findOrFail($installmentId); + + // Buscar transações dentro de uma janela de tempo (+/- 45 dias da data de vencimento) + // Janela ampla para capturar pagamentos atrasados ou antecipados + $startDate = (clone $installment->due_date)->subDays(45); + $endDate = (clone $installment->due_date)->addDays(45); + + $installmentAmount = (float) $installment->installment_amount; + + // Usar effective_date se existir, senão planned_date + $query = \App\Models\Transaction::where('user_id', Auth::id()) + ->where(function ($q) use ($startDate, $endDate) { + $q->whereBetween('effective_date', [$startDate, $endDate]) + ->orWhere(function ($q2) use ($startDate, $endDate) { + $q2->whereNull('effective_date') + ->whereBetween('planned_date', [$startDate, $endDate]); + }); + }) + ->where('type', 'debit') // Pagamentos são débitos (saídas) + ->with('account:id,name,currency'); + + // Por padrão, filtrar por valores próximos (±20% do valor da parcela) + // Permite encontrar transações mesmo com pequenas diferenças + $minAmount = $installmentAmount * 0.8; + $maxAmount = $installmentAmount * 1.2; + + // Se strict_amount = false ou não informado, ainda assim filtrar por faixa + // Usa COALESCE para considerar amount ou planned_amount + if (!$request->has('no_amount_filter')) { + $query->whereRaw('COALESCE(amount, planned_amount) BETWEEN ? AND ?', [$minAmount, $maxAmount]); + } + + // Se tiver filtro por conta específica + if ($request->has('account_id')) { + $query->where('account_id', $request->account_id); + } + + // Busca por descrição + if ($request->has('search')) { + $query->where(function ($q) use ($request) { + $q->where('description', 'like', '%' . $request->search . '%') + ->orWhere('original_description', 'like', '%' . $request->search . '%'); + }); + } + + // Ordenar por similaridade de valor (mais próximo primeiro) e depois por data + // ABS(COALESCE(amount, planned_amount) - valor_parcela) = diferença absoluta + $query->orderByRaw("ABS(COALESCE(amount, planned_amount) - ?) ASC", [$installmentAmount]) + ->orderByRaw('COALESCE(effective_date, planned_date) DESC'); + + $transactions = $query->limit(30)->get(); + + // Adicionar campo de diferença percentual para cada transação + $transactions->transform(function ($transaction) use ($installmentAmount) { + $transactionAmount = (float) ($transaction->amount ?? $transaction->planned_amount); + $diff = abs($transactionAmount - $installmentAmount); + $diffPercent = $installmentAmount > 0 ? ($diff / $installmentAmount) * 100 : 0; + $transaction->amount_difference = round($diff, 2); + $transaction->amount_difference_percent = round($diffPercent, 1); + return $transaction; + }); + + return response()->json([ + 'success' => true, + 'data' => $transactions, + 'installment' => [ + 'id' => $installment->id, + 'installment_number' => $installment->installment_number, + 'due_date' => $installment->due_date->format('Y-m-d'), + 'installment_amount' => $installmentAmount, + ], + 'search_period' => [ + 'start' => $startDate->format('Y-m-d'), + 'end' => $endDate->format('Y-m-d'), + ], + 'amount_range' => [ + 'min' => round($minAmount, 2), + 'max' => round($maxAmount, 2), + ], + ]); + } + + /** + * Listar parcelas pendentes de conciliação + */ + public function pendingReconciliation(): JsonResponse + { + $installments = LiabilityInstallment::whereHas('liabilityAccount', function ($q) { + $q->where('user_id', Auth::id())->where('is_active', true); + }) + ->whereNull('reconciled_transaction_id') + ->where('status', '!=', 'cancelled') + ->with('liabilityAccount:id,name,currency,creditor') + ->orderBy('due_date') + ->get(); + + return response()->json([ + 'success' => true, + 'data' => $installments, + 'count' => $installments->count(), + ]); + } +} diff --git a/backend/app/Http/Controllers/Api/RecurringTemplateController.php b/backend/app/Http/Controllers/Api/RecurringTemplateController.php new file mode 100644 index 0000000..122e28a --- /dev/null +++ b/backend/app/Http/Controllers/Api/RecurringTemplateController.php @@ -0,0 +1,489 @@ +recurringService = $recurringService; + } + + /** + * Lista todos os templates de recorrência do usuário + */ + public function index(Request $request): JsonResponse + { + $query = RecurringTemplate::where('user_id', Auth::id()) + ->with(['account', 'category', 'costCenter']) + ->withCount(['instances', 'pendingInstances', 'paidInstances']); + + // Filtros + if ($request->has('is_active')) { + $query->where('is_active', $request->boolean('is_active')); + } + + if ($request->has('frequency')) { + $query->where('frequency', $request->frequency); + } + + if ($request->has('type')) { + $query->where('type', $request->type); + } + + if ($request->has('account_id')) { + $query->where('account_id', $request->account_id); + } + + if ($request->has('category_id')) { + $query->where('category_id', $request->category_id); + } + + // Ordenação + $sortBy = $request->get('sort_by', 'name'); + $sortDir = $request->get('sort_dir', 'asc'); + $query->orderBy($sortBy, $sortDir); + + $templates = $query->paginate($request->get('per_page', 20)); + + return response()->json($templates); + } + + /** + * Exibe um template específico + */ + public function show(RecurringTemplate $recurringTemplate): JsonResponse + { + $this->authorize('view', $recurringTemplate); + + $recurringTemplate->load([ + 'account', + 'category', + 'costCenter', + 'sourceTransaction', + 'instances' => fn($q) => $q->orderBy('due_date', 'asc'), + ]); + + return response()->json($recurringTemplate); + } + + /** + * Cria um novo template de recorrência manualmente + */ + public function store(Request $request): JsonResponse + { + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'description' => 'nullable|string|max:1000', + 'frequency' => 'required|in:' . implode(',', array_keys(RecurringTemplate::FREQUENCIES)), + 'frequency_interval' => 'nullable|integer|min:1|max:12', + 'day_of_month' => 'nullable|integer|min:1|max:31', + 'day_of_week' => 'nullable|integer|min:0|max:6', + 'start_date' => 'required|date', + 'end_date' => 'nullable|date|after:start_date', + 'max_occurrences' => 'nullable|integer|min:1|max:999', + 'account_id' => 'required|exists:accounts,id', + 'category_id' => 'nullable|exists:categories,id', + 'cost_center_id' => 'nullable|exists:cost_centers,id', + 'type' => 'required|in:income,expense', + 'planned_amount' => 'required|numeric|min:0.01', + 'transaction_description' => 'required|string|max:255', + 'notes' => 'nullable|string|max:1000', + ]); + + $template = $this->recurringService->createTemplate(Auth::id(), $validated); + + return response()->json([ + 'message' => __('Recurring template created successfully'), + 'template' => $template, + ], 201); + } + + /** + * Cria um template a partir de uma transação existente + */ + public function createFromTransaction(Request $request): JsonResponse + { + $validated = $request->validate([ + 'transaction_id' => 'required|exists:transactions,id', + 'frequency' => 'required|in:' . implode(',', array_keys(RecurringTemplate::FREQUENCIES)), + 'name' => 'nullable|string|max:255', + 'description' => 'nullable|string|max:1000', + 'frequency_interval' => 'nullable|integer|min:1|max:12', + 'day_of_month' => 'nullable|integer|min:1|max:31', + 'day_of_week' => 'nullable|integer|min:0|max:6', + 'start_date' => 'nullable|date', + 'end_date' => 'nullable|date', + 'max_occurrences' => 'nullable|integer|min:1|max:999', + ]); + + $transaction = Transaction::where('user_id', Auth::id()) + ->findOrFail($validated['transaction_id']); + + $options = collect($validated)->except(['transaction_id', 'frequency'])->filter()->toArray(); + + $template = $this->recurringService->createFromTransaction( + $transaction, + $validated['frequency'], + $options + ); + + return response()->json([ + 'message' => __('Recurring template created from transaction'), + 'template' => $template, + ], 201); + } + + /** + * Atualiza um template + */ + public function update(Request $request, RecurringTemplate $recurringTemplate): JsonResponse + { + $this->authorize('update', $recurringTemplate); + + $validated = $request->validate([ + 'name' => 'sometimes|string|max:255', + 'description' => 'nullable|string|max:1000', + 'frequency' => 'sometimes|in:' . implode(',', array_keys(RecurringTemplate::FREQUENCIES)), + 'frequency_interval' => 'nullable|integer|min:1|max:12', + 'day_of_month' => 'nullable|integer|min:1|max:31', + 'day_of_week' => 'nullable|integer|min:0|max:6', + 'end_date' => 'nullable|date', + 'max_occurrences' => 'nullable|integer|min:1|max:999', + 'account_id' => 'sometimes|exists:accounts,id', + 'category_id' => 'nullable|exists:categories,id', + 'cost_center_id' => 'nullable|exists:cost_centers,id', + 'planned_amount' => 'sometimes|numeric|min:0.01', + 'transaction_description' => 'sometimes|string|max:255', + 'notes' => 'nullable|string|max:1000', + 'is_active' => 'sometimes|boolean', + ]); + + $recurringTemplate->update($validated); + + return response()->json([ + 'message' => __('Recurring template updated successfully'), + 'template' => $recurringTemplate->fresh(['account', 'category', 'costCenter']), + ]); + } + + /** + * Remove um template e suas instâncias pendentes + */ + public function destroy(RecurringTemplate $recurringTemplate): JsonResponse + { + $this->authorize('delete', $recurringTemplate); + + // Remover instâncias pendentes (manter as pagas para histórico) + $recurringTemplate->instances() + ->where('status', RecurringInstance::STATUS_PENDING) + ->delete(); + + // Desativar o template em vez de deletar (soft delete) + $recurringTemplate->update(['is_active' => false]); + + return response()->json([ + 'message' => __('Recurring template deleted successfully'), + ]); + } + + /** + * Pausa um template + */ + public function pause(RecurringTemplate $recurringTemplate): JsonResponse + { + $this->authorize('update', $recurringTemplate); + + $template = $this->recurringService->pauseTemplate($recurringTemplate); + + return response()->json([ + 'message' => __('Recurring template paused'), + 'template' => $template, + ]); + } + + /** + * Reativa um template + */ + public function resume(RecurringTemplate $recurringTemplate): JsonResponse + { + $this->authorize('update', $recurringTemplate); + + $template = $this->recurringService->resumeTemplate($recurringTemplate); + + return response()->json([ + 'message' => __('Recurring template resumed'), + 'template' => $template, + ]); + } + + /** + * Lista instâncias de um template + */ + public function instances(Request $request, RecurringTemplate $recurringTemplate): JsonResponse + { + $this->authorize('view', $recurringTemplate); + + $query = $recurringTemplate->instances() + ->with('transaction'); + + // Filtro por status + if ($request->has('status')) { + $query->where('status', $request->status); + } + + // Filtro por período + if ($request->has('from_date')) { + $query->where('due_date', '>=', $request->from_date); + } + + if ($request->has('to_date')) { + $query->where('due_date', '<=', $request->to_date); + } + + $instances = $query->orderBy('due_date', 'asc')->get(); + + return response()->json($instances); + } + + /** + * Lista todas as instâncias pendentes do usuário (dashboard) + */ + public function allPendingInstances(Request $request): JsonResponse + { + $query = RecurringInstance::where('user_id', Auth::id()) + ->where('status', RecurringInstance::STATUS_PENDING) + ->with(['template', 'template.account', 'template.category']); + + // Filtros + if ($request->has('type')) { + $query->whereHas('template', fn($q) => $q->where('type', $request->type)); + } + + if ($request->has('from_date')) { + $query->where('due_date', '>=', $request->from_date); + } + + if ($request->has('to_date')) { + $query->where('due_date', '<=', $request->to_date); + } + + // Ordenar por data de vencimento + $instances = $query->orderBy('due_date', 'asc') + ->limit($request->get('limit', 50)) + ->get(); + + return response()->json($instances); + } + + /** + * Lista instâncias vencidas + */ + public function overdueInstances(): JsonResponse + { + $instances = RecurringInstance::where('user_id', Auth::id()) + ->overdue() + ->with(['template', 'template.account', 'template.category']) + ->orderBy('due_date', 'asc') + ->get(); + + return response()->json($instances); + } + + /** + * Lista instâncias próximas do vencimento (próximos 7 dias) + */ + public function dueSoonInstances(Request $request): JsonResponse + { + $days = $request->get('days', 7); + + $instances = RecurringInstance::where('user_id', Auth::id()) + ->dueSoon($days) + ->with(['template', 'template.account', 'template.category']) + ->orderBy('due_date', 'asc') + ->get(); + + return response()->json($instances); + } + + /** + * Marca uma instância como paga (cria transação) + */ + public function markAsPaid(Request $request, RecurringInstance $recurringInstance): JsonResponse + { + $this->authorize('update', $recurringInstance->template); + + if ($recurringInstance->isPaid()) { + return response()->json([ + 'message' => __('This instance is already paid'), + ], 422); + } + + $validated = $request->validate([ + 'amount' => 'nullable|numeric|min:0.01', + 'effective_date' => 'nullable|date', + 'description' => 'nullable|string|max:255', + 'notes' => 'nullable|string|max:1000', + ]); + + $instance = $this->recurringService->markAsPaid($recurringInstance, $validated); + + return response()->json([ + 'message' => __('Instance marked as paid'), + 'instance' => $instance, + ]); + } + + /** + * Concilia uma instância com uma transação existente + */ + public function reconcile(Request $request, RecurringInstance $recurringInstance): JsonResponse + { + $this->authorize('update', $recurringInstance->template); + + if ($recurringInstance->isPaid()) { + return response()->json([ + 'message' => __('This instance is already reconciled'), + ], 422); + } + + $validated = $request->validate([ + 'transaction_id' => 'required|exists:transactions,id', + 'notes' => 'nullable|string|max:1000', + ]); + + $transaction = Transaction::where('user_id', Auth::id()) + ->findOrFail($validated['transaction_id']); + + $instance = $this->recurringService->reconcileWithTransaction( + $recurringInstance, + $transaction, + $validated['notes'] ?? null + ); + + return response()->json([ + 'message' => __('Instance reconciled with transaction'), + 'instance' => $instance, + ]); + } + + /** + * Busca transações candidatas para conciliação + */ + public function findCandidates(Request $request, RecurringInstance $recurringInstance): JsonResponse + { + $this->authorize('view', $recurringInstance->template); + + $daysTolerance = $request->get('days_tolerance', 7); + + $candidates = $this->recurringService->findCandidateTransactions( + $recurringInstance, + $daysTolerance + ); + + return response()->json($candidates); + } + + /** + * Pula uma instância + */ + public function skip(Request $request, RecurringInstance $recurringInstance): JsonResponse + { + $this->authorize('update', $recurringInstance->template); + + $validated = $request->validate([ + 'reason' => 'nullable|string|max:255', + ]); + + $instance = $this->recurringService->skipInstance( + $recurringInstance, + $validated['reason'] ?? null + ); + + return response()->json([ + 'message' => __('Instance skipped'), + 'instance' => $instance, + ]); + } + + /** + * Atualiza uma instância individual + */ + public function updateInstance(Request $request, RecurringInstance $recurringInstance): JsonResponse + { + $this->authorize('update', $recurringInstance->template); + + if ($recurringInstance->isPaid()) { + return response()->json([ + 'message' => __('Cannot edit a paid instance'), + ], 422); + } + + $validated = $request->validate([ + 'planned_amount' => 'sometimes|numeric|min:0.01', + 'due_date' => 'sometimes|date', + 'notes' => 'nullable|string|max:1000', + ]); + + $recurringInstance->update($validated); + + return response()->json([ + 'message' => __('Instance updated successfully'), + 'instance' => $recurringInstance->fresh(['template', 'transaction']), + ]); + } + + /** + * Cancela uma instância + */ + public function cancel(Request $request, RecurringInstance $recurringInstance): JsonResponse + { + $this->authorize('update', $recurringInstance->template); + + $validated = $request->validate([ + 'reason' => 'nullable|string|max:255', + ]); + + $instance = $this->recurringService->cancelInstance( + $recurringInstance, + $validated['reason'] ?? null + ); + + return response()->json([ + 'message' => __('Instance cancelled'), + 'instance' => $instance, + ]); + } + + /** + * Retorna as frequências disponíveis + */ + public function frequencies(): JsonResponse + { + return response()->json(RecurringTemplate::FREQUENCIES); + } + + /** + * Regenera instâncias para todos os templates ativos do usuário + */ + public function regenerateAll(): JsonResponse + { + $generated = $this->recurringService->regenerateAllForUser(Auth::id()); + + return response()->json([ + 'message' => __(':count instances generated', ['count' => $generated]), + 'generated' => $generated, + ]); + } +} diff --git a/backend/app/Http/Controllers/Api/TransactionController.php b/backend/app/Http/Controllers/Api/TransactionController.php new file mode 100644 index 0000000..0c3cc91 --- /dev/null +++ b/backend/app/Http/Controllers/Api/TransactionController.php @@ -0,0 +1,1320 @@ +user()->id) + ->with(['account:id,name,currency', 'category:id,name,color,icon', 'costCenter:id,name,color']); + + // Filtros + if ($request->has('account_id')) { + $query->ofAccount($request->account_id); + } + + if ($request->has('category_id')) { + $query->ofCategory($request->category_id); + } + + if ($request->has('cost_center_id')) { + $query->ofCostCenter($request->cost_center_id); + } + + if ($request->has('type')) { + $query->where('type', $request->type); + } + + if ($request->has('status')) { + $query->where('status', $request->status); + } + + // Filtro por período + if ($request->has('start_date') && $request->has('end_date')) { + $dateField = $request->get('date_field', 'planned_date'); + $query->inPeriod($request->start_date, $request->end_date, $dateField); + } + + // Busca por descrição e valores + if ($request->has('search')) { + $search = $request->search; + // Limpar formatação de número para busca por valor + $searchNumber = preg_replace('/[^\d.,\-]/', '', $search); + $searchNumber = str_replace(',', '.', $searchNumber); + + $query->where(function($q) use ($search, $searchNumber) { + $q->where('description', 'like', "%{$search}%") + ->orWhere('reference', 'like', "%{$search}%") + ->orWhere('notes', 'like', "%{$search}%"); + + // Se for um número válido, buscar também nos campos de valor + if (is_numeric($searchNumber)) { + $numericValue = (float) $searchNumber; + // Busca exata + $q->orWhere('planned_amount', '=', $numericValue) + ->orWhere('amount', '=', $numericValue); + + // Busca por valor sem decimais (ex: "147" encontra 147.00, 147.50, etc.) + if (strpos($searchNumber, '.') === false) { + $q->orWhereBetween('planned_amount', [$numericValue, $numericValue + 0.99]) + ->orWhereBetween('amount', [$numericValue, $numericValue + 0.99]); + } + } + }); + } + + // Ordenação + $sortField = $request->get('sort_by', 'planned_date'); + $sortOrder = $request->get('sort_order', 'desc'); + $query->orderBy($sortField, $sortOrder); + + // Paginação ou todos + if ($request->has('per_page')) { + $transactions = $query->paginate($request->per_page); + } else { + $transactions = $query->get(); + } + + return response()->json($transactions); + } + + /** + * Criar nova transação + */ + public function store(Request $request): JsonResponse + { + $validated = $request->validate([ + 'account_id' => 'required|exists:accounts,id', + 'category_id' => 'nullable|exists:categories,id', + 'cost_center_id' => 'nullable|exists:cost_centers,id', + 'amount' => 'nullable|numeric|min:0', + 'planned_amount' => 'required|numeric|min:0', + 'type' => 'required|in:credit,debit', + 'description' => 'required|string|max:255', + 'notes' => 'nullable|string', + 'effective_date' => 'nullable|date', + 'planned_date' => 'required|date', + 'status' => 'sometimes|in:pending,completed,cancelled', + 'reference' => 'nullable|string|max:100', + 'is_recurring' => 'sometimes|boolean', + ]); + + // Verificar se a conta pertence ao usuário + $account = Account::where('id', $validated['account_id']) + ->where('user_id', $request->user()->id) + ->firstOrFail(); + + $validated['user_id'] = $request->user()->id; + + // Se status é completed e não tem amount, usa planned_amount + if (($validated['status'] ?? 'pending') === 'completed') { + $validated['amount'] = $validated['amount'] ?? $validated['planned_amount']; + $validated['effective_date'] = $validated['effective_date'] ?? now()->toDateString(); + } + + $transaction = Transaction::create($validated); + + return response()->json( + $transaction->load(['account:id,name,currency', 'category:id,name,color,icon', 'costCenter:id,name,color']), + 201 + ); + } + + /** + * Exibir detalhes de uma transação + */ + public function show(Request $request, Transaction $transaction): JsonResponse + { + // Verificar propriedade + if ($transaction->user_id !== $request->user()->id) { + return response()->json(['message' => 'Transação não encontrada'], 404); + } + + return response()->json( + $transaction->load([ + 'account:id,name,currency,type', + 'category:id,name,color,icon,parent_id', + 'category.parent:id,name', + 'costCenter:id,name,color', + 'recurringParent:id,description', + 'recurringChildren:id,description,planned_date,status' + ]) + ); + } + + /** + * Atualizar transação + */ + public function update(Request $request, Transaction $transaction): JsonResponse + { + // Verificar propriedade + if ($transaction->user_id !== $request->user()->id) { + return response()->json(['message' => 'Transação não encontrada'], 404); + } + + $validated = $request->validate([ + 'account_id' => 'sometimes|exists:accounts,id', + 'category_id' => 'nullable|exists:categories,id', + 'cost_center_id' => 'nullable|exists:cost_centers,id', + 'amount' => 'nullable|numeric|min:0', + 'planned_amount' => 'sometimes|numeric|min:0', + 'type' => 'sometimes|in:credit,debit', + 'description' => 'sometimes|string|max:255', + 'notes' => 'nullable|string', + 'effective_date' => 'nullable|date', + 'planned_date' => 'sometimes|date', + 'status' => 'sometimes|in:pending,completed,cancelled', + 'reference' => 'nullable|string|max:100', + 'is_recurring' => 'sometimes|boolean', + ]); + + // Se mudou account_id, verificar propriedade + if (isset($validated['account_id'])) { + Account::where('id', $validated['account_id']) + ->where('user_id', $request->user()->id) + ->firstOrFail(); + } + + // Se mudou para completed, garantir amount e effective_date + if (isset($validated['status']) && $validated['status'] === 'completed') { + if (!$transaction->amount && !isset($validated['amount'])) { + $validated['amount'] = $validated['planned_amount'] ?? $transaction->planned_amount; + } + if (!$transaction->effective_date && !isset($validated['effective_date'])) { + $validated['effective_date'] = now()->toDateString(); + } + } + + // Se voltou para pending, limpar amount e effective_date + if (isset($validated['status']) && $validated['status'] === 'pending') { + $validated['amount'] = null; + $validated['effective_date'] = null; + } + + $transaction->update($validated); + + return response()->json( + $transaction->fresh()->load(['account:id,name,currency', 'category:id,name,color,icon', 'costCenter:id,name,color']) + ); + } + + /** + * Excluir transação + */ + public function destroy(Request $request, Transaction $transaction): JsonResponse + { + // Verificar propriedade + if ($transaction->user_id !== $request->user()->id) { + return response()->json(['message' => 'Transação não encontrada'], 404); + } + + $transaction->delete(); + + return response()->json(['message' => 'Transação excluída com sucesso']); + } + + /** + * Marcar transação como concluída + */ + public function complete(Request $request, Transaction $transaction): JsonResponse + { + if ($transaction->user_id !== $request->user()->id) { + return response()->json(['message' => 'Transação não encontrada'], 404); + } + + $validated = $request->validate([ + 'amount' => 'nullable|numeric|min:0', + 'effective_date' => 'nullable|date', + ]); + + $transaction->markAsCompleted( + $validated['amount'] ?? null, + $validated['effective_date'] ?? null + ); + + return response()->json( + $transaction->load(['account:id,name,currency', 'category:id,name,color,icon', 'costCenter:id,name,color']) + ); + } + + /** + * Cancelar transação + */ + public function cancel(Request $request, Transaction $transaction): JsonResponse + { + if ($transaction->user_id !== $request->user()->id) { + return response()->json(['message' => 'Transação não encontrada'], 404); + } + + $transaction->markAsCancelled(); + + return response()->json( + $transaction->load(['account:id,name,currency', 'category:id,name,color,icon', 'costCenter:id,name,color']) + ); + } + + /** + * Reverter para pendente + */ + public function revert(Request $request, Transaction $transaction): JsonResponse + { + if ($transaction->user_id !== $request->user()->id) { + return response()->json(['message' => 'Transação não encontrada'], 404); + } + + $transaction->markAsPending(); + + return response()->json( + $transaction->load(['account:id,name,currency', 'category:id,name,color,icon', 'costCenter:id,name,color']) + ); + } + + /** + * Resumo de transações + */ + public function summary(Request $request): JsonResponse + { + $userId = $request->user()->id; + + $query = Transaction::ofUser($userId); + + // Filtros opcionais + if ($request->has('account_id')) { + $query->ofAccount($request->account_id); + } + + if ($request->has('start_date') && $request->has('end_date')) { + $query->inPeriod($request->start_date, $request->end_date); + } + + // Totais por status + $byStatus = Transaction::ofUser($userId) + ->when($request->has('account_id'), fn($q) => $q->ofAccount($request->account_id)) + ->when($request->has('start_date'), fn($q) => $q->inPeriod($request->start_date, $request->end_date)) + ->select('status', DB::raw('COUNT(*) as count'), DB::raw('SUM(COALESCE(amount, planned_amount)) as total')) + ->groupBy('status') + ->get() + ->keyBy('status'); + + // Totais por tipo (apenas completed) + $byType = Transaction::ofUser($userId) + ->completed() + ->when($request->has('account_id'), fn($q) => $q->ofAccount($request->account_id)) + ->when($request->has('start_date'), fn($q) => $q->inPeriod($request->start_date, $request->end_date, 'effective_date')) + ->select('type', DB::raw('COUNT(*) as count'), DB::raw('SUM(amount) as total')) + ->groupBy('type') + ->get() + ->keyBy('type'); + + // Transações pendentes atrasadas + $overdue = Transaction::ofUser($userId) + ->pending() + ->when($request->has('account_id'), fn($q) => $q->ofAccount($request->account_id)) + ->where('planned_date', '<', now()->startOfDay()) + ->count(); + + return response()->json([ + 'by_status' => [ + 'pending' => [ + 'count' => $byStatus['pending']->count ?? 0, + 'total' => (float) ($byStatus['pending']->total ?? 0), + ], + 'completed' => [ + 'count' => $byStatus['completed']->count ?? 0, + 'total' => (float) ($byStatus['completed']->total ?? 0), + ], + 'cancelled' => [ + 'count' => $byStatus['cancelled']->count ?? 0, + 'total' => (float) ($byStatus['cancelled']->total ?? 0), + ], + ], + 'by_type' => [ + 'credit' => [ + 'count' => $byType['credit']->count ?? 0, + 'total' => (float) ($byType['credit']->total ?? 0), + ], + 'debit' => [ + 'count' => $byType['debit']->count ?? 0, + 'total' => (float) ($byType['debit']->total ?? 0), + ], + ], + 'balance' => (float) (($byType['credit']->total ?? 0) - ($byType['debit']->total ?? 0)), + 'overdue_count' => $overdue, + ]); + } + + /** + * Duplicar transação + */ + public function duplicate(Request $request, Transaction $transaction): JsonResponse + { + if ($transaction->user_id !== $request->user()->id) { + return response()->json(['message' => 'Transação não encontrada'], 404); + } + + $newTransaction = $transaction->replicate([ + 'amount', + 'effective_date', + 'status', + ]); + + $newTransaction->status = 'pending'; + $newTransaction->amount = null; + $newTransaction->effective_date = null; + $newTransaction->planned_date = now()->toDateString(); + $newTransaction->save(); + + return response()->json( + $newTransaction->load(['account:id,name,currency', 'category:id,name,color,icon', 'costCenter:id,name,color']), + 201 + ); + } + + /** + * Listar transações agrupadas por semana e separadas por divisa + */ + public function byWeek(Request $request): JsonResponse + { + $userId = $request->user()->id; + $perPage = $request->get('per_page', 10); // Semanas por página + $page = $request->get('page', 1); + $currency = $request->get('currency'); // Filtro de divisa opcional + $dateField = $request->get('date_field', 'planned_date'); + + // Buscar IDs de transações que estão conciliadas com passivos + $reconciledTransactionIds = \App\Models\LiabilityInstallment::whereNotNull('reconciled_transaction_id') + ->pluck('reconciled_transaction_id') + ->toArray(); + + // Construir query base + $query = Transaction::ofUser($userId) + ->with(['account:id,name,currency', 'category:id,name,color,icon', 'costCenter:id,name,color']); + + // Filtros opcionais + if ($request->has('account_id')) { + $query->ofAccount($request->account_id); + } + + if ($request->has('category_id')) { + $query->ofCategory($request->category_id); + } + + if ($request->has('cost_center_id')) { + $query->ofCostCenter($request->cost_center_id); + } + + if ($request->has('type')) { + $query->where('type', $request->type); + } + + if ($request->has('status')) { + $query->where('status', $request->status); + } + + // Filtro por período + if ($request->has('start_date') && $request->has('end_date')) { + $query->inPeriod($request->start_date, $request->end_date, $dateField); + } + + // Busca por descrição e valores + if ($request->has('search')) { + $search = $request->search; + // Limpar formatação de número para busca por valor + $searchNumber = preg_replace('/[^\d.,\-]/', '', $search); + $searchNumber = str_replace(',', '.', $searchNumber); + + $query->where(function($q) use ($search, $searchNumber) { + $q->where('description', 'like', "%{$search}%") + ->orWhere('reference', 'like', "%{$search}%") + ->orWhere('notes', 'like', "%{$search}%"); + + // Se for um número válido, buscar também nos campos de valor + if (is_numeric($searchNumber)) { + $numericValue = (float) $searchNumber; + // Busca exata + $q->orWhere('planned_amount', '=', $numericValue) + ->orWhere('amount', '=', $numericValue); + + // Busca por valor sem decimais (ex: "147" encontra 147.00, 147.50, etc.) + if (strpos($searchNumber, '.') === false) { + $q->orWhereBetween('planned_amount', [$numericValue, $numericValue + 0.99]) + ->orWhereBetween('amount', [$numericValue, $numericValue + 0.99]); + } + } + }); + } + + // Ordenar por data (effective_date com fallback para planned_date) + if ($dateField === 'effective_date') { + $query->orderByRaw('COALESCE(effective_date, planned_date) DESC'); + } else { + $query->orderBy($dateField, 'desc'); + } + + // Obter todas as transações filtradas + $allTransactions = $query->get(); + + // Agrupar por divisa (da conta) + $byCurrency = $allTransactions->groupBy(function ($transaction) { + return $transaction->account->currency ?? 'EUR'; + }); + + // Se tem filtro de divisa, aplicar + if ($currency && $byCurrency->has($currency)) { + $byCurrency = collect([$currency => $byCurrency->get($currency)]); + } elseif ($currency) { + $byCurrency = collect(); // Divisa não encontrada + } + + // Estrutura de resultado por divisa + $result = []; + + foreach ($byCurrency as $currencyCode => $currencyTransactions) { + // Agrupar transações por semana (YEARWEEK) + $byWeek = $currencyTransactions->groupBy(function ($transaction) use ($dateField) { + // Usar effective_date com fallback para planned_date + $date = $dateField === 'effective_date' + ? ($transaction->effective_date ?? $transaction->planned_date) + : $transaction->$dateField; + $carbon = \Carbon\Carbon::parse($date); + // Usar ISO week (segunda a domingo) + return $carbon->format('o-W'); // ISO year-week (ex: 2025-49) + }); + + // Ordenar semanas (mais recentes primeiro) + $byWeek = $byWeek->sortKeysDesc(); + + // Calcular total de semanas para paginação + $totalWeeks = $byWeek->count(); + + // Aplicar paginação manual (semanas) + $paginatedWeeks = $byWeek->skip(($page - 1) * $perPage)->take($perPage); + + $weeks = []; + foreach ($paginatedWeeks as $yearWeek => $weekTransactions) { + // Calcular datas de início e fim da semana + [$year, $week] = explode('-', $yearWeek); + $startOfWeek = \Carbon\Carbon::now() + ->setISODate((int)$year, (int)$week, 1) // Segunda-feira + ->startOfDay(); + $endOfWeek = $startOfWeek->copy()->addDays(6)->endOfDay(); + + // Separar transferências das transações normais + $normalTransactions = $weekTransactions->filter(fn($t) => !$t->is_transfer); + $transfers = $weekTransactions->filter(fn($t) => $t->is_transfer); + + // Ordenar transações dentro da semana por data (effective_date com fallback para planned_date) + $normalTransactions = $normalTransactions->sortByDesc(function ($t) use ($dateField) { + if ($dateField === 'effective_date') { + return $t->effective_date ?? $t->planned_date; + } + return $t->$dateField; + }); + + // Calcular resumo financeiro da semana (excluindo transferências) + $credits = $normalTransactions->where('type', 'credit'); + $debits = $normalTransactions->where('type', 'debit'); + $pending = $normalTransactions->where('status', 'pending'); + $completed = $normalTransactions->where('status', 'completed'); + $overdue = $normalTransactions->where('status', 'pending') + ->filter(function ($t) { + return \Carbon\Carbon::parse($t->planned_date)->lt(now()->startOfDay()); + }); + + $totalCredits = $credits->sum(function ($t) { + if ($t->status === 'completed') { + return $t->amount > 0 ? $t->amount : $t->planned_amount; + } + return $t->planned_amount; + }); + + $totalDebits = $debits->sum(function ($t) { + if ($t->status === 'completed') { + return $t->amount > 0 ? $t->amount : $t->planned_amount; + } + return $t->planned_amount; + }); + + // Agrupar transferências em pares (débito → crédito) + $processedTransferIds = []; + $groupedTransfers = []; + + foreach ($transfers as $transfer) { + // Pular se já processamos esta transferência + if (in_array($transfer->id, $processedTransferIds)) { + continue; + } + + $linkedTransfer = null; + if ($transfer->transfer_linked_id) { + $linkedTransfer = $transfers->firstWhere('id', $transfer->transfer_linked_id); + } + + // Determinar qual é o débito e qual é o crédito + $debitTransaction = $transfer->type === 'debit' ? $transfer : $linkedTransfer; + $creditTransaction = $transfer->type === 'credit' ? $transfer : $linkedTransfer; + + // Se não encontrou o par, usar apenas esta transação + if (!$linkedTransfer) { + $debitTransaction = $transfer->type === 'debit' ? $transfer : null; + $creditTransaction = $transfer->type === 'credit' ? $transfer : null; + } + + $amount = $debitTransaction + ? ($debitTransaction->status === 'completed' ? $debitTransaction->amount : $debitTransaction->planned_amount) + : ($creditTransaction->status === 'completed' ? $creditTransaction->amount : $creditTransaction->planned_amount); + + $groupedTransfers[] = [ + 'id' => $transfer->id, + 'is_transfer_pair' => true, + 'description' => $transfer->description, + 'original_description' => $transfer->original_description, + 'amount' => (float) $amount, + 'status' => $transfer->status, + 'planned_date' => $transfer->planned_date?->format('Y-m-d'), + 'effective_date' => $transfer->effective_date?->format('Y-m-d'), + 'from_account' => $debitTransaction && $debitTransaction->account ? [ + 'id' => $debitTransaction->account->id, + 'name' => $debitTransaction->account->name, + 'currency' => $debitTransaction->account->currency, + ] : null, + 'to_account' => $creditTransaction && $creditTransaction->account ? [ + 'id' => $creditTransaction->account->id, + 'name' => $creditTransaction->account->name, + 'currency' => $creditTransaction->account->currency, + ] : null, + 'debit_transaction_id' => $debitTransaction?->id, + 'credit_transaction_id' => $creditTransaction?->id, + ]; + + // Marcar ambas as transações como processadas + $processedTransferIds[] = $transfer->id; + if ($linkedTransfer) { + $processedTransferIds[] = $linkedTransfer->id; + } + } + + $weeks[] = [ + 'year_week' => $yearWeek, + 'year' => (int)$year, + 'week_number' => (int)$week, + 'start_date' => $startOfWeek->format('Y-m-d'), + 'end_date' => $endOfWeek->format('Y-m-d'), + 'summary' => [ + 'total_transactions' => $normalTransactions->count(), + 'transfers_count' => count($groupedTransfers), + 'credits' => [ + 'count' => $credits->count(), + 'total' => (float) $totalCredits, + ], + 'debits' => [ + 'count' => $debits->count(), + 'total' => (float) $totalDebits, + ], + 'balance' => (float) ($totalCredits - $totalDebits), + 'pending' => [ + 'count' => $pending->count(), + 'total' => (float) $pending->sum('planned_amount'), + ], + 'completed' => [ + 'count' => $completed->count(), + 'total' => (float) $completed->sum(fn($t) => $t->amount > 0 ? $t->amount : $t->planned_amount), + ], + 'overdue_count' => $overdue->count(), + ], + 'transactions' => $normalTransactions->values()->map(function ($t) use ($reconciledTransactionIds) { + return [ + 'id' => $t->id, + 'description' => $t->description, + 'original_description' => $t->original_description, + 'type' => $t->type, + 'status' => $t->status, + 'amount' => (float) $t->amount, + 'planned_amount' => (float) $t->planned_amount, + 'planned_date' => $t->planned_date?->format('Y-m-d'), + 'effective_date' => $t->effective_date?->format('Y-m-d'), + 'reference' => $t->reference, + 'notes' => $t->notes, + 'is_transfer' => false, + 'is_reconciled' => in_array($t->id, $reconciledTransactionIds), + 'account' => $t->account ? [ + 'id' => $t->account->id, + 'name' => $t->account->name, + 'currency' => $t->account->currency, + ] : null, + 'category' => $t->category ? [ + 'id' => $t->category->id, + 'name' => $t->category->name, + 'color' => $t->category->color, + 'icon' => $t->category->icon, + ] : null, + 'cost_center' => $t->costCenter ? [ + 'id' => $t->costCenter->id, + 'name' => $t->costCenter->name, + 'color' => $t->costCenter->color, + ] : null, + 'is_overdue' => $t->status === 'pending' && + \Carbon\Carbon::parse($t->planned_date)->lt(now()->startOfDay()), + ]; + }), + 'transfers' => $groupedTransfers, + ]; + } + + $result[$currencyCode] = [ + 'currency' => $currencyCode, + 'total_transactions' => $currencyTransactions->count(), + 'pagination' => [ + 'current_page' => (int) $page, + 'per_page' => (int) $perPage, + 'total_weeks' => $totalWeeks, + 'total_pages' => (int) ceil($totalWeeks / $perPage), + 'has_more' => $page < ceil($totalWeeks / $perPage), + ], + 'weeks' => $weeks, + ]; + } + + // Retornar divisas disponíveis + $availableCurrencies = array_keys($result); + + return response()->json([ + 'currencies' => $availableCurrencies, + 'selected_currency' => $currency, + 'data' => $result, + ]); + } + + /** + * Criar transferência entre contas + * Cria duas transações vinculadas: débito na origem, crédito no destino + */ + public function transfer(Request $request): JsonResponse + { + $validated = $request->validate([ + 'from_account_id' => 'required|exists:accounts,id', + 'to_account_id' => 'required|exists:accounts,id|different:from_account_id', + 'amount' => 'required|numeric|min:0.01', + 'description' => 'nullable|string|max:255', + 'date' => 'required|date', + 'notes' => 'nullable|string', + ]); + + $userId = $request->user()->id; + + // Verificar se ambas as contas pertencem ao usuário + $fromAccount = Account::where('id', $validated['from_account_id']) + ->where('user_id', $userId) + ->firstOrFail(); + + $toAccount = Account::where('id', $validated['to_account_id']) + ->where('user_id', $userId) + ->firstOrFail(); + + $description = $validated['description'] ?? "Transferência: {$fromAccount->name} → {$toAccount->name}"; + + DB::beginTransaction(); + try { + // Criar transação de DÉBITO na conta de origem + $debitTransaction = Transaction::create([ + 'user_id' => $userId, + 'account_id' => $fromAccount->id, + 'type' => 'debit', + 'amount' => $validated['amount'], + 'planned_amount' => $validated['amount'], + 'description' => $description, + 'effective_date' => $validated['date'], + 'planned_date' => $validated['date'], + 'status' => 'completed', + 'notes' => $validated['notes'] ?? "Transferência para {$toAccount->name}", + 'reference' => 'TRANSFER', + ]); + + // Criar transação de CRÉDITO na conta de destino + $creditTransaction = Transaction::create([ + 'user_id' => $userId, + 'account_id' => $toAccount->id, + 'type' => 'credit', + 'amount' => $validated['amount'], + 'planned_amount' => $validated['amount'], + 'description' => $description, + 'effective_date' => $validated['date'], + 'planned_date' => $validated['date'], + 'status' => 'completed', + 'notes' => $validated['notes'] ?? "Transferência de {$fromAccount->name}", + 'reference' => 'TRANSFER', + ]); + + // Vincular as duas transações + $debitTransaction->update(['transfer_pair_id' => $creditTransaction->id]); + $creditTransaction->update(['transfer_pair_id' => $debitTransaction->id]); + + DB::commit(); + + return response()->json([ + 'success' => true, + 'message' => 'Transferência realizada com sucesso', + 'data' => [ + 'debit' => $debitTransaction->load('account'), + 'credit' => $creditTransaction->load('account'), + ], + ], 201); + + } catch (\Exception $e) { + DB::rollBack(); + return response()->json([ + 'success' => false, + 'message' => 'Erro ao realizar transferência: ' . $e->getMessage(), + ], 500); + } + } + + /** + * Desvincular transferência + * Remove os flags is_transfer e transfer_linked_id de ambas as transações + */ + public function unlinkTransfer(Request $request, Transaction $transaction): JsonResponse + { + // Verificar se pertence ao usuário + if ($transaction->user_id !== $request->user()->id) { + return response()->json(['message' => 'Transação não encontrada'], 404); + } + + // Verificar se é uma transferência + if (!$transaction->is_transfer) { + return response()->json([ + 'success' => false, + 'message' => 'Esta transação não é uma transferência', + ], 422); + } + + DB::beginTransaction(); + try { + // Encontrar a transação vinculada (se existir) + $linkedTransaction = null; + if ($transaction->transfer_linked_id) { + $linkedTransaction = Transaction::where('id', $transaction->transfer_linked_id) + ->where('user_id', $request->user()->id) + ->first(); + } + + // Também verificar pelo transfer_pair_id + if (!$linkedTransaction && $transaction->transfer_pair_id) { + $linkedTransaction = Transaction::where('id', $transaction->transfer_pair_id) + ->where('user_id', $request->user()->id) + ->first(); + } + + // Remover flags da transação principal + $transaction->update([ + 'is_transfer' => false, + 'transfer_linked_id' => null, + 'transfer_pair_id' => null, + 'reference' => $transaction->reference === 'TRANSFER' ? null : $transaction->reference, + ]); + + // Remover flags da transação vinculada (se existir) + if ($linkedTransaction) { + $linkedTransaction->update([ + 'is_transfer' => false, + 'transfer_linked_id' => null, + 'transfer_pair_id' => null, + 'reference' => $linkedTransaction->reference === 'TRANSFER' ? null : $linkedTransaction->reference, + ]); + } + + DB::commit(); + + return response()->json([ + 'success' => true, + 'message' => 'Transferência desvinculada com sucesso', + 'data' => [ + 'transaction' => $transaction->fresh()->load(['account', 'category', 'costCenter']), + 'linked_transaction' => $linkedTransaction?->fresh()->load(['account', 'category', 'costCenter']), + ], + ]); + + } catch (\Exception $e) { + DB::rollBack(); + return response()->json([ + 'success' => false, + 'message' => 'Erro ao desvincular transferência: ' . $e->getMessage(), + ], 500); + } + } + + /** + * Efetivação rápida de transação pendente + * Permite efetivar direto da listagem com dados mínimos + */ + public function quickComplete(Request $request, Transaction $transaction): JsonResponse + { + // Verificar se pertence ao usuário + if ($transaction->user_id !== $request->user()->id) { + return response()->json(['message' => 'Transação não encontrada'], 404); + } + + if ($transaction->status !== 'pending') { + return response()->json([ + 'success' => false, + 'message' => 'Apenas transações pendentes podem ser efetivadas', + ], 422); + } + + $validated = $request->validate([ + 'amount' => 'nullable|numeric|min:0', + 'effective_date' => 'nullable|date', + ]); + + $transaction->update([ + 'status' => 'completed', + 'amount' => $validated['amount'] ?? $transaction->planned_amount, + 'effective_date' => $validated['effective_date'] ?? now()->toDateString(), + ]); + + return response()->json([ + 'success' => true, + 'message' => 'Transação efetivada com sucesso', + 'data' => $transaction->load(['account', 'category', 'costCenter']), + ]); + } + + /** + * Dividir transação em múltiplas categorias + * A transação original é marcada como "pai" e novas transações filhas são criadas + */ + public function split(Request $request, Transaction $transaction): JsonResponse + { + // Verificar se pertence ao usuário + if ($transaction->user_id !== $request->user()->id) { + return response()->json(['message' => 'Transação não encontrada'], 404); + } + + // Não pode dividir transação já dividida ou filha + if ($transaction->is_split_parent || $transaction->is_split_child) { + return response()->json([ + 'success' => false, + 'message' => 'Esta transação já foi dividida ou é resultado de uma divisão', + ], 422); + } + + $validated = $request->validate([ + 'splits' => 'required|array|min:2', + 'splits.*.category_id' => 'nullable|exists:categories,id', + 'splits.*.amount' => 'required|numeric|min:0.01', + 'splits.*.description' => 'nullable|string|max:255', + ]); + + // Validar que a soma das divisões é igual ao valor original + $totalSplit = collect($validated['splits'])->sum('amount'); + $originalAmount = $transaction->amount ?? $transaction->planned_amount; + + if (abs($totalSplit - $originalAmount) > 0.01) { + return response()->json([ + 'success' => false, + 'message' => "A soma das divisões ({$totalSplit}) deve ser igual ao valor original ({$originalAmount})", + ], 422); + } + + DB::beginTransaction(); + try { + // Marcar transação original como pai + $transaction->update([ + 'is_split_parent' => true, + ]); + + $splitTransactions = []; + + foreach ($validated['splits'] as $index => $split) { + $splitTransaction = Transaction::create([ + 'user_id' => $transaction->user_id, + 'account_id' => $transaction->account_id, + 'category_id' => $split['category_id'] ?? null, + 'cost_center_id' => $transaction->cost_center_id, + 'type' => $transaction->type, + 'amount' => $transaction->status === 'completed' ? $split['amount'] : null, + 'planned_amount' => $split['amount'], + 'description' => $split['description'] ?? "{$transaction->description} (Parte " . ($index + 1) . ")", + 'original_description' => $transaction->original_description, + 'effective_date' => $transaction->effective_date, + 'planned_date' => $transaction->planned_date, + 'status' => $transaction->status, + 'notes' => $transaction->notes, + 'reference' => $transaction->reference, + 'parent_transaction_id' => $transaction->id, + 'is_split_child' => true, + ]); + + $splitTransactions[] = $splitTransaction; + } + + DB::commit(); + + return response()->json([ + 'success' => true, + 'message' => 'Transação dividida com sucesso em ' . count($splitTransactions) . ' partes', + 'data' => [ + 'parent' => $transaction->fresh(['account', 'category', 'costCenter']), + 'splits' => collect($splitTransactions)->map(fn($t) => $t->load(['account', 'category', 'costCenter'])), + ], + ], 201); + + } catch (\Exception $e) { + DB::rollBack(); + return response()->json([ + 'success' => false, + 'message' => 'Erro ao dividir transação: ' . $e->getMessage(), + ], 500); + } + } + + /** + * Desfazer divisão de transação + * Remove as transações filhas e restaura a transação pai + */ + public function unsplit(Request $request, Transaction $transaction): JsonResponse + { + // Verificar se pertence ao usuário + if ($transaction->user_id !== $request->user()->id) { + return response()->json(['message' => 'Transação não encontrada'], 404); + } + + if (!$transaction->is_split_parent) { + return response()->json([ + 'success' => false, + 'message' => 'Esta transação não foi dividida', + ], 422); + } + + DB::beginTransaction(); + try { + // Deletar transações filhas + Transaction::where('parent_transaction_id', $transaction->id)->delete(); + + // Restaurar transação pai + $transaction->update([ + 'is_split_parent' => false, + ]); + + DB::commit(); + + return response()->json([ + 'success' => true, + 'message' => 'Divisão desfeita com sucesso', + 'data' => $transaction->fresh(['account', 'category', 'costCenter']), + ]); + + } catch (\Exception $e) { + DB::rollBack(); + return response()->json([ + 'success' => false, + 'message' => 'Erro ao desfazer divisão: ' . $e->getMessage(), + ], 500); + } + } + + /** + * Obter transações filhas de uma divisão + */ + public function getSplits(Request $request, Transaction $transaction): JsonResponse + { + // Verificar se pertence ao usuário + if ($transaction->user_id !== $request->user()->id) { + return response()->json(['message' => 'Transação não encontrada'], 404); + } + + if (!$transaction->is_split_parent) { + return response()->json([ + 'success' => false, + 'message' => 'Esta transação não foi dividida', + ], 422); + } + + $splits = Transaction::where('parent_transaction_id', $transaction->id) + ->with(['category', 'costCenter']) + ->get(); + + return response()->json([ + 'success' => true, + 'data' => [ + 'parent' => $transaction->load(['account', 'category', 'costCenter']), + 'splits' => $splits, + ], + ]); + } + + /** + * Buscar parcelas de passivo compatíveis para conciliação + */ + public function findLiabilityInstallments(Request $request, Transaction $transaction): JsonResponse + { + // Verificar se pertence ao usuário + if ($transaction->user_id !== $request->user()->id) { + return response()->json(['message' => 'Transação não encontrada'], 404); + } + + // Só transações de débito podem ser conciliadas com passivos + if ($transaction->type !== 'debit') { + return response()->json([ + 'success' => false, + 'message' => 'Apenas transações de débito podem ser conciliadas com passivos', + ], 422); + } + + // Já está conciliada? + $alreadyReconciled = \App\Models\LiabilityInstallment::where('reconciled_transaction_id', $transaction->id)->exists(); + if ($alreadyReconciled) { + return response()->json([ + 'success' => false, + 'message' => 'Esta transação já está conciliada com um passivo', + ], 422); + } + + $toleranceDays = $request->input('tolerance_days', 15); + $toleranceAmount = $request->input('tolerance_amount', 0.10); // 10% de tolerância no valor + + // Considera tanto o valor efetivo quanto o planejado da transação + $transactionEffective = abs($transaction->amount ?: 0); + $transactionPlanned = abs($transaction->planned_amount ?: 0); + // Ambas as datas para matching + $transactionDateEffective = $transaction->effective_date; + $transactionDatePlanned = $transaction->planned_date; + $transactionDate = $transactionDateEffective ?: $transactionDatePlanned; + + // Buscar parcelas pendentes com valores e datas próximas + $installments = \App\Models\LiabilityInstallment::whereNull('reconciled_transaction_id') + ->whereIn('status', ['pending', 'overdue']) + ->whereHas('liabilityAccount', function ($q) use ($request) { + $q->where('user_id', $request->user()->id); + }) + ->with(['liabilityAccount:id,name,creditor']) + ->get() + ->map(function ($installment) use ($transactionEffective, $transactionPlanned, $transactionDateEffective, $transactionDatePlanned, $toleranceDays, $toleranceAmount) { + // Valores da parcela: planejado e pago + $installmentPlanned = abs($installment->installment_amount); + $installmentPaid = $installment->paid_amount ? abs($installment->paid_amount) : null; + + // Calcular todas as combinações possíveis de diferença + $combinations = []; + + // TX efetivo vs Parcela planejado + if ($transactionEffective > 0) { + $diff = abs($transactionEffective - $installmentPlanned); + $pct = $installmentPlanned > 0 ? ($diff / $installmentPlanned) : 1; + $combinations[] = ['diff' => $diff, 'pct' => $pct, 'type' => 'effective_vs_planned']; + } + + // TX planejado vs Parcela planejado + if ($transactionPlanned > 0) { + $diff = abs($transactionPlanned - $installmentPlanned); + $pct = $installmentPlanned > 0 ? ($diff / $installmentPlanned) : 1; + $combinations[] = ['diff' => $diff, 'pct' => $pct, 'type' => 'planned_vs_planned']; + } + + // TX efetivo vs Parcela pago + if ($transactionEffective > 0 && $installmentPaid !== null && $installmentPaid > 0) { + $diff = abs($transactionEffective - $installmentPaid); + $pct = $installmentPaid > 0 ? ($diff / $installmentPaid) : 1; + $combinations[] = ['diff' => $diff, 'pct' => $pct, 'type' => 'effective_vs_paid']; + } + + // Escolher a melhor combinação (menor diferença percentual) + usort($combinations, fn($a, $b) => $a['pct'] <=> $b['pct']); + $best = $combinations[0] ?? null; + + if (!$best) return null; + + $amountDiff = $best['diff']; + $amountDiffPercent = $best['pct']; + $matchType = $best['type']; + + // Calcular confiança + $confidence = 0; + $reasons = []; + + // Valor exato ou muito próximo + if ($amountDiff < 0.01) { + $confidence += 50; + $reasons[] = 'exact_amount'; + $reasons[] = $matchType; + } elseif ($amountDiffPercent <= $toleranceAmount) { + $confidence += 30; + $reasons[] = 'similar_amount'; + $reasons[] = $matchType; + } else { + return null; // Valor muito diferente, ignorar + } + + // Calcular diferença de datas considerando ambas as datas da transação + $daysDiffEffective = $transactionDateEffective ? abs($transactionDateEffective->diffInDays($installment->due_date)) : PHP_INT_MAX; + $daysDiffPlanned = $transactionDatePlanned ? abs($transactionDatePlanned->diffInDays($installment->due_date)) : PHP_INT_MAX; + $daysDiff = min($daysDiffEffective, $daysDiffPlanned); + + // Data próxima + if ($daysDiff == 0) { + $confidence += 30; + $reasons[] = 'same_date'; + } elseif ($daysDiff <= 3) { + $confidence += 25; + $reasons[] = 'within_3_days'; + } elseif ($daysDiff <= 7) { + $confidence += 15; + $reasons[] = 'within_7_days'; + } elseif ($daysDiff <= $toleranceDays) { + $confidence += 5; + $reasons[] = 'within_tolerance'; + } else { + return null; // Data muito diferente, ignorar + } + + // Status overdue aumenta chance + if ($installment->status === 'overdue') { + $confidence += 10; + $reasons[] = 'overdue'; + } + + $level = $confidence >= 70 ? 'high' : ($confidence >= 50 ? 'medium' : 'low'); + + // Calcular sobrepagamento (cargo/juros extra) + $transactionTotalAmount = $transactionEffective > 0 ? $transactionEffective : $transactionPlanned; + $overpayment = $transactionTotalAmount - $installmentPlanned; + $hasOverpayment = $overpayment > 0.01; + + return [ + 'id' => $installment->id, + 'liability_account_id' => $installment->liability_account_id, + 'liability_name' => $installment->liabilityAccount->name ?? 'N/A', + 'creditor' => $installment->liabilityAccount->creditor ?? null, + 'installment_number' => $installment->installment_number, + 'due_date' => $installment->due_date->format('Y-m-d'), + 'installment_amount' => (float) $installment->installment_amount, + 'status' => $installment->status, + 'days_diff' => $daysDiff, + 'amount_diff' => round($amountDiff, 2), + 'overpayment' => $hasOverpayment ? round($overpayment, 2) : null, + 'has_overpayment' => $hasOverpayment, + 'confidence' => [ + 'percentage' => min(100, $confidence), + 'level' => $level, + 'reasons' => $reasons, + ], + ]; + }) + ->filter() + ->sortByDesc(fn($i) => $i['confidence']['percentage']) + ->values(); + + return response()->json([ + 'success' => true, + 'transaction' => [ + 'id' => $transaction->id, + 'description' => $transaction->description, + 'amount' => (float) abs($transaction->amount ?: $transaction->planned_amount), + 'date' => $transactionDate->format('Y-m-d'), + ], + 'installments' => $installments, + 'total' => $installments->count(), + ]); + } + + /** + * Conciliar transação com uma parcela de passivo + */ + public function reconcileWithLiability(Request $request, Transaction $transaction): JsonResponse + { + // Verificar se pertence ao usuário + if ($transaction->user_id !== $request->user()->id) { + return response()->json(['message' => 'Transação não encontrada'], 404); + } + + $validated = $request->validate([ + 'installment_id' => 'required|integer|exists:liability_installments,id', + 'fee_amount' => 'nullable|numeric|min:0', // Cargo/juros extra (sobrepagamento) + ]); + + $installment = \App\Models\LiabilityInstallment::with('liabilityAccount') + ->findOrFail($validated['installment_id']); + + // Verificar se a parcela pertence ao usuário + if ($installment->liabilityAccount->user_id !== $request->user()->id) { + return response()->json(['message' => 'Parcela não encontrada'], 404); + } + + // Verificar se a parcela já está conciliada + if ($installment->reconciled_transaction_id) { + return response()->json([ + 'success' => false, + 'message' => 'Esta parcela já está conciliada com outra transação', + ], 422); + } + + try { + DB::beginTransaction(); + + $paidAmount = abs($transaction->amount ?: $transaction->planned_amount); + $plannedAmount = (float) $installment->installment_amount; + $feeAmount = $validated['fee_amount'] ?? 0; + + // Se há sobrepagamento e não foi especificado fee, calcular automaticamente + if ($paidAmount > $plannedAmount && $feeAmount == 0) { + $feeAmount = $paidAmount - $plannedAmount; + } + + // Atualizar parcela + $installment->reconciled_transaction_id = $transaction->id; + $installment->payment_account_id = $transaction->account_id; + $installment->status = \App\Models\LiabilityInstallment::STATUS_PAID; + $installment->paid_amount = $paidAmount; + $installment->paid_date = $transaction->effective_date ?: $transaction->planned_date; + + // Se há cargo/juros extra, registrar no fee_amount + if ($feeAmount > 0) { + $installment->fee_amount = ($installment->fee_amount ?? 0) + $feeAmount; + } + + $installment->save(); + + // Recalcular totais da conta passivo + $installment->liabilityAccount->recalculateTotals(); + + DB::commit(); + + $responseData = [ + 'transaction_id' => $transaction->id, + 'installment_id' => $installment->id, + 'liability_name' => $installment->liabilityAccount->name, + 'paid_amount' => $paidAmount, + 'planned_amount' => $plannedAmount, + ]; + + if ($feeAmount > 0) { + $responseData['fee_registered'] = $feeAmount; + $responseData['message_detail'] = "Sobrepagamento de {$feeAmount} registrado como cargo/juros"; + } + + return response()->json([ + 'success' => true, + 'message' => 'Transação conciliada com sucesso', + 'data' => $responseData, + ]); + + } catch (\Exception $e) { + DB::rollBack(); + return response()->json([ + 'success' => false, + 'message' => 'Erro ao conciliar: ' . $e->getMessage(), + ], 500); + } + } +} diff --git a/backend/app/Http/Controllers/Api/TransferDetectionController.php b/backend/app/Http/Controllers/Api/TransferDetectionController.php new file mode 100644 index 0000000..b4ba480 --- /dev/null +++ b/backend/app/Http/Controllers/Api/TransferDetectionController.php @@ -0,0 +1,831 @@ +user()->id; + $toleranceDays = min(max((int) $request->input('tolerance_days', 3), 1), 30); // 1-30 dias + + // Buscar todas as transações não deletadas do usuário + $debits = DB::select(" + SELECT + t.id, + t.description, + t.planned_amount, + t.planned_date, + t.status, + t.account_id, + a.name as account_name + FROM transactions t + LEFT JOIN accounts a ON t.account_id = a.id + WHERE t.user_id = ? + AND t.deleted_at IS NULL + AND t.is_split_child = 0 + AND t.type = 'debit' + AND t.planned_amount > 0 + AND (t.is_transfer = 0 OR t.is_transfer IS NULL) + ORDER BY t.planned_date DESC, t.planned_amount DESC + ", [$userId]); + + $credits = DB::select(" + SELECT + t.id, + t.description, + t.planned_amount, + t.planned_date, + t.status, + t.account_id, + a.name as account_name + FROM transactions t + LEFT JOIN accounts a ON t.account_id = a.id + WHERE t.user_id = ? + AND t.deleted_at IS NULL + AND t.is_split_child = 0 + AND t.type = 'credit' + AND t.planned_amount > 0 + AND (t.is_transfer = 0 OR t.is_transfer IS NULL) + ORDER BY t.planned_date DESC, t.planned_amount DESC + ", [$userId]); + + $potentialTransfers = []; + $usedDebitIds = []; + $usedCreditIds = []; + + foreach ($debits as $debit) { + if (in_array($debit->id, $usedDebitIds)) continue; + + foreach ($credits as $credit) { + if (in_array($credit->id, $usedCreditIds)) continue; + + // Deve ser em contas DIFERENTES + if ($debit->account_id === $credit->account_id) continue; + + // Mesmo valor + if (abs((float)$debit->planned_amount - (float)$credit->planned_amount) > 0.01) continue; + + // Data dentro da tolerância + $debitDate = strtotime($debit->planned_date); + $creditDate = strtotime($credit->planned_date); + $daysDiff = abs(($creditDate - $debitDate) / 86400); + + if ($daysDiff > $toleranceDays) continue; + + // Calcular confiança + $confidence = $this->calculateConfidence($debit, $credit, $daysDiff); + + $potentialTransfers[] = [ + 'debit' => [ + 'id' => $debit->id, + 'description' => $debit->description, + 'amount' => $debit->planned_amount, + 'date' => $debit->planned_date, + 'status' => $debit->status, + 'account_id' => $debit->account_id, + 'account_name' => $debit->account_name, + ], + 'credit' => [ + 'id' => $credit->id, + 'description' => $credit->description, + 'amount' => $credit->planned_amount, + 'date' => $credit->planned_date, + 'status' => $credit->status, + 'account_id' => $credit->account_id, + 'account_name' => $credit->account_name, + ], + 'amount' => $debit->planned_amount, + 'days_diff' => (int)$daysDiff, + 'confidence' => $confidence, + ]; + + $usedDebitIds[] = $debit->id; + $usedCreditIds[] = $credit->id; + break; // Encontrou match, passar para próximo débito + } + } + + // Ordenar por confiança (maior primeiro) + usort($potentialTransfers, function($a, $b) { + return $b['confidence']['percentage'] <=> $a['confidence']['percentage']; + }); + + return response()->json([ + 'data' => $potentialTransfers, + 'total' => count($potentialTransfers), + 'tolerance_days' => $toleranceDays, + ]); + } + + /** + * Calcula nível de confiança de que é uma transferência + */ + private function calculateConfidence($debit, $credit, $daysDiff): array + { + $confidence = 50; // Base: mesmo valor em contas diferentes + $reasons = ['same_amount_different_accounts']; + + // Mesma data = +30% + if ($daysDiff == 0) { + $confidence += 30; + $reasons[] = 'same_date'; + } elseif ($daysDiff == 1) { + $confidence += 20; + $reasons[] = 'next_day'; + } elseif ($daysDiff <= 3) { + $confidence += 10; + $reasons[] = 'within_3_days'; + } + + // Descrição contém palavras-chave de transferência + $transferKeywords = ['transfer', 'transferencia', 'transferência', 'traspaso', 'envio', 'recebido', 'recibido', 'deposito', 'depósito']; + $debitDesc = strtolower($debit->description ?? ''); + $creditDesc = strtolower($credit->description ?? ''); + + foreach ($transferKeywords as $keyword) { + if (strpos($debitDesc, $keyword) !== false || strpos($creditDesc, $keyword) !== false) { + $confidence += 15; + $reasons[] = 'transfer_keyword'; + break; + } + } + + // Mesmo status = +5% + if ($debit->status === $credit->status) { + $confidence += 5; + $reasons[] = 'same_status'; + } + + $confidence = min(100, $confidence); + + if ($confidence >= 90) { + $level = 'high'; + } elseif ($confidence >= 70) { + $level = 'medium'; + } else { + $level = 'low'; + } + + return [ + 'percentage' => $confidence, + 'level' => $level, + 'reasons' => $reasons, + ]; + } + + /** + * Busca possíveis pares para uma transação específica + * Para converter uma transação em transferência + */ + public function findPairs(Request $request, int $transactionId): JsonResponse + { + $userId = $request->user()->id; + $toleranceDays = $request->input('tolerance_days', 7); // Tolerância maior para busca manual + + // Buscar a transação origem + $transaction = Transaction::where('id', $transactionId) + ->where('user_id', $userId) + ->whereNull('deleted_at') + ->first(); + + if (!$transaction) { + return response()->json(['error' => 'Transaction not found'], 404); + } + + // Não pode ser uma transferência já vinculada + if ($transaction->is_transfer) { + return response()->json(['error' => 'Transaction is already a transfer'], 400); + } + + // Determinar o tipo oposto + $oppositeType = $transaction->type === 'debit' ? 'credit' : 'debit'; + + // Buscar transações candidatas (tipo oposto, mesmo valor, contas diferentes, datas próximas) + $candidates = DB::select(" + SELECT + t.id, + t.description, + t.planned_amount, + t.amount, + t.planned_date, + t.effective_date, + t.status, + t.type, + t.account_id, + a.name as account_name + FROM transactions t + LEFT JOIN accounts a ON t.account_id = a.id + WHERE t.user_id = ? + AND t.deleted_at IS NULL + AND t.is_split_child = 0 + AND t.type = ? + AND t.account_id != ? + AND (t.is_transfer = 0 OR t.is_transfer IS NULL) + AND ABS(t.planned_amount - ?) < 0.01 + AND ABS(DATEDIFF(t.planned_date, ?)) <= ? + ORDER BY ABS(DATEDIFF(t.planned_date, ?)) ASC, t.planned_date DESC + ", [ + $userId, + $oppositeType, + $transaction->account_id, + $transaction->planned_amount, + $transaction->planned_date, + $toleranceDays, + $transaction->planned_date + ]); + + $potentialPairs = []; + + foreach ($candidates as $candidate) { + $candidateDate = strtotime($candidate->planned_date); + $transactionDate = strtotime($transaction->planned_date); + $daysDiff = abs(($candidateDate - $transactionDate) / 86400); + + // Usar método de confiança existente (adaptar objetos) + $sourceObj = (object)[ + 'description' => $transaction->description, + 'status' => $transaction->status, + ]; + $candidateObj = (object)[ + 'description' => $candidate->description, + 'status' => $candidate->status, + ]; + $confidence = $this->calculateConfidence($sourceObj, $candidateObj, $daysDiff); + + $potentialPairs[] = [ + 'id' => $candidate->id, + 'description' => $candidate->description, + 'amount' => $candidate->planned_amount, + 'date' => $candidate->planned_date, + 'status' => $candidate->status, + 'type' => $candidate->type, + 'account_id' => $candidate->account_id, + 'account_name' => $candidate->account_name, + 'days_diff' => (int)$daysDiff, + 'confidence' => $confidence, + ]; + } + + // Ordenar por confiança (maior primeiro), depois por diferença de dias (menor primeiro) + usort($potentialPairs, function($a, $b) { + if ($b['confidence']['percentage'] !== $a['confidence']['percentage']) { + return $b['confidence']['percentage'] <=> $a['confidence']['percentage']; + } + return $a['days_diff'] <=> $b['days_diff']; + }); + + return response()->json([ + 'source' => [ + 'id' => $transaction->id, + 'description' => $transaction->description, + 'amount' => $transaction->planned_amount, + 'date' => $transaction->planned_date, + 'status' => $transaction->status, + 'type' => $transaction->type, + 'account_id' => $transaction->account_id, + 'account_name' => $transaction->account->name ?? null, + ], + 'pairs' => $potentialPairs, + 'total' => count($potentialPairs), + 'tolerance_days' => $toleranceDays, + ]); + } + + /** + * Marca um par como transferência confirmada (vincula as duas transações) + */ + public function confirm(Request $request): JsonResponse + { + $request->validate([ + 'debit_id' => 'required|integer|exists:transactions,id', + 'credit_id' => 'required|integer|exists:transactions,id', + ]); + + $userId = $request->user()->id; + $debitId = $request->input('debit_id'); + $creditId = $request->input('credit_id'); + + // Verificar se ambas transações pertencem ao usuário + $debit = Transaction::where('id', $debitId)->where('user_id', $userId)->first(); + $credit = Transaction::where('id', $creditId)->where('user_id', $userId)->first(); + + if (!$debit || !$credit) { + return response()->json(['error' => 'Transaction not found'], 404); + } + + // Marcar como transferência vinculada + $debit->transfer_linked_id = $creditId; + $debit->is_transfer = true; + $debit->save(); + + $credit->transfer_linked_id = $debitId; + $credit->is_transfer = true; + $credit->save(); + + return response()->json([ + 'success' => true, + 'message' => 'Transfer confirmed and linked', + 'debit_id' => $debitId, + 'credit_id' => $creditId, + ]); + } + + /** + * Ignora um par de transferência (não sugerir novamente) + */ + public function ignore(Request $request): JsonResponse + { + $request->validate([ + 'debit_id' => 'required|integer|exists:transactions,id', + 'credit_id' => 'required|integer|exists:transactions,id', + ]); + + $userId = $request->user()->id; + + // Salvar na tabela de pares ignorados + DB::table('ignored_transfer_pairs')->insertOrIgnore([ + 'user_id' => $userId, + 'debit_transaction_id' => $request->input('debit_id'), + 'credit_transaction_id' => $request->input('credit_id'), + 'created_at' => now(), + ]); + + return response()->json([ + 'success' => true, + 'message' => 'Transfer pair ignored', + ]); + } + + /** + * Deleta ambas as transações (débito e crédito) de uma transferência + */ + public function deleteBoth(Request $request): JsonResponse + { + $request->validate([ + 'debit_id' => 'required|integer|exists:transactions,id', + 'credit_id' => 'required|integer|exists:transactions,id', + ]); + + $userId = $request->user()->id; + $debitId = $request->input('debit_id'); + $creditId = $request->input('credit_id'); + + $deleted = Transaction::whereIn('id', [$debitId, $creditId]) + ->where('user_id', $userId) + ->delete(); + + return response()->json([ + 'success' => true, + 'message' => 'Both transactions deleted', + 'deleted_count' => $deleted, + ]); + } + + /** + * Confirmar múltiplas transferências em lote + */ + public function confirmBatch(Request $request): JsonResponse + { + $request->validate([ + 'transfers' => 'required|array|min:1', + 'transfers.*.debit_id' => 'required|integer|exists:transactions,id', + 'transfers.*.credit_id' => 'required|integer|exists:transactions,id', + ]); + + $userId = $request->user()->id; + $transfers = $request->input('transfers'); + $confirmed = 0; + $errors = []; + + foreach ($transfers as $index => $transfer) { + $debitId = $transfer['debit_id']; + $creditId = $transfer['credit_id']; + + // Verificar se ambas transações pertencem ao usuário + $debit = Transaction::where('id', $debitId)->where('user_id', $userId)->first(); + $credit = Transaction::where('id', $creditId)->where('user_id', $userId)->first(); + + if (!$debit || !$credit) { + $errors[] = "Transfer {$index}: Transaction not found"; + continue; + } + + // Marcar como transferência vinculada + $debit->transfer_linked_id = $creditId; + $debit->is_transfer = true; + $debit->save(); + + $credit->transfer_linked_id = $debitId; + $credit->is_transfer = true; + $credit->save(); + + $confirmed++; + } + + return response()->json([ + 'success' => true, + 'message' => "Confirmed {$confirmed} transfers", + 'confirmed_count' => $confirmed, + 'errors' => $errors, + ]); + } + + /** + * Estatísticas de transferências + */ + public function stats(Request $request): JsonResponse + { + $userId = $request->user()->id; + + $confirmed = Transaction::where('user_id', $userId) + ->where('is_transfer', true) + ->whereNull('deleted_at') + ->count(); + + $ignored = DB::table('ignored_transfer_pairs') + ->where('user_id', $userId) + ->count(); + + return response()->json([ + 'confirmed_transfers' => $confirmed / 2, // Dividir por 2 pois cada transferência tem 2 transações + 'ignored_pairs' => $ignored, + ]); + } + + /** + * Detecta possíveis reembolsos (gastos que foram devolvidos) + * Critérios: mesmo valor, mesma conta, datas próximas, tipos opostos (debit/credit) + */ + public function refunds(Request $request): JsonResponse + { + $userId = $request->user()->id; + $toleranceDays = min(max((int) $request->input('tolerance_days', 7), 1), 30); + + // Buscar débitos (gastos) + $debits = DB::select(" + SELECT + t.id, + t.description, + t.original_description, + t.planned_amount, + t.amount, + t.planned_date, + t.effective_date, + t.status, + t.account_id, + a.name as account_name, + a.currency as account_currency + FROM transactions t + LEFT JOIN accounts a ON t.account_id = a.id + WHERE t.user_id = ? + AND t.deleted_at IS NULL + AND t.is_split_child = 0 + AND t.type = 'debit' + AND COALESCE(t.amount, t.planned_amount) > 0 + AND (t.is_transfer = 0 OR t.is_transfer IS NULL) + AND (t.is_refund_pair = 0 OR t.is_refund_pair IS NULL) + ORDER BY t.planned_date DESC, t.planned_amount DESC + ", [$userId]); + + // Buscar créditos (reembolsos potenciais) + $credits = DB::select(" + SELECT + t.id, + t.description, + t.original_description, + t.planned_amount, + t.amount, + t.planned_date, + t.effective_date, + t.status, + t.account_id, + a.name as account_name, + a.currency as account_currency + FROM transactions t + LEFT JOIN accounts a ON t.account_id = a.id + WHERE t.user_id = ? + AND t.deleted_at IS NULL + AND t.is_split_child = 0 + AND t.type = 'credit' + AND COALESCE(t.amount, t.planned_amount) > 0 + AND (t.is_transfer = 0 OR t.is_transfer IS NULL) + AND (t.is_refund_pair = 0 OR t.is_refund_pair IS NULL) + ORDER BY t.planned_date DESC, t.planned_amount DESC + ", [$userId]); + + $potentialRefunds = []; + $usedDebitIds = []; + $usedCreditIds = []; + + // Verificar pares ignorados + $ignoredPairs = DB::table('ignored_refund_pairs') + ->where('user_id', $userId) + ->get() + ->map(fn($p) => "{$p->debit_id}-{$p->credit_id}") + ->toArray(); + + foreach ($debits as $debit) { + if (in_array($debit->id, $usedDebitIds)) continue; + + foreach ($credits as $credit) { + if (in_array($credit->id, $usedCreditIds)) continue; + + // Deve ser na MESMA conta (diferente de transferência) + if ($debit->account_id !== $credit->account_id) continue; + + // Mesmo valor (usar amount se disponível, senão planned_amount) + $debitAmount = $debit->amount > 0 ? $debit->amount : $debit->planned_amount; + $creditAmount = $credit->amount > 0 ? $credit->amount : $credit->planned_amount; + + if (abs((float)$debitAmount - (float)$creditAmount) > 0.01) continue; + + // Data dentro da tolerância + $debitDate = strtotime($debit->effective_date ?? $debit->planned_date); + $creditDate = strtotime($credit->effective_date ?? $credit->planned_date); + $daysDiff = abs(($creditDate - $debitDate) / 86400); + + if ($daysDiff > $toleranceDays) continue; + + // Verificar se foi ignorado + $pairKey = "{$debit->id}-{$credit->id}"; + if (in_array($pairKey, $ignoredPairs)) continue; + + // Calcular confiança e similaridade de descrição + $confidence = $this->calculateRefundConfidence($debit, $credit, $daysDiff); + + $potentialRefunds[] = [ + 'debit' => [ + 'id' => $debit->id, + 'description' => $debit->description, + 'original_description' => $debit->original_description, + 'amount' => (float) $debitAmount, + 'date' => $debit->effective_date ?? $debit->planned_date, + 'status' => $debit->status, + 'account_id' => $debit->account_id, + 'account_name' => $debit->account_name, + ], + 'credit' => [ + 'id' => $credit->id, + 'description' => $credit->description, + 'original_description' => $credit->original_description, + 'amount' => (float) $creditAmount, + 'date' => $credit->effective_date ?? $credit->planned_date, + 'status' => $credit->status, + 'account_id' => $credit->account_id, + 'account_name' => $credit->account_name, + ], + 'amount' => (float) $debitAmount, + 'currency' => $debit->account_currency ?? 'EUR', + 'days_diff' => (int) $daysDiff, + 'confidence' => $confidence, + ]; + + $usedDebitIds[] = $debit->id; + $usedCreditIds[] = $credit->id; + break; + } + } + + // Ordenar por confiança (maior primeiro) + usort($potentialRefunds, function($a, $b) { + return $b['confidence']['percentage'] <=> $a['confidence']['percentage']; + }); + + return response()->json([ + 'data' => $potentialRefunds, + 'total' => count($potentialRefunds), + 'tolerance_days' => $toleranceDays, + ]); + } + + /** + * Calcula confiança de que é um par gasto/reembolso + */ + private function calculateRefundConfidence($debit, $credit, $daysDiff): array + { + $confidence = 40; // Base: mesmo valor na mesma conta + $reasons = ['same_amount_same_account']; + + // Mesma data = +25% + if ($daysDiff == 0) { + $confidence += 25; + $reasons[] = 'same_date'; + } elseif ($daysDiff == 1) { + $confidence += 20; + $reasons[] = 'next_day'; + } elseif ($daysDiff <= 3) { + $confidence += 15; + $reasons[] = 'within_3_days'; + } elseif ($daysDiff <= 7) { + $confidence += 10; + $reasons[] = 'within_week'; + } + + // Similaridade de descrição + $debitDesc = strtolower($debit->original_description ?? $debit->description ?? ''); + $creditDesc = strtolower($credit->original_description ?? $credit->description ?? ''); + + // Extrair palavras significativas (>3 caracteres) + $debitWords = array_filter(preg_split('/\s+/', $debitDesc), fn($w) => strlen($w) > 3); + $creditWords = array_filter(preg_split('/\s+/', $creditDesc), fn($w) => strlen($w) > 3); + + if (!empty($debitWords) && !empty($creditWords)) { + $commonWords = array_intersect($debitWords, $creditWords); + $totalWords = count(array_unique(array_merge($debitWords, $creditWords))); + $similarity = $totalWords > 0 ? (count($commonWords) / $totalWords) * 100 : 0; + + if ($similarity >= 50) { + $confidence += 25; + $reasons[] = 'high_description_similarity'; + } elseif ($similarity >= 30) { + $confidence += 15; + $reasons[] = 'medium_description_similarity'; + } elseif ($similarity >= 15) { + $confidence += 10; + $reasons[] = 'low_description_similarity'; + } + } + + // Keywords de reembolso + $refundKeywords = ['bizum', 'devolucion', 'devolución', 'reembolso', 'refund', 'return', 'abono', 'favor']; + foreach ($refundKeywords as $keyword) { + if (strpos($debitDesc, $keyword) !== false || strpos($creditDesc, $keyword) !== false) { + $confidence += 10; + $reasons[] = 'refund_keyword'; + break; + } + } + + // Mesmo status = +5% + if ($debit->status === $credit->status) { + $confidence += 5; + $reasons[] = 'same_status'; + } + + $confidence = min(100, $confidence); + + if ($confidence >= 85) { + $level = 'high'; + } elseif ($confidence >= 65) { + $level = 'medium'; + } else { + $level = 'low'; + } + + return [ + 'percentage' => $confidence, + 'level' => $level, + 'reasons' => $reasons, + ]; + } + + /** + * Confirma um par como reembolso (anula ambas transações) + */ + public function confirmRefund(Request $request): JsonResponse + { + $request->validate([ + 'debit_id' => 'required|integer|exists:transactions,id', + 'credit_id' => 'required|integer|exists:transactions,id', + ]); + + $userId = $request->user()->id; + $debitId = $request->input('debit_id'); + $creditId = $request->input('credit_id'); + + $debit = Transaction::where('id', $debitId)->where('user_id', $userId)->first(); + $credit = Transaction::where('id', $creditId)->where('user_id', $userId)->first(); + + if (!$debit || !$credit) { + return response()->json(['error' => 'Transaction not found'], 404); + } + + // Marcar ambas como par de reembolso (soft-anulação) + $debit->is_refund_pair = true; + $debit->refund_linked_id = $creditId; + $debit->save(); + + $credit->is_refund_pair = true; + $credit->refund_linked_id = $debitId; + $credit->save(); + + return response()->json([ + 'success' => true, + 'message' => 'Refund pair confirmed. Both transactions are now linked and excluded from calculations.', + 'debit_id' => $debitId, + 'credit_id' => $creditId, + ]); + } + + /** + * Confirma múltiplos pares como reembolso + */ + public function confirmRefundBatch(Request $request): JsonResponse + { + $request->validate([ + 'pairs' => 'required|array|min:1', + 'pairs.*.debit_id' => 'required|integer|exists:transactions,id', + 'pairs.*.credit_id' => 'required|integer|exists:transactions,id', + ]); + + $userId = $request->user()->id; + $pairs = $request->input('pairs'); + $confirmed = 0; + + foreach ($pairs as $pair) { + $debit = Transaction::where('id', $pair['debit_id'])->where('user_id', $userId)->first(); + $credit = Transaction::where('id', $pair['credit_id'])->where('user_id', $userId)->first(); + + if (!$debit || !$credit) continue; + + $debit->is_refund_pair = true; + $debit->refund_linked_id = $pair['credit_id']; + $debit->save(); + + $credit->is_refund_pair = true; + $credit->refund_linked_id = $pair['debit_id']; + $credit->save(); + + $confirmed++; + } + + return response()->json([ + 'success' => true, + 'message' => "Confirmed {$confirmed} refund pairs", + 'confirmed_count' => $confirmed, + ]); + } + + /** + * Ignorar um par de reembolso + */ + public function ignoreRefund(Request $request): JsonResponse + { + $request->validate([ + 'debit_id' => 'required|integer|exists:transactions,id', + 'credit_id' => 'required|integer|exists:transactions,id', + ]); + + $userId = $request->user()->id; + + DB::table('ignored_refund_pairs')->insert([ + 'user_id' => $userId, + 'debit_id' => $request->input('debit_id'), + 'credit_id' => $request->input('credit_id'), + 'created_at' => now(), + ]); + + return response()->json([ + 'success' => true, + 'message' => 'Refund pair ignored', + ]); + } + + /** + * Desfazer um par de reembolso confirmado + */ + public function undoRefund(Request $request): JsonResponse + { + $request->validate([ + 'debit_id' => 'required|integer|exists:transactions,id', + 'credit_id' => 'required|integer|exists:transactions,id', + ]); + + $userId = $request->user()->id; + + $debit = Transaction::where('id', $request->input('debit_id')) + ->where('user_id', $userId) + ->first(); + $credit = Transaction::where('id', $request->input('credit_id')) + ->where('user_id', $userId) + ->first(); + + if ($debit) { + $debit->is_refund_pair = false; + $debit->refund_linked_id = null; + $debit->save(); + } + + if ($credit) { + $credit->is_refund_pair = false; + $credit->refund_linked_id = null; + $credit->save(); + } + + return response()->json([ + 'success' => true, + 'message' => 'Refund pair undone', + ]); + } +} diff --git a/backend/app/Http/Controllers/Controller.php b/backend/app/Http/Controllers/Controller.php new file mode 100644 index 0000000..e7f7c94 --- /dev/null +++ b/backend/app/Http/Controllers/Controller.php @@ -0,0 +1,10 @@ +headers->set('X-XSS-Protection', '1; mode=block'); + + // Prevenir MIME type sniffing + $response->headers->set('X-Content-Type-Options', 'nosniff'); + + // Prevenir clickjacking + $response->headers->set('X-Frame-Options', 'DENY'); + + // Política de referrer + $response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin'); + + // Remover headers que expõem informações do servidor + $response->headers->remove('X-Powered-By'); + $response->headers->remove('Server'); + + // Content Security Policy (CSP) - apenas para API + if ($request->is('api/*')) { + $response->headers->set('Content-Security-Policy', "default-src 'none'; frame-ancestors 'none'"); + } + + // Permissions Policy (anteriormente Feature-Policy) + $response->headers->set('Permissions-Policy', 'geolocation=(), microphone=(), camera=()'); + + return $response; + } +} diff --git a/backend/app/Mail/WelcomeEmail.php b/backend/app/Mail/WelcomeEmail.php new file mode 100644 index 0000000..4d6cdc2 --- /dev/null +++ b/backend/app/Mail/WelcomeEmail.php @@ -0,0 +1,85 @@ +userName = $userName; + $this->userEmail = $userEmail; + } + + /** + * Get the message envelope. + */ + public function envelope(): Envelope + { + return new Envelope( + from: new Address('no-reply@cnxifly.com', 'WEBMoney - ConneXiFly'), + replyTo: [ + new Address('support@cnxifly.com', 'Soporte WEBMoney'), + ], + subject: '¡Bienvenido a WEBMoney! Tu cuenta ha sido creada', + tags: ['welcome', 'new-user'], + metadata: [ + 'user_email' => $this->userEmail, + ], + ); + } + + /** + * Get the message content definition. + */ + public function content(): Content + { + return new Content( + view: 'emails.welcome', + text: 'emails.welcome-text', + with: [ + 'userName' => $this->userName, + 'userEmail' => $this->userEmail, + ], + ); + } + + /** + * Extra headers to help deliverability. + */ + public function headers(): Headers + { + return new Headers( + text: [ + 'List-Unsubscribe' => '', + 'List-Unsubscribe-Post' => 'List-Unsubscribe=One-Click', + ], + ); + } + + /** + * Get the attachments for the message. + * + * @return array + */ + public function attachments(): array + { + return []; + } +} diff --git a/backend/app/Models/Account.php b/backend/app/Models/Account.php new file mode 100644 index 0000000..f8b0902 --- /dev/null +++ b/backend/app/Models/Account.php @@ -0,0 +1,152 @@ + 'Dinheiro', + self::TYPE_CHECKING => 'Conta Corrente', + self::TYPE_SAVINGS => 'Poupança', + self::TYPE_CREDIT_CARD => 'Cartão de Crédito', + self::TYPE_ASSET => 'Ativo', + self::TYPE_LIABILITY => 'Passivo', + ]; + + protected $fillable = [ + 'user_id', + 'name', + 'type', + 'bank_name', + 'account_number', + 'initial_balance', + 'current_balance', + 'credit_limit', + 'currency', + 'color', + 'icon', + 'description', + 'is_active', + 'include_in_total', + ]; + + protected $casts = [ + 'initial_balance' => 'decimal:2', + 'current_balance' => 'decimal:2', + 'credit_limit' => 'decimal:2', + 'is_active' => 'boolean', + 'include_in_total' => 'boolean', + ]; + + /** + * Relação com o usuário + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * Scope para contas ativas + */ + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + /** + * Scope para contas de um tipo específico + */ + public function scopeOfType($query, string $type) + { + return $query->where('type', $type); + } + + /** + * Scope para contas que devem ser incluídas no saldo total + */ + public function scopeIncludeInTotal($query) + { + return $query->where('include_in_total', true); + } + + /** + * Retorna o nome legível do tipo de conta + */ + public function getTypeNameAttribute(): string + { + return self::TYPES[$this->type] ?? $this->type; + } + + /** + * Verifica se é uma conta de crédito (cartão ou passivo) + */ + public function isCreditAccount(): bool + { + return in_array($this->type, [self::TYPE_CREDIT_CARD, self::TYPE_LIABILITY]); + } + + /** + * Calcula o saldo disponível (para cartões de crédito) + */ + public function getAvailableBalanceAttribute(): float + { + if ($this->type === self::TYPE_CREDIT_CARD && $this->credit_limit) { + return (float) $this->credit_limit + (float) $this->current_balance; + } + return (float) $this->current_balance; + } + + /** + * Relação com transações + */ + public function transactions() + { + return $this->hasMany(Transaction::class); + } + + /** + * Recalcula o saldo atual baseado nas transações efetivadas + */ + public function recalculateBalance(): float + { + $initialBalance = (float) $this->initial_balance; + + // Soma de créditos (transações efetivadas) + $credits = $this->transactions() + ->where('type', 'credit') + ->where('status', 'completed') + ->sum('amount'); + + // Soma de débitos (transações efetivadas) + $debits = $this->transactions() + ->where('type', 'debit') + ->where('status', 'completed') + ->sum('amount'); + + // Saldo = Saldo Inicial + Créditos - Débitos + $newBalance = $initialBalance + (float) $credits - (float) $debits; + + // Atualizar no banco + $this->update(['current_balance' => $newBalance]); + + return $newBalance; + } +} diff --git a/backend/app/Models/Category.php b/backend/app/Models/Category.php new file mode 100644 index 0000000..183a580 --- /dev/null +++ b/backend/app/Models/Category.php @@ -0,0 +1,153 @@ + 'Receita', + self::TYPE_EXPENSE => 'Despesa', + self::TYPE_BOTH => 'Ambos', + ]; + + protected $fillable = [ + 'user_id', + 'parent_id', + 'name', + 'type', + 'description', + 'color', + 'icon', + 'order', + 'is_active', + 'is_system', + ]; + + protected $casts = [ + 'order' => 'integer', + 'is_active' => 'boolean', + 'is_system' => 'boolean', + ]; + + /** + * Relação com o usuário + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * Relação com a categoria pai + */ + public function parent(): BelongsTo + { + return $this->belongsTo(Category::class, 'parent_id'); + } + + /** + * Relação com as sub-categorias (filhas) + */ + public function children(): HasMany + { + return $this->hasMany(Category::class, 'parent_id'); + } + + /** + * Relação com as sub-categorias ativas + */ + public function activeChildren(): HasMany + { + return $this->hasMany(Category::class, 'parent_id')->where('is_active', true); + } + + /** + * Relação com as palavras-chave + */ + public function keywords(): HasMany + { + return $this->hasMany(CategoryKeyword::class); + } + + /** + * Relação com as palavras-chave ativas + */ + public function activeKeywords(): HasMany + { + return $this->hasMany(CategoryKeyword::class)->where('is_active', true); + } + + /** + * Scope para categorias ativas + */ + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + /** + * Scope para categorias de um tipo específico + */ + public function scopeOfType($query, string $type) + { + return $query->where('type', $type)->orWhere('type', self::TYPE_BOTH); + } + + /** + * Scope para categorias raiz (sem pai) + */ + public function scopeRoot($query) + { + return $query->whereNull('parent_id'); + } + + /** + * Scope para sub-categorias (com pai) + */ + public function scopeChildren($query) + { + return $query->whereNotNull('parent_id'); + } + + /** + * Retorna o nome legível do tipo + */ + public function getTypeNameAttribute(): string + { + return self::TYPES[$this->type] ?? $this->type; + } + + /** + * Verifica se é uma categoria raiz + */ + public function isRoot(): bool + { + return $this->parent_id === null; + } + + /** + * Retorna o caminho completo da categoria (Pai > Filho) + */ + public function getFullPathAttribute(): string + { + if ($this->parent) { + return $this->parent->name . ' > ' . $this->name; + } + return $this->name; + } +} diff --git a/backend/app/Models/CategoryKeyword.php b/backend/app/Models/CategoryKeyword.php new file mode 100644 index 0000000..78d6e1b --- /dev/null +++ b/backend/app/Models/CategoryKeyword.php @@ -0,0 +1,51 @@ + 'boolean', + 'is_active' => 'boolean', + ]; + + /** + * Relação com a categoria + */ + public function category(): BelongsTo + { + return $this->belongsTo(Category::class); + } + + /** + * Scope para keywords ativas + */ + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + /** + * Verifica se o texto contém esta palavra-chave + */ + public function matchesText(string $text): bool + { + if ($this->is_case_sensitive) { + return str_contains($text, $this->keyword); + } + return str_contains(strtolower($text), strtolower($this->keyword)); + } +} diff --git a/backend/app/Models/CostCenter.php b/backend/app/Models/CostCenter.php new file mode 100644 index 0000000..720c420 --- /dev/null +++ b/backend/app/Models/CostCenter.php @@ -0,0 +1,78 @@ + 'boolean', + 'is_system' => 'boolean', + ]; + + /** + * Relação com o usuário + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * Relação com as palavras-chave + */ + public function keywords(): HasMany + { + return $this->hasMany(CostCenterKeyword::class); + } + + /** + * Relação com as palavras-chave ativas + */ + public function activeKeywords(): HasMany + { + return $this->hasMany(CostCenterKeyword::class)->where('is_active', true); + } + + /** + * Scope para centros de custo ativos + */ + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + /** + * Scope para centro de custo do sistema (padrão do usuário) + */ + public function scopeSystem($query) + { + return $query->where('is_system', true); + } + + /** + * Scope para buscar por código + */ + public function scopeByCode($query, string $code) + { + return $query->where('code', $code); + } +} diff --git a/backend/app/Models/CostCenterKeyword.php b/backend/app/Models/CostCenterKeyword.php new file mode 100644 index 0000000..13653a5 --- /dev/null +++ b/backend/app/Models/CostCenterKeyword.php @@ -0,0 +1,51 @@ + 'boolean', + 'is_active' => 'boolean', + ]; + + /** + * Relação com o centro de custo + */ + public function costCenter(): BelongsTo + { + return $this->belongsTo(CostCenter::class); + } + + /** + * Scope para keywords ativas + */ + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + /** + * Verifica se o texto contém esta palavra-chave + */ + public function matchesText(string $text): bool + { + if ($this->is_case_sensitive) { + return str_contains($text, $this->keyword); + } + return str_contains(strtolower($text), strtolower($this->keyword)); + } +} diff --git a/backend/app/Models/ImportLog.php b/backend/app/Models/ImportLog.php new file mode 100644 index 0000000..e4b3c2b --- /dev/null +++ b/backend/app/Models/ImportLog.php @@ -0,0 +1,86 @@ + 'array', + 'total_rows' => 'integer', + 'imported_rows' => 'integer', + 'skipped_rows' => 'integer', + 'error_rows' => 'integer', + ]; + + public const STATUS_PENDING = 'pending'; + public const STATUS_PROCESSING = 'processing'; + public const STATUS_COMPLETED = 'completed'; + public const STATUS_FAILED = 'failed'; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function importMapping(): BelongsTo + { + return $this->belongsTo(ImportMapping::class); + } + + /** + * Mark as processing + */ + public function markAsProcessing(): void + { + $this->update(['status' => self::STATUS_PROCESSING]); + } + + /** + * Mark as completed + */ + public function markAsCompleted(int $imported, int $skipped, int $errors, ?array $errorDetails = null): void + { + $this->update([ + 'status' => self::STATUS_COMPLETED, + 'imported_rows' => $imported, + 'skipped_rows' => $skipped, + 'error_rows' => $errors, + 'errors' => $errorDetails, + ]); + } + + /** + * Mark as failed + */ + public function markAsFailed(array $errors): void + { + $this->update([ + 'status' => self::STATUS_FAILED, + 'errors' => $errors, + ]); + } + + /** + * Scope for status + */ + public function scopeWithStatus($query, string $status) + { + return $query->where('status', $status); + } +} diff --git a/backend/app/Models/ImportMapping.php b/backend/app/Models/ImportMapping.php new file mode 100644 index 0000000..d8e3f8b --- /dev/null +++ b/backend/app/Models/ImportMapping.php @@ -0,0 +1,124 @@ + 'array', + 'header_row' => 'integer', + 'data_start_row' => 'integer', + 'is_active' => 'boolean', + ]; + + /** + * System fields that can be mapped + * Nota: O campo balance é usado APENAS para gerar o hash de duplicidade, + * NÃO é armazenado na BD para não interferir no cálculo dinâmico de saldo. + */ + public const MAPPABLE_FIELDS = [ + 'effective_date' => ['label' => 'Data Efetiva', 'required' => true, 'type' => 'date'], + 'planned_date' => ['label' => 'Data Planejada', 'required' => false, 'type' => 'date'], + 'description' => ['label' => 'Descrição', 'required' => true, 'type' => 'string'], + 'amount' => ['label' => 'Valor', 'required' => true, 'type' => 'decimal'], + 'balance' => ['label' => 'Saldo (apenas para duplicidade)', 'required' => false, 'type' => 'decimal'], + 'type' => ['label' => 'Tipo (Crédito/Débito)', 'required' => false, 'type' => 'string'], + 'notes' => ['label' => 'Observações', 'required' => false, 'type' => 'string'], + 'reference' => ['label' => 'Referência', 'required' => false, 'type' => 'string'], + 'category' => ['label' => 'Categoria', 'required' => false, 'type' => 'string'], + ]; + + /** + * Supported file types + */ + public const FILE_TYPES = ['xlsx', 'xls', 'csv', 'ofx', 'pdf']; + + /** + * Common date formats + */ + public const DATE_FORMATS = [ + 'd/m/Y' => 'DD/MM/AAAA (31/12/2025)', + 'm/d/Y' => 'MM/DD/AAAA (12/31/2025)', + 'Y-m-d' => 'AAAA-MM-DD (2025-12-31)', + 'd-m-Y' => 'DD-MM-AAAA (31-12-2025)', + 'd.m.Y' => 'DD.MM.AAAA (31.12.2025)', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function defaultAccount(): BelongsTo + { + return $this->belongsTo(Account::class, 'default_account_id'); + } + + public function defaultCategory(): BelongsTo + { + return $this->belongsTo(Category::class, 'default_category_id'); + } + + public function defaultCostCenter(): BelongsTo + { + return $this->belongsTo(CostCenter::class, 'default_cost_center_id'); + } + + public function importLogs(): HasMany + { + return $this->hasMany(ImportLog::class); + } + + /** + * Get the column mapping for a specific field + */ + public function getMappingForField(string $field): ?array + { + return $this->column_mappings[$field] ?? null; + } + + /** + * Check if a field is mapped + */ + public function hasFieldMapping(string $field): bool + { + return isset($this->column_mappings[$field]) && !empty($this->column_mappings[$field]['columns']); + } + + /** + * Scope for active mappings + */ + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + /** + * Scope for specific file type + */ + public function scopeForFileType($query, string $type) + { + return $query->where('file_type', $type); + } +} diff --git a/backend/app/Models/LiabilityAccount.php b/backend/app/Models/LiabilityAccount.php new file mode 100644 index 0000000..d8ab854 --- /dev/null +++ b/backend/app/Models/LiabilityAccount.php @@ -0,0 +1,238 @@ + 'Ativo', + self::STATUS_PAID_OFF => 'Quitado', + self::STATUS_DEFAULTED => 'Inadimplente', + self::STATUS_RENEGOTIATED => 'Renegociado', + ]; + + protected $fillable = [ + 'user_id', + 'account_id', + 'name', + 'contract_number', + 'creditor', + 'description', + 'principal_amount', + 'total_interest', + 'total_fees', + 'total_contract_value', + 'total_paid', + 'total_pending', + 'principal_paid', + 'interest_paid', + 'fees_paid', + 'monthly_interest_rate', + 'annual_interest_rate', + 'total_interest_rate', + 'total_installments', + 'paid_installments', + 'pending_installments', + 'start_date', + 'end_date', + 'first_due_date', + 'currency', + 'color', + 'icon', + 'status', + 'is_active', + ]; + + protected $casts = [ + 'principal_amount' => 'decimal:2', + 'total_interest' => 'decimal:2', + 'total_fees' => 'decimal:2', + 'total_contract_value' => 'decimal:2', + 'total_paid' => 'decimal:2', + 'total_pending' => 'decimal:2', + 'principal_paid' => 'decimal:2', + 'interest_paid' => 'decimal:2', + 'fees_paid' => 'decimal:2', + 'monthly_interest_rate' => 'decimal:4', + 'annual_interest_rate' => 'decimal:4', + 'total_interest_rate' => 'decimal:4', + 'start_date' => 'date', + 'end_date' => 'date', + 'first_due_date' => 'date', + 'is_active' => 'boolean', + ]; + + protected $appends = ['progress_percentage', 'remaining_balance']; + + /** + * Relação com o usuário + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * Relação com a conta geral (opcional) + */ + public function account(): BelongsTo + { + return $this->belongsTo(Account::class); + } + + /** + * Parcelas do contrato + */ + public function installments(): HasMany + { + return $this->hasMany(LiabilityInstallment::class)->orderBy('installment_number'); + } + + /** + * Parcelas pagas + */ + public function paidInstallments(): HasMany + { + return $this->hasMany(LiabilityInstallment::class)->where('status', 'paid'); + } + + /** + * Parcelas pendentes + */ + public function pendingInstallments(): HasMany + { + return $this->hasMany(LiabilityInstallment::class)->where('status', 'pending'); + } + + /** + * Próxima parcela a vencer + */ + public function nextInstallment() + { + return $this->installments() + ->where('status', 'pending') + ->orderBy('due_date') + ->first(); + } + + /** + * Percentual de progresso (quanto já foi pago do principal) + */ + public function getProgressPercentageAttribute(): float + { + if ($this->principal_amount <= 0) { + return 0; + } + return round(($this->principal_paid / $this->principal_amount) * 100, 2); + } + + /** + * Saldo restante do principal + */ + public function getRemainingBalanceAttribute(): float + { + return $this->principal_amount - $this->principal_paid; + } + + /** + * Recalcular totais baseado nas parcelas + */ + public function recalculateTotals(): void + { + $installments = $this->installments()->get(); + + $this->total_installments = $installments->count(); + $this->paid_installments = $installments->where('status', 'paid')->count(); + $this->pending_installments = $installments->where('status', 'pending')->count(); + + // Totais do contrato + $this->total_interest = $installments->sum('interest_amount'); + $this->total_fees = $installments->sum('fee_amount'); + $this->principal_amount = $installments->sum('principal_amount'); + $this->total_contract_value = $installments->sum('installment_amount'); + + // Valores pagos + $paidInstallments = $installments->where('status', 'paid'); + $this->total_paid = $paidInstallments->sum('installment_amount'); + $this->principal_paid = $paidInstallments->sum('principal_amount'); + $this->interest_paid = $paidInstallments->sum('interest_amount'); + $this->fees_paid = $paidInstallments->sum('fee_amount'); + + // Valores pendentes + $pendingInstallments = $installments->where('status', 'pending'); + $this->total_pending = $pendingInstallments->sum('installment_amount'); + + // Calcular taxas de juros + $this->calculateInterestRates(); + + // Datas + $firstInstallment = $installments->sortBy('due_date')->first(); + $lastInstallment = $installments->sortBy('due_date')->last(); + + if ($firstInstallment) { + $this->first_due_date = $firstInstallment->due_date; + $this->start_date = $firstInstallment->due_date; + } + if ($lastInstallment) { + $this->end_date = $lastInstallment->due_date; + } + + // Atualizar status + if ($this->pending_installments === 0 && $this->paid_installments > 0) { + $this->status = self::STATUS_PAID_OFF; + } + + $this->save(); + } + + /** + * Calcular taxas de juros baseado nos dados + */ + protected function calculateInterestRates(): void + { + if ($this->principal_amount <= 0) { + return; + } + + // Taxa total do contrato + $this->total_interest_rate = round(($this->total_interest / $this->principal_amount) * 100, 4); + + // Taxa mensal média + if ($this->total_installments > 0) { + $this->monthly_interest_rate = round($this->total_interest_rate / $this->total_installments, 4); + $this->annual_interest_rate = round($this->monthly_interest_rate * 12, 4); + } + } + + /** + * Scope para contas ativas + */ + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + /** + * Scope para um status específico + */ + public function scopeOfStatus($query, string $status) + { + return $query->where('status', $status); + } +} diff --git a/backend/app/Models/LiabilityInstallment.php b/backend/app/Models/LiabilityInstallment.php new file mode 100644 index 0000000..28f0043 --- /dev/null +++ b/backend/app/Models/LiabilityInstallment.php @@ -0,0 +1,147 @@ + 'Pendente', + self::STATUS_PAID => 'Pago', + self::STATUS_PARTIAL => 'Parcial', + self::STATUS_OVERDUE => 'Atrasado', + self::STATUS_CANCELLED => 'Cancelado', + ]; + + protected $fillable = [ + 'liability_account_id', + 'installment_number', + 'due_date', + 'installment_amount', + 'principal_amount', + 'interest_amount', + 'fee_amount', + 'paid_amount', + 'paid_date', + 'status', + 'reconciled_transaction_id', + 'payment_account_id', + 'notes', + ]; + + protected $casts = [ + 'due_date' => 'date', + 'paid_date' => 'date', + 'installment_amount' => 'decimal:2', + 'principal_amount' => 'decimal:2', + 'interest_amount' => 'decimal:2', + 'fee_amount' => 'decimal:2', + 'paid_amount' => 'decimal:2', + ]; + + protected $appends = ['is_overdue', 'days_until_due']; + + /** + * Relação com o contrato passivo + */ + public function liabilityAccount(): BelongsTo + { + return $this->belongsTo(LiabilityAccount::class); + } + + /** + * Relação com a conta usada para pagamento + */ + public function paymentAccount(): BelongsTo + { + return $this->belongsTo(Account::class, 'payment_account_id'); + } + + /** + * Verificar se está atrasado + */ + public function getIsOverdueAttribute(): bool + { + if ($this->status === self::STATUS_PAID) { + return false; + } + return $this->due_date->isPast(); + } + + /** + * Dias até o vencimento (negativo se atrasado) + */ + public function getDaysUntilDueAttribute(): int + { + return now()->startOfDay()->diffInDays($this->due_date, false); + } + + /** + * Marcar como pago + */ + public function markAsPaid(?float $amount = null, ?\DateTime $paidDate = null, ?int $paymentAccountId = null): void + { + $this->paid_amount = $amount ?? $this->installment_amount; + $this->paid_date = $paidDate ?? now(); + $this->payment_account_id = $paymentAccountId; + $this->status = self::STATUS_PAID; + $this->save(); + + // Recalcular totais do contrato + $this->liabilityAccount->recalculateTotals(); + } + + /** + * Atualizar status baseado na data de vencimento + */ + public function updateOverdueStatus(): void + { + if ($this->status === self::STATUS_PENDING && $this->is_overdue) { + $this->status = self::STATUS_OVERDUE; + $this->save(); + } + } + + /** + * Scope para parcelas pendentes + */ + public function scopePending($query) + { + return $query->where('status', self::STATUS_PENDING); + } + + /** + * Scope para parcelas pagas + */ + public function scopePaid($query) + { + return $query->where('status', self::STATUS_PAID); + } + + /** + * Scope para parcelas atrasadas + */ + public function scopeOverdue($query) + { + return $query->where('status', self::STATUS_OVERDUE) + ->orWhere(function ($q) { + $q->where('status', self::STATUS_PENDING) + ->where('due_date', '<', now()->startOfDay()); + }); + } +} diff --git a/backend/app/Models/RecurringInstance.php b/backend/app/Models/RecurringInstance.php new file mode 100644 index 0000000..06e16bb --- /dev/null +++ b/backend/app/Models/RecurringInstance.php @@ -0,0 +1,149 @@ + 'date', + 'paid_at' => 'datetime', + 'planned_amount' => 'decimal:2', + 'paid_amount' => 'decimal:2', + 'occurrence_number' => 'integer', + ]; + + public const STATUS_PENDING = 'pending'; + public const STATUS_PAID = 'paid'; + public const STATUS_SKIPPED = 'skipped'; + public const STATUS_CANCELLED = 'cancelled'; + + // ============================================ + // Relacionamentos + // ============================================ + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function template(): BelongsTo + { + return $this->belongsTo(RecurringTemplate::class, 'recurring_template_id'); + } + + public function transaction(): BelongsTo + { + return $this->belongsTo(Transaction::class); + } + + // ============================================ + // Scopes + // ============================================ + + public function scopePending($query) + { + return $query->where('status', self::STATUS_PENDING); + } + + public function scopePaid($query) + { + return $query->where('status', self::STATUS_PAID); + } + + public function scopeOverdue($query) + { + return $query->where('status', self::STATUS_PENDING) + ->where('due_date', '<', now()->startOfDay()); + } + + public function scopeDueSoon($query, int $days = 7) + { + return $query->where('status', self::STATUS_PENDING) + ->whereBetween('due_date', [ + now()->startOfDay(), + now()->addDays($days)->endOfDay() + ]); + } + + // ============================================ + // Métodos de Negócio + // ============================================ + + /** + * Verifica se está vencida + */ + public function isOverdue(): bool + { + return $this->status === self::STATUS_PENDING + && $this->due_date->lt(now()->startOfDay()); + } + + /** + * Verifica se está paga + */ + public function isPaid(): bool + { + return $this->status === self::STATUS_PAID; + } + + /** + * Verifica se está pendente + */ + public function isPending(): bool + { + return $this->status === self::STATUS_PENDING; + } + + /** + * Retorna a diferença entre valor planejado e pago + */ + public function getDifferenceAttribute(): ?float + { + if ($this->paid_amount === null) { + return null; + } + return (float) $this->paid_amount - (float) $this->planned_amount; + } + + /** + * Retorna dias até o vencimento (negativo se vencido) + */ + public function getDaysUntilDueAttribute(): int + { + return (int) now()->startOfDay()->diffInDays($this->due_date, false); + } + + /** + * Retorna status formatado + */ + public function getStatusLabelAttribute(): string + { + return match($this->status) { + self::STATUS_PENDING => 'Pendente', + self::STATUS_PAID => 'Pago', + self::STATUS_SKIPPED => 'Pulado', + self::STATUS_CANCELLED => 'Cancelado', + default => $this->status, + }; + } +} diff --git a/backend/app/Models/RecurringTemplate.php b/backend/app/Models/RecurringTemplate.php new file mode 100644 index 0000000..d88b280 --- /dev/null +++ b/backend/app/Models/RecurringTemplate.php @@ -0,0 +1,169 @@ + 'date', + 'end_date' => 'date', + 'last_generated_date' => 'date', + 'planned_amount' => 'decimal:2', + 'is_active' => 'boolean', + 'frequency_interval' => 'integer', + 'day_of_month' => 'integer', + 'day_of_week' => 'integer', + 'max_occurrences' => 'integer', + 'occurrences_generated' => 'integer', + ]; + + /** + * Frequências disponíveis + */ + public const FREQUENCIES = [ + 'daily' => ['label' => 'Diária', 'days' => 1], + 'weekly' => ['label' => 'Semanal', 'days' => 7], + 'biweekly' => ['label' => 'Quinzenal', 'days' => 14], + 'monthly' => ['label' => 'Mensal', 'months' => 1], + 'bimonthly' => ['label' => 'Bimestral', 'months' => 2], + 'quarterly' => ['label' => 'Trimestral', 'months' => 3], + 'semiannual' => ['label' => 'Semestral', 'months' => 6], + 'annual' => ['label' => 'Anual', 'months' => 12], + ]; + + // ============================================ + // Relacionamentos + // ============================================ + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function sourceTransaction(): BelongsTo + { + return $this->belongsTo(Transaction::class, 'source_transaction_id'); + } + + public function account(): BelongsTo + { + return $this->belongsTo(Account::class); + } + + public function category(): BelongsTo + { + return $this->belongsTo(Category::class); + } + + public function costCenter(): BelongsTo + { + return $this->belongsTo(CostCenter::class); + } + + public function instances(): HasMany + { + return $this->hasMany(RecurringInstance::class); + } + + public function pendingInstances(): HasMany + { + return $this->hasMany(RecurringInstance::class)->where('status', 'pending'); + } + + public function paidInstances(): HasMany + { + return $this->hasMany(RecurringInstance::class)->where('status', 'paid'); + } + + // ============================================ + // Métodos de Negócio + // ============================================ + + /** + * Verifica se pode gerar mais instâncias + */ + public function canGenerateMore(): bool + { + if (!$this->is_active) { + return false; + } + + // Verificar limite de ocorrências + if ($this->max_occurrences !== null && $this->occurrences_generated >= $this->max_occurrences) { + return false; + } + + // Verificar data fim + if ($this->end_date !== null && now()->startOfDay()->gt($this->end_date)) { + return false; + } + + return true; + } + + /** + * Retorna a frequência formatada para exibição + */ + public function getFrequencyLabelAttribute(): string + { + $freq = self::FREQUENCIES[$this->frequency] ?? null; + if (!$freq) { + return $this->frequency; + } + + $label = $freq['label']; + if ($this->frequency_interval > 1) { + $label = "A cada {$this->frequency_interval} " . strtolower($label); + } + + return $label; + } + + /** + * Retorna quantas instâncias pendentes existem + */ + public function getPendingCountAttribute(): int + { + return $this->pendingInstances()->count(); + } + + /** + * Retorna o total pago até agora + */ + public function getTotalPaidAttribute(): float + { + return (float) $this->paidInstances()->sum('paid_amount'); + } +} diff --git a/backend/app/Models/Transaction.php b/backend/app/Models/Transaction.php new file mode 100644 index 0000000..9761c6a --- /dev/null +++ b/backend/app/Models/Transaction.php @@ -0,0 +1,323 @@ + 'decimal:2', + 'planned_amount' => 'decimal:2', + 'effective_date' => 'date', + 'planned_date' => 'date', + 'is_recurring' => 'boolean', + 'is_split_child' => 'boolean', + 'is_split_parent' => 'boolean', + 'is_transfer' => 'boolean', + 'is_refund_pair' => 'boolean', + ]; + + // ========================================================================= + // RELACIONAMENTOS + // ========================================================================= + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function account(): BelongsTo + { + return $this->belongsTo(Account::class); + } + + public function category(): BelongsTo + { + return $this->belongsTo(Category::class); + } + + public function costCenter(): BelongsTo + { + return $this->belongsTo(CostCenter::class); + } + + public function recurringParent(): BelongsTo + { + return $this->belongsTo(Transaction::class, 'recurring_parent_id'); + } + + public function recurringChildren(): HasMany + { + return $this->hasMany(Transaction::class, 'recurring_parent_id'); + } + + /** + * Instância de recorrência vinculada + */ + public function recurringInstance(): BelongsTo + { + return $this->belongsTo(RecurringInstance::class, 'recurring_instance_id'); + } + + public function importLog(): BelongsTo + { + return $this->belongsTo(ImportLog::class); + } + + /** + * Transação par de transferência (débito ↔ crédito) + */ + public function transferPair(): BelongsTo + { + return $this->belongsTo(Transaction::class, 'transfer_pair_id'); + } + + /** + * Transação pai (quando esta é uma divisão) + */ + public function parentTransaction(): BelongsTo + { + return $this->belongsTo(Transaction::class, 'parent_transaction_id'); + } + + /** + * Transações filhas (divisões desta transação) + */ + public function splitChildren(): HasMany + { + return $this->hasMany(Transaction::class, 'parent_transaction_id'); + } + + // ========================================================================= + // SCOPES + // ========================================================================= + + public function scopeOfUser($query, $userId) + { + return $query->where('user_id', $userId); + } + + public function scopePending($query) + { + return $query->where('status', 'pending'); + } + + public function scopeCompleted($query) + { + return $query->where('status', 'completed'); + } + + public function scopeCancelled($query) + { + return $query->where('status', 'cancelled'); + } + + public function scopeCredits($query) + { + return $query->where('type', 'credit'); + } + + public function scopeDebits($query) + { + return $query->where('type', 'debit'); + } + + public function scopeInPeriod($query, $startDate, $endDate, $dateField = 'planned_date') + { + return $query->whereBetween($dateField, [$startDate, $endDate]); + } + + public function scopeOfAccount($query, $accountId) + { + return $query->where('account_id', $accountId); + } + + public function scopeOfCategory($query, $categoryId) + { + return $query->where('category_id', $categoryId); + } + + public function scopeOfCostCenter($query, $costCenterId) + { + return $query->where('cost_center_id', $costCenterId); + } + + // ========================================================================= + // ATRIBUTOS COMPUTADOS + // ========================================================================= + + /** + * Retorna o valor final (efetivo se existir, senão planejado) + */ + public function getFinalAmountAttribute(): float + { + return $this->amount ?? $this->planned_amount; + } + + /** + * Retorna a data final (efetiva se existir, senão planejada) + */ + public function getFinalDateAttribute() + { + return $this->effective_date ?? $this->planned_date; + } + + /** + * Verifica se a transação está atrasada (pendente e data planejada passou) + */ + public function getIsOverdueAttribute(): bool + { + if ($this->status !== 'pending') { + return false; + } + return $this->planned_date < now()->startOfDay(); + } + + /** + * Retorna o valor com sinal (positivo para crédito, negativo para débito) + */ + public function getSignedAmountAttribute(): float + { + $amount = $this->final_amount; + return $this->type === 'credit' ? $amount : -$amount; + } + + // ========================================================================= + // MÉTODOS + // ========================================================================= + + /** + * Marca a transação como concluída + */ + public function markAsCompleted(?float $amount = null, ?string $effectiveDate = null): self + { + $this->status = 'completed'; + $this->amount = $amount ?? $this->planned_amount; + $this->effective_date = $effectiveDate ?? now()->toDateString(); + $this->save(); + + return $this; + } + + /** + * Marca a transação como cancelada + */ + public function markAsCancelled(): self + { + $this->status = 'cancelled'; + $this->save(); + + return $this; + } + + /** + * Reverte para pendente + */ + public function markAsPending(): self + { + $this->status = 'pending'; + $this->amount = null; + $this->effective_date = null; + $this->save(); + + return $this; + } + + /** + * Gera hash único para evitar duplicidade na importação + * Baseado em: data + valor + descrição original + saldo (se disponível no extrato) + * + * O saldo é usado APENAS para diferenciar transações idênticas no hash, + * mas NÃO é armazenado na BD para não interferir no cálculo dinâmico de saldo. + */ + public static function generateImportHash( + string $date, + float $amount, + ?string $originalDescription, + ?float $balance = null + ): string { + // Normaliza os valores para garantir consistência + $normalizedDate = date('Y-m-d', strtotime($date)); + $normalizedAmount = number_format($amount, 2, '.', ''); + $normalizedDescription = trim(strtolower($originalDescription ?? '')); + + // Prepara os componentes do hash + $components = [ + $normalizedDate, + $normalizedAmount, + $normalizedDescription, + ]; + + // Se o saldo foi fornecido no extrato, usa para diferenciar transações idênticas + if ($balance !== null) { + $components[] = number_format($balance, 2, '.', ''); + } + + // Concatena os valores e gera hash SHA-256 + return hash('sha256', implode('|', $components)); + } + + /** + * Verifica se já existe transação com este hash para o usuário + */ + public static function existsByHash(int $userId, string $hash): bool + { + return self::where('user_id', $userId) + ->where('import_hash', $hash) + ->exists(); + } + + /** + * Scope para buscar por hash de importação + */ + public function scopeByImportHash($query, string $hash) + { + return $query->where('import_hash', $hash); + } + + /** + * Verifica se a transação foi importada + */ + public function getIsImportedAttribute(): bool + { + return !empty($this->import_hash); + } +} diff --git a/backend/app/Models/User.php b/backend/app/Models/User.php new file mode 100644 index 0000000..460cd87 --- /dev/null +++ b/backend/app/Models/User.php @@ -0,0 +1,59 @@ + */ + use HasFactory, Notifiable, HasApiTokens; + + /** + * The attributes that are mass assignable. + * + * @var list + */ + protected $fillable = [ + 'name', + 'email', + 'password', + 'is_admin', + ]; + + /** + * The attributes that should be hidden for serialization. + * + * @var list + */ + protected $hidden = [ + 'password', + 'remember_token', + ]; + + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'email_verified_at' => 'datetime', + 'password' => 'hashed', + 'is_admin' => 'boolean', + ]; + } + + /** + * Verifica se o usuário é administrador + */ + public function isAdmin(): bool + { + return $this->is_admin === true; + } +} diff --git a/backend/app/Policies/RecurringTemplatePolicy.php b/backend/app/Policies/RecurringTemplatePolicy.php new file mode 100644 index 0000000..b748a5d --- /dev/null +++ b/backend/app/Policies/RecurringTemplatePolicy.php @@ -0,0 +1,49 @@ +id === $recurringTemplate->user_id; + } + + /** + * Determine whether the user can create models. + */ + public function create(User $user): bool + { + return true; + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, RecurringTemplate $recurringTemplate): bool + { + return $user->id === $recurringTemplate->user_id; + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, RecurringTemplate $recurringTemplate): bool + { + return $user->id === $recurringTemplate->user_id; + } +} diff --git a/backend/app/Providers/AppServiceProvider.php b/backend/app/Providers/AppServiceProvider.php new file mode 100644 index 0000000..cb628b6 --- /dev/null +++ b/backend/app/Providers/AppServiceProvider.php @@ -0,0 +1,49 @@ +by($request->ip())->response(function () { + return response()->json([ + 'success' => false, + 'message' => 'Muitas tentativas de login. Tente novamente em 1 minuto.', + ], 429); + }); + }); + + RateLimiter::for('register', function (Request $request) { + return Limit::perHour(10)->by($request->ip())->response(function () { + return response()->json([ + 'success' => false, + 'message' => 'Muitas tentativas de registro. Tente novamente mais tarde.', + ], 429); + }); + }); + + // Rate limiting para API geral (proteção contra abuso) + RateLimiter::for('api', function (Request $request) { + return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip()); + }); + } +} diff --git a/backend/app/Services/Import/CsvParser.php b/backend/app/Services/Import/CsvParser.php new file mode 100644 index 0000000..6a5d79c --- /dev/null +++ b/backend/app/Services/Import/CsvParser.php @@ -0,0 +1,221 @@ +detectDelimiter($filePath); + $enclosure = $options['enclosure'] ?? '"'; + $encoding = $options['encoding'] ?? $this->detectEncoding($filePath); + + $data = []; + $headers = []; + $rowIndex = 0; + + $handle = fopen($filePath, 'r'); + + if ($handle === false) { + throw new \RuntimeException("Could not open file: $filePath"); + } + + while (($row = fgetcsv($handle, 0, $delimiter, $enclosure)) !== false) { + // Converter encoding se necessário + if ($encoding !== 'UTF-8') { + $row = array_map(function ($value) use ($encoding) { + return mb_convert_encoding($value, 'UTF-8', $encoding); + }, $row); + } + + if ($rowIndex === $headerRow) { + $headers = $row; + $rowIndex++; + continue; + } + + if ($rowIndex < $dataStartRow) { + $rowIndex++; + continue; + } + + // Verificar se não está vazia + $nonEmpty = array_filter($row, fn($v) => $v !== null && $v !== ''); + if (!empty($nonEmpty)) { + $data[] = $row; + } + + $rowIndex++; + } + + fclose($handle); + + return [ + 'headers' => $headers, + 'data' => $data, + 'total_rows' => count($data), + 'detected_delimiter' => $delimiter, + 'detected_encoding' => $encoding, + ]; + } + + /** + * Get headers from CSV file + */ + public function getHeaders(string $filePath, array $options = []): array + { + $headerRow = $options['header_row'] ?? 0; + $delimiter = $options['delimiter'] ?? $this->detectDelimiter($filePath); + $enclosure = $options['enclosure'] ?? '"'; + $encoding = $options['encoding'] ?? $this->detectEncoding($filePath); + + $handle = fopen($filePath, 'r'); + + if ($handle === false) { + throw new \RuntimeException("Could not open file: $filePath"); + } + + $rowIndex = 0; + $headers = []; + + while (($row = fgetcsv($handle, 0, $delimiter, $enclosure)) !== false) { + if ($rowIndex === $headerRow) { + $headers = $row; + break; + } + $rowIndex++; + } + + fclose($handle); + + // Converter encoding se necessário + if ($encoding !== 'UTF-8') { + $headers = array_map(function ($value) use ($encoding) { + return mb_convert_encoding($value, 'UTF-8', $encoding); + }, $headers); + } + + return $headers; + } + + /** + * Get preview data + */ + public function getPreview(string $filePath, int $rows = 10, array $options = []): array + { + $delimiter = $options['delimiter'] ?? $this->detectDelimiter($filePath); + $enclosure = $options['enclosure'] ?? '"'; + $encoding = $options['encoding'] ?? $this->detectEncoding($filePath); + + $preview = []; + $rowCount = 0; + $totalRows = 0; + + $handle = fopen($filePath, 'r'); + + if ($handle === false) { + throw new \RuntimeException("Could not open file: $filePath"); + } + + // Primeiro, contar todas as linhas + while (fgets($handle) !== false) { + $totalRows++; + } + + // Voltar ao início + rewind($handle); + + // Ler preview + while (($row = fgetcsv($handle, 0, $delimiter, $enclosure)) !== false) { + if ($rowCount >= $rows) { + break; + } + + // Converter encoding se necessário + if ($encoding !== 'UTF-8') { + $row = array_map(function ($value) use ($encoding) { + return mb_convert_encoding($value, 'UTF-8', $encoding); + }, $row); + } + + $preview[] = [ + 'row_index' => $rowCount, + 'data' => $row, + ]; + + $rowCount++; + } + + fclose($handle); + + return [ + 'preview' => $preview, + 'total_rows' => $totalRows, + 'columns_count' => !empty($preview) ? count($preview[0]['data']) : 0, + 'detected_delimiter' => $delimiter, + 'detected_encoding' => $encoding, + ]; + } + + /** + * Detect CSV delimiter + */ + protected function detectDelimiter(string $filePath): string + { + $delimiters = [',', ';', "\t", '|']; + $counts = array_fill_keys($delimiters, 0); + + $handle = fopen($filePath, 'r'); + if ($handle === false) { + return ','; + } + + // Ler primeiras 5 linhas + $lines = 0; + while (($line = fgets($handle)) !== false && $lines < 5) { + foreach ($delimiters as $d) { + $counts[$d] += substr_count($line, $d); + } + $lines++; + } + + fclose($handle); + + // Retornar o delimitador mais frequente + arsort($counts); + return array_key_first($counts); + } + + /** + * Detect file encoding + */ + protected function detectEncoding(string $filePath): string + { + $content = file_get_contents($filePath, false, null, 0, 10000); + + // Verificar BOM UTF-8 + if (substr($content, 0, 3) === "\xEF\xBB\xBF") { + return 'UTF-8'; + } + + // Tentar detectar encoding + $encoding = mb_detect_encoding($content, ['UTF-8', 'ISO-8859-1', 'Windows-1252', 'ASCII'], true); + + return $encoding ?: 'UTF-8'; + } + + /** + * Check if parser supports the extension + */ + public static function supports(string $extension): bool + { + return in_array(strtolower($extension), self::$supportedExtensions); + } +} diff --git a/backend/app/Services/Import/ExcelParser.php b/backend/app/Services/Import/ExcelParser.php new file mode 100644 index 0000000..08b771c --- /dev/null +++ b/backend/app/Services/Import/ExcelParser.php @@ -0,0 +1,164 @@ +getActiveSheet(); + + $data = []; + $headers = []; + + foreach ($worksheet->getRowIterator() as $rowIndex => $row) { + $rowData = []; + $cellIterator = $row->getCellIterator(); + $cellIterator->setIterateOnlyExistingCells(false); + + foreach ($cellIterator as $cell) { + $value = $cell->getCalculatedValue(); + // Tratar valores de data do Excel + if (\PhpOffice\PhpSpreadsheet\Shared\Date::isDateTime($cell)) { + try { + $dateValue = \PhpOffice\PhpSpreadsheet\Shared\Date::excelToDateTimeObject($value); + $value = $dateValue->format('d/m/Y'); + } catch (\Exception $e) { + // Manter valor original se não conseguir converter + } + } + $rowData[] = $value; + } + + // Linha de cabeçalho (1-indexed no PhpSpreadsheet) + if ($rowIndex === $headerRow + 1) { + $headers = $rowData; + continue; + } + + // Pular linhas antes dos dados + if ($rowIndex < $dataStartRow + 1) { + continue; + } + + // Verificar se a linha não está completamente vazia + $nonEmpty = array_filter($rowData, fn($v) => $v !== null && $v !== ''); + if (!empty($nonEmpty)) { + $data[] = $rowData; + } + } + + return [ + 'headers' => $headers, + 'data' => $data, + 'total_rows' => count($data), + ]; + } + + /** + * Get headers from the Excel file + */ + public function getHeaders(string $filePath, array $options = []): array + { + $headerRow = $options['header_row'] ?? 0; + + $spreadsheet = IOFactory::load($filePath); + $worksheet = $spreadsheet->getActiveSheet(); + + $headers = []; + $row = $worksheet->getRowIterator($headerRow + 1, $headerRow + 1)->current(); + + if ($row) { + $cellIterator = $row->getCellIterator(); + $cellIterator->setIterateOnlyExistingCells(false); + + foreach ($cellIterator as $cell) { + $headers[] = $cell->getCalculatedValue(); + } + } + + // Remover valores nulos do final + while (!empty($headers) && (end($headers) === null || end($headers) === '')) { + array_pop($headers); + } + + return $headers; + } + + /** + * Get preview data (first N rows) + */ + public function getPreview(string $filePath, int $rows = 10, array $options = []): array + { + $spreadsheet = IOFactory::load($filePath); + $worksheet = $spreadsheet->getActiveSheet(); + + $preview = []; + $rowCount = 0; + + foreach ($worksheet->getRowIterator() as $row) { + if ($rowCount >= $rows) { + break; + } + + $rowData = []; + $cellIterator = $row->getCellIterator(); + $cellIterator->setIterateOnlyExistingCells(false); + + foreach ($cellIterator as $cell) { + $value = $cell->getCalculatedValue(); + // Tratar valores de data do Excel + if (\PhpOffice\PhpSpreadsheet\Shared\Date::isDateTime($cell)) { + try { + $dateValue = \PhpOffice\PhpSpreadsheet\Shared\Date::excelToDateTimeObject($value); + $value = $dateValue->format('d/m/Y'); + } catch (\Exception $e) { + // Manter valor original + } + } + $rowData[] = $value; + } + + // Remover valores nulos do final + while (!empty($rowData) && (end($rowData) === null || end($rowData) === '')) { + array_pop($rowData); + } + + $preview[] = [ + 'row_index' => $rowCount, + 'data' => $rowData, + ]; + + $rowCount++; + } + + // Contar total de linhas + $totalRows = $worksheet->getHighestRow(); + + return [ + 'preview' => $preview, + 'total_rows' => $totalRows, + 'columns_count' => !empty($preview) ? count($preview[0]['data']) : 0, + ]; + } + + /** + * Check if parser supports the extension + */ + public static function supports(string $extension): bool + { + return in_array(strtolower($extension), self::$supportedExtensions); + } +} diff --git a/backend/app/Services/Import/FileParserInterface.php b/backend/app/Services/Import/FileParserInterface.php new file mode 100644 index 0000000..1cc322f --- /dev/null +++ b/backend/app/Services/Import/FileParserInterface.php @@ -0,0 +1,26 @@ +parsers as $parserClass) { + if ($parserClass::supports($extension)) { + return new $parserClass(); + } + } + + throw new \InvalidArgumentException("Unsupported file type: $extension"); + } + + /** + * Get preview of file contents + */ + public function getPreview(string $filePath, int $rows = 15): array + { + $extension = strtolower(pathinfo($filePath, PATHINFO_EXTENSION)); + $parser = $this->getParser($extension); + + return $parser->getPreview($filePath, $rows); + } + + /** + * Get headers from file + */ + public function getHeaders(string $filePath, array $options = []): array + { + $extension = strtolower(pathinfo($filePath, PATHINFO_EXTENSION)); + $parser = $this->getParser($extension); + + return $parser->getHeaders($filePath, $options); + } + + /** + * Parse file with mapping + */ + public function parseFile(string $filePath, ImportMapping $mapping): array + { + $extension = strtolower(pathinfo($filePath, PATHINFO_EXTENSION)); + $parser = $this->getParser($extension); + + $options = [ + 'header_row' => $mapping->header_row, + 'data_start_row' => $mapping->data_start_row, + ]; + + return $parser->parse($filePath, $options); + } + + /** + * Apply mapping to a row of data + */ + public function applyMapping(array $row, ImportMapping $mapping): array + { + $mapped = []; + $columnMappings = $mapping->column_mappings; + + foreach ($columnMappings as $field => $config) { + if (empty($config['columns'])) { + continue; + } + + $values = []; + foreach ($config['columns'] as $colIndex) { + if (isset($row[$colIndex]) && $row[$colIndex] !== null && $row[$colIndex] !== '') { + $values[] = $row[$colIndex]; + } + } + + $separator = $config['concat_separator'] ?? ' '; + $value = implode($separator, $values); + + // Processar valor baseado no tipo de campo + $mapped[$field] = $this->processFieldValue($field, $value, $mapping); + } + + return $mapped; + } + + /** + * Process field value based on field type + */ + protected function processFieldValue(string $field, $value, ImportMapping $mapping) + { + if ($value === null || $value === '') { + return null; + } + + $fieldConfig = ImportMapping::MAPPABLE_FIELDS[$field] ?? null; + + if (!$fieldConfig) { + return $value; + } + + switch ($fieldConfig['type']) { + case 'date': + return $this->parseDate($value, $mapping->date_format); + + case 'decimal': + return $this->parseDecimal( + $value, + $mapping->decimal_separator, + $mapping->thousands_separator + ); + + default: + return trim((string) $value); + } + } + + /** + * Parse date value + */ + protected function parseDate($value, string $format): ?string + { + if ($value instanceof \DateTimeInterface) { + return $value->format('Y-m-d'); + } + + $value = trim((string) $value); + + if (empty($value)) { + return null; + } + + // Tentar vários formatos comuns + $formats = [ + $format, + 'd/m/Y', + 'm/d/Y', + 'Y-m-d', + 'd-m-Y', + 'd.m.Y', + 'Y/m/d', + ]; + + foreach ($formats as $fmt) { + try { + $date = Carbon::createFromFormat($fmt, $value); + if ($date && $date->format($fmt) === $value) { + return $date->format('Y-m-d'); + } + } catch (\Exception $e) { + continue; + } + } + + // Tentar parse genérico + try { + return Carbon::parse($value)->format('Y-m-d'); + } catch (\Exception $e) { + return null; + } + } + + /** + * Parse decimal value + */ + protected function parseDecimal($value, string $decimalSeparator, string $thousandsSeparator): ?float + { + if (is_numeric($value)) { + return floatval($value); + } + + $value = trim((string) $value); + + if (empty($value)) { + return null; + } + + // Remover símbolos de moeda e espaços + $value = preg_replace('/[€$R\s]/', '', $value); + + // Substituir separadores + if ($thousandsSeparator !== '') { + $value = str_replace($thousandsSeparator, '', $value); + } + + if ($decimalSeparator !== '.') { + $value = str_replace($decimalSeparator, '.', $value); + } + + // Verificar se é numérico após processamento + if (is_numeric($value)) { + return floatval($value); + } + + return null; + } + + /** + * Import transactions from file + */ + public function importTransactions( + string $filePath, + ImportMapping $mapping, + int $userId, + ?int $accountId = null, + ?int $categoryId = null, + ?int $costCenterId = null + ): ImportLog { + $originalFilename = basename($filePath); + $extension = strtolower(pathinfo($filePath, PATHINFO_EXTENSION)); + + // Usar ID do mapeamento apenas se foi persistido (existe no banco) + $mappingId = $mapping->exists ? $mapping->id : null; + + // Criar log de importação + $importLog = ImportLog::create([ + 'user_id' => $userId, + 'import_mapping_id' => $mappingId, + 'original_filename' => $originalFilename, + 'file_type' => $extension, + 'status' => ImportLog::STATUS_PROCESSING, + ]); + + try { + // Parse arquivo + $parsed = $this->parseFile($filePath, $mapping); + $importLog->update(['total_rows' => $parsed['total_rows']]); + + $imported = 0; + $skipped = 0; + $errors = 0; + $errorDetails = []; + + // Usar conta/categoria/centro de custo do mapping se não especificados + $accountId = $accountId ?? $mapping->default_account_id; + $categoryId = $categoryId ?? $mapping->default_category_id; + $costCenterId = $costCenterId ?? $mapping->default_cost_center_id; + + DB::beginTransaction(); + + foreach ($parsed['data'] as $rowIndex => $row) { + try { + $mapped = $this->applyMapping($row, $mapping); + + // Validar campos obrigatórios + if (empty($mapped['effective_date']) || !isset($mapped['amount'])) { + $skipped++; + $errorDetails[] = [ + 'row' => $rowIndex + 1, + 'error' => 'Campos obrigatórios ausentes (data ou valor)', + 'reason' => 'missing_required', + ]; + continue; + } + + // Determinar tipo (crédito/débito) baseado no valor + $amount = $mapped['amount']; + $type = 'debit'; + + if (isset($mapped['type'])) { + $typeValue = strtolower($mapped['type']); + if (in_array($typeValue, ['credit', 'crédito', 'credito', 'c', '+'])) { + $type = 'credit'; + } elseif (in_array($typeValue, ['debit', 'débito', 'debito', 'd', '-'])) { + $type = 'debit'; + } + } elseif ($amount > 0) { + $type = 'credit'; + } elseif ($amount < 0) { + $type = 'debit'; + $amount = abs($amount); + } + + // Obter descrição original (para hash e referência) + $originalDescription = $mapped['description'] ?? ''; + + // Obter saldo se disponível no extrato (usado APENAS para o hash) + $balance = $mapped['balance'] ?? null; + + // Gerar hash único para evitar duplicidade (data + valor + descrição + saldo se houver) + // O saldo é usado APENAS para diferenciar transações idênticas no mesmo dia + // NÃO é armazenado na BD para não interferir no cálculo dinâmico de saldo + $importHash = Transaction::generateImportHash( + $mapped['effective_date'], + $amount, + $originalDescription, + $balance // Pode ser null se não mapeado + ); + + // Verificar se já existe transação com este hash + if (Transaction::existsByHash($userId, $importHash)) { + $skipped++; + $errorDetails[] = [ + 'row' => $rowIndex + 1, + 'error' => 'Transação já importada anteriormente', + 'reason' => 'duplicate', + 'hash' => substr($importHash, 0, 16) . '...', + ]; + continue; + } + + // Criar transação importada + // Nota: Transações importadas são sempre 'completed' e sem categoria + // A categorização deve ser feita manualmente pelo usuário após a importação + Transaction::create([ + 'user_id' => $userId, + 'account_id' => $accountId, + 'category_id' => null, // Importações são sempre sem categoria + 'cost_center_id' => $costCenterId, + 'amount' => abs($amount), + 'planned_amount' => abs($amount), + 'type' => $type, + 'description' => $originalDescription, + 'original_description' => $originalDescription, + 'effective_date' => $mapped['effective_date'], + 'planned_date' => $mapped['planned_date'] ?? $mapped['effective_date'], + 'status' => 'completed', // Importações são sempre concluídas + 'notes' => $mapped['notes'] ?? null, + 'reference' => $mapped['reference'] ?? null, + 'import_hash' => $importHash, + 'import_log_id' => $importLog->id, + ]); + + $imported++; + + } catch (\Exception $e) { + $errors++; + $errorDetails[] = [ + 'row' => $rowIndex + 1, + 'error' => $e->getMessage(), + 'data' => $row, + ]; + + Log::warning("Import error at row $rowIndex", [ + 'error' => $e->getMessage(), + 'row' => $row, + ]); + } + } + + DB::commit(); + + $importLog->markAsCompleted($imported, $skipped, $errors, $errorDetails); + + } catch (\Exception $e) { + DB::rollBack(); + $importLog->markAsFailed([ + 'message' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + throw $e; + } + + return $importLog->fresh(); + } + + /** + * Create predefined mapping for known bank formats + */ + public function createBankPreset(string $bankName, int $userId): ImportMapping + { + $presets = [ + 'bbva' => [ + 'name' => 'BBVA España', + 'bank_name' => 'BBVA', + 'file_type' => 'xlsx', + 'header_row' => 4, + 'data_start_row' => 5, + 'date_format' => 'd/m/Y', + 'decimal_separator' => ',', + 'thousands_separator' => '.', + 'column_mappings' => [ + 'effective_date' => ['columns' => [0], 'concat_separator' => null], + 'planned_date' => ['columns' => [1], 'concat_separator' => null], + 'description' => ['columns' => [2, 3], 'concat_separator' => ' - '], + 'amount' => ['columns' => [4], 'concat_separator' => null], + 'notes' => ['columns' => [8], 'concat_separator' => null], + ], + ], + 'santander' => [ + 'name' => 'Santander España', + 'bank_name' => 'Santander', + 'file_type' => 'xls', + 'header_row' => 7, + 'data_start_row' => 8, + 'date_format' => 'd/m/Y', + 'decimal_separator' => ',', + 'thousands_separator' => '.', + 'column_mappings' => [ + 'effective_date' => ['columns' => [0], 'concat_separator' => null], + 'planned_date' => ['columns' => [1], 'concat_separator' => null], + 'description' => ['columns' => [2], 'concat_separator' => null], + 'amount' => ['columns' => [3], 'concat_separator' => null], + ], + ], + 'caixa' => [ + 'name' => 'CaixaBank', + 'bank_name' => 'CaixaBank', + 'file_type' => 'xlsx', + 'header_row' => 0, + 'data_start_row' => 1, + 'date_format' => 'd/m/Y', + 'decimal_separator' => ',', + 'thousands_separator' => '.', + 'column_mappings' => [ + 'effective_date' => ['columns' => [0], 'concat_separator' => null], + 'description' => ['columns' => [1], 'concat_separator' => null], + 'amount' => ['columns' => [2], 'concat_separator' => null], + ], + ], + ]; + + $key = strtolower($bankName); + + if (!isset($presets[$key])) { + throw new \InvalidArgumentException("Unknown bank preset: $bankName"); + } + + $preset = $presets[$key]; + $preset['user_id'] = $userId; + + return ImportMapping::create($preset); + } + + /** + * Get available bank presets + */ + public function getAvailablePresets(): array + { + return [ + 'bbva' => [ + 'name' => 'BBVA España', + 'file_types' => ['xlsx'], + 'description' => 'Extrato BBVA em formato Excel', + ], + 'santander' => [ + 'name' => 'Santander España', + 'file_types' => ['xls'], + 'description' => 'Extrato Santander em formato Excel 97-2003', + ], + 'caixa' => [ + 'name' => 'CaixaBank', + 'file_types' => ['xlsx'], + 'description' => 'Extrato CaixaBank em formato Excel', + ], + ]; + } + + /** + * Suggest mapping based on file headers + */ + public function suggestMapping(array $headers): array + { + $suggestions = []; + + $patterns = [ + 'effective_date' => [ + '/fecha.*valor/i', '/f\.?\s*valor/i', '/date/i', '/data/i', + '/fecha.*operaci[oó]n/i', '/data.*efetiva/i', '/value.*date/i' + ], + 'planned_date' => [ + '/fecha(?!.*valor)/i', '/planned.*date/i', '/data.*planejada/i', + '/fecha.*contable/i' + ], + 'description' => [ + '/concepto/i', '/descri[çc][ãa]o/i', '/description/i', '/memo/i', + '/movimiento/i', '/name/i', '/payee/i' + ], + 'amount' => [ + '/importe/i', '/valor/i', '/amount/i', '/monto/i', '/trnamt/i', + '/quantia/i', '/value/i' + ], + 'balance' => [ + '/saldo/i', '/balance/i', '/disponible/i', '/available/i' + ], + 'type' => [ + '/tipo/i', '/type/i', '/trntype/i', '/credit.*debit/i' + ], + 'notes' => [ + '/observa/i', '/notes/i', '/notas/i', '/comment/i' + ], + 'reference' => [ + '/refer[êe]ncia/i', '/reference/i', '/fitid/i', '/n[úu]mero/i' + ], + ]; + + foreach ($headers as $index => $header) { + if ($header === null || $header === '') { + continue; + } + + $headerStr = (string) $header; + + foreach ($patterns as $field => $fieldPatterns) { + foreach ($fieldPatterns as $pattern) { + if (preg_match($pattern, $headerStr)) { + if (!isset($suggestions[$field])) { + $suggestions[$field] = [ + 'columns' => [$index], + 'concat_separator' => null, + 'header_name' => $headerStr, + ]; + } + break 2; + } + } + } + } + + return $suggestions; + } +} diff --git a/backend/app/Services/Import/OfxParser.php b/backend/app/Services/Import/OfxParser.php new file mode 100644 index 0000000..0100139 --- /dev/null +++ b/backend/app/Services/Import/OfxParser.php @@ -0,0 +1,249 @@ +parseAccountInfo($content); + + // Parse transactions + $transactions = $this->parseTransactions($content); + + // Formatar como dados tabulares + $headers = ['DTPOSTED', 'TRNTYPE', 'TRNAMT', 'FITID', 'NAME', 'MEMO']; + $data = []; + + foreach ($transactions as $txn) { + $data[] = [ + $txn['date'] ?? '', + $txn['type'] ?? '', + $txn['amount'] ?? '', + $txn['fitid'] ?? '', + $txn['name'] ?? '', + $txn['memo'] ?? '', + ]; + } + + return [ + 'headers' => $headers, + 'data' => $data, + 'total_rows' => count($data), + 'account_info' => $accountInfo, + 'raw_transactions' => $transactions, + ]; + } + + /** + * Get headers (OFX has fixed structure) + */ + public function getHeaders(string $filePath, array $options = []): array + { + return ['DTPOSTED', 'TRNTYPE', 'TRNAMT', 'FITID', 'NAME', 'MEMO']; + } + + /** + * Get preview data + */ + public function getPreview(string $filePath, int $rows = 10, array $options = []): array + { + $parsed = $this->parse($filePath, $options); + + $preview = []; + $count = 0; + + foreach ($parsed['data'] as $row) { + if ($count >= $rows) { + break; + } + $preview[] = [ + 'row_index' => $count, + 'data' => $row, + ]; + $count++; + } + + return [ + 'preview' => $preview, + 'total_rows' => $parsed['total_rows'], + 'columns_count' => count($parsed['headers']), + 'headers' => $parsed['headers'], + 'account_info' => $parsed['account_info'] ?? null, + ]; + } + + /** + * Parse account information from OFX + */ + protected function parseAccountInfo(string $content): array + { + $info = []; + + // Bank ID + if (preg_match('/([^<\n]+)/i', $content, $matches)) { + $info['bank_id'] = trim($matches[1]); + } + + // Account ID + if (preg_match('/([^<\n]+)/i', $content, $matches)) { + $info['account_id'] = trim($matches[1]); + } + + // Account Type + if (preg_match('/([^<\n]+)/i', $content, $matches)) { + $info['account_type'] = trim($matches[1]); + } + + // Currency + if (preg_match('/([^<\n]+)/i', $content, $matches)) { + $info['currency'] = trim($matches[1]); + } + + // Balance + if (preg_match('/([^<\n]+)/i', $content, $matches)) { + $info['balance'] = floatval(trim($matches[1])); + } + + // Balance Date + if (preg_match('/([^<\n]+)/i', $content, $matches)) { + $info['balance_date'] = $this->parseOfxDate(trim($matches[1])); + } + + return $info; + } + + /** + * Parse transactions from OFX + */ + protected function parseTransactions(string $content): array + { + $transactions = []; + + // Find all STMTTRN blocks + preg_match_all('/(.*?)<\/STMTTRN>/is', $content, $matches); + + // Também tentar sem tag de fechamento (OFX SGML) + if (empty($matches[1])) { + // Split by STMTTRN tags + $parts = preg_split('//i', $content); + array_shift($parts); // Remover parte antes do primeiro STMTTRN + + foreach ($parts as $part) { + // Encontrar fim da transação + $endPos = stripos($part, ''); + if ($endPos !== false) { + $part = substr($part, 0, $endPos); + } else { + // Tentar encontrar próximo STMTTRN ou fim de lista + $nextPos = stripos($part, ''); + if ($nextPos !== false) { + $part = substr($part, 0, $nextPos); + } + $endListPos = stripos($part, ''); + if ($endListPos !== false) { + $part = substr($part, 0, $endListPos); + } + } + + $txn = $this->parseTransaction($part); + if (!empty($txn['amount'])) { + $transactions[] = $txn; + } + } + } else { + foreach ($matches[1] as $block) { + $txn = $this->parseTransaction($block); + if (!empty($txn['amount'])) { + $transactions[] = $txn; + } + } + } + + return $transactions; + } + + /** + * Parse a single transaction block + */ + protected function parseTransaction(string $block): array + { + $txn = []; + + // Type (CREDIT, DEBIT, etc.) + if (preg_match('/([^<\n]+)/i', $block, $matches)) { + $txn['type'] = trim($matches[1]); + } + + // Date Posted + if (preg_match('/([^<\n]+)/i', $block, $matches)) { + $txn['date'] = $this->parseOfxDate(trim($matches[1])); + } + + // Amount + if (preg_match('/([^<\n]+)/i', $block, $matches)) { + $txn['amount'] = floatval(str_replace(',', '.', trim($matches[1]))); + } + + // FIT ID (unique identifier) + if (preg_match('/([^<\n]+)/i', $block, $matches)) { + $txn['fitid'] = trim($matches[1]); + } + + // Name/Payee + if (preg_match('/([^<\n]+)/i', $block, $matches)) { + $txn['name'] = trim($matches[1]); + } + + // Memo + if (preg_match('/([^<\n]+)/i', $block, $matches)) { + $txn['memo'] = trim($matches[1]); + } + + // Check Number + if (preg_match('/([^<\n]+)/i', $block, $matches)) { + $txn['check_num'] = trim($matches[1]); + } + + return $txn; + } + + /** + * Parse OFX date format (YYYYMMDDHHMMSS) + */ + protected function parseOfxDate(string $date): string + { + // Remove timezone info + $date = preg_replace('/\[.*\]/', '', $date); + $date = trim($date); + + if (strlen($date) >= 8) { + $year = substr($date, 0, 4); + $month = substr($date, 4, 2); + $day = substr($date, 6, 2); + return "$day/$month/$year"; + } + + return $date; + } + + /** + * Check if parser supports the extension + */ + public static function supports(string $extension): bool + { + return in_array(strtolower($extension), self::$supportedExtensions); + } +} diff --git a/backend/app/Services/Import/PdfParser.php b/backend/app/Services/Import/PdfParser.php new file mode 100644 index 0000000..f431d8d --- /dev/null +++ b/backend/app/Services/Import/PdfParser.php @@ -0,0 +1,194 @@ +extractLines($filePath); + + // Tentar identificar estrutura tabular + $parsed = $this->parseTableStructure($lines, $headerRow, $dataStartRow); + + return $parsed; + } + + /** + * Get headers from PDF + */ + public function getHeaders(string $filePath, array $options = []): array + { + $headerRow = $options['header_row'] ?? 0; + $lines = $this->extractLines($filePath); + + if (isset($lines[$headerRow])) { + return $this->parseLine($lines[$headerRow]); + } + + return []; + } + + /** + * Get preview data + */ + public function getPreview(string $filePath, int $rows = 10, array $options = []): array + { + $lines = $this->extractLines($filePath); + + $preview = []; + $count = 0; + + foreach ($lines as $index => $line) { + if ($count >= $rows) { + break; + } + + $parsed = $this->parseLine($line); + if (!empty($parsed)) { + $preview[] = [ + 'row_index' => $index, + 'data' => $parsed, + 'raw' => $line, + ]; + $count++; + } + } + + return [ + 'preview' => $preview, + 'total_rows' => count($lines), + 'columns_count' => !empty($preview) ? count($preview[0]['data']) : 0, + 'raw_text_available' => true, + ]; + } + + /** + * Extract lines from PDF + */ + protected function extractLines(string $filePath): array + { + // Verificar se a biblioteca está disponível + if (!class_exists(PdfParserLib::class)) { + // Tentar usar pdftotext (poppler-utils) + return $this->extractWithPdftotext($filePath); + } + + try { + $parser = new PdfParserLib(); + $pdf = $parser->parseFile($filePath); + $text = $pdf->getText(); + + // Dividir em linhas + $lines = explode("\n", $text); + + // Limpar linhas vazias + $lines = array_filter($lines, fn($line) => trim($line) !== ''); + + return array_values($lines); + } catch (\Exception $e) { + return $this->extractWithPdftotext($filePath); + } + } + + /** + * Extract using pdftotext command + */ + protected function extractWithPdftotext(string $filePath): array + { + $output = []; + $returnVar = 0; + + // Tentar com layout preservado + exec("pdftotext -layout " . escapeshellarg($filePath) . " - 2>/dev/null", $output, $returnVar); + + if ($returnVar !== 0 || empty($output)) { + // Tentar sem layout + exec("pdftotext " . escapeshellarg($filePath) . " - 2>/dev/null", $output, $returnVar); + } + + if ($returnVar !== 0) { + throw new \RuntimeException("Could not extract text from PDF. Please install poppler-utils or smalot/pdfparser."); + } + + // Filtrar linhas vazias + return array_values(array_filter($output, fn($line) => trim($line) !== '')); + } + + /** + * Parse a single line into columns + */ + protected function parseLine(string $line): array + { + // Tentar dividir por múltiplos espaços + $parts = preg_split('/\s{2,}/', trim($line)); + + if (count($parts) > 1) { + return array_map('trim', $parts); + } + + // Se não funcionou, tentar por tabs + $parts = explode("\t", $line); + if (count($parts) > 1) { + return array_map('trim', $parts); + } + + // Retornar linha como único elemento + return [trim($line)]; + } + + /** + * Parse table structure from lines + */ + protected function parseTableStructure(array $lines, int $headerRow, int $dataStartRow): array + { + $headers = []; + $data = []; + + foreach ($lines as $index => $line) { + $parsed = $this->parseLine($line); + + if ($index === $headerRow && !empty($parsed)) { + $headers = $parsed; + continue; + } + + if ($index >= $dataStartRow && !empty($parsed)) { + // Ajustar número de colunas para coincidir com headers + if (!empty($headers)) { + while (count($parsed) < count($headers)) { + $parsed[] = ''; + } + $parsed = array_slice($parsed, 0, count($headers)); + } + + $data[] = $parsed; + } + } + + return [ + 'headers' => $headers, + 'data' => $data, + 'total_rows' => count($data), + ]; + } + + /** + * Check if parser supports the extension + */ + public static function supports(string $extension): bool + { + return in_array(strtolower($extension), self::$supportedExtensions); + } +} diff --git a/backend/app/Services/RecurringService.php b/backend/app/Services/RecurringService.php new file mode 100644 index 0000000..646c6ea --- /dev/null +++ b/backend/app/Services/RecurringService.php @@ -0,0 +1,393 @@ + $transaction->user_id, + 'source_transaction_id' => $transaction->id, + 'name' => $options['name'] ?? $transaction->description, + 'description' => $options['description'] ?? null, + 'frequency' => $frequency, + 'frequency_interval' => $options['frequency_interval'] ?? 1, + 'day_of_month' => $options['day_of_month'] ?? $transaction->planned_date->day, + 'day_of_week' => $options['day_of_week'] ?? $transaction->planned_date->dayOfWeek, + 'start_date' => $options['start_date'] ?? $transaction->planned_date, + 'end_date' => $options['end_date'] ?? null, + 'max_occurrences' => $options['max_occurrences'] ?? null, + 'account_id' => $transaction->account_id, + 'category_id' => $transaction->category_id, + 'cost_center_id' => $transaction->cost_center_id, + 'type' => $transaction->type, + 'planned_amount' => $transaction->planned_amount, + 'transaction_description' => $transaction->description, + 'notes' => $transaction->notes, + 'is_active' => true, + ]); + + // Gerar instâncias iniciais + $this->generateInstances($template); + + return $template->fresh(['instances', 'account', 'category']); + } + + /** + * Cria um template de recorrência manualmente + */ + public function createTemplate(int $userId, array $data): RecurringTemplate + { + $template = RecurringTemplate::create([ + 'user_id' => $userId, + 'source_transaction_id' => $data['source_transaction_id'] ?? null, + 'name' => $data['name'], + 'description' => $data['description'] ?? null, + 'frequency' => $data['frequency'], + 'frequency_interval' => $data['frequency_interval'] ?? 1, + 'day_of_month' => $data['day_of_month'] ?? null, + 'day_of_week' => $data['day_of_week'] ?? null, + 'start_date' => $data['start_date'], + 'end_date' => $data['end_date'] ?? null, + 'max_occurrences' => $data['max_occurrences'] ?? null, + 'account_id' => $data['account_id'], + 'category_id' => $data['category_id'] ?? null, + 'cost_center_id' => $data['cost_center_id'] ?? null, + 'type' => $data['type'], + 'planned_amount' => $data['planned_amount'], + 'transaction_description' => $data['transaction_description'], + 'notes' => $data['notes'] ?? null, + 'is_active' => true, + ]); + + // Gerar instâncias iniciais + $this->generateInstances($template); + + return $template->fresh(['instances', 'account', 'category']); + } + + /** + * Gera instâncias para um template até o horizonte definido + */ + public function generateInstances(RecurringTemplate $template, ?int $horizonMonths = null): int + { + if (!$template->canGenerateMore()) { + return 0; + } + + $horizonMonths = $horizonMonths ?? self::DEFAULT_HORIZON_MONTHS; + $horizonDate = now()->addMonths($horizonMonths)->endOfMonth(); + + // Determinar data inicial para geração + $startDate = $template->last_generated_date + ? $this->calculateNextDate($template, $template->last_generated_date) + : Carbon::parse($template->start_date); + + $generated = 0; + $currentDate = $startDate; + $occurrenceNumber = $template->occurrences_generated; + + while ($currentDate->lte($horizonDate)) { + // Verificar limites + if ($template->max_occurrences !== null && $occurrenceNumber >= $template->max_occurrences) { + break; + } + + if ($template->end_date !== null && $currentDate->gt($template->end_date)) { + break; + } + + // Verificar se já existe instância para esta data + $exists = RecurringInstance::where('recurring_template_id', $template->id) + ->where('due_date', $currentDate->toDateString()) + ->exists(); + + if (!$exists) { + $occurrenceNumber++; + + RecurringInstance::create([ + 'user_id' => $template->user_id, + 'recurring_template_id' => $template->id, + 'occurrence_number' => $occurrenceNumber, + 'due_date' => $currentDate, + 'planned_amount' => $template->planned_amount, + 'status' => RecurringInstance::STATUS_PENDING, + ]); + + $generated++; + } + + // Próxima data + $currentDate = $this->calculateNextDate($template, $currentDate); + } + + // Atualizar template + if ($generated > 0) { + $template->update([ + 'last_generated_date' => RecurringInstance::where('recurring_template_id', $template->id) + ->max('due_date'), + 'occurrences_generated' => $occurrenceNumber, + ]); + } + + return $generated; + } + + /** + * Calcula a próxima data de vencimento baseada na frequência + * IMPORTANTE: Ajusta dias para meses curtos (ex: 31 → 28 em fevereiro) + */ + public function calculateNextDate(RecurringTemplate $template, Carbon $fromDate): Carbon + { + $interval = $template->frequency_interval ?? 1; + $nextDate = $fromDate->copy(); + + switch ($template->frequency) { + case 'daily': + $nextDate->addDays($interval); + break; + + case 'weekly': + $nextDate->addWeeks($interval); + // Se tem dia da semana definido, ajustar + if ($template->day_of_week !== null) { + $nextDate->next($template->day_of_week); + } + break; + + case 'biweekly': + $nextDate->addWeeks(2 * $interval); + break; + + case 'monthly': + case 'bimonthly': + case 'quarterly': + case 'semiannual': + case 'annual': + $months = match($template->frequency) { + 'monthly' => 1, + 'bimonthly' => 2, + 'quarterly' => 3, + 'semiannual' => 6, + 'annual' => 12, + } * $interval; + + $nextDate = $this->addMonthsWithDayAdjustment( + $fromDate, + $months, + $template->day_of_month + ); + break; + } + + return $nextDate; + } + + /** + * Adiciona meses à data mantendo o dia do mês correto + * Se o dia não existe no mês destino, usa o último dia disponível + * + * Exemplo: 31/Jan + 1 mês = 28/Fev (ou 29 em bissexto) + * 30/Jan + 1 mês = 28/Fev + * 29/Jan + 1 mês = 28/Fev (ou 29 em bissexto) + */ + private function addMonthsWithDayAdjustment(Carbon $date, int $months, ?int $preferredDay = null): Carbon + { + $targetDay = $preferredDay ?? $date->day; + + // Avançar os meses + $newDate = $date->copy()->addMonths($months); + + // Obter o último dia do mês destino + $lastDayOfMonth = $newDate->copy()->endOfMonth()->day; + + // Se o dia preferido é maior que o último dia do mês, usar o último dia + $actualDay = min($targetDay, $lastDayOfMonth); + + // Definir o dia correto + $newDate->day($actualDay); + + return $newDate; + } + + /** + * Concilia uma instância com uma transação existente + */ + public function reconcileWithTransaction( + RecurringInstance $instance, + Transaction $transaction, + ?string $notes = null + ): RecurringInstance { + return DB::transaction(function () use ($instance, $transaction, $notes) { + $instance->update([ + 'status' => RecurringInstance::STATUS_PAID, + 'transaction_id' => $transaction->id, + 'paid_at' => $transaction->effective_date ?? $transaction->planned_date, + 'paid_amount' => $transaction->amount ?? $transaction->planned_amount, + 'paid_notes' => $notes, + ]); + + // Atualizar transação com link reverso + $transaction->update([ + 'recurring_instance_id' => $instance->id, + ]); + + // Gerar próximas instâncias se necessário + $this->generateInstances($instance->template); + + return $instance->fresh(['template', 'transaction']); + }); + } + + /** + * Marca como pago criando uma nova transação + */ + public function markAsPaid( + RecurringInstance $instance, + array $transactionData = [] + ): RecurringInstance { + return DB::transaction(function () use ($instance, $transactionData) { + $template = $instance->template; + + // Criar transação + $transaction = Transaction::create([ + 'user_id' => $template->user_id, + 'account_id' => $template->account_id, + 'category_id' => $template->category_id, + 'cost_center_id' => $template->cost_center_id, + 'type' => $template->type, + 'planned_amount' => $transactionData['amount'] ?? $instance->planned_amount, + 'amount' => $transactionData['amount'] ?? $instance->planned_amount, + 'planned_date' => $instance->due_date, + 'effective_date' => $transactionData['effective_date'] ?? now(), + 'description' => $transactionData['description'] ?? $template->transaction_description, + 'notes' => $transactionData['notes'] ?? $template->notes, + 'status' => 'completed', + 'recurring_instance_id' => $instance->id, + ]); + + // Atualizar instância + $instance->update([ + 'status' => RecurringInstance::STATUS_PAID, + 'transaction_id' => $transaction->id, + 'paid_at' => $transaction->effective_date, + 'paid_amount' => $transaction->amount, + 'paid_notes' => $transactionData['notes'] ?? null, + ]); + + // Gerar próximas instâncias se necessário + $this->generateInstances($template); + + return $instance->fresh(['template', 'transaction']); + }); + } + + /** + * Pula uma instância + */ + public function skipInstance(RecurringInstance $instance, ?string $reason = null): RecurringInstance + { + $instance->update([ + 'status' => RecurringInstance::STATUS_SKIPPED, + 'paid_notes' => $reason, + ]); + + // Gerar próximas instâncias se necessário + $this->generateInstances($instance->template); + + return $instance->fresh(); + } + + /** + * Cancela uma instância + */ + public function cancelInstance(RecurringInstance $instance, ?string $reason = null): RecurringInstance + { + $instance->update([ + 'status' => RecurringInstance::STATUS_CANCELLED, + 'paid_notes' => $reason, + ]); + + return $instance->fresh(); + } + + /** + * Pausa um template (para de gerar novas instâncias) + */ + public function pauseTemplate(RecurringTemplate $template): RecurringTemplate + { + $template->update(['is_active' => false]); + return $template->fresh(); + } + + /** + * Reativa um template e gera instâncias faltantes + */ + public function resumeTemplate(RecurringTemplate $template): RecurringTemplate + { + $template->update(['is_active' => true]); + $this->generateInstances($template); + return $template->fresh(['instances']); + } + + /** + * Regenera instâncias pendentes para todos os templates ativos de um usuário + * Útil para rodar em um job diário + */ + public function regenerateAllForUser(int $userId): int + { + $templates = RecurringTemplate::where('user_id', $userId) + ->where('is_active', true) + ->get(); + + $totalGenerated = 0; + foreach ($templates as $template) { + $totalGenerated += $this->generateInstances($template); + } + + return $totalGenerated; + } + + /** + * Busca transações candidatas para conciliar com uma instância + */ + public function findCandidateTransactions(RecurringInstance $instance, int $daysTolerance = 7): \Illuminate\Support\Collection + { + $template = $instance->template; + + return Transaction::where('user_id', $template->user_id) + ->where('account_id', $template->account_id) + ->where('type', $template->type) + ->whereNull('recurring_instance_id') // Não está vinculada a outra recorrência + ->where('status', 'completed') + ->whereBetween('effective_date', [ + $instance->due_date->copy()->subDays($daysTolerance), + $instance->due_date->copy()->addDays($daysTolerance), + ]) + ->whereBetween('amount', [ + $instance->planned_amount * 0.9, // 10% de tolerância + $instance->planned_amount * 1.1, + ]) + ->orderByRaw('ABS(DATEDIFF(effective_date, ?))', [$instance->due_date]) + ->limit(10) + ->get(); + } +} diff --git a/backend/app/Services/UserSetupService.php b/backend/app/Services/UserSetupService.php new file mode 100644 index 0000000..99a71e2 --- /dev/null +++ b/backend/app/Services/UserSetupService.php @@ -0,0 +1,310 @@ +userId = $userId; + $this->now = Carbon::now(); + $this->categoryId = 0; + + // ======================================== + // RECEITAS (income) + // ======================================== + + // 1. Salário e Trabalho + $salario = $this->createCategory('Salário e Trabalho', 'income', 'Rendimentos do trabalho', '#10B981', 'bi-briefcase'); + $this->createSubcategories($salario, [ + ['name' => 'Salário Líquido', 'keywords' => ['NOMINA', 'SALARIO', 'SUELDO', 'PAGO NOMINA']], + ['name' => 'Horas Extras', 'keywords' => ['HORAS EXTRA', 'OVERTIME']], + ['name' => 'Bônus/Bonus', 'keywords' => ['BONUS', 'BONO', 'GRATIFICACION']], + ['name' => 'Comissões', 'keywords' => ['COMISION', 'COMISSAO']], + ['name' => 'Dietas/Ajudas', 'keywords' => ['DIETA', 'AJUDA CUSTO', 'VIATICOS']], + ['name' => 'Freelance', 'keywords' => ['FREELANCE', 'AUTONOMO', 'PROYECTO']], + ]); + + // 2. Investimentos + $investimentos = $this->createCategory('Investimentos', 'income', 'Rendimentos de investimentos', '#059669', 'bi-graph-up-arrow'); + $this->createSubcategories($investimentos, [ + ['name' => 'Dividendos', 'keywords' => ['DIVIDENDO', 'DIVIDEND']], + ['name' => 'Juros Poupança', 'keywords' => ['INTERESES', 'JUROS', 'RENDIMIENTO']], + ['name' => 'Aluguel Recebido', 'keywords' => ['ALQUILER RECIBIDO', 'RENTA RECIBIDA']], + ['name' => 'Venda de Ativos', 'keywords' => ['VENTA ACCIONES', 'VENDA ATIVO']], + ]); + + // 3. Reembolsos + $reembolsos = $this->createCategory('Reembolsos', 'income', 'Devoluções e reembolsos', '#14B8A6', 'bi-arrow-return-left'); + $this->createSubcategories($reembolsos, [ + ['name' => 'Devolução Compras', 'keywords' => ['DEVOLUCION', 'DEVOLUÇÃO', 'REEMBOLSO', 'REFUND']], + ['name' => 'Reembolso Despesas', 'keywords' => ['REEMBOLSO GASTOS', 'EXPENSE REFUND']], + ['name' => 'Estorno', 'keywords' => ['ESTORNO', 'ANULACION', 'CANCELACION']], + ]); + + // 4. Transferências Recebidas + $transRecebidas = $this->createCategory('Transferências Recebidas', 'income', 'Dinheiro recebido de terceiros', '#0D9488', 'bi-box-arrow-in-down'); + $this->createSubcategories($transRecebidas, [ + ['name' => 'De Familiares', 'keywords' => []], + ['name' => 'De Amigos', 'keywords' => []], + ['name' => 'Bizum Recebido', 'keywords' => ['BIZUM RECIBIDO', 'BIZUM A FAVOR']], + ]); + + // 5. Outros Rendimentos + $outrosRend = $this->createCategory('Outros Rendimentos', 'income', 'Outras fontes de renda', '#047857', 'bi-cash-stack'); + $this->createSubcategories($outrosRend, [ + ['name' => 'Prêmios/Sorteios', 'keywords' => ['PREMIO', 'SORTEO', 'LOTERIA']], + ['name' => 'Venda Usados', 'keywords' => ['WALLAPOP', 'VINTED', 'MILANUNCIOS']], + ['name' => 'Cashback', 'keywords' => ['CASHBACK', 'RECOMPENSA']], + ]); + + // ======================================== + // DESPESAS (expense) + // ======================================== + + // 6. Moradia + $moradia = $this->createCategory('Moradia', 'expense', 'Gastos com casa e moradia', '#EF4444', 'bi-house-door'); + $this->createSubcategories($moradia, [ + ['name' => 'Aluguel', 'keywords' => ['ALQUILER', 'RENTA MENSUAL', 'ARRENDAMIENTO']], + ['name' => 'Hipoteca', 'keywords' => ['HIPOTECA', 'MORTGAGE', 'PRESTAMO VIVIENDA']], + ['name' => 'Condomínio', 'keywords' => ['COMUNIDAD', 'GASTOS COMUNES', 'CONDOMINIO']], + ['name' => 'Seguro Casa', 'keywords' => ['SEGURO HOGAR', 'SEGURO VIVIENDA']], + ['name' => 'IPTU/IBI', 'keywords' => ['IBI', 'IMPUESTO BIENES INMUEBLES']], + ['name' => 'Manutenção Casa', 'keywords' => ['REPARACION', 'MANTENIMIENTO HOGAR']], + ]); + + // 7. Serviços/Utilities + $servicos = $this->createCategory('Serviços/Utilities', 'expense', 'Contas de serviços básicos', '#DC2626', 'bi-lightning-charge'); + $this->createSubcategories($servicos, [ + ['name' => 'Eletricidade', 'keywords' => ['IBERDROLA', 'ENDESA', 'NATURGY', 'ELECTRICIDAD', 'LUZ']], + ['name' => 'Gás', 'keywords' => ['GAS NATURAL', 'NEDGIA', 'MADRILEÑA DE GAS']], + ['name' => 'Água', 'keywords' => ['CANAL ISABEL II', 'AGUA', 'CICLO AGUA']], + ['name' => 'Internet/Fibra', 'keywords' => ['MOVISTAR', 'VODAFONE', 'ORANGE', 'MASMOVIL', 'DIGI', 'FIBRA', 'INTERNET']], + ['name' => 'Telefone Móvel', 'keywords' => ['TELEFONICA', 'MOVIL', 'MOBILE', 'CELULAR']], + ['name' => 'TV/Streaming', 'keywords' => ['NETFLIX', 'HBO', 'DISNEY', 'AMAZON PRIME', 'SPOTIFY', 'YOUTUBE']], + ]); + + // 8. Alimentação + $alimentacao = $this->createCategory('Alimentação', 'expense', 'Gastos com comida e bebida', '#F97316', 'bi-cart3'); + $this->createSubcategories($alimentacao, [ + ['name' => 'Supermercado', 'keywords' => ['MERCADONA', 'CARREFOUR', 'DIA', 'LIDL', 'ALDI', 'ALCAMPO', 'HIPERCOR', 'AHORRAMAS', 'SUPERMERCADO', 'EROSKI']], + ['name' => 'Padaria/Confeitaria', 'keywords' => ['PANADERIA', 'PASTELERIA', 'HORNO', 'BAKERY']], + ['name' => 'Açougue/Peixaria', 'keywords' => ['CARNICERIA', 'PESCADERIA', 'BUTCHER']], + ['name' => 'Frutas/Verduras', 'keywords' => ['FRUTERIA', 'VERDULERIA', 'MERCADO']], + ['name' => 'Delivery Comida', 'keywords' => ['GLOVO', 'UBER EATS', 'JUST EAT', 'DELIVEROO']], + ]); + + // 9. Restaurantes e Lazer + $restaurantes = $this->createCategory('Restaurantes e Lazer', 'expense', 'Comer fora e entretenimento', '#EA580C', 'bi-cup-straw'); + $this->createSubcategories($restaurantes, [ + ['name' => 'Restaurantes', 'keywords' => ['RESTAURANTE', 'RESTAURANT', 'ASADOR', 'TABERNA']], + ['name' => 'Cafés/Bares', 'keywords' => ['CAFE', 'BAR', 'CERVECERIA', 'CAFETERIA', 'STARBUCKS', 'TIM HORTONS']], + ['name' => 'Fast Food', 'keywords' => ['MCDONALDS', 'BURGER KING', 'KFC', 'TELEPIZZA', 'DOMINOS', 'FIVE GUYS']], + ['name' => 'Cinema/Teatro', 'keywords' => ['CINESA', 'YELMO', 'KINEPOLIS', 'TEATRO', 'CINEMA']], + ['name' => 'Eventos/Shows', 'keywords' => ['TICKETMASTER', 'ENTRADAS', 'CONCIERTO', 'EVENTO']], + ['name' => 'Museus/Cultura', 'keywords' => ['MUSEO', 'EXPOSICION', 'CULTURA']], + ]); + + // 10. Transporte + $transporte = $this->createCategory('Transporte', 'expense', 'Gastos com deslocamento', '#F59E0B', 'bi-car-front'); + $this->createSubcategories($transporte, [ + ['name' => 'Combustível', 'keywords' => ['REPSOL', 'CEPSA', 'BP', 'SHELL', 'GASOLINERA', 'GASOLINA', 'DIESEL', 'COMBUSTIBLE']], + ['name' => 'Metro/Ônibus', 'keywords' => ['METRO MADRID', 'EMT', 'CRTM', 'ABONO TRANSPORTE', 'TARJETA TRANSPORTE']], + ['name' => 'Táxi/Uber', 'keywords' => ['UBER', 'CABIFY', 'FREE NOW', 'TAXI', 'BOLT']], + ['name' => 'Estacionamento', 'keywords' => ['PARKING', 'APARCAMIENTO', 'ESTACIONAMIENTO', 'SER', 'EMPARK']], + ['name' => 'Pedágios', 'keywords' => ['PEAJE', 'AUTOPISTA', 'VIA T']], + ['name' => 'Manutenção Carro', 'keywords' => ['TALLER', 'MECANICO', 'REVISION', 'ITV', 'NEUMATICO', 'MIDAS', 'NORAUTO']], + ['name' => 'Seguro Carro', 'keywords' => ['SEGURO AUTO', 'SEGURO COCHE', 'MAPFRE', 'LINEA DIRECTA', 'MUTUA']], + ['name' => 'Trem/AVE', 'keywords' => ['RENFE', 'AVE', 'CERCANIAS', 'TREN', 'OUIGO', 'IRYO']], + ['name' => 'Aluguel Veículos', 'keywords' => ['RENT A CAR', 'ENTERPRISE', 'HERTZ', 'AVIS', 'EUROPCAR']], + ]); + + // 11. Saúde + $saude = $this->createCategory('Saúde', 'expense', 'Gastos médicos e bem-estar', '#EC4899', 'bi-heart-pulse'); + $this->createSubcategories($saude, [ + ['name' => 'Farmácia', 'keywords' => ['FARMACIA', 'PHARMACY', 'MEDICAMENTO', 'PARAFARMACIA']], + ['name' => 'Médico/Consulta', 'keywords' => ['CLINICA', 'HOSPITAL', 'CONSULTA MEDICA', 'SANITAS', 'ADESLAS', 'ASISA']], + ['name' => 'Dentista', 'keywords' => ['DENTISTA', 'DENTAL', 'CLINICA DENTAL', 'VITALDENT']], + ['name' => 'Ótica', 'keywords' => ['OPTICA', 'GAFAS', 'LENTILLAS', 'GENERAL OPTICA', 'MULTIOPTICAS']], + ['name' => 'Seguro Saúde', 'keywords' => ['SEGURO MEDICO', 'SEGURO SALUD', 'MUTUA', 'DKV']], + ['name' => 'Academia/Gym', 'keywords' => ['GYM', 'GIMNASIO', 'FITNESS', 'BASIC FIT', 'MCFIT', 'METROPOLITAN']], + ['name' => 'Psicólogo/Terapia', 'keywords' => ['PSICOLOGO', 'TERAPIA', 'PSIQUIATRA']], + ]); + + // 12. Compras/Shopping + $compras = $this->createCategory('Compras/Shopping', 'expense', 'Compras diversas', '#8B5CF6', 'bi-bag'); + $this->createSubcategories($compras, [ + ['name' => 'Roupas/Calçados', 'keywords' => ['ZARA', 'MANGO', 'HM', 'PRIMARK', 'PULL BEAR', 'BERSHKA', 'MASSIMO DUTTI', 'FOOTLOCKER']], + ['name' => 'Eletrônicos', 'keywords' => ['MEDIA MARKT', 'FNAC', 'PC COMPONENTES', 'APPLE STORE', 'WORTEN']], + ['name' => 'Casa/Decoração', 'keywords' => ['IKEA', 'LEROY MERLIN', 'ZARA HOME', 'MAISONS DU MONDE']], + ['name' => 'Amazon/Online', 'keywords' => ['AMAZON', 'ALIEXPRESS', 'EBAY', 'WISH']], + ['name' => 'Presentes', 'keywords' => ['REGALO', 'PRESENTE', 'GIFT']], + ['name' => 'Cosméticos/Beleza', 'keywords' => ['SEPHORA', 'PRIMOR', 'DRUNI', 'PERFUMERIA', 'RITUALS']], + ]); + + // 13. Educação + $educacao = $this->createCategory('Educação', 'expense', 'Investimento em conhecimento', '#6366F1', 'bi-mortarboard'); + $this->createSubcategories($educacao, [ + ['name' => 'Cursos/Formação', 'keywords' => ['CURSO', 'FORMACION', 'UDEMY', 'COURSERA', 'DOMESTIKA']], + ['name' => 'Livros/Material', 'keywords' => ['LIBRERIA', 'CASA DEL LIBRO', 'LIBRO', 'KINDLE']], + ['name' => 'Escola/Colégio', 'keywords' => ['COLEGIO', 'ESCUELA', 'MATRICULA']], + ['name' => 'Idiomas', 'keywords' => ['ACADEMIA IDIOMAS', 'ENGLISH', 'VAUGHAN', 'WALL STREET']], + ]); + + // 14. Finanças + $financas = $this->createCategory('Finanças', 'expense', 'Custos financeiros', '#3B82F6', 'bi-bank'); + $this->createSubcategories($financas, [ + ['name' => 'Taxas Bancárias', 'keywords' => ['COMISION', 'MANTENIMIENTO CUENTA', 'TASA BANCARIA']], + ['name' => 'Juros/Empréstimos', 'keywords' => ['INTERESES', 'PRESTAMO', 'CREDITO']], + ['name' => 'Cartão de Crédito', 'keywords' => ['PAGO TARJETA', 'CUOTA TARJETA']], + ['name' => 'Impostos', 'keywords' => ['HACIENDA', 'IRPF', 'IVA', 'IMPUESTO', 'AGENCIA TRIBUTARIA']], + ['name' => 'Seguros Gerais', 'keywords' => ['SEGURO', 'POLIZA', 'INSURANCE']], + ]); + + // 15. Lazer/Hobbies + $lazer = $this->createCategory('Lazer/Hobbies', 'expense', 'Atividades recreativas', '#A855F7', 'bi-controller'); + $this->createSubcategories($lazer, [ + ['name' => 'Jogos/Games', 'keywords' => ['PLAYSTATION', 'XBOX', 'STEAM', 'NINTENDO', 'GAME']], + ['name' => 'Esportes', 'keywords' => ['DECATHLON', 'SPRINTER', 'DEPORTE', 'FUTBOL', 'PADEL']], + ['name' => 'Viagens', 'keywords' => ['BOOKING', 'AIRBNB', 'HOTEL', 'VUELING', 'RYANAIR', 'IBERIA', 'VUELO']], + ['name' => 'Assinaturas', 'keywords' => ['SUSCRIPCION', 'MEMBERSHIP', 'PREMIUM']], + ]); + + // 16. Pessoal/Outros + $pessoal = $this->createCategory('Pessoal/Outros', 'expense', 'Gastos pessoais diversos', '#64748B', 'bi-person'); + $this->createSubcategories($pessoal, [ + ['name' => 'Cabeleireiro/Barbeiro', 'keywords' => ['PELUQUERIA', 'BARBERIA', 'CORTE PELO']], + ['name' => 'Lavanderia', 'keywords' => ['LAVANDERIA', 'TINTORERIA', 'LAUNDRY']], + ['name' => 'Pets/Animais', 'keywords' => ['VETERINARIO', 'MASCOTA', 'TIENDANIMAL', 'KIWOKO']], + ['name' => 'Doações', 'keywords' => ['DONACION', 'CARIDAD', 'ONG']], + ['name' => 'Outros', 'keywords' => []], + ]); + + // 17. Transferências Enviadas + $transEnviadas = $this->createCategory('Transferências Enviadas', 'expense', 'Dinheiro enviado a terceiros', '#475569', 'bi-box-arrow-up'); + $this->createSubcategories($transEnviadas, [ + ['name' => 'Para Familiares', 'keywords' => []], + ['name' => 'Para Amigos', 'keywords' => []], + ['name' => 'Bizum Enviado', 'keywords' => ['BIZUM ENVIADO', 'BIZUM A FAVOR DE']], + ['name' => 'Remessa Internacional', 'keywords' => ['WESTERN UNION', 'REMESA', 'TRANSFERWISE', 'WISE']], + ]); + + // ======================================== + // TRANSFERÊNCIAS (transfer) + // ======================================== + + // 18. Transferências Entre Contas + $transfer = $this->createCategory('Transferências Entre Contas', 'transfer', 'Movimentação entre contas próprias', '#0EA5E9', 'bi-arrow-left-right'); + $this->createSubcategories($transfer, [ + ['name' => 'Entre Contas Correntes', 'keywords' => ['TRANSFERENCIA PROPIA', 'TRASPASO']], + ['name' => 'Para Poupança', 'keywords' => ['AHORRO', 'POUPANCA']], + ['name' => 'Para Investimentos', 'keywords' => ['INVERSION', 'BROKER', 'TRADE REPUBLIC', 'DEGIRO']], + ['name' => 'Saque/Depósito', 'keywords' => ['CAJERO', 'ATM', 'RETIRADA', 'INGRESO EFECTIVO']], + ]); + } + + private function createCategory(string $name, string $type, string $description, string $color, string $icon): array + { + $this->categoryId++; + + DB::table('categories')->insert([ + 'user_id' => $this->userId, + 'parent_id' => null, + 'name' => $name, + 'type' => $type, + 'description' => $description, + 'color' => $color, + 'icon' => $icon, + 'order' => $this->categoryId, + 'is_active' => true, + 'is_system' => false, + 'created_at' => $this->now, + 'updated_at' => $this->now, + ]); + + return ['id' => DB::getPdo()->lastInsertId(), 'type' => $type]; + } + + private function createSubcategories(array $parent, array $subcategories): void + { + $parentId = $parent['id']; + $parentType = $parent['type']; + + foreach ($subcategories as $index => $sub) { + $this->categoryId++; + + DB::table('categories')->insert([ + 'user_id' => $this->userId, + 'parent_id' => $parentId, + 'name' => $sub['name'], + 'type' => $parentType, + 'description' => null, + 'color' => '#6B7280', + 'icon' => 'bi-tag', + 'order' => $index + 1, + 'is_active' => true, + 'is_system' => false, + 'created_at' => $this->now, + 'updated_at' => $this->now, + ]); + + $subcategoryId = DB::getPdo()->lastInsertId(); + + // Adicionar keywords + if (!empty($sub['keywords'])) { + foreach ($sub['keywords'] as $keyword) { + DB::table('category_keywords')->insert([ + 'category_id' => $subcategoryId, + 'keyword' => strtolower($keyword), + 'is_case_sensitive' => false, + 'is_active' => true, + 'created_at' => $this->now, + 'updated_at' => $this->now, + ]); + } + } + } + } + + /** + * Criar centro de custo padrão para novo usuário + */ + public function createDefaultCostCenter(int $userId): void + { + $now = Carbon::now(); + + DB::table('cost_centers')->insert([ + 'user_id' => $userId, + 'name' => 'Principal', + 'description' => 'Centro de custo padrão', + 'color' => '#3B82F6', + 'is_active' => true, + 'is_system' => true, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + + /** + * Configurar tudo para um novo usuário + */ + public function setupNewUser(int $userId): void + { + $this->createDefaultCategories($userId); + $this->createDefaultCostCenter($userId); + } +} diff --git a/backend/artisan b/backend/artisan new file mode 100644 index 0000000..c35e31d --- /dev/null +++ b/backend/artisan @@ -0,0 +1,18 @@ +#!/usr/bin/env php +handleCommand(new ArgvInput); + +exit($status); diff --git a/backend/bootstrap/app.php b/backend/bootstrap/app.php new file mode 100644 index 0000000..18b9f0a --- /dev/null +++ b/backend/bootstrap/app.php @@ -0,0 +1,25 @@ +withRouting( + web: __DIR__.'/../routes/web.php', + api: __DIR__.'/../routes/api.php', + commands: __DIR__.'/../routes/console.php', + health: '/up', + ) + ->withMiddleware(function (Middleware $middleware): void { + // Adicionar headers de segurança em todas as requisições + $middleware->append(\App\Http\Middleware\SecurityHeaders::class); + + // Alias para rate limiting + $middleware->alias([ + 'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class, + ]); + }) + ->withExceptions(function (Exceptions $exceptions): void { + // + })->create(); diff --git a/backend/bootstrap/cache/.gitignore b/backend/bootstrap/cache/.gitignore new file mode 100644 index 0000000..c96a04f --- /dev/null +++ b/backend/bootstrap/cache/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file diff --git a/backend/bootstrap/providers.php b/backend/bootstrap/providers.php new file mode 100644 index 0000000..38b258d --- /dev/null +++ b/backend/bootstrap/providers.php @@ -0,0 +1,5 @@ +=5.0.0" + }, + "require-dev": { + "doctrine/dbal": "^4.0.0", + "nesbot/carbon": "^2.71.0 || ^3.0.0", + "phpunit/phpunit": "^10.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Carbon\\Doctrine\\": "src/Carbon/Doctrine/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "KyleKatarn", + "email": "kylekatarnls@gmail.com" + } + ], + "description": "Types to use Carbon in Doctrine", + "keywords": [ + "carbon", + "date", + "datetime", + "doctrine", + "time" + ], + "support": { + "issues": "https://github.com/CarbonPHP/carbon-doctrine-types/issues", + "source": "https://github.com/CarbonPHP/carbon-doctrine-types/tree/3.2.0" + }, + "funding": [ + { + "url": "https://github.com/kylekatarnls", + "type": "github" + }, + { + "url": "https://opencollective.com/Carbon", + "type": "open_collective" + }, + { + "url": "https://tidelift.com/funding/github/packagist/nesbot/carbon", + "type": "tidelift" + } + ], + "time": "2024-02-09T16:56:22+00:00" + }, + { + "name": "composer/pcre", + "version": "3.3.2", + "source": { + "type": "git", + "url": "https://github.com/composer/pcre.git", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "conflict": { + "phpstan/phpstan": "<1.11.10" + }, + "require-dev": { + "phpstan/phpstan": "^1.12 || ^2", + "phpstan/phpstan-strict-rules": "^1 || ^2", + "phpunit/phpunit": "^8 || ^9" + }, + "type": "library", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Pcre\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "PCRE wrapping library that offers type-safe preg_* replacements.", + "keywords": [ + "PCRE", + "preg", + "regex", + "regular expression" + ], + "support": { + "issues": "https://github.com/composer/pcre/issues", + "source": "https://github.com/composer/pcre/tree/3.3.2" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2024-11-12T16:29:46+00:00" + }, + { + "name": "dflydev/dot-access-data", + "version": "v3.0.3", + "source": { + "type": "git", + "url": "https://github.com/dflydev/dflydev-dot-access-data.git", + "reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dflydev/dflydev-dot-access-data/zipball/a23a2bf4f31d3518f3ecb38660c95715dfead60f", + "reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^0.12.42", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.3", + "scrutinizer/ocular": "1.6.0", + "squizlabs/php_codesniffer": "^3.5", + "vimeo/psalm": "^4.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Dflydev\\DotAccessData\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Dragonfly Development Inc.", + "email": "info@dflydev.com", + "homepage": "http://dflydev.com" + }, + { + "name": "Beau Simensen", + "email": "beau@dflydev.com", + "homepage": "http://beausimensen.com" + }, + { + "name": "Carlos Frutos", + "email": "carlos@kiwing.it", + "homepage": "https://github.com/cfrutos" + }, + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com" + } + ], + "description": "Given a deep data structure, access data by dot notation.", + "homepage": "https://github.com/dflydev/dflydev-dot-access-data", + "keywords": [ + "access", + "data", + "dot", + "notation" + ], + "support": { + "issues": "https://github.com/dflydev/dflydev-dot-access-data/issues", + "source": "https://github.com/dflydev/dflydev-dot-access-data/tree/v3.0.3" + }, + "time": "2024-07-08T12:26:09+00:00" + }, + { + "name": "doctrine/inflector", + "version": "2.1.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/inflector.git", + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/inflector/zipball/6d6c96277ea252fc1304627204c3d5e6e15faa3b", + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "doctrine/coding-standard": "^12.0 || ^13.0", + "phpstan/phpstan": "^1.12 || ^2.0", + "phpstan/phpstan-phpunit": "^1.4 || ^2.0", + "phpstan/phpstan-strict-rules": "^1.6 || ^2.0", + "phpunit/phpunit": "^8.5 || ^12.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Inflector\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Inflector is a small library that can perform string manipulations with regard to upper/lowercase and singular/plural forms of words.", + "homepage": "https://www.doctrine-project.org/projects/inflector.html", + "keywords": [ + "inflection", + "inflector", + "lowercase", + "manipulation", + "php", + "plural", + "singular", + "strings", + "uppercase", + "words" + ], + "support": { + "issues": "https://github.com/doctrine/inflector/issues", + "source": "https://github.com/doctrine/inflector/tree/2.1.0" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finflector", + "type": "tidelift" + } + ], + "time": "2025-08-10T19:31:58+00:00" + }, + { + "name": "doctrine/lexer", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/doctrine/lexer.git", + "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/lexer/zipball/31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", + "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "doctrine/coding-standard": "^12", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^10.5", + "psalm/plugin-phpunit": "^0.18.3", + "vimeo/psalm": "^5.21" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Common\\Lexer\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Lexer parser library that can be used in Top-Down, Recursive Descent Parsers.", + "homepage": "https://www.doctrine-project.org/projects/lexer.html", + "keywords": [ + "annotations", + "docblock", + "lexer", + "parser", + "php" + ], + "support": { + "issues": "https://github.com/doctrine/lexer/issues", + "source": "https://github.com/doctrine/lexer/tree/3.0.1" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Flexer", + "type": "tidelift" + } + ], + "time": "2024-02-05T11:56:58+00:00" + }, + { + "name": "dragonmantank/cron-expression", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/dragonmantank/cron-expression.git", + "reference": "d61a8a9604ec1f8c3d150d09db6ce98b32675013" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/d61a8a9604ec1f8c3d150d09db6ce98b32675013", + "reference": "d61a8a9604ec1f8c3d150d09db6ce98b32675013", + "shasum": "" + }, + "require": { + "php": "^8.2|^8.3|^8.4|^8.5" + }, + "replace": { + "mtdowling/cron-expression": "^1.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.32|^2.1.31", + "phpunit/phpunit": "^8.5.48|^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Cron\\": "src/Cron/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Chris Tankersley", + "email": "chris@ctankersley.com", + "homepage": "https://github.com/dragonmantank" + } + ], + "description": "CRON for PHP: Calculate the next or previous run date and determine if a CRON expression is due", + "keywords": [ + "cron", + "schedule" + ], + "support": { + "issues": "https://github.com/dragonmantank/cron-expression/issues", + "source": "https://github.com/dragonmantank/cron-expression/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://github.com/dragonmantank", + "type": "github" + } + ], + "time": "2025-10-31T18:51:33+00:00" + }, + { + "name": "egulias/email-validator", + "version": "4.0.4", + "source": { + "type": "git", + "url": "https://github.com/egulias/EmailValidator.git", + "reference": "d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/egulias/EmailValidator/zipball/d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa", + "reference": "d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa", + "shasum": "" + }, + "require": { + "doctrine/lexer": "^2.0 || ^3.0", + "php": ">=8.1", + "symfony/polyfill-intl-idn": "^1.26" + }, + "require-dev": { + "phpunit/phpunit": "^10.2", + "vimeo/psalm": "^5.12" + }, + "suggest": { + "ext-intl": "PHP Internationalization Libraries are required to use the SpoofChecking validation" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Egulias\\EmailValidator\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Eduardo Gulias Davis" + } + ], + "description": "A library for validating emails against several RFCs", + "homepage": "https://github.com/egulias/EmailValidator", + "keywords": [ + "email", + "emailvalidation", + "emailvalidator", + "validation", + "validator" + ], + "support": { + "issues": "https://github.com/egulias/EmailValidator/issues", + "source": "https://github.com/egulias/EmailValidator/tree/4.0.4" + }, + "funding": [ + { + "url": "https://github.com/egulias", + "type": "github" + } + ], + "time": "2025-03-06T22:45:56+00:00" + }, + { + "name": "fruitcake/php-cors", + "version": "v1.4.0", + "source": { + "type": "git", + "url": "https://github.com/fruitcake/php-cors.git", + "reference": "38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/fruitcake/php-cors/zipball/38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379", + "reference": "38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379", + "shasum": "" + }, + "require": { + "php": "^8.1", + "symfony/http-foundation": "^5.4|^6.4|^7.3|^8" + }, + "require-dev": { + "phpstan/phpstan": "^2", + "phpunit/phpunit": "^9", + "squizlabs/php_codesniffer": "^4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.3-dev" + } + }, + "autoload": { + "psr-4": { + "Fruitcake\\Cors\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fruitcake", + "homepage": "https://fruitcake.nl" + }, + { + "name": "Barryvdh", + "email": "barryvdh@gmail.com" + } + ], + "description": "Cross-origin resource sharing library for the Symfony HttpFoundation", + "homepage": "https://github.com/fruitcake/php-cors", + "keywords": [ + "cors", + "laravel", + "symfony" + ], + "support": { + "issues": "https://github.com/fruitcake/php-cors/issues", + "source": "https://github.com/fruitcake/php-cors/tree/v1.4.0" + }, + "funding": [ + { + "url": "https://fruitcake.nl", + "type": "custom" + }, + { + "url": "https://github.com/barryvdh", + "type": "github" + } + ], + "time": "2025-12-03T09:33:47+00:00" + }, + { + "name": "graham-campbell/result-type", + "version": "v1.1.3", + "source": { + "type": "git", + "url": "https://github.com/GrahamCampbell/Result-Type.git", + "reference": "3ba905c11371512af9d9bdd27d99b782216b6945" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/3ba905c11371512af9d9bdd27d99b782216b6945", + "reference": "3ba905c11371512af9d9bdd27d99b782216b6945", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "phpoption/phpoption": "^1.9.3" + }, + "require-dev": { + "phpunit/phpunit": "^8.5.39 || ^9.6.20 || ^10.5.28" + }, + "type": "library", + "autoload": { + "psr-4": { + "GrahamCampbell\\ResultType\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + } + ], + "description": "An Implementation Of The Result Type", + "keywords": [ + "Graham Campbell", + "GrahamCampbell", + "Result Type", + "Result-Type", + "result" + ], + "support": { + "issues": "https://github.com/GrahamCampbell/Result-Type/issues", + "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.1.3" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/graham-campbell/result-type", + "type": "tidelift" + } + ], + "time": "2024-07-20T21:45:45+00:00" + }, + { + "name": "guzzlehttp/guzzle", + "version": "7.10.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/guzzle.git", + "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", + "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", + "shasum": "" + }, + "require": { + "ext-json": "*", + "guzzlehttp/promises": "^2.3", + "guzzlehttp/psr7": "^2.8", + "php": "^7.2.5 || ^8.0", + "psr/http-client": "^1.0", + "symfony/deprecation-contracts": "^2.2 || ^3.0" + }, + "provide": { + "psr/http-client-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-curl": "*", + "guzzle/client-integration-tests": "3.0.2", + "php-http/message-factory": "^1.1", + "phpunit/phpunit": "^8.5.39 || ^9.6.20", + "psr/log": "^1.1 || ^2.0 || ^3.0" + }, + "suggest": { + "ext-curl": "Required for CURL handler support", + "ext-intl": "Required for Internationalized Domain Name (IDN) support", + "psr/log": "Required for using the Log middleware" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "GuzzleHttp\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Jeremy Lindblom", + "email": "jeremeamia@gmail.com", + "homepage": "https://github.com/jeremeamia" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle is a PHP HTTP client library", + "keywords": [ + "client", + "curl", + "framework", + "http", + "http client", + "psr-18", + "psr-7", + "rest", + "web service" + ], + "support": { + "issues": "https://github.com/guzzle/guzzle/issues", + "source": "https://github.com/guzzle/guzzle/tree/7.10.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle", + "type": "tidelift" + } + ], + "time": "2025-08-23T22:36:01+00:00" + }, + { + "name": "guzzlehttp/promises", + "version": "2.3.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/promises.git", + "reference": "481557b130ef3790cf82b713667b43030dc9c957" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/promises/zipball/481557b130ef3790cf82b713667b43030dc9c957", + "reference": "481557b130ef3790cf82b713667b43030dc9c957", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.44 || ^9.6.25" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle promises library", + "keywords": [ + "promise" + ], + "support": { + "issues": "https://github.com/guzzle/promises/issues", + "source": "https://github.com/guzzle/promises/tree/2.3.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises", + "type": "tidelift" + } + ], + "time": "2025-08-22T14:34:08+00:00" + }, + { + "name": "guzzlehttp/psr7", + "version": "2.8.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/psr7.git", + "reference": "21dc724a0583619cd1652f673303492272778051" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/21dc724a0583619cd1652f673303492272778051", + "reference": "21dc724a0583619cd1652f673303492272778051", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0", + "ralouphie/getallheaders": "^3.0" + }, + "provide": { + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "http-interop/http-factory-tests": "0.9.0", + "phpunit/phpunit": "^8.5.44 || ^9.6.25" + }, + "suggest": { + "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://sagikazarmark.hu" + } + ], + "description": "PSR-7 message implementation that also provides common utility methods", + "keywords": [ + "http", + "message", + "psr-7", + "request", + "response", + "stream", + "uri", + "url" + ], + "support": { + "issues": "https://github.com/guzzle/psr7/issues", + "source": "https://github.com/guzzle/psr7/tree/2.8.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", + "type": "tidelift" + } + ], + "time": "2025-08-23T21:21:41+00:00" + }, + { + "name": "guzzlehttp/uri-template", + "version": "v1.0.5", + "source": { + "type": "git", + "url": "https://github.com/guzzle/uri-template.git", + "reference": "4f4bbd4e7172148801e76e3decc1e559bdee34e1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/uri-template/zipball/4f4bbd4e7172148801e76e3decc1e559bdee34e1", + "reference": "4f4bbd4e7172148801e76e3decc1e559bdee34e1", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "symfony/polyfill-php80": "^1.24" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.44 || ^9.6.25", + "uri-template/tests": "1.0.0" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\UriTemplate\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + } + ], + "description": "A polyfill class for uri_template of PHP", + "keywords": [ + "guzzlehttp", + "uri-template" + ], + "support": { + "issues": "https://github.com/guzzle/uri-template/issues", + "source": "https://github.com/guzzle/uri-template/tree/v1.0.5" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/uri-template", + "type": "tidelift" + } + ], + "time": "2025-08-22T14:27:06+00:00" + }, + { + "name": "laravel/framework", + "version": "v12.41.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/framework.git", + "reference": "3e229b05935fd0300c632fb1f718c73046d664fc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/framework/zipball/3e229b05935fd0300c632fb1f718c73046d664fc", + "reference": "3e229b05935fd0300c632fb1f718c73046d664fc", + "shasum": "" + }, + "require": { + "brick/math": "^0.11|^0.12|^0.13|^0.14", + "composer-runtime-api": "^2.2", + "doctrine/inflector": "^2.0.5", + "dragonmantank/cron-expression": "^3.4", + "egulias/email-validator": "^3.2.1|^4.0", + "ext-ctype": "*", + "ext-filter": "*", + "ext-hash": "*", + "ext-mbstring": "*", + "ext-openssl": "*", + "ext-session": "*", + "ext-tokenizer": "*", + "fruitcake/php-cors": "^1.3", + "guzzlehttp/guzzle": "^7.8.2", + "guzzlehttp/uri-template": "^1.0", + "laravel/prompts": "^0.3.0", + "laravel/serializable-closure": "^1.3|^2.0", + "league/commonmark": "^2.7", + "league/flysystem": "^3.25.1", + "league/flysystem-local": "^3.25.1", + "league/uri": "^7.5.1", + "monolog/monolog": "^3.0", + "nesbot/carbon": "^3.8.4", + "nunomaduro/termwind": "^2.0", + "php": "^8.2", + "psr/container": "^1.1.1|^2.0.1", + "psr/log": "^1.0|^2.0|^3.0", + "psr/simple-cache": "^1.0|^2.0|^3.0", + "ramsey/uuid": "^4.7", + "symfony/console": "^7.2.0", + "symfony/error-handler": "^7.2.0", + "symfony/finder": "^7.2.0", + "symfony/http-foundation": "^7.2.0", + "symfony/http-kernel": "^7.2.0", + "symfony/mailer": "^7.2.0", + "symfony/mime": "^7.2.0", + "symfony/polyfill-php83": "^1.33", + "symfony/polyfill-php84": "^1.33", + "symfony/polyfill-php85": "^1.33", + "symfony/process": "^7.2.0", + "symfony/routing": "^7.2.0", + "symfony/uid": "^7.2.0", + "symfony/var-dumper": "^7.2.0", + "tijsverkoyen/css-to-inline-styles": "^2.2.5", + "vlucas/phpdotenv": "^5.6.1", + "voku/portable-ascii": "^2.0.2" + }, + "conflict": { + "tightenco/collect": "<5.5.33" + }, + "provide": { + "psr/container-implementation": "1.1|2.0", + "psr/log-implementation": "1.0|2.0|3.0", + "psr/simple-cache-implementation": "1.0|2.0|3.0" + }, + "replace": { + "illuminate/auth": "self.version", + "illuminate/broadcasting": "self.version", + "illuminate/bus": "self.version", + "illuminate/cache": "self.version", + "illuminate/collections": "self.version", + "illuminate/concurrency": "self.version", + "illuminate/conditionable": "self.version", + "illuminate/config": "self.version", + "illuminate/console": "self.version", + "illuminate/container": "self.version", + "illuminate/contracts": "self.version", + "illuminate/cookie": "self.version", + "illuminate/database": "self.version", + "illuminate/encryption": "self.version", + "illuminate/events": "self.version", + "illuminate/filesystem": "self.version", + "illuminate/hashing": "self.version", + "illuminate/http": "self.version", + "illuminate/json-schema": "self.version", + "illuminate/log": "self.version", + "illuminate/macroable": "self.version", + "illuminate/mail": "self.version", + "illuminate/notifications": "self.version", + "illuminate/pagination": "self.version", + "illuminate/pipeline": "self.version", + "illuminate/process": "self.version", + "illuminate/queue": "self.version", + "illuminate/redis": "self.version", + "illuminate/routing": "self.version", + "illuminate/session": "self.version", + "illuminate/support": "self.version", + "illuminate/testing": "self.version", + "illuminate/translation": "self.version", + "illuminate/validation": "self.version", + "illuminate/view": "self.version", + "spatie/once": "*" + }, + "require-dev": { + "ably/ably-php": "^1.0", + "aws/aws-sdk-php": "^3.322.9", + "ext-gmp": "*", + "fakerphp/faker": "^1.24", + "guzzlehttp/promises": "^2.0.3", + "guzzlehttp/psr7": "^2.4", + "laravel/pint": "^1.18", + "league/flysystem-aws-s3-v3": "^3.25.1", + "league/flysystem-ftp": "^3.25.1", + "league/flysystem-path-prefixing": "^3.25.1", + "league/flysystem-read-only": "^3.25.1", + "league/flysystem-sftp-v3": "^3.25.1", + "mockery/mockery": "^1.6.10", + "opis/json-schema": "^2.4.1", + "orchestra/testbench-core": "^10.8.0", + "pda/pheanstalk": "^5.0.6|^7.0.0", + "php-http/discovery": "^1.15", + "phpstan/phpstan": "^2.0", + "phpunit/phpunit": "^10.5.35|^11.5.3|^12.0.1", + "predis/predis": "^2.3|^3.0", + "resend/resend-php": "^0.10.0|^1.0", + "symfony/cache": "^7.2.0", + "symfony/http-client": "^7.2.0", + "symfony/psr-http-message-bridge": "^7.2.0", + "symfony/translation": "^7.2.0" + }, + "suggest": { + "ably/ably-php": "Required to use the Ably broadcast driver (^1.0).", + "aws/aws-sdk-php": "Required to use the SQS queue driver, DynamoDb failed job storage, and SES mail driver (^3.322.9).", + "brianium/paratest": "Required to run tests in parallel (^7.0|^8.0).", + "ext-apcu": "Required to use the APC cache driver.", + "ext-fileinfo": "Required to use the Filesystem class.", + "ext-ftp": "Required to use the Flysystem FTP driver.", + "ext-gd": "Required to use Illuminate\\Http\\Testing\\FileFactory::image().", + "ext-memcached": "Required to use the memcache cache driver.", + "ext-pcntl": "Required to use all features of the queue worker and console signal trapping.", + "ext-pdo": "Required to use all database features.", + "ext-posix": "Required to use all features of the queue worker.", + "ext-redis": "Required to use the Redis cache and queue drivers (^4.0|^5.0|^6.0).", + "fakerphp/faker": "Required to generate fake data using the fake() helper (^1.23).", + "filp/whoops": "Required for friendly error pages in development (^2.14.3).", + "laravel/tinker": "Required to use the tinker console command (^2.0).", + "league/flysystem-aws-s3-v3": "Required to use the Flysystem S3 driver (^3.25.1).", + "league/flysystem-ftp": "Required to use the Flysystem FTP driver (^3.25.1).", + "league/flysystem-path-prefixing": "Required to use the scoped driver (^3.25.1).", + "league/flysystem-read-only": "Required to use read-only disks (^3.25.1)", + "league/flysystem-sftp-v3": "Required to use the Flysystem SFTP driver (^3.25.1).", + "mockery/mockery": "Required to use mocking (^1.6).", + "pda/pheanstalk": "Required to use the beanstalk queue driver (^5.0).", + "php-http/discovery": "Required to use PSR-7 bridging features (^1.15).", + "phpunit/phpunit": "Required to use assertions and run tests (^10.5.35|^11.5.3|^12.0.1).", + "predis/predis": "Required to use the predis connector (^2.3|^3.0).", + "psr/http-message": "Required to allow Storage::put to accept a StreamInterface (^1.0).", + "pusher/pusher-php-server": "Required to use the Pusher broadcast driver (^6.0|^7.0).", + "resend/resend-php": "Required to enable support for the Resend mail transport (^0.10.0|^1.0).", + "symfony/cache": "Required to PSR-6 cache bridge (^7.2).", + "symfony/filesystem": "Required to enable support for relative symbolic links (^7.2).", + "symfony/http-client": "Required to enable support for the Symfony API mail transports (^7.2).", + "symfony/mailgun-mailer": "Required to enable support for the Mailgun mail transport (^7.2).", + "symfony/postmark-mailer": "Required to enable support for the Postmark mail transport (^7.2).", + "symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^7.2)." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "12.x-dev" + } + }, + "autoload": { + "files": [ + "src/Illuminate/Collections/functions.php", + "src/Illuminate/Collections/helpers.php", + "src/Illuminate/Events/functions.php", + "src/Illuminate/Filesystem/functions.php", + "src/Illuminate/Foundation/helpers.php", + "src/Illuminate/Log/functions.php", + "src/Illuminate/Support/functions.php", + "src/Illuminate/Support/helpers.php" + ], + "psr-4": { + "Illuminate\\": "src/Illuminate/", + "Illuminate\\Support\\": [ + "src/Illuminate/Macroable/", + "src/Illuminate/Collections/", + "src/Illuminate/Conditionable/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Laravel Framework.", + "homepage": "https://laravel.com", + "keywords": [ + "framework", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2025-12-03T01:02:13+00:00" + }, + { + "name": "laravel/prompts", + "version": "v0.3.8", + "source": { + "type": "git", + "url": "https://github.com/laravel/prompts.git", + "reference": "096748cdfb81988f60090bbb839ce3205ace0d35" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/prompts/zipball/096748cdfb81988f60090bbb839ce3205ace0d35", + "reference": "096748cdfb81988f60090bbb839ce3205ace0d35", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.2", + "ext-mbstring": "*", + "php": "^8.1", + "symfony/console": "^6.2|^7.0" + }, + "conflict": { + "illuminate/console": ">=10.17.0 <10.25.0", + "laravel/framework": ">=10.17.0 <10.25.0" + }, + "require-dev": { + "illuminate/collections": "^10.0|^11.0|^12.0", + "mockery/mockery": "^1.5", + "pestphp/pest": "^2.3|^3.4|^4.0", + "phpstan/phpstan": "^1.12.28", + "phpstan/phpstan-mockery": "^1.1.3" + }, + "suggest": { + "ext-pcntl": "Required for the spinner to be animated." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "0.3.x-dev" + } + }, + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "Laravel\\Prompts\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Add beautiful and user-friendly forms to your command-line applications.", + "support": { + "issues": "https://github.com/laravel/prompts/issues", + "source": "https://github.com/laravel/prompts/tree/v0.3.8" + }, + "time": "2025-11-21T20:52:52+00:00" + }, + { + "name": "laravel/sanctum", + "version": "v4.2.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/sanctum.git", + "reference": "f5fb373be39a246c74a060f2cf2ae2c2145b3664" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/sanctum/zipball/f5fb373be39a246c74a060f2cf2ae2c2145b3664", + "reference": "f5fb373be39a246c74a060f2cf2ae2c2145b3664", + "shasum": "" + }, + "require": { + "ext-json": "*", + "illuminate/console": "^11.0|^12.0", + "illuminate/contracts": "^11.0|^12.0", + "illuminate/database": "^11.0|^12.0", + "illuminate/support": "^11.0|^12.0", + "php": "^8.2", + "symfony/console": "^7.0" + }, + "require-dev": { + "mockery/mockery": "^1.6", + "orchestra/testbench": "^9.15|^10.8", + "phpstan/phpstan": "^1.10" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Sanctum\\SanctumServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Sanctum\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Laravel Sanctum provides a featherweight authentication system for SPAs and simple APIs.", + "keywords": [ + "auth", + "laravel", + "sanctum" + ], + "support": { + "issues": "https://github.com/laravel/sanctum/issues", + "source": "https://github.com/laravel/sanctum" + }, + "time": "2025-11-21T13:59:03+00:00" + }, + { + "name": "laravel/serializable-closure", + "version": "v2.0.7", + "source": { + "type": "git", + "url": "https://github.com/laravel/serializable-closure.git", + "reference": "cb291e4c998ac50637c7eeb58189c14f5de5b9dd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/cb291e4c998ac50637c7eeb58189c14f5de5b9dd", + "reference": "cb291e4c998ac50637c7eeb58189c14f5de5b9dd", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "illuminate/support": "^10.0|^11.0|^12.0", + "nesbot/carbon": "^2.67|^3.0", + "pestphp/pest": "^2.36|^3.0|^4.0", + "phpstan/phpstan": "^2.0", + "symfony/var-dumper": "^6.2.0|^7.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\SerializableClosure\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Nuno Maduro", + "email": "nuno@laravel.com" + } + ], + "description": "Laravel Serializable Closure provides an easy and secure way to serialize closures in PHP.", + "keywords": [ + "closure", + "laravel", + "serializable" + ], + "support": { + "issues": "https://github.com/laravel/serializable-closure/issues", + "source": "https://github.com/laravel/serializable-closure" + }, + "time": "2025-11-21T20:52:36+00:00" + }, + { + "name": "laravel/tinker", + "version": "v2.10.2", + "source": { + "type": "git", + "url": "https://github.com/laravel/tinker.git", + "reference": "3bcb5f62d6f837e0f093a601e26badafb127bd4c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/tinker/zipball/3bcb5f62d6f837e0f093a601e26badafb127bd4c", + "reference": "3bcb5f62d6f837e0f093a601e26badafb127bd4c", + "shasum": "" + }, + "require": { + "illuminate/console": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", + "illuminate/contracts": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", + "illuminate/support": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", + "php": "^7.2.5|^8.0", + "psy/psysh": "^0.11.1|^0.12.0", + "symfony/var-dumper": "^4.3.4|^5.0|^6.0|^7.0" + }, + "require-dev": { + "mockery/mockery": "~1.3.3|^1.4.2", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^8.5.8|^9.3.3|^10.0" + }, + "suggest": { + "illuminate/database": "The Illuminate Database package (^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0)." + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Tinker\\TinkerServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Tinker\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Powerful REPL for the Laravel framework.", + "keywords": [ + "REPL", + "Tinker", + "laravel", + "psysh" + ], + "support": { + "issues": "https://github.com/laravel/tinker/issues", + "source": "https://github.com/laravel/tinker/tree/v2.10.2" + }, + "time": "2025-11-20T16:29:12+00:00" + }, + { + "name": "league/commonmark", + "version": "2.8.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/commonmark.git", + "reference": "4efa10c1e56488e658d10adf7b7b7dcd19940bfb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/4efa10c1e56488e658d10adf7b7b7dcd19940bfb", + "reference": "4efa10c1e56488e658d10adf7b7b7dcd19940bfb", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "league/config": "^1.1.1", + "php": "^7.4 || ^8.0", + "psr/event-dispatcher": "^1.0", + "symfony/deprecation-contracts": "^2.1 || ^3.0", + "symfony/polyfill-php80": "^1.16" + }, + "require-dev": { + "cebe/markdown": "^1.0", + "commonmark/cmark": "0.31.1", + "commonmark/commonmark.js": "0.31.1", + "composer/package-versions-deprecated": "^1.8", + "embed/embed": "^4.4", + "erusev/parsedown": "^1.0", + "ext-json": "*", + "github/gfm": "0.29.0", + "michelf/php-markdown": "^1.4 || ^2.0", + "nyholm/psr7": "^1.5", + "phpstan/phpstan": "^1.8.2", + "phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0", + "scrutinizer/ocular": "^1.8.1", + "symfony/finder": "^5.3 | ^6.0 | ^7.0", + "symfony/process": "^5.4 | ^6.0 | ^7.0", + "symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0", + "unleashedtech/php-coding-standard": "^3.1.1", + "vimeo/psalm": "^4.24.0 || ^5.0.0 || ^6.0.0" + }, + "suggest": { + "symfony/yaml": "v2.3+ required if using the Front Matter extension" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.9-dev" + } + }, + "autoload": { + "psr-4": { + "League\\CommonMark\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com", + "role": "Lead Developer" + } + ], + "description": "Highly-extensible PHP Markdown parser which fully supports the CommonMark spec and GitHub-Flavored Markdown (GFM)", + "homepage": "https://commonmark.thephpleague.com", + "keywords": [ + "commonmark", + "flavored", + "gfm", + "github", + "github-flavored", + "markdown", + "md", + "parser" + ], + "support": { + "docs": "https://commonmark.thephpleague.com/", + "forum": "https://github.com/thephpleague/commonmark/discussions", + "issues": "https://github.com/thephpleague/commonmark/issues", + "rss": "https://github.com/thephpleague/commonmark/releases.atom", + "source": "https://github.com/thephpleague/commonmark" + }, + "funding": [ + { + "url": "https://www.colinodell.com/sponsor", + "type": "custom" + }, + { + "url": "https://www.paypal.me/colinpodell/10.00", + "type": "custom" + }, + { + "url": "https://github.com/colinodell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/league/commonmark", + "type": "tidelift" + } + ], + "time": "2025-11-26T21:48:24+00:00" + }, + { + "name": "league/config", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/config.git", + "reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/config/zipball/754b3604fb2984c71f4af4a9cbe7b57f346ec1f3", + "reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3", + "shasum": "" + }, + "require": { + "dflydev/dot-access-data": "^3.0.1", + "nette/schema": "^1.2", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.8.2", + "phpunit/phpunit": "^9.5.5", + "scrutinizer/ocular": "^1.8.1", + "unleashedtech/php-coding-standard": "^3.1", + "vimeo/psalm": "^4.7.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.2-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Config\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com", + "role": "Lead Developer" + } + ], + "description": "Define configuration arrays with strict schemas and access values with dot notation", + "homepage": "https://config.thephpleague.com", + "keywords": [ + "array", + "config", + "configuration", + "dot", + "dot-access", + "nested", + "schema" + ], + "support": { + "docs": "https://config.thephpleague.com/", + "issues": "https://github.com/thephpleague/config/issues", + "rss": "https://github.com/thephpleague/config/releases.atom", + "source": "https://github.com/thephpleague/config" + }, + "funding": [ + { + "url": "https://www.colinodell.com/sponsor", + "type": "custom" + }, + { + "url": "https://www.paypal.me/colinpodell/10.00", + "type": "custom" + }, + { + "url": "https://github.com/colinodell", + "type": "github" + } + ], + "time": "2022-12-11T20:36:23+00:00" + }, + { + "name": "league/flysystem", + "version": "3.30.2", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem.git", + "reference": "5966a8ba23e62bdb518dd9e0e665c2dbd4b5b277" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/5966a8ba23e62bdb518dd9e0e665c2dbd4b5b277", + "reference": "5966a8ba23e62bdb518dd9e0e665c2dbd4b5b277", + "shasum": "" + }, + "require": { + "league/flysystem-local": "^3.0.0", + "league/mime-type-detection": "^1.0.0", + "php": "^8.0.2" + }, + "conflict": { + "async-aws/core": "<1.19.0", + "async-aws/s3": "<1.14.0", + "aws/aws-sdk-php": "3.209.31 || 3.210.0", + "guzzlehttp/guzzle": "<7.0", + "guzzlehttp/ringphp": "<1.1.1", + "phpseclib/phpseclib": "3.0.15", + "symfony/http-client": "<5.2" + }, + "require-dev": { + "async-aws/s3": "^1.5 || ^2.0", + "async-aws/simple-s3": "^1.1 || ^2.0", + "aws/aws-sdk-php": "^3.295.10", + "composer/semver": "^3.0", + "ext-fileinfo": "*", + "ext-ftp": "*", + "ext-mongodb": "^1.3|^2", + "ext-zip": "*", + "friendsofphp/php-cs-fixer": "^3.5", + "google/cloud-storage": "^1.23", + "guzzlehttp/psr7": "^2.6", + "microsoft/azure-storage-blob": "^1.1", + "mongodb/mongodb": "^1.2|^2", + "phpseclib/phpseclib": "^3.0.36", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^9.5.11|^10.0", + "sabre/dav": "^4.6.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\Flysystem\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "File storage abstraction for PHP", + "keywords": [ + "WebDAV", + "aws", + "cloud", + "file", + "files", + "filesystem", + "filesystems", + "ftp", + "s3", + "sftp", + "storage" + ], + "support": { + "issues": "https://github.com/thephpleague/flysystem/issues", + "source": "https://github.com/thephpleague/flysystem/tree/3.30.2" + }, + "time": "2025-11-10T17:13:11+00:00" + }, + { + "name": "league/flysystem-local", + "version": "3.30.2", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem-local.git", + "reference": "ab4f9d0d672f601b102936aa728801dd1a11968d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/ab4f9d0d672f601b102936aa728801dd1a11968d", + "reference": "ab4f9d0d672f601b102936aa728801dd1a11968d", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "league/flysystem": "^3.0.0", + "league/mime-type-detection": "^1.0.0", + "php": "^8.0.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\Flysystem\\Local\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "Local filesystem adapter for Flysystem.", + "keywords": [ + "Flysystem", + "file", + "files", + "filesystem", + "local" + ], + "support": { + "source": "https://github.com/thephpleague/flysystem-local/tree/3.30.2" + }, + "time": "2025-11-10T11:23:37+00:00" + }, + { + "name": "league/mime-type-detection", + "version": "1.16.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/mime-type-detection.git", + "reference": "2d6702ff215bf922936ccc1ad31007edc76451b9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/2d6702ff215bf922936ccc1ad31007edc76451b9", + "reference": "2d6702ff215bf922936ccc1ad31007edc76451b9", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.2", + "phpstan/phpstan": "^0.12.68", + "phpunit/phpunit": "^8.5.8 || ^9.3 || ^10.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\MimeTypeDetection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "Mime-type detection for Flysystem", + "support": { + "issues": "https://github.com/thephpleague/mime-type-detection/issues", + "source": "https://github.com/thephpleague/mime-type-detection/tree/1.16.0" + }, + "funding": [ + { + "url": "https://github.com/frankdejonge", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/league/flysystem", + "type": "tidelift" + } + ], + "time": "2024-09-21T08:32:55+00:00" + }, + { + "name": "league/uri", + "version": "7.6.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri.git", + "reference": "f625804987a0a9112d954f9209d91fec52182344" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri/zipball/f625804987a0a9112d954f9209d91fec52182344", + "reference": "f625804987a0a9112d954f9209d91fec52182344", + "shasum": "" + }, + "require": { + "league/uri-interfaces": "^7.6", + "php": "^8.1", + "psr/http-factory": "^1" + }, + "conflict": { + "league/uri-schemes": "^1.0" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-dom": "to convert the URI into an HTML anchor tag", + "ext-fileinfo": "to create Data URI from file contennts", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "ext-uri": "to use the PHP native URI class", + "jeremykendall/php-domain-parser": "to resolve Public Suffix and Top Level Domain", + "league/uri-components": "Needed to easily manipulate URI objects components", + "league/uri-polyfill": "Needed to backport the PHP URI extension for older versions of PHP", + "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle WHATWG URL", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "URI manipulation library", + "homepage": "https://uri.thephpleague.com", + "keywords": [ + "URN", + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "middleware", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc2141", + "rfc3986", + "rfc3987", + "rfc6570", + "rfc8141", + "uri", + "uri-template", + "url", + "ws" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri/tree/7.6.0" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2025-11-18T12:17:23+00:00" + }, + { + "name": "league/uri-interfaces", + "version": "7.6.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri-interfaces.git", + "reference": "ccbfb51c0445298e7e0b7f4481b942f589665368" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/ccbfb51c0445298e7e0b7f4481b942f589665368", + "reference": "ccbfb51c0445298e7e0b7f4481b942f589665368", + "shasum": "" + }, + "require": { + "ext-filter": "*", + "php": "^8.1", + "psr/http-message": "^1.1 || ^2.0" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle WHATWG URL", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "Common tools for parsing and resolving RFC3987/RFC3986 URI", + "homepage": "https://uri.thephpleague.com", + "keywords": [ + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc3986", + "rfc3987", + "rfc6570", + "uri", + "url", + "ws" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri-interfaces/tree/7.6.0" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2025-11-18T12:17:23+00:00" + }, + { + "name": "maennchen/zipstream-php", + "version": "3.2.0", + "source": { + "type": "git", + "url": "https://github.com/maennchen/ZipStream-PHP.git", + "reference": "9712d8fa4cdf9240380b01eb4be55ad8dcf71416" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/9712d8fa4cdf9240380b01eb4be55ad8dcf71416", + "reference": "9712d8fa4cdf9240380b01eb4be55ad8dcf71416", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "ext-zlib": "*", + "php-64bit": "^8.3" + }, + "require-dev": { + "brianium/paratest": "^7.7", + "ext-zip": "*", + "friendsofphp/php-cs-fixer": "^3.16", + "guzzlehttp/guzzle": "^7.5", + "mikey179/vfsstream": "^1.6", + "php-coveralls/php-coveralls": "^2.5", + "phpunit/phpunit": "^12.0", + "vimeo/psalm": "^6.0" + }, + "suggest": { + "guzzlehttp/psr7": "^2.4", + "psr/http-message": "^2.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "ZipStream\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paul Duncan", + "email": "pabs@pablotron.org" + }, + { + "name": "Jonatan Männchen", + "email": "jonatan@maennchen.ch" + }, + { + "name": "Jesse Donat", + "email": "donatj@gmail.com" + }, + { + "name": "András Kolesár", + "email": "kolesar@kolesar.hu" + } + ], + "description": "ZipStream is a library for dynamically streaming dynamic zip files from PHP without writing to the disk at all on the server.", + "keywords": [ + "stream", + "zip" + ], + "support": { + "issues": "https://github.com/maennchen/ZipStream-PHP/issues", + "source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.0" + }, + "funding": [ + { + "url": "https://github.com/maennchen", + "type": "github" + } + ], + "time": "2025-07-17T11:15:13+00:00" + }, + { + "name": "markbaker/complex", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/MarkBaker/PHPComplex.git", + "reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/MarkBaker/PHPComplex/zipball/95c56caa1cf5c766ad6d65b6344b807c1e8405b9", + "reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "dev-master", + "phpcompatibility/php-compatibility": "^9.3", + "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0", + "squizlabs/php_codesniffer": "^3.7" + }, + "type": "library", + "autoload": { + "psr-4": { + "Complex\\": "classes/src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mark Baker", + "email": "mark@lange.demon.co.uk" + } + ], + "description": "PHP Class for working with complex numbers", + "homepage": "https://github.com/MarkBaker/PHPComplex", + "keywords": [ + "complex", + "mathematics" + ], + "support": { + "issues": "https://github.com/MarkBaker/PHPComplex/issues", + "source": "https://github.com/MarkBaker/PHPComplex/tree/3.0.2" + }, + "time": "2022-12-06T16:21:08+00:00" + }, + { + "name": "markbaker/matrix", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/MarkBaker/PHPMatrix.git", + "reference": "728434227fe21be27ff6d86621a1b13107a2562c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/MarkBaker/PHPMatrix/zipball/728434227fe21be27ff6d86621a1b13107a2562c", + "reference": "728434227fe21be27ff6d86621a1b13107a2562c", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "dev-master", + "phpcompatibility/php-compatibility": "^9.3", + "phpdocumentor/phpdocumentor": "2.*", + "phploc/phploc": "^4.0", + "phpmd/phpmd": "2.*", + "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0", + "sebastian/phpcpd": "^4.0", + "squizlabs/php_codesniffer": "^3.7" + }, + "type": "library", + "autoload": { + "psr-4": { + "Matrix\\": "classes/src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mark Baker", + "email": "mark@demon-angel.eu" + } + ], + "description": "PHP Class for working with matrices", + "homepage": "https://github.com/MarkBaker/PHPMatrix", + "keywords": [ + "mathematics", + "matrix", + "vector" + ], + "support": { + "issues": "https://github.com/MarkBaker/PHPMatrix/issues", + "source": "https://github.com/MarkBaker/PHPMatrix/tree/3.0.1" + }, + "time": "2022-12-02T22:17:43+00:00" + }, + { + "name": "monolog/monolog", + "version": "3.9.0", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/monolog.git", + "reference": "10d85740180ecba7896c87e06a166e0c95a0e3b6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/10d85740180ecba7896c87e06a166e0c95a0e3b6", + "reference": "10d85740180ecba7896c87e06a166e0c95a0e3b6", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/log": "^2.0 || ^3.0" + }, + "provide": { + "psr/log-implementation": "3.0.0" + }, + "require-dev": { + "aws/aws-sdk-php": "^3.0", + "doctrine/couchdb": "~1.0@dev", + "elasticsearch/elasticsearch": "^7 || ^8", + "ext-json": "*", + "graylog2/gelf-php": "^1.4.2 || ^2.0", + "guzzlehttp/guzzle": "^7.4.5", + "guzzlehttp/psr7": "^2.2", + "mongodb/mongodb": "^1.8", + "php-amqplib/php-amqplib": "~2.4 || ^3", + "php-console/php-console": "^3.1.8", + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^10.5.17 || ^11.0.7", + "predis/predis": "^1.1 || ^2", + "rollbar/rollbar": "^4.0", + "ruflin/elastica": "^7 || ^8", + "symfony/mailer": "^5.4 || ^6", + "symfony/mime": "^5.4 || ^6" + }, + "suggest": { + "aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB", + "doctrine/couchdb": "Allow sending log messages to a CouchDB server", + "elasticsearch/elasticsearch": "Allow sending log messages to an Elasticsearch server via official client", + "ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)", + "ext-curl": "Required to send log messages using the IFTTTHandler, the LogglyHandler, the SendGridHandler, the SlackWebhookHandler or the TelegramBotHandler", + "ext-mbstring": "Allow to work properly with unicode symbols", + "ext-mongodb": "Allow sending log messages to a MongoDB server (via driver)", + "ext-openssl": "Required to send log messages using SSL", + "ext-sockets": "Allow sending log messages to a Syslog server (via UDP driver)", + "graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server", + "mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)", + "php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib", + "rollbar/rollbar": "Allow sending log messages to Rollbar", + "ruflin/elastica": "Allow sending log messages to an Elastic Search server" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Monolog\\": "src/Monolog" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "https://seld.be" + } + ], + "description": "Sends your logs to files, sockets, inboxes, databases and various web services", + "homepage": "https://github.com/Seldaek/monolog", + "keywords": [ + "log", + "logging", + "psr-3" + ], + "support": { + "issues": "https://github.com/Seldaek/monolog/issues", + "source": "https://github.com/Seldaek/monolog/tree/3.9.0" + }, + "funding": [ + { + "url": "https://github.com/Seldaek", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/monolog/monolog", + "type": "tidelift" + } + ], + "time": "2025-03-24T10:02:05+00:00" + }, + { + "name": "nesbot/carbon", + "version": "3.11.0", + "source": { + "type": "git", + "url": "https://github.com/CarbonPHP/carbon.git", + "reference": "bdb375400dcd162624531666db4799b36b64e4a1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/bdb375400dcd162624531666db4799b36b64e4a1", + "reference": "bdb375400dcd162624531666db4799b36b64e4a1", + "shasum": "" + }, + "require": { + "carbonphp/carbon-doctrine-types": "<100.0", + "ext-json": "*", + "php": "^8.1", + "psr/clock": "^1.0", + "symfony/clock": "^6.3.12 || ^7.0 || ^8.0", + "symfony/polyfill-mbstring": "^1.0", + "symfony/translation": "^4.4.18 || ^5.2.1 || ^6.0 || ^7.0 || ^8.0" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "require-dev": { + "doctrine/dbal": "^3.6.3 || ^4.0", + "doctrine/orm": "^2.15.2 || ^3.0", + "friendsofphp/php-cs-fixer": "^v3.87.1", + "kylekatarnls/multi-tester": "^2.5.3", + "phpmd/phpmd": "^2.15.0", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.1.22", + "phpunit/phpunit": "^10.5.53", + "squizlabs/php_codesniffer": "^3.13.4" + }, + "bin": [ + "bin/carbon" + ], + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Carbon\\Laravel\\ServiceProvider" + ] + }, + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-2.x": "2.x-dev", + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Carbon\\": "src/Carbon/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Brian Nesbitt", + "email": "brian@nesbot.com", + "homepage": "https://markido.com" + }, + { + "name": "kylekatarnls", + "homepage": "https://github.com/kylekatarnls" + } + ], + "description": "An API extension for DateTime that supports 281 different languages.", + "homepage": "https://carbon.nesbot.com", + "keywords": [ + "date", + "datetime", + "time" + ], + "support": { + "docs": "https://carbon.nesbot.com/docs", + "issues": "https://github.com/CarbonPHP/carbon/issues", + "source": "https://github.com/CarbonPHP/carbon" + }, + "funding": [ + { + "url": "https://github.com/sponsors/kylekatarnls", + "type": "github" + }, + { + "url": "https://opencollective.com/Carbon#sponsor", + "type": "opencollective" + }, + { + "url": "https://tidelift.com/subscription/pkg/packagist-nesbot-carbon?utm_source=packagist-nesbot-carbon&utm_medium=referral&utm_campaign=readme", + "type": "tidelift" + } + ], + "time": "2025-12-02T21:04:28+00:00" + }, + { + "name": "nette/schema", + "version": "v1.3.3", + "source": { + "type": "git", + "url": "https://github.com/nette/schema.git", + "reference": "2befc2f42d7c715fd9d95efc31b1081e5d765004" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nette/schema/zipball/2befc2f42d7c715fd9d95efc31b1081e5d765004", + "reference": "2befc2f42d7c715fd9d95efc31b1081e5d765004", + "shasum": "" + }, + "require": { + "nette/utils": "^4.0", + "php": "8.1 - 8.5" + }, + "require-dev": { + "nette/tester": "^2.5.2", + "phpstan/phpstan-nette": "^2.0@stable", + "tracy/tracy": "^2.8" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.3-dev" + } + }, + "autoload": { + "psr-4": { + "Nette\\": "src" + }, + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause", + "GPL-2.0-only", + "GPL-3.0-only" + ], + "authors": [ + { + "name": "David Grudl", + "homepage": "https://davidgrudl.com" + }, + { + "name": "Nette Community", + "homepage": "https://nette.org/contributors" + } + ], + "description": "📐 Nette Schema: validating data structures against a given Schema.", + "homepage": "https://nette.org", + "keywords": [ + "config", + "nette" + ], + "support": { + "issues": "https://github.com/nette/schema/issues", + "source": "https://github.com/nette/schema/tree/v1.3.3" + }, + "time": "2025-10-30T22:57:59+00:00" + }, + { + "name": "nette/utils", + "version": "v4.1.0", + "source": { + "type": "git", + "url": "https://github.com/nette/utils.git", + "reference": "fa1f0b8261ed150447979eb22e373b7b7ad5a8e0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nette/utils/zipball/fa1f0b8261ed150447979eb22e373b7b7ad5a8e0", + "reference": "fa1f0b8261ed150447979eb22e373b7b7ad5a8e0", + "shasum": "" + }, + "require": { + "php": "8.2 - 8.5" + }, + "conflict": { + "nette/finder": "<3", + "nette/schema": "<1.2.2" + }, + "require-dev": { + "jetbrains/phpstorm-attributes": "^1.2", + "nette/tester": "^2.5", + "phpstan/phpstan-nette": "^2.0@stable", + "tracy/tracy": "^2.9" + }, + "suggest": { + "ext-gd": "to use Image", + "ext-iconv": "to use Strings::webalize(), toAscii(), chr() and reverse()", + "ext-intl": "to use Strings::webalize(), toAscii(), normalize() and compare()", + "ext-json": "to use Nette\\Utils\\Json", + "ext-mbstring": "to use Strings::lower() etc...", + "ext-tokenizer": "to use Nette\\Utils\\Reflection::getUseStatements()" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.1-dev" + } + }, + "autoload": { + "psr-4": { + "Nette\\": "src" + }, + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause", + "GPL-2.0-only", + "GPL-3.0-only" + ], + "authors": [ + { + "name": "David Grudl", + "homepage": "https://davidgrudl.com" + }, + { + "name": "Nette Community", + "homepage": "https://nette.org/contributors" + } + ], + "description": "🛠 Nette Utils: lightweight utilities for string & array manipulation, image handling, safe JSON encoding/decoding, validation, slug or strong password generating etc.", + "homepage": "https://nette.org", + "keywords": [ + "array", + "core", + "datetime", + "images", + "json", + "nette", + "paginator", + "password", + "slugify", + "string", + "unicode", + "utf-8", + "utility", + "validation" + ], + "support": { + "issues": "https://github.com/nette/utils/issues", + "source": "https://github.com/nette/utils/tree/v4.1.0" + }, + "time": "2025-12-01T17:49:23+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.7.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" + }, + "time": "2025-12-06T11:56:16+00:00" + }, + { + "name": "nunomaduro/termwind", + "version": "v2.3.3", + "source": { + "type": "git", + "url": "https://github.com/nunomaduro/termwind.git", + "reference": "6fb2a640ff502caace8e05fd7be3b503a7e1c017" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/6fb2a640ff502caace8e05fd7be3b503a7e1c017", + "reference": "6fb2a640ff502caace8e05fd7be3b503a7e1c017", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^8.2", + "symfony/console": "^7.3.6" + }, + "require-dev": { + "illuminate/console": "^11.46.1", + "laravel/pint": "^1.25.1", + "mockery/mockery": "^1.6.12", + "pestphp/pest": "^2.36.0 || ^3.8.4 || ^4.1.3", + "phpstan/phpstan": "^1.12.32", + "phpstan/phpstan-strict-rules": "^1.6.2", + "symfony/var-dumper": "^7.3.5", + "thecodingmachine/phpstan-strict-rules": "^1.0.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Termwind\\Laravel\\TermwindServiceProvider" + ] + }, + "branch-alias": { + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "files": [ + "src/Functions.php" + ], + "psr-4": { + "Termwind\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "Its like Tailwind CSS, but for the console.", + "keywords": [ + "cli", + "console", + "css", + "package", + "php", + "style" + ], + "support": { + "issues": "https://github.com/nunomaduro/termwind/issues", + "source": "https://github.com/nunomaduro/termwind/tree/v2.3.3" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + }, + { + "url": "https://github.com/xiCO2k", + "type": "github" + } + ], + "time": "2025-11-20T02:34:59+00:00" + }, + { + "name": "phpoffice/phpspreadsheet", + "version": "5.3.0", + "source": { + "type": "git", + "url": "https://github.com/PHPOffice/PhpSpreadsheet.git", + "reference": "4d597c1aacdde1805a33c525b9758113ea0d90df" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/4d597c1aacdde1805a33c525b9758113ea0d90df", + "reference": "4d597c1aacdde1805a33c525b9758113ea0d90df", + "shasum": "" + }, + "require": { + "composer/pcre": "^1||^2||^3", + "ext-ctype": "*", + "ext-dom": "*", + "ext-fileinfo": "*", + "ext-gd": "*", + "ext-iconv": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-simplexml": "*", + "ext-xml": "*", + "ext-xmlreader": "*", + "ext-xmlwriter": "*", + "ext-zip": "*", + "ext-zlib": "*", + "maennchen/zipstream-php": "^2.1 || ^3.0", + "markbaker/complex": "^3.0", + "markbaker/matrix": "^3.0", + "php": "^8.1", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0", + "psr/simple-cache": "^1.0 || ^2.0 || ^3.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "dev-main", + "dompdf/dompdf": "^2.0 || ^3.0", + "friendsofphp/php-cs-fixer": "^3.2", + "mitoteam/jpgraph": "^10.5", + "mpdf/mpdf": "^8.1.1", + "phpcompatibility/php-compatibility": "^9.3", + "phpstan/phpstan": "^1.1 || ^2.0", + "phpstan/phpstan-deprecation-rules": "^1.0 || ^2.0", + "phpstan/phpstan-phpunit": "^1.0 || ^2.0", + "phpunit/phpunit": "^10.5", + "squizlabs/php_codesniffer": "^3.7", + "tecnickcom/tcpdf": "^6.5" + }, + "suggest": { + "dompdf/dompdf": "Option for rendering PDF with PDF Writer", + "ext-intl": "PHP Internationalization Functions, required for NumberFormat Wizard", + "mitoteam/jpgraph": "Option for rendering charts, or including charts with PDF or HTML Writers", + "mpdf/mpdf": "Option for rendering PDF with PDF Writer", + "tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer" + }, + "type": "library", + "autoload": { + "psr-4": { + "PhpOffice\\PhpSpreadsheet\\": "src/PhpSpreadsheet" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Maarten Balliauw", + "homepage": "https://blog.maartenballiauw.be" + }, + { + "name": "Mark Baker", + "homepage": "https://markbakeruk.net" + }, + { + "name": "Franck Lefevre", + "homepage": "https://rootslabs.net" + }, + { + "name": "Erik Tilt" + }, + { + "name": "Adrien Crivelli" + } + ], + "description": "PHPSpreadsheet - Read, Create and Write Spreadsheet documents in PHP - Spreadsheet engine", + "homepage": "https://github.com/PHPOffice/PhpSpreadsheet", + "keywords": [ + "OpenXML", + "excel", + "gnumeric", + "ods", + "php", + "spreadsheet", + "xls", + "xlsx" + ], + "support": { + "issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues", + "source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/5.3.0" + }, + "time": "2025-11-24T15:47:10+00:00" + }, + { + "name": "phpoption/phpoption", + "version": "1.9.4", + "source": { + "type": "git", + "url": "https://github.com/schmittjoh/php-option.git", + "reference": "638a154f8d4ee6a5cfa96d6a34dfbe0cffa9566d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/638a154f8d4ee6a5cfa96d6a34dfbe0cffa9566d", + "reference": "638a154f8d4ee6a5cfa96d6a34dfbe0cffa9566d", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.44 || ^9.6.25 || ^10.5.53 || ^11.5.34" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + }, + "branch-alias": { + "dev-master": "1.9-dev" + } + }, + "autoload": { + "psr-4": { + "PhpOption\\": "src/PhpOption/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Johannes M. Schmitt", + "email": "schmittjoh@gmail.com", + "homepage": "https://github.com/schmittjoh" + }, + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + } + ], + "description": "Option Type for PHP", + "keywords": [ + "language", + "option", + "php", + "type" + ], + "support": { + "issues": "https://github.com/schmittjoh/php-option/issues", + "source": "https://github.com/schmittjoh/php-option/tree/1.9.4" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpoption/phpoption", + "type": "tidelift" + } + ], + "time": "2025-08-21T11:53:16+00:00" + }, + { + "name": "psr/clock", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/clock.git", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Psr\\Clock\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for reading the clock.", + "homepage": "https://github.com/php-fig/clock", + "keywords": [ + "clock", + "now", + "psr", + "psr-20", + "time" + ], + "support": { + "issues": "https://github.com/php-fig/clock/issues", + "source": "https://github.com/php-fig/clock/tree/1.0.0" + }, + "time": "2022-11-25T14:36:26+00:00" + }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "psr/event-dispatcher", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/event-dispatcher.git", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "shasum": "" + }, + "require": { + "php": ">=7.2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\EventDispatcher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Standard interfaces for event handling.", + "keywords": [ + "events", + "psr", + "psr-14" + ], + "support": { + "issues": "https://github.com/php-fig/event-dispatcher/issues", + "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + }, + "time": "2019-01-08T18:20:26+00:00" + }, + { + "name": "psr/http-client", + "version": "1.0.3", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-client.git", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP clients", + "homepage": "https://github.com/php-fig/http-client", + "keywords": [ + "http", + "http-client", + "psr", + "psr-18" + ], + "support": { + "source": "https://github.com/php-fig/http-client" + }, + "time": "2023-09-23T14:17:50+00:00" + }, + { + "name": "psr/http-factory", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory" + }, + "time": "2024-04-15T12:06:14+00:00" + }, + { + "name": "psr/http-message", + "version": "2.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/2.0" + }, + "time": "2023-04-04T09:54:51+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "psr/simple-cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/simple-cache.git", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\SimpleCache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interfaces for simple caching", + "keywords": [ + "cache", + "caching", + "psr", + "psr-16", + "simple-cache" + ], + "support": { + "source": "https://github.com/php-fig/simple-cache/tree/3.0.0" + }, + "time": "2021-10-29T13:26:27+00:00" + }, + { + "name": "psy/psysh", + "version": "v0.12.16", + "source": { + "type": "git", + "url": "https://github.com/bobthecow/psysh.git", + "reference": "ee6d5028be4774f56c6c2c85ec4e6bc9acfe6b67" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/ee6d5028be4774f56c6c2c85ec4e6bc9acfe6b67", + "reference": "ee6d5028be4774f56c6c2c85ec4e6bc9acfe6b67", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-tokenizer": "*", + "nikic/php-parser": "^5.0 || ^4.0", + "php": "^8.0 || ^7.4", + "symfony/console": "^8.0 || ^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4", + "symfony/var-dumper": "^8.0 || ^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4" + }, + "conflict": { + "symfony/console": "4.4.37 || 5.3.14 || 5.3.15 || 5.4.3 || 5.4.4 || 6.0.3 || 6.0.4" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.2", + "composer/class-map-generator": "^1.6" + }, + "suggest": { + "composer/class-map-generator": "Improved tab completion performance with better class discovery.", + "ext-pcntl": "Enabling the PCNTL extension makes PsySH a lot happier :)", + "ext-posix": "If you have PCNTL, you'll want the POSIX extension as well." + }, + "bin": [ + "bin/psysh" + ], + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": false, + "forward-command": false + }, + "branch-alias": { + "dev-main": "0.12.x-dev" + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Psy\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Justin Hileman", + "email": "justin@justinhileman.info" + } + ], + "description": "An interactive shell for modern PHP.", + "homepage": "https://psysh.org", + "keywords": [ + "REPL", + "console", + "interactive", + "shell" + ], + "support": { + "issues": "https://github.com/bobthecow/psysh/issues", + "source": "https://github.com/bobthecow/psysh/tree/v0.12.16" + }, + "time": "2025-12-07T03:39:01+00:00" + }, + { + "name": "ralouphie/getallheaders", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/ralouphie/getallheaders.git", + "reference": "120b605dfeb996808c31b6477290a714d356e822" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", + "reference": "120b605dfeb996808c31b6477290a714d356e822", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.1", + "phpunit/phpunit": "^5 || ^6.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/getallheaders.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ralph Khattar", + "email": "ralph.khattar@gmail.com" + } + ], + "description": "A polyfill for getallheaders.", + "support": { + "issues": "https://github.com/ralouphie/getallheaders/issues", + "source": "https://github.com/ralouphie/getallheaders/tree/develop" + }, + "time": "2019-03-08T08:55:37+00:00" + }, + { + "name": "ramsey/collection", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/ramsey/collection.git", + "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ramsey/collection/zipball/344572933ad0181accbf4ba763e85a0306a8c5e2", + "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "captainhook/plugin-composer": "^5.3", + "ergebnis/composer-normalize": "^2.45", + "fakerphp/faker": "^1.24", + "hamcrest/hamcrest-php": "^2.0", + "jangregor/phpstan-prophecy": "^2.1", + "mockery/mockery": "^1.6", + "php-parallel-lint/php-console-highlighter": "^1.0", + "php-parallel-lint/php-parallel-lint": "^1.4", + "phpspec/prophecy-phpunit": "^2.3", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-mockery": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^10.5", + "ramsey/coding-standard": "^2.3", + "ramsey/conventional-commits": "^1.6", + "roave/security-advisories": "dev-latest" + }, + "type": "library", + "extra": { + "captainhook": { + "force-install": true + }, + "ramsey/conventional-commits": { + "configFile": "conventional-commits.json" + } + }, + "autoload": { + "psr-4": { + "Ramsey\\Collection\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ben Ramsey", + "email": "ben@benramsey.com", + "homepage": "https://benramsey.com" + } + ], + "description": "A PHP library for representing and manipulating collections.", + "keywords": [ + "array", + "collection", + "hash", + "map", + "queue", + "set" + ], + "support": { + "issues": "https://github.com/ramsey/collection/issues", + "source": "https://github.com/ramsey/collection/tree/2.1.1" + }, + "time": "2025-03-22T05:38:12+00:00" + }, + { + "name": "ramsey/uuid", + "version": "4.9.1", + "source": { + "type": "git", + "url": "https://github.com/ramsey/uuid.git", + "reference": "81f941f6f729b1e3ceea61d9d014f8b6c6800440" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/81f941f6f729b1e3ceea61d9d014f8b6c6800440", + "reference": "81f941f6f729b1e3ceea61d9d014f8b6c6800440", + "shasum": "" + }, + "require": { + "brick/math": "^0.8.8 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14", + "php": "^8.0", + "ramsey/collection": "^1.2 || ^2.0" + }, + "replace": { + "rhumsaa/uuid": "self.version" + }, + "require-dev": { + "captainhook/captainhook": "^5.25", + "captainhook/plugin-composer": "^5.3", + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "ergebnis/composer-normalize": "^2.47", + "mockery/mockery": "^1.6", + "paragonie/random-lib": "^2", + "php-mock/php-mock": "^2.6", + "php-mock/php-mock-mockery": "^1.5", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpbench/phpbench": "^1.2.14", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-mockery": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^9.6", + "slevomat/coding-standard": "^8.18", + "squizlabs/php_codesniffer": "^3.13" + }, + "suggest": { + "ext-bcmath": "Enables faster math with arbitrary-precision integers using BCMath.", + "ext-gmp": "Enables faster math with arbitrary-precision integers using GMP.", + "ext-uuid": "Enables the use of PeclUuidTimeGenerator and PeclUuidRandomGenerator.", + "paragonie/random-lib": "Provides RandomLib for use with the RandomLibAdapter", + "ramsey/uuid-doctrine": "Allows the use of Ramsey\\Uuid\\Uuid as Doctrine field type." + }, + "type": "library", + "extra": { + "captainhook": { + "force-install": true + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Ramsey\\Uuid\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A PHP library for generating and working with universally unique identifiers (UUIDs).", + "keywords": [ + "guid", + "identifier", + "uuid" + ], + "support": { + "issues": "https://github.com/ramsey/uuid/issues", + "source": "https://github.com/ramsey/uuid/tree/4.9.1" + }, + "time": "2025-09-04T20:59:21+00:00" + }, + { + "name": "symfony/clock", + "version": "v7.4.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/clock.git", + "reference": "9169f24776edde469914c1e7a1442a50f7a4e110" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/clock/zipball/9169f24776edde469914c1e7a1442a50f7a4e110", + "reference": "9169f24776edde469914c1e7a1442a50f7a4e110", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/clock": "^1.0", + "symfony/polyfill-php83": "^1.28" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/now.php" + ], + "psr-4": { + "Symfony\\Component\\Clock\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Decouples applications from the system clock", + "homepage": "https://symfony.com", + "keywords": [ + "clock", + "psr20", + "time" + ], + "support": { + "source": "https://github.com/symfony/clock/tree/v7.4.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-11-12T15:39:26+00:00" + }, + { + "name": "symfony/console", + "version": "v7.4.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "6d9f0fbf2ec2e9785880096e3abd0ca0c88b506e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/6d9f0fbf2ec2e9785880096e3abd0ca0c88b506e", + "reference": "6d9f0fbf2ec2e9785880096e3abd0ca0c88b506e", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/string": "^7.2|^8.0" + }, + "conflict": { + "symfony/dependency-injection": "<6.4", + "symfony/dotenv": "<6.4", + "symfony/event-dispatcher": "<6.4", + "symfony/lock": "<6.4", + "symfony/process": "<6.4" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/lock": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Console\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases the creation of beautiful and testable command line interfaces", + "homepage": "https://symfony.com", + "keywords": [ + "cli", + "command-line", + "console", + "terminal" + ], + "support": { + "source": "https://github.com/symfony/console/tree/v7.4.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-12-05T15:23:39+00:00" + }, + { + "name": "symfony/css-selector", + "version": "v7.4.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/css-selector.git", + "reference": "ab862f478513e7ca2fe9ec117a6f01a8da6e1135" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/ab862f478513e7ca2fe9ec117a6f01a8da6e1135", + "reference": "ab862f478513e7ca2fe9ec117a6f01a8da6e1135", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\CssSelector\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Jean-François Simon", + "email": "jeanfrancois.simon@sensiolabs.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Converts CSS selectors to XPath expressions", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/css-selector/tree/v7.4.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-10-30T13:39:42+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-25T14:21:43+00:00" + }, + { + "name": "symfony/error-handler", + "version": "v7.4.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/error-handler.git", + "reference": "48be2b0653594eea32dcef130cca1c811dcf25c2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/48be2b0653594eea32dcef130cca1c811dcf25c2", + "reference": "48be2b0653594eea32dcef130cca1c811dcf25c2", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/polyfill-php85": "^1.32", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "conflict": { + "symfony/deprecation-contracts": "<2.5", + "symfony/http-kernel": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0", + "symfony/webpack-encore-bundle": "^1.0|^2.0" + }, + "bin": [ + "Resources/bin/patch-type-declarations" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\ErrorHandler\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to manage errors and ease debugging PHP code", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/error-handler/tree/v7.4.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-11-05T14:29:59+00:00" + }, + { + "name": "symfony/event-dispatcher", + "version": "v7.4.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher.git", + "reference": "9dddcddff1ef974ad87b3708e4b442dc38b2261d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/9dddcddff1ef974ad87b3708e4b442dc38b2261d", + "reference": "9dddcddff1ef974ad87b3708e4b442dc38b2261d", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/event-dispatcher-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/dependency-injection": "<6.4", + "symfony/service-contracts": "<2.5" + }, + "provide": { + "psr/event-dispatcher-implementation": "1.0", + "symfony/event-dispatcher-implementation": "2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/error-handler": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/framework-bundle": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/stopwatch": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\EventDispatcher\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/event-dispatcher/tree/v7.4.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-10-28T09:38:46+00:00" + }, + { + "name": "symfony/event-dispatcher-contracts", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher-contracts.git", + "reference": "59eb412e93815df44f05f342958efa9f46b1e586" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/59eb412e93815df44f05f342958efa9f46b1e586", + "reference": "59eb412e93815df44f05f342958efa9f46b1e586", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/event-dispatcher": "^1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\EventDispatcher\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to dispatching event", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-25T14:21:43+00:00" + }, + { + "name": "symfony/finder", + "version": "v7.4.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "340b9ed7320570f319028a2cbec46d40535e94bd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/340b9ed7320570f319028a2cbec46d40535e94bd", + "reference": "340b9ed7320570f319028a2cbec46d40535e94bd", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "symfony/filesystem": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Finder\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Finds files and directories via an intuitive fluent interface", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/finder/tree/v7.4.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-11-05T05:42:40+00:00" + }, + { + "name": "symfony/http-foundation", + "version": "v7.4.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-foundation.git", + "reference": "bd1af1e425811d6f077db240c3a588bdb405cd27" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/bd1af1e425811d6f077db240c3a588bdb405cd27", + "reference": "bd1af1e425811d6f077db240c3a588bdb405cd27", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "^1.1" + }, + "conflict": { + "doctrine/dbal": "<3.6", + "symfony/cache": "<6.4.12|>=7.0,<7.1.5" + }, + "require-dev": { + "doctrine/dbal": "^3.6|^4", + "predis/predis": "^1.1|^2.0", + "symfony/cache": "^6.4.12|^7.1.5|^8.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/mime": "^6.4|^7.0|^8.0", + "symfony/rate-limiter": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpFoundation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Defines an object-oriented layer for the HTTP specification", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-foundation/tree/v7.4.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-12-07T11:13:10+00:00" + }, + { + "name": "symfony/http-kernel", + "version": "v7.4.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-kernel.git", + "reference": "171d2ec4002012a023e042c6041d7fde58b143c6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/171d2ec4002012a023e042c6041d7fde58b143c6", + "reference": "171d2ec4002012a023e042c6041d7fde58b143c6", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/error-handler": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^7.3|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/browser-kit": "<6.4", + "symfony/cache": "<6.4", + "symfony/config": "<6.4", + "symfony/console": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/doctrine-bridge": "<6.4", + "symfony/flex": "<2.10", + "symfony/form": "<6.4", + "symfony/http-client": "<6.4", + "symfony/http-client-contracts": "<2.5", + "symfony/mailer": "<6.4", + "symfony/messenger": "<6.4", + "symfony/translation": "<6.4", + "symfony/translation-contracts": "<2.5", + "symfony/twig-bridge": "<6.4", + "symfony/validator": "<6.4", + "symfony/var-dumper": "<6.4", + "twig/twig": "<3.12" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/cache": "^1.0|^2.0|^3.0", + "symfony/browser-kit": "^6.4|^7.0|^8.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/css-selector": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/dom-crawler": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/http-client-contracts": "^2.5|^3", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/property-access": "^7.1|^8.0", + "symfony/routing": "^6.4|^7.0|^8.0", + "symfony/serializer": "^7.1|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/translation": "^6.4|^7.0|^8.0", + "symfony/translation-contracts": "^2.5|^3", + "symfony/uid": "^6.4|^7.0|^8.0", + "symfony/validator": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0", + "symfony/var-exporter": "^6.4|^7.0|^8.0", + "twig/twig": "^3.12" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpKernel\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a structured process for converting a Request into a Response", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-kernel/tree/v7.4.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-12-07T16:28:51+00:00" + }, + { + "name": "symfony/mailer", + "version": "v7.4.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/mailer.git", + "reference": "a3d9eea8cfa467ece41f0f54ba28185d74bd53fd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mailer/zipball/a3d9eea8cfa467ece41f0f54ba28185d74bd53fd", + "reference": "a3d9eea8cfa467ece41f0f54ba28185d74bd53fd", + "shasum": "" + }, + "require": { + "egulias/email-validator": "^2.1.10|^3|^4", + "php": ">=8.2", + "psr/event-dispatcher": "^1", + "psr/log": "^1|^2|^3", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/mime": "^7.2|^8.0", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/http-client-contracts": "<2.5", + "symfony/http-kernel": "<6.4", + "symfony/messenger": "<6.4", + "symfony/mime": "<6.4", + "symfony/twig-bridge": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/twig-bridge": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Mailer\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Helps sending emails", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/mailer/tree/v7.4.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-11-21T15:26:00+00:00" + }, + { + "name": "symfony/mime", + "version": "v7.4.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/mime.git", + "reference": "bdb02729471be5d047a3ac4a69068748f1a6be7a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mime/zipball/bdb02729471be5d047a3ac4a69068748f1a6be7a", + "reference": "bdb02729471be5d047a3ac4a69068748f1a6be7a", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-intl-idn": "^1.10", + "symfony/polyfill-mbstring": "^1.0" + }, + "conflict": { + "egulias/email-validator": "~3.0.0", + "phpdocumentor/reflection-docblock": "<3.2.2", + "phpdocumentor/type-resolver": "<1.4.0", + "symfony/mailer": "<6.4", + "symfony/serializer": "<6.4.3|>7.0,<7.0.3" + }, + "require-dev": { + "egulias/email-validator": "^2.1.10|^3.1|^4", + "league/html-to-markdown": "^5.0", + "phpdocumentor/reflection-docblock": "^3.0|^4.0|^5.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/property-info": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4.3|^7.0.3|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Mime\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Allows manipulating MIME messages", + "homepage": "https://symfony.com", + "keywords": [ + "mime", + "mime-type" + ], + "support": { + "source": "https://github.com/symfony/mime/tree/v7.4.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-11-16T10:14:42+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638", + "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/380872130d3a5dd3ace2f4010d95125fde5d5c70", + "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's grapheme_* functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-06-27T09:58:17+00:00" + }, + { + "name": "symfony/polyfill-intl-idn", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-idn.git", + "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/9614ac4d8061dc257ecc64cba1b140873dce8ad3", + "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3", + "shasum": "" + }, + "require": { + "php": ">=7.2", + "symfony/polyfill-intl-normalizer": "^1.10" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Idn\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Laurent Bassin", + "email": "laurent@bassin.info" + }, + { + "name": "Trevor Rowbotham", + "email": "trevor.rowbotham@pm.me" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's idn_to_ascii and idn_to_utf8 functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "idn", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-10T14:38:51+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "3833d7255cc303546435cb650316bff708a1c75c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c", + "reference": "3833d7255cc303546435cb650316bff708a1c75c", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493", + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-12-23T08:48:59+00:00" + }, + { + "name": "symfony/polyfill-php80", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/0cc9dd0f17f61d8131e7df6b84bd344899fe2608", + "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php80/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-01-02T08:10:11+00:00" + }, + { + "name": "symfony/polyfill-php83", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php83.git", + "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/17f6f9a6b1735c0f163024d959f700cfbc5155e5", + "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php83\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.3+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php83/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-07-08T02:45:35+00:00" + }, + { + "name": "symfony/polyfill-php84", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php84.git", + "reference": "d8ced4d875142b6a7426000426b8abc631d6b191" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/d8ced4d875142b6a7426000426b8abc631d6b191", + "reference": "d8ced4d875142b6a7426000426b8abc631d6b191", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php84\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php84/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-06-24T13:30:11+00:00" + }, + { + "name": "symfony/polyfill-php85", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php85.git", + "reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91", + "reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php85\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php85/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-06-23T16:12:55+00:00" + }, + { + "name": "symfony/polyfill-uuid", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-uuid.git", + "reference": "21533be36c24be3f4b1669c4725c7d1d2bab4ae2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/21533be36c24be3f4b1669c4725c7d1d2bab4ae2", + "reference": "21533be36c24be3f4b1669c4725c7d1d2bab4ae2", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-uuid": "*" + }, + "suggest": { + "ext-uuid": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Uuid\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for uuid functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/polyfill-uuid/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/process", + "version": "v7.4.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/process.git", + "reference": "7ca8dc2d0dcf4882658313aba8be5d9fd01026c8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/process/zipball/7ca8dc2d0dcf4882658313aba8be5d9fd01026c8", + "reference": "7ca8dc2d0dcf4882658313aba8be5d9fd01026c8", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Process\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Executes commands in sub-processes", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/process/tree/v7.4.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-10-16T11:21:06+00:00" + }, + { + "name": "symfony/routing", + "version": "v7.4.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/routing.git", + "reference": "4720254cb2644a0b876233d258a32bf017330db7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/routing/zipball/4720254cb2644a0b876233d258a32bf017330db7", + "reference": "4720254cb2644a0b876233d258a32bf017330db7", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/config": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/yaml": "<6.4" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/yaml": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Routing\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Maps an HTTP request to a set of configuration variables", + "homepage": "https://symfony.com", + "keywords": [ + "router", + "routing", + "uri", + "url" + ], + "support": { + "source": "https://github.com/symfony/routing/tree/v7.4.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-11-27T13:27:24+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v3.6.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43", + "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/service-contracts/tree/v3.6.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-07-15T11:30:57+00:00" + }, + { + "name": "symfony/string", + "version": "v7.4.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "d50e862cb0a0e0886f73ca1f31b865efbb795003" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/d50e862cb0a0e0886f73ca1f31b865efbb795003", + "reference": "d50e862cb0a0e0886f73ca1f31b865efbb795003", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3.0", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-intl-grapheme": "~1.33", + "symfony/polyfill-intl-normalizer": "~1.0", + "symfony/polyfill-mbstring": "~1.0" + }, + "conflict": { + "symfony/translation-contracts": "<2.5" + }, + "require-dev": { + "symfony/emoji": "^7.1|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/intl": "^6.4|^7.0|^8.0", + "symfony/translation-contracts": "^2.5|^3.0", + "symfony/var-exporter": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\String\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", + "homepage": "https://symfony.com", + "keywords": [ + "grapheme", + "i18n", + "string", + "unicode", + "utf-8", + "utf8" + ], + "support": { + "source": "https://github.com/symfony/string/tree/v7.4.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-11-27T13:27:24+00:00" + }, + { + "name": "symfony/translation", + "version": "v7.4.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation.git", + "reference": "2d01ca0da3f092f91eeedb46f24aa30d2fca8f68" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation/zipball/2d01ca0da3f092f91eeedb46f24aa30d2fca8f68", + "reference": "2d01ca0da3f092f91eeedb46f24aa30d2fca8f68", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/translation-contracts": "^2.5.3|^3.3" + }, + "conflict": { + "nikic/php-parser": "<5.0", + "symfony/config": "<6.4", + "symfony/console": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/http-client-contracts": "<2.5", + "symfony/http-kernel": "<6.4", + "symfony/service-contracts": "<2.5", + "symfony/twig-bundle": "<6.4", + "symfony/yaml": "<6.4" + }, + "provide": { + "symfony/translation-implementation": "2.3|3.0" + }, + "require-dev": { + "nikic/php-parser": "^5.0", + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/http-client-contracts": "^2.5|^3.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/intl": "^6.4|^7.0|^8.0", + "symfony/polyfill-intl-icu": "^1.21", + "symfony/routing": "^6.4|^7.0|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/yaml": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to internationalize your application", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/translation/tree/v7.4.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-11-27T13:27:24+00:00" + }, + { + "name": "symfony/translation-contracts", + "version": "v3.6.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation-contracts.git", + "reference": "65a8bc82080447fae78373aa10f8d13b38338977" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/65a8bc82080447fae78373aa10f8d13b38338977", + "reference": "65a8bc82080447fae78373aa10f8d13b38338977", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to translation", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/translation-contracts/tree/v3.6.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-07-15T13:41:35+00:00" + }, + { + "name": "symfony/uid", + "version": "v7.4.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/uid.git", + "reference": "2498e9f81b7baa206f44de583f2f48350b90142c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/uid/zipball/2498e9f81b7baa206f44de583f2f48350b90142c", + "reference": "2498e9f81b7baa206f44de583f2f48350b90142c", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/polyfill-uuid": "^1.15" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Uid\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to generate and represent UIDs", + "homepage": "https://symfony.com", + "keywords": [ + "UID", + "ulid", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/uid/tree/v7.4.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-09-25T11:02:55+00:00" + }, + { + "name": "symfony/var-dumper", + "version": "v7.4.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/var-dumper.git", + "reference": "41fd6c4ae28c38b294b42af6db61446594a0dece" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/41fd6c4ae28c38b294b42af6db61446594a0dece", + "reference": "41fd6c4ae28c38b294b42af6db61446594a0dece", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0" + }, + "conflict": { + "symfony/console": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/uid": "^6.4|^7.0|^8.0", + "twig/twig": "^3.12" + }, + "bin": [ + "Resources/bin/var-dump-server" + ], + "type": "library", + "autoload": { + "files": [ + "Resources/functions/dump.php" + ], + "psr-4": { + "Symfony\\Component\\VarDumper\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides mechanisms for walking through any arbitrary PHP variable", + "homepage": "https://symfony.com", + "keywords": [ + "debug", + "dump" + ], + "support": { + "source": "https://github.com/symfony/var-dumper/tree/v7.4.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-10-27T20:36:44+00:00" + }, + { + "name": "tijsverkoyen/css-to-inline-styles", + "version": "v2.3.0", + "source": { + "type": "git", + "url": "https://github.com/tijsverkoyen/CssToInlineStyles.git", + "reference": "0d72ac1c00084279c1816675284073c5a337c20d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/tijsverkoyen/CssToInlineStyles/zipball/0d72ac1c00084279c1816675284073c5a337c20d", + "reference": "0d72ac1c00084279c1816675284073c5a337c20d", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "php": "^7.4 || ^8.0", + "symfony/css-selector": "^5.4 || ^6.0 || ^7.0" + }, + "require-dev": { + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^8.5.21 || ^9.5.10" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "TijsVerkoyen\\CssToInlineStyles\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Tijs Verkoyen", + "email": "css_to_inline_styles@verkoyen.eu", + "role": "Developer" + } + ], + "description": "CssToInlineStyles is a class that enables you to convert HTML-pages/files into HTML-pages/files with inline styles. This is very useful when you're sending emails.", + "homepage": "https://github.com/tijsverkoyen/CssToInlineStyles", + "support": { + "issues": "https://github.com/tijsverkoyen/CssToInlineStyles/issues", + "source": "https://github.com/tijsverkoyen/CssToInlineStyles/tree/v2.3.0" + }, + "time": "2024-12-21T16:25:41+00:00" + }, + { + "name": "vlucas/phpdotenv", + "version": "v5.6.2", + "source": { + "type": "git", + "url": "https://github.com/vlucas/phpdotenv.git", + "reference": "24ac4c74f91ee2c193fa1aaa5c249cb0822809af" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/24ac4c74f91ee2c193fa1aaa5c249cb0822809af", + "reference": "24ac4c74f91ee2c193fa1aaa5c249cb0822809af", + "shasum": "" + }, + "require": { + "ext-pcre": "*", + "graham-campbell/result-type": "^1.1.3", + "php": "^7.2.5 || ^8.0", + "phpoption/phpoption": "^1.9.3", + "symfony/polyfill-ctype": "^1.24", + "symfony/polyfill-mbstring": "^1.24", + "symfony/polyfill-php80": "^1.24" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-filter": "*", + "phpunit/phpunit": "^8.5.34 || ^9.6.13 || ^10.4.2" + }, + "suggest": { + "ext-filter": "Required to use the boolean validator." + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + }, + "branch-alias": { + "dev-master": "5.6-dev" + } + }, + "autoload": { + "psr-4": { + "Dotenv\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Vance Lucas", + "email": "vance@vancelucas.com", + "homepage": "https://github.com/vlucas" + } + ], + "description": "Loads environment variables from `.env` to `getenv()`, `$_ENV` and `$_SERVER` automagically.", + "keywords": [ + "dotenv", + "env", + "environment" + ], + "support": { + "issues": "https://github.com/vlucas/phpdotenv/issues", + "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.2" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/vlucas/phpdotenv", + "type": "tidelift" + } + ], + "time": "2025-04-30T23:37:27+00:00" + }, + { + "name": "voku/portable-ascii", + "version": "2.0.3", + "source": { + "type": "git", + "url": "https://github.com/voku/portable-ascii.git", + "reference": "b1d923f88091c6bf09699efcd7c8a1b1bfd7351d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/voku/portable-ascii/zipball/b1d923f88091c6bf09699efcd7c8a1b1bfd7351d", + "reference": "b1d923f88091c6bf09699efcd7c8a1b1bfd7351d", + "shasum": "" + }, + "require": { + "php": ">=7.0.0" + }, + "require-dev": { + "phpunit/phpunit": "~6.0 || ~7.0 || ~9.0" + }, + "suggest": { + "ext-intl": "Use Intl for transliterator_transliterate() support" + }, + "type": "library", + "autoload": { + "psr-4": { + "voku\\": "src/voku/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Lars Moelleken", + "homepage": "https://www.moelleken.org/" + } + ], + "description": "Portable ASCII library - performance optimized (ascii) string functions for php.", + "homepage": "https://github.com/voku/portable-ascii", + "keywords": [ + "ascii", + "clean", + "php" + ], + "support": { + "issues": "https://github.com/voku/portable-ascii/issues", + "source": "https://github.com/voku/portable-ascii/tree/2.0.3" + }, + "funding": [ + { + "url": "https://www.paypal.me/moelleken", + "type": "custom" + }, + { + "url": "https://github.com/voku", + "type": "github" + }, + { + "url": "https://opencollective.com/portable-ascii", + "type": "open_collective" + }, + { + "url": "https://www.patreon.com/voku", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/voku/portable-ascii", + "type": "tidelift" + } + ], + "time": "2024-11-21T01:49:47+00:00" + } + ], + "packages-dev": [ + { + "name": "fakerphp/faker", + "version": "v1.24.1", + "source": { + "type": "git", + "url": "https://github.com/FakerPHP/Faker.git", + "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/FakerPHP/Faker/zipball/e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", + "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "psr/container": "^1.0 || ^2.0", + "symfony/deprecation-contracts": "^2.2 || ^3.0" + }, + "conflict": { + "fzaninotto/faker": "*" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.4.1", + "doctrine/persistence": "^1.3 || ^2.0", + "ext-intl": "*", + "phpunit/phpunit": "^9.5.26", + "symfony/phpunit-bridge": "^5.4.16" + }, + "suggest": { + "doctrine/orm": "Required to use Faker\\ORM\\Doctrine", + "ext-curl": "Required by Faker\\Provider\\Image to download images.", + "ext-dom": "Required by Faker\\Provider\\HtmlLorem for generating random HTML.", + "ext-iconv": "Required by Faker\\Provider\\ru_RU\\Text::realText() for generating real Russian text.", + "ext-mbstring": "Required for multibyte Unicode string functionality." + }, + "type": "library", + "autoload": { + "psr-4": { + "Faker\\": "src/Faker/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "François Zaninotto" + } + ], + "description": "Faker is a PHP library that generates fake data for you.", + "keywords": [ + "data", + "faker", + "fixtures" + ], + "support": { + "issues": "https://github.com/FakerPHP/Faker/issues", + "source": "https://github.com/FakerPHP/Faker/tree/v1.24.1" + }, + "time": "2024-11-21T13:46:39+00:00" + }, + { + "name": "filp/whoops", + "version": "2.18.4", + "source": { + "type": "git", + "url": "https://github.com/filp/whoops.git", + "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/filp/whoops/zipball/d2102955e48b9fd9ab24280a7ad12ed552752c4d", + "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0", + "psr/log": "^1.0.1 || ^2.0 || ^3.0" + }, + "require-dev": { + "mockery/mockery": "^1.0", + "phpunit/phpunit": "^7.5.20 || ^8.5.8 || ^9.3.3", + "symfony/var-dumper": "^4.0 || ^5.0" + }, + "suggest": { + "symfony/var-dumper": "Pretty print complex values better with var-dumper available", + "whoops/soap": "Formats errors as SOAP responses" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Whoops\\": "src/Whoops/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Filipe Dobreira", + "homepage": "https://github.com/filp", + "role": "Developer" + } + ], + "description": "php error handling for cool kids", + "homepage": "https://filp.github.io/whoops/", + "keywords": [ + "error", + "exception", + "handling", + "library", + "throwable", + "whoops" + ], + "support": { + "issues": "https://github.com/filp/whoops/issues", + "source": "https://github.com/filp/whoops/tree/2.18.4" + }, + "funding": [ + { + "url": "https://github.com/denis-sokolov", + "type": "github" + } + ], + "time": "2025-08-08T12:00:00+00:00" + }, + { + "name": "hamcrest/hamcrest-php", + "version": "v2.1.1", + "source": { + "type": "git", + "url": "https://github.com/hamcrest/hamcrest-php.git", + "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/hamcrest/hamcrest-php/zipball/f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487", + "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0" + }, + "replace": { + "cordoval/hamcrest-php": "*", + "davedevelopment/hamcrest-php": "*", + "kodova/hamcrest-php": "*" + }, + "require-dev": { + "phpunit/php-file-iterator": "^1.4 || ^2.0 || ^3.0", + "phpunit/phpunit": "^4.8.36 || ^5.7 || ^6.5 || ^7.0 || ^8.0 || ^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.1-dev" + } + }, + "autoload": { + "classmap": [ + "hamcrest" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "This is the PHP port of Hamcrest Matchers", + "keywords": [ + "test" + ], + "support": { + "issues": "https://github.com/hamcrest/hamcrest-php/issues", + "source": "https://github.com/hamcrest/hamcrest-php/tree/v2.1.1" + }, + "time": "2025-04-30T06:54:44+00:00" + }, + { + "name": "laravel/pail", + "version": "v1.2.4", + "source": { + "type": "git", + "url": "https://github.com/laravel/pail.git", + "reference": "49f92285ff5d6fc09816e976a004f8dec6a0ea30" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/pail/zipball/49f92285ff5d6fc09816e976a004f8dec6a0ea30", + "reference": "49f92285ff5d6fc09816e976a004f8dec6a0ea30", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "illuminate/console": "^10.24|^11.0|^12.0", + "illuminate/contracts": "^10.24|^11.0|^12.0", + "illuminate/log": "^10.24|^11.0|^12.0", + "illuminate/process": "^10.24|^11.0|^12.0", + "illuminate/support": "^10.24|^11.0|^12.0", + "nunomaduro/termwind": "^1.15|^2.0", + "php": "^8.2", + "symfony/console": "^6.0|^7.0" + }, + "require-dev": { + "laravel/framework": "^10.24|^11.0|^12.0", + "laravel/pint": "^1.13", + "orchestra/testbench-core": "^8.13|^9.17|^10.8", + "pestphp/pest": "^2.20|^3.0|^4.0", + "pestphp/pest-plugin-type-coverage": "^2.3|^3.0|^4.0", + "phpstan/phpstan": "^1.12.27", + "symfony/var-dumper": "^6.3|^7.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Pail\\PailServiceProvider" + ] + }, + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Pail\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "Easily delve into your Laravel application's log files directly from the command line.", + "homepage": "https://github.com/laravel/pail", + "keywords": [ + "dev", + "laravel", + "logs", + "php", + "tail" + ], + "support": { + "issues": "https://github.com/laravel/pail/issues", + "source": "https://github.com/laravel/pail" + }, + "time": "2025-11-20T16:29:35+00:00" + }, + { + "name": "laravel/pint", + "version": "v1.26.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/pint.git", + "reference": "69dcca060ecb15e4b564af63d1f642c81a241d6f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/pint/zipball/69dcca060ecb15e4b564af63d1f642c81a241d6f", + "reference": "69dcca060ecb15e4b564af63d1f642c81a241d6f", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-mbstring": "*", + "ext-tokenizer": "*", + "ext-xml": "*", + "php": "^8.2.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.90.0", + "illuminate/view": "^12.40.1", + "larastan/larastan": "^3.8.0", + "laravel-zero/framework": "^12.0.4", + "mockery/mockery": "^1.6.12", + "nunomaduro/termwind": "^2.3.3", + "pestphp/pest": "^3.8.4" + }, + "bin": [ + "builds/pint" + ], + "type": "project", + "autoload": { + "psr-4": { + "App\\": "app/", + "Database\\Seeders\\": "database/seeders/", + "Database\\Factories\\": "database/factories/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "An opinionated code formatter for PHP.", + "homepage": "https://laravel.com", + "keywords": [ + "dev", + "format", + "formatter", + "lint", + "linter", + "php" + ], + "support": { + "issues": "https://github.com/laravel/pint/issues", + "source": "https://github.com/laravel/pint" + }, + "time": "2025-11-25T21:15:52+00:00" + }, + { + "name": "laravel/sail", + "version": "v1.50.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/sail.git", + "reference": "9177d5de1c8247166b92ea6049c2b069d2a1802f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/sail/zipball/9177d5de1c8247166b92ea6049c2b069d2a1802f", + "reference": "9177d5de1c8247166b92ea6049c2b069d2a1802f", + "shasum": "" + }, + "require": { + "illuminate/console": "^9.52.16|^10.0|^11.0|^12.0", + "illuminate/contracts": "^9.52.16|^10.0|^11.0|^12.0", + "illuminate/support": "^9.52.16|^10.0|^11.0|^12.0", + "php": "^8.0", + "symfony/console": "^6.0|^7.0", + "symfony/yaml": "^6.0|^7.0" + }, + "require-dev": { + "orchestra/testbench": "^7.0|^8.0|^9.0|^10.0", + "phpstan/phpstan": "^2.0" + }, + "bin": [ + "bin/sail" + ], + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Sail\\SailServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Sail\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Docker files for running a basic Laravel application.", + "keywords": [ + "docker", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/sail/issues", + "source": "https://github.com/laravel/sail" + }, + "time": "2025-12-03T17:16:36+00:00" + }, + { + "name": "mockery/mockery", + "version": "1.6.12", + "source": { + "type": "git", + "url": "https://github.com/mockery/mockery.git", + "reference": "1f4efdd7d3beafe9807b08156dfcb176d18f1699" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/mockery/mockery/zipball/1f4efdd7d3beafe9807b08156dfcb176d18f1699", + "reference": "1f4efdd7d3beafe9807b08156dfcb176d18f1699", + "shasum": "" + }, + "require": { + "hamcrest/hamcrest-php": "^2.0.1", + "lib-pcre": ">=7.0", + "php": ">=7.3" + }, + "conflict": { + "phpunit/phpunit": "<8.0" + }, + "require-dev": { + "phpunit/phpunit": "^8.5 || ^9.6.17", + "symplify/easy-coding-standard": "^12.1.14" + }, + "type": "library", + "autoload": { + "files": [ + "library/helpers.php", + "library/Mockery.php" + ], + "psr-4": { + "Mockery\\": "library/Mockery" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Pádraic Brady", + "email": "padraic.brady@gmail.com", + "homepage": "https://github.com/padraic", + "role": "Author" + }, + { + "name": "Dave Marshall", + "email": "dave.marshall@atstsolutions.co.uk", + "homepage": "https://davedevelopment.co.uk", + "role": "Developer" + }, + { + "name": "Nathanael Esayeas", + "email": "nathanael.esayeas@protonmail.com", + "homepage": "https://github.com/ghostwriter", + "role": "Lead Developer" + } + ], + "description": "Mockery is a simple yet flexible PHP mock object framework", + "homepage": "https://github.com/mockery/mockery", + "keywords": [ + "BDD", + "TDD", + "library", + "mock", + "mock objects", + "mockery", + "stub", + "test", + "test double", + "testing" + ], + "support": { + "docs": "https://docs.mockery.io/", + "issues": "https://github.com/mockery/mockery/issues", + "rss": "https://github.com/mockery/mockery/releases.atom", + "security": "https://github.com/mockery/mockery/security/advisories", + "source": "https://github.com/mockery/mockery" + }, + "time": "2024-05-16T03:13:13+00:00" + }, + { + "name": "myclabs/deep-copy", + "version": "1.13.4", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2025-08-01T08:46:24+00:00" + }, + { + "name": "nunomaduro/collision", + "version": "v8.8.3", + "source": { + "type": "git", + "url": "https://github.com/nunomaduro/collision.git", + "reference": "1dc9e88d105699d0fee8bb18890f41b274f6b4c4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nunomaduro/collision/zipball/1dc9e88d105699d0fee8bb18890f41b274f6b4c4", + "reference": "1dc9e88d105699d0fee8bb18890f41b274f6b4c4", + "shasum": "" + }, + "require": { + "filp/whoops": "^2.18.1", + "nunomaduro/termwind": "^2.3.1", + "php": "^8.2.0", + "symfony/console": "^7.3.0" + }, + "conflict": { + "laravel/framework": "<11.44.2 || >=13.0.0", + "phpunit/phpunit": "<11.5.15 || >=13.0.0" + }, + "require-dev": { + "brianium/paratest": "^7.8.3", + "larastan/larastan": "^3.4.2", + "laravel/framework": "^11.44.2 || ^12.18", + "laravel/pint": "^1.22.1", + "laravel/sail": "^1.43.1", + "laravel/sanctum": "^4.1.1", + "laravel/tinker": "^2.10.1", + "orchestra/testbench-core": "^9.12.0 || ^10.4", + "pestphp/pest": "^3.8.2 || ^4.0.0", + "sebastian/environment": "^7.2.1 || ^8.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider" + ] + }, + "branch-alias": { + "dev-8.x": "8.x-dev" + } + }, + "autoload": { + "files": [ + "./src/Adapters/Phpunit/Autoload.php" + ], + "psr-4": { + "NunoMaduro\\Collision\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "Cli error handling for console/command-line PHP applications.", + "keywords": [ + "artisan", + "cli", + "command-line", + "console", + "dev", + "error", + "handling", + "laravel", + "laravel-zero", + "php", + "symfony" + ], + "support": { + "issues": "https://github.com/nunomaduro/collision/issues", + "source": "https://github.com/nunomaduro/collision" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + }, + { + "url": "https://www.patreon.com/nunomaduro", + "type": "patreon" + } + ], + "time": "2025-11-20T02:55:25+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "11.0.11", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "4f7722aa9a7b76aa775e2d9d4e95d1ea16eeeef4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/4f7722aa9a7b76aa775e2d9d4e95d1ea16eeeef4", + "reference": "4f7722aa9a7b76aa775e2d9d4e95d1ea16eeeef4", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^5.4.0", + "php": ">=8.2", + "phpunit/php-file-iterator": "^5.1.0", + "phpunit/php-text-template": "^4.0.1", + "sebastian/code-unit-reverse-lookup": "^4.0.1", + "sebastian/complexity": "^4.0.1", + "sebastian/environment": "^7.2.0", + "sebastian/lines-of-code": "^3.0.1", + "sebastian/version": "^5.0.2", + "theseer/tokenizer": "^1.2.3" + }, + "require-dev": { + "phpunit/phpunit": "^11.5.2" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/11.0.11" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage", + "type": "tidelift" + } + ], + "time": "2025-08-27T14:37:49+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "5.1.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "118cfaaa8bc5aef3287bf315b6060b1174754af6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/118cfaaa8bc5aef3287bf315b6060b1174754af6", + "reference": "118cfaaa8bc5aef3287bf315b6060b1174754af6", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.1.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-08-27T05:02:59+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "5.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/c1ca3814734c07492b3d4c5f794f4b0995333da2", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^11.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/5.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:07:44+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:08:43+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "7.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "security": "https://github.com/sebastianbergmann/php-timer/security/policy", + "source": "https://github.com/sebastianbergmann/php-timer/tree/7.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:09:35+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "11.5.46", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "75dfe79a2aa30085b7132bb84377c24062193f33" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/75dfe79a2aa30085b7132bb84377c24062193f33", + "reference": "75dfe79a2aa30085b7132bb84377c24062193f33", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xml": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.13.4", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.2", + "phpunit/php-code-coverage": "^11.0.11", + "phpunit/php-file-iterator": "^5.1.0", + "phpunit/php-invoker": "^5.0.1", + "phpunit/php-text-template": "^4.0.1", + "phpunit/php-timer": "^7.0.1", + "sebastian/cli-parser": "^3.0.2", + "sebastian/code-unit": "^3.0.3", + "sebastian/comparator": "^6.3.2", + "sebastian/diff": "^6.0.2", + "sebastian/environment": "^7.2.1", + "sebastian/exporter": "^6.3.2", + "sebastian/global-state": "^7.0.2", + "sebastian/object-enumerator": "^6.0.1", + "sebastian/type": "^5.1.3", + "sebastian/version": "^5.0.2", + "staabm/side-effects-detector": "^1.0.5" + }, + "suggest": { + "ext-soap": "To be able to generate mocks based on WSDL files" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.5-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.46" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsors.html", + "type": "custom" + }, + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", + "type": "tidelift" + } + ], + "time": "2025-12-06T08:01:15+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/15c5dd40dc4f38794d383bb95465193f5e0ae180", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/3.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:41:36+00:00" + }, + { + "name": "sebastian/code-unit", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit.git", + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the PHP code units", + "homepage": "https://github.com/sebastianbergmann/code-unit", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit/issues", + "security": "https://github.com/sebastianbergmann/code-unit/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit/tree/3.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-03-19T07:56:08+00:00" + }, + { + "name": "sebastian/code-unit-reverse-lookup", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/183a9b2632194febd219bb9246eee421dad8d45e", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Looks up which function or method a line of code belongs to", + "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", + "security": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:45:54+00:00" + }, + { + "name": "sebastian/comparator", + "version": "6.3.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "85c77556683e6eee4323e4c5468641ca0237e2e8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/85c77556683e6eee4323e4c5468641ca0237e2e8", + "reference": "85c77556683e6eee4323e4c5468641ca0237e2e8", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/diff": "^6.0", + "sebastian/exporter": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.4" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/6.3.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2025-08-10T08:07:46+00:00" + }, + { + "name": "sebastian/complexity", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/ee41d384ab1906c68852636b6de493846e13e5a0", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:49:50+00:00" + }, + { + "name": "sebastian/diff", + "version": "6.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/b4ccd857127db5d41a5b676f24b51371d76d8544", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0", + "symfony/process": "^4.2 || ^5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/6.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:53:05+00:00" + }, + { + "name": "sebastian/environment", + "version": "7.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/a5c75038693ad2e8d4b6c15ba2403532647830c4", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/7.2.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", + "type": "tidelift" + } + ], + "time": "2025-05-21T11:55:47+00:00" + }, + { + "name": "sebastian/exporter", + "version": "6.3.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "70a298763b40b213ec087c51c739efcaa90bcd74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/70a298763b40b213ec087c51c739efcaa90bcd74", + "reference": "70a298763b40b213ec087c51c739efcaa90bcd74", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/6.3.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2025-09-24T06:12:51+00:00" + }, + { + "name": "sebastian/global-state", + "version": "7.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/3be331570a721f9a4b5917f4209773de17f747d7", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/7.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:57:36+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/3.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:58:38+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "6.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/f5b498e631a74204185071eb41f33f38d64608aa", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/6.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:00:13+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:01:32+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "6.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/6.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2025-08-13T04:42:22+00:00" + }, + { + "name": "sebastian/type", + "version": "5.1.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/5.1.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/type", + "type": "tidelift" + } + ], + "time": "2025-08-09T06:55:48+00:00" + }, + { + "name": "sebastian/version", + "version": "5.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c687e3387b99f5b03b6caa64c74b63e2936ff874", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/5.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-10-09T05:16:32+00:00" + }, + { + "name": "staabm/side-effects-detector", + "version": "1.0.5", + "source": { + "type": "git", + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A static analysis tool to detect side effects in PHP code", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" + }, + "funding": [ + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2024-10-20T05:08:20+00:00" + }, + { + "name": "symfony/yaml", + "version": "v7.4.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/yaml.git", + "reference": "24dd4de28d2e3988b311751ac49e684d783e2345" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/yaml/zipball/24dd4de28d2e3988b311751ac49e684d783e2345", + "reference": "24dd4de28d2e3988b311751ac49e684d783e2345", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/console": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0" + }, + "bin": [ + "Resources/bin/yaml-lint" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Yaml\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Loads and dumps YAML files", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/yaml/tree/v7.4.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-12-04T18:11:45+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "1.3.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/1.3.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-11-17T20:03:58+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": true, + "prefer-lowest": false, + "platform": { + "php": "^8.2" + }, + "platform-dev": {}, + "plugin-api-version": "2.6.0" +} diff --git a/backend/config/app.php b/backend/config/app.php new file mode 100644 index 0000000..423eed5 --- /dev/null +++ b/backend/config/app.php @@ -0,0 +1,126 @@ + env('APP_NAME', 'Laravel'), + + /* + |-------------------------------------------------------------------------- + | Application Environment + |-------------------------------------------------------------------------- + | + | This value determines the "environment" your application is currently + | running in. This may determine how you prefer to configure various + | services the application utilizes. Set this in your ".env" file. + | + */ + + 'env' => env('APP_ENV', 'production'), + + /* + |-------------------------------------------------------------------------- + | Application Debug Mode + |-------------------------------------------------------------------------- + | + | When your application is in debug mode, detailed error messages with + | stack traces will be shown on every error that occurs within your + | application. If disabled, a simple generic error page is shown. + | + */ + + 'debug' => (bool) env('APP_DEBUG', false), + + /* + |-------------------------------------------------------------------------- + | Application URL + |-------------------------------------------------------------------------- + | + | This URL is used by the console to properly generate URLs when using + | the Artisan command line tool. You should set this to the root of + | the application so that it's available within Artisan commands. + | + */ + + 'url' => env('APP_URL', 'http://localhost'), + + /* + |-------------------------------------------------------------------------- + | Application Timezone + |-------------------------------------------------------------------------- + | + | Here you may specify the default timezone for your application, which + | will be used by the PHP date and date-time functions. The timezone + | is set to "UTC" by default as it is suitable for most use cases. + | + */ + + 'timezone' => 'UTC', + + /* + |-------------------------------------------------------------------------- + | Application Locale Configuration + |-------------------------------------------------------------------------- + | + | The application locale determines the default locale that will be used + | by Laravel's translation / localization methods. This option can be + | set to any locale for which you plan to have translation strings. + | + */ + + 'locale' => env('APP_LOCALE', 'en'), + + 'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'), + + 'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'), + + /* + |-------------------------------------------------------------------------- + | Encryption Key + |-------------------------------------------------------------------------- + | + | This key is utilized by Laravel's encryption services and should be set + | to a random, 32 character string to ensure that all encrypted values + | are secure. You should do this prior to deploying the application. + | + */ + + 'cipher' => 'AES-256-CBC', + + 'key' => env('APP_KEY'), + + 'previous_keys' => [ + ...array_filter( + explode(',', (string) env('APP_PREVIOUS_KEYS', '')) + ), + ], + + /* + |-------------------------------------------------------------------------- + | Maintenance Mode Driver + |-------------------------------------------------------------------------- + | + | These configuration options determine the driver used to determine and + | manage Laravel's "maintenance mode" status. The "cache" driver will + | allow maintenance mode to be controlled across multiple machines. + | + | Supported drivers: "file", "cache" + | + */ + + 'maintenance' => [ + 'driver' => env('APP_MAINTENANCE_DRIVER', 'file'), + 'store' => env('APP_MAINTENANCE_STORE', 'database'), + ], + +]; diff --git a/backend/config/auth.php b/backend/config/auth.php new file mode 100644 index 0000000..7d1eb0d --- /dev/null +++ b/backend/config/auth.php @@ -0,0 +1,115 @@ + [ + 'guard' => env('AUTH_GUARD', 'web'), + 'passwords' => env('AUTH_PASSWORD_BROKER', 'users'), + ], + + /* + |-------------------------------------------------------------------------- + | Authentication Guards + |-------------------------------------------------------------------------- + | + | Next, you may define every authentication guard for your application. + | Of course, a great default configuration has been defined for you + | which utilizes session storage plus the Eloquent user provider. + | + | All authentication guards have a user provider, which defines how the + | users are actually retrieved out of your database or other storage + | system used by the application. Typically, Eloquent is utilized. + | + | Supported: "session" + | + */ + + 'guards' => [ + 'web' => [ + 'driver' => 'session', + 'provider' => 'users', + ], + ], + + /* + |-------------------------------------------------------------------------- + | User Providers + |-------------------------------------------------------------------------- + | + | All authentication guards have a user provider, which defines how the + | users are actually retrieved out of your database or other storage + | system used by the application. Typically, Eloquent is utilized. + | + | If you have multiple user tables or models you may configure multiple + | providers to represent the model / table. These providers may then + | be assigned to any extra authentication guards you have defined. + | + | Supported: "database", "eloquent" + | + */ + + 'providers' => [ + 'users' => [ + 'driver' => 'eloquent', + 'model' => env('AUTH_MODEL', App\Models\User::class), + ], + + // 'users' => [ + // 'driver' => 'database', + // 'table' => 'users', + // ], + ], + + /* + |-------------------------------------------------------------------------- + | Resetting Passwords + |-------------------------------------------------------------------------- + | + | These configuration options specify the behavior of Laravel's password + | reset functionality, including the table utilized for token storage + | and the user provider that is invoked to actually retrieve users. + | + | The expiry time is the number of minutes that each reset token will be + | considered valid. This security feature keeps tokens short-lived so + | they have less time to be guessed. You may change this as needed. + | + | The throttle setting is the number of seconds a user must wait before + | generating more password reset tokens. This prevents the user from + | quickly generating a very large amount of password reset tokens. + | + */ + + 'passwords' => [ + 'users' => [ + 'provider' => 'users', + 'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'), + 'expire' => 60, + 'throttle' => 60, + ], + ], + + /* + |-------------------------------------------------------------------------- + | Password Confirmation Timeout + |-------------------------------------------------------------------------- + | + | Here you may define the number of seconds before a password confirmation + | window expires and users are asked to re-enter their password via the + | confirmation screen. By default, the timeout lasts for three hours. + | + */ + + 'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800), + +]; diff --git a/backend/config/cache.php b/backend/config/cache.php new file mode 100644 index 0000000..b32aead --- /dev/null +++ b/backend/config/cache.php @@ -0,0 +1,117 @@ + env('CACHE_STORE', 'database'), + + /* + |-------------------------------------------------------------------------- + | Cache Stores + |-------------------------------------------------------------------------- + | + | Here you may define all of the cache "stores" for your application as + | well as their drivers. You may even define multiple stores for the + | same cache driver to group types of items stored in your caches. + | + | Supported drivers: "array", "database", "file", "memcached", + | "redis", "dynamodb", "octane", + | "failover", "null" + | + */ + + 'stores' => [ + + 'array' => [ + 'driver' => 'array', + 'serialize' => false, + ], + + 'database' => [ + 'driver' => 'database', + 'connection' => env('DB_CACHE_CONNECTION'), + 'table' => env('DB_CACHE_TABLE', 'cache'), + 'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'), + 'lock_table' => env('DB_CACHE_LOCK_TABLE'), + ], + + 'file' => [ + 'driver' => 'file', + 'path' => storage_path('framework/cache/data'), + 'lock_path' => storage_path('framework/cache/data'), + ], + + 'memcached' => [ + 'driver' => 'memcached', + 'persistent_id' => env('MEMCACHED_PERSISTENT_ID'), + 'sasl' => [ + env('MEMCACHED_USERNAME'), + env('MEMCACHED_PASSWORD'), + ], + 'options' => [ + // Memcached::OPT_CONNECT_TIMEOUT => 2000, + ], + 'servers' => [ + [ + 'host' => env('MEMCACHED_HOST', '127.0.0.1'), + 'port' => env('MEMCACHED_PORT', 11211), + 'weight' => 100, + ], + ], + ], + + 'redis' => [ + 'driver' => 'redis', + 'connection' => env('REDIS_CACHE_CONNECTION', 'cache'), + 'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'), + ], + + 'dynamodb' => [ + 'driver' => 'dynamodb', + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), + 'table' => env('DYNAMODB_CACHE_TABLE', 'cache'), + 'endpoint' => env('DYNAMODB_ENDPOINT'), + ], + + 'octane' => [ + 'driver' => 'octane', + ], + + 'failover' => [ + 'driver' => 'failover', + 'stores' => [ + 'database', + 'array', + ], + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Cache Key Prefix + |-------------------------------------------------------------------------- + | + | When utilizing the APC, database, memcached, Redis, and DynamoDB cache + | stores, there might be other applications using the same cache. For + | that reason, you may prefix every cache key to avoid collisions. + | + */ + + 'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'), + +]; diff --git a/backend/config/cors.php b/backend/config/cors.php new file mode 100644 index 0000000..652311f --- /dev/null +++ b/backend/config/cors.php @@ -0,0 +1,34 @@ + ['api/*', 'sanctum/csrf-cookie'], + + 'allowed_methods' => ['*'], + + 'allowed_origins' => explode(',', env('CORS_ALLOWED_ORIGINS', 'https://webmoney.cnxifly.com')), + + 'allowed_origins_patterns' => [], + + 'allowed_headers' => ['*'], + + 'exposed_headers' => [], + + 'max_age' => 0, + + 'supports_credentials' => true, + +]; diff --git a/backend/config/database.php b/backend/config/database.php new file mode 100644 index 0000000..53dcae0 --- /dev/null +++ b/backend/config/database.php @@ -0,0 +1,183 @@ + env('DB_CONNECTION', 'sqlite'), + + /* + |-------------------------------------------------------------------------- + | Database Connections + |-------------------------------------------------------------------------- + | + | Below are all of the database connections defined for your application. + | An example configuration is provided for each database system which + | is supported by Laravel. You're free to add / remove connections. + | + */ + + 'connections' => [ + + 'sqlite' => [ + 'driver' => 'sqlite', + 'url' => env('DB_URL'), + 'database' => env('DB_DATABASE', database_path('database.sqlite')), + 'prefix' => '', + 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true), + 'busy_timeout' => null, + 'journal_mode' => null, + 'synchronous' => null, + 'transaction_mode' => 'DEFERRED', + ], + + 'mysql' => [ + 'driver' => 'mysql', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '3306'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'unix_socket' => env('DB_SOCKET', ''), + 'charset' => env('DB_CHARSET', 'utf8mb4'), + 'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'), + 'prefix' => '', + 'prefix_indexes' => true, + 'strict' => true, + 'engine' => null, + 'options' => extension_loaded('pdo_mysql') ? array_filter([ + PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), + ]) : [], + ], + + 'mariadb' => [ + 'driver' => 'mariadb', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '3306'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'unix_socket' => env('DB_SOCKET', ''), + 'charset' => env('DB_CHARSET', 'utf8mb4'), + 'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'), + 'prefix' => '', + 'prefix_indexes' => true, + 'strict' => true, + 'engine' => null, + 'options' => extension_loaded('pdo_mysql') ? array_filter([ + PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), + ]) : [], + ], + + 'pgsql' => [ + 'driver' => 'pgsql', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '5432'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'charset' => env('DB_CHARSET', 'utf8'), + 'prefix' => '', + 'prefix_indexes' => true, + 'search_path' => 'public', + 'sslmode' => 'prefer', + ], + + 'sqlsrv' => [ + 'driver' => 'sqlsrv', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', 'localhost'), + 'port' => env('DB_PORT', '1433'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'charset' => env('DB_CHARSET', 'utf8'), + 'prefix' => '', + 'prefix_indexes' => true, + // 'encrypt' => env('DB_ENCRYPT', 'yes'), + // 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'), + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Migration Repository Table + |-------------------------------------------------------------------------- + | + | This table keeps track of all the migrations that have already run for + | your application. Using this information, we can determine which of + | the migrations on disk haven't actually been run on the database. + | + */ + + 'migrations' => [ + 'table' => 'migrations', + 'update_date_on_publish' => true, + ], + + /* + |-------------------------------------------------------------------------- + | Redis Databases + |-------------------------------------------------------------------------- + | + | Redis is an open source, fast, and advanced key-value store that also + | provides a richer body of commands than a typical key-value system + | such as Memcached. You may define your connection settings here. + | + */ + + 'redis' => [ + + 'client' => env('REDIS_CLIENT', 'phpredis'), + + 'options' => [ + 'cluster' => env('REDIS_CLUSTER', 'redis'), + 'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'), + 'persistent' => env('REDIS_PERSISTENT', false), + ], + + 'default' => [ + 'url' => env('REDIS_URL'), + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'username' => env('REDIS_USERNAME'), + 'password' => env('REDIS_PASSWORD'), + 'port' => env('REDIS_PORT', '6379'), + 'database' => env('REDIS_DB', '0'), + 'max_retries' => env('REDIS_MAX_RETRIES', 3), + 'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'), + 'backoff_base' => env('REDIS_BACKOFF_BASE', 100), + 'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000), + ], + + 'cache' => [ + 'url' => env('REDIS_URL'), + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'username' => env('REDIS_USERNAME'), + 'password' => env('REDIS_PASSWORD'), + 'port' => env('REDIS_PORT', '6379'), + 'database' => env('REDIS_CACHE_DB', '1'), + 'max_retries' => env('REDIS_MAX_RETRIES', 3), + 'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'), + 'backoff_base' => env('REDIS_BACKOFF_BASE', 100), + 'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000), + ], + + ], + +]; diff --git a/backend/config/filesystems.php b/backend/config/filesystems.php new file mode 100644 index 0000000..3d671bd --- /dev/null +++ b/backend/config/filesystems.php @@ -0,0 +1,80 @@ + env('FILESYSTEM_DISK', 'local'), + + /* + |-------------------------------------------------------------------------- + | Filesystem Disks + |-------------------------------------------------------------------------- + | + | Below you may configure as many filesystem disks as necessary, and you + | may even configure multiple disks for the same driver. Examples for + | most supported storage drivers are configured here for reference. + | + | Supported drivers: "local", "ftp", "sftp", "s3" + | + */ + + 'disks' => [ + + 'local' => [ + 'driver' => 'local', + 'root' => storage_path('app/private'), + 'serve' => true, + 'throw' => false, + 'report' => false, + ], + + 'public' => [ + 'driver' => 'local', + 'root' => storage_path('app/public'), + 'url' => env('APP_URL').'/storage', + 'visibility' => 'public', + 'throw' => false, + 'report' => false, + ], + + 's3' => [ + 'driver' => 's3', + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION'), + 'bucket' => env('AWS_BUCKET'), + 'url' => env('AWS_URL'), + 'endpoint' => env('AWS_ENDPOINT'), + 'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false), + 'throw' => false, + 'report' => false, + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Symbolic Links + |-------------------------------------------------------------------------- + | + | Here you may configure the symbolic links that will be created when the + | `storage:link` Artisan command is executed. The array keys should be + | the locations of the links and the values should be their targets. + | + */ + + 'links' => [ + public_path('storage') => storage_path('app/public'), + ], + +]; diff --git a/backend/config/logging.php b/backend/config/logging.php new file mode 100644 index 0000000..9e998a4 --- /dev/null +++ b/backend/config/logging.php @@ -0,0 +1,132 @@ + env('LOG_CHANNEL', 'stack'), + + /* + |-------------------------------------------------------------------------- + | Deprecations Log Channel + |-------------------------------------------------------------------------- + | + | This option controls the log channel that should be used to log warnings + | regarding deprecated PHP and library features. This allows you to get + | your application ready for upcoming major versions of dependencies. + | + */ + + 'deprecations' => [ + 'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'), + 'trace' => env('LOG_DEPRECATIONS_TRACE', false), + ], + + /* + |-------------------------------------------------------------------------- + | Log Channels + |-------------------------------------------------------------------------- + | + | Here you may configure the log channels for your application. Laravel + | utilizes the Monolog PHP logging library, which includes a variety + | of powerful log handlers and formatters that you're free to use. + | + | Available drivers: "single", "daily", "slack", "syslog", + | "errorlog", "monolog", "custom", "stack" + | + */ + + 'channels' => [ + + 'stack' => [ + 'driver' => 'stack', + 'channels' => explode(',', (string) env('LOG_STACK', 'single')), + 'ignore_exceptions' => false, + ], + + 'single' => [ + 'driver' => 'single', + 'path' => storage_path('logs/laravel.log'), + 'level' => env('LOG_LEVEL', 'debug'), + 'replace_placeholders' => true, + ], + + 'daily' => [ + 'driver' => 'daily', + 'path' => storage_path('logs/laravel.log'), + 'level' => env('LOG_LEVEL', 'debug'), + 'days' => env('LOG_DAILY_DAYS', 14), + 'replace_placeholders' => true, + ], + + 'slack' => [ + 'driver' => 'slack', + 'url' => env('LOG_SLACK_WEBHOOK_URL'), + 'username' => env('LOG_SLACK_USERNAME', 'Laravel Log'), + 'emoji' => env('LOG_SLACK_EMOJI', ':boom:'), + 'level' => env('LOG_LEVEL', 'critical'), + 'replace_placeholders' => true, + ], + + 'papertrail' => [ + 'driver' => 'monolog', + 'level' => env('LOG_LEVEL', 'debug'), + 'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class), + 'handler_with' => [ + 'host' => env('PAPERTRAIL_URL'), + 'port' => env('PAPERTRAIL_PORT'), + 'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'), + ], + 'processors' => [PsrLogMessageProcessor::class], + ], + + 'stderr' => [ + 'driver' => 'monolog', + 'level' => env('LOG_LEVEL', 'debug'), + 'handler' => StreamHandler::class, + 'handler_with' => [ + 'stream' => 'php://stderr', + ], + 'formatter' => env('LOG_STDERR_FORMATTER'), + 'processors' => [PsrLogMessageProcessor::class], + ], + + 'syslog' => [ + 'driver' => 'syslog', + 'level' => env('LOG_LEVEL', 'debug'), + 'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER), + 'replace_placeholders' => true, + ], + + 'errorlog' => [ + 'driver' => 'errorlog', + 'level' => env('LOG_LEVEL', 'debug'), + 'replace_placeholders' => true, + ], + + 'null' => [ + 'driver' => 'monolog', + 'handler' => NullHandler::class, + ], + + 'emergency' => [ + 'path' => storage_path('logs/laravel.log'), + ], + + ], + +]; diff --git a/backend/config/mail.php b/backend/config/mail.php new file mode 100644 index 0000000..522b284 --- /dev/null +++ b/backend/config/mail.php @@ -0,0 +1,118 @@ + env('MAIL_MAILER', 'log'), + + /* + |-------------------------------------------------------------------------- + | Mailer Configurations + |-------------------------------------------------------------------------- + | + | Here you may configure all of the mailers used by your application plus + | their respective settings. Several examples have been configured for + | you and you are free to add your own as your application requires. + | + | Laravel supports a variety of mail "transport" drivers that can be used + | when delivering an email. You may specify which one you're using for + | your mailers below. You may also add additional mailers if needed. + | + | Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2", + | "postmark", "resend", "log", "array", + | "failover", "roundrobin" + | + */ + + 'mailers' => [ + + 'smtp' => [ + 'transport' => 'smtp', + 'scheme' => env('MAIL_SCHEME'), + 'url' => env('MAIL_URL'), + 'host' => env('MAIL_HOST', '127.0.0.1'), + 'port' => env('MAIL_PORT', 2525), + 'username' => env('MAIL_USERNAME'), + 'password' => env('MAIL_PASSWORD'), + 'timeout' => null, + 'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)), + ], + + 'ses' => [ + 'transport' => 'ses', + ], + + 'postmark' => [ + 'transport' => 'postmark', + // 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'), + // 'client' => [ + // 'timeout' => 5, + // ], + ], + + 'resend' => [ + 'transport' => 'resend', + ], + + 'sendmail' => [ + 'transport' => 'sendmail', + 'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'), + ], + + 'log' => [ + 'transport' => 'log', + 'channel' => env('MAIL_LOG_CHANNEL'), + ], + + 'array' => [ + 'transport' => 'array', + ], + + 'failover' => [ + 'transport' => 'failover', + 'mailers' => [ + 'smtp', + 'log', + ], + 'retry_after' => 60, + ], + + 'roundrobin' => [ + 'transport' => 'roundrobin', + 'mailers' => [ + 'ses', + 'postmark', + ], + 'retry_after' => 60, + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Global "From" Address + |-------------------------------------------------------------------------- + | + | You may wish for all emails sent by your application to be sent from + | the same address. Here you may specify a name and address that is + | used globally for all emails that are sent by your application. + | + */ + + 'from' => [ + 'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'), + 'name' => env('MAIL_FROM_NAME', 'Example'), + ], + +]; diff --git a/backend/config/queue.php b/backend/config/queue.php new file mode 100644 index 0000000..79c2c0a --- /dev/null +++ b/backend/config/queue.php @@ -0,0 +1,129 @@ + env('QUEUE_CONNECTION', 'database'), + + /* + |-------------------------------------------------------------------------- + | Queue Connections + |-------------------------------------------------------------------------- + | + | Here you may configure the connection options for every queue backend + | used by your application. An example configuration is provided for + | each backend supported by Laravel. You're also free to add more. + | + | Drivers: "sync", "database", "beanstalkd", "sqs", "redis", + | "deferred", "background", "failover", "null" + | + */ + + 'connections' => [ + + 'sync' => [ + 'driver' => 'sync', + ], + + 'database' => [ + 'driver' => 'database', + 'connection' => env('DB_QUEUE_CONNECTION'), + 'table' => env('DB_QUEUE_TABLE', 'jobs'), + 'queue' => env('DB_QUEUE', 'default'), + 'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90), + 'after_commit' => false, + ], + + 'beanstalkd' => [ + 'driver' => 'beanstalkd', + 'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'), + 'queue' => env('BEANSTALKD_QUEUE', 'default'), + 'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90), + 'block_for' => 0, + 'after_commit' => false, + ], + + 'sqs' => [ + 'driver' => 'sqs', + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'), + 'queue' => env('SQS_QUEUE', 'default'), + 'suffix' => env('SQS_SUFFIX'), + 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), + 'after_commit' => false, + ], + + 'redis' => [ + 'driver' => 'redis', + 'connection' => env('REDIS_QUEUE_CONNECTION', 'default'), + 'queue' => env('REDIS_QUEUE', 'default'), + 'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90), + 'block_for' => null, + 'after_commit' => false, + ], + + 'deferred' => [ + 'driver' => 'deferred', + ], + + 'background' => [ + 'driver' => 'background', + ], + + 'failover' => [ + 'driver' => 'failover', + 'connections' => [ + 'database', + 'deferred', + ], + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Job Batching + |-------------------------------------------------------------------------- + | + | The following options configure the database and table that store job + | batching information. These options can be updated to any database + | connection and table which has been defined by your application. + | + */ + + 'batching' => [ + 'database' => env('DB_CONNECTION', 'sqlite'), + 'table' => 'job_batches', + ], + + /* + |-------------------------------------------------------------------------- + | Failed Queue Jobs + |-------------------------------------------------------------------------- + | + | These options configure the behavior of failed queue job logging so you + | can control how and where failed jobs are stored. Laravel ships with + | support for storing failed jobs in a simple file or in a database. + | + | Supported drivers: "database-uuids", "dynamodb", "file", "null" + | + */ + + 'failed' => [ + 'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'), + 'database' => env('DB_CONNECTION', 'sqlite'), + 'table' => 'failed_jobs', + ], + +]; diff --git a/backend/config/sanctum.php b/backend/config/sanctum.php new file mode 100644 index 0000000..7a88901 --- /dev/null +++ b/backend/config/sanctum.php @@ -0,0 +1,84 @@ + explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf( + '%s%s', + 'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1', + Sanctum::currentApplicationUrlWithPort(), + // Sanctum::currentRequestHost(), + ))), + + /* + |-------------------------------------------------------------------------- + | Sanctum Guards + |-------------------------------------------------------------------------- + | + | This array contains the authentication guards that will be checked when + | Sanctum is trying to authenticate a request. If none of these guards + | are able to authenticate the request, Sanctum will use the bearer + | token that's present on an incoming request for authentication. + | + */ + + 'guard' => ['web'], + + /* + |-------------------------------------------------------------------------- + | Expiration Minutes + |-------------------------------------------------------------------------- + | + | This value controls the number of minutes until an issued token will be + | considered expired. This will override any values set in the token's + | "expires_at" attribute, but first-party sessions are not affected. + | + */ + + 'expiration' => env('SANCTUM_TOKEN_EXPIRATION', 60 * 24 * 7), // 7 dias por padrão + + /* + |-------------------------------------------------------------------------- + | Token Prefix + |-------------------------------------------------------------------------- + | + | Sanctum can prefix new tokens in order to take advantage of numerous + | security scanning initiatives maintained by open source platforms + | that notify developers if they commit tokens into repositories. + | + | See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning + | + */ + + 'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''), + + /* + |-------------------------------------------------------------------------- + | Sanctum Middleware + |-------------------------------------------------------------------------- + | + | When authenticating your first-party SPA with Sanctum you may need to + | customize some of the middleware Sanctum uses while processing the + | request. You may change the middleware listed below as required. + | + */ + + 'middleware' => [ + 'authenticate_session' => Laravel\Sanctum\Http\Middleware\AuthenticateSession::class, + 'encrypt_cookies' => Illuminate\Cookie\Middleware\EncryptCookies::class, + 'validate_csrf_token' => Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class, + ], + +]; diff --git a/backend/config/services.php b/backend/config/services.php new file mode 100644 index 0000000..6a90eb8 --- /dev/null +++ b/backend/config/services.php @@ -0,0 +1,38 @@ + [ + 'key' => env('POSTMARK_API_KEY'), + ], + + 'resend' => [ + 'key' => env('RESEND_API_KEY'), + ], + + 'ses' => [ + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), + ], + + 'slack' => [ + 'notifications' => [ + 'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'), + 'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'), + ], + ], + +]; diff --git a/backend/config/session.php b/backend/config/session.php new file mode 100644 index 0000000..bc45901 --- /dev/null +++ b/backend/config/session.php @@ -0,0 +1,217 @@ + env('SESSION_DRIVER', 'database'), + + /* + |-------------------------------------------------------------------------- + | Session Lifetime + |-------------------------------------------------------------------------- + | + | Here you may specify the number of minutes that you wish the session + | to be allowed to remain idle before it expires. If you want them + | to expire immediately when the browser is closed then you may + | indicate that via the expire_on_close configuration option. + | + */ + + 'lifetime' => (int) env('SESSION_LIFETIME', 120), + + 'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false), + + /* + |-------------------------------------------------------------------------- + | Session Encryption + |-------------------------------------------------------------------------- + | + | This option allows you to easily specify that all of your session data + | should be encrypted before it's stored. All encryption is performed + | automatically by Laravel and you may use the session like normal. + | + */ + + 'encrypt' => env('SESSION_ENCRYPT', false), + + /* + |-------------------------------------------------------------------------- + | Session File Location + |-------------------------------------------------------------------------- + | + | When utilizing the "file" session driver, the session files are placed + | on disk. The default storage location is defined here; however, you + | are free to provide another location where they should be stored. + | + */ + + 'files' => storage_path('framework/sessions'), + + /* + |-------------------------------------------------------------------------- + | Session Database Connection + |-------------------------------------------------------------------------- + | + | When using the "database" or "redis" session drivers, you may specify a + | connection that should be used to manage these sessions. This should + | correspond to a connection in your database configuration options. + | + */ + + 'connection' => env('SESSION_CONNECTION'), + + /* + |-------------------------------------------------------------------------- + | Session Database Table + |-------------------------------------------------------------------------- + | + | When using the "database" session driver, you may specify the table to + | be used to store sessions. Of course, a sensible default is defined + | for you; however, you're welcome to change this to another table. + | + */ + + 'table' => env('SESSION_TABLE', 'sessions'), + + /* + |-------------------------------------------------------------------------- + | Session Cache Store + |-------------------------------------------------------------------------- + | + | When using one of the framework's cache driven session backends, you may + | define the cache store which should be used to store the session data + | between requests. This must match one of your defined cache stores. + | + | Affects: "dynamodb", "memcached", "redis" + | + */ + + 'store' => env('SESSION_STORE'), + + /* + |-------------------------------------------------------------------------- + | Session Sweeping Lottery + |-------------------------------------------------------------------------- + | + | Some session drivers must manually sweep their storage location to get + | rid of old sessions from storage. Here are the chances that it will + | happen on a given request. By default, the odds are 2 out of 100. + | + */ + + 'lottery' => [2, 100], + + /* + |-------------------------------------------------------------------------- + | Session Cookie Name + |-------------------------------------------------------------------------- + | + | Here you may change the name of the session cookie that is created by + | the framework. Typically, you should not need to change this value + | since doing so does not grant a meaningful security improvement. + | + */ + + 'cookie' => env( + 'SESSION_COOKIE', + Str::slug((string) env('APP_NAME', 'laravel')).'-session' + ), + + /* + |-------------------------------------------------------------------------- + | Session Cookie Path + |-------------------------------------------------------------------------- + | + | The session cookie path determines the path for which the cookie will + | be regarded as available. Typically, this will be the root path of + | your application, but you're free to change this when necessary. + | + */ + + 'path' => env('SESSION_PATH', '/'), + + /* + |-------------------------------------------------------------------------- + | Session Cookie Domain + |-------------------------------------------------------------------------- + | + | This value determines the domain and subdomains the session cookie is + | available to. By default, the cookie will be available to the root + | domain and all subdomains. Typically, this shouldn't be changed. + | + */ + + 'domain' => env('SESSION_DOMAIN'), + + /* + |-------------------------------------------------------------------------- + | HTTPS Only Cookies + |-------------------------------------------------------------------------- + | + | By setting this option to true, session cookies will only be sent back + | to the server if the browser has a HTTPS connection. This will keep + | the cookie from being sent to you when it can't be done securely. + | + */ + + 'secure' => env('SESSION_SECURE_COOKIE'), + + /* + |-------------------------------------------------------------------------- + | HTTP Access Only + |-------------------------------------------------------------------------- + | + | Setting this value to true will prevent JavaScript from accessing the + | value of the cookie and the cookie will only be accessible through + | the HTTP protocol. It's unlikely you should disable this option. + | + */ + + 'http_only' => env('SESSION_HTTP_ONLY', true), + + /* + |-------------------------------------------------------------------------- + | Same-Site Cookies + |-------------------------------------------------------------------------- + | + | This option determines how your cookies behave when cross-site requests + | take place, and can be used to mitigate CSRF attacks. By default, we + | will set this value to "lax" to permit secure cross-site requests. + | + | See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value + | + | Supported: "lax", "strict", "none", null + | + */ + + 'same_site' => env('SESSION_SAME_SITE', 'lax'), + + /* + |-------------------------------------------------------------------------- + | Partitioned Cookies + |-------------------------------------------------------------------------- + | + | Setting this value to true will tie the cookie to the top-level site for + | a cross-site context. Partitioned cookies are accepted by the browser + | when flagged "secure" and the Same-Site attribute is set to "none". + | + */ + + 'partitioned' => env('SESSION_PARTITIONED_COOKIE', false), + +]; diff --git a/backend/database/.gitignore b/backend/database/.gitignore new file mode 100644 index 0000000..9b19b93 --- /dev/null +++ b/backend/database/.gitignore @@ -0,0 +1 @@ +*.sqlite* diff --git a/backend/database/factories/UserFactory.php b/backend/database/factories/UserFactory.php new file mode 100644 index 0000000..584104c --- /dev/null +++ b/backend/database/factories/UserFactory.php @@ -0,0 +1,44 @@ + + */ +class UserFactory extends Factory +{ + /** + * The current password being used by the factory. + */ + protected static ?string $password; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'name' => fake()->name(), + 'email' => fake()->unique()->safeEmail(), + 'email_verified_at' => now(), + 'password' => static::$password ??= Hash::make('password'), + 'remember_token' => Str::random(10), + ]; + } + + /** + * Indicate that the model's email address should be unverified. + */ + public function unverified(): static + { + return $this->state(fn (array $attributes) => [ + 'email_verified_at' => null, + ]); + } +} diff --git a/backend/database/migrations/0001_01_01_000000_create_users_table.php b/backend/database/migrations/0001_01_01_000000_create_users_table.php new file mode 100644 index 0000000..05fb5d9 --- /dev/null +++ b/backend/database/migrations/0001_01_01_000000_create_users_table.php @@ -0,0 +1,49 @@ +id(); + $table->string('name'); + $table->string('email')->unique(); + $table->timestamp('email_verified_at')->nullable(); + $table->string('password'); + $table->rememberToken(); + $table->timestamps(); + }); + + Schema::create('password_reset_tokens', function (Blueprint $table) { + $table->string('email')->primary(); + $table->string('token'); + $table->timestamp('created_at')->nullable(); + }); + + Schema::create('sessions', function (Blueprint $table) { + $table->string('id')->primary(); + $table->foreignId('user_id')->nullable()->index(); + $table->string('ip_address', 45)->nullable(); + $table->text('user_agent')->nullable(); + $table->longText('payload'); + $table->integer('last_activity')->index(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('users'); + Schema::dropIfExists('password_reset_tokens'); + Schema::dropIfExists('sessions'); + } +}; diff --git a/backend/database/migrations/0001_01_01_000001_create_cache_table.php b/backend/database/migrations/0001_01_01_000001_create_cache_table.php new file mode 100644 index 0000000..b9c106b --- /dev/null +++ b/backend/database/migrations/0001_01_01_000001_create_cache_table.php @@ -0,0 +1,35 @@ +string('key')->primary(); + $table->mediumText('value'); + $table->integer('expiration'); + }); + + Schema::create('cache_locks', function (Blueprint $table) { + $table->string('key')->primary(); + $table->string('owner'); + $table->integer('expiration'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('cache'); + Schema::dropIfExists('cache_locks'); + } +}; diff --git a/backend/database/migrations/0001_01_01_000002_create_jobs_table.php b/backend/database/migrations/0001_01_01_000002_create_jobs_table.php new file mode 100644 index 0000000..425e705 --- /dev/null +++ b/backend/database/migrations/0001_01_01_000002_create_jobs_table.php @@ -0,0 +1,57 @@ +id(); + $table->string('queue')->index(); + $table->longText('payload'); + $table->unsignedTinyInteger('attempts'); + $table->unsignedInteger('reserved_at')->nullable(); + $table->unsignedInteger('available_at'); + $table->unsignedInteger('created_at'); + }); + + Schema::create('job_batches', function (Blueprint $table) { + $table->string('id')->primary(); + $table->string('name'); + $table->integer('total_jobs'); + $table->integer('pending_jobs'); + $table->integer('failed_jobs'); + $table->longText('failed_job_ids'); + $table->mediumText('options')->nullable(); + $table->integer('cancelled_at')->nullable(); + $table->integer('created_at'); + $table->integer('finished_at')->nullable(); + }); + + Schema::create('failed_jobs', function (Blueprint $table) { + $table->id(); + $table->string('uuid')->unique(); + $table->text('connection'); + $table->text('queue'); + $table->longText('payload'); + $table->longText('exception'); + $table->timestamp('failed_at')->useCurrent(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('jobs'); + Schema::dropIfExists('job_batches'); + Schema::dropIfExists('failed_jobs'); + } +}; diff --git a/backend/database/migrations/2025_12_07_195622_create_personal_access_tokens_table.php b/backend/database/migrations/2025_12_07_195622_create_personal_access_tokens_table.php new file mode 100644 index 0000000..40ff706 --- /dev/null +++ b/backend/database/migrations/2025_12_07_195622_create_personal_access_tokens_table.php @@ -0,0 +1,33 @@ +id(); + $table->morphs('tokenable'); + $table->text('name'); + $table->string('token', 64)->unique(); + $table->text('abilities')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable()->index(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('personal_access_tokens'); + } +}; diff --git a/backend/database/migrations/2025_12_08_000001_create_accounts_table.php b/backend/database/migrations/2025_12_08_000001_create_accounts_table.php new file mode 100644 index 0000000..8c55bfa --- /dev/null +++ b/backend/database/migrations/2025_12_08_000001_create_accounts_table.php @@ -0,0 +1,49 @@ +id(); + $table->foreignId('user_id')->constrained()->onDelete('cascade'); + $table->string('name', 100); + $table->enum('type', ['cash', 'checking', 'savings', 'credit_card', 'asset', 'liability']); + $table->string('bank_name', 100)->nullable(); + $table->string('account_number', 50)->nullable(); + $table->decimal('initial_balance', 15, 2)->default(0); + $table->decimal('current_balance', 15, 2)->default(0); + $table->decimal('credit_limit', 15, 2)->nullable(); // Para cartões de crédito + $table->string('currency', 3)->default('BRL'); + $table->string('color', 7)->default('#1E40AF'); // Cor para identificação visual + $table->string('icon', 50)->default('bi-wallet2'); // Bootstrap icon + $table->text('description')->nullable(); + $table->boolean('is_active')->default(true); + $table->boolean('include_in_total')->default(true); // Incluir no saldo total + $table->timestamps(); + $table->softDeletes(); + + // Índices para performance + $table->index(['user_id', 'type']); + $table->index(['user_id', 'is_active']); + }); + } + + public function down(): void + { + Schema::dropIfExists('accounts'); + } +}; diff --git a/backend/database/migrations/2025_12_08_000002_create_cost_centers_table.php b/backend/database/migrations/2025_12_08_000002_create_cost_centers_table.php new file mode 100644 index 0000000..057de0a --- /dev/null +++ b/backend/database/migrations/2025_12_08_000002_create_cost_centers_table.php @@ -0,0 +1,52 @@ +id(); + $table->foreignId('user_id')->constrained()->onDelete('cascade'); + $table->string('name', 100); + $table->string('code', 20)->nullable(); // Código curto: CC001, PROJ-A + $table->text('description')->nullable(); + $table->string('color', 7)->default('#10B981'); // Verde esmeralda por padrão + $table->string('icon', 50)->default('bi-building'); + $table->boolean('is_active')->default(true); + $table->timestamps(); + $table->softDeletes(); + + // Índices + $table->index(['user_id', 'is_active']); + $table->unique(['user_id', 'code']); + }); + + // Tabela de palavras-chave para aplicação em lote + Schema::create('cost_center_keywords', function (Blueprint $table) { + $table->id(); + $table->foreignId('cost_center_id')->constrained()->onDelete('cascade'); + $table->string('keyword', 100); + $table->boolean('is_case_sensitive')->default(false); + $table->boolean('is_active')->default(true); + $table->timestamps(); + + // Índices para busca rápida + $table->index(['keyword', 'is_active']); + $table->index('cost_center_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('cost_center_keywords'); + Schema::dropIfExists('cost_centers'); + } +}; diff --git a/backend/database/migrations/2025_12_08_000003_create_categories_table.php b/backend/database/migrations/2025_12_08_000003_create_categories_table.php new file mode 100644 index 0000000..8e112ed --- /dev/null +++ b/backend/database/migrations/2025_12_08_000003_create_categories_table.php @@ -0,0 +1,57 @@ + Sub-categorias + * Inclui palavras-chave para aplicação automática em lote + */ + public function up(): void + { + Schema::create('categories', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained()->onDelete('cascade'); + $table->foreignId('parent_id')->nullable()->constrained('categories')->onDelete('cascade'); + $table->string('name', 100); + $table->enum('type', ['income', 'expense', 'both'])->default('expense'); + $table->text('description')->nullable(); + $table->string('color', 7)->default('#3B82F6'); // Azul claro por padrão + $table->string('icon', 50)->default('bi-tag'); + $table->integer('order')->default(0); // Para ordenação personalizada + $table->boolean('is_active')->default(true); + $table->boolean('is_system')->default(false); // Categorias padrão do sistema + $table->timestamps(); + $table->softDeletes(); + + // Índices + $table->index(['user_id', 'type', 'is_active']); + $table->index(['user_id', 'parent_id']); + $table->index(['user_id', 'order']); + }); + + // Tabela de palavras-chave para aplicação em lote + Schema::create('category_keywords', function (Blueprint $table) { + $table->id(); + $table->foreignId('category_id')->constrained()->onDelete('cascade'); + $table->string('keyword', 100); + $table->boolean('is_case_sensitive')->default(false); + $table->boolean('is_active')->default(true); + $table->timestamps(); + + // Índices para busca rápida + $table->index(['keyword', 'is_active']); + $table->index('category_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('category_keywords'); + Schema::dropIfExists('categories'); + } +}; diff --git a/backend/database/migrations/2025_12_08_100534_add_is_system_to_cost_centers_table.php b/backend/database/migrations/2025_12_08_100534_add_is_system_to_cost_centers_table.php new file mode 100644 index 0000000..53ae252 --- /dev/null +++ b/backend/database/migrations/2025_12_08_100534_add_is_system_to_cost_centers_table.php @@ -0,0 +1,29 @@ +boolean('is_system')->default(false)->after('is_active'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('cost_centers', function (Blueprint $table) { + $table->dropColumn('is_system'); + }); + } +}; diff --git a/backend/database/migrations/2025_12_08_102524_add_is_admin_to_users_table.php b/backend/database/migrations/2025_12_08_102524_add_is_admin_to_users_table.php new file mode 100644 index 0000000..7a71e54 --- /dev/null +++ b/backend/database/migrations/2025_12_08_102524_add_is_admin_to_users_table.php @@ -0,0 +1,28 @@ +boolean('is_admin')->default(false)->after('email'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('is_admin'); + }); + } +}; diff --git a/backend/database/migrations/2025_12_08_170001_add_transfer_and_split_fields_to_transactions.php b/backend/database/migrations/2025_12_08_170001_add_transfer_and_split_fields_to_transactions.php new file mode 100644 index 0000000..da04253 --- /dev/null +++ b/backend/database/migrations/2025_12_08_170001_add_transfer_and_split_fields_to_transactions.php @@ -0,0 +1,59 @@ +foreignId('transfer_pair_id') + ->nullable() + ->after('recurring_parent_id') + ->constrained('transactions') + ->nullOnDelete(); + + // Campo para identificar transação pai em caso de divisão + // Transações divididas têm um parent_id apontando para a transação original + $table->foreignId('parent_transaction_id') + ->nullable() + ->after('transfer_pair_id') + ->constrained('transactions') + ->nullOnDelete(); + + // Flag para indicar se é uma transação dividida (filha) + $table->boolean('is_split_child')->default(false)->after('parent_transaction_id'); + + // Flag para indicar se a transação original foi dividida + $table->boolean('is_split_parent')->default(false)->after('is_split_child'); + + // Índices para performance + $table->index('transfer_pair_id'); + $table->index('parent_transaction_id'); + }); + } + + public function down(): void + { + Schema::table('transactions', function (Blueprint $table) { + $table->dropForeign(['transfer_pair_id']); + $table->dropForeign(['parent_transaction_id']); + $table->dropColumn([ + 'transfer_pair_id', + 'parent_transaction_id', + 'is_split_child', + 'is_split_parent', + ]); + }); + } +}; diff --git a/backend/database/migrations/2025_12_08_181427_add_transfer_fields_to_transactions_table.php b/backend/database/migrations/2025_12_08_181427_add_transfer_fields_to_transactions_table.php new file mode 100644 index 0000000..78aa886 --- /dev/null +++ b/backend/database/migrations/2025_12_08_181427_add_transfer_fields_to_transactions_table.php @@ -0,0 +1,55 @@ +boolean('is_transfer')->default(false)->after('is_split_parent'); + } + if (!Schema::hasColumn('transactions', 'transfer_linked_id')) { + $table->unsignedBigInteger('transfer_linked_id')->nullable()->after('is_transfer'); + } + }); + + // Criar tabela para pares de transferência ignorados + if (!Schema::hasTable('ignored_transfer_pairs')) { + Schema::create('ignored_transfer_pairs', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained()->onDelete('cascade'); + $table->unsignedBigInteger('debit_transaction_id'); + $table->unsignedBigInteger('credit_transaction_id'); + $table->timestamps(); + + $table->unique(['user_id', 'debit_transaction_id', 'credit_transaction_id'], 'unique_ignored_pair'); + $table->index('user_id'); + }); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('transactions', function (Blueprint $table) { + if (Schema::hasColumn('transactions', 'is_transfer')) { + $table->dropColumn('is_transfer'); + } + if (Schema::hasColumn('transactions', 'transfer_linked_id')) { + $table->dropColumn('transfer_linked_id'); + } + }); + + Schema::dropIfExists('ignored_transfer_pairs'); + } +}; diff --git a/backend/database/migrations/2025_12_08_200001_create_liability_accounts_table.php b/backend/database/migrations/2025_12_08_200001_create_liability_accounts_table.php new file mode 100644 index 0000000..e7f08eb --- /dev/null +++ b/backend/database/migrations/2025_12_08_200001_create_liability_accounts_table.php @@ -0,0 +1,80 @@ +id(); + $table->foreignId('user_id')->constrained()->onDelete('cascade'); + $table->foreignId('account_id')->nullable()->constrained()->onDelete('set null'); // Vincula à conta geral (opcional) + + // Identificação do contrato + $table->string('name', 150); // Nome do contrato (ex: "Empréstimo Wanna") + $table->string('contract_number', 100)->nullable(); // Número do contrato + $table->string('creditor', 150)->nullable(); // Nome do credor/banco + $table->text('description')->nullable(); + + // Valores do contrato + $table->decimal('principal_amount', 15, 2); // Valor emprestado (capital inicial) + $table->decimal('total_interest', 15, 2)->default(0); // Total de juros do contrato + $table->decimal('total_fees', 15, 2)->default(0); // Total de taxas extras (multas, atrasos) + $table->decimal('total_contract_value', 15, 2)->default(0); // Soma total (principal + juros + taxas) + + // Valores calculados dinamicamente + $table->decimal('total_paid', 15, 2)->default(0); // Total já pago + $table->decimal('total_pending', 15, 2)->default(0); // Total pendente + $table->decimal('principal_paid', 15, 2)->default(0); // Capital já amortizado + $table->decimal('interest_paid', 15, 2)->default(0); // Juros já pagos + $table->decimal('fees_paid', 15, 2)->default(0); // Taxas já pagas + + // Taxas de juros (calculadas a partir dos dados importados) + $table->decimal('monthly_interest_rate', 8, 4)->nullable(); // Taxa mensal (%) + $table->decimal('annual_interest_rate', 8, 4)->nullable(); // Taxa anual (%) + $table->decimal('total_interest_rate', 8, 4)->nullable(); // Taxa total do contrato (%) + + // Parcelas + $table->integer('total_installments')->default(0); // Total de parcelas + $table->integer('paid_installments')->default(0); // Parcelas pagas + $table->integer('pending_installments')->default(0); // Parcelas pendentes + + // Datas + $table->date('start_date')->nullable(); // Data de início do contrato + $table->date('end_date')->nullable(); // Data prevista de término + $table->date('first_due_date')->nullable(); // Data do primeiro vencimento + + // Configurações + $table->string('currency', 3)->default('EUR'); + $table->string('color', 7)->default('#DC2626'); // Vermelho para passivos + $table->string('icon', 50)->default('bi-file-earmark-text'); + $table->enum('status', ['active', 'paid_off', 'defaulted', 'renegotiated'])->default('active'); + $table->boolean('is_active')->default(true); + + $table->timestamps(); + $table->softDeletes(); + + // Índices + $table->index(['user_id', 'status']); + $table->index(['user_id', 'is_active']); + }); + } + + public function down(): void + { + Schema::dropIfExists('liability_accounts'); + } +}; diff --git a/backend/database/migrations/2025_12_08_200002_create_liability_installments_table.php b/backend/database/migrations/2025_12_08_200002_create_liability_installments_table.php new file mode 100644 index 0000000..39b2489 --- /dev/null +++ b/backend/database/migrations/2025_12_08_200002_create_liability_installments_table.php @@ -0,0 +1,63 @@ +id(); + $table->foreignId('liability_account_id')->constrained()->onDelete('cascade'); + + // Identificação da parcela + $table->integer('installment_number'); // Número da parcela (1, 2, 3...) + $table->date('due_date'); // Data de vencimento + + // Valores da parcela + $table->decimal('installment_amount', 15, 2); // Valor total da parcela (cuota) + $table->decimal('principal_amount', 15, 2)->default(0); // Capital (amortização) + $table->decimal('interest_amount', 15, 2)->default(0); // Juros + $table->decimal('fee_amount', 15, 2)->default(0); // Taxas extras (multas, atrasos) + + // Valores pagos (pode ser parcial) + $table->decimal('paid_amount', 15, 2)->default(0); // Valor efetivamente pago + $table->date('paid_date')->nullable(); // Data do pagamento + + // Estado + $table->enum('status', ['pending', 'paid', 'partial', 'overdue', 'cancelled'])->default('pending'); + + // Referência para conciliação futura + // Quando implementarmos conciliação, estas colunas serão populadas + $table->foreignId('reconciled_transaction_id')->nullable(); // ID da transação conciliada + $table->foreignId('payment_account_id')->nullable()->constrained('accounts')->onDelete('set null'); // Conta usada para pagar + + $table->text('notes')->nullable(); + + $table->timestamps(); + $table->softDeletes(); + + // Índices + $table->index(['liability_account_id', 'status'], 'liab_inst_account_status_idx'); + $table->index(['liability_account_id', 'due_date'], 'liab_inst_account_due_idx'); + $table->unique(['liability_account_id', 'installment_number'], 'liab_inst_unique'); + }); + } + + public function down(): void + { + Schema::dropIfExists('liability_installments'); + } +}; diff --git a/backend/database/migrations/2025_12_08_210001_create_transactions_table.php b/backend/database/migrations/2025_12_08_210001_create_transactions_table.php new file mode 100644 index 0000000..64475b4 --- /dev/null +++ b/backend/database/migrations/2025_12_08_210001_create_transactions_table.php @@ -0,0 +1,63 @@ +id(); + $table->foreignId('user_id')->constrained()->onDelete('cascade'); + $table->foreignId('account_id')->constrained()->onDelete('cascade'); + $table->foreignId('category_id')->nullable()->constrained()->onDelete('set null'); + $table->foreignId('cost_center_id')->nullable()->constrained()->onDelete('set null'); + + // Valores + $table->decimal('amount', 15, 2)->nullable(); // Valor efetivo (quando pago/recebido) + $table->decimal('planned_amount', 15, 2); // Valor planejado/previsto + + // Tipo e descrição + $table->enum('type', ['credit', 'debit']); // crédito (entrada) ou débito (saída) + $table->string('description', 255); + $table->text('notes')->nullable(); // Observações adicionais + + // Datas + $table->date('effective_date')->nullable(); // Data efetiva (quando ocorreu) + $table->date('planned_date'); // Data planejada/prevista + + // Status + $table->enum('status', ['pending', 'completed', 'cancelled'])->default('pending'); + // pending = pendente (planejado) + // completed = concluída (efetivada) + // cancelled = cancelada + + // Controle + $table->string('reference', 100)->nullable(); // Referência externa (nº documento, etc) + $table->boolean('is_recurring')->default(false); // Se é recorrente + $table->foreignId('recurring_parent_id')->nullable(); // ID da transação pai (se for recorrência) + + $table->timestamps(); + $table->softDeletes(); + + // Índices para performance + $table->index(['user_id', 'account_id', 'status']); + $table->index(['user_id', 'planned_date']); + $table->index(['user_id', 'effective_date']); + $table->index(['user_id', 'type', 'status']); + $table->index(['user_id', 'category_id']); + $table->index(['user_id', 'cost_center_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('transactions'); + } +}; diff --git a/backend/database/migrations/2025_12_08_230001_add_duplicate_ignored_with_to_transactions.php b/backend/database/migrations/2025_12_08_230001_add_duplicate_ignored_with_to_transactions.php new file mode 100644 index 0000000..4401c24 --- /dev/null +++ b/backend/database/migrations/2025_12_08_230001_add_duplicate_ignored_with_to_transactions.php @@ -0,0 +1,29 @@ +text('duplicate_ignored_with')->nullable()->after('import_hash'); + }); + } + + public function down(): void + { + Schema::table('transactions', function (Blueprint $table) { + $table->dropColumn('duplicate_ignored_with'); + }); + } +}; diff --git a/backend/database/migrations/2025_12_09_100001_create_import_mappings_table.php b/backend/database/migrations/2025_12_09_100001_create_import_mappings_table.php new file mode 100644 index 0000000..31e4973 --- /dev/null +++ b/backend/database/migrations/2025_12_09_100001_create_import_mappings_table.php @@ -0,0 +1,73 @@ +id(); + $table->foreignId('user_id')->constrained()->onDelete('cascade'); + $table->string('name'); // Nome do mapeamento (ex: "BBVA Extrato", "Santander") + $table->string('bank_name')->nullable(); // Nome do banco + $table->string('file_type'); // xlsx, xls, csv, ofx, pdf + $table->integer('header_row')->default(0); // Linha do cabeçalho (0-indexed) + $table->integer('data_start_row')->default(1); // Linha onde começam os dados + $table->string('date_format')->default('d/m/Y'); // Formato da data + $table->string('decimal_separator')->default(','); // Separador decimal + $table->string('thousands_separator')->default('.'); // Separador de milhares + $table->json('column_mappings'); // Mapeamento das colunas + /* + * Formato do column_mappings: + * { + * "effective_date": {"columns": [0], "concat_separator": null}, + * "planned_date": {"columns": [1], "concat_separator": null}, + * "description": {"columns": [2, 3], "concat_separator": " - "}, // Concatenar colunas 2 e 3 + * "amount": {"columns": [4], "concat_separator": null}, + * "notes": {"columns": [8], "concat_separator": null} + * } + */ + $table->foreignId('default_account_id')->nullable()->constrained('accounts')->onDelete('set null'); + $table->foreignId('default_category_id')->nullable()->constrained('categories')->onDelete('set null'); + $table->foreignId('default_cost_center_id')->nullable()->constrained('cost_centers')->onDelete('set null'); + $table->boolean('is_active')->default(true); + $table->timestamps(); + + $table->index(['user_id', 'name']); + $table->index(['user_id', 'bank_name']); + }); + + // Tabela para histórico de importações + Schema::create('import_logs', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained()->onDelete('cascade'); + $table->foreignId('import_mapping_id')->nullable()->constrained('import_mappings')->onDelete('set null'); + $table->string('original_filename'); + $table->string('file_type'); + $table->integer('total_rows')->default(0); + $table->integer('imported_rows')->default(0); + $table->integer('skipped_rows')->default(0); + $table->integer('error_rows')->default(0); + $table->json('errors')->nullable(); + $table->enum('status', ['pending', 'processing', 'completed', 'failed'])->default('pending'); + $table->timestamps(); + + $table->index(['user_id', 'status']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('import_logs'); + Schema::dropIfExists('import_mappings'); + } +}; diff --git a/backend/database/migrations/2025_12_09_150001_add_import_hash_to_transactions_table.php b/backend/database/migrations/2025_12_09_150001_add_import_hash_to_transactions_table.php new file mode 100644 index 0000000..f7c0141 --- /dev/null +++ b/backend/database/migrations/2025_12_09_150001_add_import_hash_to_transactions_table.php @@ -0,0 +1,41 @@ +string('original_description', 500)->nullable()->after('description'); + + // Saldo após a transação (extraído do extrato bancário) + $table->decimal('balance_after', 15, 2)->nullable()->after('amount'); + + // Hash único para evitar duplicidade na importação + // Gerado com: data + valor + saldo + descrição_original + $table->string('import_hash', 64)->nullable()->unique()->after('reference'); + + // ID do log de importação que criou esta transação + $table->foreignId('import_log_id')->nullable()->after('import_hash') + ->constrained('import_logs')->onDelete('set null'); + }); + } + + public function down(): void + { + Schema::table('transactions', function (Blueprint $table) { + $table->dropForeign(['import_log_id']); + $table->dropColumn(['original_description', 'balance_after', 'import_hash', 'import_log_id']); + }); + } +}; diff --git a/backend/database/migrations/2025_12_09_160001_remove_balance_after_from_transactions_table.php b/backend/database/migrations/2025_12_09_160001_remove_balance_after_from_transactions_table.php new file mode 100644 index 0000000..59d067b --- /dev/null +++ b/backend/database/migrations/2025_12_09_160001_remove_balance_after_from_transactions_table.php @@ -0,0 +1,26 @@ +dropColumn('balance_after'); + }); + } + + public function down(): void + { + Schema::table('transactions', function (Blueprint $table) { + $table->decimal('balance_after', 15, 2)->nullable()->after('amount'); + }); + } +}; diff --git a/backend/database/migrations/2025_12_10_000001_optimize_database_for_scalability.php b/backend/database/migrations/2025_12_10_000001_optimize_database_for_scalability.php new file mode 100644 index 0000000..d2ddc14 --- /dev/null +++ b/backend/database/migrations/2025_12_10_000001_optimize_database_for_scalability.php @@ -0,0 +1,173 @@ + 0; + } + + private function columnExists(string $table, string $column): bool + { + return Schema::hasColumn($table, $column); + } + + public function up(): void + { + // ============================================================ + // 1. ÍNDICES COMPOSTOS PARA RELATÓRIOS (Dashboard) + // ============================================================ + + if (!$this->indexExists('transactions', 'idx_trans_dashboard_period')) { + DB::statement('CREATE INDEX idx_trans_dashboard_period ON transactions (user_id, account_id, effective_date, type)'); + } + + if (!$this->indexExists('transactions', 'idx_trans_category_sum')) { + DB::statement('CREATE INDEX idx_trans_category_sum ON transactions (user_id, category_id, effective_date, amount)'); + } + + if (!$this->indexExists('transactions', 'idx_trans_duplicate_check')) { + DB::statement('CREATE INDEX idx_trans_duplicate_check ON transactions (user_id, account_id, effective_date, amount, description(100))'); + } + + // ============================================================ + // 2. ÍNDICES FULLTEXT PARA KEYWORDS (Auto-categorização) + // ============================================================ + + if (!$this->indexExists('category_keywords', 'ft_keyword')) { + DB::statement('CREATE FULLTEXT INDEX ft_keyword ON category_keywords (keyword)'); + } + + if (!$this->indexExists('cost_center_keywords', 'ft_cc_keyword')) { + DB::statement('CREATE FULLTEXT INDEX ft_cc_keyword ON cost_center_keywords (keyword)'); + } + + // ============================================================ + // 3. ÍNDICE FULLTEXT PARA BUSCA DE TEXTO (Transactions) + // ============================================================ + + if (!$this->indexExists('transactions', 'ft_trans_description')) { + DB::statement('CREATE FULLTEXT INDEX ft_trans_description ON transactions (description)'); + } + + // ============================================================ + // 4. COLUNA DE PERÍODO PARA PARTICIONAMENTO FUTURO + // ============================================================ + + if (!$this->columnExists('transactions', 'period')) { + DB::statement("ALTER TABLE transactions ADD COLUMN period CHAR(7) NULL COMMENT 'YYYY-MM for future partitioning' AFTER effective_date"); + + // Preencher coluna period com dados existentes + DB::statement("UPDATE transactions SET period = DATE_FORMAT(effective_date, '%Y-%m') WHERE period IS NULL AND effective_date IS NOT NULL"); + + // Índice para queries por período + DB::statement('CREATE INDEX idx_trans_period ON transactions (user_id, period)'); + } + + // ============================================================ + // 5. TRIGGERS PARA MANTER PERIOD ATUALIZADO + // ============================================================ + + DB::unprepared('DROP TRIGGER IF EXISTS tr_transactions_period_insert'); + DB::unprepared(" + CREATE TRIGGER tr_transactions_period_insert + BEFORE INSERT ON transactions + FOR EACH ROW + BEGIN + IF NEW.period IS NULL AND NEW.effective_date IS NOT NULL THEN + SET NEW.period = DATE_FORMAT(NEW.effective_date, '%Y-%m'); + END IF; + END + "); + + DB::unprepared('DROP TRIGGER IF EXISTS tr_transactions_period_update'); + DB::unprepared(" + CREATE TRIGGER tr_transactions_period_update + BEFORE UPDATE ON transactions + FOR EACH ROW + BEGIN + IF NEW.effective_date != OLD.effective_date OR NEW.period IS NULL THEN + SET NEW.period = DATE_FORMAT(NEW.effective_date, '%Y-%m'); + END IF; + END + "); + + // ============================================================ + // 6. ÍNDICE PARA LIABILITY (Parcelas a vencer) + // ============================================================ + + if (!$this->indexExists('liability_installments', 'idx_liab_pending_due')) { + DB::statement('CREATE INDEX idx_liab_pending_due ON liability_installments (status, due_date)'); + } + + // ============================================================ + // 7. OTIMIZAÇÃO DE COLUNAS (ENUM - menor footprint) + // ============================================================ + + DB::statement("ALTER TABLE transactions MODIFY COLUMN status ENUM('effective', 'pending', 'scheduled', 'cancelled', 'completed') NOT NULL DEFAULT 'effective'"); + DB::statement("ALTER TABLE transactions MODIFY COLUMN type ENUM('credit', 'debit', 'income', 'expense', 'transfer') NOT NULL"); + + // ============================================================ + // 8. ATUALIZAR ESTATÍSTICAS PARA OTIMIZADOR + // ============================================================ + + DB::statement('ANALYZE TABLE transactions'); + DB::statement('ANALYZE TABLE categories'); + DB::statement('ANALYZE TABLE category_keywords'); + DB::statement('ANALYZE TABLE accounts'); + } + + public function down(): void + { + // Remover triggers + DB::unprepared('DROP TRIGGER IF EXISTS tr_transactions_period_insert'); + DB::unprepared('DROP TRIGGER IF EXISTS tr_transactions_period_update'); + + // Remover índices de transactions + if ($this->indexExists('transactions', 'idx_trans_dashboard_period')) { + DB::statement('DROP INDEX idx_trans_dashboard_period ON transactions'); + } + if ($this->indexExists('transactions', 'idx_trans_category_sum')) { + DB::statement('DROP INDEX idx_trans_category_sum ON transactions'); + } + if ($this->indexExists('transactions', 'idx_trans_duplicate_check')) { + DB::statement('DROP INDEX idx_trans_duplicate_check ON transactions'); + } + if ($this->indexExists('transactions', 'ft_trans_description')) { + DB::statement('DROP INDEX ft_trans_description ON transactions'); + } + if ($this->indexExists('transactions', 'idx_trans_period')) { + DB::statement('DROP INDEX idx_trans_period ON transactions'); + } + + // Remover coluna period + if ($this->columnExists('transactions', 'period')) { + DB::statement('ALTER TABLE transactions DROP COLUMN period'); + } + + // Remover full-text de keywords + if ($this->indexExists('category_keywords', 'ft_keyword')) { + DB::statement('DROP INDEX ft_keyword ON category_keywords'); + } + if ($this->indexExists('cost_center_keywords', 'ft_cc_keyword')) { + DB::statement('DROP INDEX ft_cc_keyword ON cost_center_keywords'); + } + + // Remover índice de liability + if ($this->indexExists('liability_installments', 'idx_liab_pending_due')) { + DB::statement('DROP INDEX idx_liab_pending_due ON liability_installments'); + } + + // Reverter ENUM para VARCHAR + DB::statement("ALTER TABLE transactions MODIFY COLUMN status VARCHAR(255) NOT NULL DEFAULT 'effective'"); + DB::statement("ALTER TABLE transactions MODIFY COLUMN type VARCHAR(255) NOT NULL"); + } +}; diff --git a/backend/database/migrations/2025_12_10_100001_create_recurring_templates_table.php b/backend/database/migrations/2025_12_10_100001_create_recurring_templates_table.php new file mode 100644 index 0000000..2713661 --- /dev/null +++ b/backend/database/migrations/2025_12_10_100001_create_recurring_templates_table.php @@ -0,0 +1,75 @@ +id(); + $table->foreignId('user_id')->constrained()->onDelete('cascade'); + $table->foreignId('source_transaction_id')->nullable()->constrained('transactions')->onDelete('set null'); + + // Identificação + $table->string('name'); // "Aluguel Apartamento" + $table->text('description')->nullable(); + + // Frequência + $table->enum('frequency', [ + 'daily', // Diária + 'weekly', // Semanal + 'biweekly', // Quinzenal + 'monthly', // Mensal + 'bimonthly', // Bimestral + 'quarterly', // Trimestral + 'semiannual', // Semestral + 'annual' // Anual + ]); + $table->unsignedTinyInteger('frequency_interval')->default(1); // A cada X períodos + + // Configuração de dia + $table->unsignedTinyInteger('day_of_month')->nullable(); // 1-31 para mensal+ + $table->unsignedTinyInteger('day_of_week')->nullable(); // 0-6 para semanal (0=domingo) + + // Período de vigência + $table->date('start_date'); + $table->date('end_date')->nullable(); // null = infinito + $table->unsignedInteger('max_occurrences')->nullable(); // null = infinito + + // Dados do template (herdados nas instâncias) + $table->foreignId('account_id')->constrained()->onDelete('cascade'); + $table->foreignId('category_id')->nullable()->constrained()->onDelete('set null'); + $table->foreignId('cost_center_id')->nullable()->constrained()->onDelete('set null'); + $table->enum('type', ['credit', 'debit']); + $table->decimal('planned_amount', 15, 2); + $table->string('transaction_description', 255); // Descrição que vai na transação + $table->text('notes')->nullable(); + + // Controle + $table->boolean('is_active')->default(true); + $table->date('last_generated_date')->nullable(); + $table->unsignedInteger('occurrences_generated')->default(0); + + $table->timestamps(); + $table->softDeletes(); + + // Índices + $table->index(['user_id', 'is_active']); + $table->index(['user_id', 'frequency']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('recurring_templates'); + } +}; diff --git a/backend/database/migrations/2025_12_10_100002_create_recurring_instances_table.php b/backend/database/migrations/2025_12_10_100002_create_recurring_instances_table.php new file mode 100644 index 0000000..90b6210 --- /dev/null +++ b/backend/database/migrations/2025_12_10_100002_create_recurring_instances_table.php @@ -0,0 +1,71 @@ +id(); + $table->foreignId('user_id')->constrained()->onDelete('cascade'); + $table->foreignId('recurring_template_id')->constrained()->onDelete('cascade'); + + // Dados da instância + $table->unsignedInteger('occurrence_number'); // 1, 2, 3... + $table->date('due_date'); // Data de vencimento + $table->decimal('planned_amount', 15, 2); // Valor previsto + + // Status da instância + $table->enum('status', [ + 'pending', // Aguardando pagamento + 'paid', // Pago/Conciliado + 'skipped', // Pulado (ex: férias) + 'cancelled' // Cancelado + ])->default('pending'); + + // Conciliação com transação + $table->foreignId('transaction_id')->nullable()->constrained()->onDelete('set null'); + $table->dateTime('paid_at')->nullable(); + $table->decimal('paid_amount', 15, 2)->nullable(); + $table->text('paid_notes')->nullable(); + + $table->timestamps(); + $table->softDeletes(); + + // Índices + $table->index(['user_id', 'status'], 'ri_user_status_idx'); + $table->index(['user_id', 'due_date'], 'ri_user_due_idx'); + $table->index(['recurring_template_id', 'occurrence_number'], 'ri_template_occ_idx'); + $table->index(['recurring_template_id', 'status'], 'ri_template_status_idx'); + $table->unique(['recurring_template_id', 'occurrence_number'], 'ri_template_occ_unique'); + }); + + // Adicionar campo na tabela transactions para link reverso + Schema::table('transactions', function (Blueprint $table) { + $table->foreignId('recurring_instance_id') + ->nullable() + ->after('import_log_id') + ->constrained('recurring_instances') + ->onDelete('set null'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('transactions', function (Blueprint $table) { + $table->dropForeign(['recurring_instance_id']); + $table->dropColumn('recurring_instance_id'); + }); + + Schema::dropIfExists('recurring_instances'); + } +}; diff --git a/backend/database/migrations/2025_12_11_100001_add_refund_fields_to_transactions.php b/backend/database/migrations/2025_12_11_100001_add_refund_fields_to_transactions.php new file mode 100644 index 0000000..eee9c42 --- /dev/null +++ b/backend/database/migrations/2025_12_11_100001_add_refund_fields_to_transactions.php @@ -0,0 +1,46 @@ +boolean('is_refund_pair')->default(false)->after('is_transfer'); + $table->foreignId('refund_linked_id')->nullable()->after('is_refund_pair') + ->constrained('transactions')->onDelete('set null'); + }); + + // Criar tabela para pares de reembolso ignorados + Schema::create('ignored_refund_pairs', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained()->onDelete('cascade'); + $table->foreignId('debit_id')->constrained('transactions')->onDelete('cascade'); + $table->foreignId('credit_id')->constrained('transactions')->onDelete('cascade'); + $table->timestamp('created_at')->useCurrent(); + + $table->unique(['user_id', 'debit_id', 'credit_id']); + $table->index(['user_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('ignored_refund_pairs'); + + Schema::table('transactions', function (Blueprint $table) { + $table->dropForeign(['refund_linked_id']); + $table->dropColumn(['is_refund_pair', 'refund_linked_id']); + }); + } +}; diff --git a/backend/database/seeders/CategoriesOnlySeeder.php b/backend/database/seeders/CategoriesOnlySeeder.php new file mode 100644 index 0000000..d936a27 --- /dev/null +++ b/backend/database/seeders/CategoriesOnlySeeder.php @@ -0,0 +1,354 @@ +now = Carbon::now(); + + // Pegar o primeiro usuário ou o ID passado + $this->userId = DB::table('users')->first()->id ?? 1; + + // Limpar categorias existentes do usuário + DB::table('category_keywords') + ->whereIn('category_id', function($query) { + $query->select('id')->from('categories')->where('user_id', $this->userId); + })->delete(); + DB::table('categories')->where('user_id', $this->userId)->delete(); + + // ===================================================================== + // CATEGORIAS DE DESPESA (expense) + // ===================================================================== + + // 🏠 MORADIA + $moradia = $this->createCategory('Moradia', 'expense', '#8B5CF6', 'bi-house-door-fill', 'Gastos com habitação'); + $this->createSubcategories($moradia, [ + ['name' => 'Aluguel', 'icon' => 'bi-key-fill', 'color' => '#7C3AED'], + ['name' => 'Hipoteca', 'icon' => 'bi-bank2', 'color' => '#6D28D9'], + ['name' => 'Condomínio', 'icon' => 'bi-buildings-fill', 'color' => '#5B21B6'], + ['name' => 'Água', 'icon' => 'bi-droplet-fill', 'color' => '#3B82F6'], + ['name' => 'Eletricidade', 'icon' => 'bi-lightning-charge-fill', 'color' => '#F59E0B'], + ['name' => 'Gás', 'icon' => 'bi-fire', 'color' => '#EF4444'], + ['name' => 'Internet/Telefone', 'icon' => 'bi-wifi', 'color' => '#06B6D4'], + ['name' => 'Seguro Residencial', 'icon' => 'bi-shield-fill-check', 'color' => '#10B981'], + ['name' => 'Manutenção/Reparos', 'icon' => 'bi-tools', 'color' => '#6B7280'], + ['name' => 'Móveis', 'icon' => 'bi-house-gear-fill', 'color' => '#78716C'], + ['name' => 'Decoração', 'icon' => 'bi-paint-bucket', 'color' => '#EC4899'], + ['name' => 'Limpeza Casa', 'icon' => 'bi-trash2-fill', 'color' => '#14B8A6'], + ]); + + // 🍽️ ALIMENTAÇÃO + $alimentacao = $this->createCategory('Alimentação', 'expense', '#22C55E', 'bi-cart4', 'Comida e bebidas'); + $this->createSubcategories($alimentacao, [ + ['name' => 'Supermercado', 'icon' => 'bi-basket3-fill', 'color' => '#16A34A'], + ['name' => 'Padaria', 'icon' => 'bi-cake2-fill', 'color' => '#CA8A04'], + ['name' => 'Açougue', 'icon' => 'bi-egg-fried', 'color' => '#DC2626'], + ['name' => 'Peixaria', 'icon' => 'bi-water', 'color' => '#0891B2'], + ['name' => 'Frutas/Verduras', 'icon' => 'bi-basket-fill', 'color' => '#65A30D'], + ['name' => 'Bebidas', 'icon' => 'bi-cup-straw', 'color' => '#7C3AED'], + ['name' => 'Delivery', 'icon' => 'bi-box-seam-fill', 'color' => '#F97316'], + ['name' => 'Café/Snacks', 'icon' => 'bi-cup-hot-fill', 'color' => '#92400E'], + ]); + + // 🍴 RESTAURANTES E BARES + $restaurantes = $this->createCategory('Restaurantes e Bares', 'expense', '#F97316', 'bi-cup-straw', 'Comer fora'); + $this->createSubcategories($restaurantes, [ + ['name' => 'Restaurante', 'icon' => 'bi-shop-window', 'color' => '#EA580C'], + ['name' => 'Fast Food', 'icon' => 'bi-bag-fill', 'color' => '#DC2626'], + ['name' => 'Bar/Cafetería', 'icon' => 'bi-cup-hot', 'color' => '#92400E'], + ['name' => 'Discoteca/Club', 'icon' => 'bi-music-note-beamed', 'color' => '#7C3AED'], + ['name' => 'Tapas', 'icon' => 'bi-egg-fill', 'color' => '#CA8A04'], + ['name' => 'Comida Asiática', 'icon' => 'bi-yin-yang', 'color' => '#DC2626'], + ['name' => 'Comida Italiana', 'icon' => 'bi-circle', 'color' => '#16A34A'], + ['name' => 'Comida Mexicana', 'icon' => 'bi-fire', 'color' => '#EA580C'], + ['name' => 'Kebab', 'icon' => 'bi-globe2', 'color' => '#854D0E'], + ]); + + // 🚗 TRANSPORTE + $transporte = $this->createCategory('Transporte', 'expense', '#3B82F6', 'bi-car-front', 'Deslocamentos'); + $this->createSubcategories($transporte, [ + ['name' => 'Metro/Bus', 'icon' => 'bi-train-lightrail-front-fill', 'color' => '#2563EB'], + ['name' => 'Taxi/VTC', 'icon' => 'bi-taxi-front-fill', 'color' => '#CA8A04'], + ['name' => 'Combustível', 'icon' => 'bi-fuel-pump-fill', 'color' => '#DC2626'], + ['name' => 'Estacionamento', 'icon' => 'bi-p-circle-fill', 'color' => '#0891B2'], + ['name' => 'Pedágios', 'icon' => 'bi-sign-stop-fill', 'color' => '#7C2D12'], + ['name' => 'Seguro Carro', 'icon' => 'bi-shield-fill-check', 'color' => '#059669'], + ['name' => 'Manutenção Carro', 'icon' => 'bi-wrench-adjustable', 'color' => '#525252'], + ['name' => 'Multas', 'icon' => 'bi-exclamation-triangle-fill', 'color' => '#DC2626'], + ['name' => 'Aluguel Carro', 'icon' => 'bi-key-fill', 'color' => '#4338CA'], + ['name' => 'BiciMAD/Patinetes', 'icon' => 'bi-scooter', 'color' => '#65A30D'], + ['name' => 'Carsharing', 'icon' => 'bi-ev-front-fill', 'color' => '#0D9488'], + ['name' => 'AVE/Trem', 'icon' => 'bi-train-front-fill', 'color' => '#7C3AED'], + ['name' => 'Avião', 'icon' => 'bi-airplane-engines-fill', 'color' => '#0284C7'], + ['name' => 'BlaBlaCar', 'icon' => 'bi-people-fill', 'color' => '#0891B2'], + ]); + + // 🛒 COMPRAS + $compras = $this->createCategory('Compras', 'expense', '#EC4899', 'bi-bag', 'Compras diversas'); + $this->createSubcategories($compras, [ + ['name' => 'Roupas', 'icon' => 'bi-handbag-fill', 'color' => '#DB2777'], + ['name' => 'Calçados', 'icon' => 'bi-boot', 'color' => '#9333EA'], + ['name' => 'Acessórios', 'icon' => 'bi-watch', 'color' => '#C026D3'], + ['name' => 'Eletrônicos', 'icon' => 'bi-laptop', 'color' => '#2563EB'], + ['name' => 'Livros/Papelaria', 'icon' => 'bi-book', 'color' => '#854D0E'], + ['name' => 'Presentes', 'icon' => 'bi-gift', 'color' => '#DC2626'], + ['name' => 'Bazar/Casa', 'icon' => 'bi-house', 'color' => '#F59E0B'], + ['name' => 'Bricolagem', 'icon' => 'bi-tools', 'color' => '#525252'], + ['name' => 'Amazon/Online', 'icon' => 'bi-box-seam', 'color' => '#F97316'], + ]); + + // 💊 SAÚDE + $saude = $this->createCategory('Saúde', 'expense', '#EF4444', 'bi-heart-pulse', 'Cuidados médicos'); + $this->createSubcategories($saude, [ + ['name' => 'Farmácia', 'icon' => 'bi-capsule-pill', 'color' => '#16A34A'], + ['name' => 'Médico/Consulta', 'icon' => 'bi-hospital-fill', 'color' => '#0891B2'], + ['name' => 'Seguro Saúde', 'icon' => 'bi-shield-heart-fill', 'color' => '#DC2626'], + ['name' => 'Dentista', 'icon' => 'bi-emoji-smile-fill', 'color' => '#0EA5E9'], + ['name' => 'Ótica', 'icon' => 'bi-eyeglasses', 'color' => '#4338CA'], + ['name' => 'Análises/Exames', 'icon' => 'bi-clipboard2-pulse-fill', 'color' => '#7C3AED'], + ['name' => 'Psicólogo/Terapia', 'icon' => 'bi-chat-heart-fill', 'color' => '#EC4899'], + ['name' => 'Fisioterapia', 'icon' => 'bi-bandaid-fill', 'color' => '#F59E0B'], + ['name' => 'Hospital/Urgências', 'icon' => 'bi-hospital', 'color' => '#DC2626'], + ]); + + // 💇 BELEZA E CUIDADO PESSOAL + $beleza = $this->createCategory('Beleza e Cuidado Pessoal', 'expense', '#D946EF', 'bi-scissors', 'Estética e higiene'); + $this->createSubcategories($beleza, [ + ['name' => 'Cabeleireiro', 'icon' => 'bi-scissors', 'color' => '#A21CAF'], + ['name' => 'Manicure/Pedicure', 'icon' => 'bi-brush-fill', 'color' => '#DB2777'], + ['name' => 'Cosméticos', 'icon' => 'bi-bag-heart-fill', 'color' => '#EC4899'], + ['name' => 'Perfumaria', 'icon' => 'bi-droplet-half', 'color' => '#8B5CF6'], + ['name' => 'Spa/Massagem', 'icon' => 'bi-emoji-relaxed-fill', 'color' => '#0D9488'], + ['name' => 'Depilação', 'icon' => 'bi-stars', 'color' => '#F472B6'], + ['name' => 'Higiene Pessoal', 'icon' => 'bi-shop', 'color' => '#0891B2'], + ]); + + // 🎓 EDUCAÇÃO + $educacao = $this->createCategory('Educação', 'expense', '#0EA5E9', 'bi-mortarboard', 'Formação e estudos'); + $this->createSubcategories($educacao, [ + ['name' => 'Mensalidade Escolar', 'icon' => 'bi-building-fill', 'color' => '#0369A1'], + ['name' => 'Universidade', 'icon' => 'bi-mortarboard-fill', 'color' => '#1D4ED8'], + ['name' => 'Cursos/Formação', 'icon' => 'bi-laptop', 'color' => '#7C3AED'], + ['name' => 'Idiomas', 'icon' => 'bi-translate', 'color' => '#059669'], + ['name' => 'Material Escolar', 'icon' => 'bi-pencil-fill', 'color' => '#CA8A04'], + ['name' => 'Aulas Particulares', 'icon' => 'bi-person-video3', 'color' => '#4338CA'], + ]); + + // 🎮 ENTRETENIMENTO + $entretenimento = $this->createCategory('Entretenimento', 'expense', '#A855F7', 'bi-controller', 'Lazer e diversão'); + $this->createSubcategories($entretenimento, [ + ['name' => 'Streaming', 'icon' => 'bi-tv-fill', 'color' => '#DC2626'], + ['name' => 'Música/Spotify', 'icon' => 'bi-music-note-beamed', 'color' => '#16A34A'], + ['name' => 'Cinema', 'icon' => 'bi-film', 'color' => '#1D4ED8'], + ['name' => 'Teatro/Shows', 'icon' => 'bi-mask', 'color' => '#C026D3'], + ['name' => 'Museu/Exposições', 'icon' => 'bi-palette-fill', 'color' => '#F59E0B'], + ['name' => 'Concertos', 'icon' => 'bi-megaphone-fill', 'color' => '#7C3AED'], + ['name' => 'Jogos/Gaming', 'icon' => 'bi-controller', 'color' => '#059669'], + ['name' => 'Parques/Zoo', 'icon' => 'bi-emoji-smile', 'color' => '#65A30D'], + ['name' => 'Eventos Esportivos', 'icon' => 'bi-trophy-fill', 'color' => '#CA8A04'], + ]); + + // 🏋️ FITNESS E ESPORTES + $fitness = $this->createCategory('Fitness e Esportes', 'expense', '#14B8A6', 'bi-bicycle', 'Atividades físicas'); + $this->createSubcategories($fitness, [ + ['name' => 'Academia/Gimnasio', 'icon' => 'bi-buildings-fill', 'color' => '#0F766E'], + ['name' => 'Yoga/Pilates', 'icon' => 'bi-heart-fill', 'color' => '#EC4899'], + ['name' => 'Piscina', 'icon' => 'bi-droplet-fill', 'color' => '#0891B2'], + ['name' => 'Equipamento Esportivo', 'icon' => 'bi-handbag', 'color' => '#2563EB'], + ['name' => 'Futebol/Padel', 'icon' => 'bi-trophy', 'color' => '#16A34A'], + ['name' => 'Corrida/Running', 'icon' => 'bi-lightning-fill', 'color' => '#F97316'], + ['name' => 'Bicicleta', 'icon' => 'bi-bicycle', 'color' => '#65A30D'], + ]); + + // ✈️ VIAGENS + $viagens = $this->createCategory('Viagens', 'expense', '#06B6D4', 'bi-airplane', 'Turismo e férias'); + $this->createSubcategories($viagens, [ + ['name' => 'Hospedagem/Hotel', 'icon' => 'bi-building', 'color' => '#0891B2'], + ['name' => 'Passagens', 'icon' => 'bi-airplane-fill', 'color' => '#2563EB'], + ['name' => 'Passeios/Tours', 'icon' => 'bi-signpost-fill', 'color' => '#16A34A'], + ['name' => 'Seguro Viagem', 'icon' => 'bi-shield-check', 'color' => '#7C3AED'], + ['name' => 'Souvenirs', 'icon' => 'bi-gift', 'color' => '#EC4899'], + ['name' => 'Alimentação Viagem', 'icon' => 'bi-cup-straw', 'color' => '#F97316'], + ]); + + // 🐕 PETS/MASCOTAS + $pets = $this->createCategory('Pets/Mascotas', 'expense', '#F59E0B', 'bi-heart', 'Animais de estimação'); + $this->createSubcategories($pets, [ + ['name' => 'Ração/Comida', 'icon' => 'bi-cup-fill', 'color' => '#92400E'], + ['name' => 'Veterinário', 'icon' => 'bi-heart-pulse-fill', 'color' => '#DC2626'], + ['name' => 'Acessórios Pet', 'icon' => 'bi-bag', 'color' => '#7C3AED'], + ['name' => 'Peluquería Canina', 'icon' => 'bi-scissors', 'color' => '#EC4899'], + ['name' => 'Seguro Mascota', 'icon' => 'bi-shield-fill-plus', 'color' => '#059669'], + ]); + + // 🏦 FINANCEIRO + $financeiro = $this->createCategory('Financeiro', 'expense', '#64748B', 'bi-bank', 'Serviços bancários'); + $this->createSubcategories($financeiro, [ + ['name' => 'Taxas Bancárias', 'icon' => 'bi-bank2', 'color' => '#475569'], + ['name' => 'Juros/Intereses', 'icon' => 'bi-percent', 'color' => '#DC2626'], + ['name' => 'Transferências', 'icon' => 'bi-arrow-left-right', 'color' => '#2563EB'], + ['name' => 'Seguros Diversos', 'icon' => 'bi-shield-fill', 'color' => '#059669'], + ['name' => 'Assessoria/Contabilidade', 'icon' => 'bi-calculator-fill', 'color' => '#7C3AED'], + ['name' => 'Impostos', 'icon' => 'bi-receipt', 'color' => '#DC2626'], + ]); + + // 👶 FAMÍLIA E FILHOS + $familia = $this->createCategory('Família e Filhos', 'expense', '#F472B6', 'bi-people', 'Gastos familiares'); + $this->createSubcategories($familia, [ + ['name' => 'Creche/Guardería', 'icon' => 'bi-house-heart-fill', 'color' => '#EC4899'], + ['name' => 'Atividades Extracurriculares', 'icon' => 'bi-star-fill', 'color' => '#F59E0B'], + ['name' => 'Brinquedos', 'icon' => 'bi-balloon-fill', 'color' => '#7C3AED'], + ['name' => 'Fraldas/Pañales', 'icon' => 'bi-heart', 'color' => '#0891B2'], + ['name' => 'Mesada/Semanada', 'icon' => 'bi-cash-coin', 'color' => '#16A34A'], + ]); + + // 📱 ASSINATURAS E SERVIÇOS + $assinaturas = $this->createCategory('Assinaturas e Serviços', 'expense', '#8B5CF6', 'bi-credit-card-2-front', 'Pagamentos recorrentes'); + $this->createSubcategories($assinaturas, [ + ['name' => 'Cloud Storage', 'icon' => 'bi-cloud-fill', 'color' => '#0891B2'], + ['name' => 'Apps/Software', 'icon' => 'bi-phone-fill', 'color' => '#2563EB'], + ['name' => 'Jornais/Revistas', 'icon' => 'bi-newspaper', 'color' => '#525252'], + ['name' => 'Domínios/Hosting', 'icon' => 'bi-globe', 'color' => '#059669'], + ['name' => 'VPN/Segurança', 'icon' => 'bi-shield-lock-fill', 'color' => '#DC2626'], + ['name' => 'Coworking', 'icon' => 'bi-briefcase-fill', 'color' => '#F59E0B'], + ]); + + // 🎁 DOAÇÕES + $doacoes = $this->createCategory('Doações', 'expense', '#10B981', 'bi-gift', 'Caridade e contribuições'); + $this->createSubcategories($doacoes, [ + ['name' => 'ONGs', 'icon' => 'bi-heart-fill', 'color' => '#DC2626'], + ['name' => 'Crowdfunding', 'icon' => 'bi-people-fill', 'color' => '#7C3AED'], + ['name' => 'Igreja/Religião', 'icon' => 'bi-heart', 'color' => '#F59E0B'], + ]); + + // 📦 OUTROS GASTOS + $outros = $this->createCategory('Outros Gastos', 'expense', '#94A3B8', 'bi-three-dots', 'Gastos diversos'); + $this->createSubcategories($outros, [ + ['name' => 'Correios/Envíos', 'icon' => 'bi-box-seam', 'color' => '#CA8A04'], + ['name' => 'Loteria/Apostas', 'icon' => 'bi-dice-5-fill', 'color' => '#16A34A'], + ['name' => 'Tabaco', 'icon' => 'bi-cloud-haze2-fill', 'color' => '#78716C'], + ['name' => 'Fotocópias/Impressões', 'icon' => 'bi-printer-fill', 'color' => '#525252'], + ['name' => 'Lavanderia', 'icon' => 'bi-tsunami', 'color' => '#0891B2'], + ['name' => 'Imprevistos', 'icon' => 'bi-exclamation-circle-fill', 'color' => '#DC2626'], + ]); + + // ===================================================================== + // CATEGORIAS DE RENDA (income) + // ===================================================================== + + // 💰 SALÁRIO E TRABALHO + $salario = $this->createCategory('Salário e Trabalho', 'income', '#10B981', 'bi-briefcase', 'Rendimentos de trabalho'); + $this->createSubcategories($salario, [ + ['name' => 'Salário Líquido', 'icon' => 'bi-cash-stack', 'color' => '#059669'], + ['name' => 'Horas Extras', 'icon' => 'bi-clock-fill', 'color' => '#0891B2'], + ['name' => 'Bônus', 'icon' => 'bi-trophy-fill', 'color' => '#F59E0B'], + ['name' => 'Comissões', 'icon' => 'bi-percent', 'color' => '#7C3AED'], + ['name' => 'Dietas/Ajudas', 'icon' => 'bi-receipt', 'color' => '#6B7280'], + ['name' => 'Freelance', 'icon' => 'bi-laptop', 'color' => '#2563EB'], + ]); + + // 📈 INVESTIMENTOS + $investimentos = $this->createCategory('Investimentos', 'income', '#3B82F6', 'bi-graph-up-arrow', 'Rendimentos de investimentos'); + $this->createSubcategories($investimentos, [ + ['name' => 'Dividendos', 'icon' => 'bi-currency-dollar', 'color' => '#16A34A'], + ['name' => 'Juros Poupança', 'icon' => 'bi-piggy-bank-fill', 'color' => '#F59E0B'], + ['name' => 'Venda de Ações', 'icon' => 'bi-graph-up', 'color' => '#059669'], + ['name' => 'Cripto', 'icon' => 'bi-currency-bitcoin', 'color' => '#F97316'], + ['name' => 'Fundos', 'icon' => 'bi-bar-chart-fill', 'color' => '#7C3AED'], + ]); + + // 🏠 RENDAS E ALUGUÉIS + $rendas = $this->createCategory('Rendas e Aluguéis', 'income', '#F59E0B', 'bi-house-door', 'Rendimentos de propriedades'); + $this->createSubcategories($rendas, [ + ['name' => 'Aluguel Recebido', 'icon' => 'bi-house-check-fill', 'color' => '#16A34A'], + ['name' => 'Aluguel Airbnb', 'icon' => 'bi-house-heart-fill', 'color' => '#EC4899'], + ['name' => 'Garagem/Trastero', 'icon' => 'bi-p-square-fill', 'color' => '#0891B2'], + ]); + + // 🎁 OUTROS RENDIMENTOS + $outrosRend = $this->createCategory('Outros Rendimentos', 'income', '#8B5CF6', 'bi-cash-stack', 'Outras fontes de renda'); + $this->createSubcategories($outrosRend, [ + ['name' => 'Reembolsos', 'icon' => 'bi-arrow-return-left', 'color' => '#0891B2'], + ['name' => 'Venda de Objetos', 'icon' => 'bi-bag-check-fill', 'color' => '#F97316'], + ['name' => 'Presentes Recebidos', 'icon' => 'bi-gift-fill', 'color' => '#EC4899'], + ['name' => 'Ajudas/Subsídios', 'icon' => 'bi-building', 'color' => '#2563EB'], + ['name' => 'Loteria/Prêmios', 'icon' => 'bi-trophy', 'color' => '#F59E0B'], + ]); + + // ===================================================================== + // CATEGORIAS MISTAS (both) + // ===================================================================== + + // 🔄 TRANSFERÊNCIAS + $transferencias = $this->createCategory('Transferências', 'both', '#6B7280', 'bi-arrow-left-right', 'Movimentações entre contas'); + $this->createSubcategories($transferencias, [ + ['name' => 'Entre Contas', 'icon' => 'bi-arrow-down-up', 'color' => '#475569'], + ['name' => 'Bizum', 'icon' => 'bi-phone-vibrate', 'color' => '#0891B2'], + ['name' => 'PayPal', 'icon' => 'bi-paypal', 'color' => '#1D4ED8'], + ['name' => 'Wise/Revolut', 'icon' => 'bi-globe2', 'color' => '#0D9488'], + ]); + + $this->command->info("✅ {$this->categoryId} categorias criadas para o usuário ID={$this->userId} (SEM keywords)"); + } + + private function createCategory(string $name, string $type, string $color, string $icon, string $description = null): array + { + $this->categoryId++; + + DB::table('categories')->insert([ + 'user_id' => $this->userId, + 'parent_id' => null, + 'name' => $name, + 'type' => $type, + 'description' => $description, + 'color' => $color, + 'icon' => $icon, + 'order' => $this->categoryId, + 'is_active' => true, + 'is_system' => false, + 'created_at' => $this->now, + 'updated_at' => $this->now, + ]); + + return ['id' => DB::getPdo()->lastInsertId(), 'type' => $type]; + } + + private function createSubcategories(array $parent, array $subcategories): void + { + $parentId = $parent['id']; + $parentType = $parent['type']; + + foreach ($subcategories as $index => $sub) { + $this->categoryId++; + + DB::table('categories')->insert([ + 'user_id' => $this->userId, + 'parent_id' => $parentId, + 'name' => $sub['name'], + 'type' => $parentType, + 'description' => null, + 'color' => $sub['color'] ?? '#6B7280', + 'icon' => $sub['icon'] ?? 'bi-tag', + 'order' => $index + 1, + 'is_active' => true, + 'is_system' => false, + 'created_at' => $this->now, + 'updated_at' => $this->now, + ]); + } + } +} diff --git a/backend/database/seeders/DatabaseSeeder.php b/backend/database/seeders/DatabaseSeeder.php new file mode 100644 index 0000000..6b901f8 --- /dev/null +++ b/backend/database/seeders/DatabaseSeeder.php @@ -0,0 +1,25 @@ +create(); + + User::factory()->create([ + 'name' => 'Test User', + 'email' => 'test@example.com', + ]); + } +} diff --git a/backend/database/seeders/MadridCategoriesSeeder.php b/backend/database/seeders/MadridCategoriesSeeder.php new file mode 100644 index 0000000..4ed82e3 --- /dev/null +++ b/backend/database/seeders/MadridCategoriesSeeder.php @@ -0,0 +1,368 @@ +now = Carbon::now(); + + // Limpar categorias existentes do usuário + DB::table('category_keywords') + ->whereIn('category_id', function($query) { + $query->select('id')->from('categories')->where('user_id', $this->userId); + })->delete(); + DB::table('categories')->where('user_id', $this->userId)->delete(); + + // ===================================================================== + // CATEGORIAS DE DESPESA (expense) + // ===================================================================== + + // 🏠 MORADIA + $moradia = $this->createCategory('Moradia', 'expense', '#8B5CF6', 'bi-house-door-fill', 'Gastos relacionados à habitação'); + $this->createSubcategories($moradia, [ + ['name' => 'Aluguel', 'icon' => 'bi-key-fill', 'keywords' => ['alquiler', 'rent', 'arrendamiento', 'mensualidad piso']], + ['name' => 'Hipoteca', 'icon' => 'bi-bank2', 'keywords' => ['hipoteca', 'mortgage', 'cuota hipotecaria']], + ['name' => 'Condomínio/Comunidad', 'icon' => 'bi-buildings-fill', 'keywords' => ['comunidad', 'comunidade', 'gastos comunes', 'cuota comunidad']], + ['name' => 'Água', 'icon' => 'bi-droplet-fill', 'keywords' => ['agua', 'canal isabel', 'water']], + ['name' => 'Eletricidade', 'icon' => 'bi-lightning-charge-fill', 'keywords' => ['luz', 'electricidad', 'iberdrola', 'endesa', 'naturgy', 'repsol luz']], + ['name' => 'Gás', 'icon' => 'bi-fire', 'keywords' => ['gas natural', 'gas', 'naturgy gas', 'madrileña de gas']], + ['name' => 'Internet/Telefone', 'icon' => 'bi-wifi', 'keywords' => ['movistar', 'vodafone', 'orange', 'telefonica', 'masmovil', 'yoigo', 'pepephone', 'o2', 'digi', 'finetwork', 'internet', 'fibra']], + ['name' => 'Seguro Residencial', 'icon' => 'bi-shield-fill-check', 'keywords' => ['seguro hogar', 'mapfre hogar', 'axa hogar', 'seguro casa', 'seguro vivienda']], + ['name' => 'Manutenção/Reparos', 'icon' => 'bi-tools', 'keywords' => ['reparacion', 'mantenimiento', 'fontanero', 'electricista', 'cerrajero', 'pintor']], + ['name' => 'Móveis', 'icon' => 'bi-house-gear-fill', 'keywords' => ['ikea', 'muebles', 'sofa', 'cama', 'armario', 'leroy merlin muebles']], + ['name' => 'Decoração', 'icon' => 'bi-paint-bucket', 'keywords' => ['decoracion', 'zara home', 'casa', 'maisons du monde', 'cortinas', 'alfombra']], + ['name' => 'Limpeza Casa', 'icon' => 'bi-trash2-fill', 'keywords' => ['limpieza', 'servicio domestico', 'empleada hogar', 'asistenta']], + ]); + + // 🍽️ ALIMENTAÇÃO + $alimentacao = $this->createCategory('Alimentação', 'expense', '#22C55E', 'bi-cart4', 'Comida e bebidas'); + $this->createSubcategories($alimentacao, [ + ['name' => 'Supermercado', 'icon' => 'bi-basket3-fill', 'keywords' => ['mercadona', 'carrefour', 'lidl', 'aldi', 'dia', 'alcampo', 'hipercor', 'eroski', 'ahoramas', 'supercor', 'bon area', 'simply', 'el corte ingles alimentacion', 'supermercado']], + ['name' => 'Padaria', 'icon' => 'bi-cake2-fill', 'keywords' => ['panaderia', 'pan', 'bolleria', 'granier', 'panaria']], + ['name' => 'Açougue/Carnicería', 'icon' => 'bi-egg-fried', 'keywords' => ['carniceria', 'carne', 'pollo', 'ternera']], + ['name' => 'Peixaria', 'icon' => 'bi-water', 'keywords' => ['pescaderia', 'pescado', 'marisco', 'gambas']], + ['name' => 'Frutas/Verduras', 'icon' => 'bi-basket-fill', 'keywords' => ['fruteria', 'verduleria', 'frutas', 'verduras', 'mercado']], + ['name' => 'Bebidas', 'icon' => 'bi-cup-straw', 'keywords' => ['bebidas', 'agua mineral', 'refrescos', 'zumos', 'cerveza casa']], + ['name' => 'Delivery/Comida a Domicilio', 'icon' => 'bi-box-seam-fill', 'keywords' => ['glovo', 'uber eats', 'just eat', 'deliveroo', 'telepizza domicilio']], + ['name' => 'Café/Snacks', 'icon' => 'bi-cup-hot-fill', 'keywords' => ['cafe', 'starbucks', 'costa coffee', 'cafeteria', 'desayuno fuera', 'merienda']], + ]); + + // 🍴 RESTAURANTES E BARES + $restaurantes = $this->createCategory('Restaurantes e Bares', 'expense', '#F97316', 'bi-cup-straw', 'Comer e beber fora de casa'); + $this->createSubcategories($restaurantes, [ + ['name' => 'Restaurante', 'icon' => 'bi-shop-window', 'keywords' => ['restaurante', 'restaurant', 'comida fuera', 'cena fuera', 'almuerzo fuera']], + ['name' => 'Fast Food', 'icon' => 'bi-bag-fill', 'keywords' => ['mcdonalds', 'burger king', 'kfc', 'five guys', 'goiko', 'telepizza', 'dominos', 'papa johns', 'taco bell', 'wendys', 'subway', 'pans']], + ['name' => 'Bar/Cafetería', 'icon' => 'bi-cup-hot', 'keywords' => ['bar', 'cafeteria', 'cerveza bar', 'copa', 'tapas']], + ['name' => 'Discoteca/Club', 'icon' => 'bi-music-note-beamed', 'keywords' => ['discoteca', 'club', 'fabrik', 'kapital', 'opium', 'teatro barcelo', 'joy eslava', 'shoko']], + ['name' => 'Tapas', 'icon' => 'bi-egg-fill', 'keywords' => ['tapas', 'raciones', 'pinchos', 'canas']], + ['name' => 'Comida Asiática', 'icon' => 'bi-yin-yang', 'keywords' => ['chino', 'japones', 'sushi', 'wok', 'thai', 'vietnamita', 'udon', 'ramen']], + ['name' => 'Comida Italiana', 'icon' => 'bi-circle', 'keywords' => ['italiano', 'pizza', 'pasta', 'risotto', 'la tagliatella', 'pizzeria']], + ['name' => 'Comida Mexicana', 'icon' => 'bi-fire', 'keywords' => ['mexicano', 'tacos', 'burritos', 'enchiladas', 'taqueria']], + ['name' => 'Kebab/Döner', 'icon' => 'bi-globe2', 'keywords' => ['kebab', 'doner', 'turco', 'shawarma', 'falafel']], + ]); + + // 🚗 TRANSPORTE + $transporte = $this->createCategory('Transporte', 'expense', '#3B82F6', 'bi-car-front', 'Deslocamentos e veículos'); + $this->createSubcategories($transporte, [ + ['name' => 'Metro/Bus', 'icon' => 'bi-train-lightrail-front-fill', 'keywords' => ['metro madrid', 'emt', 'abono transporte', 'tarjeta transporte', 'cercanias', 'renfe cercanias', 'metro']], + ['name' => 'Taxi/VTC', 'icon' => 'bi-taxi-front-fill', 'keywords' => ['taxi', 'cabify', 'uber', 'bolt', 'free now', 'vtc']], + ['name' => 'Combustível', 'icon' => 'bi-fuel-pump-fill', 'keywords' => ['gasolina', 'diesel', 'gasoleo', 'repsol', 'cepsa', 'bp', 'shell', 'galp', 'combustible']], + ['name' => 'Estacionamento', 'icon' => 'bi-p-circle-fill', 'keywords' => ['parking', 'aparcamiento', 'ser', 'zona azul', 'zona verde', 'garaje']], + ['name' => 'Pedágios', 'icon' => 'bi-sign-stop-fill', 'keywords' => ['peaje', 'autopista', 'radial', 'r2', 'r3', 'r4', 'r5']], + ['name' => 'Seguro Carro', 'icon' => 'bi-shield-fill-check', 'keywords' => ['seguro coche', 'seguro auto', 'mapfre auto', 'linea directa', 'mutua madrilena', 'axa coche', 'seguros coche']], + ['name' => 'Manutenção Carro', 'icon' => 'bi-wrench-adjustable', 'keywords' => ['taller', 'mecanico', 'itv', 'revision', 'aceite', 'neumaticos', 'norauto', 'midas', 'feu vert', 'aurgi']], + ['name' => 'Multas', 'icon' => 'bi-exclamation-triangle-fill', 'keywords' => ['multa', 'sancion', 'dgt', 'trafico']], + ['name' => 'Aluguel Carro', 'icon' => 'bi-key-fill', 'keywords' => ['alquiler coche', 'rent a car', 'enterprise', 'hertz', 'avis', 'europcar', 'sixt']], + ['name' => 'BiciMAD/Patinetes', 'icon' => 'bi-scooter', 'keywords' => ['bicimad', 'lime', 'voi', 'tier', 'dott', 'bird', 'patinete electrico']], + ['name' => 'Carsharing', 'icon' => 'bi-ev-front-fill', 'keywords' => ['share now', 'zity', 'wible', 'emov', 'carsharing']], + ['name' => 'AVE/Tren', 'icon' => 'bi-train-front-fill', 'keywords' => ['ave', 'renfe', 'tren', 'alvia', 'talgo', 'avlo', 'ouigo', 'iryo']], + ['name' => 'Avião', 'icon' => 'bi-airplane-engines-fill', 'keywords' => ['vueling', 'iberia', 'ryanair', 'air europa', 'easyjet', 'avion', 'vuelo']], + ['name' => 'BlaBlaCar', 'icon' => 'bi-people-fill', 'keywords' => ['blablacar', 'compartir coche', 'coche compartido']], + ]); + + // 🛒 COMPRAS + $compras = $this->createCategory('Compras', 'expense', '#EC4899', 'bi-bag', 'Compras diversas'); + $this->createSubcategories($compras, [ + ['name' => 'Roupas', 'keywords' => ['zara', 'mango', 'h&m', 'primark', 'pull and bear', 'bershka', 'stradivarius', 'massimo dutti', 'uniqlo', 'c&a', 'springfield', 'cortefiel', 'ropa', 'camiseta', 'pantalon', 'vestido']], + ['name' => 'Calçados', 'keywords' => ['zapatos', 'zapatillas', 'foot locker', 'jd sports', 'snipes', 'ulanka', 'marypaz', 'merkal', 'nike', 'adidas store']], + ['name' => 'Acessórios', 'keywords' => ['accesorios', 'bolso', 'cinturon', 'gafas sol', 'reloj', 'joyeria', 'bijuteria']], + ['name' => 'Eletrônicos', 'keywords' => ['mediamarkt', 'fnac', 'pc componentes', 'amazon', 'apple store', 'samsung', 'worten', 'movil', 'ordenador', 'tablet', 'electronica']], + ['name' => 'Livros/Papelaria', 'keywords' => ['casa del libro', 'fnac libros', 'papeleria', 'libro', 'cuaderno', 'la central', 'tipos infames']], + ['name' => 'Presentes', 'keywords' => ['regalo', 'gift', 'presente', 'cumpleanos', 'navidad regalo']], + ['name' => 'Bazar/Casa', 'keywords' => ['tiger', 'ale hop', 'bazar', 'todo a 100', 'chino bazar', 'action']], + ['name' => 'Bricolaje/Ferragens', 'keywords' => ['leroy merlin', 'bricomart', 'bricodepot', 'aki', 'ferreteria', 'herramientas']], + ['name' => 'Amazon/Online', 'keywords' => ['amazon', 'aliexpress', 'shein', 'temu', 'zalando', 'asos', 'ebay', 'wallapop']], + ]); + + // 💊 SAÚDE + $saude = $this->createCategory('Saúde', 'expense', '#EF4444', 'bi-heart-pulse', 'Cuidados médicos e saúde'); + $this->createSubcategories($saude, [ + ['name' => 'Farmácia', 'icon' => 'bi-capsule-pill', 'keywords' => ['farmacia', 'medicamento', 'parafarmacia', 'promofarma', 'dosfarma', 'medicina']], + ['name' => 'Médico/Consulta', 'icon' => 'bi-hospital-fill', 'keywords' => ['medico', 'consulta', 'especialista', 'doctor', 'clinica']], + ['name' => 'Seguro Saúde', 'icon' => 'bi-shield-heart-fill', 'keywords' => ['sanitas', 'adeslas', 'asisa', 'dkv', 'cigna', 'caser salud', 'seguro medico']], + ['name' => 'Dentista', 'icon' => 'bi-emoji-smile-fill', 'keywords' => ['dentista', 'dental', 'vitaldent', 'sanitas dental', 'ortodoncista', 'implante dental']], + ['name' => 'Ótica', 'icon' => 'bi-eyeglasses', 'keywords' => ['optica', 'gafas', 'lentillas', 'general optica', 'multilopticas', 'vision lab', 'oculista']], + ['name' => 'Análises/Exames', 'icon' => 'bi-clipboard2-pulse-fill', 'keywords' => ['analisis', 'laboratorio', 'radiografia', 'ecografia', 'resonancia', 'tac']], + ['name' => 'Psicólogo/Terapia', 'icon' => 'bi-chat-heart-fill', 'keywords' => ['psicologo', 'psiquiatra', 'terapia', 'psicoterapia', 'salud mental']], + ['name' => 'Fisioterapia', 'icon' => 'bi-bandaid-fill', 'keywords' => ['fisio', 'fisioterapia', 'masaje terapeutico', 'osteopata', 'quiropractico']], + ['name' => 'Hospital/Urgências', 'icon' => 'bi-hospital', 'keywords' => ['hospital', 'urgencias', 'clinica universitaria', 'ruber', 'quironsalud', 'hm hospitales']], + ]); + + // 💇 BELEZA E CUIDADO PESSOAL + $beleza = $this->createCategory('Beleza e Cuidado Pessoal', 'expense', '#D946EF', 'bi-scissors', 'Estética e higiene pessoal'); + $this->createSubcategories($beleza, [ + ['name' => 'Cabeleireiro/Peluquería', 'icon' => 'bi-scissors', 'keywords' => ['peluqueria', 'corte pelo', 'tinte', 'barberia', 'cabeleireiro']], + ['name' => 'Manicure/Pedicure', 'icon' => 'bi-brush-fill', 'keywords' => ['manicura', 'pedicura', 'unas', 'nail art', 'estetica unas']], + ['name' => 'Cosméticos', 'icon' => 'bi-bag-heart-fill', 'keywords' => ['sephora', 'primor', 'kiko', 'maquillaje', 'cosmeticos', 'druni', 'perfumeria']], + ['name' => 'Perfumaria', 'icon' => 'bi-droplet-half', 'keywords' => ['perfume', 'colonia', 'douglas', 'juteco', 'fragancia']], + ['name' => 'Spa/Massagem', 'icon' => 'bi-emoji-relaxed-fill', 'keywords' => ['spa', 'masaje', 'hammam', 'wellness', 'balneario', 'termas']], + ['name' => 'Depilação', 'icon' => 'bi-stars', 'keywords' => ['depilacion', 'laser', 'cera', 'centros ideal']], + ['name' => 'Higiene Pessoal', 'icon' => 'bi-shop', 'keywords' => ['drogueria', 'gel', 'champu', 'desodorante', 'higiene']], + ]); + + // 🎓 EDUCAÇÃO + $educacao = $this->createCategory('Educação', 'expense', '#0EA5E9', 'bi-mortarboard', 'Formação e estudos'); + $this->createSubcategories($educacao, [ + ['name' => 'Mensalidade Escolar', 'icon' => 'bi-building-fill', 'keywords' => ['colegio', 'escuela', 'matricula', 'mensualidad colegio']], + ['name' => 'Universidade', 'icon' => 'bi-mortarboard-fill', 'keywords' => ['universidad', 'facultad', 'master', 'grado', 'uam', 'ucm', 'uc3m', 'upcomillas', 'upm', 'urjc']], + ['name' => 'Cursos/Formação', 'icon' => 'bi-laptop', 'keywords' => ['curso', 'formacion', 'udemy', 'coursera', 'domestika', 'platzi', 'academia']], + ['name' => 'Idiomas', 'icon' => 'bi-translate', 'keywords' => ['idiomas', 'ingles', 'british council', 'oxford', 'vaughan', 'academia idiomas', 'speaking']], + ['name' => 'Material Escolar', 'icon' => 'bi-pencil-fill', 'keywords' => ['material escolar', 'libros texto', 'mochila', 'cuadernos']], + ['name' => 'Tutoria/Aulas Particulares', 'icon' => 'bi-person-video3', 'keywords' => ['profesor particular', 'clases particulares', 'tutor', 'academia']], + ]); + + // 🎮 ENTRETENIMENTO + $entretenimento = $this->createCategory('Entretenimento', 'expense', '#A855F7', 'bi-controller', 'Lazer e diversão'); + $this->createSubcategories($entretenimento, [ + ['name' => 'Streaming', 'icon' => 'bi-tv-fill', 'keywords' => ['netflix', 'hbo', 'disney+', 'amazon prime video', 'movistar+', 'dazn', 'filmin', 'atresplayer', 'apple tv', 'paramount+']], + ['name' => 'Música/Spotify', 'icon' => 'bi-music-note-beamed', 'keywords' => ['spotify', 'apple music', 'youtube music', 'deezer', 'tidal']], + ['name' => 'Cinema', 'icon' => 'bi-film', 'keywords' => ['cine', 'yelmo', 'cinesa', 'mk2', 'renoir', 'ideal', 'palacio de la prensa', 'callao', 'verdi']], + ['name' => 'Teatro/Shows', 'icon' => 'bi-mask', 'keywords' => ['teatro', 'musical', 'el rey leon', 'espectaculo', 'gran via teatro', 'lope de vega']], + ['name' => 'Museu/Exposições', 'icon' => 'bi-palette-fill', 'keywords' => ['museo', 'exposicion', 'prado', 'reina sofia', 'thyssen', 'caixaforum', 'matadero']], + ['name' => 'Concertos', 'icon' => 'bi-megaphone-fill', 'keywords' => ['concierto', 'wizink center', 'la riviera', 'sala but', 'movistar arena', 'mad cool', 'festival']], + ['name' => 'Jogos/Gaming', 'icon' => 'bi-controller', 'keywords' => ['playstation', 'xbox', 'nintendo', 'steam', 'videojuegos', 'game', 'ps5', 'ps plus']], + ['name' => 'Parques/Zoo', 'icon' => 'bi-emoji-smile', 'keywords' => ['zoo', 'faunia', 'parque warner', 'parque atracciones', 'safari', 'aquopolis']], + ['name' => 'Eventos Esportivos', 'icon' => 'bi-trophy-fill', 'keywords' => ['futbol', 'real madrid', 'atletico', 'bernabeu', 'metropolitano', 'baloncesto', 'padel', 'tenis']], + ]); + + // 🏋️ FITNESS E ESPORTES + $fitness = $this->createCategory('Fitness e Esportes', 'expense', '#14B8A6', 'bi-bicycle', 'Atividades físicas'); + $this->createSubcategories($fitness, [ + ['name' => 'Academia/Gimnasio', 'icon' => 'bi-buildings-fill', 'keywords' => ['gimnasio', 'basic fit', 'mcfit', 'fitness park', 'gym', 'viva gym', 'altafit', 'dir', 'o2 centro wellness']], + ['name' => 'Yoga/Pilates', 'icon' => 'bi-heart-fill', 'keywords' => ['yoga', 'pilates', 'bikram', 'hot yoga', 'urban sports club']], + ['name' => 'Piscina', 'icon' => 'bi-droplet-fill', 'keywords' => ['piscina', 'natacion', 'polideportivo']], + ['name' => 'Equipamento Esportivo', 'icon' => 'bi-handbag', 'keywords' => ['decathlon', 'deportes', 'sprinter', 'sport zone', 'equipamiento deportivo']], + ['name' => 'Futebol/Padel', 'icon' => 'bi-trophy', 'keywords' => ['alquiler pista', 'padel', 'futbol 5', 'playtomic', 'reserva pista']], + ['name' => 'Corrida/Running', 'icon' => 'bi-lightning-fill', 'keywords' => ['running', 'carrera', 'maraton', 'zapatillas running', 'garmin']], + ['name' => 'Bicicleta', 'icon' => 'bi-bicycle', 'keywords' => ['bicicleta', 'ciclismo', 'decathlon bici', 'taller bici']], + ]); + + // ✈️ VIAGENS + $viagens = $this->createCategory('Viagens', 'expense', '#06B6D4', 'bi-airplane', 'Turismo e férias'); + $this->createSubcategories($viagens, [ + ['name' => 'Hospedagem/Hotel', 'icon' => 'bi-building', 'keywords' => ['hotel', 'booking', 'airbnb', 'hostal', 'apartamento turistico', 'nh hoteles', 'melia', 'tryp']], + ['name' => 'Passagens', 'icon' => 'bi-airplane-fill', 'keywords' => ['vuelo', 'billete avion', 'skyscanner', 'kayak', 'edreams', 'vueling', 'iberia']], + ['name' => 'Passeios/Tours', 'icon' => 'bi-signpost-fill', 'keywords' => ['excursion', 'tour', 'guia turistico', 'civitatis', 'getyourguide', 'viator']], + ['name' => 'Seguro Viagem', 'icon' => 'bi-shield-check', 'keywords' => ['seguro viaje', 'iati', 'chapka', 'mondo', 'seguro cancelacion']], + ['name' => 'Souvenirs', 'icon' => 'bi-gift', 'keywords' => ['souvenir', 'recuerdo', 'regalo viaje']], + ['name' => 'Alimentação Viagem', 'icon' => 'bi-cup-straw', 'keywords' => ['comida viaje', 'restaurante viaje', 'gastronomia local']], + ]); + + // 🐕 PETS/MASCOTAS + $pets = $this->createCategory('Pets/Mascotas', 'expense', '#F59E0B', 'bi-github', 'Animais de estimação'); + $this->createSubcategories($pets, [ + ['name' => 'Ração/Comida', 'icon' => 'bi-cup-fill', 'keywords' => ['pienso', 'comida mascota', 'comida perro', 'comida gato', 'tiendanimal', 'kiwoko', 'zooplus']], + ['name' => 'Veterinário', 'icon' => 'bi-heart-pulse-fill', 'keywords' => ['veterinario', 'clinica veterinaria', 'vacuna mascota', 'desparasitar']], + ['name' => 'Acessórios Pet', 'icon' => 'bi-bag', 'keywords' => ['collar', 'correa', 'cama mascota', 'transportin', 'juguete mascota']], + ['name' => 'Peluquería Canina', 'icon' => 'bi-scissors', 'keywords' => ['peluqueria canina', 'grooming', 'bano perro']], + ['name' => 'Seguro Mascota', 'icon' => 'bi-shield-fill-plus', 'keywords' => ['seguro mascota', 'seguro perro', 'seguro gato']], + ]); + + // 🏦 FINANCEIRO + $financeiro = $this->createCategory('Financeiro', 'expense', '#64748B', 'bi-bank', 'Serviços bancários e financeiros'); + $this->createSubcategories($financeiro, [ + ['name' => 'Taxas Bancárias', 'icon' => 'bi-bank2', 'keywords' => ['comision', 'mantenimiento cuenta', 'comision tarjeta', 'gastos bancarios']], + ['name' => 'Juros/Intereses', 'icon' => 'bi-percent', 'keywords' => ['intereses', 'interes prestamo', 'interes credito']], + ['name' => 'Transferências', 'icon' => 'bi-arrow-left-right', 'keywords' => ['transferencia', 'bizum', 'wise', 'remesa']], + ['name' => 'Seguros Diversos', 'icon' => 'bi-shield-fill', 'keywords' => ['seguro vida', 'seguro accidentes', 'plan pension']], + ['name' => 'Assessoria/Contabilidade', 'icon' => 'bi-calculator-fill', 'keywords' => ['gestor', 'asesoria', 'contable', 'declaracion renta', 'autonomo gestor']], + ['name' => 'Impostos', 'icon' => 'bi-receipt', 'keywords' => ['hacienda', 'irpf', 'iva', 'ibi', 'impuesto', 'aeat', 'agencia tributaria']], + ]); + + // 👶 FAMÍLIA E FILHOS + $familia = $this->createCategory('Família e Filhos', 'expense', '#F472B6', 'bi-people', 'Gastos familiares'); + $this->createSubcategories($familia, [ + ['name' => 'Creche/Guardería', 'icon' => 'bi-house-heart-fill', 'keywords' => ['guarderia', 'escuela infantil', 'ludoteca', 'canguro']], + ['name' => 'Atividades Extracurriculares', 'icon' => 'bi-star-fill', 'keywords' => ['extraescolar', 'actividad ninos', 'futbol ninos', 'natacion ninos', 'musica ninos']], + ['name' => 'Brinquedos', 'icon' => 'bi-balloon-fill', 'keywords' => ['juguete', 'toys r us', 'imaginarium', 'lego', 'playmobil', 'el corte ingles juguetes']], + ['name' => 'Fraldas/Pañales', 'icon' => 'bi-heart', 'keywords' => ['panales', 'dodot', 'huggies', 'bebe']], + ['name' => 'Mesada/Semanada', 'icon' => 'bi-cash-coin', 'keywords' => ['paga', 'mesada', 'dinero hijos']], + ]); + + // 📱 ASSINATURAS E SERVIÇOS + $assinaturas = $this->createCategory('Assinaturas e Serviços', 'expense', '#8B5CF6', 'bi-credit-card-2-front', 'Pagamentos recorrentes'); + $this->createSubcategories($assinaturas, [ + ['name' => 'Cloud Storage', 'icon' => 'bi-cloud-fill', 'keywords' => ['icloud', 'google one', 'dropbox', 'onedrive', 'almacenamiento nube']], + ['name' => 'Apps/Software', 'icon' => 'bi-phone-fill', 'keywords' => ['app store', 'google play', 'suscripcion app', 'adobe', 'microsoft 365', 'notion', 'evernote']], + ['name' => 'Jornais/Revistas', 'icon' => 'bi-newspaper', 'keywords' => ['el pais', 'el mundo', 'abc', 'la razon', 'periodico', 'revista', 'prensa digital']], + ['name' => 'Domínios/Hosting', 'icon' => 'bi-globe', 'keywords' => ['dominio', 'hosting', 'godaddy', 'namecheap', 'servidor web']], + ['name' => 'VPN/Segurança', 'icon' => 'bi-shield-lock-fill', 'keywords' => ['vpn', 'nordvpn', 'expressvpn', 'antivirus', 'norton', 'mcafee']], + ['name' => 'Coworking', 'icon' => 'bi-briefcase-fill', 'keywords' => ['coworking', 'wework', 'spaces', 'oficina compartida']], + ]); + + // 🎁 DOAÇÕES E CARIDADE + $doacoes = $this->createCategory('Doações', 'expense', '#10B981', 'bi-gift', 'Caridade e contribuições'); + $this->createSubcategories($doacoes, [ + ['name' => 'ONGs', 'icon' => 'bi-heart-fill', 'keywords' => ['ong', 'unicef', 'cruz roja', 'medicos sin fronteras', 'intermon oxfam', 'caritas', 'greenpeace']], + ['name' => 'Crowdfunding', 'icon' => 'bi-people-fill', 'keywords' => ['gofundme', 'kickstarter', 'patreon', 'donacion online']], + ['name' => 'Igreja/Religião', 'icon' => 'bi-heart', 'keywords' => ['iglesia', 'parroquia', 'donacion religiosa', 'diezmo']], + ]); + + // 📦 OUTROS GASTOS + $outros = $this->createCategory('Outros Gastos', 'expense', '#94A3B8', 'bi-three-dots', 'Gastos diversos'); + $this->createSubcategories($outros, [ + ['name' => 'Correios/Envíos', 'icon' => 'bi-box-seam', 'keywords' => ['correos', 'seur', 'mrw', 'dhl', 'ups', 'fedex', 'nacex', 'gls', 'envio paquete']], + ['name' => 'Loteria/Apostas', 'icon' => 'bi-dice-5-fill', 'keywords' => ['loteria', 'primitiva', 'euromillon', 'once', 'quiniela', 'apuestas']], + ['name' => 'Tabaco', 'icon' => 'bi-cloud-haze2-fill', 'keywords' => ['tabaco', 'estanco', 'cigarrillos']], + ['name' => 'Fotocópias/Impressões', 'icon' => 'bi-printer-fill', 'keywords' => ['copisteria', 'fotocopia', 'impresion', 'encuadernacion']], + ['name' => 'Lavanderia', 'icon' => 'bi-tsunami', 'keywords' => ['lavanderia', 'tintoreria', 'limpieza ropa']], + ['name' => 'Imprevistos', 'icon' => 'bi-exclamation-circle-fill', 'keywords' => ['imprevisto', 'emergencia', 'gasto inesperado']], + ]); + + // ===================================================================== + // CATEGORIAS DE RENDA (income) + // ===================================================================== + + // 💰 SALÁRIO E TRABALHO + $salario = $this->createCategory('Salário e Trabalho', 'income', '#10B981', 'bi-briefcase', 'Rendimentos de trabalho'); + $this->createSubcategories($salario, [ + ['name' => 'Salário Líquido', 'icon' => 'bi-cash-stack', 'keywords' => ['nomina', 'salario', 'sueldo', 'pago mensual']], + ['name' => 'Horas Extras', 'icon' => 'bi-clock-fill', 'keywords' => ['horas extra', 'overtime', 'complemento']], + ['name' => 'Bônus/Bonus', 'icon' => 'bi-trophy-fill', 'keywords' => ['bonus', 'paga extra', 'incentivo', 'variable']], + ['name' => 'Comissões', 'icon' => 'bi-percent', 'keywords' => ['comision ventas', 'comisiones', 'incentivo comercial']], + ['name' => 'Dietas/Ajudas', 'icon' => 'bi-receipt', 'keywords' => ['dietas', 'kilometraje', 'gastos viaje trabajo']], + ['name' => 'Freelance', 'icon' => 'bi-laptop', 'keywords' => ['freelance', 'autonomo ingreso', 'factura cliente', 'proyecto']], + ]); + + // 📈 INVESTIMENTOS + $investimentos = $this->createCategory('Investimentos', 'income', '#3B82F6', 'bi-graph-up-arrow', 'Rendimentos de investimentos'); + $this->createSubcategories($investimentos, [ + ['name' => 'Dividendos', 'icon' => 'bi-currency-dollar', 'keywords' => ['dividendo', 'reparto beneficios', 'dividends']], + ['name' => 'Juros Poupança', 'icon' => 'bi-piggy-bank-fill', 'keywords' => ['intereses cuenta', 'intereses deposito', 'rentabilidad']], + ['name' => 'Venda de Ações', 'icon' => 'bi-graph-up', 'keywords' => ['venta acciones', 'plusvalia', 'ganancia bolsa', 'stock sale']], + ['name' => 'Cripto', 'icon' => 'bi-currency-bitcoin', 'keywords' => ['bitcoin', 'ethereum', 'cripto', 'binance', 'coinbase', 'crypto']], + ['name' => 'Fundos', 'icon' => 'bi-bar-chart-fill', 'keywords' => ['fondo inversion', 'etf', 'indexado', 'myinvestor', 'indexa capital']], + ]); + + // 🏠 RENDAS E ALUGUÉIS + $rendas = $this->createCategory('Rendas e Aluguéis', 'income', '#F59E0B', 'bi-house-door', 'Rendimentos de propriedades'); + $this->createSubcategories($rendas, [ + ['name' => 'Aluguel Recebido', 'icon' => 'bi-house-check-fill', 'keywords' => ['alquiler cobrado', 'renta inmueble', 'inquilino pago']], + ['name' => 'Aluguel Airbnb', 'icon' => 'bi-house-heart-fill', 'keywords' => ['airbnb ingreso', 'alquiler turistico', 'arrendamiento corto']], + ['name' => 'Garagem/Trastero', 'icon' => 'bi-p-square-fill', 'keywords' => ['alquiler garaje', 'alquiler trastero', 'parking alquilado']], + ]); + + // 🎁 OUTROS RENDIMENTOS + $outrosRendimentos = $this->createCategory('Outros Rendimentos', 'income', '#8B5CF6', 'bi-cash-stack', 'Outras fontes de renda'); + $this->createSubcategories($outrosRendimentos, [ + ['name' => 'Reembolsos', 'icon' => 'bi-arrow-return-left', 'keywords' => ['reembolso', 'devolucion', 'refund', 'cashback']], + ['name' => 'Venda de Objetos', 'icon' => 'bi-bag-check-fill', 'keywords' => ['wallapop', 'vinted', 'venta segunda mano', 'ebay venta']], + ['name' => 'Presentes Recebidos', 'icon' => 'bi-gift-fill', 'keywords' => ['regalo recibido', 'herencia', 'donacion recibida']], + ['name' => 'Ajudas/Subsídios', 'icon' => 'bi-building', 'keywords' => ['ayuda gobierno', 'subsidio', 'beca', 'prestacion', 'erte', 'sepe']], + ['name' => 'Loteria/Prêmios', 'icon' => 'bi-trophy', 'keywords' => ['premio loteria', 'sorteo', 'premio']], + ]); + + // ===================================================================== + // CATEGORIAS MISTAS (both) + // ===================================================================== + + // 🔄 TRANSFERÊNCIAS + $transferencias = $this->createCategory('Transferências', 'both', '#6B7280', 'bi-arrow-left-right', 'Movimentações entre contas'); + $this->createSubcategories($transferencias, [ + ['name' => 'Entre Contas', 'icon' => 'bi-arrow-down-up', 'keywords' => ['transferencia propia', 'traspaso', 'entre cuentas']], + ['name' => 'Bizum', 'icon' => 'bi-phone-vibrate', 'keywords' => ['bizum']], + ['name' => 'PayPal', 'icon' => 'bi-paypal', 'keywords' => ['paypal']], + ['name' => 'Wise/Revolut', 'icon' => 'bi-globe2', 'keywords' => ['wise', 'revolut', 'n26', 'transferencia internacional']], + ]); + + $this->command->info("✅ {$this->categoryId} categorias e subcategorias criadas para o usuário marco@cnxifly.com!"); + } + + private function createCategory(string $name, string $type, string $color, string $icon, string $description = null): array + { + $this->categoryId++; + + DB::table('categories')->insert([ + 'user_id' => $this->userId, + 'parent_id' => null, + 'name' => $name, + 'type' => $type, + 'description' => $description, + 'color' => $color, + 'icon' => $icon, + 'order' => $this->categoryId, + 'is_active' => true, + 'is_system' => false, + 'created_at' => $this->now, + 'updated_at' => $this->now, + ]); + + return ['id' => DB::getPdo()->lastInsertId(), 'type' => $type]; + } + + private function createSubcategories(array $parent, array $subcategories): void + { + $parentId = $parent['id']; + $parentType = $parent['type']; + + foreach ($subcategories as $index => $sub) { + $this->categoryId++; + + DB::table('categories')->insert([ + 'user_id' => $this->userId, + 'parent_id' => $parentId, + 'name' => $sub['name'], + 'type' => $parentType, // Herda do pai + 'description' => null, + 'color' => '#6B7280', // Cinza neutro para subcategorias + 'icon' => $sub['icon'] ?? 'bi-tag', // Usa ícone personalizado se fornecido + 'order' => $index + 1, + 'is_active' => true, + 'is_system' => false, + 'created_at' => $this->now, + 'updated_at' => $this->now, + ]); + + $subcategoryId = DB::getPdo()->lastInsertId(); + + // Adicionar keywords + if (!empty($sub['keywords'])) { + foreach ($sub['keywords'] as $keyword) { + DB::table('category_keywords')->insert([ + 'category_id' => $subcategoryId, + 'keyword' => strtolower($keyword), + 'is_case_sensitive' => false, + 'is_active' => true, + 'created_at' => $this->now, + 'updated_at' => $this->now, + ]); + } + } + } + } +} diff --git a/backend/deploy.ps1 b/backend/deploy.ps1 new file mode 100644 index 0000000..c2a5e95 --- /dev/null +++ b/backend/deploy.ps1 @@ -0,0 +1,124 @@ +# ============================================================================= +# WEBMoney Backend - Script de Deploy para Windows +# ============================================================================= +# Este script sincroniza e deploya o backend Laravel para o servidor +# Uso: .\deploy.ps1 +# ============================================================================= + +# Configurações +$SERVER_USER = "root" +$SERVER_HOST = "213.165.93.60" +$SERVER_PATH = "/var/www/webmoney/backend" +$LOCAL_PATH = $PSScriptRoot + +# Cores +function Write-Color { + param([string]$Text, [string]$Color = "White") + Write-Host $Text -ForegroundColor $Color +} + +Write-Color "╔═══════════════════════════════════════════════════════╗" "Green" +Write-Color "║ WEBMoney Laravel - Deploy para Produção ║" "Green" +Write-Color "╚═══════════════════════════════════════════════════════╝" "Green" +Write-Host "" + +# 1. Build local (se necessário) +Write-Color "[1/8] Verificando dependências locais..." "Yellow" +if (-not (Test-Path "$LOCAL_PATH\vendor")) { + Write-Host "Instalando dependências do Composer..." + composer install --no-dev --optimize-autoloader +} +Write-Color "✓ Dependências locais OK" "Green" + +# 2. Preparar lista de exclusões +$excludes = @( + ".git", + "node_modules", + "storage/logs/*", + "storage/framework/cache/*", + "storage/framework/sessions/*", + "storage/framework/views/*", + ".env", + "database/database.sqlite" +) + +# 3. Sincronizar arquivos via scp (alternativa a rsync) +Write-Color "[2/8] Sincronizando arquivos para o servidor..." "Yellow" + +# Criar arquivo tar local excluindo pastas +$tarFile = "$env:TEMP\webmoney-backend.tar.gz" +$excludeArgs = ($excludes | ForEach-Object { "--exclude='$_'" }) -join " " + +# Usar tar se disponível, sino scp directo +$tarAvailable = Get-Command tar -ErrorAction SilentlyContinue + +if ($tarAvailable) { + Push-Location $LOCAL_PATH + tar -czf $tarFile --exclude='.git' --exclude='node_modules' --exclude='storage/logs/*' --exclude='storage/framework/cache/*' --exclude='storage/framework/sessions/*' --exclude='storage/framework/views/*' --exclude='.env' --exclude='vendor' . + Pop-Location + + # Enviar tar al servidor + scp $tarFile "${SERVER_USER}@${SERVER_HOST}:/tmp/webmoney-backend.tar.gz" + + # Extraer en el servidor + ssh "$SERVER_USER@$SERVER_HOST" "cd $SERVER_PATH && tar -xzf /tmp/webmoney-backend.tar.gz && rm /tmp/webmoney-backend.tar.gz" + + Remove-Item $tarFile -ErrorAction SilentlyContinue +} else { + # Copiar archivos directamente (más lento pero funciona) + Write-Host "Usando SCP directo (tar no disponible)..." + scp -r "$LOCAL_PATH\app" "${SERVER_USER}@${SERVER_HOST}:${SERVER_PATH}/" + scp -r "$LOCAL_PATH\bootstrap" "${SERVER_USER}@${SERVER_HOST}:${SERVER_PATH}/" + scp -r "$LOCAL_PATH\config" "${SERVER_USER}@${SERVER_HOST}:${SERVER_PATH}/" + scp -r "$LOCAL_PATH\database" "${SERVER_USER}@${SERVER_HOST}:${SERVER_PATH}/" + scp -r "$LOCAL_PATH\public" "${SERVER_USER}@${SERVER_HOST}:${SERVER_PATH}/" + scp -r "$LOCAL_PATH\resources" "${SERVER_USER}@${SERVER_HOST}:${SERVER_PATH}/" + scp -r "$LOCAL_PATH\routes" "${SERVER_USER}@${SERVER_HOST}:${SERVER_PATH}/" + scp "$LOCAL_PATH\artisan" "${SERVER_USER}@${SERVER_HOST}:${SERVER_PATH}/" + scp "$LOCAL_PATH\composer.json" "${SERVER_USER}@${SERVER_HOST}:${SERVER_PATH}/" + scp "$LOCAL_PATH\composer.lock" "${SERVER_USER}@${SERVER_HOST}:${SERVER_PATH}/" +} + +Write-Color "✓ Arquivos sincronizados" "Green" + +# 4. Copiar .env de produção +Write-Color "[3/8] Configurando .env de produção..." "Yellow" +ssh "$SERVER_USER@$SERVER_HOST" "cp $SERVER_PATH/.env.production $SERVER_PATH/.env 2>/dev/null || true" +Write-Color "✓ .env configurado" "Green" + +# 5. Instalar dependências no servidor +Write-Color "[4/8] Instalando dependências no servidor..." "Yellow" +ssh "$SERVER_USER@$SERVER_HOST" "cd $SERVER_PATH && composer install --no-dev --optimize-autoloader" +Write-Color "✓ Dependências instaladas" "Green" + +# 6. Ajustar permissões +Write-Color "[5/8] Ajustando permissões..." "Yellow" +ssh "$SERVER_USER@$SERVER_HOST" "chown -R www-data:www-data $SERVER_PATH/storage $SERVER_PATH/bootstrap/cache" +ssh "$SERVER_USER@$SERVER_HOST" "chmod -R 775 $SERVER_PATH/storage $SERVER_PATH/bootstrap/cache" +Write-Color "✓ Permissões ajustadas" "Green" + +# 7. Executar migrações +Write-Color "[6/8] Executando migrações de banco de dados..." "Yellow" +ssh "$SERVER_USER@$SERVER_HOST" "cd $SERVER_PATH && php artisan migrate --force" +Write-Color "✓ Migrações executadas" "Green" + +# 8. Cache e otimizações +Write-Color "[7/8] Otimizando aplicação..." "Yellow" +ssh "$SERVER_USER@$SERVER_HOST" "cd $SERVER_PATH && php artisan config:cache" +ssh "$SERVER_USER@$SERVER_HOST" "cd $SERVER_PATH && php artisan route:cache" +ssh "$SERVER_USER@$SERVER_HOST" "cd $SERVER_PATH && php artisan view:cache" +Write-Color "✓ Caches gerados" "Green" + +# 9. Reiniciar PHP-FPM +Write-Color "[8/8] Reiniciando serviços..." "Yellow" +ssh "$SERVER_USER@$SERVER_HOST" "systemctl restart php8.4-fpm" +Write-Color "✓ PHP-FPM reiniciado" "Green" + +Write-Host "" +Write-Color "╔═══════════════════════════════════════════════════════╗" "Green" +Write-Color "║ ✓ Deploy concluído com sucesso! ║" "Green" +Write-Color "╚═══════════════════════════════════════════════════════╝" "Green" +Write-Host "" +Write-Host "API disponível em: " -NoNewline +Write-Color "https://webmoney.cnxifly.com/api" "Cyan" +Write-Host "" diff --git a/backend/deploy.sh b/backend/deploy.sh new file mode 100644 index 0000000..ba05279 --- /dev/null +++ b/backend/deploy.sh @@ -0,0 +1,104 @@ +#!/bin/bash + +############################################################################### +# SCRIPT DE DEPLOY - WEBMoney Backend Laravel +# Servidor: 213.165.93.60 +# Domínio: https://webmoney.cnxifly.com +# Versão: 1.0.0 +############################################################################### + +set -e # Exit on error + +# Colors para output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Configurações +SERVER_USER="root" +SERVER_HOST="213.165.93.60" +SERVER_PASSWORD="Master9354" +SERVER_PATH="/var/www/webmoney/backend" +LOCAL_PATH="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +echo -e "${GREEN}╔═══════════════════════════════════════════════════════╗${NC}" +echo -e "${GREEN}║ WEBMoney Laravel - Deploy para Produção ║${NC}" +echo -e "${GREEN}╚═══════════════════════════════════════════════════════╝${NC}" +echo "" + +# Função para executar comandos no servidor remoto +remote_exec() { + sshpass -p "$SERVER_PASSWORD" ssh -o StrictHostKeyChecking=no "$SERVER_USER@$SERVER_HOST" "$1" +} + +# 1. Build local (se necessário) +echo -e "${YELLOW}[1/8]${NC} Verificando dependências locais..." +if [ ! -d "$LOCAL_PATH/vendor" ]; then + echo "Instalando dependências do Composer..." + composer install --no-dev --optimize-autoloader +fi + +# 2. Sync arquivos via rsync +echo -e "${YELLOW}[2/8]${NC} Sincronizando arquivos para o servidor..." +sshpass -p "$SERVER_PASSWORD" rsync -avz --delete \ + --exclude='.git' \ + --exclude='node_modules' \ + --exclude='storage/logs/*' \ + --exclude='storage/framework/cache/*' \ + --exclude='storage/framework/sessions/*' \ + --exclude='storage/framework/views/*' \ + --exclude='.env' \ + --exclude='database/database.sqlite' \ + "$LOCAL_PATH/" "$SERVER_USER@$SERVER_HOST:$SERVER_PATH/" + +echo -e "${GREEN}✓${NC} Arquivos sincronizados" + +# 3. Copiar .env de produção +echo -e "${YELLOW}[3/8]${NC} Configurando .env de produção..." +sshpass -p "$SERVER_PASSWORD" ssh -o StrictHostKeyChecking=no "$SERVER_USER@$SERVER_HOST" \ + "cp $SERVER_PATH/.env.production $SERVER_PATH/.env" +echo -e "${GREEN}✓${NC} .env configurado" + +# 4. Instalar dependências no servidor +echo -e "${YELLOW}[4/8]${NC} Instalando dependências no servidor..." +remote_exec "cd $SERVER_PATH && composer install --no-dev --optimize-autoloader" +echo -e "${GREEN}✓${NC} Dependências instaladas" + +# 5. Ajustar permissões +echo -e "${YELLOW}[5/8]${NC} Ajustando permissões..." +remote_exec "chown -R www-data:www-data $SERVER_PATH/storage $SERVER_PATH/bootstrap/cache" +remote_exec "chmod -R 775 $SERVER_PATH/storage $SERVER_PATH/bootstrap/cache" +echo -e "${GREEN}✓${NC} Permissões ajustadas" + +# 6. Executar migrações +echo -e "${YELLOW}[6/8]${NC} Executando migrações de banco de dados..." +remote_exec "cd $SERVER_PATH && php artisan migrate --force" +echo -e "${GREEN}✓${NC} Migrações executadas" + +# 7. Cache e otimizações +echo -e "${YELLOW}[7/8]${NC} Otimizando aplicação..." +remote_exec "cd $SERVER_PATH && php artisan config:cache" +remote_exec "cd $SERVER_PATH && php artisan route:cache" +remote_exec "cd $SERVER_PATH && php artisan view:cache" +echo -e "${GREEN}✓${NC} Caches gerados" + +# 8. Reload PHP-FPM +echo -e "${YELLOW}[8/8]${NC} Recarregando PHP-FPM..." +remote_exec "systemctl reload php8.4-fpm" +echo -e "${GREEN}✓${NC} PHP-FPM recarregado" + +echo "" +echo -e "${GREEN}╔═══════════════════════════════════════════════════════╗${NC}" +echo -e "${GREEN}║ ✓ DEPLOY CONCLUÍDO COM SUCESSO! ║${NC}" +echo -e "${GREEN}╚═══════════════════════════════════════════════════════╝${NC}" +echo "" +echo -e "URL: ${GREEN}https://webmoney.cnxifly.com${NC}" +echo -e "API: ${GREEN}https://webmoney.cnxifly.com/api${NC}" +echo "" +echo -e "${YELLOW}Endpoints disponíveis:${NC}" +echo -e " POST /api/register - Registrar usuário" +echo -e " POST /api/login - Login" +echo -e " POST /api/logout - Logout (auth)" +echo -e " GET /api/me - Dados do usuário (auth)" +echo "" diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..7686b29 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://www.schemastore.org/package.json", + "private": true, + "type": "module", + "scripts": { + "build": "vite build", + "dev": "vite" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.0.0", + "axios": "^1.11.0", + "concurrently": "^9.0.1", + "laravel-vite-plugin": "^2.0.0", + "tailwindcss": "^4.0.0", + "vite": "^7.0.7" + } +} diff --git a/backend/phpunit.xml b/backend/phpunit.xml new file mode 100644 index 0000000..d703241 --- /dev/null +++ b/backend/phpunit.xml @@ -0,0 +1,35 @@ + + + + + tests/Unit + + + tests/Feature + + + + + app + + + + + + + + + + + + + + + + + + diff --git a/backend/public/.htaccess b/backend/public/.htaccess new file mode 100644 index 0000000..b574a59 --- /dev/null +++ b/backend/public/.htaccess @@ -0,0 +1,25 @@ + + + Options -MultiViews -Indexes + + + RewriteEngine On + + # Handle Authorization Header + RewriteCond %{HTTP:Authorization} . + RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] + + # Handle X-XSRF-Token Header + RewriteCond %{HTTP:x-xsrf-token} . + RewriteRule .* - [E=HTTP_X_XSRF_TOKEN:%{HTTP:X-XSRF-Token}] + + # Redirect Trailing Slashes If Not A Folder... + RewriteCond %{REQUEST_FILENAME} !-d + RewriteCond %{REQUEST_URI} (.+)/$ + RewriteRule ^ %1 [L,R=301] + + # Send Requests To Front Controller... + RewriteCond %{REQUEST_FILENAME} !-d + RewriteCond %{REQUEST_FILENAME} !-f + RewriteRule ^ index.php [L] + diff --git a/backend/public/favicon.ico b/backend/public/favicon.ico new file mode 100644 index 0000000..e69de29 diff --git a/backend/public/index.php b/backend/public/index.php new file mode 100644 index 0000000..ee8f07e --- /dev/null +++ b/backend/public/index.php @@ -0,0 +1,20 @@ +handleRequest(Request::capture()); diff --git a/backend/public/robots.txt b/backend/public/robots.txt new file mode 100644 index 0000000..eb05362 --- /dev/null +++ b/backend/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: diff --git a/backend/resources/css/app.css b/backend/resources/css/app.css new file mode 100644 index 0000000..3e6abea --- /dev/null +++ b/backend/resources/css/app.css @@ -0,0 +1,11 @@ +@import 'tailwindcss'; + +@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php'; +@source '../../storage/framework/views/*.php'; +@source '../**/*.blade.php'; +@source '../**/*.js'; + +@theme { + --font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', + 'Segoe UI Symbol', 'Noto Color Emoji'; +} diff --git a/backend/resources/js/app.js b/backend/resources/js/app.js new file mode 100644 index 0000000..e59d6a0 --- /dev/null +++ b/backend/resources/js/app.js @@ -0,0 +1 @@ +import './bootstrap'; diff --git a/backend/resources/js/bootstrap.js b/backend/resources/js/bootstrap.js new file mode 100644 index 0000000..5f1390b --- /dev/null +++ b/backend/resources/js/bootstrap.js @@ -0,0 +1,4 @@ +import axios from 'axios'; +window.axios = axios; + +window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'; diff --git a/backend/resources/views/emails/welcome-text.blade.php b/backend/resources/views/emails/welcome-text.blade.php new file mode 100644 index 0000000..6b79739 --- /dev/null +++ b/backend/resources/views/emails/welcome-text.blade.php @@ -0,0 +1,33 @@ +¡BIENVENIDO A WEBMONEY! +======================= + +Hola {{ $userName }}, + +¡Gracias por registrarte en WEBMoney! Tu cuenta ha sido creada exitosamente y ya puedes comenzar a gestionar tus finanzas personales de manera fácil y segura. + +CUENTA REGISTRADA +----------------- +{{ $userEmail }} + +¿QUÉ PUEDES HACER AHORA? +------------------------- +* Registrar tus ingresos y gastos +* Visualizar reportes y gráficos +* Gestionar tu presupuesto mensual +* Exportar datos en múltiples formatos + +ACCEDE A TU CUENTA: +https://webmoney.cnxifly.com/login + +Si tienes alguna pregunta o necesitas ayuda, no dudes en contactarnos respondiendo a este email. + +¡Gracias por confiar en nosotros! + +--- +WEBMoney - Tu gestor financiero personal +ConneXiFly · webmoney.cnxifly.com + +Soporte: support@cnxifly.com + +© 2025 ConneXiFly. Todos los derechos reservados. +Si no solicitaste esta cuenta, ignora este correo. diff --git a/backend/resources/views/emails/welcome.blade.php b/backend/resources/views/emails/welcome.blade.php new file mode 100644 index 0000000..b2154b6 --- /dev/null +++ b/backend/resources/views/emails/welcome.blade.php @@ -0,0 +1,113 @@ + + + + + + + Bienvenido a WEBMoney + + + + + + + +
+ + + + + + + + + + + + + + + +
+

+ 🎉 ¡Bienvenido a WEBMoney! +

+
+

+ Hola {{ $userName }}, +

+ +

+ ¡Gracias por registrarte en WEBMoney! Tu cuenta ha sido creada exitosamente y ya puedes comenzar a gestionar tus finanzas personales de manera fácil y segura. +

+ +
+

+ 📧 Cuenta registrada: +

+

+ {{ $userEmail }} +

+
+ +

+ ¿Qué puedes hacer ahora? +

+ +
    +
  • 📊 Registrar tus ingresos y gastos
  • +
  • 📈 Visualizar reportes y gráficos
  • +
  • 💰 Gestionar tu presupuesto mensual
  • +
  • 📄 Exportar datos en múltiples formatos
  • +
+ + + + + +
+ + Iniciar Sesión Ahora + +
+ +

+ Si tienes alguna pregunta o necesitas ayuda, no dudes en contactarnos respondiendo a este email. +

+ +

+ ¡Gracias por confiar en nosotros! +

+
+

+ WEBMoney - Tu gestor financiero personal +

+

+ ConneXiFly · webmoney.cnxifly.com +

+

+ Este es un correo automático. Por favor, no respondas directamente a este mensaje. +
+ Para soporte, escríbenos a: support@cnxifly.com +

+
+ + + + + + +
+

+ © 2025 ConneXiFly. Todos los derechos reservados. +
+ Si no solicitaste esta cuenta, ignora este correo. +

+
+
+ + diff --git a/backend/resources/views/welcome.blade.php b/backend/resources/views/welcome.blade.php new file mode 100644 index 0000000..b7355d7 --- /dev/null +++ b/backend/resources/views/welcome.blade.php @@ -0,0 +1,277 @@ + + + + + + + {{ config('app.name', 'Laravel') }} + + + + + + + @if (file_exists(public_path('build/manifest.json')) || file_exists(public_path('hot'))) + @vite(['resources/css/app.css', 'resources/js/app.js']) + @else + + @endif + + +
+ @if (Route::has('login')) + + @endif +
+
+
+
+

Let's get started

+

Laravel has an incredibly rich ecosystem.
We suggest starting with the following.

+ + +
+
+ {{-- Laravel Logo --}} + + + + + + + + + + + {{-- Light Mode 12 SVG --}} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{-- Dark Mode 12 SVG --}} + +
+
+
+
+ + @if (Route::has('login')) + + @endif + + diff --git a/backend/routes/api.php b/backend/routes/api.php new file mode 100644 index 0000000..cd5f2d2 --- /dev/null +++ b/backend/routes/api.php @@ -0,0 +1,193 @@ +middleware('throttle:register'); +Route::post('/login', [AuthController::class, 'login'])->middleware('throttle:login'); + +// Email testing routes (should be protected in production) +Route::post('/email/send-test', [EmailTestController::class, 'sendTest']); +Route::get('/email/anti-spam-info', [EmailTestController::class, 'getAntiSpamInfo']); + +// Protected routes (require authentication) +Route::middleware('auth:sanctum')->group(function () { + // Auth routes + Route::post('/logout', [AuthController::class, 'logout']); + Route::get('/me', [AuthController::class, 'me']); + Route::get('/user', function (Request $request) { + return $request->user(); + }); + + // ============================================ + // Contas (Accounts) + // ============================================ + // Rotas específicas ANTES do apiResource + Route::post('accounts/recalculate-all', [AccountController::class, 'recalculateBalances']); + Route::post('accounts/{id}/recalculate', [AccountController::class, 'recalculateBalance']); + Route::post('accounts/{id}/adjust-balance', [AccountController::class, 'adjustBalance']); + + // Resource principal + Route::apiResource('accounts', AccountController::class); + Route::get('accounts-total', [AccountController::class, 'totalBalance']); + + // ============================================ + // Centros de Custo (Cost Centers) + // ============================================ + Route::apiResource('cost-centers', CostCenterController::class); + Route::post('cost-centers/{id}/keywords', [CostCenterController::class, 'addKeyword']); + Route::delete('cost-centers/{id}/keywords/{keywordId}', [CostCenterController::class, 'removeKeyword']); + Route::post('cost-centers/match', [CostCenterController::class, 'matchByText']); + + // ============================================ + // Categorias (Categories) + // ============================================ + // Rotas específicas ANTES do apiResource + Route::post('categories/categorize-batch/preview', [CategoryController::class, 'categorizeBatchPreview']); + Route::post('categories/categorize-batch', [CategoryController::class, 'categorizeBatch']); + Route::post('categories/categorize-batch/manual', [CategoryController::class, 'categorizeBatchManual']); + Route::post('categories/match', [CategoryController::class, 'matchByText']); + Route::post('categories/reorder', [CategoryController::class, 'reorder']); + + // Resource principal + Route::apiResource('categories', CategoryController::class); + + // Rotas com parâmetros (depois do apiResource) + Route::post('categories/{id}/keywords', [CategoryController::class, 'addKeyword']); + Route::delete('categories/{id}/keywords/{keywordId}', [CategoryController::class, 'removeKeyword']); + + // ============================================ + // Contas Passivo (Liability Accounts) + // ============================================ + // Rotas específicas ANTES do apiResource (para evitar conflito com {id}) + Route::get('liability-accounts/pending-reconciliation', [LiabilityAccountController::class, 'pendingReconciliation']); + Route::post('liability-accounts/import', [LiabilityAccountController::class, 'import']); + Route::get('liability-summary', [LiabilityAccountController::class, 'summary']); + + // Resource principal + Route::apiResource('liability-accounts', LiabilityAccountController::class); + + // Rotas com parâmetros (depois do apiResource) + Route::get('liability-accounts/{id}/installments', [LiabilityAccountController::class, 'installments']); + Route::put('liability-accounts/{accountId}/installments/{installmentId}', [LiabilityAccountController::class, 'updateInstallment']); + + // Conciliação de parcelas + Route::get('liability-accounts/{accountId}/installments/{installmentId}/eligible-transactions', [LiabilityAccountController::class, 'eligibleTransactions']); + Route::post('liability-accounts/{accountId}/installments/{installmentId}/reconcile', [LiabilityAccountController::class, 'reconcile']); + Route::delete('liability-accounts/{accountId}/installments/{installmentId}/reconcile', [LiabilityAccountController::class, 'unreconcile']); + + // ============================================ + // Transações (Transactions) + // ============================================ + Route::apiResource('transactions', TransactionController::class); + Route::get('transactions-by-week', [TransactionController::class, 'byWeek']); + Route::get('transactions-summary', [TransactionController::class, 'summary']); + + // Ações em transações + Route::post('transactions/{transaction}/complete', [TransactionController::class, 'complete']); + Route::post('transactions/{transaction}/quick-complete', [TransactionController::class, 'quickComplete']); + Route::post('transactions/{transaction}/cancel', [TransactionController::class, 'cancel']); + Route::post('transactions/{transaction}/revert', [TransactionController::class, 'revert']); + Route::post('transactions/{transaction}/duplicate', [TransactionController::class, 'duplicate']); + + // Divisão de transações + Route::post('transactions/{transaction}/split', [TransactionController::class, 'split']); + Route::post('transactions/{transaction}/unsplit', [TransactionController::class, 'unsplit']); + Route::get('transactions/{transaction}/splits', [TransactionController::class, 'getSplits']); + + // Transferências entre contas + Route::post('transactions/transfer', [TransactionController::class, 'transfer']); + Route::post('transactions/{transaction}/unlink-transfer', [TransactionController::class, 'unlinkTransfer']); + + // Conciliação com Passivos + Route::get('transactions/{transaction}/liability-installments', [TransactionController::class, 'findLiabilityInstallments']); + Route::post('transactions/{transaction}/reconcile-liability', [TransactionController::class, 'reconcileWithLiability']); + + // ============================================ + // Importação de Extratos (Bank Statement Import) + // ============================================ + Route::post('import/upload', [ImportController::class, 'upload']); + Route::post('import/headers', [ImportController::class, 'getHeaders']); + Route::post('import/process', [ImportController::class, 'import']); + Route::get('import/mappings', [ImportController::class, 'mappings']); + Route::get('import/mappings/{id}', [ImportController::class, 'getMapping']); + Route::put('import/mappings/{id}', [ImportController::class, 'updateMapping']); + Route::delete('import/mappings/{id}', [ImportController::class, 'deleteMapping']); + Route::get('import/presets', [ImportController::class, 'presets']); + Route::post('import/presets/create', [ImportController::class, 'createFromPreset']); + Route::get('import/history', [ImportController::class, 'history']); + Route::get('import/fields', [ImportController::class, 'fields']); + + // Detecção de Transferências entre Contas (Transfer Detection) + // ============================================================ + Route::get('transfer-detection', [TransferDetectionController::class, 'index']); + Route::get('transfer-detection/stats', [TransferDetectionController::class, 'stats']); + Route::get('transfer-detection/{transaction}/pairs', [TransferDetectionController::class, 'findPairs']); + Route::post('transfer-detection/confirm', [TransferDetectionController::class, 'confirm']); + Route::post('transfer-detection/confirm-batch', [TransferDetectionController::class, 'confirmBatch']); + Route::post('transfer-detection/ignore', [TransferDetectionController::class, 'ignore']); + Route::post('transfer-detection/delete-both', [TransferDetectionController::class, 'deleteBoth']); + + // Detecção de Reembolsos (Refund Detection) + // ============================================================ + Route::get('refund-detection', [TransferDetectionController::class, 'refunds']); + Route::post('refund-detection/confirm', [TransferDetectionController::class, 'confirmRefund']); + Route::post('refund-detection/confirm-batch', [TransferDetectionController::class, 'confirmRefundBatch']); + Route::post('refund-detection/ignore', [TransferDetectionController::class, 'ignoreRefund']); + Route::post('refund-detection/undo', [TransferDetectionController::class, 'undoRefund']); + + // ============================================ + // Dashboard + // ============================================ + Route::get('dashboard/summary', [DashboardController::class, 'summary']); + Route::get('dashboard/cashflow', [DashboardController::class, 'cashflow']); + Route::get('dashboard/expenses-by-category', [DashboardController::class, 'expensesByCategory']); + Route::get('dashboard/income-by-category', [DashboardController::class, 'incomeByCategory']); + Route::get('dashboard/payment-variances', [DashboardController::class, 'paymentVariances']); + Route::get('dashboard/calendar', [DashboardController::class, 'calendar']); + Route::get('dashboard/calendar-day', [DashboardController::class, 'calendarDay']); + Route::get('dashboard/upcoming', [DashboardController::class, 'upcomingTransactions']); + Route::get('dashboard/overdue', [DashboardController::class, 'overdueTransactions']); + + // ============================================ + // Transações Recorrentes (Recurring Transactions) + // ============================================ + // Rotas de listagem geral + Route::get('recurring/frequencies', [RecurringTemplateController::class, 'frequencies']); + Route::get('recurring/pending', [RecurringTemplateController::class, 'allPendingInstances']); + Route::get('recurring/overdue', [RecurringTemplateController::class, 'overdueInstances']); + Route::get('recurring/due-soon', [RecurringTemplateController::class, 'dueSoonInstances']); + Route::post('recurring/regenerate-all', [RecurringTemplateController::class, 'regenerateAll']); + + // Criar a partir de transação existente + Route::post('recurring/from-transaction', [RecurringTemplateController::class, 'createFromTransaction']); + + // Templates CRUD + Route::apiResource('recurring', RecurringTemplateController::class)->parameters(['recurring' => 'recurringTemplate']); + + // Ações em templates + Route::post('recurring/{recurringTemplate}/pause', [RecurringTemplateController::class, 'pause']); + Route::post('recurring/{recurringTemplate}/resume', [RecurringTemplateController::class, 'resume']); + Route::get('recurring/{recurringTemplate}/instances', [RecurringTemplateController::class, 'instances']); + + // Ações em instâncias + Route::post('recurring-instances/{recurringInstance}/pay', [RecurringTemplateController::class, 'markAsPaid']); + Route::post('recurring-instances/{recurringInstance}/reconcile', [RecurringTemplateController::class, 'reconcile']); + Route::get('recurring-instances/{recurringInstance}/candidates', [RecurringTemplateController::class, 'findCandidates']); + Route::post('recurring-instances/{recurringInstance}/skip', [RecurringTemplateController::class, 'skip']); + Route::post('recurring-instances/{recurringInstance}/cancel', [RecurringTemplateController::class, 'cancel']); + Route::put('recurring-instances/{recurringInstance}', [RecurringTemplateController::class, 'updateInstance']); +}); + diff --git a/backend/routes/console.php b/backend/routes/console.php new file mode 100644 index 0000000..3c9adf1 --- /dev/null +++ b/backend/routes/console.php @@ -0,0 +1,8 @@ +comment(Inspiring::quote()); +})->purpose('Display an inspiring quote'); diff --git a/backend/routes/web.php b/backend/routes/web.php new file mode 100644 index 0000000..86a06c5 --- /dev/null +++ b/backend/routes/web.php @@ -0,0 +1,7 @@ +get('/'); + + $response->assertStatus(200); + } +} diff --git a/backend/tests/TestCase.php b/backend/tests/TestCase.php new file mode 100644 index 0000000..fe1ffc2 --- /dev/null +++ b/backend/tests/TestCase.php @@ -0,0 +1,10 @@ +assertTrue(true); + } +} diff --git a/backend/vite.config.js b/backend/vite.config.js new file mode 100644 index 0000000..29fbfe9 --- /dev/null +++ b/backend/vite.config.js @@ -0,0 +1,13 @@ +import { defineConfig } from 'vite'; +import laravel from 'laravel-vite-plugin'; +import tailwindcss from '@tailwindcss/vite'; + +export default defineConfig({ + plugins: [ + laravel({ + input: ['resources/css/app.css', 'resources/js/app.js'], + refresh: true, + }), + tailwindcss(), + ], +}); diff --git a/cats_main.sql b/cats_main.sql new file mode 100644 index 0000000..6992aa1 --- /dev/null +++ b/cats_main.sql @@ -0,0 +1,23 @@ +INSERT INTO categories (user_id, parent_id, name, type, description, color, icon, `order`, is_active, is_system, created_at, updated_at) VALUES +(3, NULL, 'Moradia', 'expense', 'Gastos com habitacao', '#8B5CF6', 'bi-house-door-fill', 1, 1, 0, NOW(), NOW()), +(3, NULL, 'Alimentacao', 'expense', 'Comida e bebidas', '#22C55E', 'bi-cart4', 2, 1, 0, NOW(), NOW()), +(3, NULL, 'Restaurantes e Bares', 'expense', 'Comer fora', '#F97316', 'bi-cup-straw', 3, 1, 0, NOW(), NOW()), +(3, NULL, 'Transporte', 'expense', 'Deslocamentos', '#3B82F6', 'bi-car-front', 4, 1, 0, NOW(), NOW()), +(3, NULL, 'Compras', 'expense', 'Compras diversas', '#EC4899', 'bi-bag', 5, 1, 0, NOW(), NOW()), +(3, NULL, 'Saude', 'expense', 'Cuidados medicos', '#EF4444', 'bi-heart-pulse', 6, 1, 0, NOW(), NOW()), +(3, NULL, 'Beleza e Cuidado Pessoal', 'expense', 'Estetica e higiene', '#D946EF', 'bi-scissors', 7, 1, 0, NOW(), NOW()), +(3, NULL, 'Educacao', 'expense', 'Formacao e estudos', '#0EA5E9', 'bi-mortarboard', 8, 1, 0, NOW(), NOW()), +(3, NULL, 'Entretenimento', 'expense', 'Lazer e diversao', '#A855F7', 'bi-controller', 9, 1, 0, NOW(), NOW()), +(3, NULL, 'Fitness e Esportes', 'expense', 'Atividades fisicas', '#14B8A6', 'bi-bicycle', 10, 1, 0, NOW(), NOW()), +(3, NULL, 'Viagens', 'expense', 'Turismo e ferias', '#06B6D4', 'bi-airplane', 11, 1, 0, NOW(), NOW()), +(3, NULL, 'Pets/Mascotas', 'expense', 'Animais de estimacao', '#F59E0B', 'bi-heart', 12, 1, 0, NOW(), NOW()), +(3, NULL, 'Financeiro', 'expense', 'Servicos bancarios', '#64748B', 'bi-bank', 13, 1, 0, NOW(), NOW()), +(3, NULL, 'Familia e Filhos', 'expense', 'Gastos familiares', '#F472B6', 'bi-people', 14, 1, 0, NOW(), NOW()), +(3, NULL, 'Assinaturas e Servicos', 'expense', 'Pagamentos recorrentes', '#8B5CF6', 'bi-credit-card-2-front', 15, 1, 0, NOW(), NOW()), +(3, NULL, 'Doacoes', 'expense', 'Caridade e contribuicoes', '#10B981', 'bi-gift', 16, 1, 0, NOW(), NOW()), +(3, NULL, 'Outros Gastos', 'expense', 'Gastos diversos', '#94A3B8', 'bi-three-dots', 17, 1, 0, NOW(), NOW()), +(3, NULL, 'Salario e Trabalho', 'income', 'Rendimentos de trabalho', '#10B981', 'bi-briefcase', 18, 1, 0, NOW(), NOW()), +(3, NULL, 'Investimentos', 'income', 'Rendimentos de investimentos', '#3B82F6', 'bi-graph-up-arrow', 19, 1, 0, NOW(), NOW()), +(3, NULL, 'Rendas e Alugueis', 'income', 'Rendimentos de propriedades', '#F59E0B', 'bi-house-door', 20, 1, 0, NOW(), NOW()), +(3, NULL, 'Outros Rendimentos', 'income', 'Outras fontes de renda', '#8B5CF6', 'bi-cash-stack', 21, 1, 0, NOW(), NOW()), +(3, NULL, 'Transferencias', 'both', 'Movimentacoes entre contas', '#6B7280', 'bi-arrow-left-right', 22, 1, 0, NOW(), NOW()); diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md new file mode 100644 index 0000000..c01b6f5 --- /dev/null +++ b/docs/API_REFERENCE.md @@ -0,0 +1,1117 @@ +# 🔧 WebMoney - Documentação Técnica da API + +## Referência Completa dos Endpoints + +**Versão:** 1.0 +**Base URL:** `https://webmoney.cnxifly.com/api` +**Autenticação:** Bearer Token (Laravel Sanctum) + +--- + +## 📋 Índice + +1. [Autenticação](#1-autenticação) +2. [Contas](#2-contas) +3. [Transações](#3-transações) +4. [Categorias](#4-categorias) +5. [Centros de Custo](#5-centros-de-custo) +6. [Importação](#6-importação) +7. [Passivos](#7-passivos) +8. [Transações Recorrentes](#8-transações-recorrentes) +9. [Detecção de Transferências](#9-detecção-de-transferências) +10. [Detecção de Reembolsos](#10-detecção-de-reembolsos) +11. [Dashboard](#11-dashboard) + +--- + +## 1. Autenticação + +### POST /register +Cria uma nova conta de usuário. + +**Request Body:** +```json +{ + "name": "string", + "email": "string", + "password": "string", + "password_confirmation": "string" +} +``` + +**Response:** `201 Created` +```json +{ + "success": true, + "data": { + "user": { "id": 1, "name": "...", "email": "..." }, + "token": "1|abc123..." + } +} +``` + +--- + +### POST /login +Autentica um usuário existente. + +**Request Body:** +```json +{ + "email": "string", + "password": "string" +} +``` + +**Response:** `200 OK` +```json +{ + "success": true, + "data": { + "user": { "id": 1, "name": "...", "email": "...", "role": "admin" }, + "token": "2|xyz789..." + } +} +``` + +--- + +### POST /logout +Encerra a sessão do usuário autenticado. + +**Headers:** `Authorization: Bearer {token}` + +**Response:** `200 OK` +```json +{ + "success": true, + "message": "Logged out successfully" +} +``` + +--- + +### GET /me +Retorna dados do usuário autenticado. + +**Headers:** `Authorization: Bearer {token}` + +**Response:** `200 OK` +```json +{ + "success": true, + "data": { + "id": 1, + "name": "Marco Leite", + "email": "marco@cnxifly.com", + "role": "admin" + } +} +``` + +--- + +## 2. Contas + +### GET /accounts +Lista todas as contas do usuário. + +**Query Parameters:** +| Parâmetro | Tipo | Descrição | +|-----------|------|-----------| +| type | string | Filtrar por tipo (checking, savings, etc.) | +| is_active | boolean | Filtrar por status | + +**Response:** `200 OK` +```json +{ + "success": true, + "data": [ + { + "id": 1, + "name": "Nubank", + "type": "checking", + "bank_name": "Nu Pagamentos", + "account_number": "1234567-8", + "initial_balance": 0, + "current_balance": 5420.50, + "credit_limit": null, + "currency": "BRL", + "color": "#8B5CF6", + "icon": "bi-bank", + "is_active": true, + "include_in_total": true + } + ] +} +``` + +--- + +### POST /accounts +Cria uma nova conta. + +**Request Body:** +```json +{ + "name": "string (required)", + "type": "string (required)", + "bank_name": "string", + "account_number": "string", + "initial_balance": "number", + "credit_limit": "number", + "currency": "string (default: BRL)", + "color": "string (hex)", + "icon": "string", + "description": "string", + "is_active": "boolean", + "include_in_total": "boolean" +} +``` + +--- + +### GET /accounts/{id} +Retorna detalhes de uma conta específica. + +--- + +### PUT /accounts/{id} +Atualiza uma conta existente. + +--- + +### DELETE /accounts/{id} +Remove uma conta (soft delete). + +--- + +### POST /accounts/recalculate-balances +Recalcula saldos de todas as contas. + +**Response:** `200 OK` +```json +{ + "success": true, + "message": "Balances recalculated", + "data": { + "updated_accounts": 5 + } +} +``` + +--- + +### POST /accounts/{id}/adjust-balance +Ajusta o saldo de uma conta criando transação de ajuste. + +**Request Body:** +```json +{ + "target_balance": 5000.00 +} +``` + +--- + +## 3. Transações + +### GET /transactions +Lista transações com filtros. + +**Query Parameters:** +| Parâmetro | Tipo | Descrição | +|-----------|------|-----------| +| account_id | integer | Filtrar por conta | +| category_id | integer | Filtrar por categoria | +| cost_center_id | integer | Filtrar por centro de custo | +| type | string | debit, credit | +| status | string | pending, completed, cancelled | +| search | string | Busca por descrição | +| start_date | date | Data inicial | +| end_date | date | Data final | +| page | integer | Página | +| per_page | integer | Itens por página | + +--- + +### GET /transactions/by-week +Retorna transações agrupadas por semana. + +**Query Parameters:** Mesmos do `/transactions` + +**Response:** `200 OK` +```json +{ + "success": true, + "data": { + "weeks": [ + { + "week_start": "2025-12-09", + "week_end": "2025-12-15", + "total_income": 5000.00, + "total_expense": 3200.00, + "balance": 1800.00, + "transactions": [...] + } + ], + "pagination": {...} + } +} +``` + +--- + +### GET /transactions/summary +Retorna resumo financeiro do período. + +--- + +### POST /transactions +Cria nova transação. + +**Request Body:** +```json +{ + "account_id": "integer (required)", + "category_id": "integer", + "cost_center_id": "integer", + "type": "string (required: debit|credit)", + "planned_amount": "number (required)", + "amount": "number", + "description": "string (required)", + "notes": "string", + "planned_date": "date (required)", + "effective_date": "date", + "status": "string (default: pending)", + "reference": "string" +} +``` + +--- + +### PUT /transactions/{id} +Atualiza uma transação. + +--- + +### DELETE /transactions/{id} +Remove uma transação. + +--- + +### POST /transactions/{id}/complete +Marca transação como concluída. + +**Request Body:** +```json +{ + "amount": 150.00, + "effective_date": "2025-12-13" +} +``` + +--- + +### POST /transactions/{id}/quick-complete +Completa com valores planejados. + +--- + +### POST /transactions/{id}/cancel +Cancela uma transação. + +--- + +### POST /transactions/{id}/revert +Reverte transação concluída para pendente. + +--- + +### POST /transactions/{id}/duplicate +Cria cópia da transação. + +--- + +### POST /transactions/{id}/split +Divide transação em múltiplas. + +**Request Body:** +```json +{ + "splits": [ + { "category_id": 1, "amount": 100, "description": "Parte 1" }, + { "category_id": 2, "amount": 50, "description": "Parte 2" } + ] +} +``` + +--- + +### POST /transactions/{id}/unsplit +Desfaz divisão de transação. + +--- + +### GET /transactions/{id}/splits +Lista divisões de uma transação. + +--- + +### POST /transactions/transfer +Cria transferência entre contas. + +**Request Body:** +```json +{ + "from_account_id": 1, + "to_account_id": 2, + "amount": 500.00, + "description": "Transferência", + "date": "2025-12-13", + "notes": "string" +} +``` + +--- + +### POST /transactions/{id}/unlink-transfer +Desvincula transações de transferência. + +--- + +### GET /transactions/{id}/liability-installments +Busca parcelas de passivo compatíveis. + +--- + +### POST /transactions/{id}/reconcile-liability +Concilia transação com parcela de passivo. + +--- + +## 4. Categorias + +### GET /categories +Lista categorias (hierárquicas). + +**Query Parameters:** +| Parâmetro | Tipo | Descrição | +|-----------|------|-----------| +| type | string | expense, income, both | +| flat | boolean | Lista plana (sem hierarquia) | + +--- + +### POST /categories +Cria nova categoria. + +**Request Body:** +```json +{ + "name": "string (required)", + "parent_id": "integer (para subcategoria)", + "type": "string (expense|income|both)", + "description": "string", + "color": "string (hex)", + "icon": "string", + "is_active": "boolean", + "keywords": ["uber", "99", "cabify"] +} +``` + +--- + +### PUT /categories/{id} +Atualiza categoria. + +--- + +### DELETE /categories/{id} +Remove categoria. + +--- + +### POST /categories/{id}/keywords +Adiciona palavras-chave. + +**Request Body:** +```json +{ + "keywords": ["netflix", "spotify"] +} +``` + +--- + +### DELETE /categories/{id}/keywords/{keywordId} +Remove palavra-chave. + +--- + +### POST /categories/match +Busca categoria por palavra-chave. + +**Request Body:** +```json +{ + "description": "UBER TRIP SAO PAULO" +} +``` + +--- + +### GET /categories/categorize-batch/preview +Preview da categorização em lote. + +**Query Parameters:** +| Parâmetro | Tipo | Descrição | +|-----------|------|-----------| +| include_cost_centers | boolean | Incluir centros de custo | +| limit | integer | Limite de transações | + +--- + +### POST /categories/categorize-batch +Executa categorização em lote automática. + +--- + +### POST /categories/categorize-batch/manual +Categorização em lote manual. + +**Request Body:** +```json +{ + "category_id": 5, + "cost_center_id": 2, + "add_keyword": true, + "filters": { + "search": "UBER", + "account_id": 1 + } +} +``` + +--- + +### PUT /categories/reorder +Reordena categorias. + +--- + +## 5. Centros de Custo + +### GET /cost-centers +Lista centros de custo. + +--- + +### POST /cost-centers +Cria centro de custo. + +**Request Body:** +```json +{ + "name": "string (required)", + "code": "string", + "description": "string", + "color": "string (hex)", + "icon": "string", + "is_active": "boolean", + "keywords": ["cliente-abc"] +} +``` + +--- + +### PUT /cost-centers/{id} +Atualiza centro de custo. + +--- + +### DELETE /cost-centers/{id} +Remove centro de custo. + +--- + +### POST /cost-centers/{id}/keywords +Adiciona palavras-chave. + +--- + +### DELETE /cost-centers/{id}/keywords/{keywordId} +Remove palavra-chave. + +--- + +### POST /cost-centers/match +Busca centro de custo por palavra-chave. + +--- + +## 6. Importação + +### POST /import/upload +Upload de arquivo para importação. + +**Request:** `multipart/form-data` +- `file`: Arquivo (CSV, XLSX, XLS, OFX, PDF) + +**Response:** +```json +{ + "success": true, + "data": { + "temp_file": "imports/temp/abc123.csv", + "preview": { + "headers": [...], + "preview": [...] + } + } +} +``` + +--- + +### POST /import/headers +Obtém cabeçalhos com configuração. + +**Request Body:** +```json +{ + "temp_file": "imports/temp/abc123.csv", + "header_row": 0, + "data_start_row": 1 +} +``` + +--- + +### POST /import/process +Executa a importação. + +**Request Body:** +```json +{ + "temp_file": "string", + "account_id": "integer (required)", + "category_id": "integer", + "cost_center_id": "integer", + "column_mappings": { + "date": 0, + "description": 1, + "amount": 2 + }, + "date_format": "d/m/Y", + "decimal_separator": ",", + "thousands_separator": ".", + "save_mapping": true, + "mapping_name": "Nubank CSV", + "bank_name": "Nubank" +} +``` + +**Response:** +```json +{ + "success": true, + "data": { + "imported": 45, + "duplicates": 3, + "errors": 0 + } +} +``` + +--- + +### GET /import/mappings +Lista mapeamentos salvos. + +--- + +### GET /import/mappings/{id} +Detalhes de um mapeamento. + +--- + +### PUT /import/mappings/{id} +Atualiza mapeamento. + +--- + +### DELETE /import/mappings/{id} +Remove mapeamento. + +--- + +### GET /import/presets +Lista presets de bancos. + +--- + +### POST /import/presets/create +Cria mapeamento a partir de preset. + +--- + +### GET /import/history +Histórico de importações. + +--- + +### GET /import/fields +Definições de campos e formatos de data. + +--- + +## 7. Passivos + +### GET /liabilities +Lista passivos/financiamentos. + +**Query Parameters:** +| Parâmetro | Tipo | Descrição | +|-----------|------|-----------| +| status | string | active, paid, cancelled | +| is_active | boolean | Ativo | + +**Response:** +```json +{ + "success": true, + "data": [...], + "summary": { + "total_contracts": 2, + "total_value": 150000, + "total_paid": 45000, + "total_pending": 105000 + } +} +``` + +--- + +### POST /liabilities/import +Importa passivo de PDF. + +**Request:** `multipart/form-data` +- `file`: PDF do contrato/simulação +- `name`: Nome do passivo +- `creditor`: Nome do credor +- `contract_number`: Número do contrato +- `currency`: Moeda +- `description`: Descrição + +--- + +### GET /liabilities/{id} +Detalhes do passivo com parcelas. + +--- + +### PUT /liabilities/{id} +Atualiza passivo. + +--- + +### DELETE /liabilities/{id} +Remove passivo. + +--- + +### GET /liabilities/{id}/installments +Lista parcelas do passivo. + +--- + +### POST /liabilities/{id}/installments/{installmentId}/reconcile +Concilia parcela com transação. + +**Request Body:** +```json +{ + "transaction_id": 123, + "mark_as_paid": true +} +``` + +--- + +## 8. Transações Recorrentes + +### GET /recurring +Lista templates de recorrência. + +--- + +### POST /recurring +Cria novo template. + +**Request Body:** +```json +{ + "account_id": "integer (required)", + "category_id": "integer", + "type": "string (required: debit|credit)", + "amount": "number (required)", + "description": "string (required)", + "frequency": "string (required)", + "start_date": "date (required)", + "end_date": "date", + "is_active": "boolean" +} +``` + +--- + +### GET /recurring/{id} +Detalhes do template. + +--- + +### PUT /recurring/{id} +Atualiza template. + +--- + +### DELETE /recurring/{id} +Remove template. + +--- + +### POST /recurring/{id}/pause +Pausa template. + +--- + +### POST /recurring/{id}/resume +Retoma template pausado. + +--- + +### GET /recurring/{id}/instances +Lista instâncias do template. + +--- + +### GET /recurring/frequencies +Lista frequências disponíveis. + +--- + +### GET /recurring/pending +Lista instâncias pendentes (todas). + +--- + +### GET /recurring/overdue +Lista instâncias vencidas. + +--- + +### GET /recurring/due-soon +Lista instâncias a vencer. + +--- + +### POST /recurring/regenerate-all +Regenera instâncias de todos os templates. + +--- + +### POST /recurring/from-transaction +Cria recorrência a partir de transação. + +**Request Body:** +```json +{ + "transaction_id": 123, + "frequency": "monthly", + "end_date": "2026-12-31" +} +``` + +--- + +### POST /recurring-instances/{id}/pay +Marca instância como paga (cria transação). + +--- + +### POST /recurring-instances/{id}/reconcile +Concilia com transação existente. + +**Request Body:** +```json +{ + "transaction_id": 456 +} +``` + +--- + +### GET /recurring-instances/{id}/candidates +Busca transações candidatas para conciliação. + +--- + +### POST /recurring-instances/{id}/skip +Pula instância. + +--- + +### POST /recurring-instances/{id}/cancel +Cancela instância. + +--- + +### PUT /recurring-instances/{id} +Edita instância específica. + +--- + +## 9. Detecção de Transferências + +### GET /transfer-detection +Lista transferências detectadas. + +**Query Parameters:** +| Parâmetro | Tipo | Descrição | +|-----------|------|-----------| +| tolerance_days | integer | Tolerância em dias (default: 3) | + +**Response:** +```json +{ + "success": true, + "data": [ + { + "debit": { "id": 1, "amount": 500, "date": "2025-12-10", "account": {...} }, + "credit": { "id": 2, "amount": 500, "date": "2025-12-11", "account": {...} }, + "confidence": "high", + "day_difference": 1 + } + ] +} +``` + +--- + +### GET /transfer-detection/stats +Estatísticas de transferências detectadas. + +--- + +### GET /transfer-detection/{id}/pairs +Busca pares para uma transação. + +--- + +### POST /transfer-detection/confirm +Confirma uma transferência. + +**Request Body:** +```json +{ + "debit_id": 1, + "credit_id": 2 +} +``` + +--- + +### POST /transfer-detection/confirm-batch +Confirma múltiplas transferências. + +**Request Body:** +```json +{ + "transfers": [ + { "debit_id": 1, "credit_id": 2 }, + { "debit_id": 3, "credit_id": 4 } + ] +} +``` + +--- + +### POST /transfer-detection/ignore +Ignora par de transferência. + +--- + +### POST /transfer-detection/delete-both +Exclui ambas as transações. + +--- + +## 10. Detecção de Reembolsos + +### GET /refund-detection +Lista reembolsos detectados. + +**Query Parameters:** +| Parâmetro | Tipo | Descrição | +|-----------|------|-----------| +| tolerance_days | integer | Tolerância em dias (default: 7) | + +--- + +### POST /refund-detection/confirm +Confirma reembolso. + +**Request Body:** +```json +{ + "debit_id": 1, + "credit_id": 2 +} +``` + +--- + +### POST /refund-detection/confirm-batch +Confirma múltiplos reembolsos. + +--- + +### POST /refund-detection/ignore +Ignora par de reembolso. + +--- + +### POST /refund-detection/undo +Desfaz vinculação de reembolso. + +--- + +## 11. Dashboard + +### GET /dashboard/summary +Resumo financeiro geral. + +**Response:** +```json +{ + "success": true, + "data": { + "total_balance": 15420.50, + "month_income": 8000.00, + "month_expense": 5200.00, + "month_balance": 2800.00, + "pending_transactions": 12, + "overdue_transactions": 3 + } +} +``` + +--- + +### GET /dashboard/cashflow +Fluxo de caixa por mês. + +**Query Parameters:** +| Parâmetro | Tipo | Descrição | +|-----------|------|-----------| +| months | integer | Número de meses (default: 12) | + +**Response:** +```json +{ + "success": true, + "data": [ + { + "month": "2025-12", + "income": 8000, + "expense": 5200, + "balance": 2800 + } + ] +} +``` + +--- + +### GET /dashboard/expenses-by-category +Despesas agrupadas por categoria. + +--- + +### GET /dashboard/income-by-category +Receitas agrupadas por categoria. + +--- + +### GET /dashboard/payment-variances +Análise de variações de pagamento. + +**Query Parameters:** +| Parâmetro | Tipo | Descrição | +|-----------|------|-----------| +| months | integer | Período de análise | + +--- + +### GET /dashboard/calendar +Dados para calendário financeiro. + +**Query Parameters:** +| Parâmetro | Tipo | Descrição | +|-----------|------|-----------| +| month | integer | Mês (1-12) | +| year | integer | Ano | + +--- + +### GET /dashboard/calendar-day +Transações de um dia específico. + +**Query Parameters:** +| Parâmetro | Tipo | Descrição | +|-----------|------|-----------| +| date | date | Data (YYYY-MM-DD) | + +--- + +### GET /dashboard/upcoming +Transações próximas (7 dias). + +--- + +### GET /dashboard/overdue +Transações vencidas. + +--- + +## Códigos de Resposta + +| Código | Significado | +|--------|-------------| +| 200 | Sucesso | +| 201 | Criado com sucesso | +| 400 | Requisição inválida | +| 401 | Não autenticado | +| 403 | Não autorizado | +| 404 | Não encontrado | +| 422 | Validação falhou | +| 500 | Erro interno | + +--- + +## Formato de Erro + +```json +{ + "success": false, + "message": "Descrição do erro", + "errors": { + "campo": ["mensagem de validação"] + } +} +``` + +--- + +*Documentação gerada em Dezembro 2025* diff --git a/docs/DOCUMENTO_INSTITUCIONAL.md b/docs/DOCUMENTO_INSTITUCIONAL.md new file mode 100644 index 0000000..ec6b830 --- /dev/null +++ b/docs/DOCUMENTO_INSTITUCIONAL.md @@ -0,0 +1,568 @@ +# 🌐 WebMoney - Documento Institucional + +## Conteúdo para Website e Marketing + +**Versão:** 1.0 +**Data:** Dezembro 2025 + +--- + +## 📑 Índice + +1. [Tagline e Proposta de Valor](#1-tagline-e-proposta-de-valor) +2. [Sobre o Sistema](#2-sobre-o-sistema) +3. [Funcionalidades](#3-funcionalidades) +4. [Público-Alvo](#4-público-alvo) +5. [Diferenciais](#5-diferenciais) +6. [Casos de Uso](#6-casos-de-uso) +7. [Especificações Técnicas](#7-especificações-técnicas) +8. [FAQ](#8-faq) +9. [Conteúdo para Redes Sociais](#9-conteúdo-para-redes-sociais) + +--- + +## 1. Tagline e Proposta de Valor + +### Taglines Sugeridas + +**Principal:** +> "WebMoney - Sua gestão financeira simplificada" + +**Alternativas:** +- "Controle total das suas finanças em um só lugar" +- "Importa. Categoriza. Analisa. Tudo automático." +- "De extratos bancários a insights financeiros" +- "Gestão financeira inteligente para pessoas e empresas" + +### Proposta de Valor (Elevator Pitch) + +> **WebMoney** é uma plataforma de gestão financeira que transforma seus extratos bancários em inteligência financeira. Importe extratos de qualquer banco, categorize automaticamente com IA, controle financiamentos e recorrências, e tenha visão completa do seu fluxo de caixa. Tudo em uma interface moderna, multi-idioma e acessível de qualquer dispositivo. + +--- + +## 2. Sobre o Sistema + +### Descrição Curta (50 palavras) + +WebMoney é uma plataforma web de gestão financeira pessoal e empresarial. Permite importar extratos bancários, categorizar transações automaticamente, controlar financiamentos, gerenciar recorrências e visualizar seu fluxo de caixa em tempo real. Suporta múltiplas contas, moedas e idiomas. + +### Descrição Média (150 palavras) + +WebMoney é uma solução completa de gestão financeira desenvolvida para simplificar o controle de finanças pessoais e de pequenas empresas. + +A plataforma permite importar extratos de qualquer banco nos formatos CSV, Excel, OFX ou até PDF, e automaticamente categoriza suas transações usando um sistema inteligente de palavras-chave. + +Com recursos avançados como controle de financiamentos com conciliação de parcelas, transações recorrentes automatizadas, detecção inteligente de transferências entre contas e reembolsos, o WebMoney elimina o trabalho manual e fornece insights valiosos sobre suas finanças. + +O dashboard apresenta gráficos de fluxo de caixa, análise de variações de pagamento, calendário financeiro e alertas de transações vencidas. Tudo isso em uma interface moderna e responsiva, disponível em português, espanhol e inglês, com suporte a múltiplas moedas. + +### Descrição Longa (300 palavras) + +**WebMoney** nasceu da necessidade real de uma ferramenta que simplificasse a gestão financeira sem exigir conhecimento contábil ou horas de trabalho manual. + +**O Problema:** +A maioria das pessoas e pequenas empresas enfrenta dificuldades para manter o controle financeiro atualizado. Baixar extratos, categorizar manualmente centenas de transações, lembrar de contas fixas, conciliar pagamentos de financiamentos - tarefas que consomem tempo e frequentemente ficam desatualizadas. + +**A Solução:** +WebMoney automatiza todo esse processo. Basta importar seus extratos bancários uma vez por mês e o sistema faz o resto. O motor de categorização inteligente aprende suas preferências e categoriza transações automaticamente. Financiamentos são extraídos de PDFs com reconhecimento óptico. Contas recorrentes são lançadas automaticamente. Transferências entre contas são detectadas e vinculadas. + +**Recursos Principais:** +- **Importação Universal**: CSV, Excel, OFX e PDF de qualquer banco +- **Categorização Automática**: Sistema de palavras-chave que aprende com você +- **Multi-Conta**: Gerencie contas correntes, poupança, cartões, investimentos +- **Multi-Moeda**: BRL, EUR, USD, GBP e outras +- **Passivos**: Controle financiamentos com extração automática de parcelas +- **Recorrências**: Automatize lançamentos de contas fixas +- **Detecção Inteligente**: Identifica transferências e reembolsos +- **Dashboard Completo**: Gráficos, calendário, alertas em tempo real + +**Para Quem:** +- Pessoas que querem organizar suas finanças pessoais +- Autônomos e profissionais liberais +- Pequenas empresas e startups +- Gestores que precisam de visão consolidada + +**Tecnologia:** +Desenvolvido com tecnologias modernas (Laravel + React), o WebMoney é uma aplicação web responsiva que funciona em qualquer dispositivo com navegador, sem necessidade de instalação. + +--- + +## 3. Funcionalidades + +### 3.1 Lista de Features (para página de funcionalidades) + +#### 💳 Gestão de Contas +**Gerencie todas as suas contas em um só lugar** + +- Contas correntes, poupança, cartões de crédito +- Carteiras digitais e investimentos +- Saldos atualizados em tempo real +- Ajuste manual quando necessário +- Cores e ícones personalizáveis + +#### 📥 Importação de Extratos +**Importe de qualquer banco em segundos** + +- Formatos: CSV, XLSX, XLS, OFX, PDF +- Mapeamento inteligente de colunas +- Detecção automática de duplicatas +- Presets para bancos populares +- Salve configurações para reutilizar + +#### 🏷️ Categorização Automática +**Nunca mais categorize manualmente** + +- Sistema de palavras-chave aprendiz +- Categorias hierárquicas (pai/filho) +- Categorização em lote com um clique +- Subcategorias para detalhamento +- Cores e ícones visuais + +#### 🏢 Centros de Custo +**Organize por projeto, cliente ou departamento** + +- Estrutura independente das categorias +- Palavras-chave para associação automática +- Relatórios por centro de custo +- Ideal para freelancers e empresas + +#### 💰 Controle de Transações +**Visão completa de todas as movimentações** + +- Visualização por semana +- Filtros avançados +- Dividir transações (split) +- Transferências entre contas +- Status: pendente, pago, cancelado + +#### 📋 Passivos e Financiamentos +**Controle total de suas dívidas** + +- Importação de PDF com OCR +- Extração automática de parcelas +- Conciliação com transações +- Análise de juros e taxas +- Progresso visual do pagamento + +#### 🔄 Transações Recorrentes +**Automatize suas contas fixas** + +- Múltiplas frequências (diária a anual) +- Pausar e retomar +- Conciliar com transações existentes +- Alertas de vencimento +- Criar a partir de transação + +#### ↔️ Detecção de Transferências +**Identifica automaticamente transferências** + +- Detecta débitos e créditos correspondentes +- Níveis de confiança (alta, média, baixa) +- Confirmação individual ou em lote +- Tolerância de dias configurável + +#### 🔁 Detecção de Reembolsos +**Encontra estornos automaticamente** + +- Identifica pares compra/reembolso +- Vinculação automática +- Facilita reconciliação + +#### 📊 Dashboard Analítico +**Insights financeiros em tempo real** + +- Cards de resumo financeiro +- Gráfico de fluxo de caixa +- Calendário de transações +- Alertas de vencimento +- Análise de variações + +#### 🌐 Multi-Idioma +**Use no seu idioma** + +- Português do Brasil +- Español +- English +- Interface completa traduzida + +#### 💱 Multi-Moeda +**Gerencie finanças em qualquer moeda** + +- BRL, EUR, USD, GBP e outras +- Cada conta com sua moeda +- Formatação automática +- Símbolos corretos + +--- + +## 4. Público-Alvo + +### 4.1 Personas + +#### Persona 1: O Profissional Organizado +**Nome:** Carlos, 35 anos +**Profissão:** Desenvolvedor de Software +**Renda:** R$ 15.000/mês +**Dor:** Não consegue manter planilhas atualizadas, perde tempo categorizando extratos manualmente +**Solução:** WebMoney importa seus extratos e categoriza automaticamente + +#### Persona 2: O Empreendedor Digital +**Nome:** Ana, 28 anos +**Profissão:** Designer Freelancer +**Renda:** Variável, R$ 8-20.000/mês +**Dor:** Precisa separar gastos pessoais de profissionais, controlar clientes diferentes +**Solução:** WebMoney com centros de custo por cliente + +#### Persona 3: O Devedor Controlado +**Nome:** Roberto, 42 anos +**Profissão:** Gerente Comercial +**Renda:** R$ 12.000/mês +**Dor:** Tem financiamento de carro e casa, perde controle das parcelas +**Solução:** WebMoney importa PDFs de financiamento e concilia automaticamente + +#### Persona 4: O Casal Unido +**Nomes:** Julia e Pedro, 30 anos +**Profissão:** Advogada e Contador +**Renda:** R$ 25.000/mês combinado +**Dor:** Múltiplas contas, cartões, e precisam de visão consolidada +**Solução:** WebMoney centraliza todas as contas com detecção de transferências + +--- + +## 5. Diferenciais + +### 5.1 Comparativo com Concorrentes + +| Recurso | WebMoney | Organizze | Mobills | GuiaBolso | +|---------|----------|-----------|---------|-----------| +| Importação CSV/Excel | ✅ | ✅ | ❌ | ❌ | +| Importação PDF | ✅ | ❌ | ❌ | ❌ | +| Importação OFX | ✅ | ✅ | ✅ | ✅ | +| Categorização Automática | ✅ | ✅ | ✅ | ✅ | +| Palavras-chave Customizáveis | ✅ | Limitado | Limitado | ❌ | +| Centros de Custo | ✅ | ❌ | ❌ | ❌ | +| Controle de Financiamentos | ✅ | Básico | ❌ | ❌ | +| Recorrências Avançadas | ✅ | ✅ | ✅ | ✅ | +| Detecção de Transferências | ✅ | ❌ | ❌ | ✅ | +| Detecção de Reembolsos | ✅ | ❌ | ❌ | ❌ | +| Multi-Idioma | ✅ | ❌ | ❌ | ❌ | +| Multi-Moeda Real | ✅ | Limitado | ❌ | ❌ | +| Divisão de Transações | ✅ | ✅ | ❌ | ❌ | +| Web + Responsivo | ✅ | ✅ | App | App | + +### 5.2 Diferenciais Únicos + +1. **Importação Universal**: Único a importar PDFs de extratos e contratos com OCR +2. **Centros de Custo**: Organização adicional ideal para profissionais +3. **Detecção Dupla**: Identifica transferências E reembolsos automaticamente +4. **Multi-Idioma Nativo**: PT-BR, ES, EN - não tradução automática +5. **Open Source Friendly**: Código limpo e documentado + +--- + +## 6. Casos de Uso + +### Caso 1: Organização Mensal de Finanças Pessoais + +**Situação:** Maria recebe o extrato do banco todo mês e gasta horas copiando para uma planilha. + +**Com WebMoney:** +1. Maria baixa o extrato em CSV do internet banking +2. Importa no WebMoney em 30 segundos +3. O sistema categoriza 80% automaticamente +4. Maria categoriza o restante em lote +5. Dashboard mostra quanto gastou em cada categoria + +**Tempo economizado:** 3 horas/mês + +--- + +### Caso 2: Freelancer com Múltiplos Clientes + +**Situação:** João é designer e precisa separar gastos por cliente para faturamento. + +**Com WebMoney:** +1. João cria centros de custo por cliente +2. Adiciona palavras-chave (nome do cliente nos pagamentos) +3. Transações são associadas automaticamente +4. Relatórios por centro de custo para declaração + +**Benefício:** Organização profissional sem esforço + +--- + +### Caso 3: Controle de Financiamento de Veículo + +**Situação:** Roberto tem financiamento de carro com 48 parcelas e perde o controle. + +**Com WebMoney:** +1. Roberto importa o PDF da simulação/contrato +2. WebMoney extrai todas as parcelas automaticamente +3. Quando paga, o sistema detecta e concilia +4. Dashboard mostra progresso: 12/48 pagas + +**Benefício:** Visão clara do progresso e parcelas restantes + +--- + +### Caso 4: Casal com Finanças Compartilhadas + +**Situação:** Julia e Pedro têm 4 contas bancárias e 3 cartões de crédito. + +**Com WebMoney:** +1. Cadastram todas as contas no sistema +2. Importam extratos de todas as fontes +3. Sistema detecta transferências entre contas deles +4. Dashboard consolida saldo total real + +**Benefício:** Visão unificada das finanças do casal + +--- + +### Caso 5: Controle de Assinaturas + +**Situação:** Ana não sabe quanto gasta com Netflix, Spotify, iCloud, etc. + +**Com WebMoney:** +1. Ana categoriza uma vez como "Assinaturas" +2. Cria recorrências para cada serviço +3. Sistema alerta variações de preço +4. Dashboard mostra total mensal em assinaturas + +**Benefício:** Consciência do custo total de assinaturas + +--- + +## 7. Especificações Técnicas + +### 7.1 Stack Tecnológico + +#### Backend +- **Framework:** Laravel 12 (PHP 8.4) +- **API:** RESTful com Laravel Sanctum (autenticação) +- **Database:** SQLite (development) / MySQL/PostgreSQL (production) +- **Cache:** Redis (opcional) + +#### Frontend +- **Framework:** React 18 +- **Build Tool:** Vite 7 +- **Estilização:** Bootstrap 5 + CSS Custom +- **Gráficos:** Chart.js / Recharts +- **Internacionalização:** react-i18next + +#### Infraestrutura +- **Servidor:** PHP-FPM + Nginx +- **Contêiner:** Docker (opcional) +- **SSL:** Let's Encrypt + +### 7.2 Requisitos de Sistema + +#### Para Usuários +- Navegador moderno (Chrome, Firefox, Safari, Edge) +- Conexão à internet +- Não requer instalação + +#### Para Hospedagem +- PHP 8.2+ +- Node.js 18+ (para build) +- Banco de dados (SQLite/MySQL/PostgreSQL) +- 1GB RAM mínimo +- 10GB armazenamento + +### 7.3 Segurança + +- Autenticação por token (Bearer) +- Senhas hasheadas com bcrypt +- HTTPS obrigatório em produção +- Proteção CSRF +- Rate limiting em APIs +- Validação de entrada +- Sanitização de dados + +### 7.4 API Endpoints (Resumo) + +| Grupo | Endpoints | Descrição | +|-------|-----------|-----------| +| Auth | 4 | Login, registro, logout, perfil | +| Accounts | 6 | CRUD + ajustes de saldo | +| Transactions | 15+ | CRUD + split, transfer, reconcile | +| Categories | 8 | CRUD + keywords + batch | +| Cost Centers | 5 | CRUD + keywords | +| Import | 8 | Upload, map, process | +| Liabilities | 6 | CRUD + installments | +| Recurring | 10 | Templates + instances | +| Detection | 6 | Transfers + refunds | +| Dashboard | 8 | Charts + widgets | + +--- + +## 8. FAQ + +### Perguntas Frequentes + +**P: O WebMoney se conecta diretamente ao meu banco?** +R: Não. O WebMoney não se conecta via Open Banking. Você baixa o extrato do seu banco e importa manualmente. Isso garante maior segurança e privacidade. + +**P: Meus dados financeiros estão seguros?** +R: Sim. Os dados são armazenados com criptografia, senhas são hasheadas e toda comunicação usa HTTPS. Não compartilhamos dados com terceiros. + +**P: Posso usar no celular?** +R: Sim! O WebMoney é responsivo e funciona perfeitamente em smartphones e tablets através do navegador. + +**P: Quantas contas posso cadastrar?** +R: Não há limite. Você pode cadastrar quantas contas, categorias e transações precisar. + +**P: O sistema funciona com bancos digitais?** +R: Sim! Funciona com qualquer banco que permita exportar extratos em CSV, Excel ou OFX - incluindo Nubank, Inter, C6, etc. + +**P: Posso dividir uma transação em várias categorias?** +R: Sim! O recurso de Split permite dividir uma transação, como uma compra de supermercado que inclui alimentação e limpeza. + +**P: Como funciona a detecção de transferências?** +R: O sistema analisa suas transações e identifica automaticamente quando um débito em uma conta corresponde a um crédito em outra (mesmo valor, datas próximas). + +**P: Posso usar em espanhol ou inglês?** +R: Sim! O sistema suporta nativamente Português, Espanhol e Inglês. Basta selecionar no menu. + +**P: Precisa instalar alguma coisa?** +R: Não! WebMoney é uma aplicação web. Basta acessar pelo navegador. + +--- + +## 9. Conteúdo para Redes Sociais + +### 9.1 Posts para Instagram/LinkedIn + +**Post 1: Introdução** +``` +🚀 Chega de planilhas! + +Conheça o WebMoney - sua gestão financeira simplificada. + +✅ Importe extratos de qualquer banco +✅ Categorização automática +✅ Controle de financiamentos +✅ Multi-conta e multi-moeda + +Sua vida financeira organizada em minutos! + +#gestaofinanceira #financaspessoais #controle #organizacao +``` + +**Post 2: Feature - Importação** +``` +📥 Importação Universal + +Com o WebMoney você importa extratos em: +• CSV +• Excel +• OFX +• Até PDF! + +Funciona com qualquer banco: Nubank, Itaú, Bradesco, Inter, C6... + +Adeus digitação manual! 👋 + +#extratobancario #importacao #financas +``` + +**Post 3: Feature - Categorização** +``` +🏷️ Nunca mais categorize manualmente! + +O WebMoney aprende suas preferências e categoriza transações automaticamente. + +Como funciona: +1️⃣ Defina palavras-chave (ex: "UBER" = Transporte) +2️⃣ Importe seu extrato +3️⃣ Pronto! Categorização automática + +Tempo economizado: 3 horas/mês 🕐 + +#automacao #produtividade #financas +``` + +**Post 4: Feature - Passivos** +``` +📋 Financiamento sob controle + +Tem financiamento de carro, casa ou empréstimo? + +Com o WebMoney você: +✅ Importa o PDF do contrato +✅ Extrai parcelas automaticamente +✅ Concilia com pagamentos +✅ Visualiza progresso + +Saiba exatamente quanto falta pagar! + +#financiamento #dividas #controle +``` + +**Post 5: Feature - Multi-idioma** +``` +🌍 Fale nossa língua! + +O WebMoney está disponível em: +🇧🇷 Português +🇪🇸 Español +🇺🇸 English + +Gestão financeira sem barreiras de idioma. + +#multilingual #global #financas +``` + +### 9.2 Headlines para Anúncios + +- "Importe seu extrato em 30 segundos" +- "Categorização automática que aprende com você" +- "Múltiplas contas, uma visão consolidada" +- "De 3 horas para 5 minutos" +- "Financiamentos sob controle total" +- "Sua planilha de gastos. Só que automática." + +### 9.3 Call to Actions + +- "Comece grátis agora" +- "Experimente sem compromisso" +- "Organize suas finanças hoje" +- "Veja como funciona" +- "Crie sua conta grátis" + +--- + +## 📎 Anexos + +### Anexo A: Lista Completa de Ícones de Contas +- bi-bank (Banco) +- bi-piggy-bank (Poupança) +- bi-credit-card (Cartão) +- bi-wallet (Carteira) +- bi-graph-up (Investimentos) +- bi-cash-stack (Dinheiro) +- bi-phone (Digital) + +### Anexo B: Tipos de Conta +- checking (Conta Corrente) +- savings (Poupança) +- credit_card (Cartão de Crédito) +- investment (Investimentos) +- cash (Dinheiro) +- digital_wallet (Carteira Digital) +- other (Outros) + +### Anexo C: Frequências de Recorrência +- daily (Diária) +- weekly (Semanal) +- biweekly (Quinzenal) +- monthly (Mensal) +- bimonthly (Bimestral) +- quarterly (Trimestral) +- semiannual (Semestral) +- annual (Anual) + +--- + +*Documento gerado em Dezembro 2025* +*WebMoney - Sua gestão financeira simplificada* diff --git a/docs/MANUAL_USUARIO.md b/docs/MANUAL_USUARIO.md new file mode 100644 index 0000000..465a0c6 --- /dev/null +++ b/docs/MANUAL_USUARIO.md @@ -0,0 +1,800 @@ +# 📖 WebMoney - Manual do Usuário + +## Sistema de Gestão Financeira Pessoal e Empresarial + +**Versão:** 1.0 +**Data:** Dezembro 2025 + +--- + +## 📋 Índice + +1. [Visão Geral](#1-visão-geral) +2. [Primeiros Passos](#2-primeiros-passos) +3. [Dashboard](#3-dashboard) +4. [Contas Financeiras](#4-contas-financeiras) +5. [Transações](#5-transações) +6. [Categorias](#6-categorias) +7. [Centros de Custo](#7-centros-de-custo) +8. [Importação de Extratos](#8-importação-de-extratos) +9. [Passivos e Financiamentos](#9-passivos-e-financiamentos) +10. [Transações Recorrentes](#10-transações-recorrentes) +11. [Detecção de Transferências](#11-detecção-de-transferências) +12. [Detecção de Reembolsos](#12-detecção-de-reembolsos) +13. [Configurações](#13-configurações) + +--- + +## 1. Visão Geral + +### O que é o WebMoney? + +O **WebMoney** é um sistema completo de gestão financeira que permite: + +- ✅ Controlar múltiplas contas bancárias e cartões +- ✅ Categorizar receitas e despesas automaticamente +- ✅ Importar extratos bancários (CSV, XLSX, OFX, PDF) +- ✅ Gerenciar financiamentos e passivos +- ✅ Acompanhar transações recorrentes +- ✅ Detectar transferências e reembolsos automaticamente +- ✅ Visualizar fluxo de caixa e análises financeiras +- ✅ Multi-idioma (Português, Espanhol, Inglês) +- ✅ Multi-moeda (BRL, EUR, USD, etc.) + +### Características Principais + +| Recurso | Descrição | +|---------|-----------| +| **Multi-Conta** | Gerencie contas correntes, poupança, cartões de crédito, investimentos | +| **Categorização Automática** | Sistema de palavras-chave para categorização inteligente | +| **Importação Flexível** | Suporte a CSV, XLSX, XLS, OFX e até PDF | +| **Passivos** | Controle financiamentos com parcelas e conciliação | +| **Recorrências** | Automatize contas fixas mensais | +| **Detecção Inteligente** | Identifica transferências entre contas automaticamente | + +--- + +## 2. Primeiros Passos + +### 2.1 Cadastro e Login + +1. Acesse o sistema através do navegador +2. Clique em **"Criar Conta"** se for seu primeiro acesso +3. Preencha: + - Nome completo + - E-mail + - Senha (mínimo 8 caracteres) +4. Clique em **"Registrar"** +5. Faça login com seu e-mail e senha + +### 2.2 Configuração Inicial Recomendada + +Após o primeiro login, recomendamos seguir esta ordem: + +1. **Criar Contas** - Cadastre suas contas bancárias +2. **Criar Categorias** - Configure categorias de receitas e despesas +3. **Criar Centros de Custo** - Organize por projetos ou departamentos (opcional) +4. **Importar Extratos** - Importe seu histórico bancário +5. **Categorizar Transações** - Use a categorização em lote + +### 2.3 Navegação Principal + +O menu lateral oferece acesso rápido a: + +| Ícone | Seção | Função | +|-------|-------|--------| +| 📊 | Dashboard | Visão geral financeira | +| 💳 | Contas | Gerenciar contas bancárias | +| 💰 | Transações | Lista de movimentações | +| 🏷️ | Categorias | Organização por tipo | +| 🏢 | Centros de Custo | Organização por projeto | +| 📥 | Importar | Importar extratos | +| 📋 | Passivos | Financiamentos e dívidas | +| 🔄 | Recorrentes | Contas fixas | +| ↔️ | Transferências | Detecção automática | +| 🔁 | Reembolsos | Detecção de estornos | + +--- + +## 3. Dashboard + +O Dashboard é a tela inicial que apresenta uma visão consolidada das suas finanças. + +### 3.1 Cards de Resumo + +No topo da tela você encontra: + +- **Saldo Total** - Soma dos saldos de todas as contas ativas +- **Receitas do Mês** - Total de créditos no mês atual +- **Despesas do Mês** - Total de débitos no mês atual +- **Balanço do Mês** - Diferença entre receitas e despesas + +### 3.2 Gráfico de Fluxo de Caixa + +Mostra a evolução de receitas x despesas ao longo dos meses. + +**Configurações disponíveis:** +- Período: 3, 6, 12 ou 24 meses +- Filtro por conta específica + +### 3.3 Análise de Variações de Pagamento + +Identifica contas que apresentaram variação significativa em relação ao valor esperado (recorrente). + +**Exemplo:** Se sua conta de luz costuma ser R$ 200 e veio R$ 350, o sistema alerta a variação de +75%. + +### 3.4 Calendário Financeiro + +Visualize transações planejadas e realizadas em formato de calendário. + +- **Azul**: Transações pendentes +- **Verde**: Transações concluídas +- **Vermelho**: Transações vencidas + +### 3.5 Transações Próximas e Vencidas + +Widgets que mostram: +- **Próximas**: Transações com vencimento nos próximos 7 dias +- **Vencidas**: Transações pendentes com data passada + +--- + +## 4. Contas Financeiras + +### 4.1 Tipos de Conta Suportados + +| Tipo | Ícone | Descrição | +|------|-------|-----------| +| Conta Corrente | 🏦 | Conta bancária principal | +| Conta Poupança | 💰 | Conta de economia | +| Cartão de Crédito | 💳 | Cartão com limite | +| Carteira Digital | 📱 | PayPal, PicPay, etc. | +| Investimentos | 📈 | Aplicações financeiras | +| Dinheiro | 💵 | Dinheiro em espécie | +| Outros | 📋 | Demais tipos | + +### 4.2 Criar Nova Conta + +1. Acesse **Contas** no menu lateral +2. Clique em **"+ Nova Conta"** +3. Preencha os campos: + - **Nome**: Nome identificador (ex: "Nubank", "Itaú Corrente") + - **Tipo**: Selecione o tipo de conta + - **Banco**: Nome da instituição (opcional) + - **Número da Conta**: Para referência (opcional) + - **Saldo Inicial**: Saldo atual da conta + - **Limite de Crédito**: Para cartões de crédito + - **Moeda**: BRL, EUR, USD, etc. + - **Cor**: Cor para identificação visual + - **Ícone**: Ícone personalizado + - **Incluir no Total**: Se deve somar no saldo geral + - **Ativo**: Se a conta está ativa + +4. Clique em **"Salvar"** + +### 4.3 Ajustar Saldo + +Se o saldo calculado não corresponde ao saldo real: + +1. Clique no botão **"⚙️"** da conta +2. Selecione **"Ajustar Saldo"** +3. Informe o **saldo real atual** +4. O sistema criará uma transação de ajuste + +### 4.4 Recalcular Saldos + +O sistema recalcula automaticamente os saldos, mas você pode forçar o recálculo: + +1. Clique em **"Recalcular Todos os Saldos"** no topo da página + +--- + +## 5. Transações + +### 5.1 Visualização por Semana + +As transações são agrupadas por semana para facilitar a análise. + +**Cada semana mostra:** +- Data inicial e final +- Total de receitas (créditos) +- Total de despesas (débitos) +- Saldo da semana + +### 5.2 Filtros Disponíveis + +| Filtro | Descrição | +|--------|-----------| +| Conta | Filtrar por conta específica | +| Categoria | Filtrar por categoria | +| Centro de Custo | Filtrar por centro de custo | +| Tipo | Débito, Crédito ou Todos | +| Status | Pendente, Concluído, Cancelado | +| Busca | Pesquisar por descrição | +| Período | Data inicial e final | + +### 5.3 Criar Nova Transação + +1. Clique no botão **"+ Nova Transação"** +2. Preencha: + - **Conta**: Selecione a conta + - **Tipo**: Débito (saída) ou Crédito (entrada) + - **Descrição**: Descrição da transação + - **Categoria**: Categoria da transação + - **Centro de Custo**: Centro de custo (opcional) + - **Valor Planejado**: Valor esperado + - **Data Planejada**: Data prevista + - **Status**: Pendente ou Concluído + +3. Clique em **"Salvar"** + +### 5.4 Editar Transação + +1. Clique no ícone **"✏️"** na linha da transação +2. Modifique os campos desejados +3. Clique em **"Salvar"** + +### 5.5 Completar Transação (Marcar como Pago) + +Para transações pendentes: + +1. Clique no ícone **"✓"** (check) na transação +2. No modal de completar: + - **Valor Real**: Confirme ou altere o valor pago + - **Data Efetiva**: Data do pagamento +3. Clique em **"Completar"** + +### 5.6 Completar Rapidamente (Quick Complete) + +Para completar com os valores planejados: +- Clique no ícone de **raio** ⚡ na transação +- A transação será marcada como concluída com a data e valor planejados + +### 5.7 Cancelar Transação + +1. Clique no menu **"⋮"** da transação +2. Selecione **"Cancelar"** +3. Confirme a operação + +### 5.8 Reverter Transação + +Para voltar uma transação concluída ao status pendente: + +1. Clique no menu **"⋮"** da transação +2. Selecione **"Reverter"** + +### 5.9 Duplicar Transação + +1. Clique no menu **"⋮"** da transação +2. Selecione **"Duplicar"** +3. Uma cópia será criada para edição + +### 5.10 Dividir Transação (Split) + +Divida uma transação em múltiplas categorias: + +1. Clique no menu **"⋮"** da transação +2. Selecione **"Dividir"** +3. No modal: + - Adicione as divisões com categoria e valor + - O total das divisões deve ser igual ao valor original +4. Clique em **"Dividir"** + +### 5.11 Criar Transferência + +Para registrar transferência entre contas: + +1. Clique em **"+ Transferência"** +2. Preencha: + - **Conta de Origem**: De onde sai o dinheiro + - **Conta de Destino**: Para onde vai + - **Valor**: Valor transferido + - **Data**: Data da transferência + - **Descrição**: Descrição (opcional) +3. Clique em **"Transferir"** + +> **Nota:** O sistema cria duas transações vinculadas: um débito na origem e um crédito no destino. + +### 5.12 Categorização em Lote + +Para categorizar múltiplas transações de uma vez: + +1. Use o filtro de **busca** para encontrar transações similares + - Exemplo: Busque "UBER" para ver todas as corridas +2. Clique em **"Categorizar em Lote"** +3. No modal: + - Selecione a **Categoria** a aplicar + - Selecione o **Centro de Custo** (opcional) + - Marque **"Adicionar como palavra-chave"** para categorização automática futura +4. Clique em **"Aplicar"** + +### 5.13 Converter em Transferência + +Se você tem duas transações que são uma transferência: + +1. Na transação de débito, clique no menu **"⋮"** +2. Selecione **"Converter em Transferência"** +3. O sistema buscará transações de crédito compatíveis +4. Selecione o par correspondente +5. Confirme a vinculação + +### 5.14 Conciliar com Passivo + +Para vincular uma transação a uma parcela de financiamento: + +1. Clique no menu **"⋮"** da transação +2. Selecione **"Conciliar com Passivo"** +3. Selecione a parcela correspondente +4. Confirme + +--- + +## 6. Categorias + +### 6.1 Estrutura Hierárquica + +As categorias suportam **subcategorias** para organização detalhada: + +``` +📁 Alimentação + ├── 🍔 Restaurantes + ├── 🛒 Supermercado + └── ☕ Cafeterias + +📁 Transporte + ├── 🚗 Combustível + ├── 🚌 Transporte Público + └── 🚕 Táxi/Uber +``` + +### 6.2 Tipos de Categoria + +| Tipo | Cor | Uso | +|------|-----|-----| +| Despesa | Vermelho | Gastos e saídas | +| Receita | Verde | Entradas e ganhos | +| Ambos | Azul | Pode ser usado em ambos | + +### 6.3 Criar Categoria + +1. Acesse **Categorias** no menu +2. Clique em **"+ Nova Categoria"** +3. Preencha: + - **Nome**: Nome da categoria + - **Tipo**: Despesa, Receita ou Ambos + - **Categoria Pai**: Para criar subcategoria (opcional) + - **Cor**: Cor de identificação + - **Ícone**: Ícone visual + - **Palavras-chave**: Para categorização automática + +4. Clique em **"Salvar"** + +### 6.4 Criar Subcategoria + +1. Na linha da categoria pai, clique em **"+"** +2. Preencha os dados da subcategoria +3. Salve + +### 6.5 Sistema de Palavras-Chave + +As palavras-chave permitem **categorização automática** de transações importadas. + +**Como funciona:** +- Ao importar um extrato com "UBER TRIP", se a categoria "Transporte" tem a palavra-chave "UBER", a transação será categorizada automaticamente. + +**Adicionar palavra-chave:** +1. Edite a categoria +2. No campo "Nova palavra-chave", digite a palavra +3. Pressione Enter ou clique em **"+"** +4. Salve a categoria + +### 6.6 Categorização Automática em Lote + +Para aplicar palavras-chave existentes a transações não categorizadas: + +1. Na página de Categorias, clique em **"Categorizar em Lote"** +2. Visualize o preview das transações que serão categorizadas +3. Clique em **"Executar"** + +### 6.7 Reordenar Categorias + +As categorias podem ser reordenadas por drag-and-drop para personalizar a lista. + +--- + +## 7. Centros de Custo + +### 7.1 O que são Centros de Custo? + +Centros de custo permitem **organizar transações por projeto, departamento ou cliente**, independente da categoria. + +**Exemplos de uso:** +- Projetos: "Website Cliente X", "Reforma Casa" +- Departamentos: "Marketing", "TI", "RH" +- Clientes: "Empresa ABC", "Cliente João" + +### 7.2 Criar Centro de Custo + +1. Acesse **Centros de Custo** no menu +2. Clique em **"+ Novo Centro de Custo"** +3. Preencha: + - **Nome**: Nome identificador + - **Código**: Código interno (opcional) + - **Descrição**: Detalhes adicionais + - **Cor**: Cor de identificação + - **Ícone**: Ícone visual + - **Palavras-chave**: Para associação automática + +4. Salve + +### 7.3 Palavras-Chave em Centros de Custo + +Similar às categorias, você pode usar palavras-chave para associar transações automaticamente a centros de custo. + +--- + +## 8. Importação de Extratos + +### 8.1 Formatos Suportados + +| Formato | Extensão | Descrição | +|---------|----------|-----------| +| CSV | .csv | Arquivo separado por vírgula | +| Excel | .xlsx, .xls | Planilha Microsoft Excel | +| OFX | .ofx | Open Financial Exchange | +| PDF | .pdf | Extrato em PDF (com OCR) | + +### 8.2 Processo de Importação + +O processo é dividido em **4 etapas**: + +#### Etapa 1: Upload do Arquivo + +1. Acesse **Importar** no menu +2. Arraste o arquivo ou clique para selecionar +3. Aguarde o processamento + +#### Etapa 2: Configuração + +Configure como o arquivo deve ser lido: + +- **Linha do Cabeçalho**: Qual linha contém os nomes das colunas +- **Linha de Início dos Dados**: Onde começam os dados +- **Formato de Data**: dd/mm/yyyy, mm/dd/yyyy, etc. +- **Separador Decimal**: Vírgula (,) ou ponto (.) +- **Separador de Milhar**: Ponto (.) ou vírgula (,) + +#### Etapa 3: Mapeamento de Colunas + +Associe as colunas do arquivo aos campos do sistema: + +| Campo | Descrição | Obrigatório | +|-------|-----------|-------------| +| Data | Data da transação | ✅ Sim | +| Descrição | Descrição/histórico | ✅ Sim | +| Valor | Valor (pode ser com sinal) | ✅ Sim | +| Valor de Entrada | Valor de crédito separado | Não | +| Valor de Saída | Valor de débito separado | Não | +| Referência | Número do documento | Não | + +#### Etapa 4: Opções e Importação + +- **Conta de Destino**: Selecione a conta para importar +- **Categoria Padrão**: Categoria para transações não categorizadas +- **Centro de Custo**: Centro de custo padrão (opcional) +- **Salvar Mapeamento**: Para reutilizar em importações futuras +- **Nome do Mapeamento**: Nome para identificar o preset + +Clique em **"Importar"**. + +### 8.3 Resultado da Importação + +Após a importação, você verá: + +- ✅ **Importadas**: Transações novas criadas +- ⚠️ **Duplicadas**: Transações ignoradas por já existirem +- ❌ **Erros**: Linhas com problemas + +### 8.4 Mapeamentos Salvos + +Você pode salvar configurações de importação para reutilizar: + +1. Na lista de mapeamentos, selecione um existente +2. O sistema preenche automaticamente as configurações +3. Modifique se necessário e importe + +### 8.5 Presets de Banco + +O sistema inclui presets para bancos comuns: +- Nubank +- Itaú +- Bradesco +- Banco do Brasil +- Santander +- Caixa Econômica + +--- + +## 9. Passivos e Financiamentos + +### 9.1 O que são Passivos? + +Passivos são compromissos financeiros com parcelas, como: +- Financiamento de veículo +- Financiamento imobiliário +- Empréstimos pessoais +- Consórcios + +### 9.2 Importar Passivo de PDF + +1. Acesse **Passivos** no menu +2. Clique em **"+ Importar Contrato"** +3. Selecione o arquivo PDF do contrato/simulação +4. Preencha: + - **Nome**: Nome do financiamento + - **Credor**: Nome da instituição + - **Número do Contrato**: Para referência + - **Moeda**: Moeda do contrato + - **Descrição**: Notas adicionais + +5. Clique em **"Importar"** + +> O sistema usa OCR para extrair as parcelas automaticamente do PDF. + +### 9.3 Visualizar Detalhes do Passivo + +Ao clicar em um passivo, você vê: + +- **Resumo**: Valor total, pago, pendente +- **Progresso**: Barra visual do pagamento +- **Lista de Parcelas**: Todas as parcelas com status + +### 9.4 Status das Parcelas + +| Status | Cor | Descrição | +|--------|-----|-----------| +| Pendente | Amarelo | Aguardando pagamento | +| Pago | Verde | Parcela quitada | +| Vencida | Vermelho | Parcela não paga e vencida | +| Parcial | Laranja | Parcialmente paga | + +### 9.5 Conciliar Parcela com Transação + +Para vincular um pagamento a uma parcela: + +1. Na lista de parcelas, clique em **"Conciliar"** +2. O sistema busca transações compatíveis (mesmo valor/período) +3. Selecione a transação correspondente +4. Confirme + +### 9.6 Marcar Parcela como Paga + +Se você pagou mas não tem a transação registrada: + +1. Na parcela, clique em **"Marcar como Pago"** +2. Uma transação será criada automaticamente + +### 9.7 Análise de Preços + +O sistema analisa as parcelas e identifica: +- Valor principal +- Juros +- Seguros +- Taxas + +--- + +## 10. Transações Recorrentes + +### 10.1 O que são Recorrências? + +Recorrências automatizam o lançamento de transações que se repetem, como: +- Aluguel mensal +- Salário +- Netflix/Spotify +- Conta de luz/água + +### 10.2 Criar Recorrência + +#### Método 1: Do Zero + +1. Acesse **Recorrentes** no menu +2. Clique em **"+ Nova Recorrência"** +3. Preencha: + - **Descrição**: Nome da transação + - **Conta**: Conta associada + - **Categoria**: Categoria da transação + - **Tipo**: Débito ou Crédito + - **Valor**: Valor da transação + - **Frequência**: Mensal, Semanal, etc. + - **Data de Início**: Quando começa + - **Data de Término**: Quando termina (opcional) + +4. Salve + +#### Método 2: A partir de Transação + +1. Na página de Transações, clique no menu **"⋮"** +2. Selecione **"Criar Recorrência"** +3. Configure a frequência e datas +4. Salve + +### 10.3 Frequências Disponíveis + +| Frequência | Descrição | +|------------|-----------| +| Diária | Todo dia | +| Semanal | Toda semana | +| Quinzenal | A cada 15 dias | +| Mensal | Todo mês | +| Bimestral | A cada 2 meses | +| Trimestral | A cada 3 meses | +| Semestral | A cada 6 meses | +| Anual | Uma vez por ano | + +### 10.4 Gerenciar Instâncias + +Cada recorrência gera **instâncias** - as transações individuais. + +**Ações em instâncias:** +- **Pagar**: Marcar como pago (cria transação) +- **Conciliar**: Vincular a transação existente +- **Pular**: Ignorar esta instância +- **Cancelar**: Cancelar esta instância +- **Editar**: Modificar valor ou data desta instância + +### 10.5 Pausar/Retomar Recorrência + +Para pausar temporariamente: + +1. No template, clique em **"Pausar"** +2. Nenhuma nova instância será gerada +3. Para retomar, clique em **"Retomar"** + +### 10.6 Conciliar com Transação Existente + +Se você já tem a transação registrada: + +1. Na instância pendente, clique em **"Conciliar"** +2. O sistema sugere transações compatíveis +3. Selecione a correspondente +4. Confirme + +--- + +## 11. Detecção de Transferências + +### 11.1 Como Funciona + +O sistema analisa suas transações e identifica automaticamente **pares de transferência**: um débito em uma conta e um crédito correspondente em outra. + +**Critérios de detecção:** +- Mesmo valor (ou similar) +- Datas próximas (tolerância configurável) +- Contas diferentes + +### 11.2 Níveis de Confiança + +| Nível | Cor | Significado | +|-------|-----|-------------| +| Alta | Verde | Valores e datas muito próximos | +| Média | Amarelo | Valores iguais, datas diferentes | +| Baixa | Cinza | Valores similares | + +### 11.3 Ações Disponíveis + +Para cada par detectado: + +- **Confirmar**: Vincula as transações como transferência +- **Ignorar**: Marca para não sugerir novamente +- **Excluir Ambas**: Remove as duas transações + +### 11.4 Confirmação em Lote + +1. Marque os pares desejados com o checkbox +2. Clique em **"Confirmar Selecionados"** +3. Confirme a operação + +### 11.5 Configurar Tolerância + +Ajuste a tolerância de dias para detecção: +- Use o seletor no topo da página +- Valores maiores encontram mais pares +- Valores menores são mais precisos + +--- + +## 12. Detecção de Reembolsos + +### 12.1 O que são Reembolsos? + +Reembolsos são transações onde você foi estornado por uma compra: + +- Devolução de produto +- Cancelamento de assinatura +- Estorno de cobrança indevida + +### 12.2 Como Funciona + +O sistema detecta pares onde: +- Existe um débito (compra) +- Existe um crédito (reembolso) na mesma conta +- Valores iguais ou similares +- Datas próximas + +### 12.3 Confirmar Reembolso + +1. Na lista de reembolsos detectados +2. Clique em **"Confirmar"** +3. As transações são vinculadas +4. O sistema marca o status como reembolsado + +### 12.4 Desfazer Reembolso + +Se confirmou por engano: + +1. Na transação vinculada +2. Use a opção **"Desfazer Reembolso"** + +--- + +## 13. Configurações + +### 13.1 Idioma + +O sistema suporta três idiomas: +- 🇧🇷 Português (Brasil) +- 🇪🇸 Español +- 🇺🇸 English + +Para alterar: +1. Clique no seletor de idioma no topo +2. Selecione o idioma desejado + +### 13.2 Moeda Padrão + +Cada conta pode ter sua própria moeda: +- BRL - Real Brasileiro +- EUR - Euro +- USD - Dólar Americano +- GBP - Libra Esterlina + +### 13.3 Formato de Data + +O sistema ajusta automaticamente o formato de data conforme o idioma: +- PT-BR: dd/mm/aaaa +- ES: dd/mm/aaaa +- EN: mm/dd/yyyy + +### 13.4 Perfil do Usuário + +Acesse seu perfil para: +- Alterar nome +- Alterar e-mail +- Alterar senha +- Ver tipo de conta (Admin/Usuário) + +--- + +## 📞 Suporte + +Para dúvidas ou problemas: +- Email: suporte@webmoney.com +- Documentação: Este manual + +--- + +## 📝 Notas de Versão + +### Versão 1.0 (Dezembro 2025) +- Lançamento inicial +- Multi-conta e multi-moeda +- Importação de extratos +- Categorização automática +- Passivos e recorrências +- Detecção de transferências e reembolsos +- Multi-idioma (PT-BR, ES, EN) + +--- + +*WebMoney - Sua gestão financeira simplificada* diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..e03e51a --- /dev/null +++ b/docs/README.md @@ -0,0 +1,245 @@ +# 📚 WebMoney - Documentação + +## Sistema de Gestão Financeira Pessoal e Empresarial + +**Versão:** 1.0 +**Data:** Dezembro 2025 + +--- + +## 📖 Documentos Disponíveis + +### 1. [Manual do Usuário](MANUAL_USUARIO.md) +Guia completo para usuários finais com instruções detalhadas de como usar cada funcionalidade do sistema. + +**Conteúdo:** +- Primeiros passos e configuração inicial +- Dashboard e visão geral +- Gestão de contas e transações +- Categorias e centros de custo +- Importação de extratos +- Passivos e financiamentos +- Transações recorrentes +- Detecção de transferências e reembolsos + +--- + +### 2. [Documento Institucional](DOCUMENTO_INSTITUCIONAL.md) +Conteúdo para website, marketing e comunicação institucional. + +**Conteúdo:** +- Taglines e propostas de valor +- Descrições (curta, média, longa) +- Lista completa de funcionalidades +- Público-alvo e personas +- Diferenciais competitivos +- Casos de uso +- Especificações técnicas resumidas +- FAQ +- Conteúdo para redes sociais + +--- + +### 3. [Referência da API](API_REFERENCE.md) +Documentação técnica completa de todos os endpoints da API REST. + +**Conteúdo:** +- Autenticação +- Endpoints de Contas +- Endpoints de Transações +- Endpoints de Categorias +- Endpoints de Centros de Custo +- Endpoints de Importação +- Endpoints de Passivos +- Endpoints de Recorrências +- Endpoints de Detecção +- Endpoints de Dashboard + +--- + +## 🏗️ Estrutura do Projeto + +``` +webmoney/ +├── backend/ # Laravel 12 (PHP 8.4) +│ ├── app/ +│ │ ├── Http/Controllers/Api/ +│ │ │ ├── AccountController.php +│ │ │ ├── AuthController.php +│ │ │ ├── CategoryController.php +│ │ │ ├── CostCenterController.php +│ │ │ ├── DashboardController.php +│ │ │ ├── ImportController.php +│ │ │ ├── LiabilityAccountController.php +│ │ │ ├── RecurringTemplateController.php +│ │ │ ├── TransactionController.php +│ │ │ └── TransferDetectionController.php +│ │ └── Models/ +│ │ ├── Account.php +│ │ ├── Category.php +│ │ ├── CostCenter.php +│ │ ├── LiabilityAccount.php +│ │ ├── RecurringTemplate.php +│ │ ├── RecurringInstance.php +│ │ ├── Transaction.php +│ │ └── User.php +│ ├── database/ +│ │ └── migrations/ +│ ├── routes/ +│ │ └── api.php +│ └── storage/ +│ └── app/private/imports/ +│ +├── frontend/ # React 18 + Vite 7 +│ ├── src/ +│ │ ├── components/ +│ │ ├── context/ +│ │ ├── hooks/ +│ │ ├── i18n/ +│ │ │ ├── es.json +│ │ │ ├── en.json +│ │ │ └── pt-BR.json +│ │ ├── pages/ +│ │ │ ├── Accounts.jsx +│ │ │ ├── Categories.jsx +│ │ │ ├── CostCenters.jsx +│ │ │ ├── Dashboard.jsx +│ │ │ ├── ImportTransactions.jsx +│ │ │ ├── LiabilityAccounts.jsx +│ │ │ ├── Login.jsx +│ │ │ ├── RecurringTransactions.jsx +│ │ │ ├── RefundDetection.jsx +│ │ │ ├── Register.jsx +│ │ │ ├── TransactionsByWeek.jsx +│ │ │ └── TransferDetection.jsx +│ │ └── services/ +│ │ └── api.js +│ └── public/ +│ +├── docs/ # Documentação +│ ├── README.md # Este arquivo +│ ├── MANUAL_USUARIO.md # Manual do usuário +│ ├── DOCUMENTO_INSTITUCIONAL.md # Conteúdo institucional +│ └── API_REFERENCE.md # Referência da API +│ +└── docker/ # Configuração Docker (opcional) +``` + +--- + +## 🔧 Stack Tecnológico + +### Backend +| Tecnologia | Versão | Uso | +|------------|--------|-----| +| PHP | 8.4 | Linguagem principal | +| Laravel | 12 | Framework web | +| Sanctum | - | Autenticação API | +| SQLite/MySQL | - | Banco de dados | + +### Frontend +| Tecnologia | Versão | Uso | +|------------|--------|-----| +| React | 18 | Framework UI | +| Vite | 7 | Build tool | +| Bootstrap | 5 | Estilização | +| react-i18next | - | Internacionalização | +| Chart.js | - | Gráficos | + +### Infraestrutura +| Tecnologia | Uso | +|------------|-----| +| Nginx | Web server | +| PHP-FPM | Process manager | +| Docker | Containerização (opcional) | + +--- + +## 🌐 Idiomas Suportados + +| Código | Idioma | Arquivo | +|--------|--------|---------| +| pt-BR | Português (Brasil) | i18n/pt-BR.json | +| es | Español | i18n/es.json | +| en | English | i18n/en.json | + +--- + +## 💱 Moedas Suportadas + +| Código | Moeda | Símbolo | +|--------|-------|---------| +| BRL | Real Brasileiro | R$ | +| EUR | Euro | € | +| USD | Dólar Americano | $ | +| GBP | Libra Esterlina | £ | +| JPY | Iene Japonês | ¥ | + +--- + +## 📊 Resumo de Funcionalidades + +| Módulo | Funcionalidades | +|--------|-----------------| +| **Contas** | CRUD, multi-tipo, multi-moeda, ajuste de saldo | +| **Transações** | CRUD, split, transferência, completar, cancelar | +| **Categorias** | Hierárquicas, palavras-chave, categorização em lote | +| **Centros de Custo** | CRUD, palavras-chave | +| **Importação** | CSV, XLSX, OFX, PDF, mapeamentos salvos | +| **Passivos** | Importação PDF, parcelas, conciliação | +| **Recorrências** | Templates, instâncias, pausar/retomar | +| **Detecção** | Transferências e reembolsos automáticos | +| **Dashboard** | Resumo, cashflow, calendário, alertas | + +--- + +## 🚀 Deploy + +### Produção +- **Servidor:** 213.165.93.60 +- **Domínio:** webmoney.cnxifly.com +- **Protocolo:** HTTPS (Let's Encrypt) + +### Comandos Úteis + +```bash +# Backend - Rodar migrations +php artisan migrate + +# Backend - Limpar cache +php artisan cache:clear +php artisan config:clear +php artisan route:clear + +# Frontend - Build de produção +npm run build + +# Frontend - Desenvolvimento +npm run dev +``` + +--- + +## 📝 Notas de Versão + +### v1.0.0 (Dezembro 2025) +- Lançamento inicial +- Todas as funcionalidades core implementadas +- Multi-idioma (PT-BR, ES, EN) +- Multi-moeda +- Importação universal +- Categorização automática +- Passivos e recorrências +- Detecção inteligente + +--- + +## 📞 Contato + +- **Desenvolvedor:** Marco Leite +- **Email:** marco@cnxifly.com +- **Sistema:** webmoney.cnxifly.com + +--- + +*WebMoney - Sua gestão financeira simplificada* diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..18bc70e --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,16 @@ +# React + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project. diff --git a/frontend/deploy.ps1 b/frontend/deploy.ps1 new file mode 100644 index 0000000..e7159f7 --- /dev/null +++ b/frontend/deploy.ps1 @@ -0,0 +1,79 @@ +# ============================================================================= +# WEBMoney Frontend - Script de Deploy para Windows +# ============================================================================= +# Este script faz build e deploy do frontend para o servidor de produção +# Uso: .\deploy.ps1 +# Requer: Node.js, npm, e clave SSH configurada (ou escribir contraseña) +# ============================================================================= + +# Configurações +$SERVER_USER = "root" +$SERVER_HOST = "213.165.93.60" +$REMOTE_PATH = "/var/www/webmoney/frontend/dist" +$LOCAL_DIST = ".\dist" + +# Cores +function Write-Color { + param([string]$Text, [string]$Color = "White") + Write-Host $Text -ForegroundColor $Color +} + +Write-Color "========================================" "Cyan" +Write-Color " WEBMoney Frontend - Deploy Script " "Cyan" +Write-Color "========================================" "Cyan" +Write-Host "" + +# 1. Build +Write-Color "[1/4] Fazendo build do frontend..." "Yellow" + +if (Test-Path $LOCAL_DIST) { + Remove-Item -Recurse -Force $LOCAL_DIST +} + +npm run build + +if (-not (Test-Path $LOCAL_DIST)) { + Write-Color "ERRO: Build falhou - pasta dist não encontrada" "Red" + exit 1 +} + +Write-Color "✓ Build concluído" "Green" +Write-Host "" + +# 2. Limpar diretório remoto +Write-Color "[2/4] Limpando diretório remoto..." "Yellow" +ssh "$SERVER_USER@$SERVER_HOST" "rm -rf $REMOTE_PATH/* && echo 'Diretório limpo'" + +Write-Color "✓ Diretório remoto limpo" "Green" +Write-Host "" + +# 3. Enviar arquivos +Write-Color "[3/4] Enviando arquivos para $REMOTE_PATH ..." "Yellow" +scp -r "$LOCAL_DIST\*" "${SERVER_USER}@${SERVER_HOST}:${REMOTE_PATH}/" + +Write-Color "✓ Arquivos enviados" "Green" +Write-Host "" + +# 4. Verificar deploy +Write-Color "[4/4] Verificando deploy..." "Yellow" +$remoteFiles = ssh "$SERVER_USER@$SERVER_HOST" "ls -la $REMOTE_PATH/" +Write-Host $remoteFiles +Write-Host "" + +# Verificar se index.html existe +$indexExists = ssh "$SERVER_USER@$SERVER_HOST" "test -f $REMOTE_PATH/index.html && echo 'OK'" + +if ($indexExists -eq "OK") { + Write-Color "========================================" "Green" + Write-Color " ✓ Deploy concluído com sucesso! " "Green" + Write-Color "========================================" "Green" + Write-Host "" + Write-Host "Acesse: " -NoNewline + Write-Color "https://webmoney.cnxifly.com" "Cyan" + Write-Host "" +} else { + Write-Color "========================================" "Red" + Write-Color " ✗ ERRO: index.html não encontrado " "Red" + Write-Color "========================================" "Red" + exit 1 +} diff --git a/frontend/deploy.sh b/frontend/deploy.sh new file mode 100644 index 0000000..79e2e1e --- /dev/null +++ b/frontend/deploy.sh @@ -0,0 +1,81 @@ +#!/bin/bash +# ============================================================================= +# WEBMoney Frontend - Script de Deploy +# ============================================================================= +# Este script faz build e deploy do frontend para o servidor de produção +# Uso: ./deploy.sh +# ============================================================================= + +set -e # Sair em caso de erro + +# Configurações - CAMINHO CORRETO! +SERVER_USER="root" +SERVER_HOST="213.165.93.60" +SERVER_PASS="Master9354" +REMOTE_PATH="/var/www/webmoney/frontend/dist" # <<< IMPORTANTE: Sempre /dist +LOCAL_DIST="./dist" + +# Cores para output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +echo -e "${BLUE}========================================${NC}" +echo -e "${BLUE} WEBMoney Frontend - Deploy Script ${NC}" +echo -e "${BLUE}========================================${NC}" +echo "" + +# 1. Build +echo -e "${YELLOW}[1/4]${NC} Fazendo build do frontend..." +rm -rf dist +npm run build + +if [ ! -d "$LOCAL_DIST" ]; then + echo -e "${RED}ERRO: Build falhou - pasta dist não encontrada${NC}" + exit 1 +fi + +echo -e "${GREEN}✓ Build concluído${NC}" +echo "" + +# 2. Limpar diretório remoto +echo -e "${YELLOW}[2/4]${NC} Limpando diretório remoto..." +sshpass -p "$SERVER_PASS" ssh -o StrictHostKeyChecking=no "$SERVER_USER@$SERVER_HOST" \ + "rm -rf $REMOTE_PATH/* && echo 'Diretório limpo'" + +echo -e "${GREEN}✓ Diretório remoto limpo${NC}" +echo "" + +# 3. Enviar arquivos para o caminho CORRETO +echo -e "${YELLOW}[3/4]${NC} Enviando arquivos para $REMOTE_PATH ..." +sshpass -p "$SERVER_PASS" scp -o StrictHostKeyChecking=no -r $LOCAL_DIST/* \ + "$SERVER_USER@$SERVER_HOST:$REMOTE_PATH/" + +echo -e "${GREEN}✓ Arquivos enviados${NC}" +echo "" + +# 4. Verificar deploy +echo -e "${YELLOW}[4/4]${NC} Verificando deploy..." +REMOTE_FILES=$(sshpass -p "$SERVER_PASS" ssh -o StrictHostKeyChecking=no "$SERVER_USER@$SERVER_HOST" \ + "ls -la $REMOTE_PATH/") + +echo "$REMOTE_FILES" +echo "" + +# Verificar se index.html existe +if sshpass -p "$SERVER_PASS" ssh -o StrictHostKeyChecking=no "$SERVER_USER@$SERVER_HOST" \ + "test -f $REMOTE_PATH/index.html"; then + echo -e "${GREEN}========================================${NC}" + echo -e "${GREEN} ✓ Deploy concluído com sucesso! ${NC}" + echo -e "${GREEN}========================================${NC}" + echo "" + echo -e "Acesse: ${BLUE}https://webmoney.cnxifly.com${NC}" + echo "" +else + echo -e "${RED}========================================${NC}" + echo -e "${RED} ✗ ERRO: index.html não encontrado ${NC}" + echo -e "${RED}========================================${NC}" + exit 1 +fi diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..4fa125d --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,29 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{js,jsx}'], + extends: [ + js.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + parserOptions: { + ecmaVersion: 'latest', + ecmaFeatures: { jsx: true }, + sourceType: 'module', + }, + }, + rules: { + 'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }], + }, + }, +]) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..dc8e806 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + WebMoney - Gestão Financeira + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..1dfa57b --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,3046 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "dependencies": { + "axios": "^1.13.2", + "bootstrap": "^5.3.8", + "bootstrap-icons": "^1.13.1", + "chart.js": "^4.5.1", + "i18next": "^25.7.1", + "i18next-browser-languagedetector": "^8.2.0", + "react": "^19.2.0", + "react-chartjs-2": "^5.3.1", + "react-dom": "^19.2.0", + "react-dropzone": "^14.3.8", + "react-i18next": "^16.4.0", + "react-router-dom": "^7.10.1" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@types/react": "^19.2.5", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "vite": "npm:rolldown-vite@7.2.5" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", + "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", + "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", + "dev": true, + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", + "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "dev": true, + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "dev": true, + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", + "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.5", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "dev": true, + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.1.tgz", + "integrity": "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==", + "dev": true, + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz", + "integrity": "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==", + "dev": true, + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", + "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", + "dev": true, + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", + "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "dev": true, + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", + "dev": true, + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.1.tgz", + "integrity": "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.0.tgz", + "integrity": "sha512-Fq6DJW+Bb5jaWE69/qOE0D1TUN9+6uWhCeZpdnSBk14pjLcCWR7Q8n49PTSPHazM37JqrsdpEthXy2xn6jWWiA==", + "dev": true, + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + } + }, + "node_modules/@oxc-project/runtime": { + "version": "0.97.0", + "resolved": "https://registry.npmjs.org/@oxc-project/runtime/-/runtime-0.97.0.tgz", + "integrity": "sha512-yH0zw7z+jEws4dZ4IUKoix5Lh3yhqIJWF9Dc8PWvhpo7U7O+lJrv7ZZL4BeRO0la8LBQFwcCewtLBnVV7hPe/w==", + "dev": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.97.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.97.0.tgz", + "integrity": "sha512-lxmZK4xFrdvU0yZiDwgVQTCvh2gHWBJCBk5ALsrtsBWhs0uDIi+FTOnXRQeQfs304imdvTdaakT/lqwQ8hkOXQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-beta.50", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-beta.50.tgz", + "integrity": "sha512-XlEkrOIHLyGT3avOgzfTFSjG+f+dZMw+/qd+Y3HLN86wlndrB/gSimrJCk4gOhr1XtRtEKfszpadI3Md4Z4/Ag==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-beta.50", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-beta.50.tgz", + "integrity": "sha512-+JRqKJhoFlt5r9q+DecAGPLZ5PxeLva+wCMtAuoFMWPoZzgcYrr599KQ+Ix0jwll4B4HGP43avu9My8KtSOR+w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-beta.50", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-beta.50.tgz", + "integrity": "sha512-fFXDjXnuX7/gQZQm/1FoivVtRcyAzdjSik7Eo+9iwPQ9EgtA5/nB2+jmbzaKtMGG3q+BnZbdKHCtOacmNrkIDA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-beta.50", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-beta.50.tgz", + "integrity": "sha512-F1b6vARy49tjmT/hbloplzgJS7GIvwWZqt+tAHEstCh0JIh9sa8FAMVqEmYxDviqKBaAI8iVvUREm/Kh/PD26Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-beta.50", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-beta.50.tgz", + "integrity": "sha512-U6cR76N8T8M6lHj7EZrQ3xunLPxSvYYxA8vJsBKZiFZkT8YV4kjgCO3KwMJL0NOjQCPGKyiXO07U+KmJzdPGRw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-beta.50", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-beta.50.tgz", + "integrity": "sha512-ONgyjofCrrE3bnh5GZb8EINSFyR/hmwTzZ7oVuyUB170lboza1VMCnb8jgE6MsyyRgHYmN8Lb59i3NKGrxrYjw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-beta.50", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-beta.50.tgz", + "integrity": "sha512-L0zRdH2oDPkmB+wvuTl+dJbXCsx62SkqcEqdM+79LOcB+PxbAxxjzHU14BuZIQdXcAVDzfpMfaHWzZuwhhBTcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-beta.50", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-beta.50.tgz", + "integrity": "sha512-gyoI8o/TGpQd3OzkJnh1M2kxy1Bisg8qJ5Gci0sXm9yLFzEXIFdtc4EAzepxGvrT2ri99ar5rdsmNG0zP0SbIg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-beta.50", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-beta.50.tgz", + "integrity": "sha512-zti8A7M+xFDpKlghpcCAzyOi+e5nfUl3QhU023ce5NCgUxRG5zGP2GR9LTydQ1rnIPwZUVBWd4o7NjZDaQxaXA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-beta.50", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-beta.50.tgz", + "integrity": "sha512-eZUssog7qljrrRU9Mi0eqYEPm3Ch0UwB+qlWPMKSUXHNqhm3TvDZarJQdTevGEfu3EHAXJvBIe0YFYr0TPVaMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-beta.50", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-beta.50.tgz", + "integrity": "sha512-nmCN0nIdeUnmgeDXiQ+2HU6FT162o+rxnF7WMkBm4M5Ds8qTU7Dzv2Wrf22bo4ftnlrb2hKK6FSwAJSAe2FWLg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^1.0.7" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-beta.50", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-beta.50.tgz", + "integrity": "sha512-7kcNLi7Ua59JTTLvbe1dYb028QEPaJPJQHqkmSZ5q3tJueUeb6yjRtx8mw4uIqgWZcnQHAR3PrLN4XRJxvgIkA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-ia32-msvc": { + "version": "1.0.0-beta.50", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.0.0-beta.50.tgz", + "integrity": "sha512-lL70VTNvSCdSZkDPPVMwWn/M2yQiYvSoXw9hTLgdIWdUfC3g72UaruezusR6ceRuwHCY1Ayu2LtKqXkBO5LIwg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-beta.50", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-beta.50.tgz", + "integrity": "sha512-4qU4x5DXWB4JPjyTne/wBNPqkbQU8J45bl21geERBKtEittleonioACBL1R0PsBu0Aq21SwMK5a9zdBkWSlQtQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.47", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.47.tgz", + "integrity": "sha512-8QagwMH3kNCuzD8EWL8R2YPW5e4OrHNSAHRFDdmFqEwEaD/KcNKjVoumo+gP2vW5eKB2UPbM6vTYiGZX0ixLnw==", + "dev": true + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true + }, + "node_modules/@types/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", + "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", + "dev": true, + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.1.tgz", + "integrity": "sha512-WQfkSw0QbQ5aJ2CHYw23ZGkqnRwqKHD/KYsMeTkZzPT4Jcf0DcBxBtwMJxnu6E7oxw5+JC6ZAiePgh28uJ1HBA==", + "dev": true, + "dependencies": { + "@babel/core": "^7.28.5", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.47", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "peer": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "node_modules/attr-accept": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz", + "integrity": "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==", + "engines": { + "node": ">=4" + } + }, + "node_modules/axios": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", + "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.4.tgz", + "integrity": "sha512-ZCQ9GEWl73BVm8bu5Fts8nt7MHdbt5vY9bP6WGnUh+r3l8M7CgfyTlwsgCbMC66BNxPr6Xoce3j66Ms5YUQTNA==", + "dev": true, + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/bootstrap": { + "version": "5.3.8", + "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz", + "integrity": "sha512-HP1SZDqaLDPwsNiqRqi5NcP0SSXciX2s9E+RyqJIIqGo+vJeN5AJVM98CXmW/Wux0nQ5L7jeWUdplCEf0Ee+tg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/twbs" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/bootstrap" + } + ], + "peerDependencies": { + "@popperjs/core": "^2.11.8" + } + }, + "node_modules/bootstrap-icons": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/bootstrap-icons/-/bootstrap-icons-1.13.1.tgz", + "integrity": "sha512-ijombt4v6bv5CLeXvRWKy7CuM3TRTuPEuGaGKvTV5cz65rQSY8RQ2JcHt6b90cBBAC7s8fsf2EkQDldzCoXUjw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/twbs" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/bootstrap" + } + ] + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001759", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001759.tgz", + "integrity": "sha512-Pzfx9fOKoKvevQf8oCXoyNRQ5QyxJj+3O0Rqx2V5oxT61KGx8+n6hV/IUyJeifUci2clnmmKVpvtiqRzgiWjSw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ] + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chart.js": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", + "peer": true, + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.266", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.266.tgz", + "integrity": "sha512-kgWEglXvkEfMH7rxP5OSZZwnaDWT7J9EoZCujhnpLbfi0bbNtRkgdX2E3gt0Uer11c61qCYktB3hwkAS325sJg==", + "dev": true + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz", + "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", + "dev": true, + "peer": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.1", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "dev": true, + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.24.tgz", + "integrity": "sha512-nLHIW7TEq3aLrEYWpVaJ1dRgFR+wLDPN8e8FpYAql/bMV2oBEfC37K0gLEGgv9fy66juNShSMV8OkTqzltcG/w==", + "dev": true, + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/file-selector": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz", + "integrity": "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==", + "dependencies": { + "tslib": "^2.7.0" + }, + "engines": { + "node": ">= 12" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "dependencies": { + "void-elements": "3.1.0" + } + }, + "node_modules/i18next": { + "version": "25.7.1", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.7.1.tgz", + "integrity": "sha512-XbTnkh1yCZWSAZGnA9xcQfHcYNgZs2cNxm+c6v1Ma9UAUGCeJPplRe1ILia6xnDvXBjk0uXU+Z8FYWhA19SKFw==", + "funding": [ + { + "type": "individual", + "url": "https://locize.com" + }, + { + "type": "individual", + "url": "https://locize.com/i18next.html" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + } + ], + "peer": true, + "dependencies": { + "@babel/runtime": "^7.28.4" + }, + "peerDependencies": { + "typescript": "^5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/i18next-browser-languagedetector": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.0.tgz", + "integrity": "sha512-P+3zEKLnOF0qmiesW383vsLdtQVyKtCNA9cjSoKCppTKPQVfKd2W8hbVo5ZhNJKDqeM7BOcvNoKJOjpHh4Js9g==", + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", + "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", + "dev": true, + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.30.2", + "lightningcss-darwin-arm64": "1.30.2", + "lightningcss-darwin-x64": "1.30.2", + "lightningcss-freebsd-x64": "1.30.2", + "lightningcss-linux-arm-gnueabihf": "1.30.2", + "lightningcss-linux-arm64-gnu": "1.30.2", + "lightningcss-linux-arm64-musl": "1.30.2", + "lightningcss-linux-x64-gnu": "1.30.2", + "lightningcss-linux-x64-musl": "1.30.2", + "lightningcss-win32-arm64-msvc": "1.30.2", + "lightningcss-win32-x64-msvc": "1.30.2" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", + "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", + "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", + "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", + "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", + "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", + "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", + "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", + "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", + "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", + "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", + "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.1", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz", + "integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-chartjs-2": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.3.1.tgz", + "integrity": "sha512-h5IPXKg9EXpjoBzUfyWJvllMjG2mQ4EiuHQFhms/AjUm0XSZHhyRy2xVmLXHKrtcdrPO4mnGqRtYoD0vp95A0A==", + "peerDependencies": { + "chart.js": "^4.1.1", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.1.tgz", + "integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==", + "peer": true, + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.1" + } + }, + "node_modules/react-dropzone": { + "version": "14.3.8", + "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.3.8.tgz", + "integrity": "sha512-sBgODnq+lcA4P296DY4wacOZz3JFpD99fp+hb//iBO2HHnyeZU3FwWyXJ6salNpqQdsZrgMrotuko/BdJMV8Ug==", + "dependencies": { + "attr-accept": "^2.2.4", + "file-selector": "^2.1.0", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">= 10.13" + }, + "peerDependencies": { + "react": ">= 16.8 || 18.0.0" + } + }, + "node_modules/react-i18next": { + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.4.0.tgz", + "integrity": "sha512-bxVeBA8Ky2UeItNhF4JRxHCFIrpEJHGFG/mOAa4CR0JkqaDEYSLmlEgmC4Os63SBlZ+E5U0YyrNJOSVl2mtVqQ==", + "dependencies": { + "@babel/runtime": "^7.27.6", + "html-parse-stringify": "^3.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 25.6.2", + "react": ">= 16.8.0", + "typescript": "^5" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.10.1.tgz", + "integrity": "sha512-gHL89dRa3kwlUYtRQ+m8NmxGI6CgqN+k4XyGjwcFoQwwCWF6xXpOCUlDovkXClS0d0XJN/5q7kc5W3kiFEd0Yw==", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.10.1.tgz", + "integrity": "sha512-JNBANI6ChGVjA5bwsUIwJk7LHKmqB4JYnYfzFwyp2t12Izva11elds2jx7Yfoup2zssedntwU0oZ5DEmk5Sdaw==", + "dependencies": { + "react-router": "7.10.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/rolldown": { + "version": "1.0.0-beta.50", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-beta.50.tgz", + "integrity": "sha512-JFULvCNl/anKn99eKjOSEubi0lLmNqQDAjyEMME2T4CwezUDL0i6t1O9xZsu2OMehPnV2caNefWpGF+8TnzB6A==", + "dev": true, + "dependencies": { + "@oxc-project/types": "=0.97.0", + "@rolldown/pluginutils": "1.0.0-beta.50" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-beta.50", + "@rolldown/binding-darwin-arm64": "1.0.0-beta.50", + "@rolldown/binding-darwin-x64": "1.0.0-beta.50", + "@rolldown/binding-freebsd-x64": "1.0.0-beta.50", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-beta.50", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-beta.50", + "@rolldown/binding-linux-arm64-musl": "1.0.0-beta.50", + "@rolldown/binding-linux-x64-gnu": "1.0.0-beta.50", + "@rolldown/binding-linux-x64-musl": "1.0.0-beta.50", + "@rolldown/binding-openharmony-arm64": "1.0.0-beta.50", + "@rolldown/binding-wasm32-wasi": "1.0.0-beta.50", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-beta.50", + "@rolldown/binding-win32-ia32-msvc": "1.0.0-beta.50", + "@rolldown/binding-win32-x64-msvc": "1.0.0-beta.50" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.50", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.50.tgz", + "integrity": "sha512-5e76wQiQVeL1ICOZVUg4LSOVYg9jyhGCin+icYozhsUzM+fHE7kddi1bdiE0jwVqTfkjba3jUFbEkoC9WkdvyA==", + "dev": true + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.2.tgz", + "integrity": "sha512-E85pfNzMQ9jpKkA7+TJAi4TJN+tBCuWh5rUcS/sv6cFi+1q9LYDwDI5dpUL0u/73EElyQ8d3TEaeW4sPedBqYA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/vite": { + "name": "rolldown-vite", + "version": "7.2.5", + "resolved": "https://registry.npmjs.org/rolldown-vite/-/rolldown-vite-7.2.5.tgz", + "integrity": "sha512-u09tdk/huMiN8xwoiBbig197jKdCamQTtOruSalOzbqGje3jdHiV0njQlAW0YvzoahkirFePNQ4RYlfnRQpXZA==", + "dev": true, + "peer": true, + "dependencies": { + "@oxc-project/runtime": "0.97.0", + "fdir": "^6.5.0", + "lightningcss": "^1.30.2", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rolldown": "1.0.0-beta.50", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "esbuild": "^0.25.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.13.tgz", + "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==", + "dev": true, + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..e3a6933 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,40 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "axios": "^1.13.2", + "bootstrap": "^5.3.8", + "bootstrap-icons": "^1.13.1", + "chart.js": "^4.5.1", + "i18next": "^25.7.1", + "i18next-browser-languagedetector": "^8.2.0", + "react": "^19.2.0", + "react-chartjs-2": "^5.3.1", + "react-dom": "^19.2.0", + "react-dropzone": "^14.3.8", + "react-i18next": "^16.4.0", + "react-router-dom": "^7.10.1" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@types/react": "^19.2.5", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "vite": "npm:rolldown-vite@7.2.5" + }, + "overrides": { + "vite": "npm:rolldown-vite@7.2.5" + } +} diff --git a/frontend/public/apple-touch-icon.png b/frontend/public/apple-touch-icon.png new file mode 100644 index 0000000..8ad4c51 Binary files /dev/null and b/frontend/public/apple-touch-icon.png differ diff --git a/frontend/public/favicon-16x16.png b/frontend/public/favicon-16x16.png new file mode 100644 index 0000000..7253e86 Binary files /dev/null and b/frontend/public/favicon-16x16.png differ diff --git a/frontend/public/favicon-32x32.png b/frontend/public/favicon-32x32.png new file mode 100644 index 0000000..3f374bf Binary files /dev/null and b/frontend/public/favicon-32x32.png differ diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico new file mode 100644 index 0000000..6adcd2c Binary files /dev/null and b/frontend/public/favicon.ico differ diff --git a/frontend/public/logo-192.png b/frontend/public/logo-192.png new file mode 100644 index 0000000..ae2c15c Binary files /dev/null and b/frontend/public/logo-192.png differ diff --git a/frontend/public/logo-512.png b/frontend/public/logo-512.png new file mode 100644 index 0000000..1e13ad8 Binary files /dev/null and b/frontend/public/logo-512.png differ diff --git a/frontend/public/logo.png b/frontend/public/logo.png new file mode 100644 index 0000000..1e5071f Binary files /dev/null and b/frontend/public/logo.png differ diff --git a/frontend/public/manifest.json b/frontend/public/manifest.json new file mode 100644 index 0000000..40a94f9 --- /dev/null +++ b/frontend/public/manifest.json @@ -0,0 +1,34 @@ +{ + "name": "WebMoney - Gestão Financeira", + "short_name": "WebMoney", + "description": "Sistema de Gestão Financeira Pessoal", + "start_url": "/", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#1a365d", + "orientation": "portrait-primary", + "icons": [ + { + "src": "/favicon-16x16.png", + "sizes": "16x16", + "type": "image/png" + }, + { + "src": "/favicon-32x32.png", + "sizes": "32x32", + "type": "image/png" + }, + { + "src": "/logo-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/logo-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ] +} diff --git a/frontend/src/App.css b/frontend/src/App.css new file mode 100644 index 0000000..0bde6e1 --- /dev/null +++ b/frontend/src/App.css @@ -0,0 +1 @@ +/* App-level styles are handled in index.css to enforce the dark blue theme. */ diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..7e29fc4 --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,136 @@ +import React from 'react'; +import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'; +import { AuthProvider } from './context/AuthContext'; +import { ToastProvider } from './components/Toast'; +import ProtectedRoute from './components/ProtectedRoute'; +import Layout from './components/Layout'; +import CookieConsent from './components/CookieConsent'; +import Login from './pages/Login'; +import Dashboard from './pages/Dashboard'; +import Accounts from './pages/Accounts'; +import CostCenters from './pages/CostCenters'; +import Categories from './pages/Categories'; +import LiabilityAccounts from './pages/LiabilityAccounts'; +import TransactionsByWeek from './pages/TransactionsByWeek'; +import ImportTransactions from './pages/ImportTransactions'; +import TransferDetection from './pages/TransferDetection'; +import RefundDetection from './pages/RefundDetection'; +import RecurringTransactions from './pages/RecurringTransactions'; + +function App() { + return ( + + + + + } /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + } /> + + + + + + ); +} + +export default App; diff --git a/frontend/src/assets/logo-white.png b/frontend/src/assets/logo-white.png new file mode 100644 index 0000000..94f0a54 Binary files /dev/null and b/frontend/src/assets/logo-white.png differ diff --git a/frontend/src/assets/logo.png b/frontend/src/assets/logo.png new file mode 100644 index 0000000..1e5071f Binary files /dev/null and b/frontend/src/assets/logo.png differ diff --git a/frontend/src/components/CookieConsent.jsx b/frontend/src/components/CookieConsent.jsx new file mode 100644 index 0000000..3fb6b28 --- /dev/null +++ b/frontend/src/components/CookieConsent.jsx @@ -0,0 +1,239 @@ +import React, { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; + +const COOKIE_CONSENT_KEY = 'webmoney_cookie_consent'; + +const CookieConsent = () => { + const { t } = useTranslation(); + const [showBanner, setShowBanner] = useState(false); + const [showSettings, setShowSettings] = useState(false); + const [preferences, setPreferences] = useState({ + essential: true, + analytics: false, + marketing: false, + }); + + useEffect(() => { + const consent = localStorage.getItem(COOKIE_CONSENT_KEY); + + if (!consent) { + const timer = setTimeout(() => { + setShowBanner(true); + }, 1000); + return () => clearTimeout(timer); + } else { + try { + const savedPreferences = JSON.parse(consent); + setPreferences(savedPreferences); + } catch (e) { + setShowBanner(true); + } + } + }, []); + + const saveConsent = (prefs) => { + localStorage.setItem(COOKIE_CONSENT_KEY, JSON.stringify(prefs)); + setPreferences(prefs); + setShowBanner(false); + setShowSettings(false); + window.dispatchEvent(new CustomEvent('cookieConsentUpdated', { detail: prefs })); + }; + + const acceptAll = () => { + saveConsent({ essential: true, analytics: true, marketing: true }); + }; + + const acceptEssential = () => { + saveConsent({ essential: true, analytics: false, marketing: false }); + }; + + const saveSettingsHandler = () => { + saveConsent(preferences); + }; + + if (!showBanner) return null; + + const overlayStyle = { + position: 'fixed', + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: 'rgba(0, 0, 0, 0.5)', + zIndex: 1040, + }; + + const bannerStyle = { + position: 'fixed', + bottom: 0, + left: 0, + right: 0, + zIndex: 1050, + backgroundColor: '#1a1d21', + borderTop: '1px solid #343a40', + boxShadow: '0 -4px 20px rgba(0, 0, 0, 0.3)', + }; + + return ( + <> +
+ +
+
+ {!showSettings ? ( +
+
+
+ +
{t('cookies.title')}
+
+

+ {t('cookies.description')} +

+
+
+
+ + + +
+
+
+ ) : ( +
+
+
+ + {t('cookies.settingsTitle')} +
+ +
+ +

+ {t('cookies.settingsDescription')} +

+ +
+
+
+
{t('cookies.essential.title')}
+ {t('cookies.essential.description')} +
+ {t('cookies.alwaysActive')} +
+
+ +
+
+
+
{t('cookies.analytics.title')}
+ {t('cookies.analytics.description')} +
+
+ setPreferences({ ...preferences, analytics: e.target.checked })} + style={{ cursor: 'pointer', width: '3rem', height: '1.5rem' }} + /> +
+
+
+ +
+
+
+
{t('cookies.marketing.title')}
+ {t('cookies.marketing.description')} +
+
+ setPreferences({ ...preferences, marketing: e.target.checked })} + style={{ cursor: 'pointer', width: '3rem', height: '1.5rem' }} + /> +
+
+
+ +
+ + +
+
+ )} +
+
+ + ); +}; + +export const useCookieConsent = () => { + const [preferences, setPreferences] = useState({ + essential: true, + analytics: false, + marketing: false, + }); + + useEffect(() => { + const loadPreferences = () => { + const consent = localStorage.getItem(COOKIE_CONSENT_KEY); + if (consent) { + try { + setPreferences(JSON.parse(consent)); + } catch (e) {} + } + }; + + loadPreferences(); + + const handleUpdate = (event) => { + setPreferences(event.detail); + }; + + window.addEventListener('cookieConsentUpdated', handleUpdate); + return () => window.removeEventListener('cookieConsentUpdated', handleUpdate); + }, []); + + return preferences; +}; + +export default CookieConsent; diff --git a/frontend/src/components/CreateRecurrenceModal.jsx b/frontend/src/components/CreateRecurrenceModal.jsx new file mode 100644 index 0000000..b22ea64 --- /dev/null +++ b/frontend/src/components/CreateRecurrenceModal.jsx @@ -0,0 +1,290 @@ +import React, { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import Modal from './Modal'; +import { recurringService } from '../services/api'; + +/** + * Modal para criar recorrência a partir de uma transação existente + */ +const CreateRecurrenceModal = ({ + show, + onClose, + transaction, + onSuccess +}) => { + const { t } = useTranslation(); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [frequencies, setFrequencies] = useState({}); + + const [formData, setFormData] = useState({ + frequency: 'monthly', + name: '', + description: '', + frequency_interval: 1, + day_of_month: null, + start_date: '', + end_date: '', + max_occurrences: '', + }); + + // Carregar frequências disponíveis + useEffect(() => { + const loadFrequencies = async () => { + try { + const data = await recurringService.getFrequencies(); + setFrequencies(data); + } catch (err) { + console.error('Erro ao carregar frequências:', err); + } + }; + loadFrequencies(); + }, []); + + // Reset form quando transação muda + useEffect(() => { + if (transaction) { + const date = new Date(transaction.planned_date); + setFormData({ + frequency: 'monthly', + name: transaction.description || '', + description: '', + frequency_interval: 1, + day_of_month: date.getDate(), + start_date: transaction.planned_date?.split('T')[0] || '', + end_date: '', + max_occurrences: '', + }); + } + }, [transaction]); + + const handleChange = (e) => { + const { name, value, type } = e.target; + setFormData(prev => ({ + ...prev, + [name]: type === 'number' ? (value ? parseInt(value) : '') : value, + })); + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + setLoading(true); + setError(null); + + try { + const payload = { + transaction_id: transaction.id, + frequency: formData.frequency, + name: formData.name || undefined, + description: formData.description || undefined, + frequency_interval: formData.frequency_interval || 1, + day_of_month: formData.day_of_month || undefined, + start_date: formData.start_date || undefined, + end_date: formData.end_date || undefined, + max_occurrences: formData.max_occurrences || undefined, + }; + + const result = await recurringService.createFromTransaction(payload); + + if (onSuccess) { + onSuccess(result); + } + onClose(); + } catch (err) { + setError(err.response?.data?.message || t('common.error')); + } finally { + setLoading(false); + } + }; + + if (!transaction) return null; + + const showDayOfMonth = ['monthly', 'bimonthly', 'quarterly', 'semiannual', 'annual'].includes(formData.frequency); + + return ( + +
+
+ {error && ( +
+ {error} +
+ )} + + {/* Info da transação */} +
+
{t('transactions.transaction')}
+
{transaction.description}
+
+ {new Date(transaction.planned_date).toLocaleDateString()} - + {' '}{new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'EUR' }).format(transaction.planned_amount)} +
+
+ +
+ {/* Nome */} +
+ + +
+ + {/* Descrição */} +
+ + +
+ + {/* Checkboxes */} +
+
+ + +
+
+
+
+ + +
+
+
+
+
+ + +
+
+
+ + + )} + + {/* Modal de Ajuste de Saldo */} + {showAdjustModal && adjustAccount && ( +
+
+
+
+
+ + {t('accounts.adjustBalance')} +
+ +
+
+
+ + {t('accounts.adjustInfo')} +
+
+ + +
+
+ + +
+
+ + setTargetBalance(e.target.value)} + placeholder={t('accounts.targetBalancePlaceholder')} + /> + + {t('accounts.targetBalanceHelp')} + +
+
+
+ + +
+
+
+
+ )} + + {/* Modal de Confirmação de Exclusão */} + setShowDeleteModal(false)} + onConfirm={handleDeleteConfirm} + title={t('accounts.deleteAccount')} + message={t('accounts.deleteConfirm')} + confirmText={t('common.delete')} + loading={saving} + /> + + ); +}; + +export default Accounts; diff --git a/frontend/src/pages/Categories.jsx b/frontend/src/pages/Categories.jsx new file mode 100644 index 0000000..4d48367 --- /dev/null +++ b/frontend/src/pages/Categories.jsx @@ -0,0 +1,815 @@ +import React, { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { categoryService } from '../services/api'; +import { useToast } from '../components/Toast'; +import { ConfirmModal } from '../components/Modal'; +import IconSelector from '../components/IconSelector'; + +const Categories = () => { + const { t } = useTranslation(); + const toast = useToast(); + const [categories, setCategories] = useState([]); + const [flatCategories, setFlatCategories] = useState([]); + const [loading, setLoading] = useState(true); + const [showModal, setShowModal] = useState(false); + const [showDeleteModal, setShowDeleteModal] = useState(false); + const [selectedItem, setSelectedItem] = useState(null); + const [saving, setSaving] = useState(false); + const [newKeyword, setNewKeyword] = useState(''); + const [filter, setFilter] = useState({ type: '' }); + const [expandedCategories, setExpandedCategories] = useState({}); + + // Estados para categorização em lote + const [showBatchModal, setShowBatchModal] = useState(false); + const [batchPreview, setBatchPreview] = useState(null); + const [loadingBatch, setLoadingBatch] = useState(false); + const [executingBatch, setExecutingBatch] = useState(false); + + const [formData, setFormData] = useState({ + name: '', + parent_id: '', + type: 'expense', + description: '', + color: '#3B82F6', + icon: 'bi-tag', + is_active: true, + keywords: [], + }); + + const categoryTypes = categoryService.types; + + useEffect(() => { + loadCategories(); + }, [filter]); + + const loadCategories = async () => { + try { + setLoading(true); + const params = {}; + if (filter.type) params.type = filter.type; + + // Carregar hierárquicas + const response = await categoryService.getAll(params); + if (response.success) { + setCategories(response.data); + } + + // Carregar flat para o select de parent + const flatResponse = await categoryService.getAll({ flat: true }); + if (flatResponse.success) { + setFlatCategories(flatResponse.data.filter(c => !c.parent_id)); // Apenas categorias raiz + } + } catch (error) { + toast.error(t('categories.loadError')); + } finally { + setLoading(false); + } + }; + + const toggleExpand = (categoryId) => { + setExpandedCategories(prev => ({ + ...prev, + [categoryId]: !prev[categoryId], + })); + }; + + const handleOpenModal = (item = null, parentId = null) => { + if (item) { + setSelectedItem(item); + setFormData({ + name: item.name || '', + parent_id: item.parent_id?.toString() || '', + type: item.type || 'expense', + description: item.description || '', + color: item.color || '#3B82F6', + icon: item.icon || 'bi-tag', + is_active: item.is_active ?? true, + keywords: item.keywords?.map(k => k.keyword) || [], + }); + } else { + setSelectedItem(null); + setFormData({ + name: '', + parent_id: parentId?.toString() || '', + type: 'expense', + description: '', + color: '#3B82F6', + icon: 'bi-tag', + is_active: true, + keywords: [], + }); + } + setNewKeyword(''); + setShowModal(true); + }; + + const handleCloseModal = () => { + setShowModal(false); + setSelectedItem(null); + setNewKeyword(''); + }; + + // Funções de categorização em lote + const handleOpenBatchModal = async () => { + setShowBatchModal(true); + setLoadingBatch(true); + try { + const response = await categoryService.categorizeBatchPreview(true, 50); + if (response.success) { + setBatchPreview(response.data); + } + } catch (error) { + toast.error(t('categories.batchPreviewError') || 'Erro ao carregar preview'); + } finally { + setLoadingBatch(false); + } + }; + + const handleCloseBatchModal = () => { + setShowBatchModal(false); + setBatchPreview(null); + }; + + const handleExecuteBatch = async () => { + setExecutingBatch(true); + try { + const response = await categoryService.categorizeBatch(true); + if (response.success) { + toast.success( + `${t('categories.batchSuccess') || 'Categorização concluída'}: ${response.data.categorized} ${t('categories.categorized') || 'categorizadas'}` + ); + handleCloseBatchModal(); + } + } catch (error) { + toast.error(t('categories.batchError') || 'Erro ao categorizar'); + } finally { + setExecutingBatch(false); + } + }; + + const handleChange = (e) => { + const { name, value, type, checked } = e.target; + setFormData(prev => ({ + ...prev, + [name]: type === 'checkbox' ? checked : value, + })); + }; + + const handleAddKeyword = () => { + const keyword = newKeyword.trim(); + if (keyword && !formData.keywords.includes(keyword)) { + setFormData(prev => ({ + ...prev, + keywords: [...prev.keywords, keyword], + })); + setNewKeyword(''); + } + }; + + const handleRemoveKeyword = (keyword) => { + setFormData(prev => ({ + ...prev, + keywords: prev.keywords.filter(k => k !== keyword), + })); + }; + + const handleKeywordKeyPress = (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleAddKeyword(); + } + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + + if (!formData.name.trim()) { + toast.error(t('validation.required')); + return; + } + + setSaving(true); + try { + const data = { + ...formData, + parent_id: formData.parent_id ? parseInt(formData.parent_id) : null, + }; + + let response; + if (selectedItem) { + response = await categoryService.update(selectedItem.id, data); + } else { + response = await categoryService.create(data); + } + + if (response.success) { + toast.success(selectedItem ? t('categories.updateSuccess') : t('categories.createSuccess')); + handleCloseModal(); + loadCategories(); + } + } catch (error) { + toast.error(error.response?.data?.message || t('categories.createError')); + } finally { + setSaving(false); + } + }; + + const handleDeleteClick = (item) => { + setSelectedItem(item); + setShowDeleteModal(true); + }; + + const handleDeleteConfirm = async () => { + if (!selectedItem) return; + + setSaving(true); + try { + const response = await categoryService.delete(selectedItem.id); + if (response.success) { + toast.success(t('categories.deleteSuccess')); + setShowDeleteModal(false); + setSelectedItem(null); + loadCategories(); + } + } catch (error) { + toast.error(error.response?.data?.message || t('categories.deleteError')); + } finally { + setSaving(false); + } + }; + + const getTypeColor = (type) => { + switch (type) { + case 'income': return 'success'; + case 'expense': return 'danger'; + case 'both': return 'info'; + default: return 'secondary'; + } + }; + + const renderCategory = (category, level = 0, parentColor = null) => { + const hasChildren = category.children && category.children.length > 0; + const isExpanded = expandedCategories[category.id]; + // Subcategorias herdam a cor da categoria pai + const displayColor = level > 0 && parentColor ? parentColor : category.color; + + return ( +
+
0 ? 'ms-4' : ''}`} + style={{ + borderBottom: '1px solid #334155', + backgroundColor: level > 0 ? '#1a2332' : 'transparent', + }} + > + {/* Expand Button */} +
+ {hasChildren && ( + + )} +
+ + {/* Icon & Name */} +
+
+ +
+
+
{category.name}
+ {category.keywords && category.keywords.length > 0 && ( + + {category.keywords.length} {t('categories.keywords')} + + )} +
+
+ + {/* Type Badge */} +
+ + {categoryTypes[category.type]} + +
+ + {/* Status */} +
+ {category.is_active ? ( + {t('common.active')} + ) : ( + {t('common.inactive')} + )} +
+ + {/* Actions */} +
+ + + {!category.is_system && ( + + )} +
+
+ + {/* Children */} + {hasChildren && isExpanded && ( +
+ {category.children.map(child => renderCategory(child, level + 1, category.color))} +
+ )} +
+ ); + }; + + return ( +
+ {/* Header */} +
+
+

+ + {t('nav.categories')} +

+

+ {t('categories.title')} +

+
+
+ + +
+
+ + {/* Summary Cards */} +
+
+
+
+
+
+ +
+
+

{t('categories.types.expense')}

+

+ {flatCategories.filter(c => c.type === 'expense' || c.type === 'both').length} +

+
+
+
+
+
+
+
+
+
+
+ +
+
+

{t('categories.types.income')}

+

+ {flatCategories.filter(c => c.type === 'income' || c.type === 'both').length} +

+
+
+
+
+
+
+
+
+
+
+ +
+
+

{t('common.total')}

+

{flatCategories.length}

+
+
+
+
+
+
+ + {/* Filters */} +
+
+
+
+ + +
+
+
+
+ + {/* Categories List */} +
+ {loading ? ( +
+
+ {t('common.loading')} +
+
+ ) : categories.length === 0 ? ( +
+ +

{t('categories.noCategories')}

+ +
+ ) : ( +
+ {categories.map(category => renderCategory(category))} +
+ )} +
+ + {/* Modal de Criar/Editar */} + {showModal && ( +
+
+
+
+
+ + {selectedItem ? t('categories.editCategory') : formData.parent_id ? t('categories.createSubcategory') : t('categories.newCategory')} +
+ +
+
+
+
+ {/* Nome */} +
+ + +
+ + {/* Tipo */} +
+ + +
+ + {/* Categoria Pai */} +
+ + +
+ + {/* Cor */} +
+ + +
+ + {/* Ícone */} +
+ + setFormData(prev => ({ ...prev, icon }))} + type="category" + /> +
+ + {/* Status */} +
+ +
+ + +
+
+ + {/* Descrição */} +
+ + +
+ + {/* Palavras-chave */} +
+ +
+ setNewKeyword(e.target.value)} + onKeyPress={handleKeywordKeyPress} + placeholder="Digite uma palavra-chave e pressione Enter..." + /> + +
+
+ {formData.keywords.map((keyword, index) => ( + + {keyword} + + + ))} + {formData.keywords.length === 0 && ( + + {t('common.noData')} + + )} +
+ + Ex: "RESTAURANTE", "PIZZA", "HAMBURGUER" - Para a categoria Alimentação + +
+
+
+
+ + +
+
+
+
+
+ )} + + {/* Modal de Confirmação de Exclusão */} + setShowDeleteModal(false)} + onConfirm={handleDeleteConfirm} + title={t('categories.deleteCategory')} + message={t('categories.deleteConfirm')} + confirmText={t('common.delete')} + loading={saving} + /> + + {/* Modal de Categorização em Lote */} + {showBatchModal && ( +
+
+
+
+
+ + {t('categories.batchCategorize') || 'Categorização em Lote'} +
+ +
+
+ {loadingBatch ? ( +
+
+ Loading... +
+

{t('common.loading') || 'Carregando...'}

+
+ ) : batchPreview ? ( + <> + {/* Resumo */} +
+
+
+
+

{batchPreview.total_uncategorized}

+ {t('categories.uncategorized') || 'Sem categoria'} +
+
+
+
+
+
+

{batchPreview.would_categorize}

+ {t('categories.willCategorize') || 'Serão categorizadas'} +
+
+
+
+
+
+

{batchPreview.would_skip}

+ {t('categories.willSkip') || 'Sem correspondência'} +
+
+
+
+
+
+

{batchPreview.total_keywords}

+ {t('categories.totalKeywords') || 'Palavras-chave'} +
+
+
+
+ + {/* Preview */} + {batchPreview.preview.length > 0 ? ( + <> +
+ + {t('categories.previewTitle') || 'Preview das categorizações'} +
+
+ + + + + + + + + + {batchPreview.preview.map((item, index) => ( + + + + + + ))} + +
{t('transactions.description') || 'Descrição'}{t('categories.matchedKeyword') || 'Keyword'}{t('categories.category') || 'Categoria'}
+ {item.description} + + {item.matched_keyword} + {item.category_name}
+
+ + ) : ( +
+ + {t('categories.noMatchesFound') || 'Nenhuma transação corresponde às palavras-chave configuradas'} +
+ )} + + ) : ( +
+ {t('categories.previewError') || 'Erro ao carregar preview'} +
+ )} +
+
+ + +
+
+
+
+ )} +
+ ); +}; + +export default Categories; diff --git a/frontend/src/pages/CostCenters.jsx b/frontend/src/pages/CostCenters.jsx new file mode 100644 index 0000000..a49ba3a --- /dev/null +++ b/frontend/src/pages/CostCenters.jsx @@ -0,0 +1,549 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { costCenterService } from '../services/api'; +import { useToast } from '../components/Toast'; +import { ConfirmModal } from '../components/Modal'; +import IconSelector from '../components/IconSelector'; + +const CostCenters = () => { + const { t } = useTranslation(); + const toast = useToast(); + const [costCenters, setCostCenters] = useState([]); + const [loading, setLoading] = useState(true); + const [showModal, setShowModal] = useState(false); + const [showDeleteModal, setShowDeleteModal] = useState(false); + const [selectedItem, setSelectedItem] = useState(null); + const [saving, setSaving] = useState(false); + const [newKeyword, setNewKeyword] = useState(''); + const [openDropdownId, setOpenDropdownId] = useState(null); + const dropdownRef = useRef(null); + + // Close dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (event) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target)) { + setOpenDropdownId(null); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + const [formData, setFormData] = useState({ + name: '', + code: '', + description: '', + color: '#10B981', + icon: 'bi-building', + is_active: true, + keywords: [], + }); + + useEffect(() => { + loadCostCenters(); + }, []); + + const loadCostCenters = async () => { + try { + setLoading(true); + const response = await costCenterService.getAll(); + if (response.success) { + setCostCenters(response.data); + } + } catch (error) { + toast.error(t('costCenters.loadError')); + } finally { + setLoading(false); + } + }; + + const handleOpenModal = (item = null) => { + if (item) { + setSelectedItem(item); + setFormData({ + name: item.name || '', + code: item.code || '', + description: item.description || '', + color: item.color || '#10B981', + icon: item.icon || 'bi-building', + is_active: item.is_active ?? true, + keywords: item.keywords?.map(k => k.keyword) || [], + }); + } else { + setSelectedItem(null); + setFormData({ + name: '', + code: '', + description: '', + color: '#10B981', + icon: 'bi-building', + is_active: true, + keywords: [], + }); + } + setNewKeyword(''); + setShowModal(true); + }; + + const handleCloseModal = () => { + setShowModal(false); + setSelectedItem(null); + setNewKeyword(''); + }; + + const handleChange = (e) => { + const { name, value, type, checked } = e.target; + setFormData(prev => ({ + ...prev, + [name]: type === 'checkbox' ? checked : value, + })); + }; + + const handleAddKeyword = () => { + const keyword = newKeyword.trim(); + if (keyword && !formData.keywords.includes(keyword)) { + setFormData(prev => ({ + ...prev, + keywords: [...prev.keywords, keyword], + })); + setNewKeyword(''); + } + }; + + const handleRemoveKeyword = (keyword) => { + setFormData(prev => ({ + ...prev, + keywords: prev.keywords.filter(k => k !== keyword), + })); + }; + + const handleKeywordKeyPress = (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleAddKeyword(); + } + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + + if (!formData.name.trim()) { + toast.error(t('validation.required')); + return; + } + + setSaving(true); + try { + let response; + if (selectedItem) { + response = await costCenterService.update(selectedItem.id, formData); + } else { + response = await costCenterService.create(formData); + } + + if (response.success) { + toast.success(selectedItem ? t('costCenters.updateSuccess') : t('costCenters.createSuccess')); + handleCloseModal(); + loadCostCenters(); + } + } catch (error) { + toast.error(error.response?.data?.message || t('costCenters.createError')); + } finally { + setSaving(false); + } + }; + + const handleDeleteClick = (item) => { + setSelectedItem(item); + setShowDeleteModal(true); + }; + + const handleDeleteConfirm = async () => { + if (!selectedItem) return; + + setSaving(true); + try { + const response = await costCenterService.delete(selectedItem.id); + if (response.success) { + toast.success(t('costCenters.deleteSuccess')); + setShowDeleteModal(false); + setSelectedItem(null); + loadCostCenters(); + } + } catch (error) { + toast.error(error.response?.data?.message || t('costCenters.deleteError')); + } finally { + setSaving(false); + } + }; + + return ( +
+ {/* Header */} +
+
+

+ + {t('nav.costCenters')} +

+

+ {t('costCenters.title')} +

+
+ +
+ + {/* Info Card */} +
+
+
+
+ +
+
+
{t('costCenters.keywordHelp')}
+

+ {t('common.info')} +

+
+
+
+
+ + {/* Cost Centers Grid */} + {loading ? ( +
+
+ {t('common.loading')} +
+
+ ) : costCenters.length === 0 ? ( +
+
+ +

{t('costCenters.noCostCenters')}

+ +
+
+ ) : ( +
+ {costCenters.map((item) => ( +
+
+
+
+
+
+ +
+
+
+ {item.name} + {item.is_system && ( + + )} +
+ {item.code && ( + {t('costCenters.code')}: {item.code} + )} +
+
+ {!item.is_system && ( +
+ +
    +
  • + +
  • +
  • + +
  • +
+
+ )} +
+ + {item.description && ( +

{item.description}

+ )} + + {/* Keywords */} +
+ + + {t('costCenters.keywords')} ({item.keywords?.length || 0}) + +
+ {item.keywords?.slice(0, 5).map((kw) => ( + + {kw.keyword} + + ))} + {item.keywords?.length > 5 && ( + + +{item.keywords.length - 5} + + )} + {(!item.keywords || item.keywords.length === 0) && ( + {t('common.noData')} + )} +
+
+ + {/* Status */} +
+ {item.is_active ? ( + {t('common.active')} + ) : ( + {t('common.inactive')} + )} +
+
+
+
+ ))} +
+ )} + + {/* Modal de Criar/Editar */} + {showModal && ( +
+
+
+
+
+ + {selectedItem ? t('costCenters.editCostCenter') : t('costCenters.newCostCenter')} +
+ +
+
+
+
+ {/* Nome */} +
+ + +
+ + {/* Código */} +
+ + +
+ + {/* Cor */} +
+ + +
+ + {/* Ícone */} +
+ + setFormData(prev => ({ ...prev, icon }))} + type="costCenter" + /> +
+ + {/* Status */} +
+ +
+ + +
+
+ + {/* Descrição */} +
+ + +
+ + {/* Palavras-chave */} +
+ +
+ setNewKeyword(e.target.value)} + onKeyPress={handleKeywordKeyPress} + placeholder="Digite uma palavra-chave e pressione Enter..." + /> + +
+
+ {formData.keywords.map((keyword, index) => ( + + {keyword} + + + ))} + {formData.keywords.length === 0 && ( + + {t('common.noData')} + + )} +
+ + Ex: "UBER", "iFood", "Supermercado XYZ" - Quando estas palavras aparecerem na + descrição de uma transação, este centro de custo será sugerido automaticamente. + +
+
+
+
+ + +
+
+
+
+
+ )} + + {/* Modal de Confirmação de Exclusão */} + setShowDeleteModal(false)} + onConfirm={handleDeleteConfirm} + title={t('costCenters.deleteCostCenter')} + message={t('costCenters.deleteConfirm')} + confirmText={t('common.delete')} + loading={saving} + /> +
+ ); +}; + +export default CostCenters; diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx new file mode 100644 index 0000000..6051ccc --- /dev/null +++ b/frontend/src/pages/Dashboard.jsx @@ -0,0 +1,426 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { useAuth } from '../context/AuthContext'; +import { dashboardService, accountService } from '../services/api'; +import useFormatters from '../hooks/useFormatters'; +import CashflowChart from '../components/dashboard/CashflowChart'; +import OverpaymentsAnalysis from '../components/dashboard/OverpaymentsAnalysis'; +import CalendarWidget from '../components/dashboard/CalendarWidget'; +import UpcomingWidget from '../components/dashboard/UpcomingWidget'; +import OverdueWidget from '../components/dashboard/OverdueWidget'; + +const Dashboard = () => { + const { user } = useAuth(); + const { t, i18n } = useTranslation(); + const { currency } = useFormatters(); + const navigate = useNavigate(); + + // Estados + const [loading, setLoading] = useState(true); + const [cashflowLoading, setCashflowLoading] = useState(true); + const [variancesLoading, setVariancesLoading] = useState(true); + + // Dados do dashboard + const [summary, setSummary] = useState(null); + const [cashflow, setCashflow] = useState(null); + const [variances, setVariances] = useState(null); + const [accounts, setAccounts] = useState([]); + + // Filtros dos gráficos + const [cashflowMonths, setCashflowMonths] = useState(12); + const [variancesMonths, setVariancesMonths] = useState(12); + + // Carregar resumo geral + const loadSummary = useCallback(async () => { + try { + const [summaryData, accountsData] = await Promise.all([ + dashboardService.getSummary(), + accountService.getAll(), + ]); + setSummary(summaryData); + setAccounts(accountsData.data || []); + } catch (error) { + console.error('Error loading summary:', error); + } finally { + setLoading(false); + } + }, []); + + // Carregar fluxo de caixa + const loadCashflow = useCallback(async () => { + setCashflowLoading(true); + try { + const data = await dashboardService.getCashflow(cashflowMonths); + setCashflow(data); + } catch (error) { + console.error('Error loading cashflow:', error); + } finally { + setCashflowLoading(false); + } + }, [cashflowMonths]); + + // Carregar variações de pagamento + const loadVariances = useCallback(async () => { + setVariancesLoading(true); + try { + const data = await dashboardService.getPaymentVariances(variancesMonths); + setVariances(data); + } catch (error) { + console.error('Error loading variances:', error); + } finally { + setVariancesLoading(false); + } + }, [variancesMonths]); + + useEffect(() => { + loadSummary(); + }, [loadSummary]); + + useEffect(() => { + loadCashflow(); + }, [loadCashflow]); + + useEffect(() => { + loadVariances(); + }, [loadVariances]); + + // Handlers + const handleMonthsChange = (months) => { + setCashflowMonths(months); + }; + + const handleVariancesMonthsChange = (months) => { + setVariancesMonths(months); + }; + + const handleTransactionClick = (transactionId) => { + navigate(`/transactions?highlight=${transactionId}`); + }; + + // Componente de Stat Card modernizado + const StatCard = ({ icon, label, value, valueColor = 'text-white', trend = null, trendUp = true, subValue = null, accentColor = '#3b82f6' }) => ( +
+
+
+
+

+ {label} +

+ {loading ? ( +
+ +
+ ) : ( +

+ {value} +

+ )} + {subValue && !loading && ( + {subValue} + )} +
+
+ +
+
+ {trend !== null && !loading && ( +
+ + + {trend} + + {t('dashboard.vsLastMonth')} +
+ )} +
+
+ ); + + // Componente de conta + const AccountItem = ({ account }) => { + const balance = parseFloat(account.current_balance); + const isPositive = balance >= 0; + + const getAccountIcon = (type) => { + const icons = { + 'checking': 'bi-bank', + 'savings': 'bi-piggy-bank', + 'credit_card': 'bi-credit-card', + 'investment': 'bi-graph-up-arrow', + 'cash': 'bi-wallet2', + }; + return icons[type] || 'bi-wallet2'; + }; + + return ( +
+
+ +
+
+
{account.name}
+
+ {account.bank_name || t(`accounts.types.${account.type}`)} +
+
+
+
+ {currency(balance, account.currency || 'BRL')} +
+ {!account.include_in_total && ( + + + {t('accounts.notInTotal')} + + )} +
+
+ ); + }; + + return ( +
+ {/* Header */} +
+
+

+ {t('dashboard.welcome')}, {user?.name?.split(' ')[0]}! + 👋 +

+

+ {new Date().toLocaleDateString(i18n.language === 'pt-BR' ? 'pt-BR' : i18n.language === 'es' ? 'es-ES' : 'en-US', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric' + })} +

+
+
+ + + {t('nav.transactions')} + + + + {t('nav.import')} + +
+
+ + {/* Stats Cards Row */} +
+
+ = 0 ? 'text-success' : 'text-danger'} + accentColor={summary?.total_balance >= 0 ? '#22c55e' : '#ef4444'} + /> +
+
+ +
+
+ +
+
+ 0 ? + `⚠️ ${summary.overdue.count} ${t('dashboard.overdue')}` : null + } + accentColor="#f59e0b" + /> +
+
+ + {/* Calendário e Transações Futuras */} +
+
+ +
+
+
+ + +
+
+
+ + {/* Main Content Grid */} +
+ {/* Coluna Principal - Fluxo de Caixa */} +
+
+
+
+ + {t('dashboard.cashflow')} +
+
+ {[6, 12, 24].map(months => ( + + ))} +
+
+
+ + + {/* Resumo do período */} + {cashflow && !cashflowLoading && ( +
+
+ + {t('dashboard.totalIncome')} + + + {currency(cashflow.totals?.income || 0, 'BRL')} + +
+
+ + {t('dashboard.totalExpenses')} + + + {currency(cashflow.totals?.expense || 0, 'BRL')} + +
+
+ + {t('dashboard.avgIncome')} + + + {currency(cashflow.totals?.average_income || 0, 'BRL')} + +
+
+ + {t('dashboard.avgExpense')} + + + {currency(cashflow.totals?.average_expense || 0, 'BRL')} + +
+
+ )} +
+
+
+ + {/* Coluna Lateral - Contas */} +
+
+
+
+ + {t('dashboard.accountBalances')} +
+ + {t('common.viewAll')} + +
+
+ {loading ? ( +
+ {[1, 2, 3].map(i => ( +
+ ))} +
+ ) : accounts.length === 0 ? ( +
+ +

{t('dashboard.noAccounts')}

+ + {t('dashboard.createAccount')} + +
+ ) : ( +
+ {accounts + .filter(a => a.is_active) + .sort((a, b) => Math.abs(b.current_balance) - Math.abs(a.current_balance)) + .map(account => ( + + ))} +
+ )} +
+
+
+
+ + {/* Análise de Sobrepagamentos - Full Width */} +
+
+ +
+
+ + {/* CSS inline para efeitos hover */} + +
+ ); +}; + +export default Dashboard; diff --git a/frontend/src/pages/ImportTransactions.jsx b/frontend/src/pages/ImportTransactions.jsx new file mode 100644 index 0000000..e283ae2 --- /dev/null +++ b/frontend/src/pages/ImportTransactions.jsx @@ -0,0 +1,886 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useDropzone } from 'react-dropzone'; +import { importService, accountService, categoryService, costCenterService } from '../services/api'; + +const ImportTransactions = () => { + const { t } = useTranslation(); + + // Steps: 1=Upload, 2=Configure, 3=Map Columns, 4=Import + const [step, setStep] = useState(1); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(''); + + // File data + const [uploadedFile, setUploadedFile] = useState(null); + const [preview, setPreview] = useState(null); + + // Configuration + const [headerRow, setHeaderRow] = useState(0); + const [dataStartRow, setDataStartRow] = useState(1); + const [dateFormat, setDateFormat] = useState('d/m/Y'); + const [decimalSeparator, setDecimalSeparator] = useState(','); + const [thousandsSeparator, setThousandsSeparator] = useState('.'); + + // Headers and mapping + const [headers, setHeaders] = useState([]); + const [suggestions, setSuggestions] = useState({}); + const [columnMappings, setColumnMappings] = useState({}); + + // Import options + const [accountId, setAccountId] = useState(''); + const [categoryId, setCategoryId] = useState(''); + const [costCenterId, setCostCenterId] = useState(''); + const [saveMapping, setSaveMapping] = useState(false); + const [mappingName, setMappingName] = useState(''); + const [bankName, setBankName] = useState(''); + + // Data lists + const [accounts, setAccounts] = useState([]); + const [categories, setCategories] = useState([]); + const [costCenters, setCostCenters] = useState([]); + const [savedMappings, setSavedMappings] = useState([]); + const [importHistory, setImportHistory] = useState([]); + const [selectedMapping, setSelectedMapping] = useState(null); + + // Field definitions + const [fieldDefinitions, setFieldDefinitions] = useState({}); + const [dateFormats, setDateFormats] = useState({}); + + // Import result + const [importResult, setImportResult] = useState(null); + + // Load initial data + useEffect(() => { + loadInitialData(); + }, []); + + const loadInitialData = async () => { + try { + const [accountsRes, categoriesRes, costCentersRes, mappingsRes, historyRes, fieldsRes] = await Promise.all([ + accountService.getAll(), + categoryService.getAll(), + costCenterService.getAll(), + importService.getMappings(), + importService.getHistory(), + importService.getFields(), + ]); + + setAccounts(Array.isArray(accountsRes) ? accountsRes : (accountsRes.data || [])); + setCategories(Array.isArray(categoriesRes) ? categoriesRes : (categoriesRes.data || [])); + setCostCenters(Array.isArray(costCentersRes) ? costCentersRes : (costCentersRes.data || [])); + setSavedMappings(Array.isArray(mappingsRes) ? mappingsRes : (mappingsRes.data || [])); + setImportHistory(Array.isArray(historyRes) ? historyRes : (historyRes.data || [])); + + if (fieldsRes.data) { + setFieldDefinitions(fieldsRes.data.fields || {}); + setDateFormats(fieldsRes.data.date_formats || {}); + } + } catch (err) { + console.error('Error loading initial data:', err); + } + }; + + // File upload handler + const onDrop = useCallback(async (acceptedFiles) => { + if (acceptedFiles.length === 0) return; + + const file = acceptedFiles[0]; + setError(''); + setLoading(true); + + try { + const formData = new FormData(); + formData.append('file', file); + + const response = await importService.upload(formData); + + if (response.success && response.data) { + setUploadedFile(response.data); + setPreview(response.data.preview); + + // Auto-detect header row + if (response.data.preview?.preview) { + const previewRows = response.data.preview.preview; + // Find first row with most non-empty cells + let bestRow = 0; + let maxCells = 0; + previewRows.forEach((row, idx) => { + const nonEmpty = row.data.filter(c => c !== null && c !== '').length; + if (nonEmpty > maxCells) { + maxCells = nonEmpty; + bestRow = idx; + } + }); + setHeaderRow(bestRow); + setDataStartRow(bestRow + 1); + } + + setStep(2); + } else { + setError(response.message || t('import.uploadError')); + } + } catch (err) { + setError(err.response?.data?.message || t('import.uploadError')); + } finally { + setLoading(false); + } + }, [t]); + + const { getRootProps, getInputProps, isDragActive } = useDropzone({ + onDrop, + accept: { + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'], + 'application/vnd.ms-excel': ['.xls'], + 'text/csv': ['.csv'], + 'application/x-ofx': ['.ofx'], + 'application/pdf': ['.pdf'], + }, + maxFiles: 1, + maxSize: 10 * 1024 * 1024, // 10MB + }); + + // Load headers when configuration changes + const loadHeaders = async () => { + if (!uploadedFile?.temp_file) return; + + setLoading(true); + setError(''); + + try { + const response = await importService.getHeaders({ + temp_file: uploadedFile.temp_file, + header_row: headerRow, + }); + + if (response.success && response.data) { + setHeaders(response.data.headers || []); + setSuggestions(response.data.suggestions || {}); + + // Apply suggestions to mappings + const newMappings = {}; + Object.keys(response.data.suggestions || {}).forEach(field => { + newMappings[field] = response.data.suggestions[field]; + }); + setColumnMappings(newMappings); + } + } catch (err) { + setError(err.response?.data?.message || t('import.headerError')); + } finally { + setLoading(false); + } + }; + + // Go to mapping step + const goToMapping = async () => { + await loadHeaders(); + setStep(3); + }; + + // Apply saved mapping + const applySavedMapping = (mapping) => { + setSelectedMapping(mapping); + setHeaderRow(mapping.header_row); + setDataStartRow(mapping.data_start_row); + setDateFormat(mapping.date_format); + setDecimalSeparator(mapping.decimal_separator); + setThousandsSeparator(mapping.thousands_separator); + setColumnMappings(mapping.column_mappings); + setAccountId(mapping.default_account_id || ''); + setCategoryId(mapping.default_category_id || ''); + setCostCenterId(mapping.default_cost_center_id || ''); + setBankName(mapping.bank_name || ''); + setMappingName(mapping.name); + }; + + // Update column mapping + const updateMapping = (field, columnIndex, add = true) => { + setColumnMappings(prev => { + const current = prev[field] || { columns: [], concat_separator: ' ' }; + let newColumns; + + if (add) { + if (!current.columns.includes(columnIndex)) { + newColumns = [...current.columns, columnIndex]; + } else { + newColumns = current.columns; + } + } else { + newColumns = current.columns.filter(c => c !== columnIndex); + } + + return { + ...prev, + [field]: { + ...current, + columns: newColumns, + }, + }; + }); + }; + + // Update concat separator + const updateConcatSeparator = (field, separator) => { + setColumnMappings(prev => ({ + ...prev, + [field]: { + ...(prev[field] || { columns: [] }), + concat_separator: separator, + }, + })); + }; + + // Remove field mapping + const removeFieldMapping = (field) => { + setColumnMappings(prev => { + const newMappings = { ...prev }; + delete newMappings[field]; + return newMappings; + }); + }; + + // Execute import + const executeImport = async () => { + setLoading(true); + setError(''); + setSuccess(''); + + try { + const payload = { + temp_file: uploadedFile.temp_file, + column_mappings: columnMappings, + header_row: headerRow, + data_start_row: dataStartRow, + date_format: dateFormat, + decimal_separator: decimalSeparator, + thousands_separator: thousandsSeparator, + account_id: accountId || null, + category_id: categoryId || null, + cost_center_id: costCenterId || null, + save_mapping: saveMapping, + mapping_name: saveMapping ? mappingName : null, + bank_name: bankName || null, + }; + + if (selectedMapping?.id) { + payload.mapping_id = selectedMapping.id; + } + + const response = await importService.process(payload); + + if (response.success) { + setImportResult(response.data); + setSuccess(response.message); + setStep(4); + + // Reload history + const historyRes = await importService.getHistory(); + setImportHistory(Array.isArray(historyRes) ? historyRes : (historyRes.data || [])); + } else { + setError(response.message || t('import.importError')); + } + } catch (err) { + setError(err.response?.data?.message || t('import.importError')); + } finally { + setLoading(false); + } + }; + + // Reset to start + const resetImport = () => { + setStep(1); + setUploadedFile(null); + setPreview(null); + setHeaders([]); + setSuggestions({}); + setColumnMappings({}); + setSelectedMapping(null); + setImportResult(null); + setError(''); + setSuccess(''); + }; + + // Render step indicators + const renderStepIndicator = () => ( +
+
+ {[1, 2, 3, 4].map((s, idx) => ( + +
= s ? 'bg-primary text-white' : 'bg-secondary text-white' + }`} + style={{ width: 36, height: 36, cursor: step > s ? 'pointer' : 'default' }} + onClick={() => step > s && setStep(s)} + > + {s} +
+ {idx < 3 && ( +
s ? 'bg-primary' : 'bg-secondary'}`} + style={{ width: 60, height: 3 }} + /> + )} + + ))} +
+
+ ); + + // Render Step 1: Upload + const renderUploadStep = () => ( +
+
+
+ + {t('import.step1Title')} +
+
+
+ {/* Saved Mappings */} + {savedMappings.length > 0 && ( +
+ + +
+ )} + + {/* Dropzone */} +
+ + +

+ {isDragActive ? t('import.dropHere') : t('import.dragDrop')} +

+ + {t('import.supportedFormats')}: XLSX, XLS, CSV, OFX, PDF + +
+ + {/* Recent Imports */} + {importHistory.length > 0 && ( +
+
{t('import.recentImports')}
+
+ + + + + + + + + + + {importHistory.slice(0, 5).map(log => ( + + + + + + + ))} + +
{t('import.date')}{t('import.file')}{t('import.status')}{t('import.imported')}
{new Date(log.created_at).toLocaleDateString()}{log.original_filename} + + {log.status} + + {log.imported_rows} / {log.total_rows}
+
+
+ )} +
+
+ ); + + // Render Step 2: Configure + const renderConfigureStep = () => ( +
+
+
+ + {t('import.step2Title')} +
+
+
+ {/* File Info */} +
+ + {uploadedFile?.original_name} + ({(uploadedFile?.size / 1024).toFixed(1)} KB) +
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ + {/* Preview Table */} +
+
{t('import.preview')}
+
+ + + + + {preview?.preview?.[0]?.data.map((_, idx) => ( + + ))} + + + + {preview?.preview?.map((row, rowIdx) => ( + = dataStartRow ? '' : 'table-secondary' + } + > + + {row.data.map((cell, cellIdx) => ( + + ))} + + ))} + +
#{t('import.col')} {idx}
{rowIdx} + {cell !== null && cell !== '' ? String(cell).substring(0, 30) : '-'} +
+
+ + {t('import.headerRowLabel')} + {t('import.skippedRows')} + {t('import.dataRows')} + +
+ +
+ + +
+
+
+ ); + + // Render Step 3: Map Columns + const renderMappingStep = () => ( +
+
+
+ + {t('import.step3Title')} +
+
+
+
+ {/* Column Mappings */} +
+
{t('import.mapColumns')}
+ + {Object.entries(fieldDefinitions).map(([field, config]) => ( +
+
+
+
+ {config.label} + {config.required && *} +
+ {config.type} +
+
+ + + {/* Additional columns for concatenation */} + {columnMappings[field]?.columns?.length > 0 && ( +
+ {t('import.concatenateWith')}: +
+ + + {columnMappings[field]?.columns?.length > 1 && ( + updateConcatSeparator(field, e.target.value)} + /> + )} +
+
+ )} +
+
+ {columnMappings[field]?.columns?.map((colIdx, i) => ( + + {headers[colIdx] || `Col ${colIdx}`} + {columnMappings[field].columns.length > 1 && ( +
+
+
+
+ ))} +
+ + {/* Import Options */} +
+
{t('import.importOptions')}
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ setSaveMapping(e.target.checked)} + /> + +
+ + {saveMapping && ( + <> +
+ + setMappingName(e.target.value)} + placeholder={t('import.mappingNamePlaceholder')} + /> +
+
+ + setBankName(e.target.value)} + placeholder={t('import.bankNamePlaceholder')} + /> +
+ + )} +
+
+ +
+ + +
+
+
+ ); + + // Render Step 4: Result + const renderResultStep = () => ( +
+
+
+ + {t('import.step4Title')} +
+
+
+ +

{t('import.importComplete')}

+ + {importResult?.import_log && ( +
+
+
+
+
+
+

{importResult.import_log.imported_rows}

+ {t('import.imported')} +
+
+

{importResult.import_log.skipped_rows}

+ {t('import.skipped')} +
+
+

{importResult.import_log.error_rows}

+ {t('import.errors')} +
+
+
+
+
+
+ )} + +
+ + + + {t('import.viewTransactions')} + +
+
+
+ ); + + return ( +
+
+
+

+ + {t('import.title')} +

+

{t('import.subtitle')}

+
+
+ + {renderStepIndicator()} + + {error && ( +
+ + {error} + +
+ )} + + {success && ( +
+ + {success} + +
+ )} + + {loading && step === 1 && ( +
+
+ Loading... +
+

{t('import.processing')}

+
+ )} + + {step === 1 && !loading && renderUploadStep()} + {step === 2 && renderConfigureStep()} + {step === 3 && renderMappingStep()} + {step === 4 && renderResultStep()} +
+ ); +}; + +export default ImportTransactions; diff --git a/frontend/src/pages/LiabilityAccounts.jsx b/frontend/src/pages/LiabilityAccounts.jsx new file mode 100644 index 0000000..648f551 --- /dev/null +++ b/frontend/src/pages/LiabilityAccounts.jsx @@ -0,0 +1,1348 @@ +import React, { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { liabilityAccountService } from '../services/api'; +import { useToast } from '../components/Toast'; +import { ConfirmModal } from '../components/Modal'; +import { useFormatters } from '../hooks'; + +const LiabilityAccounts = () => { + const { t } = useTranslation(); + const toast = useToast(); + const { currency: formatCurrency } = useFormatters(); + + // States + const [accounts, setAccounts] = useState([]); + const [summary, setSummary] = useState(null); + const [loading, setLoading] = useState(true); + const [showImportModal, setShowImportModal] = useState(false); + const [showDetailModal, setShowDetailModal] = useState(false); + const [showDeleteModal, setShowDeleteModal] = useState(false); + const [showReconcileModal, setShowReconcileModal] = useState(false); + const [selectedAccount, setSelectedAccount] = useState(null); + const [selectedInstallment, setSelectedInstallment] = useState(null); + const [eligibleTransactions, setEligibleTransactions] = useState([]); + const [loadingTransactions, setLoadingTransactions] = useState(false); + const [reconcileSearch, setReconcileSearch] = useState(''); + const [markAsPaidOnReconcile, setMarkAsPaidOnReconcile] = useState(true); + const [saving, setSaving] = useState(false); + const [filter, setFilter] = useState({ status: '', is_active: '' }); + const [showPriceAnalysisModal, setShowPriceAnalysisModal] = useState(false); + + // Import form state + const [importForm, setImportForm] = useState({ + file: null, + name: '', + creditor: '', + contract_number: '', + currency: 'EUR', + description: '', + }); + + useEffect(() => { + loadAccounts(); + }, [filter]); + + const loadAccounts = async () => { + try { + setLoading(true); + const params = {}; + if (filter.status) params.status = filter.status; + if (filter.is_active !== '') params.is_active = filter.is_active; + + const response = await liabilityAccountService.getAll(params); + if (response.success) { + setAccounts(response.data); + setSummary(response.summary); + } + } catch (error) { + toast.error(t('liabilities.loadError')); + } finally { + setLoading(false); + } + }; + + const handleOpenImportModal = () => { + setImportForm({ + file: null, + name: '', + creditor: '', + contract_number: '', + currency: 'EUR', + description: '', + }); + setShowImportModal(true); + }; + + const handleCloseImportModal = () => { + setShowImportModal(false); + }; + + const handleImportChange = (e) => { + const { name, value, files } = e.target; + if (name === 'file') { + setImportForm(prev => ({ ...prev, file: files[0] })); + } else { + setImportForm(prev => ({ ...prev, [name]: value })); + } + }; + + const handleImportSubmit = async (e) => { + e.preventDefault(); + + if (!importForm.file) { + toast.error(t('liabilities.selectFile')); + return; + } + + if (!importForm.name.trim()) { + toast.error(t('validation.required')); + return; + } + + setSaving(true); + try { + const formData = new FormData(); + formData.append('file', importForm.file); + formData.append('name', importForm.name); + formData.append('creditor', importForm.creditor); + formData.append('contract_number', importForm.contract_number); + formData.append('currency', importForm.currency); + formData.append('description', importForm.description); + + const response = await liabilityAccountService.import(formData); + + if (response.success) { + toast.success(t('liabilities.importSuccess', { count: response.imported_installments })); + handleCloseImportModal(); + loadAccounts(); + // Abrir detalhes do contrato importado + setSelectedAccount(response.data); + setShowDetailModal(true); + } + } catch (error) { + toast.error(error.response?.data?.message || t('liabilities.importError')); + } finally { + setSaving(false); + } + }; + + const handleOpenDetail = async (account) => { + try { + const response = await liabilityAccountService.getById(account.id); + if (response.success) { + setSelectedAccount(response.data); + setShowDetailModal(true); + } + } catch (error) { + toast.error(t('liabilities.loadError')); + } + }; + + const handleCloseDetail = () => { + setShowDetailModal(false); + setSelectedAccount(null); + }; + + const handleDelete = async () => { + if (!selectedAccount) return; + + setSaving(true); + try { + const response = await liabilityAccountService.delete(selectedAccount.id); + if (response.success) { + toast.success(t('liabilities.deleteSuccess')); + setShowDeleteModal(false); + setSelectedAccount(null); + loadAccounts(); + } + } catch (error) { + toast.error(t('liabilities.deleteError')); + } finally { + setSaving(false); + } + }; + + const handleMarkInstallmentPaid = async (installment) => { + try { + const response = await liabilityAccountService.updateInstallment( + selectedAccount.id, + installment.id, + { status: 'paid' } + ); + + if (response.success) { + toast.success(t('liabilities.installmentPaid')); + // Recarregar conta atualizada + const accountResponse = await liabilityAccountService.getById(selectedAccount.id); + if (accountResponse.success) { + setSelectedAccount(accountResponse.data); + } + loadAccounts(); + } + } catch (error) { + toast.error(t('liabilities.updateError')); + } + }; + + // ============================================ + // Conciliação de Parcelas + // ============================================ + + const handleOpenReconcileModal = async (installment) => { + setSelectedInstallment(installment); + setReconcileSearch(''); + setMarkAsPaidOnReconcile(true); + setShowReconcileModal(true); + await loadEligibleTransactions(installment); + }; + + const handleCloseReconcileModal = () => { + setShowReconcileModal(false); + setSelectedInstallment(null); + setEligibleTransactions([]); + }; + + const loadEligibleTransactions = async (installment, search = '') => { + setLoadingTransactions(true); + try { + const params = {}; + if (search) params.search = search; + + const response = await liabilityAccountService.getEligibleTransactions( + selectedAccount.id, + installment.id, + params + ); + + if (response.success) { + setEligibleTransactions(response.data); + } + } catch (error) { + toast.error(t('liabilities.loadError')); + } finally { + setLoadingTransactions(false); + } + }; + + const handleReconcileSearch = async (e) => { + const value = e.target.value; + setReconcileSearch(value); + + // Debounce search + if (value.length >= 2 || value.length === 0) { + await loadEligibleTransactions(selectedInstallment, value); + } + }; + + const handleReconcile = async (transaction) => { + setSaving(true); + try { + const response = await liabilityAccountService.reconcile( + selectedAccount.id, + selectedInstallment.id, + transaction.id, + markAsPaidOnReconcile + ); + + if (response.success) { + toast.success(t('liabilities.reconcileSuccess')); + handleCloseReconcileModal(); + + // Recarregar conta atualizada + const accountResponse = await liabilityAccountService.getById(selectedAccount.id); + if (accountResponse.success) { + setSelectedAccount(accountResponse.data); + } + loadAccounts(); + } + } catch (error) { + toast.error(error.response?.data?.message || t('liabilities.reconcileError')); + } finally { + setSaving(false); + } + }; + + const handleUnreconcile = async (installment) => { + if (!confirm(t('liabilities.unreconcile') + '?')) return; + + setSaving(true); + try { + const response = await liabilityAccountService.unreconcile( + selectedAccount.id, + installment.id + ); + + if (response.success) { + toast.success(t('liabilities.unreconcileSuccess')); + + // Recarregar conta atualizada + const accountResponse = await liabilityAccountService.getById(selectedAccount.id); + if (accountResponse.success) { + setSelectedAccount(accountResponse.data); + } + loadAccounts(); + } + } catch (error) { + toast.error(t('liabilities.unreconcileError')); + } finally { + setSaving(false); + } + }; + + const formatPercent = (value) => { + if (value === null || value === undefined) return '-'; + return `${parseFloat(value).toFixed(2)}%`; + }; + + const formatDate = (dateString) => { + if (!dateString) return '-'; + return new Date(dateString).toLocaleDateString(); + }; + + const getStatusBadge = (status) => { + const badges = { + active: 'bg-primary', + paid_off: 'bg-success', + defaulted: 'bg-danger', + renegotiated: 'bg-warning text-dark', + pending: 'bg-secondary', + paid: 'bg-success', + partial: 'bg-info', + overdue: 'bg-danger', + cancelled: 'bg-dark', + }; + return badges[status] || 'bg-secondary'; + }; + + const getStatusLabel = (status, isInstallment = false) => { + const labels = isInstallment + ? liabilityAccountService.installmentStatuses + : liabilityAccountService.statuses; + return labels[status] || status; + }; + + // Calcular totais por moeda + const getTotalsByCurrency = () => { + const totals = {}; + accounts.forEach(account => { + const currency = account.currency || 'EUR'; + if (!totals[currency]) { + totals[currency] = { + principal: 0, + paid: 0, + pending: 0, + interest: 0, + }; + } + totals[currency].principal += parseFloat(account.principal_amount) || 0; + totals[currency].paid += parseFloat(account.total_paid) || 0; + totals[currency].pending += parseFloat(account.total_pending) || 0; + totals[currency].interest += parseFloat(account.total_interest) || 0; + }); + return totals; + }; + + const totalsByCurrency = getTotalsByCurrency(); + + return ( +
+ {/* Header */} +
+
+

+ + {t('liabilities.title')} +

+ {t('liabilities.subtitle')} +
+ +
+ + {/* Summary Cards */} + {Object.keys(totalsByCurrency).length > 0 && ( +
+ {Object.entries(totalsByCurrency).map(([currency, totals]) => ( + +
+
+
+
+
+
{t('liabilities.totalDebt')} ({currency})
+
{formatCurrency(totals.principal, currency)}
+
+ +
+
+
+
+
+
+
+
+
+
{t('liabilities.totalPaid')} ({currency})
+
{formatCurrency(totals.paid, currency)}
+
+ +
+
+
+
+
+
+
+
+
+
{t('liabilities.totalPending')} ({currency})
+
{formatCurrency(totals.pending, currency)}
+
+ +
+
+
+
+
+
+
+
+
+
{t('liabilities.totalInterest')} ({currency})
+
{formatCurrency(totals.interest, currency)}
+
+ +
+
+
+
+
+ ))} +
+ )} + + {/* Filters */} +
+
+
+
+ +
+
+ +
+
+ +
+
+
+
+ + {/* Contracts List */} + {loading ? ( +
+
+ {t('common.loading')} +
+
+ ) : accounts.length === 0 ? ( +
+
+ +
{t('liabilities.noContracts')}
+

{t('liabilities.importHint')}

+ +
+
+ ) : ( +
+ {accounts.map(account => ( +
+
+
+
+ + {account.name} +
+ + {getStatusLabel(account.status)} + +
+
+ {account.creditor && ( +
+ + {account.creditor} +
+ )} + + {/* Progress Bar */} +
+
+ {t('liabilities.progress')} + {account.progress_percentage || 0}% +
+
+
+
+
+ + {/* Values */} +
+
+
{t('liabilities.principal')}
+
{formatCurrency(account.principal_amount, account.currency)}
+
+
+
{t('liabilities.remaining')}
+
{formatCurrency(account.remaining_balance, account.currency)}
+
+
+
{t('liabilities.installments')}
+
{account.paid_installments}/{account.total_installments}
+
+
+
{t('liabilities.monthlyRate')}
+
{formatPercent(account.monthly_interest_rate)}
+
+
+
+
+
+ + +
+
+
+
+ ))} +
+ )} + + {/* Import Modal */} + {showImportModal && ( +
+
+
+
+
+ + {t('liabilities.importContract')} +
+ +
+
+
+
+ + {t('liabilities.importInfo')} +
+ +
+
+ + +
{t('liabilities.fileFormatHint')}
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+
+ + +
+
+
+
+
+ )} + + {/* Detail Modal */} + {showDetailModal && selectedAccount && ( +
+
+
+
+
+ + {selectedAccount.name} + +
+ +
+
+ {/* Contract Type / Description */} + {selectedAccount.description && ( +
+ + {t('liabilities.contractType')}: {selectedAccount.description} +
+ )} + + {/* Summary Cards */} +
+
+
+
+
{t('liabilities.principal')}
+
{formatCurrency(selectedAccount.principal_amount, selectedAccount.currency)}
+
+
+
+
+
+
+
{t('liabilities.totalInterest')}
+
{formatCurrency(selectedAccount.total_interest, selectedAccount.currency)}
+
+
+
+
+
+
+
{t('liabilities.totalFees')}
+
{formatCurrency(selectedAccount.total_fees, selectedAccount.currency)}
+
{t('liabilities.extraCharges')}
+
+
+
+
+
+
+
{t('liabilities.totalContract')}
+
{formatCurrency(selectedAccount.total_contract_value, selectedAccount.currency)}
+
+
+
+
+ + {/* Interest Rates */} +
+
+
+
+
{t('liabilities.monthlyRate')}
+
{formatPercent(selectedAccount.monthly_interest_rate)}
+
+
+
+
+
+
+
{t('liabilities.annualRate')}
+
{formatPercent(selectedAccount.annual_interest_rate)}
+
+
+
+
+
+
+
{t('liabilities.totalRate')}
+
{formatPercent(selectedAccount.total_interest_rate)}
+
+
+
+
+ + {/* Progress */} +
+
+ {t('liabilities.paymentProgress')} + {selectedAccount.paid_installments}/{selectedAccount.total_installments} {t('liabilities.installments').toLowerCase()} +
+
+
+ {selectedAccount.progress_percentage}% +
+
+
+ {t('liabilities.paid')}: {formatCurrency(selectedAccount.principal_paid, selectedAccount.currency)} + {t('liabilities.remaining')}: {formatCurrency(selectedAccount.remaining_balance, selectedAccount.currency)} +
+
+ + {/* Installments Table */} +
+ + {t('liabilities.installmentsList')} +
+
+ + + + + + + + + + + + + + + + + {selectedAccount.installments?.map(inst => ( + + + + + + + + + + + + + ))} + + + + + + + + + + + +
#{t('liabilities.dueDate')}{t('liabilities.installmentAmount')}{t('liabilities.paidAmount')}{t('liabilities.capital')}{t('liabilities.interest')}{t('liabilities.fees')}{t('common.status')}{t('liabilities.reconciliation')}
{inst.installment_number}{formatDate(inst.due_date)}{formatCurrency(inst.installment_amount, selectedAccount.currency)} + {inst.paid_amount > 0 ? ( + inst.installment_amount ? 'text-warning' : ''}> + {formatCurrency(inst.paid_amount, selectedAccount.currency)} + {inst.paid_amount > inst.installment_amount && ( + + )} + + ) : '-'} + {formatCurrency(inst.principal_amount, selectedAccount.currency)}{formatCurrency(inst.interest_amount, selectedAccount.currency)}{inst.fee_amount > 0 ? formatCurrency(inst.fee_amount, selectedAccount.currency) : '-'} + + {getStatusLabel(inst.status, true)} + + + {inst.reconciled_transaction_id ? ( + + + {t('liabilities.reconciled')} + + ) : ( + + {t('liabilities.notReconciled')} + + )} + +
+ {inst.status !== 'paid' && !inst.reconciled_transaction_id && ( + + )} + {!inst.reconciled_transaction_id ? ( + + ) : ( + + )} +
+
{t('common.total')}{formatCurrency(selectedAccount.total_contract_value, selectedAccount.currency)}{formatCurrency(selectedAccount.principal_amount, selectedAccount.currency)}{formatCurrency(selectedAccount.total_interest, selectedAccount.currency)}{formatCurrency(selectedAccount.total_fees, selectedAccount.currency)}
+
+
+
+ +
+
+
+
+ )} + + {/* Reconcile Modal */} + {showReconcileModal && selectedInstallment && ( +
+
+
+
+
+ + {t('liabilities.reconcileInstallment')} +
+ +
+
+ {/* Installment Info */} +
+
+
+ {t('liabilities.installments')}: #{selectedInstallment.installment_number} +
+
+ {t('liabilities.dueDate')}: {formatDate(selectedInstallment.due_date)} +
+
+ {t('liabilities.installmentAmount')}: {formatCurrency(selectedInstallment.installment_amount, selectedAccount?.currency)} +
+
+
+ + {/* Options */} +
+
+ setMarkAsPaidOnReconcile(e.target.checked)} + /> + +
+
+ + {/* Search */} +
+ + +
+ + {/* Transactions List */} +
{t('liabilities.eligibleTransactions')}
+ + {loadingTransactions ? ( +
+
+ {t('common.loading')} +
+
+ ) : eligibleTransactions.length === 0 ? ( +
+ {t('liabilities.noEligibleTransactions')} +
+ ) : ( +
+ + + + + + + + + + + + {eligibleTransactions.map(tx => ( + + + + + + + + ))} + +
{t('transactions.date')}{t('transactions.description')}{t('transactions.account')}{t('transactions.amount')}
{formatDate(tx.effective_date || tx.planned_date)} +
+ {tx.description || tx.original_description} +
+
{tx.account?.name || '-'} + {formatCurrency(Math.abs(tx.amount), tx.account?.currency)} + + +
+
+ )} +
+
+ +
+
+
+
+ )} + + {/* Delete Confirmation Modal */} + setShowDeleteModal(false)} + onConfirm={handleDelete} + title={t('liabilities.deleteTitle')} + message={t('liabilities.deleteConfirm', { name: selectedAccount?.name })} + confirmText={t('common.delete')} + cancelText={t('common.cancel')} + variant="danger" + loading={saving} + /> + + {/* PRICE System Analysis Modal */} + {showPriceAnalysisModal && selectedAccount && ( +
+
+
+
+
+ + {t('liabilities.priceAnalysisTitle')} - {selectedAccount.name} +
+ +
+
+ {/* Contract Summary */} +
+
+
+
{t('liabilities.principal')}
+
{formatCurrency(selectedAccount.principal_amount, selectedAccount.currency)}
+
+
+
{t('liabilities.totalInterest')}
+
{formatCurrency(selectedAccount.total_interest, selectedAccount.currency)}
+
+
+
{t('liabilities.installments')}
+
{selectedAccount.total_installments}
+
+
+
{t('liabilities.totalContract')}
+
{formatCurrency(selectedAccount.total_contract_value, selectedAccount.currency)}
+
+
+
+ + {/* Overview */} +
+
+ + {t('liabilities.priceOverview')} +
+

{t('liabilities.priceOverviewText')}

+
+ + {/* What is PRICE */} +
+
+ + {t('liabilities.whatIsPrice')} +
+
+

{t('liabilities.whatIsPriceText')}

+
    +
  • {t('liabilities.priceFeature1')}
  • +
  • {t('liabilities.priceFeature2')}
  • +
  • {t('liabilities.priceFeature3')}
  • +
+
+
+ + {/* Mathematical Formula */} +
+
+ + {t('liabilities.priceMathFormula')} +
+
+
+ PMT = PV × [i × (1 + i)ⁿ] / [(1 + i)ⁿ - 1] +
+
+
+

{t('liabilities.priceWhere')}:

+
    +
  • PMT = {t('liabilities.pricePMT')}
  • +
  • PV = {t('liabilities.pricePV')}
  • +
  • i = {t('liabilities.priceI')}
  • +
  • n = {t('liabilities.priceN')}
  • +
+
+
+
+ {t('liabilities.thisContract')}:
+ PV = {formatCurrency(selectedAccount.principal_amount, selectedAccount.currency)}
+ i = {formatPercent(selectedAccount.monthly_interest_rate)} {t('liabilities.perMonth')}
+ n = {selectedAccount.total_installments} {t('liabilities.installments').toLowerCase()}
+
+ PMT ≈ {formatCurrency(selectedAccount.installments?.[0]?.installment_amount || 0, selectedAccount.currency)}/{t('liabilities.perMonth')} +
+
+
+
+
+ + {/* Amortization Behavior */} +
+
+ + {t('liabilities.amortizationBehavior')} +
+
+
+
+
{t('liabilities.earlyInstallments')}
+
    +
  • {t('liabilities.earlyInstallmentsText1')}
  • +
  • {t('liabilities.earlyInstallmentsText2')}
  • +
+
+
+
{t('liabilities.lateInstallments')}
+
    +
  • {t('liabilities.lateInstallmentsText1')}
  • +
  • {t('liabilities.lateInstallmentsText2')}
  • +
+
+
+ {selectedAccount.installments?.length >= 3 && ( +
+
{t('liabilities.visualExample')}:
+
+ + + + + + + + + + + {/* First installment */} + + + + + + + {/* Middle installment */} + {(() => { + const midIndex = Math.floor(selectedAccount.installments.length / 2); + const midInst = selectedAccount.installments[midIndex]; + return ( + + + + + + + ); + })()} + {/* Last installment */} + + + + + + + +
#{t('liabilities.installmentValue')}{t('liabilities.interest')}{t('liabilities.amortization')}
{selectedAccount.installments[0].installment_number}{formatCurrency(selectedAccount.installments[0].installment_amount, selectedAccount.currency)}{formatCurrency(selectedAccount.installments[0].interest_amount, selectedAccount.currency)}{formatCurrency(selectedAccount.installments[0].principal_amount, selectedAccount.currency)}
{midInst.installment_number}{formatCurrency(midInst.installment_amount, selectedAccount.currency)}{formatCurrency(midInst.interest_amount, selectedAccount.currency)}{formatCurrency(midInst.principal_amount, selectedAccount.currency)}
{selectedAccount.installments[selectedAccount.installments.length - 1].installment_number}{formatCurrency(selectedAccount.installments[selectedAccount.installments.length - 1].installment_amount, selectedAccount.currency)}{formatCurrency(selectedAccount.installments[selectedAccount.installments.length - 1].interest_amount, selectedAccount.currency)}{formatCurrency(selectedAccount.installments[selectedAccount.installments.length - 1].principal_amount, selectedAccount.currency)}
+
+
+ )} +
+
+ + {/* Interest Rates */} +
+
+ + {t('liabilities.interestRates')} +
+
+
+
+
+
{t('liabilities.monthlyRate')}
+
{formatPercent(selectedAccount.monthly_interest_rate)}
+
{t('liabilities.perMonth')}
+
+
+
+
+
{t('liabilities.annualRate')}
+
{formatPercent(selectedAccount.annual_interest_rate)}
+
{t('liabilities.perYear')}
+
+
+
+
+
{t('liabilities.totalRate')}
+
{formatPercent(selectedAccount.total_interest_rate)}
+
{t('liabilities.interestOverPrincipal')}
+
+
+
+
+
+ + {/* Contract Financial Summary */} +
+
+ + {t('liabilities.contractCost')} +
+
+

{t('liabilities.contractCostText')}

+
+
+
+
{t('liabilities.principal')}
+
{formatCurrency(selectedAccount.principal_amount, selectedAccount.currency)}
+
+
+
{t('liabilities.totalInterest')}
+
+{formatCurrency(selectedAccount.total_interest, selectedAccount.currency)}
+
+
+
{t('liabilities.totalFees')}
+
+{formatCurrency(selectedAccount.total_fees, selectedAccount.currency)}
+
+
+
{t('liabilities.totalContract')}
+
{formatCurrency(selectedAccount.total_contract_value, selectedAccount.currency)}
+
+
+
+
+
+ + {/* Financial Analysis Summary */} +
+
+ + {t('liabilities.financialSummary')} +
+
    +
  • {t('liabilities.summaryPointDynamic1', { + ratio: selectedAccount.principal_amount > 0 + ? (selectedAccount.total_interest / selectedAccount.principal_amount * 100).toFixed(0) + : 0 + })}
  • +
  • {t('liabilities.summaryPoint2')}
  • +
  • {t('liabilities.summaryPoint3')}
  • +
  • {t('liabilities.summaryPoint4')}
  • +
+
+
+
+ +
+
+
+
+ )} +
+ ); +}; + +export default LiabilityAccounts; diff --git a/frontend/src/pages/Login.jsx b/frontend/src/pages/Login.jsx new file mode 100644 index 0000000..71a7b9e --- /dev/null +++ b/frontend/src/pages/Login.jsx @@ -0,0 +1,136 @@ +import React, { useState } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; +import { useAuth } from '../context/AuthContext'; +import Footer from '../components/Footer'; +import logo from '../assets/logo-white.png'; + +const Login = () => { + const navigate = useNavigate(); + const { login } = useAuth(); + const [formData, setFormData] = useState({ + email: '', + password: '', + }); + const [errors, setErrors] = useState({}); + const [loading, setLoading] = useState(false); + + const handleChange = (e) => { + setFormData({ + ...formData, + [e.target.name]: e.target.value, + }); + // Limpiar error del campo cuando el usuario escribe + if (errors[e.target.name]) { + setErrors({ ...errors, [e.target.name]: null }); + } + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + setLoading(true); + setErrors({}); + + try { + const response = await login(formData); + if (response.success) { + navigate('/dashboard'); + } + } catch (error) { + if (error.response?.data?.errors) { + setErrors(error.response.data.errors); + } else if (error.response?.data?.message) { + setErrors({ general: error.response.data.message }); + } else { + setErrors({ general: 'Error de conexión. Intenta nuevamente.' }); + } + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+
+
+
+ WebMoney +

WebMoney

+

Gestión Financiera Inteligente

+
+ + {errors.general && ( +
+ + {errors.general} +
+ )} + +
+
+ + + {errors.email && ( +
{errors.email}
+ )} +
+ +
+ + + {errors.password && ( +
{errors.password}
+ )} +
+ + +
+
+
+ +
+
+
+
+ ); +}; + +export default Login; diff --git a/frontend/src/pages/RecurringTransactions.jsx b/frontend/src/pages/RecurringTransactions.jsx new file mode 100644 index 0000000..53f7eef --- /dev/null +++ b/frontend/src/pages/RecurringTransactions.jsx @@ -0,0 +1,1362 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import Modal from '../components/Modal'; +import Toast from '../components/Toast'; +import { recurringService, accountService, categoryService } from '../services/api'; + +const RecurringTransactions = () => { + const { t } = useTranslation(); + + // State + const [templates, setTemplates] = useState([]); + const [pendingInstances, setPendingInstances] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [toast, setToast] = useState({ show: false, message: '', type: 'success' }); + + // Filters + const [filters, setFilters] = useState({ + is_active: '', + type: '', + frequency: '', + }); + + // Modals + const [selectedTemplate, setSelectedTemplate] = useState(null); + const [selectedInstance, setSelectedInstance] = useState(null); + const [showDetailModal, setShowDetailModal] = useState(false); + const [showPayModal, setShowPayModal] = useState(false); + const [showReconcileModal, setShowReconcileModal] = useState(false); + const [showEditTemplateModal, setShowEditTemplateModal] = useState(false); + const [showEditInstanceModal, setShowEditInstanceModal] = useState(false); + const [showDeleteTemplateModal, setShowDeleteTemplateModal] = useState(false); + const [showSkipInstanceModal, setShowSkipInstanceModal] = useState(false); + const [showCancelInstanceModal, setShowCancelInstanceModal] = useState(false); + const [showPauseResumeModal, setShowPauseResumeModal] = useState(false); + const [showPayConfirmModal, setShowPayConfirmModal] = useState(false); + const [showReconcileConfirmModal, setShowReconcileConfirmModal] = useState(false); + const [reconcileTransactionId, setReconcileTransactionId] = useState(null); + + // Data for selects + const [accounts, setAccounts] = useState([]); + const [categories, setCategories] = useState([]); + const [frequencies, setFrequencies] = useState({}); + const [candidates, setCandidates] = useState([]); + + // Tab active + const [activeTab, setActiveTab] = useState('templates'); + + // Load data + const loadData = useCallback(async () => { + setLoading(true); + setError(null); + try { + const [templatesRes, pendingRes, freqRes, accountsRes, categoriesRes] = await Promise.all([ + recurringService.getTemplates(filters), + recurringService.getPendingInstances({ limit: 50 }), + recurringService.getFrequencies(), + accountService.getAll(), + categoryService.getAll(), + ]); + + setTemplates(templatesRes.data || templatesRes); + setPendingInstances(pendingRes); + setFrequencies(freqRes); + setAccounts(accountsRes.data || accountsRes); + setCategories(categoriesRes.data || categoriesRes); + } catch (err) { + setError(err.response?.data?.message || t('common.error')); + } finally { + setLoading(false); + } + }, [filters, t]); + + useEffect(() => { + loadData(); + }, [loadData]); + + // Toast helper + const showToast = (message, type = 'success') => { + setToast({ show: true, message, type }); + }; + + // Handlers + const handleEditTemplate = (template) => { + setSelectedTemplate(template); + setShowEditTemplateModal(true); + }; + + const handleEditInstance = (instance) => { + setSelectedInstance(instance); + setShowEditInstanceModal(true); + }; + + const submitEditTemplate = async (data) => { + try { + await recurringService.updateTemplate(selectedTemplate.id, data); + showToast(t('recurring.updateSuccess')); + setShowEditTemplateModal(false); + loadData(); + } catch (err) { + showToast(err.response?.data?.message || t('common.error'), 'danger'); + } + }; + + const submitEditInstance = async (data) => { + try { + await recurringService.updateInstance(selectedInstance.id, data); + showToast(t('recurring.instanceUpdateSuccess')); + setShowEditInstanceModal(false); + loadData(); + } catch (err) { + showToast(err.response?.data?.message || t('common.error'), 'danger'); + } + }; + + const handlePauseResume = (template) => { + setSelectedTemplate(template); + setShowPauseResumeModal(true); + }; + + const submitPauseResume = async () => { + try { + if (selectedTemplate.is_active) { + await recurringService.pauseTemplate(selectedTemplate.id); + showToast(t('recurring.pauseSuccess')); + } else { + await recurringService.resumeTemplate(selectedTemplate.id); + showToast(t('recurring.resumeSuccess')); + } + setShowPauseResumeModal(false); + loadData(); + } catch (err) { + showToast(err.response?.data?.message || t('common.error'), 'danger'); + } + }; + + const handleDelete = (template) => { + setSelectedTemplate(template); + setShowDeleteTemplateModal(true); + }; + + const submitDeleteTemplate = async () => { + try { + await recurringService.deleteTemplate(selectedTemplate.id); + showToast(t('recurring.deleteSuccess')); + setShowDeleteTemplateModal(false); + loadData(); + } catch (err) { + showToast(err.response?.data?.message || t('common.error'), 'danger'); + } + }; + + const handleViewInstances = async (template) => { + setSelectedTemplate(template); + setShowDetailModal(true); + }; + + const handlePayInstance = (instance) => { + setSelectedInstance(instance); + setShowPayModal(true); + }; + + const handleReconcileInstance = async (instance) => { + setSelectedInstance(instance); + try { + const candidatesData = await recurringService.findCandidates(instance.id); + setCandidates(candidatesData); + setShowReconcileModal(true); + } catch (err) { + showToast(err.response?.data?.message || t('common.error'), 'danger'); + } + }; + + const handleSkipInstance = (instance) => { + setSelectedInstance(instance); + setShowSkipInstanceModal(true); + }; + + const submitSkipInstance = async () => { + try { + await recurringService.skipInstance(selectedInstance.id); + showToast(t('recurring.skipSuccess')); + setShowSkipInstanceModal(false); + loadData(); + } catch (err) { + showToast(err.response?.data?.message || t('common.error'), 'danger'); + } + }; + + const handleCancelInstance = (instance) => { + setSelectedInstance(instance); + setShowCancelInstanceModal(true); + }; + + const submitCancelInstance = async () => { + try { + await recurringService.cancelInstance(selectedInstance.id); + showToast(t('recurring.cancelSuccess')); + setShowCancelInstanceModal(false); + loadData(); + } catch (err) { + showToast(err.response?.data?.message || t('common.error'), 'danger'); + } + }; + + const submitDeleteTemplateFromInstance = async () => { + try { + const templateId = selectedInstance.template_id || selectedInstance.template?.id; + await recurringService.deleteTemplate(templateId); + showToast(t('recurring.deleteSuccess')); + setShowCancelInstanceModal(false); + loadData(); + } catch (err) { + showToast(err.response?.data?.message || t('common.error'), 'danger'); + } + }; + + const submitPay = async (data) => { + try { + await recurringService.payInstance(selectedInstance.id, data); + showToast(t('recurring.paySuccess')); + setShowPayModal(false); + loadData(); + } catch (err) { + showToast(err.response?.data?.message || t('common.error'), 'danger'); + } + }; + + const submitReconcile = async (transactionId) => { + try { + await recurringService.reconcileInstance(selectedInstance.id, transactionId); + showToast(t('recurring.reconcileSuccess')); + setShowReconcileModal(false); + loadData(); + } catch (err) { + showToast(err.response?.data?.message || t('common.error'), 'danger'); + } + }; + + // Format helpers + const formatCurrency = (value) => { + return new Intl.NumberFormat('pt-BR', { + style: 'currency', + currency: 'EUR' + }).format(value); + }; + + const formatDate = (date) => { + return new Date(date).toLocaleDateString(); + }; + + const getStatusBadge = (status) => { + const badges = { + pending: 'bg-warning text-dark', + paid: 'bg-success', + skipped: 'bg-secondary', + cancelled: 'bg-danger', + }; + return badges[status] || 'bg-secondary'; + }; + + const getDueDateClass = (instance) => { + if (instance.status !== 'pending') return ''; + const daysUntil = Math.ceil((new Date(instance.due_date) - new Date()) / (1000 * 60 * 60 * 24)); + if (daysUntil < 0) return 'text-danger fw-bold'; + if (daysUntil <= 3) return 'text-warning'; + return ''; + }; + + return ( + <> +
+ {/* Header */} +
+

+ + {t('recurring.title')} +

+ +
+ + {error && ( +
{error}
+ )} + + {/* Tabs */} +
    +
  • + +
  • +
  • + +
  • +
+ + {/* Templates Tab */} + {activeTab === 'templates' && ( + <> + {/* Filters */} +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + {/* Templates List */} + {loading ? ( +
+
+
+ ) : templates.length === 0 ? ( +
+ +

{t('recurring.noTemplates')}

+
+ ) : ( +
+ + + + + + + + + + + + + + {templates.map((template) => ( + + + + + + + + + + ))} + +
{t('common.name')}{t('recurring.frequency')}{t('common.type')}{t('recurring.plannedAmount')}{t('recurring.pendingInstances')}{t('common.status')}{t('common.actions')}
+
{template.name}
+ {template.transaction_description} +
+ {t(`recurring.frequencies.${template.frequency}`, template.frequency)} + {template.frequency_interval > 1 && ( + + (x{template.frequency_interval}) + + )} + + + {t(`transactions.${template.type}`)} + + + {formatCurrency(template.planned_amount)} + + + {template.pending_instances_count || 0} + + + + {template.is_active ? t('common.active') : t('common.inactive')} + + +
+ + + + +
+
+
+ )} + + )} + + {/* Pending Instances Tab */} + {activeTab === 'pending' && ( + <> + {loading ? ( +
+
+
+ ) : pendingInstances.length === 0 ? ( +
+ +

{t('recurring.noPendingInstances')}

+
+ ) : ( +
+ + + + + + + + + + + + + {pendingInstances.map((instance) => ( + + + + + + + + + ))} + +
{t('recurring.templateName')}{t('recurring.occurrenceNumber')}{t('recurring.dueDate')}{t('recurring.plannedAmount')}{t('common.status')}{t('common.actions')}
+
{instance.template?.name}
+ + {instance.template?.account?.name} + +
#{instance.occurrence_number} + {formatDate(instance.due_date)} + {instance.status === 'pending' && ( +
+ {(() => { + const days = Math.ceil((new Date(instance.due_date) - new Date()) / (1000 * 60 * 60 * 24)); + if (days < 0) return {Math.abs(days)} {t('recurring.daysOverdue')}; + if (days === 0) return {t('recurring.dueToday')}; + return {days} {t('recurring.daysUntilDue')}; + })()} +
+ )} +
+ {formatCurrency(instance.planned_amount)} + + + {t(`recurring.status.${instance.status}`)} + + +
+ + + + + +
+
+
+ )} + + )} + + {/* Detail Modal */} + setShowDetailModal(false)} + title={selectedTemplate?.name} + size="lg" + > + {selectedTemplate && ( +
+
+
+

{t('recurring.frequency')}: {t(`recurring.frequencies.${selectedTemplate.frequency}`)}

+

{t('recurring.plannedAmount')}: {formatCurrency(selectedTemplate.planned_amount)}

+

{t('common.type')}: {t(`transactions.${selectedTemplate.type}`)}

+
+
+

{t('recurring.startDate')}: {formatDate(selectedTemplate.start_date)}

+ {selectedTemplate.end_date && ( +

{t('recurring.endDate')}: {formatDate(selectedTemplate.end_date)}

+ )} + {selectedTemplate.max_occurrences && ( +

{t('recurring.maxOccurrences')}: {selectedTemplate.max_occurrences}

+ )} +
+
+ +
{t('recurring.instances')}
+
+ + + + + + + + + + + + {selectedTemplate.instances?.map((instance) => ( + + + + + + + + ))} + +
#{t('recurring.dueDate')}{t('recurring.plannedAmount')}{t('common.status')}{t('common.actions')}
{instance.occurrence_number}{formatDate(instance.due_date)}{formatCurrency(instance.planned_amount)} + + {t(`recurring.status.${instance.status}`)} + + + {instance.status === 'pending' && ( +
+ + + +
+ )} +
+
+
+ )} +
+ + {/* Pay Modal */} + setShowPayModal(false)} + instance={selectedInstance} + onSubmit={submitPay} + formatCurrency={formatCurrency} + t={t} + /> + + {/* Reconcile Modal */} + setShowReconcileModal(false)} + instance={selectedInstance} + candidates={candidates} + onSubmit={submitReconcile} + formatCurrency={formatCurrency} + formatDate={formatDate} + t={t} + /> + + {/* Edit Template Modal */} + setShowEditTemplateModal(false)} + template={selectedTemplate} + accounts={accounts} + categories={categories} + onSubmit={submitEditTemplate} + t={t} + /> + + {/* Edit Instance Modal */} + setShowEditInstanceModal(false)} + instance={selectedInstance} + onSubmit={submitEditInstance} + formatCurrency={formatCurrency} + t={t} + /> + + {/* Delete Template Confirmation Modal */} + setShowDeleteTemplateModal(false)} + onConfirm={submitDeleteTemplate} + template={selectedTemplate} + t={t} + /> + + {/* Skip Instance Confirmation Modal */} + setShowSkipInstanceModal(false)} + onConfirm={submitSkipInstance} + instance={selectedInstance} + t={t} + /> + + {/* Cancel Instance Modal with options */} + setShowCancelInstanceModal(false)} + instance={selectedInstance} + onCancelInstance={submitCancelInstance} + onDeleteTemplate={submitDeleteTemplateFromInstance} + t={t} + /> + + {/* Pause/Resume Confirmation Modal */} + setShowPauseResumeModal(false)} + onConfirm={submitPauseResume} + template={selectedTemplate} + t={t} + /> + + {/* Toast */} + setToast({ ...toast, show: false })} + /> +
+ + ); +}; + +// Sub-component: Pay Instance Modal +const PayInstanceModal = ({ show, onClose, instance, onSubmit, formatCurrency, t }) => { + const [formData, setFormData] = useState({ + amount: '', + effective_date: new Date().toISOString().split('T')[0], + notes: '', + }); + + useEffect(() => { + if (instance) { + setFormData({ + amount: instance.planned_amount, + effective_date: new Date().toISOString().split('T')[0], + notes: '', + }); + } + }, [instance]); + + const handleSubmit = (e) => { + e.preventDefault(); + onSubmit(formData); + }; + + if (!instance) return null; + + return ( + +
+
+
+ {instance.template?.name} +
+ {t('recurring.occurrenceNumber')}{instance.occurrence_number} - + {t('recurring.dueDate')}: {new Date(instance.due_date).toLocaleDateString()} +
+
+ +
+ + setFormData(f => ({ ...f, amount: e.target.value }))} + required + /> + + {t('recurring.plannedAmount')}: {formatCurrency(instance.planned_amount)} + +
+ +
+ + setFormData(f => ({ ...f, effective_date: e.target.value }))} + required + /> +
+ +
+ +