feat: IconSelector no modal de categoria + traducao costCenters.costCenter + categorias UTF-8 corrigidas

This commit is contained in:
CnxiFly Dev 2025-12-13 18:33:14 +01:00
commit 6bb1adeef6
206 changed files with 60141 additions and 0 deletions

View File

@ -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.
═══════════════════════════════════════════════════════════════════════════════

51
.gitignore vendored Normal file
View File

@ -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

234
ANALISE_CONTRATO_PRICE.md Normal file
View File

@ -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*

192
ANALISE_CONTRATO_PRICE.txt Normal file
View File

@ -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
================================================================================

269
APRENDIZADOS_TECNICOS.md Normal file
View File

@ -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
<div className="dropdown">
<button data-bs-toggle="dropdown">Menu</button>
<ul className="dropdown-menu">...</ul>
</div>
// ✅ 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);
}, []);
<div className="dropdown" ref={dropdownRef} style={{ position: 'relative' }}>
<button onClick={() => setIsOpen(!isOpen)}>Menu</button>
<ul
className={`dropdown-menu ${isOpen ? 'show' : ''}`}
style={{
position: 'absolute',
right: 0,
top: '100%',
zIndex: 1000
}}
>
...
</ul>
</div>
```
#### 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 (
<>
<div className="modal-backdrop show" style={{ backgroundColor: 'rgba(0,0,0,0.7)' }} />
<div className="modal show d-block" onClick={(e) => e.target === e.currentTarget && onHide()}>
<div className="modal-dialog modal-dialog-centered">
<div className="modal-content">
<div className="modal-header">
<h5>{title}</h5>
<button className="btn-close" onClick={onHide} />
</div>
<div className="modal-body">{children}</div>
{footer && <div className="modal-footer">{footer}</div>}
</div>
</div>
</div>
</>
);
};
```
### 📝 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*

1650
CHANGELOG.md Normal file

File diff suppressed because it is too large Load Diff

159
CONFIGURACION_LOCAL.md Normal file
View File

@ -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"
```

205
CREDENCIAIS_SERVIDOR.md Normal file
View File

@ -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**

36
DKIM_DNS_RECORD.txt Normal file
View File

@ -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
═══════════════════════════════════════════════════════════════════════════

1139
ESPECIFICACIONES_WEBMONEY.md Normal file

File diff suppressed because it is too large Load Diff

331
ESTRUTURA_PROJETO.md Normal file
View File

@ -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 |
---
## <20><> 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]
```

114
README.md Normal file
View File

@ -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

File diff suppressed because it is too large Load Diff

1
VERSION Normal file
View File

@ -0,0 +1 @@
1.27.2

18
backend/.editorconfig Normal file
View File

@ -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

65
backend/.env.example Normal file
View File

@ -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}"

11
backend/.gitattributes vendored Normal file
View File

@ -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

24
backend/.gitignore vendored Normal file
View File

@ -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

59
backend/README.md Normal file
View File

@ -0,0 +1,59 @@
<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p>
<p align="center">
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
</p>
## 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).

View File

@ -0,0 +1,261 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Account;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\Rule;
class AccountController extends Controller
{
/**
* Listar todas as contas do usuário
*/
public function index(Request $request): JsonResponse
{
$query = Account::where('user_id', Auth::id());
// Filtros opcionais
if ($request->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,
],
]);
}
}

View File

@ -0,0 +1,183 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Services\UserSetupService;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
class AuthController extends Controller
{
/**
* Register a new user
*/
public function register(Request $request): JsonResponse
{
try {
$validator = Validator::make($request->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);
}
}
}

View File

@ -0,0 +1,730 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Category;
use App\Models\CategoryKeyword;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
class CategoryController extends Controller
{
/**
* Listar todas as categorias do usuário (hierárquico)
*/
public function index(Request $request): JsonResponse
{
$query = Category::where('user_id', Auth::id())
->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,
]
]);
}
}

View File

@ -0,0 +1,289 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\CostCenter;
use App\Models\CostCenterKeyword;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
class CostCenterController extends Controller
{
/**
* Listar todos os centros de custo do usuário
*/
public function index(Request $request): JsonResponse
{
$userId = Auth::id();
// Verificar se o usuário tem um centro de custo do sistema
// Se não tiver, criar automaticamente o "Geral"
$hasSystemCostCenter = CostCenter::where('user_id', $userId)
->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,
]);
}
}

