feat: IconSelector no modal de categoria + traducao costCenters.costCenter + categorias UTF-8 corrigidas
This commit is contained in:
commit
6bb1adeef6
211
.DIRETRIZES_DESENVOLVIMENTO_v3
Normal file
211
.DIRETRIZES_DESENVOLVIMENTO_v3
Normal 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
51
.gitignore
vendored
Normal 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
234
ANALISE_CONTRATO_PRICE.md
Normal 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
192
ANALISE_CONTRATO_PRICE.txt
Normal 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
269
APRENDIZADOS_TECNICOS.md
Normal 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
1650
CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load Diff
159
CONFIGURACION_LOCAL.md
Normal file
159
CONFIGURACION_LOCAL.md
Normal 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
205
CREDENCIAIS_SERVIDOR.md
Normal 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
36
DKIM_DNS_RECORD.txt
Normal 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
1139
ESPECIFICACIONES_WEBMONEY.md
Normal file
File diff suppressed because it is too large
Load Diff
331
ESTRUTURA_PROJETO.md
Normal file
331
ESTRUTURA_PROJETO.md
Normal 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
114
README.md
Normal 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
|
||||
2633
ROTEIRO_INSTALACAO_SERVIDOR.md
Normal file
2633
ROTEIRO_INSTALACAO_SERVIDOR.md
Normal file
File diff suppressed because it is too large
Load Diff
18
backend/.editorconfig
Normal file
18
backend/.editorconfig
Normal 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
65
backend/.env.example
Normal 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
11
backend/.gitattributes
vendored
Normal 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
24
backend/.gitignore
vendored
Normal 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
59
backend/README.md
Normal 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).
|
||||
261
backend/app/Http/Controllers/Api/AccountController.php
Normal file
261
backend/app/Http/Controllers/Api/AccountController.php
Normal 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,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
183
backend/app/Http/Controllers/Api/AuthController.php
Normal file
183
backend/app/Http/Controllers/Api/AuthController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
730
backend/app/Http/Controllers/Api/CategoryController.php
Normal file
730
backend/app/Http/Controllers/Api/CategoryController.php
Normal 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,
|
||||
]
|
||||
]);
|
||||
}
|
||||
}
|
||||
289
backend/app/Http/Controllers/Api/CostCenterController.php
Normal file
289
backend/app/Http/Controllers/Api/CostCenterController.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
885
backend/app/Http/Controllers/Api/DashboardController.php
Normal file
885
backend/app/Http/Controllers/Api/DashboardController.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
123
backend/app/Http/Controllers/Api/EmailTestController.php
Normal file
123
backend/app/Http/Controllers/Api/EmailTestController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
401
backend/app/Http/Controllers/Api/ImportController.php
Normal file
401
backend/app/Http/Controllers/Api/ImportController.php
Normal 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,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
686
backend/app/Http/Controllers/Api/LiabilityAccountController.php
Normal file
686
backend/app/Http/Controllers/Api/LiabilityAccountController.php
Normal 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 já registrada
|
||||
*/
|
||||
public function reconcile(Request $request, int $accountId, int $installmentId): JsonResponse
|
||||
{
|
||||
$account = LiabilityAccount::where('user_id', Auth::id())->findOrFail($accountId);
|
||||
$installment = LiabilityInstallment::where('liability_account_id', $account->id)
|
||||
->findOrFail($installmentId);
|
||||
|
||||
$validated = $request->validate([
|
||||
'transaction_id' => 'required|exists:transactions,id',
|
||||
'mark_as_paid' => 'nullable|boolean',
|
||||
]);
|
||||
|
||||
// Verificar se a transação pertence ao usuário
|
||||
$transaction = \App\Models\Transaction::where('user_id', Auth::id())
|
||||
->findOrFail($validated['transaction_id']);
|
||||
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
|
||||
// Atualizar parcela com referência à transação
|
||||
$installment->reconciled_transaction_id = $transaction->id;
|
||||
$installment->payment_account_id = $transaction->account_id;
|
||||
|
||||
// Opcionalmente marcar como paga
|
||||
if ($request->boolean('mark_as_paid', true)) {
|
||||
$installment->status = LiabilityInstallment::STATUS_PAID;
|
||||
$installment->paid_amount = abs($transaction->amount);
|
||||
$installment->paid_date = $transaction->date;
|
||||
}
|
||||
|
||||
$installment->save();
|
||||
|
||||
// Recalcular totais da conta passivo
|
||||
$account->recalculateTotals();
|
||||
|
||||
DB::commit();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Parcela conciliada com sucesso',
|
||||
'data' => $installment->fresh()->load('liabilityAccount'),
|
||||
]);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Erro ao conciliar: ' . $e->getMessage(),
|
||||
], 422);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remover conciliação de uma parcela
|
||||
*/
|
||||
public function unreconcile(int $accountId, int $installmentId): JsonResponse
|
||||
{
|
||||
$account = LiabilityAccount::where('user_id', Auth::id())->findOrFail($accountId);
|
||||
$installment = LiabilityInstallment::where('liability_account_id', $account->id)
|
||||
->findOrFail($installmentId);
|
||||
|
||||
if (!$installment->reconciled_transaction_id) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Parcela não está conciliada',
|
||||
], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
|
||||
// Calcular o sobrepagamento que foi registrado (paid_amount - installment_amount)
|
||||
$paidAmount = (float) $installment->paid_amount;
|
||||
$plannedAmount = (float) $installment->installment_amount;
|
||||
$overpaymentToRemove = max(0, $paidAmount - $plannedAmount);
|
||||
|
||||
// Remover referência à transação
|
||||
$installment->reconciled_transaction_id = null;
|
||||
$installment->status = LiabilityInstallment::STATUS_PENDING;
|
||||
$installment->paid_amount = 0;
|
||||
$installment->paid_date = null;
|
||||
|
||||
// Remover o cargo extra (sobrepagamento) que foi adicionado na conciliação
|
||||
if ($overpaymentToRemove > 0 && $installment->fee_amount >= $overpaymentToRemove) {
|
||||
$installment->fee_amount = $installment->fee_amount - $overpaymentToRemove;
|
||||
}
|
||||
|
||||
$installment->save();
|
||||
|
||||
// Recalcular totais
|
||||
$account->recalculateTotals();
|
||||
|
||||
DB::commit();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Conciliação removida com sucesso',
|
||||
'data' => $installment->fresh(),
|
||||
'fee_removed' => $overpaymentToRemove > 0 ? $overpaymentToRemove : null,
|
||||
]);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Erro ao remover conciliação: ' . $e->getMessage(),
|
||||
], 422);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Buscar transações elegíveis para conciliação
|
||||
*
|
||||
* Retorna transações que podem ser vinculadas a uma parcela
|
||||
* Ordenadas por similaridade de valor com a parcela
|
||||
*/
|
||||
public function eligibleTransactions(Request $request, int $accountId, int $installmentId): JsonResponse
|
||||
{
|
||||
$account = LiabilityAccount::where('user_id', Auth::id())->findOrFail($accountId);
|
||||
$installment = LiabilityInstallment::where('liability_account_id', $account->id)
|
||||
->findOrFail($installmentId);
|
||||
|
||||
// Buscar transações dentro de uma janela de tempo (+/- 45 dias da data de vencimento)
|
||||
// Janela ampla para capturar pagamentos atrasados ou antecipados
|
||||
$startDate = (clone $installment->due_date)->subDays(45);
|
||||
$endDate = (clone $installment->due_date)->addDays(45);
|
||||
|
||||
$installmentAmount = (float) $installment->installment_amount;
|
||||
|
||||
// Usar effective_date se existir, senão planned_date
|
||||
$query = \App\Models\Transaction::where('user_id', Auth::id())
|
||||
->where(function ($q) use ($startDate, $endDate) {
|
||||
$q->whereBetween('effective_date', [$startDate, $endDate])
|
||||
->orWhere(function ($q2) use ($startDate, $endDate) {
|
||||
$q2->whereNull('effective_date')
|
||||
->whereBetween('planned_date', [$startDate, $endDate]);
|
||||
});
|
||||
})
|
||||
->where('type', 'debit') // Pagamentos são débitos (saídas)
|
||||
->with('account:id,name,currency');
|
||||
|
||||
// Por padrão, filtrar por valores próximos (±20% do valor da parcela)
|
||||
// Permite encontrar transações mesmo com pequenas diferenças
|
||||
$minAmount = $installmentAmount * 0.8;
|
||||
$maxAmount = $installmentAmount * 1.2;
|
||||
|
||||
// Se strict_amount = false ou não informado, ainda assim filtrar por faixa
|
||||
// Usa COALESCE para considerar amount ou planned_amount
|
||||
if (!$request->has('no_amount_filter')) {
|
||||
$query->whereRaw('COALESCE(amount, planned_amount) BETWEEN ? AND ?', [$minAmount, $maxAmount]);
|
||||
}
|
||||
|
||||
// Se tiver filtro por conta específica
|
||||
if ($request->has('account_id')) {
|
||||
$query->where('account_id', $request->account_id);
|
||||
}
|
||||
|
||||
// Busca por descrição
|
||||
if ($request->has('search')) {
|
||||
$query->where(function ($q) use ($request) {
|
||||
$q->where('description', 'like', '%' . $request->search . '%')
|
||||
->orWhere('original_description', 'like', '%' . $request->search . '%');
|
||||
});
|
||||
}
|
||||
|
||||
// Ordenar por similaridade de valor (mais próximo primeiro) e depois por data
|
||||
// ABS(COALESCE(amount, planned_amount) - valor_parcela) = diferença absoluta
|
||||
$query->orderByRaw("ABS(COALESCE(amount, planned_amount) - ?) ASC", [$installmentAmount])
|
||||
->orderByRaw('COALESCE(effective_date, planned_date) DESC');
|
||||
|
||||
$transactions = $query->limit(30)->get();
|
||||
|
||||
// Adicionar campo de diferença percentual para cada transação
|
||||
$transactions->transform(function ($transaction) use ($installmentAmount) {
|
||||
$transactionAmount = (float) ($transaction->amount ?? $transaction->planned_amount);
|
||||
$diff = abs($transactionAmount - $installmentAmount);
|
||||
$diffPercent = $installmentAmount > 0 ? ($diff / $installmentAmount) * 100 : 0;
|
||||
$transaction->amount_difference = round($diff, 2);
|
||||
$transaction->amount_difference_percent = round($diffPercent, 1);
|
||||
return $transaction;
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $transactions,
|
||||
'installment' => [
|
||||
'id' => $installment->id,
|
||||
'installment_number' => $installment->installment_number,
|
||||
'due_date' => $installment->due_date->format('Y-m-d'),
|
||||
'installment_amount' => $installmentAmount,
|
||||
],
|
||||
'search_period' => [
|
||||
'start' => $startDate->format('Y-m-d'),
|
||||
'end' => $endDate->format('Y-m-d'),
|
||||
],
|
||||
'amount_range' => [
|
||||
'min' => round($minAmount, 2),
|
||||
'max' => round($maxAmount, 2),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Listar parcelas pendentes de conciliação
|
||||
*/
|
||||
public function pendingReconciliation(): JsonResponse
|
||||
{
|
||||
$installments = LiabilityInstallment::whereHas('liabilityAccount', function ($q) {
|
||||
$q->where('user_id', Auth::id())->where('is_active', true);
|
||||
})
|
||||
->whereNull('reconciled_transaction_id')
|
||||
->where('status', '!=', 'cancelled')
|
||||
->with('liabilityAccount:id,name,currency,creditor')
|
||||
->orderBy('due_date')
|
||||
->get();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $installments,
|
||||
'count' => $installments->count(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
489
backend/app/Http/Controllers/Api/RecurringTemplateController.php
Normal file
489
backend/app/Http/Controllers/Api/RecurringTemplateController.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
1320
backend/app/Http/Controllers/Api/TransactionController.php
Normal file
1320
backend/app/Http/Controllers/Api/TransactionController.php
Normal file
File diff suppressed because it is too large
Load Diff
831
backend/app/Http/Controllers/Api/TransferDetectionController.php
Normal file
831
backend/app/Http/Controllers/Api/TransferDetectionController.php
Normal 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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
10
backend/app/Http/Controllers/Controller.php
Normal file
10
backend/app/Http/Controllers/Controller.php
Normal file
@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
}
|
||||
46
backend/app/Http/Middleware/SecurityHeaders.php
Normal file
46
backend/app/Http/Middleware/SecurityHeaders.php
Normal 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;
|
||||
}
|
||||
}
|
||||
85
backend/app/Mail/WelcomeEmail.php
Normal file
85
backend/app/Mail/WelcomeEmail.php
Normal 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 [];
|
||||
}
|
||||
}
|
||||
152
backend/app/Models/Account.php
Normal file
152
backend/app/Models/Account.php
Normal 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;
|
||||
}
|
||||
}
|
||||
153
backend/app/Models/Category.php
Normal file
153
backend/app/Models/Category.php
Normal 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;
|
||||
}
|
||||
}
|
||||
51
backend/app/Models/CategoryKeyword.php
Normal file
51
backend/app/Models/CategoryKeyword.php
Normal 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));
|
||||
}
|
||||
}
|
||||
78
backend/app/Models/CostCenter.php
Normal file
78
backend/app/Models/CostCenter.php
Normal 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);
|
||||
}
|
||||
}
|
||||
51
backend/app/Models/CostCenterKeyword.php
Normal file
51
backend/app/Models/CostCenterKeyword.php
Normal 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));
|
||||
}
|
||||
}
|
||||
86
backend/app/Models/ImportLog.php
Normal file
86
backend/app/Models/ImportLog.php
Normal 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);
|
||||
}
|
||||
}
|
||||
124
backend/app/Models/ImportMapping.php
Normal file
124
backend/app/Models/ImportMapping.php
Normal 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);
|
||||
}
|
||||
}
|
||||
238
backend/app/Models/LiabilityAccount.php
Normal file
238
backend/app/Models/LiabilityAccount.php
Normal 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 já foi pago do principal)
|
||||
*/
|
||||
public function getProgressPercentageAttribute(): float
|
||||
{
|
||||
if ($this->principal_amount <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return round(($this->principal_paid / $this->principal_amount) * 100, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Saldo restante do principal
|
||||
*/
|
||||
public function getRemainingBalanceAttribute(): float
|
||||
{
|
||||
return $this->principal_amount - $this->principal_paid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recalcular totais baseado nas parcelas
|
||||
*/
|
||||
public function recalculateTotals(): void
|
||||
{
|
||||
$installments = $this->installments()->get();
|
||||
|
||||
$this->total_installments = $installments->count();
|
||||
$this->paid_installments = $installments->where('status', 'paid')->count();
|
||||
$this->pending_installments = $installments->where('status', 'pending')->count();
|
||||
|
||||
// Totais do contrato
|
||||
$this->total_interest = $installments->sum('interest_amount');
|
||||
$this->total_fees = $installments->sum('fee_amount');
|
||||
$this->principal_amount = $installments->sum('principal_amount');
|
||||
$this->total_contract_value = $installments->sum('installment_amount');
|
||||
|
||||
// Valores pagos
|
||||
$paidInstallments = $installments->where('status', 'paid');
|
||||
$this->total_paid = $paidInstallments->sum('installment_amount');
|
||||
$this->principal_paid = $paidInstallments->sum('principal_amount');
|
||||
$this->interest_paid = $paidInstallments->sum('interest_amount');
|
||||
$this->fees_paid = $paidInstallments->sum('fee_amount');
|
||||
|
||||
// Valores pendentes
|
||||
$pendingInstallments = $installments->where('status', 'pending');
|
||||
$this->total_pending = $pendingInstallments->sum('installment_amount');
|
||||
|
||||
// Calcular taxas de juros
|
||||
$this->calculateInterestRates();
|
||||
|
||||
// Datas
|
||||
$firstInstallment = $installments->sortBy('due_date')->first();
|
||||
$lastInstallment = $installments->sortBy('due_date')->last();
|
||||
|
||||
if ($firstInstallment) {
|
||||
$this->first_due_date = $firstInstallment->due_date;
|
||||
$this->start_date = $firstInstallment->due_date;
|
||||
}
|
||||
if ($lastInstallment) {
|
||||
$this->end_date = $lastInstallment->due_date;
|
||||
}
|
||||
|
||||
// Atualizar status
|
||||
if ($this->pending_installments === 0 && $this->paid_installments > 0) {
|
||||
$this->status = self::STATUS_PAID_OFF;
|
||||
}
|
||||
|
||||
$this->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcular taxas de juros baseado nos dados
|
||||
*/
|
||||
protected function calculateInterestRates(): void
|
||||
{
|
||||
if ($this->principal_amount <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Taxa total do contrato
|
||||
$this->total_interest_rate = round(($this->total_interest / $this->principal_amount) * 100, 4);
|
||||
|
||||
// Taxa mensal média
|
||||
if ($this->total_installments > 0) {
|
||||
$this->monthly_interest_rate = round($this->total_interest_rate / $this->total_installments, 4);
|
||||
$this->annual_interest_rate = round($this->monthly_interest_rate * 12, 4);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope para contas ativas
|
||||
*/
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope para um status específico
|
||||
*/
|
||||
public function scopeOfStatus($query, string $status)
|
||||
{
|
||||
return $query->where('status', $status);
|
||||
}
|
||||
}
|
||||
147
backend/app/Models/LiabilityInstallment.php
Normal file
147
backend/app/Models/LiabilityInstallment.php
Normal 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());
|
||||
});
|
||||
}
|
||||
}
|
||||
149
backend/app/Models/RecurringInstance.php
Normal file
149
backend/app/Models/RecurringInstance.php
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
169
backend/app/Models/RecurringTemplate.php
Normal file
169
backend/app/Models/RecurringTemplate.php
Normal 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');
|
||||
}
|
||||
}
|
||||
323
backend/app/Models/Transaction.php
Normal file
323
backend/app/Models/Transaction.php
Normal 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 já existe transação com este hash para o usuário
|
||||
*/
|
||||
public static function existsByHash(int $userId, string $hash): bool
|
||||
{
|
||||
return self::where('user_id', $userId)
|
||||
->where('import_hash', $hash)
|
||||
->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope para buscar por hash de importação
|
||||
*/
|
||||
public function scopeByImportHash($query, string $hash)
|
||||
{
|
||||
return $query->where('import_hash', $hash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica se a transação foi importada
|
||||
*/
|
||||
public function getIsImportedAttribute(): bool
|
||||
{
|
||||
return !empty($this->import_hash);
|
||||
}
|
||||
}
|
||||
59
backend/app/Models/User.php
Normal file
59
backend/app/Models/User.php
Normal 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;
|
||||
}
|
||||
}
|
||||
49
backend/app/Policies/RecurringTemplatePolicy.php
Normal file
49
backend/app/Policies/RecurringTemplatePolicy.php
Normal 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;
|
||||
}
|
||||
}
|
||||
49
backend/app/Providers/AppServiceProvider.php
Normal file
49
backend/app/Providers/AppServiceProvider.php
Normal 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());
|
||||
});
|
||||
}
|
||||
}
|
||||
221
backend/app/Services/Import/CsvParser.php
Normal file
221
backend/app/Services/Import/CsvParser.php
Normal 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);
|
||||
}
|
||||
}
|
||||
164
backend/app/Services/Import/ExcelParser.php
Normal file
164
backend/app/Services/Import/ExcelParser.php
Normal 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);
|
||||
}
|
||||
}
|
||||
26
backend/app/Services/Import/FileParserInterface.php
Normal file
26
backend/app/Services/Import/FileParserInterface.php
Normal 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;
|
||||
}
|
||||
530
backend/app/Services/Import/ImportService.php
Normal file
530
backend/app/Services/Import/ImportService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
249
backend/app/Services/Import/OfxParser.php
Normal file
249
backend/app/Services/Import/OfxParser.php
Normal 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);
|
||||
}
|
||||
}
|
||||
194
backend/app/Services/Import/PdfParser.php
Normal file
194
backend/app/Services/Import/PdfParser.php
Normal 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);
|
||||
}
|
||||
}
|
||||
393
backend/app/Services/RecurringService.php
Normal file
393
backend/app/Services/RecurringService.php
Normal 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();
|
||||
}
|
||||
}
|
||||
310
backend/app/Services/UserSetupService.php
Normal file
310
backend/app/Services/UserSetupService.php
Normal 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
18
backend/artisan
Normal 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
25
backend/bootstrap/app.php
Normal 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
2
backend/bootstrap/cache/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
5
backend/bootstrap/providers.php
Normal file
5
backend/bootstrap/providers.php
Normal file
@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
App\Providers\AppServiceProvider::class,
|
||||
];
|
||||
88
backend/composer.json
Normal file
88
backend/composer.json
Normal 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
8812
backend/composer.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
126
backend/config/app.php
Normal file
126
backend/config/app.php
Normal 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
115
backend/config/auth.php
Normal 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
117
backend/config/cache.php
Normal 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
34
backend/config/cors.php
Normal 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
183
backend/config/database.php
Normal 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),
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
];
|
||||
80
backend/config/filesystems.php
Normal file
80
backend/config/filesystems.php
Normal 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
132
backend/config/logging.php
Normal 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
118
backend/config/mail.php
Normal 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
129
backend/config/queue.php
Normal 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',
|
||||
],
|
||||
|
||||
];
|
||||
84
backend/config/sanctum.php
Normal file
84
backend/config/sanctum.php
Normal 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,
|
||||
],
|
||||
|
||||
];
|
||||
38
backend/config/services.php
Normal file
38
backend/config/services.php
Normal 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
217
backend/config/session.php
Normal 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
1
backend/database/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
*.sqlite*
|
||||
44
backend/database/factories/UserFactory.php
Normal file
44
backend/database/factories/UserFactory.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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 já 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');
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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");
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
354
backend/database/seeders/CategoriesOnlySeeder.php
Normal file
354
backend/database/seeders/CategoriesOnlySeeder.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
25
backend/database/seeders/DatabaseSeeder.php
Normal file
25
backend/database/seeders/DatabaseSeeder.php
Normal 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
Loading…
Reference in New Issue
Block a user