View File

@ -0,0 +1,885 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Transaction;
use App\Models\Account;
use App\Models\RecurringInstance;
use App\Models\LiabilityInstallment;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
use Carbon\Carbon;
class DashboardController extends Controller
{
/**
* Fluxo de caixa mensal
* Retorna receitas, despesas e saldo por mês
* Ignora transações marcadas como transferências entre contas
*/
public function cashflow(Request $request): JsonResponse
{
$userId = $request->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,
]);
}
}

View File

@ -0,0 +1,123 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Mail\WelcomeEmail;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Validator;
class EmailTestController extends Controller
{
/**
* Enviar email de teste com boas práticas anti-spam
*
* Este endpoint demonstra:
* - Headers corretos (From, Reply-To, List-Unsubscribe)
* - DKIM (configurado no servidor Postfix)
* - SPF (configurado no DNS)
* - HTML + Plain Text versions
* - Conteúdo bem formatado e sem palavras suspeitas
*/
public function sendTest(Request $request): JsonResponse
{
try {
$validator = Validator::make($request->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);
}
}

View File

@ -0,0 +1,401 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\ImportMapping;
use App\Models\ImportLog;
use App\Services\Import\ImportService;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
class ImportController extends Controller
{
protected ImportService $importService;
public function __construct(ImportService $importService)
{
$this->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,
],
]);
}
}

View File

@ -0,0 +1,686 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\LiabilityAccount;
use App\Models\LiabilityInstallment;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use PhpOffice\PhpSpreadsheet\IOFactory;
class LiabilityAccountController extends Controller
{
/**
* Listar todas as contas passivo do usuário
*/
public function index(Request $request): JsonResponse
{
$query = LiabilityAccount::where('user_id', Auth::id())
->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 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(),
]);
}
}

View File

@ -0,0 +1,489 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\RecurringTemplate;
use App\Models\RecurringInstance;
use App\Models\Transaction;
use App\Services\RecurringService;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Auth;
class RecurringTemplateController extends Controller
{
private RecurringService $recurringService;
public function __construct(RecurringService $recurringService)
{
$this->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,
]);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,831 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Transaction;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
class TransferDetectionController extends Controller
{
/**
* Detecta possíveis transferências entre contas
* Critérios: mesmo valor absoluto, datas próximas, tipos opostos (debit/credit), contas diferentes
*/
public function index(Request $request): JsonResponse
{
$userId = $request->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',
]);
}
}

View File

@ -0,0 +1,10 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
abstract class Controller
{
use AuthorizesRequests;
}

View File

@ -0,0 +1,46 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class SecurityHeaders
{
/**
* Handle an incoming request and add security headers.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
// Prevenir ataques XSS
$response->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;
}
}

View File

@ -0,0 +1,85 @@
<?php
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Mail\Mailables\Address;
use Illuminate\Mail\Mailables\Headers;
use Illuminate\Queue\SerializesModels;
class WelcomeEmail extends Mailable
{
use Queueable, SerializesModels;
public string $userName;
public string $userEmail;
/**
* Create a new message instance.
*/
public function __construct(string $userName, string $userEmail)
{
$this->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' => '<mailto:support@cnxifly.com?subject=unsubscribe>',
'List-Unsubscribe-Post' => 'List-Unsubscribe=One-Click',
],
);
}
/**
* Get the attachments for the message.
*
* @return array<int, \Illuminate\Mail\Mailables\Attachment>
*/
public function attachments(): array
{
return [];
}
}

View File

@ -0,0 +1,152 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Account extends Model
{
use HasFactory, SoftDeletes;
/**
* Tipos de conta disponíveis
*/
public const TYPE_CASH = 'cash';
public const TYPE_CHECKING = 'checking';
public const TYPE_SAVINGS = 'savings';
public const TYPE_CREDIT_CARD = 'credit_card';
public const TYPE_ASSET = 'asset';
public const TYPE_LIABILITY = 'liability';
public const TYPES = [
self::TYPE_CASH => '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;
}
}

View File

@ -0,0 +1,153 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Category extends Model
{
use HasFactory, SoftDeletes;
/**
* Tipos de categoria
*/
public const TYPE_INCOME = 'income';
public const TYPE_EXPENSE = 'expense';
public const TYPE_BOTH = 'both';
public const TYPES = [
self::TYPE_INCOME => '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;
}
}

View File

@ -0,0 +1,51 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class CategoryKeyword extends Model
{
use HasFactory;
protected $fillable = [
'category_id',
'keyword',
'is_case_sensitive',
'is_active',
];
protected $casts = [
'is_case_sensitive' => '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));
}
}

View File

@ -0,0 +1,78 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class CostCenter extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = [
'user_id',
'name',
'code',
'description',
'color',
'icon',
'is_active',
'is_system',
];
protected $casts = [
'is_active' => '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);
}
}

View File

@ -0,0 +1,51 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class CostCenterKeyword extends Model
{
use HasFactory;
protected $fillable = [
'cost_center_id',
'keyword',
'is_case_sensitive',
'is_active',
];
protected $casts = [
'is_case_sensitive' => '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));
}
}

View File

@ -0,0 +1,86 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ImportLog extends Model
{
protected $fillable = [
'user_id',
'import_mapping_id',
'original_filename',
'file_type',
'total_rows',
'imported_rows',
'skipped_rows',
'error_rows',
'errors',
'status',
];
protected $casts = [
'errors' => '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);
}
}

View File

@ -0,0 +1,124 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class ImportMapping extends Model
{
protected $fillable = [
'user_id',
'name',
'bank_name',
'file_type',
'header_row',
'data_start_row',
'date_format',
'decimal_separator',
'thousands_separator',
'column_mappings',
'default_account_id',
'default_category_id',
'default_cost_center_id',
'is_active',
];
protected $casts = [
'column_mappings' => '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);
}
}

View File

@ -0,0 +1,238 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class LiabilityAccount extends Model
{
use HasFactory, SoftDeletes;
/**
* Status do contrato
*/
public const STATUS_ACTIVE = 'active';
public const STATUS_PAID_OFF = 'paid_off';
public const STATUS_DEFAULTED = 'defaulted';
public const STATUS_RENEGOTIATED = 'renegotiated';
public const STATUSES = [
self::STATUS_ACTIVE => '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 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);
}
}

View File

@ -0,0 +1,147 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class LiabilityInstallment extends Model
{
use HasFactory, SoftDeletes;
/**
* Status da parcela
*/
public const STATUS_PENDING = 'pending';
public const STATUS_PAID = 'paid';
public const STATUS_PARTIAL = 'partial';
public const STATUS_OVERDUE = 'overdue';
public const STATUS_CANCELLED = 'cancelled';
public const STATUSES = [
self::STATUS_PENDING => '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());
});
}
}

View File

@ -0,0 +1,149 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class RecurringInstance extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = [
'user_id',
'recurring_template_id',
'occurrence_number',
'due_date',
'planned_amount',
'status',
'transaction_id',
'paid_at',
'paid_amount',
'paid_notes',
];
protected $casts = [
'due_date' => '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,
};
}
}

View File

@ -0,0 +1,169 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class RecurringTemplate extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = [
'user_id',
'source_transaction_id',
'name',
'description',
'frequency',
'frequency_interval',
'day_of_month',
'day_of_week',
'start_date',
'end_date',
'max_occurrences',
'account_id',
'category_id',
'cost_center_id',
'type',
'planned_amount',
'transaction_description',
'notes',
'is_active',
'last_generated_date',
'occurrences_generated',
];
protected $casts = [
'start_date' => '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');
}
}

View File

@ -0,0 +1,323 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Transaction extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = [
'user_id',
'account_id',
'category_id',
'cost_center_id',
'amount',
'planned_amount',
'type',
'description',
'original_description',
'notes',
'effective_date',
'planned_date',
'status',
'reference',
'import_hash',
'import_log_id',
'is_recurring',
'recurring_parent_id',
'recurring_instance_id',
'transfer_pair_id',
'parent_transaction_id',
'is_split_child',
'is_split_parent',
'duplicate_ignored_with',
'is_transfer',
'transfer_linked_id',
'is_refund_pair',
'refund_linked_id',
];
protected $casts = [
'amount' => '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 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);
}
}

View File

@ -0,0 +1,59 @@
<?php
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable
{
/** @use HasFactory<\Database\Factories\UserFactory> */
use HasFactory, Notifiable, HasApiTokens;
/**
* The attributes that are mass assignable.
*
* @var list<string>
*/
protected $fillable = [
'name',
'email',
'password',
'is_admin',
];
/**
* The attributes that should be hidden for serialization.
*
* @var list<string>
*/
protected $hidden = [
'password',
'remember_token',
];
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
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;
}
}

View File

@ -0,0 +1,49 @@
<?php
namespace App\Policies;
use App\Models\RecurringTemplate;
use App\Models\User;
class RecurringTemplatePolicy
{
/**
* Determine whether the user can view any models.
*/
public function viewAny(User $user): bool
{
return true;
}
/**
* Determine whether the user can view the model.
*/
public function view(User $user, RecurringTemplate $recurringTemplate): bool
{
return $user->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;
}
}

View File

@ -0,0 +1,49 @@
<?php
namespace App\Providers;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
//
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
// Rate limiting para autenticação (proteção contra brute force)
RateLimiter::for('login', function (Request $request) {
return Limit::perMinute(5)->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());
});
}
}

View File

@ -0,0 +1,221 @@
<?php
namespace App\Services\Import;
class CsvParser implements FileParserInterface
{
protected static array $supportedExtensions = ['csv', 'txt'];
/**
* Parse the CSV file and return all data
*/
public function parse(string $filePath, array $options = []): array
{
$headerRow = $options['header_row'] ?? 0;
$dataStartRow = $options['data_start_row'] ?? 1;
$delimiter = $options['delimiter'] ?? $this->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);
}
}

View File

@ -0,0 +1,164 @@
<?php
namespace App\Services\Import;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Reader\Exception as ReaderException;
class ExcelParser implements FileParserInterface
{
protected static array $supportedExtensions = ['xlsx', 'xls'];
/**
* Parse the Excel file and return all data
*/
public function parse(string $filePath, array $options = []): array
{
$headerRow = $options['header_row'] ?? 0;
$dataStartRow = $options['data_start_row'] ?? 1;
$spreadsheet = IOFactory::load($filePath);
$worksheet = $spreadsheet->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);
}
}

View File

@ -0,0 +1,26 @@
<?php
namespace App\Services\Import;
interface FileParserInterface
{
/**
* Parse the file and return raw data
*/
public function parse(string $filePath, array $options = []): array;
/**
* Get headers from the file
*/
public function getHeaders(string $filePath, array $options = []): array;
/**
* Get preview data (first N rows)
*/
public function getPreview(string $filePath, int $rows = 10, array $options = []): array;
/**
* Check if the parser supports the given file type
*/
public static function supports(string $extension): bool;
}

View File

@ -0,0 +1,530 @@
<?php
namespace App\Services\Import;
use App\Models\ImportMapping;
use App\Models\ImportLog;
use App\Models\Transaction;
use App\Models\Category;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class ImportService
{
protected array $parsers = [
ExcelParser::class,
CsvParser::class,
OfxParser::class,
PdfParser::class,
];
/**
* Get appropriate parser for file type
*/
public function getParser(string $extension): FileParserInterface
{
foreach ($this->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;
}
}

View File

@ -0,0 +1,249 @@
<?php
namespace App\Services\Import;
class OfxParser implements FileParserInterface
{
protected static array $supportedExtensions = ['ofx', 'qfx'];
/**
* Parse OFX file and return all transactions
*/
public function parse(string $filePath, array $options = []): array
{
$content = file_get_contents($filePath);
if ($content === false) {
throw new \RuntimeException("Could not read file: $filePath");
}
// Parse bank account info
$accountInfo = $this->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('/<BANKID>([^<\n]+)/i', $content, $matches)) {
$info['bank_id'] = trim($matches[1]);
}
// Account ID
if (preg_match('/<ACCTID>([^<\n]+)/i', $content, $matches)) {
$info['account_id'] = trim($matches[1]);
}
// Account Type
if (preg_match('/<ACCTTYPE>([^<\n]+)/i', $content, $matches)) {
$info['account_type'] = trim($matches[1]);
}
// Currency
if (preg_match('/<CURDEF>([^<\n]+)/i', $content, $matches)) {
$info['currency'] = trim($matches[1]);
}
// Balance
if (preg_match('/<BALAMT>([^<\n]+)/i', $content, $matches)) {
$info['balance'] = floatval(trim($matches[1]));
}
// Balance Date
if (preg_match('/<DTASOF>([^<\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>(.*?)<\/STMTTRN>/is', $content, $matches);
// Também tentar sem tag de fechamento (OFX SGML)
if (empty($matches[1])) {
// Split by STMTTRN tags
$parts = preg_split('/<STMTTRN>/i', $content);
array_shift($parts); // Remover parte antes do primeiro STMTTRN
foreach ($parts as $part) {
// Encontrar fim da transação
$endPos = stripos($part, '</STMTTRN>');
if ($endPos !== false) {
$part = substr($part, 0, $endPos);
} else {
// Tentar encontrar próximo STMTTRN ou fim de lista
$nextPos = stripos($part, '<STMTTRN>');
if ($nextPos !== false) {
$part = substr($part, 0, $nextPos);
}
$endListPos = stripos($part, '</BANKTRANLIST>');
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('/<TRNTYPE>([^<\n]+)/i', $block, $matches)) {
$txn['type'] = trim($matches[1]);
}
// Date Posted
if (preg_match('/<DTPOSTED>([^<\n]+)/i', $block, $matches)) {
$txn['date'] = $this->parseOfxDate(trim($matches[1]));
}
// Amount
if (preg_match('/<TRNAMT>([^<\n]+)/i', $block, $matches)) {
$txn['amount'] = floatval(str_replace(',', '.', trim($matches[1])));
}
// FIT ID (unique identifier)
if (preg_match('/<FITID>([^<\n]+)/i', $block, $matches)) {
$txn['fitid'] = trim($matches[1]);
}
// Name/Payee
if (preg_match('/<NAME>([^<\n]+)/i', $block, $matches)) {
$txn['name'] = trim($matches[1]);
}
// Memo
if (preg_match('/<MEMO>([^<\n]+)/i', $block, $matches)) {
$txn['memo'] = trim($matches[1]);
}
// Check Number
if (preg_match('/<CHECKNUM>([^<\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);
}
}

View File

@ -0,0 +1,194 @@
<?php
namespace App\Services\Import;
use Smalot\PdfParser\Parser as PdfParserLib;
class PdfParser implements FileParserInterface
{
protected static array $supportedExtensions = ['pdf'];
/**
* Parse PDF file - extrai texto e tenta identificar tabelas
*/
public function parse(string $filePath, array $options = []): array
{
$headerRow = $options['header_row'] ?? 0;
$dataStartRow = $options['data_start_row'] ?? 1;
// Extrair texto do PDF
$lines = $this->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);
}
}

View File

@ -0,0 +1,393 @@
<?php
namespace App\Services;
use App\Models\RecurringTemplate;
use App\Models\RecurringInstance;
use App\Models\Transaction;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
class RecurringService
{
/**
* Horizonte padrão de geração (em meses)
*/
private const DEFAULT_HORIZON_MONTHS = 12;
/**
* Cria um template de recorrência a partir de uma transação existente
*/
public function createFromTransaction(
Transaction $transaction,
string $frequency,
array $options = []
): RecurringTemplate {
$template = RecurringTemplate::create([
'user_id' => $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();
}
}

View File

@ -0,0 +1,310 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\DB;
use Carbon\Carbon;
/**
* Serviço para configurar dados iniciais de novos usuários
* Cria categorias, subcategorias e keywords padrão
*/
class UserSetupService
{
private $userId;
private $now;
private $categoryId = 0;
/**
* Criar todas as categorias padrão para um novo usuário
*/
public function createDefaultCategories(int $userId): void
{
$this->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);
}
}

18
backend/artisan Normal file
View File

@ -0,0 +1,18 @@
#!/usr/bin/env php
<?php
use Illuminate\Foundation\Application;
use Symfony\Component\Console\Input\ArgvInput;
define('LARAVEL_START', microtime(true));
// Register the Composer autoloader...
require __DIR__.'/vendor/autoload.php';
// Bootstrap Laravel and handle the command...
/** @var Application $app */
$app = require_once __DIR__.'/bootstrap/app.php';
$status = $app->handleCommand(new ArgvInput);
exit($status);

25
backend/bootstrap/app.php Normal file
View File

@ -0,0 +1,25 @@
<?php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__))
->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();

2
backend/bootstrap/cache/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*
!.gitignore

View File

@ -0,0 +1,5 @@
<?php
return [
App\Providers\AppServiceProvider::class,
];

88
backend/composer.json Normal file
View File

@ -0,0 +1,88 @@
{
"$schema": "https://getcomposer.org/schema.json",
"name": "laravel/laravel",
"type": "project",
"description": "The skeleton application for the Laravel framework.",
"keywords": ["laravel", "framework"],
"license": "MIT",
"require": {
"php": "^8.2",
"laravel/framework": "^12.0",
"laravel/sanctum": "^4.0",
"laravel/tinker": "^2.10.1",
"phpoffice/phpspreadsheet": "^5.3"
},
"require-dev": {
"fakerphp/faker": "^1.23",
"laravel/pail": "^1.2.2",
"laravel/pint": "^1.24",
"laravel/sail": "^1.41",
"mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.6",
"phpunit/phpunit": "^11.5.3"
},
"autoload": {
"psr-4": {
"App\\": "app/",
"Database\\Factories\\": "database/factories/",
"Database\\Seeders\\": "database/seeders/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"scripts": {
"setup": [
"composer install",
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
"@php artisan key:generate",
"@php artisan migrate --force",
"npm install",
"npm run build"
],
"dev": [
"Composer\\Config::disableProcessTimeout",
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others"
],
"test": [
"@php artisan config:clear --ansi",
"@php artisan test"
],
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi"
],
"post-update-cmd": [
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
],
"post-root-package-install": [
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
],
"post-create-project-cmd": [
"@php artisan key:generate --ansi",
"@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"",
"@php artisan migrate --graceful --ansi"
],
"pre-package-uninstall": [
"Illuminate\\Foundation\\ComposerScripts::prePackageUninstall"
]
},
"extra": {
"laravel": {
"dont-discover": []
}
},
"config": {
"optimize-autoloader": true,
"preferred-install": "dist",
"sort-packages": true,
"allow-plugins": {
"pestphp/pest-plugin": true,
"php-http/discovery": true
}
},
"minimum-stability": "stable",
"prefer-stable": true
}

8812
backend/composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

126
backend/config/app.php Normal file
View File

@ -0,0 +1,126 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Application Name
|--------------------------------------------------------------------------
|
| This value is the name of your application, which will be used when the
| framework needs to place the application's name in a notification or
| other UI elements where an application name needs to be displayed.
|
*/
'name' => 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'),
],
];

115
backend/config/auth.php Normal file
View File

@ -0,0 +1,115 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Authentication Defaults
|--------------------------------------------------------------------------
|
| This option defines the default authentication "guard" and password
| reset "broker" for your application. You may change these values
| as required, but they're a perfect start for most applications.
|
*/
'defaults' => [
'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),
];

117
backend/config/cache.php Normal file
View File

@ -0,0 +1,117 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Default Cache Store
|--------------------------------------------------------------------------
|
| This option controls the default cache store that will be used by the
| framework. This connection is utilized if another isn't explicitly
| specified when running a cache operation inside the application.
|
*/
'default' => 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-'),
];

34
backend/config/cors.php Normal file
View File

@ -0,0 +1,34 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Cross-Origin Resource Sharing (CORS) Configuration
|--------------------------------------------------------------------------
|
| Here you may configure your settings for cross-origin resource sharing
| or "CORS". This determines what cross-origin operations may execute
| in web browsers. You are free to adjust these settings as needed.
|
| To learn more: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
|
*/
'paths' => ['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,
];

183
backend/config/database.php Normal file
View File

@ -0,0 +1,183 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Default Database Connection Name
|--------------------------------------------------------------------------
|
| Here you may specify which of the database connections below you wish
| to use as your default connection for database operations. This is
| the connection which will be utilized unless another connection
| is explicitly specified when you execute a query / statement.
|
*/
'default' => 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),
],
],
];

View File

@ -0,0 +1,80 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Filesystem Disk
|--------------------------------------------------------------------------
|
| Here you may specify the default filesystem disk that should be used
| by the framework. The "local" disk, as well as a variety of cloud
| based disks are available to your application for file storage.
|
*/
'default' => 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'),
],
];

132
backend/config/logging.php Normal file
View File

@ -0,0 +1,132 @@
<?php
use Monolog\Handler\NullHandler;
use Monolog\Handler\StreamHandler;
use Monolog\Handler\SyslogUdpHandler;
use Monolog\Processor\PsrLogMessageProcessor;
return [
/*
|--------------------------------------------------------------------------
| Default Log Channel
|--------------------------------------------------------------------------
|
| This option defines the default log channel that is utilized to write
| messages to your logs. The value provided here should match one of
| the channels present in the list of "channels" configured below.
|
*/
'default' => 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'),
],
],
];

118
backend/config/mail.php Normal file
View File

@ -0,0 +1,118 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Mailer
|--------------------------------------------------------------------------
|
| This option controls the default mailer that is used to send all email
| messages unless another mailer is explicitly specified when sending
| the message. All additional mailers can be configured within the
| "mailers" array. Examples of each type of mailer are provided.
|
*/
'default' => 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'),
],
];

129
backend/config/queue.php Normal file
View File

@ -0,0 +1,129 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Queue Connection Name
|--------------------------------------------------------------------------
|
| Laravel's queue supports a variety of backends via a single, unified
| API, giving you convenient access to each backend using identical
| syntax for each. The default queue connection is defined below.
|
*/
'default' => 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',
],
];

View File

@ -0,0 +1,84 @@
<?php
use Laravel\Sanctum\Sanctum;
return [
/*
|--------------------------------------------------------------------------
| Stateful Domains
|--------------------------------------------------------------------------
|
| Requests from the following domains / hosts will receive stateful API
| authentication cookies. Typically, these should include your local
| and production domains which access your API via a frontend SPA.
|
*/
'stateful' => 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,
],
];

View File

@ -0,0 +1,38 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Third Party Services
|--------------------------------------------------------------------------
|
| This file is for storing the credentials for third party services such
| as Mailgun, Postmark, AWS and more. This file provides the de facto
| location for this type of information, allowing packages to have
| a conventional file to locate the various service credentials.
|
*/
'postmark' => [
'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'),
],
],
];

217
backend/config/session.php Normal file
View File

@ -0,0 +1,217 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Default Session Driver
|--------------------------------------------------------------------------
|
| This option determines the default session driver that is utilized for
| incoming requests. Laravel supports a variety of storage options to
| persist session data. Database storage is a great default choice.
|
| Supported: "file", "cookie", "database", "memcached",
| "redis", "dynamodb", "array"
|
*/
'driver' => 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),
];

1
backend/database/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
*.sqlite*

View File

@ -0,0 +1,44 @@
<?php
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\User>
*/
class UserFactory extends Factory
{
/**
* The current password being used by the factory.
*/
protected static ?string $password;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
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,
]);
}
}

View File

@ -0,0 +1,49 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('users', function (Blueprint $table) {
$table->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');
}
};

View File

@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('cache', function (Blueprint $table) {
$table->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');
}
};

View File

@ -0,0 +1,57 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('jobs', function (Blueprint $table) {
$table->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');
}
};

View File

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('personal_access_tokens', function (Blueprint $table) {
$table->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');
}
};

View File

@ -0,0 +1,49 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Tipos de conta:
* - cash: Dinheiro em espécie
* - checking: Conta Corrente
* - savings: Conta Poupança
* - credit_card: Cartão de Crédito
* - asset: Ativos (investimentos, bens)
* - liability: Passivos (dívidas, empréstimos)
*/
public function up(): void
{
Schema::create('accounts', function (Blueprint $table) {
$table->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');
}
};

View File

@ -0,0 +1,52 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Centros de Custo - para categorizar gastos por projeto/departamento
* Inclui palavras-chave para aplicação automática em lote
*/
public function up(): void
{
Schema::create('cost_centers', function (Blueprint $table) {
$table->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');
}
};

View File

@ -0,0 +1,57 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Categorias e Sub-Categorias para transações
* Estrutura hierárquica: Categoria pai -> 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');
}
};

View File

@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Adiciona campo is_system para marcar o centro de custo padrão do usuário
* Cada usuário tem um centro de custo "Geral" que não pode ser excluído
*/
public function up(): void
{
Schema::table('cost_centers', function (Blueprint $table) {
$table->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');
});
}
};

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Adiciona campo is_admin para identificar usuários administradores
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->boolean('is_admin')->default(false)->after('email');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('is_admin');
});
}
};

View File

@ -0,0 +1,59 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Migration para adicionar campos de transferência e divisão de transações
*
* Transferências: Uma transação de débito em conta A vinculada a crédito em conta B
* Divisão: Uma transação pode ter várias categorias (split transactions)
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('transactions', function (Blueprint $table) {
// Campo para vincular transações de transferência
// A transação de débito aponta para a transação de crédito correspondente
$table->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',
]);
});
}
};

View File

@ -0,0 +1,55 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// Adicionar campos extras para transferências na tabela transactions
Schema::table('transactions', function (Blueprint $table) {
if (!Schema::hasColumn('transactions', 'is_transfer')) {
$table->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');
}
};

View File

@ -0,0 +1,80 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Contas Passivo (Empréstimos, Financiamentos)
*
* Esta tabela armazena os contratos de passivo com todos os dados calculados:
* - Valor emprestado (principal)
* - Total de juros do contrato
* - Total pago
* - Total pendente
* - Taxas de juros calculadas (mensal, anual)
*/
public function up(): void
{
Schema::create('liability_accounts', function (Blueprint $table) {
$table->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');
}
};

View File

@ -0,0 +1,63 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Parcelas de Contas Passivo
*
* Cada parcela tem seu próprio registro com:
* - Data de vencimento
* - Valor da parcela
* - Composição (capital + juros + taxas)
* - Estado (pendente, pago, atrasado)
* - Transações associadas (para conciliação futura)
*/
public function up(): void
{
Schema::create('liability_installments', function (Blueprint $table) {
$table->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');
}
};

View File

@ -0,0 +1,63 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Tabela de Transações
* Registra todas as movimentações financeiras (créditos e débitos)
*/
public function up(): void
{
Schema::create('transactions', function (Blueprint $table) {
$table->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');
}
};

View File

@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Adiciona campo para marcar transações como não-duplicatas
* Quando duas transações são marcadas como "ignorar duplicidade",
* cada uma guarda o ID da outra neste campo
*/
public function up(): void
{
Schema::table('transactions', function (Blueprint $table) {
// ID da transação que foi comparada e marcada como "não é duplicata"
// Permite múltiplos IDs separados por vírgula para casos com várias possíveis duplicatas
$table->text('duplicate_ignored_with')->nullable()->after('import_hash');
});
}
public function down(): void
{
Schema::table('transactions', function (Blueprint $table) {
$table->dropColumn('duplicate_ignored_with');
});
}
};

View File

@ -0,0 +1,73 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('import_mappings', function (Blueprint $table) {
$table->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');
}
};

View File

@ -0,0 +1,41 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Adiciona campos para controle de duplicidade na importação
* - original_description: descrição original do banco (read-only)
* - balance_after: saldo após a transação (usado no hash)
* - import_hash: hash único para evitar duplicidade (data + valor + saldo + descrição original)
*/
public function up(): void
{
Schema::table('transactions', function (Blueprint $table) {
// Descrição original do banco (read-only, para hash e referência)
$table->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']);
});
}
};

View File

@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Remove o campo balance_after da tabela transactions
* O saldo deve ser sempre calculado dinamicamente para evitar erros de cálculo
*/
public function up(): void
{
Schema::table('transactions', function (Blueprint $table) {
$table->dropColumn('balance_after');
});
}
public function down(): void
{
Schema::table('transactions', function (Blueprint $table) {
$table->decimal('balance_after', 15, 2)->nullable()->after('amount');
});
}
};

View File

@ -0,0 +1,173 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Otimizações de banco de dados para escalabilidade futura
*/
return new class extends Migration
{
private function indexExists(string $table, string $indexName): bool
{
$result = DB::select("SHOW INDEX FROM {$table} WHERE Key_name = ?", [$indexName]);
return count($result) > 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");
}
};

View File

@ -0,0 +1,75 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('recurring_templates', function (Blueprint $table) {
$table->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');
}
};

View File

@ -0,0 +1,71 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('recurring_instances', function (Blueprint $table) {
$table->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');
}
};

View File

@ -0,0 +1,46 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// Adicionar campos de reembolso na tabela transactions
Schema::table('transactions', function (Blueprint $table) {
$table->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']);
});
}
};

View File

@ -0,0 +1,354 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
use Carbon\Carbon;
/**
* Seeder de Categorias SEM Keywords
* Cores e ícones variados para cada categoria
*/
class CategoriesOnlySeeder extends Seeder
{
private $userId;
private $now;
private $categoryId = 0;
public function run(): void
{
$this->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,
]);
}
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace Database\Seeders;
use App\Models\User;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder
{
use WithoutModelEvents;
/**
* Seed the application's database.
*/
public function run(): void
{
// User::factory(10)->create();
User::factory()->create([
'name' => 'Test User',
'email' => 'test@example.com',
]);
}
}

Some files were not shown because too many files have changed in this diff Show More