Compare commits

..

10 Commits

Author SHA1 Message Date
marcoitaloesp-ai
6a86b2627d
fix: Remove undefined businesses() relation call in AssetAccountController
- Fixed 500 error on POST /api/asset-accounts/wizard
- business_id is now optional and only set if provided in request
- Previously tried to call User::businesses() which doesn't exist
2025-12-18 19:27:57 +00:00
marcoitaloesp-ai
a90ff9d013
feat: Redesign cost center modal + document modal pattern
- Completely redesigned Cost Center create/edit modal with elegant wizard-style UI
- Added preview card, visual settings section, keyword tags with auto-assign badge
- Added missing i18n translations for costCenters (namePlaceholder, descPlaceholder, etc.)
- Documented modal design pattern in copilot-instructions.md for future reference
- Pattern includes: colors, structure, labels, cards, tags, switch components
2025-12-18 19:20:20 +00:00
marcoitaloesp-ai
3ebb19e9c6
fix: Add common.selectIcon i18n key 2025-12-18 19:15:20 +00:00
marcoitaloesp-ai
e70f5d169c
fix: Add common.selection i18n key 2025-12-18 19:14:01 +00:00
marcoitaloesp-ai
48e6857ef1
fix: Complete i18n translations for category modals
Added missing translations for ES, PT-BR and EN:
- categories.selectParent, noKeywords, namePlaceholder, descPlaceholder
- categories.visualSettings, autoCategorizationLabel
- categories.batchDescription, analyzingTransactions
- categories.noMatchesFoundTitle
- Improved existing translations for better UX
2025-12-18 19:09:28 +00:00
marcoitaloesp-ai
9c9d6443e7
v1.57.0: Redesign category modals + i18n updates + demo transactions fix
- Redesigned category create/edit modal with elegant wizard-style UI
- Redesigned batch categorization modal with visual cards and better preview
- Added missing i18n translations (common.continue, creating, remove)
- Added budgets.general and wizard translations for ES, PT-BR, EN
- Fixed 3 demo user transactions that were missing categories
2025-12-18 19:06:07 +00:00
marcoitaloesp-ai
6292b62315
feat: complete email system redesign with corporate templates
- Redesigned all email templates with professional corporate style
- Created base layout with dark header, status cards, and footer
- Updated: subscription-cancelled, account-activation, welcome, welcome-new-user, due-payments-alert
- Removed emojis and gradients for cleaner look
- Added multi-language support (ES, PT-BR, EN)
- Fixed email delivery (sync instead of queue)
- Fixed PayPal already-cancelled subscription handling
- Cleaned orphan subscriptions from deleted users
2025-12-18 00:44:37 +00:00
marcoitaloesp-ai
984855e36c
feat: Landing page pública + Registro com seleção de plano
- Nova Landing Page institucional em /
- Seções: Hero, Features, Pricing, FAQ, CTA, Footer
- Pricing integrado com API de planos
- Register.jsx agora suporta seleção de plano
- Parâmetro ?plan=slug na URL do registro
- Traduções EN, ES, PT-BR para landing
- PayPal configurado no servidor (sandbox)

Versão: 1.54.0
2025-12-17 19:44:12 +00:00
marcoitaloesp-ai
c99bca9404
v1.53.0: Fix admin middleware, deploy cnxifly pages from server 2025-12-17 17:42:46 +00:00
marcoitaloesp-ai
3a336eb692
feat: Admin user management + SaaS limits tested v1.51.0
- Add UserManagementController@store for creating users
- Add POST /api/admin/users endpoint
- Support user types: Free, Pro, Admin
- Auto-create 100-year subscription for Pro/Admin users
- Add user creation modal to Users.jsx
- Complete SaaS limit testing:
  - Free user limits: 1 account, 10 categories, 3 budgets, 100 tx
  - Middleware blocks correctly at limits
  - Error messages are user-friendly
  - Usage stats API working correctly
- Update SAAS_STATUS.md with test results
- Bump version to 1.51.0
2025-12-17 15:22:01 +00:00
91 changed files with 19292 additions and 1399 deletions

View File

@ -58,6 +58,30 @@ Os scripts de deploy:
- Novos comandos artisan
- Mudança na estrutura do projeto
## 🚫 Regras de UI/UX
**NUNCA use alert(), confirm() ou prompt() do navegador.**
Sempre usar componentes modais ou toast:
- Para erros: `toast.error('mensagem')`
- Para sucesso: `toast.success('mensagem')`
- Para confirmação: Usar `<ConfirmModal />` component
- Para formulários: Criar modal customizado
```jsx
// ❌ PROIBIDO
alert('Erro!');
confirm('Tem certeza?');
// ✅ CORRETO
import { useToast } from '../components/Toast';
import { ConfirmModal } from '../components/Modal';
const toast = useToast();
toast.error('Erro!');
toast.success('Sucesso!');
```
## Estrutura do Servidor
```
@ -93,6 +117,115 @@ sshpass -p 'Master9354' ssh -o StrictHostKeyChecking=no root@213.165.93.60 "mysq
❌ NUNCA usar `ssh root@213.165.93.60` sem sshpass (vai travar esperando senha)
## 🎨 Padrão Visual de Modais
**TODOS os modais de formulário devem seguir este padrão elegante:**
### Estrutura Base
```jsx
<div className="modal show d-block" style={{ backgroundColor: 'rgba(0,0,0,0.8)' }}>
<div className="modal-dialog modal-dialog-centered modal-lg">
<div className="modal-content border-0" style={{ background: '#1e293b', maxHeight: '90vh' }}>
{/* Header sem borda */}
<div className="modal-header border-0 pb-0">
<div>
<h5 className="modal-title text-white mb-1">
<i className="bi bi-icon me-2 text-primary"></i>
Título
</h5>
<p className="text-slate-400 mb-0 small">Subtítulo</p>
</div>
<button className="btn-close btn-close-white" onClick={onClose}></button>
</div>
{/* Body com scroll */}
<div className="modal-body pt-3" style={{ maxHeight: '65vh', overflowY: 'auto' }}>
{/* Preview Card - SEMPRE no topo */}
<div className="mb-4 p-3 rounded-3" style={{ background: '#0f172a' }}>
{/* Preview visual do item sendo criado/editado */}
</div>
{/* Campos em cards com background #0f172a */}
{/* Labels com ícones coloridos */}
{/* Badges "Opcional" quando necessário */}
</div>
{/* Footer sem borda */}
<div className="modal-footer border-0">
<button className="btn btn-outline-secondary px-4">Cancelar</button>
<button className="btn btn-primary px-4">Salvar</button>
</div>
</div>
</div>
</div>
```
### Cores do Sistema
- **Background modal**: `#1e293b`
- **Background campos/cards**: `#0f172a`
- **Texto principal**: `text-white`
- **Texto secundário**: `text-slate-400`
- **Texto desabilitado**: `text-slate-500`
### Labels com Ícones
```jsx
<label className="form-label text-white fw-medium mb-2">
<i className="bi bi-type me-2 text-primary"></i>
Nome *
</label>
```
### Badge Opcional
```jsx
<span className="badge bg-secondary ms-2" style={{ fontSize: '0.65rem' }}>
{t('common.optional')}
</span>
```
### Seleção Visual com Cards
Para seleções (categorias, ícones), usar cards clicáveis:
```jsx
<div
onClick={() => handleSelect(item)}
className="p-2 rounded text-center"
style={{
background: isSelected ? 'rgba(59, 130, 246, 0.15)' : '#0f172a',
cursor: 'pointer',
border: isSelected ? '2px solid #3b82f6' : '2px solid transparent'
}}
>
<i className={`bi ${icon} d-block mb-1`} style={{ color }}></i>
<small className="text-white">{label}</small>
</div>
```
### Seção de Keywords/Tags
```jsx
<div className="p-3 rounded" style={{ background: '#0f172a' }}>
<div className="input-group mb-2">
<input className="form-control bg-dark text-white border-0" />
<button className="btn btn-primary px-3">
<i className="bi bi-plus-lg"></i>
</button>
</div>
<div className="d-flex flex-wrap gap-2">
{/* Tags com cor do item */}
</div>
</div>
```
### Switch de Status
```jsx
<div className="form-check form-switch">
<input type="checkbox" className="form-check-input" role="switch" />
<label className="form-check-label text-white">
<i className={`bi ${isActive ? 'bi-check-circle text-success' : 'bi-x-circle text-secondary'} me-2`}></i>
{isActive ? 'Activo' : 'Inactivo'}
</label>
</div>
```
## Documentação
Consulte `.DIRETRIZES_DESENVOLVIMENTO_v5` para regras completas.

View File

@ -5,6 +5,280 @@ O formato segue [Keep a Changelog](https://keepachangelog.com/pt-BR/).
Este projeto adota [Versionamento Semântico](https://semver.org/pt-BR/).
## [1.57.0] - 2025-12-18
### Improved
- 🎨 **Novo Design do Modal de Categorias** - Interface completamente redesenhada seguindo o estilo do BudgetWizard:
- Preview em tempo real da categoria (ícone, cor, nome)
- Seleção visual de categoria pai com cards clicáveis
- Seção de palavras-chave com badge de auto-categorização
- Layout mais organizado e elegante
- Feedback visual melhorado para estado ativo/inativo
- 🎨 **Modal de Categorização em Lote Redesenhado**:
- Cards de resumo com ícones e cores distintivas
- Tabela de preview mais limpa e legível
- Estado vazio com ilustração e orientação
- Loading spinner maior e mais visível
- 🌐 **Traduções i18n Atualizadas** para ES, PT-BR e EN:
- `common.continue`, `common.creating`, `common.remove`
- `budgets.general`
- `budgets.wizard.createBudget`, `fillRequired`, `updated`, `created`, `selectAtLeast`
### Fixed
- 🔧 **Transações Demo Categorizadas** - 3 transações pendentes (luz, internet, seguro carro) agora possuem categorias corretas
## [1.56.0] - 2025-12-18
### Added
- 🧙 **Novo Wizard de Contas Unificado** - Wizard inteligente para criar diferentes tipos de contas com roteamento automático:
- **Cuenta Corriente** → Criada como conta normal (Account)
- **Cuenta de Ahorro** → Automaticamente criada como **Activo** (poupança é um investimento)
- **Tarjeta de Crédito** → Automaticamente criada como **Pasivo** (dívida de cartão)
- **Efectivo** → Criada como conta normal de caixa (Account)
### Features do AccountWizard
- Interface de 4 etapas com feedback visual claro
- Etapa 1: Seleção visual do tipo de conta com badges indicando destino (Activo/Pasivo/Cuenta)
- Etapa 2: Informações básicas (nome, moeda, cor)
- Etapa 3: Informações financeiras (saldo, limite, taxas de juros)
- Etapa 4: Dados bancários opcionais e resumo
- Campos dinâmicos baseados no tipo de conta:
- Cartão de crédito: Limite, día de cierre, día de vencimiento, tasa de interés
- Poupança: Taxa de juros de rendimento
- Suporte completo a modo mobile (fullscreen)
- Integração com serviços existentes (accountService, assetAccountService, liabilityAccountService)
## [1.55.0] - 2025-12-18
### Added
- 🎛️ **Campos Avançados no Wizard de Passivos** - Baseado em requisitos profissionais de contratos
- **Seção Indexadores de Taxa**:
- Tipo de indexador (CDI, SELIC, IPCA, Euribor, LIBOR, SOFR, Prime Rate, TJLP, INPC, TR, IGP-M, Fixo)
- Spread adicional (%)
- CET - Custo Efetivo Total (%)
- **Seção Garantias**:
- Tipo de garantia (Alienação Fiduciária, Hipoteca, Penhor, Avalista, Carta Fiança Bancária, Fiança, Seguro Garantia, Depósito Caução, Patrimônio Líquido, Sem Garantia)
- Valor da garantia
- Nome do fiador/avalista
- Descrição da garantia
- **Seção Penalidades por Atraso**:
- Multa por atraso (%)
- Mora diária (%)
- Dias de tolerância antes de aplicar multa
- **Seção Dados do Bem** (para Leasing/Veículo):
- Valor do bem
- Valor residual (opção de compra)
- Descrição do bem
- **Seção Gestão Interna**:
- Dias de alerta antes do vencimento
- Número do documento (ref. interna)
- Responsável interno
- Notas internas (covenants, condições especiais)
- 📊 **Expansão do Banco de Dados**:
- 20+ novos campos na tabela `liability_accounts`
- Suporte a JSON para covenants complexos
- Campos específicos por jurisdição (EU, USA, Brasil)
### Changed
- 🎨 **UI do Wizard Melhorada**:
- Nova seção "Opciones Avanzadas" em acordeão colapsável
- Badge "Opcional" para indicar campos não obrigatórios
- Campos específicos aparecem dinamicamente (ex: Dados do Bem só para leasing/veículo)
- Step 5 (Confirmação) exibe campos avançados preenchidos
- 🔧 **API Atualizada**:
- `contractTypes()` agora retorna `index_types` e `guarantee_types`
- `storeWithWizard()` valida e aceita todos os campos avançados
## [1.54.0] - 2025-12-18
### Added
- 🎯 **Sistema de Criação de Passivos Completo** - Wizard profissional para registro de contratos
- **Tipos de Contrato Suportados**:
- Préstamo Personal (Sistema PRICE)
- Financiación de Vehículo
- Hipoteca/Financiación Inmobiliaria (SAC ou PRICE)
- Tarjeta de Crédito
- Consorcio
- Leasing
- Descubierto/Cheque Especial
- Préstamo con Nómina
- Outro
- **Sistemas de Amortização**:
- PRICE (Cuota Fija) - Parcelas iguais, juros decrescentes
- SAC (Amortización Constante) - Amortização fixa, parcelas decrescentes
- Americano - Só juros durante o prazo, principal no final
- Consorcio - Parcelas variáveis
- **Wizard de 5 Passos**:
1. Seleção do tipo de contrato (com ícones e descrições)
2. Dados básicos (nome, acreedor, contrato, moeda)
3. Valores e taxas (principal, taxas, amortização, carência, seguros)
4. Datas (início, primeiro vencimento) + Preview de parcelas
5. Confirmação com resumo completo
- **Preview Automático de Parcelas**:
- Calcula todas as parcelas baseado no sistema de amortização
- Mostra primeiras 6 e últimas 2 parcelas
- Exibe totais (valor total, juros total, parcela média)
- 📊 **Template Excel Profissional para Importação**
- Download direto via botão no frontend
- 3 abas: Parcelas, Instruções, Ejemplo
- Cabeçalhos formatados e validações de dados
- Exemplo preenchido com contrato PRICE real
- Instruções detalhadas sobre sistemas de amortização
- Suporta carência, seguros e taxas administrativas
### Changed
- 🔄 **UI Melhorada para Passivos**:
- Novo botão "Crear Pasivo" (verde) para abrir wizard
- Dropdown de importação com opções:
- Importar desde Excel
- Descargar Plantilla
- Estado vazio com 3 opções claras (Crear, Importar, Baixar Template)
- 📝 **Labels em Espanhol** - Status de contas e parcelas traduzidos:
- Activo, Liquidado, En mora, Renegociado
- Pendiente, Pagado, Parcial, Vencido, Cancelado
### Technical Details
- **Backend**:
- `LiabilityTemplateService.php` - Gera template Excel com PhpSpreadsheet
- `LiabilityAccount::CONTRACT_TYPES` - Constantes com metadata de cada tipo
- `LiabilityAccount::AMORTIZATION_SYSTEMS` - Sistemas de amortização suportados
- Migration: `add_contract_type_to_liability_accounts_table`
- Novos campos: `contract_type`, `amortization_system`, `has_grace_period`, `grace_period_months`
- Endpoint `POST /api/liability-accounts/wizard` - Criação via wizard
- Endpoint `GET /api/liability-accounts/template` - Download do template
- Endpoint `GET /api/liability-accounts/contract-types` - Lista tipos de contrato
- Geração automática de parcelas (PRICE, SAC, Americano)
- **Frontend**:
- `LiabilityWizard.jsx` - Componente de wizard com 5 passos
- Cálculo de parcelas em tempo real no frontend
- Download de arquivo Excel via Blob
- Integração com `liabilityAccountService`
### API Endpoints
```
GET /api/liability-accounts/template - Download template Excel
GET /api/liability-accounts/contract-types - Lista tipos de contrato
POST /api/liability-accounts/wizard - Criar conta via wizard
```
---
## [1.53.0] - 2025-12-17
### Fixed
- 🔧 **Correção Middleware Admin** - Alterado de `admin` para `admin.only` nas rotas site-settings
- 🔧 **Correção Deploy cnxifly.com** - Controller atualizado para usar arquivos do servidor
- Páginas armazenadas em `/var/www/cnxifly-pages/`
- Deploy direto via `File::copy()` ao invés de instruções manuais
### Added
- 📁 **Diretório de páginas no servidor** (`/var/www/cnxifly-pages/`)
- `index.html` - Página institucional live
- `maintenance.html` - Página de manutenção
- Permite trocar páginas via painel admin
### Technical Details
- Middleware correto: `admin.only` (registrado em bootstrap/app.php)
- SiteSettingsController agora copia arquivos diretamente no servidor
- Permissões ajustadas para www-data no cnxifly-pages
---
## [1.52.0] - 2025-12-17
### Added
- 🌐 **Landing Page Institucional ConneXiFly** - Página completa para cnxifly.com
- **Produtos destacados**:
- WebMoney: Gestão financeira inteligente (Disponível)
- EZPool: Software de mantenimiento de piscinas (Próximamente)
- **Seções**:
- Hero com animações e gradientes
- Cards de produtos com preços
- Features do ConneXiFly
- CTA para registro
- Footer completo
- **Modal de notificação** para EZPool (pré-registro)
- **Design responsivo** e dark theme consistente
- 🔧 **Página de Manutenção** alternativa (`landing/maintenance.html`)
- Design simples para modo manutenção
- Links para produtos disponíveis
- ⚙️ **Painel Admin - Configurações do Site**
- **Backend**:
- `SiteSetting` model para configurações persistentes
- `SiteSettingsController` para gerenciar cnxifly.com
- Rotas admin para toggle de modo (live/maintenance)
- Migration para tabela `site_settings`
- **Frontend**:
- Nova página `/site-settings` (admin only)
- Toggle entre modo Live e Manutenção
- Preview links para todos os sites
- Instruções de deploy
- 📝 **Script de Deploy Landing** (`deploy-landing.sh`)
- Deploy automático para cnxifly.com
- Suporte a modos: live, maintenance
### Technical Details
- Arquivos criados em `/landing/` (index.html, maintenance.html)
- Bootstrap 5.3 + Bootstrap Icons para a landing
- Integração com menu do WebMoney admin
---
## [1.51.0] - 2025-12-17
### Added
- 👥 **Gestão de Usuários Admin** - Interface completa para administradores criarem usuários
- **Backend**:
- `UserManagementController@store`: Novo endpoint para criar usuários
- Rota `POST /api/admin/users` para criação
- Suporte a tipos de usuário: Free, Pro, Admin
- Assinatura automática Pro/Admin (100 anos, sem PayPal)
- **Frontend**:
- Modal de criação de usuário em `/users`
- Seleção de tipo: Free (sem assinatura), Pro (com assinatura), Admin (admin + assinatura)
- Feedback visual após criação
### Tested
- 🧪 **Testes Completos do SaaS** - Validação do sistema de limites
- **Usuário Free testfree2@webmoney.test (ID: 4)**:
- ✅ Limite de contas: 1/1 → Bloqueia corretamente
- ✅ Limite de categorias: 10/10 → Bloqueia corretamente
- ✅ Limite de budgets: 3/3 → Bloqueia corretamente
- ✅ Mensagens de erro amigáveis em espanhol
- ✅ API retorna `usage` e `usage_percentages` corretos
- **Usuário Pro (admin)**:
- ✅ Limites `null` = ilimitado
- ✅ 173 categorias, 1204 transações sem bloqueio
### Technical Details
- Middleware `CheckPlanLimits` validado em produção
- Endpoint `/subscription/status` retorna uso atual corretamente
- Widget `PlanUsageWidget` funcional no Dashboard
---
## [1.50.0] - 2025-12-17
### Added

View File

@ -1 +1 @@
1.50.0
1.58.0

BIN
Wanna.xlsx Normal file

Binary file not shown.

View File

@ -0,0 +1,119 @@
<?php
namespace App\Console\Commands;
use App\Models\LiabilityAccount;
use App\Models\LiabilityInstallment;
use Carbon\Carbon;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class GenerateDemoInstallments extends Command
{
protected $signature = 'demo:generate-installments';
protected $description = 'Gerar parcelas de exemplo para passivos DEMO que não têm parcelas';
public function handle(): int
{
$this->info('Verificando passivos sem parcelas...');
// Buscar passivos que têm total_installments > 0 mas não têm parcelas
$accounts = LiabilityAccount::withCount('installments')
->having('installments_count', '=', 0)
->where('total_installments', '>', 0)
->get();
if ($accounts->isEmpty()) {
$this->info('Todos os passivos já têm parcelas geradas.');
return Command::SUCCESS;
}
$this->info("Encontrados {$accounts->count()} passivos sem parcelas.");
DB::beginTransaction();
try {
foreach ($accounts as $account) {
$this->generateInstallments($account);
}
DB::commit();
$this->info('✓ Parcelas geradas com sucesso!');
return Command::SUCCESS;
} catch (\Exception $e) {
DB::rollBack();
$this->error('Erro: ' . $e->getMessage());
return Command::FAILURE;
}
}
private function generateInstallments(LiabilityAccount $account): void
{
$this->info("Gerando parcelas para: {$account->name}");
$totalInstallments = $account->total_installments;
$paidInstallments = $account->paid_installments ?? 0;
$principal = $account->principal_amount;
$annualRate = $account->annual_interest_rate ?? 0;
$monthlyRate = $annualRate / 12 / 100;
$startDate = $account->first_due_date ?? $account->start_date ?? now();
if (is_string($startDate)) {
$startDate = Carbon::parse($startDate);
}
// Calcular parcela mensal (sistema PRICE)
if ($monthlyRate > 0) {
$pmt = $principal * ($monthlyRate * pow(1 + $monthlyRate, $totalInstallments)) /
(pow(1 + $monthlyRate, $totalInstallments) - 1);
} else {
$pmt = $principal / $totalInstallments;
}
$balance = $principal;
$installments = [];
for ($i = 1; $i <= $totalInstallments; $i++) {
$dueDate = $startDate->copy()->addMonths($i - 1);
// Calcular juros e capital
$interestAmount = $balance * $monthlyRate;
$principalPaid = $pmt - $interestAmount;
// Última parcela ajusta para zerar o saldo
if ($i === $totalInstallments) {
$principalPaid = $balance;
$pmt = $principalPaid + $interestAmount;
}
$balance -= $principalPaid;
// Determinar status
$isPaid = $i <= $paidInstallments;
$status = $isPaid ? 'paid' : ($dueDate->isPast() ? 'overdue' : 'pending');
$installments[] = [
'liability_account_id' => $account->id,
'installment_number' => $i,
'due_date' => $dueDate->format('Y-m-d'),
'installment_amount' => round($pmt, 2),
'principal_amount' => round($principalPaid, 2),
'interest_amount' => round($interestAmount, 2),
'fee_amount' => 0,
'paid_amount' => $isPaid ? round($pmt, 2) : 0,
'paid_date' => $isPaid ? $dueDate->format('Y-m-d') : null,
'status' => $status,
'created_at' => now(),
'updated_at' => now(),
];
}
// Inserir em lotes
foreach (array_chunk($installments, 50) as $chunk) {
LiabilityInstallment::insert($chunk);
}
$this->info("{$totalInstallments} parcelas criadas");
// Recalcular totais
$account->recalculateTotals();
}
}

View File

@ -0,0 +1,661 @@
<?php
namespace App\Console\Commands;
use App\Models\Account;
use App\Models\Category;
use App\Models\Transaction;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class PopulateDemoData extends Command
{
protected $signature = 'demo:populate {--fresh : Limpar dados existentes antes de popular}';
protected $description = 'Popular dados de demonstração (contas, categorias, transações) para o usuário DEMO';
private User $user;
private array $accounts = [];
private array $categories = [];
private array $subcategories = [];
public function handle(): int
{
$this->user = User::where('email', 'demo@webmoney.com')->first();
if (!$this->user) {
$this->error('Usuário demo@webmoney.com não encontrado!');
return Command::FAILURE;
}
$this->info("Populando dados para usuário DEMO (ID: {$this->user->id})...");
if ($this->option('fresh')) {
$this->clearExistingData();
}
DB::beginTransaction();
try {
$this->createAccounts();
$this->createCategories();
$this->createTransactions();
// Recalcular saldos das contas
$this->recalculateBalances();
DB::commit();
$this->newLine();
$this->info('✓ Dados DEMO populados com sucesso!');
$this->showSummary();
return Command::SUCCESS;
} catch (\Exception $e) {
DB::rollBack();
$this->error('Erro: ' . $e->getMessage());
$this->error($e->getTraceAsString());
return Command::FAILURE;
}
}
private function clearExistingData(): void
{
$this->info('Limpando dados existentes...');
Transaction::where('user_id', $this->user->id)->delete();
// Subcategorias são Category com parent_id
Category::where('user_id', $this->user->id)->whereNotNull('parent_id')->delete();
Category::where('user_id', $this->user->id)->whereNull('parent_id')->delete();
Account::where('user_id', $this->user->id)->delete();
}
private function createAccounts(): void
{
$this->info('Criando contas...');
$accountsData = [
[
'name' => 'Cuenta Corriente Principal',
'type' => 'checking',
'bank_name' => 'Santander',
'account_number' => 'ES12 3456 7890 1234',
'initial_balance' => 5000.00,
'currency' => 'EUR',
'color' => '#3B82F6',
'icon' => 'bi-bank',
'is_active' => true,
'include_in_total' => true,
],
[
'name' => 'Cuenta de Ahorro',
'type' => 'savings',
'bank_name' => 'BBVA',
'account_number' => 'ES98 7654 3210 9876',
'initial_balance' => 15000.00,
'currency' => 'EUR',
'color' => '#10B981',
'icon' => 'bi-piggy-bank',
'is_active' => true,
'include_in_total' => true,
],
[
'name' => 'Efectivo',
'type' => 'cash',
'bank_name' => null,
'account_number' => null,
'initial_balance' => 500.00,
'currency' => 'EUR',
'color' => '#F59E0B',
'icon' => 'bi-cash-stack',
'is_active' => true,
'include_in_total' => true,
],
];
foreach ($accountsData as $data) {
$account = Account::create([
'user_id' => $this->user->id,
'name' => $data['name'],
'type' => $data['type'],
'bank_name' => $data['bank_name'],
'account_number' => $data['account_number'],
'initial_balance' => $data['initial_balance'],
'current_balance' => $data['initial_balance'],
'currency' => $data['currency'],
'color' => $data['color'],
'icon' => $data['icon'],
'is_active' => $data['is_active'],
'include_in_total' => $data['include_in_total'],
]);
$this->accounts[$data['type']] = $account;
$this->info("{$data['name']}");
}
}
private function createCategories(): void
{
$this->info('Criando categorias e subcategorias...');
$categoriesData = [
// DESPESAS (expense)
[
'name' => 'Vivienda',
'type' => 'debit',
'icon' => 'bi-house',
'color' => '#EF4444',
'subcategories' => ['Alquiler', 'Hipoteca', 'Comunidad', 'Seguro Hogar', 'Reparaciones', 'Muebles'],
],
[
'name' => 'Alimentación',
'type' => 'debit',
'icon' => 'bi-cart',
'color' => '#F97316',
'subcategories' => ['Supermercado', 'Restaurantes', 'Cafeterías', 'Delivery', 'Panadería'],
],
[
'name' => 'Transporte',
'type' => 'debit',
'icon' => 'bi-car-front',
'color' => '#8B5CF6',
'subcategories' => ['Combustible', 'Transporte Público', 'Taxi/Uber', 'Mantenimiento Coche', 'Parking', 'Seguro Coche'],
],
[
'name' => 'Servicios',
'type' => 'debit',
'icon' => 'bi-lightning',
'color' => '#EC4899',
'subcategories' => ['Electricidad', 'Gas', 'Agua', 'Internet', 'Teléfono', 'Streaming'],
],
[
'name' => 'Salud',
'type' => 'debit',
'icon' => 'bi-heart-pulse',
'color' => '#14B8A6',
'subcategories' => ['Médico', 'Farmacia', 'Dentista', 'Óptica', 'Gimnasio', 'Seguro Médico'],
],
[
'name' => 'Ocio',
'type' => 'debit',
'icon' => 'bi-controller',
'color' => '#6366F1',
'subcategories' => ['Cine', 'Conciertos', 'Viajes', 'Hobbies', 'Libros', 'Videojuegos'],
],
[
'name' => 'Ropa',
'type' => 'debit',
'icon' => 'bi-bag',
'color' => '#A855F7',
'subcategories' => ['Ropa', 'Calzado', 'Accesorios', 'Ropa Deportiva'],
],
[
'name' => 'Educación',
'type' => 'debit',
'icon' => 'bi-book',
'color' => '#0EA5E9',
'subcategories' => ['Cursos', 'Material Escolar', 'Idiomas', 'Certificaciones'],
],
[
'name' => 'Mascotas',
'type' => 'debit',
'icon' => 'bi-piggy-bank',
'color' => '#84CC16',
'subcategories' => ['Comida Mascota', 'Veterinario', 'Accesorios Mascota'],
],
[
'name' => 'Otros Gastos',
'type' => 'debit',
'icon' => 'bi-three-dots',
'color' => '#64748B',
'subcategories' => ['Regalos', 'Donaciones', 'Imprevistos', 'Varios'],
],
// INGRESOS (income)
[
'name' => 'Salario',
'type' => 'credit',
'icon' => 'bi-briefcase',
'color' => '#22C55E',
'subcategories' => ['Nómina', 'Horas Extra', 'Bonus', 'Comisiones'],
],
[
'name' => 'Inversiones',
'type' => 'credit',
'icon' => 'bi-graph-up-arrow',
'color' => '#10B981',
'subcategories' => ['Dividendos', 'Intereses', 'Plusvalías', 'Alquileres'],
],
[
'name' => 'Freelance',
'type' => 'credit',
'icon' => 'bi-laptop',
'color' => '#06B6D4',
'subcategories' => ['Proyectos', 'Consultoría', 'Clases Particulares'],
],
[
'name' => 'Otros Ingresos',
'type' => 'credit',
'icon' => 'bi-plus-circle',
'color' => '#84CC16',
'subcategories' => ['Reembolsos', 'Ventas', 'Premios', 'Herencias'],
],
];
foreach ($categoriesData as $catData) {
$category = Category::create([
'user_id' => $this->user->id,
'name' => $catData['name'],
'type' => $catData['type'],
'icon' => $catData['icon'],
'color' => $catData['color'],
'is_active' => true,
'parent_id' => null,
]);
$this->categories[$catData['name']] = $category;
// Subcategorias são Category com parent_id
foreach ($catData['subcategories'] as $subName) {
$sub = Category::create([
'user_id' => $this->user->id,
'parent_id' => $category->id,
'name' => $subName,
'type' => $catData['type'],
'icon' => $catData['icon'],
'color' => $catData['color'],
'is_active' => true,
]);
$this->subcategories[$subName] = $sub;
}
$this->info("{$catData['name']} ({$catData['type']}) - " . count($catData['subcategories']) . " subcategorias");
}
}
private function createTransactions(): void
{
$this->info('Criando transações de 2025-2026...');
$checkingAccount = $this->accounts['checking'];
$savingsAccount = $this->accounts['savings'];
$cashAccount = $this->accounts['cash'];
$transactionCount = 0;
$today = Carbon::today();
// Helper para determinar status baseado na data
$getStatus = function(Carbon $date) use ($today) {
return $date->isAfter($today) ? 'pending' : 'effective';
};
// Gerar transações de Janeiro 2025 a Março 2026
for ($i = 1; $i <= 15; $i++) { // 12 meses de 2025 + 3 de 2026
$year = $i <= 12 ? 2025 : 2026;
$month = $i <= 12 ? $i : $i - 12;
$daysInMonth = Carbon::create($year, $month)->daysInMonth;
// RECEITAS FIXAS (mensais)
// Salário - dia 28 ou último dia útil
$salaryDay = min(28, $daysInMonth);
$salaryDate = Carbon::create($year, $month, $salaryDay);
$this->createTransaction([
'account_id' => $checkingAccount->id,
'category' => 'Salario',
'subcategory' => 'Nómina',
'type' => 'credit',
'amount' => 3200.00,
'date' => $salaryDate,
'status' => $getStatus($salaryDate),
'description' => 'Salario mensual',
]);
$transactionCount++;
// DESPESAS FIXAS (mensais)
// Aluguel - dia 1
$rentDate = Carbon::create($year, $month, 1);
$this->createTransaction([
'account_id' => $checkingAccount->id,
'category' => 'Vivienda',
'subcategory' => 'Alquiler',
'type' => 'debit',
'amount' => 850.00,
'date' => $rentDate,
'status' => $getStatus($rentDate),
'description' => 'Alquiler apartamento',
]);
$transactionCount++;
// Serviços - vários dias do mês
$services = [
['subcategory' => 'Electricidad', 'amount' => rand(45, 85), 'day' => 5],
['subcategory' => 'Gas', 'amount' => rand(25, 55), 'day' => 8],
['subcategory' => 'Agua', 'amount' => rand(20, 35), 'day' => 10],
['subcategory' => 'Internet', 'amount' => 49.99, 'day' => 15],
['subcategory' => 'Teléfono', 'amount' => 25.00, 'day' => 15],
['subcategory' => 'Streaming', 'amount' => 17.99, 'day' => 20],
];
foreach ($services as $service) {
if ($service['day'] <= $daysInMonth) {
$serviceDate = Carbon::create($year, $month, $service['day']);
$this->createTransaction([
'account_id' => $checkingAccount->id,
'category' => 'Servicios',
'subcategory' => $service['subcategory'],
'type' => 'debit',
'amount' => $service['amount'],
'date' => $serviceDate,
'status' => $getStatus($serviceDate),
'description' => $service['subcategory'],
]);
$transactionCount++;
}
}
// Supermercado - várias vezes por mês
$supermarketDays = [3, 10, 17, 24];
foreach ($supermarketDays as $day) {
if ($day <= $daysInMonth) {
$marketDate = Carbon::create($year, $month, $day);
$this->createTransaction([
'account_id' => $checkingAccount->id,
'category' => 'Alimentación',
'subcategory' => 'Supermercado',
'type' => 'debit',
'amount' => rand(60, 120),
'date' => $marketDate,
'status' => $getStatus($marketDate),
'description' => 'Compra supermercado',
]);
$transactionCount++;
}
}
// Transporte - combustível e outros
$fuelDate = Carbon::create($year, $month, rand(1, min(15, $daysInMonth)));
$this->createTransaction([
'account_id' => $checkingAccount->id,
'category' => 'Transporte',
'subcategory' => 'Combustible',
'type' => 'debit',
'amount' => rand(50, 80),
'date' => $fuelDate,
'status' => $getStatus($fuelDate),
'description' => 'Gasolina',
]);
$transactionCount++;
// Restaurantes - algumas vezes por mês
$restaurantCount = rand(2, 4);
for ($j = 0; $j < $restaurantCount; $j++) {
$restaurantDate = Carbon::create($year, $month, rand(1, $daysInMonth));
$this->createTransaction([
'account_id' => $cashAccount->id,
'category' => 'Alimentación',
'subcategory' => 'Restaurantes',
'type' => 'debit',
'amount' => rand(25, 60),
'date' => $restaurantDate,
'status' => $getStatus($restaurantDate),
'description' => 'Cena/Almuerzo fuera',
]);
$transactionCount++;
}
// Café - várias vezes
$coffeeCount = rand(5, 10);
for ($j = 0; $j < $coffeeCount; $j++) {
$coffeeDate = Carbon::create($year, $month, rand(1, $daysInMonth));
$this->createTransaction([
'account_id' => $cashAccount->id,
'category' => 'Alimentación',
'subcategory' => 'Cafeterías',
'type' => 'debit',
'amount' => rand(3, 8),
'date' => $coffeeDate,
'status' => $getStatus($coffeeDate),
'description' => 'Café',
]);
$transactionCount++;
}
// Retirada de cajero para efectivo (do banco para cash)
$atmDate = Carbon::create($year, $month, rand(1, min(5, $daysInMonth)));
$this->createTransaction([
'account_id' => $checkingAccount->id,
'category' => 'Otros Gastos',
'subcategory' => 'Varios',
'type' => 'debit',
'amount' => 200.00,
'date' => $atmDate,
'status' => $getStatus($atmDate),
'description' => 'Retiro cajero automático',
]);
$transactionCount++;
$this->createTransaction([
'account_id' => $cashAccount->id,
'category' => 'Otros Ingresos',
'subcategory' => 'Reembolsos',
'type' => 'credit',
'amount' => 200.00,
'date' => $atmDate,
'status' => $getStatus($atmDate),
'description' => 'Retiro cajero automático',
]);
$transactionCount++;
// Saúde - eventual
if (rand(1, 3) == 1) {
$healthDate = Carbon::create($year, $month, rand(1, $daysInMonth));
$healthSubs = ['Farmacia', 'Médico', 'Gimnasio'];
$this->createTransaction([
'account_id' => $checkingAccount->id,
'category' => 'Salud',
'subcategory' => $healthSubs[array_rand($healthSubs)],
'type' => 'debit',
'amount' => rand(15, 80),
'date' => $healthDate,
'status' => $getStatus($healthDate),
'description' => 'Gasto salud',
]);
$transactionCount++;
}
// Ginásio - mensal
$gymDate = Carbon::create($year, $month, 1);
$this->createTransaction([
'account_id' => $checkingAccount->id,
'category' => 'Salud',
'subcategory' => 'Gimnasio',
'type' => 'debit',
'amount' => 35.00,
'date' => $gymDate,
'status' => $getStatus($gymDate),
'description' => 'Cuota gimnasio',
]);
$transactionCount++;
// Lazer - algumas vezes
if (rand(1, 2) == 1) {
$leisureDate = Carbon::create($year, $month, rand(1, $daysInMonth));
$leisureSubs = ['Cine', 'Conciertos', 'Hobbies', 'Libros', 'Videojuegos'];
$this->createTransaction([
'account_id' => $checkingAccount->id,
'category' => 'Ocio',
'subcategory' => $leisureSubs[array_rand($leisureSubs)],
'type' => 'debit',
'amount' => rand(15, 60),
'date' => $leisureDate,
'status' => $getStatus($leisureDate),
'description' => 'Entretenimiento',
]);
$transactionCount++;
}
// Roupa - eventual
if (rand(1, 4) == 1) {
$clothesDate = Carbon::create($year, $month, rand(1, $daysInMonth));
$this->createTransaction([
'account_id' => $checkingAccount->id,
'category' => 'Ropa',
'subcategory' => 'Ropa',
'type' => 'debit',
'amount' => rand(30, 120),
'date' => $clothesDate,
'status' => $getStatus($clothesDate),
'description' => 'Compra ropa',
]);
$transactionCount++;
}
// Freelance - eventual (2-3 vezes por trimestre)
if ($month % 3 == 0 || rand(1, 5) == 1) {
$freelanceDate = Carbon::create($year, $month, rand(10, min(25, $daysInMonth)));
$this->createTransaction([
'account_id' => $checkingAccount->id,
'category' => 'Freelance',
'subcategory' => 'Proyectos',
'type' => 'credit',
'amount' => rand(200, 800),
'date' => $freelanceDate,
'status' => $getStatus($freelanceDate),
'description' => 'Proyecto freelance',
]);
$transactionCount++;
}
// Dividendos - trimestral
if ($month % 3 == 0) {
$dividendDate = Carbon::create($year, $month, 15);
$this->createTransaction([
'account_id' => $savingsAccount->id,
'category' => 'Inversiones',
'subcategory' => 'Dividendos',
'type' => 'credit',
'amount' => rand(50, 150),
'date' => $dividendDate,
'status' => $getStatus($dividendDate),
'description' => 'Dividendos trimestre',
]);
$transactionCount++;
}
// Juros poupança - mensal
$interestDate = Carbon::create($year, $month, $daysInMonth);
$this->createTransaction([
'account_id' => $savingsAccount->id,
'category' => 'Inversiones',
'subcategory' => 'Intereses',
'type' => 'credit',
'amount' => round(rand(15, 35) + (rand(0, 99) / 100), 2),
'date' => $interestDate,
'status' => $getStatus($interestDate),
'description' => 'Intereses cuenta ahorro',
]);
$transactionCount++;
// Transferência para poupança - mensal
if (rand(1, 2) == 1) {
$transferDate = Carbon::create($year, $month, rand(25, min(28, $daysInMonth)));
$transferAmount = rand(200, 500);
$this->createTransaction([
'account_id' => $checkingAccount->id,
'category' => 'Otros Gastos',
'subcategory' => 'Varios',
'type' => 'debit',
'amount' => $transferAmount,
'date' => $transferDate,
'status' => $getStatus($transferDate),
'description' => 'Transferencia a cuenta ahorro',
]);
$transactionCount++;
$this->createTransaction([
'account_id' => $savingsAccount->id,
'category' => 'Otros Ingresos',
'subcategory' => 'Reembolsos',
'type' => 'credit',
'amount' => $transferAmount,
'date' => $transferDate,
'status' => $getStatus($transferDate),
'description' => 'Transferencia desde cuenta corriente',
]);
$transactionCount++;
}
$this->info(" ✓ Mes $month/$year procesado");
}
$this->info(" Total: $transactionCount transacciones creadas");
}
private function createTransaction(array $data): Transaction
{
$category = $this->categories[$data['category']] ?? null;
// Subcategoria é usada diretamente como category_id (pois são Categories com parent_id)
$subcategory = $this->subcategories[$data['subcategory']] ?? null;
// Se tiver subcategoria, usa ela; senão usa a categoria pai
$categoryId = $subcategory?->id ?? $category?->id;
// Status vem do data ou default 'effective'
$status = $data['status'] ?? 'effective';
// Para transações pendentes, não definir effective_date
$effectiveDate = $status === 'pending' ? null : $data['date'];
return Transaction::create([
'user_id' => $this->user->id,
'account_id' => $data['account_id'],
'category_id' => $categoryId,
'type' => $data['type'],
'amount' => $status === 'pending' ? null : $data['amount'],
'planned_amount' => $data['amount'],
'planned_date' => $data['date'],
'effective_date' => $effectiveDate,
'description' => $data['description'],
'notes' => null,
'is_recurring' => false,
'status' => $status,
]);
}
private function recalculateBalances(): void
{
$this->info('Recalculando saldos das contas...');
foreach ($this->accounts as $account) {
// Apenas transações efetivas afetam o saldo atual
$income = Transaction::where('account_id', $account->id)
->where('type', 'credit')
->where('status', 'effective')
->sum('amount');
$expense = Transaction::where('account_id', $account->id)
->where('type', 'debit')
->where('status', 'effective')
->sum('amount');
$newBalance = $account->initial_balance + $income - $expense;
$account->update(['current_balance' => $newBalance]);
$this->info("{$account->name}: €" . number_format($newBalance, 2));
}
}
private function showSummary(): void
{
$this->newLine();
$this->info('=== RESUMO ===');
$this->info('Contas: ' . count($this->accounts));
$this->info('Categorias: ' . count($this->categories));
$this->info('Subcategorias: ' . count($this->subcategories));
$this->info('Transações: ' . Transaction::where('user_id', $this->user->id)->count());
$this->newLine();
$this->info('Saldos finais:');
foreach ($this->accounts as $account) {
$account->refresh();
$this->info(" {$account->name}: €" . number_format($account->current_balance, 2));
}
}
}

View File

@ -0,0 +1,189 @@
<?php
namespace App\Console\Commands;
use App\Models\Plan;
use App\Services\PayPalService;
use Illuminate\Console\Command;
class SetupPayPalPlans extends Command
{
protected $signature = 'paypal:setup-plans';
protected $description = 'Create products and billing plans in PayPal for all active plans';
public function handle()
{
$this->info('🚀 Starting PayPal Plans Setup...');
$this->newLine();
$paypal = new PayPalService();
// Test authentication first
$this->info('Testing PayPal authentication...');
try {
$token = $paypal->getAccessToken();
$this->info('✅ PayPal authentication successful!');
$this->newLine();
} catch (\Exception $e) {
$this->error('❌ PayPal authentication failed: ' . $e->getMessage());
return 1;
}
// Get plans that don't have PayPal plan IDs yet
$plans = Plan::where('is_active', true)
->where('is_free', false)
->get();
if ($plans->isEmpty()) {
$this->warn('No paid plans found in database.');
return 0;
}
$this->info("Found {$plans->count()} paid plan(s) to setup in PayPal:");
$this->newLine();
foreach ($plans as $plan) {
$this->info("📦 Processing: {$plan->name}");
// Check if already has PayPal plan ID
if ($plan->paypal_plan_id) {
$this->warn(" Already has PayPal Plan ID: {$plan->paypal_plan_id}");
$this->line(" Skipping...");
$this->newLine();
continue;
}
try {
// Step 1: Create Product in PayPal
$this->line(" Creating product in PayPal...");
$product = $this->createProduct($paypal, $plan);
$this->info(" ✅ Product created: {$product['id']}");
// Step 2: Create Billing Plan in PayPal
$this->line(" Creating billing plan in PayPal...");
$billingPlan = $this->createBillingPlan($paypal, $plan, $product['id']);
$this->info(" ✅ Billing Plan created: {$billingPlan['id']}");
// Step 3: Save PayPal Plan ID to database
$plan->paypal_plan_id = $billingPlan['id'];
$plan->save();
$this->info(" ✅ Saved to database!");
$this->newLine();
} catch (\Exception $e) {
$this->error(" ❌ Error: " . $e->getMessage());
$this->newLine();
}
}
$this->info('🎉 PayPal Plans Setup completed!');
$this->newLine();
// Show summary
$this->table(
['Plan', 'Price', 'Billing', 'PayPal Plan ID'],
Plan::where('is_free', false)->get()->map(function ($p) {
return [
$p->name,
'€' . number_format($p->price, 2),
$p->billing_period,
$p->paypal_plan_id ?? 'Not set'
];
})
);
return 0;
}
private function createProduct(PayPalService $paypal, Plan $plan): array
{
$baseUrl = config('services.paypal.mode') === 'sandbox'
? 'https://api-m.sandbox.paypal.com'
: 'https://api-m.paypal.com';
// Get fresh token for this request
\Illuminate\Support\Facades\Cache::forget('paypal_access_token');
$token = $paypal->getAccessToken();
$response = \Illuminate\Support\Facades\Http::withToken($token)
->post("{$baseUrl}/v1/catalogs/products", [
'name' => "WEBMoney - {$plan->name}",
'description' => $plan->description ?? "Subscription plan for WEBMoney",
'type' => 'SERVICE',
'category' => 'SOFTWARE',
'home_url' => config('app.url'),
]);
if (!$response->successful()) {
throw new \Exception('Failed to create product: ' . $response->body());
}
return $response->json();
}
private function createBillingPlan(PayPalService $paypal, Plan $plan, string $productId): array
{
$baseUrl = config('services.paypal.mode') === 'sandbox'
? 'https://api-m.sandbox.paypal.com'
: 'https://api-m.paypal.com';
$billingCycles = [];
// Add trial period if plan has trial
if ($plan->trial_days > 0) {
$billingCycles[] = [
'frequency' => [
'interval_unit' => 'DAY',
'interval_count' => $plan->trial_days,
],
'tenure_type' => 'TRIAL',
'sequence' => 1,
'total_cycles' => 1,
'pricing_scheme' => [
'fixed_price' => [
'value' => '0',
'currency_code' => $plan->currency,
],
],
];
}
// Regular billing cycle
$billingCycles[] = [
'frequency' => [
'interval_unit' => $plan->billing_period === 'annual' ? 'YEAR' : 'MONTH',
'interval_count' => 1,
],
'tenure_type' => 'REGULAR',
'sequence' => $plan->trial_days > 0 ? 2 : 1,
'total_cycles' => 0, // Infinite
'pricing_scheme' => [
'fixed_price' => [
'value' => number_format($plan->price, 2, '.', ''),
'currency_code' => $plan->currency,
],
],
];
$response = \Illuminate\Support\Facades\Http::withToken($paypal->getAccessToken())
->post("{$baseUrl}/v1/billing/plans", [
'product_id' => $productId,
'name' => $plan->name,
'description' => $plan->description ?? "WEBMoney {$plan->name} subscription",
'status' => 'ACTIVE',
'billing_cycles' => $billingCycles,
'payment_preferences' => [
'auto_bill_outstanding' => true,
'setup_fee_failure_action' => 'CONTINUE',
'payment_failure_threshold' => 3,
],
]);
if (!$response->successful()) {
throw new \Exception('Failed to create billing plan: ' . $response->body());
}
return $response->json();
}
}

View File

@ -0,0 +1,423 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\AssetAccount;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
class AssetAccountController extends Controller
{
/**
* Listar todos os ativos do usuário
*/
public function index(Request $request): JsonResponse
{
$query = AssetAccount::where('user_id', Auth::id());
// Filtros
if ($request->has('asset_type') && $request->asset_type) {
$query->where('asset_type', $request->asset_type);
}
if ($request->has('status') && $request->status) {
$query->where('status', $request->status);
}
if ($request->has('search') && $request->search) {
$search = $request->search;
$query->where(function($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('description', 'like', "%{$search}%")
->orWhere('document_number', 'like', "%{$search}%");
});
}
// Ordenação
$sortBy = $request->get('sort_by', 'created_at');
$sortDir = $request->get('sort_dir', 'desc');
$query->orderBy($sortBy, $sortDir);
// Paginação ou todos
if ($request->has('per_page')) {
$assets = $query->paginate($request->per_page);
} else {
$assets = $query->get();
}
return response()->json([
'success' => true,
'data' => $assets,
]);
}
/**
* Criar novo ativo
*/
public function store(Request $request): JsonResponse
{
$validator = Validator::make($request->all(), [
'asset_type' => ['required', Rule::in(array_keys(AssetAccount::ASSET_TYPES))],
'name' => 'required|string|max:255',
'description' => 'nullable|string',
'currency' => 'required|string|size:3',
'color' => 'nullable|string|max:7',
'acquisition_value' => 'required|numeric|min:0',
'current_value' => 'required|numeric|min:0',
'acquisition_date' => 'nullable|date',
// Campos opcionais conforme tipo
'property_type' => 'nullable|string',
'investment_type' => 'nullable|string',
'depreciation_method' => 'nullable|string',
]);
if ($validator->fails()) {
return response()->json([
'success' => false,
'errors' => $validator->errors(),
], 422);
}
$data = $request->all();
$data['user_id'] = Auth::id();
$data['business_id'] = $request->business_id ?? Auth::user()->businesses()->first()?->id;
$asset = AssetAccount::create($data);
return response()->json([
'success' => true,
'message' => 'Activo creado con éxito',
'data' => $asset,
], 201);
}
/**
* Ver um ativo específico
*/
public function show(AssetAccount $assetAccount): JsonResponse
{
// Verificar se pertence ao usuário
if ($assetAccount->user_id !== Auth::id()) {
return response()->json([
'success' => false,
'message' => 'No autorizado',
], 403);
}
return response()->json([
'success' => true,
'data' => $assetAccount->load('linkedLiability'),
]);
}
/**
* Atualizar ativo
*/
public function update(Request $request, AssetAccount $assetAccount): JsonResponse
{
if ($assetAccount->user_id !== Auth::id()) {
return response()->json([
'success' => false,
'message' => 'No autorizado',
], 403);
}
$validator = Validator::make($request->all(), [
'asset_type' => ['nullable', Rule::in(array_keys(AssetAccount::ASSET_TYPES))],
'name' => 'nullable|string|max:255',
'current_value' => 'nullable|numeric|min:0',
'status' => ['nullable', Rule::in(array_keys(AssetAccount::STATUSES))],
]);
if ($validator->fails()) {
return response()->json([
'success' => false,
'errors' => $validator->errors(),
], 422);
}
$assetAccount->update($request->all());
return response()->json([
'success' => true,
'message' => 'Activo actualizado con éxito',
'data' => $assetAccount->fresh(),
]);
}
/**
* Deletar ativo
*/
public function destroy(AssetAccount $assetAccount): JsonResponse
{
if ($assetAccount->user_id !== Auth::id()) {
return response()->json([
'success' => false,
'message' => 'No autorizado',
], 403);
}
$assetAccount->delete();
return response()->json([
'success' => true,
'message' => 'Activo eliminado con éxito',
]);
}
/**
* Retornar tipos de ativos e opções para o wizard
*/
public function assetTypes(): JsonResponse
{
return response()->json([
'success' => true,
'data' => AssetAccount::ASSET_TYPES,
'property_types' => AssetAccount::PROPERTY_TYPES,
'investment_types' => AssetAccount::INVESTMENT_TYPES,
'depreciation_methods' => AssetAccount::DEPRECIATION_METHODS,
'index_types' => AssetAccount::INDEX_TYPES,
'statuses' => AssetAccount::STATUSES,
]);
}
/**
* Criar ativo via wizard
*/
public function storeWithWizard(Request $request): JsonResponse
{
$rules = [
// Step 1 - Tipo
'asset_type' => ['required', Rule::in(array_keys(AssetAccount::ASSET_TYPES))],
// Step 2 - Dados básicos
'name' => 'required|string|max:255',
'description' => 'nullable|string|max:1000',
'currency' => 'required|string|size:3',
'color' => 'nullable|string|max:7',
// Step 3 - Valores
'acquisition_value' => 'required|numeric|min:0',
'current_value' => 'required|numeric|min:0',
'acquisition_date' => 'nullable|date',
// Depreciação
'is_depreciable' => 'nullable|boolean',
'depreciation_method' => ['nullable', Rule::in(array_keys(AssetAccount::DEPRECIATION_METHODS))],
'useful_life_years' => 'nullable|numeric|min:0.5|max:100',
'residual_value' => 'nullable|numeric|min:0',
// Imóveis
'property_type' => ['nullable', Rule::in(array_keys(AssetAccount::PROPERTY_TYPES))],
'address' => 'nullable|string|max:500',
'city' => 'nullable|string|max:100',
'state' => 'nullable|string|max:100',
'postal_code' => 'nullable|string|max:20',
'country' => 'nullable|string|size:2',
'property_area_m2' => 'nullable|numeric|min:0',
'registry_number' => 'nullable|string|max:100',
// Veículos
'vehicle_brand' => 'nullable|string|max:100',
'vehicle_model' => 'nullable|string|max:100',
'vehicle_year' => 'nullable|integer|min:1900|max:2100',
'vehicle_plate' => 'nullable|string|max:20',
'vehicle_vin' => 'nullable|string|max:50',
'vehicle_mileage' => 'nullable|integer|min:0',
// Investimentos
'investment_type' => ['nullable', Rule::in(array_keys(AssetAccount::INVESTMENT_TYPES))],
'institution' => 'nullable|string|max:100',
'account_number' => 'nullable|string|max:100',
'quantity' => 'nullable|integer|min:0',
'unit_price' => 'nullable|numeric|min:0',
'ticker' => 'nullable|string|max:20',
'maturity_date' => 'nullable|date',
'interest_rate' => 'nullable|numeric|min:0|max:100',
'index_type' => ['nullable', Rule::in(array_keys(AssetAccount::INDEX_TYPES))],
// Equipamentos
'equipment_brand' => 'nullable|string|max:100',
'equipment_model' => 'nullable|string|max:100',
'serial_number' => 'nullable|string|max:100',
'warranty_expiry' => 'nullable|date',
// Recebíveis
'debtor_name' => 'nullable|string|max:200',
'debtor_document' => 'nullable|string|max:50',
'receivable_due_date' => 'nullable|date',
'receivable_amount' => 'nullable|numeric|min:0',
// Garantias
'is_collateral' => 'nullable|boolean',
'collateral_for' => 'nullable|string|max:200',
'linked_liability_id' => 'nullable|integer|exists:liability_accounts,id',
// Seguros
'has_insurance' => 'nullable|boolean',
'insurance_company' => 'nullable|string|max:100',
'insurance_policy' => 'nullable|string|max:100',
'insurance_value' => 'nullable|numeric|min:0',
'insurance_expiry' => 'nullable|date',
// Gestão
'alert_days_before' => 'nullable|integer|min:0|max:365',
'internal_responsible' => 'nullable|string|max:200',
'internal_notes' => 'nullable|string|max:2000',
'document_number' => 'nullable|string|max:100',
];
$validator = Validator::make($request->all(), $rules);
if ($validator->fails()) {
return response()->json([
'success' => false,
'errors' => $validator->errors(),
], 422);
}
// Criar o ativo
$data = $validator->validated();
$data['user_id'] = Auth::id();
// business_id é opcional e vem do request se fornecido
if ($request->filled('business_id')) {
$data['business_id'] = $request->business_id;
}
$data['status'] = 'active';
$asset = AssetAccount::create($data);
return response()->json([
'success' => true,
'message' => 'Activo creado con éxito',
'data' => $asset,
], 201);
}
/**
* Resumo dos ativos do usuário
*/
public function summary(): JsonResponse
{
$userId = Auth::id();
$summary = [
'total_assets' => AssetAccount::where('user_id', $userId)->active()->count(),
'total_value' => AssetAccount::where('user_id', $userId)->active()->sum('current_value'),
'total_acquisition' => AssetAccount::where('user_id', $userId)->active()->sum('acquisition_value'),
'by_type' => [],
];
// Agrupar por tipo
$byType = AssetAccount::where('user_id', $userId)
->active()
->selectRaw('asset_type, COUNT(*) as count, SUM(current_value) as total_value')
->groupBy('asset_type')
->get();
foreach ($byType as $item) {
$summary['by_type'][$item->asset_type] = [
'name' => AssetAccount::ASSET_TYPES[$item->asset_type]['name'] ?? $item->asset_type,
'count' => $item->count,
'total_value' => $item->total_value,
];
}
// Ganho/perda total
$summary['total_gain_loss'] = $summary['total_value'] - $summary['total_acquisition'];
$summary['total_gain_loss_percent'] = $summary['total_acquisition'] > 0
? (($summary['total_value'] - $summary['total_acquisition']) / $summary['total_acquisition']) * 100
: 0;
return response()->json([
'success' => true,
'data' => $summary,
]);
}
/**
* Atualizar valor de mercado de um ativo
*/
public function updateValue(Request $request, AssetAccount $assetAccount): JsonResponse
{
if ($assetAccount->user_id !== Auth::id()) {
return response()->json([
'success' => false,
'message' => 'No autorizado',
], 403);
}
$validator = Validator::make($request->all(), [
'current_value' => 'required|numeric|min:0',
'note' => 'nullable|string|max:500',
]);
if ($validator->fails()) {
return response()->json([
'success' => false,
'errors' => $validator->errors(),
], 422);
}
$assetAccount->update([
'current_value' => $request->current_value,
]);
// Adicionar nota se fornecida
if ($request->note) {
$notes = $assetAccount->internal_notes ?? '';
$notes .= "\n[" . now()->format('d/m/Y') . "] Valor actualizado: {$request->note}";
$assetAccount->update(['internal_notes' => trim($notes)]);
}
return response()->json([
'success' => true,
'message' => 'Valor actualizado con éxito',
'data' => $assetAccount->fresh(),
]);
}
/**
* Registrar venda/baixa de ativo
*/
public function dispose(Request $request, AssetAccount $assetAccount): JsonResponse
{
if ($assetAccount->user_id !== Auth::id()) {
return response()->json([
'success' => false,
'message' => 'No autorizado',
], 403);
}
$validator = Validator::make($request->all(), [
'disposal_date' => 'required|date',
'disposal_value' => 'required|numeric|min:0',
'disposal_reason' => 'nullable|string|max:200',
'status' => ['required', Rule::in(['sold', 'written_off', 'depreciated'])],
]);
if ($validator->fails()) {
return response()->json([
'success' => false,
'errors' => $validator->errors(),
], 422);
}
$assetAccount->update([
'status' => $request->status,
'disposal_date' => $request->disposal_date,
'disposal_value' => $request->disposal_value,
'disposal_reason' => $request->disposal_reason,
]);
return response()->json([
'success' => true,
'message' => 'Baja registrada con éxito',
'data' => $assetAccount->fresh(),
]);
}
}

View File

@ -4,17 +4,21 @@
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Models\EmailVerificationToken;
use App\Services\UserSetupService;
use App\Mail\AccountActivationMail;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Log;
class AuthController extends Controller
{
/**
* Register a new user
* Register a new user (without auto-login - requires PayPal payment and email activation)
*/
public function register(Request $request): JsonResponse
{
@ -23,6 +27,7 @@ public function register(Request $request): JsonResponse
'name' => 'required|string|max:255',
'email' => 'required|string|email|max:255|unique:users',
'password' => 'required|string|min:8|confirmed',
'plan_id' => 'nullable|exists:plans,id',
], [
'name.required' => 'El nombre es obligatorio',
'email.required' => 'El email es obligatorio',
@ -41,32 +46,40 @@ public function register(Request $request): JsonResponse
], 422);
}
// Create user WITHOUT email verification (will be verified after PayPal payment)
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($request->password),
'email_verified_at' => null, // NOT verified yet
]);
// Criar categorias e dados padrão para o novo usuário
$setupService = new UserSetupService();
$setupService->setupNewUser($user->id);
// DISABLED: Create default categories and data for the new user
// TODO: Re-enable when category templates are ready
// $setupService = new UserSetupService();
// $setupService->setupNewUser($user->id);
$token = $user->createToken('auth-token')->plainTextToken;
// Create a temporary token for PayPal flow (expires in 1 hour)
$tempToken = $user->createToken('registration-flow', ['registration'])->plainTextToken;
return response()->json([
'success' => true,
'message' => 'Usuario registrado exitosamente',
'message' => 'Usuario registrado. Procede al pago para activar tu cuenta.',
'data' => [
'user' => [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'email_verified' => false,
],
'token' => $token,
'token' => $tempToken,
'requires_payment' => true,
'requires_activation' => true,
]
], 201);
} catch (\Exception $e) {
Log::error('Registration error: ' . $e->getMessage());
return response()->json([
'success' => false,
'message' => 'Error al registrar usuario',
@ -98,30 +111,63 @@ public function login(Request $request): JsonResponse
], 422);
}
if (!Auth::attempt($request->only('email', 'password'))) {
$user = User::where('email', $request->email)->first();
if (!$user || !Hash::check($request->password, $user->password)) {
return response()->json([
'success' => false,
'message' => 'Credenciales incorrectas'
], 401);
}
$user = User::where('email', $request->email)->first();
// Check if email is verified (account activated)
if (!$user->email_verified_at) {
return response()->json([
'success' => false,
'message' => 'Tu cuenta aún no está activada. Revisa tu email para activarla.',
'error' => 'email_not_verified',
'data' => [
'email_verified' => false,
'can_resend' => true,
]
], 403);
}
// Check if user has an active subscription (skip for demo users)
if (!$user->is_demo) {
$hasActiveSubscription = $user->subscriptions()->active()->exists();
if (!$hasActiveSubscription) {
return response()->json([
'success' => false,
'message' => 'No tienes una suscripción activa. Por favor, completa el pago.',
'error' => 'no_subscription',
'data' => [
'email_verified' => true,
'has_subscription' => false,
]
], 403);
}
}
$token = $user->createToken('auth-token')->plainTextToken;
return response()->json([
'success' => true,
'message' => 'Inicio de sesión exitoso',
'message' => $user->is_demo ? 'Bienvenido al modo demostración' : 'Inicio de sesión exitoso',
'data' => [
'user' => [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'email_verified' => true,
'is_demo' => $user->is_demo ?? false,
],
'token' => $token,
]
], 200);
} catch (\Exception $e) {
Log::error('Login error: ' . $e->getMessage());
return response()->json([
'success' => false,
'message' => 'Error al iniciar sesión',
@ -130,6 +176,175 @@ public function login(Request $request): JsonResponse
}
}
/**
* Activate account via email token
*/
public function activateAccount(Request $request): JsonResponse
{
try {
$validator = Validator::make($request->all(), [
'token' => 'required|string|size:64',
]);
if ($validator->fails()) {
return response()->json([
'success' => false,
'message' => 'Token inválido',
], 422);
}
$verificationToken = EmailVerificationToken::findValid($request->token);
if (!$verificationToken) {
return response()->json([
'success' => false,
'message' => 'El enlace de activación es inválido o ha expirado.',
'error' => 'invalid_token',
], 400);
}
$user = $verificationToken->user;
// Activate the user
$user->update(['email_verified_at' => now()]);
$verificationToken->markAsUsed();
// Create auth token for immediate login
$authToken = $user->createToken('auth-token')->plainTextToken;
return response()->json([
'success' => true,
'message' => '¡Tu cuenta ha sido activada! Ya puedes acceder al sistema.',
'data' => [
'user' => [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'email_verified' => true,
],
'token' => $authToken,
]
]);
} catch (\Exception $e) {
Log::error('Activation error: ' . $e->getMessage());
return response()->json([
'success' => false,
'message' => 'Error al activar la cuenta',
], 500);
}
}
/**
* Resend activation email
*/
public function resendActivation(Request $request): JsonResponse
{
try {
$validator = Validator::make($request->all(), [
'email' => 'required|email|exists:users,email',
]);
if ($validator->fails()) {
return response()->json([
'success' => false,
'message' => 'Email no encontrado',
], 404);
}
$user = User::where('email', $request->email)->first();
if ($user->email_verified_at) {
return response()->json([
'success' => false,
'message' => 'Esta cuenta ya está activada',
], 400);
}
// Check if user has completed payment
$subscription = $user->subscriptions()->active()->first();
if (!$subscription) {
return response()->json([
'success' => false,
'message' => 'Primero debes completar el pago de tu suscripción',
], 400);
}
// Create new verification token and send email
$verificationToken = EmailVerificationToken::createForUser($user);
$frontendUrl = config('app.frontend_url', 'https://webmoney.cnxifly.com');
$activationUrl = "{$frontendUrl}/activate?token={$verificationToken->token}";
Mail::to($user->email)->send(new AccountActivationMail(
$user,
$activationUrl,
$subscription->plan->name
));
return response()->json([
'success' => true,
'message' => 'Email de activación reenviado. Revisa tu bandeja de entrada.',
]);
} catch (\Exception $e) {
Log::error('Resend activation error: ' . $e->getMessage());
return response()->json([
'success' => false,
'message' => 'Error al reenviar el email',
], 500);
}
}
/**
* Cancel registration - delete unactivated user account
* Used when PayPal payment is canceled or fails
*/
public function cancelRegistration(Request $request): JsonResponse
{
try {
$validator = Validator::make($request->all(), [
'email' => 'required|email|exists:users,email',
]);
if ($validator->fails()) {
return response()->json([
'success' => false,
'message' => 'Usuario no encontrado',
], 404);
}
$user = User::where('email', $request->email)->first();
// Only allow deletion if account is NOT activated
if ($user->email_verified_at) {
return response()->json([
'success' => false,
'message' => 'Esta cuenta ya está activada y no puede ser eliminada',
], 400);
}
// Delete associated data
$user->tokens()->delete(); // Delete all tokens
EmailVerificationToken::where('user_id', $user->id)->delete();
$user->subscriptions()->delete();
$user->delete();
Log::info("Unactivated user account deleted: {$request->email}");
return response()->json([
'success' => true,
'message' => 'Registro cancelado. Puedes intentar nuevamente.',
]);
} catch (\Exception $e) {
Log::error('Cancel registration error: ' . $e->getMessage());
return response()->json([
'success' => false,
'message' => 'Error al cancelar el registro',
], 500);
}
}
/**
* Logout user (revoke token)
*/

View File

@ -62,6 +62,36 @@ public function store(Request $request): JsonResponse
'keywords.*' => 'string|max:100',
]);
$user = Auth::user();
$plan = $user->currentPlan();
// Check subcategory limit if parent_id is provided
if (!empty($validated['parent_id']) && $plan) {
$limits = $plan->limits ?? [];
$subcategoryLimit = $limits['subcategories'] ?? null;
if ($subcategoryLimit !== null) {
$currentSubcategories = Category::where('user_id', $user->id)
->whereNotNull('parent_id')
->count();
if ($currentSubcategories >= $subcategoryLimit) {
return response()->json([
'success' => false,
'message' => "Has alcanzado el límite de {$subcategoryLimit} subcategorías de tu plan. Actualiza a Pro para subcategorías ilimitadas.",
'error' => 'plan_limit_exceeded',
'data' => [
'resource' => 'subcategories',
'current' => $currentSubcategories,
'limit' => $subcategoryLimit,
'plan' => $plan->name,
'upgrade_url' => '/pricing',
],
], 403);
}
}
}
// Verificar se parent_id pertence ao usuário
if (!empty($validated['parent_id'])) {
$parent = Category::where('user_id', Auth::id())

View File

@ -133,7 +133,7 @@ public function summary(Request $request): JsonResponse
];
$monthlyStatsByCurrency = Transaction::where('transactions.user_id', $userId)
->where('transactions.status', 'completed')
->whereIn('transactions.status', ['completed', 'effective'])
->where('transactions.is_transfer', false)
->whereBetween('transactions.effective_date', $currentMonth)
->whereNull('transactions.deleted_at')
@ -465,18 +465,20 @@ public function calendar(Request $request): JsonResponse
$endDate = $startDate->copy()->endOfMonth();
// Buscar transações do período
// Usar planned_date para todas as transações (funciona para efetivadas e pendentes)
$transactions = Transaction::ofUser($userId)
->whereBetween('effective_date', [$startDate, $endDate])
->whereBetween('planned_date', [$startDate, $endDate])
->with(['account:id,name,currency', 'category:id,name,color,icon'])
->orderBy('effective_date')
->orderBy('planned_date')
->get()
->map(function ($t) {
$date = $t->effective_date ?? $t->planned_date;
return [
'id' => $t->id,
'type' => 'transaction',
'date' => $t->effective_date->format('Y-m-d'),
'date' => $date->format('Y-m-d'),
'description' => $t->description,
'amount' => (float) $t->amount,
'amount' => (float) ($t->amount ?? $t->planned_amount),
'transaction_type' => $t->type,
'status' => $t->status,
'is_transfer' => $t->is_transfer,
@ -573,20 +575,21 @@ 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
// Buscar transações do dia (usar planned_date para incluir pendentes)
$transactions = Transaction::ofUser($userId)
->whereDate('effective_date', $date)
->whereDate('planned_date', $date)
->with(['account:id,name,currency', 'category:id,name,color,icon'])
->orderBy('effective_date')
->orderBy('planned_date')
->orderBy('created_at')
->get()
->map(function ($t) {
$txDate = $t->effective_date ?? $t->planned_date;
return [
'id' => $t->id,
'type' => 'transaction',
'date' => $t->effective_date->format('Y-m-d'),
'date' => $txDate->format('Y-m-d'),
'description' => $t->description,
'amount' => (float) $t->amount,
'amount' => (float) ($t->amount ?? $t->planned_amount),
'transaction_type' => $t->type,
'status' => $t->status,
'is_transfer' => $t->is_transfer,
@ -670,26 +673,28 @@ public function upcomingTransactions(Request $request): JsonResponse
$endDate = now()->addDays($days - 1)->endOfDay();
// Buscar transações pendentes do período
// Para pendentes: usar planned_date (effective_date é NULL)
$transactions = Transaction::ofUser($userId)
->whereIn('status', ['pending', 'scheduled'])
->whereBetween('effective_date', [$startDate, $endDate])
->whereBetween('planned_date', [$startDate, $endDate])
->with(['account:id,name,currency', 'category:id,name,color,icon'])
->orderBy('effective_date')
->orderBy('planned_date')
->orderBy('created_at')
->get()
->map(function ($t) {
$date = $t->effective_date ?? $t->planned_date;
return [
'id' => $t->id,
'type' => 'transaction',
'date' => $t->effective_date->format('Y-m-d'),
'date_formatted' => $t->effective_date->translatedFormat('D, d M'),
'date' => $date->format('Y-m-d'),
'date_formatted' => $date->translatedFormat('D, d M'),
'description' => $t->description,
'amount' => (float) $t->amount,
'amount' => (float) ($t->amount ?? $t->planned_amount),
'currency' => $t->account->currency ?? 'EUR',
'transaction_type' => $t->type,
'status' => $t->status,
'is_transfer' => $t->is_transfer,
'days_until' => (int) now()->startOfDay()->diffInDays($t->effective_date, false),
'days_until' => (int) now()->startOfDay()->diffInDays($date, false),
'account' => $t->account ? [
'id' => $t->account->id,
'name' => $t->account->name,
@ -769,6 +774,8 @@ public function upcomingTransactions(Request $request): JsonResponse
'recurring_count' => $recurringInstances->count(),
'total_credit' => $nonTransferItems->where('transaction_type', 'credit')->sum('amount'),
'total_debit' => $nonTransferItems->where('transaction_type', 'debit')->sum('amount'),
'credit_count' => $nonTransferItems->where('transaction_type', 'credit')->count(),
'debit_count' => $nonTransferItems->where('transaction_type', 'debit')->count(),
];
return response()->json([
@ -891,7 +898,7 @@ public function overdueTransactions(Request $request): JsonResponse
'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,
'amount' => (float) $li->installment_amount,
'currency' => $li->liabilityAccount->currency ?? 'EUR',
'transaction_type' => 'debit',
'status' => $li->status,

View File

@ -30,6 +30,9 @@ public function index(Request $request)
$this->setPrimaryCurrency();
$this->loadExchangeRates();
// Verificar si hay datos suficientes para análisis
$dataStatus = $this->checkDataSufficiency();
// Obtener datos base
$financialSummary = $this->getFinancialSummary();
$cashFlowAnalysis = $this->analyzeCashFlow();
@ -77,6 +80,9 @@ public function index(Request $request)
'last_updated' => now()->toIso8601String(),
'currency' => $this->primaryCurrency,
// Estado de datos
'data_status' => $dataStatus,
// Resumen financiero
'summary' => [
'total_assets' => $financialSummary['total_assets'],
@ -118,6 +124,56 @@ public function index(Request $request)
]);
}
/**
* Verificar si hay datos suficientes para análisis
*/
private function checkDataSufficiency()
{
$accountsCount = Account::where('user_id', $this->userId)->count();
$transactionsCount = Transaction::where('user_id', $this->userId)->count();
$categoriesCount = Category::where('user_id', $this->userId)->count();
// Transacciones de los últimos 30 días
$recentTransactions = Transaction::where('user_id', $this->userId)
->where('effective_date', '>=', now()->subDays(30))
->count();
// Determinar nivel de suficiencia
$hasSufficientData = $accountsCount >= 1 && $transactionsCount >= 10;
$hasMinimalData = $accountsCount >= 1 || $transactionsCount >= 1;
// Mensaje apropiado
$message = null;
$level = 'sufficient';
if ($accountsCount === 0 && $transactionsCount === 0) {
$level = 'no_data';
$message = 'No hay datos registrados. Añade cuentas y transacciones para comenzar el análisis.';
} elseif ($accountsCount === 0) {
$level = 'insufficient';
$message = 'Añade al menos una cuenta bancaria para un análisis más preciso.';
} elseif ($transactionsCount < 10) {
$level = 'limited';
$message = 'Hay pocos datos para un análisis completo. Registra más transacciones para mejorar la precisión.';
} elseif ($recentTransactions === 0) {
$level = 'outdated';
$message = 'No hay transacciones recientes. Los datos pueden estar desactualizados.';
}
return [
'has_sufficient_data' => $hasSufficientData,
'has_minimal_data' => $hasMinimalData,
'level' => $level,
'message' => $message,
'counts' => [
'accounts' => $accountsCount,
'transactions' => $transactionsCount,
'categories' => $categoriesCount,
'recent_transactions' => $recentTransactions,
],
];
}
/**
* Establecer moneda principal del usuario
*/

View File

@ -683,4 +683,290 @@ public function pendingReconciliation(): JsonResponse
'count' => $installments->count(),
]);
}
/**
* Download template Excel para importação
*/
public function downloadTemplate()
{
$service = new \App\Services\LiabilityTemplateService();
$spreadsheet = $service->generateTemplate();
$writer = new \PhpOffice\PhpSpreadsheet\Writer\Xlsx($spreadsheet);
$filename = 'plantilla_importacion_pasivo.xlsx';
// Criar response com stream
return response()->streamDownload(function () use ($writer) {
$writer->save('php://output');
}, $filename, [
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Content-Disposition' => 'attachment; filename="' . $filename . '"',
'Cache-Control' => 'max-age=0',
]);
}
/**
* Obter tipos de contrato disponíveis
*/
public function contractTypes(): JsonResponse
{
return response()->json([
'success' => true,
'data' => LiabilityAccount::CONTRACT_TYPES,
'amortization_systems' => LiabilityAccount::AMORTIZATION_SYSTEMS,
'index_types' => LiabilityAccount::INDEX_TYPES,
'guarantee_types' => LiabilityAccount::GUARANTEE_TYPES,
]);
}
/**
* Criar conta passivo com wizard (formulário completo)
*/
public function storeWithWizard(Request $request): JsonResponse
{
$validated = $request->validate([
// Dados básicos
'name' => 'required|string|max:150',
'contract_type' => ['required', \Illuminate\Validation\Rule::in(array_keys(LiabilityAccount::CONTRACT_TYPES))],
'creditor' => 'nullable|string|max:150',
'contract_number' => 'nullable|string|max:100',
'description' => 'nullable|string',
'currency' => 'nullable|string|size:3',
'color' => 'nullable|string|max:7',
// Dados do contrato
'principal_amount' => 'required|numeric|min:0.01',
'annual_interest_rate' => 'nullable|numeric|min:0|max:100',
'monthly_interest_rate' => 'nullable|numeric|min:0|max:20',
'amortization_system' => ['nullable', \Illuminate\Validation\Rule::in(array_keys(LiabilityAccount::AMORTIZATION_SYSTEMS))],
'total_installments' => 'nullable|integer|min:1|max:600',
'start_date' => 'required|date',
'first_due_date' => 'required|date',
'end_date' => 'nullable|date',
// Opções básicas
'has_grace_period' => 'nullable|boolean',
'grace_period_months' => 'nullable|integer|min:0|max:12',
'include_insurance' => 'nullable|boolean',
'insurance_amount' => 'nullable|numeric|min:0',
'include_admin_fee' => 'nullable|boolean',
'admin_fee_amount' => 'nullable|numeric|min:0',
// ============================================
// CAMPOS AVANÇADOS (opcionais)
// ============================================
// Indexadores
'index_type' => ['nullable', \Illuminate\Validation\Rule::in(array_keys(LiabilityAccount::INDEX_TYPES))],
'index_spread' => 'nullable|numeric|min:-50|max:50',
'total_effective_cost' => 'nullable|numeric|min:0|max:500',
// Garantias
'guarantee_type' => ['nullable', \Illuminate\Validation\Rule::in(array_keys(LiabilityAccount::GUARANTEE_TYPES))],
'guarantee_value' => 'nullable|numeric|min:0',
'guarantee_description' => 'nullable|string|max:500',
'guarantor_name' => 'nullable|string|max:150',
// Penalidades
'late_fee_percent' => 'nullable|numeric|min:0|max:100',
'daily_penalty_percent' => 'nullable|numeric|min:0|max:10',
'grace_days_for_penalty' => 'nullable|integer|min:0|max:30',
// Específicos por tipo
'asset_value' => 'nullable|numeric|min:0',
'asset_description' => 'nullable|string|max:300',
'residual_value' => 'nullable|numeric|min:0',
'admin_fee_percent' => 'nullable|numeric|min:0|max:50',
'reserve_fund_percent' => 'nullable|numeric|min:0|max:20',
// Covenants e gestão
'covenants' => 'nullable|array',
'covenants.*.name' => 'required_with:covenants|string|max:100',
'covenants.*.condition' => 'required_with:covenants|string|max:50',
'covenants.*.value' => 'required_with:covenants|string|max:100',
'alert_days_before' => 'nullable|integer|min:0|max:60',
'internal_responsible' => 'nullable|string|max:150',
'internal_notes' => 'nullable|string',
'document_number' => 'nullable|string|max:100',
'registry_office' => 'nullable|string|max:200',
// Parcelas (opcional - se não enviado, será calculado)
'installments' => 'nullable|array',
'installments.*.installment_number' => 'required|integer|min:1',
'installments.*.due_date' => 'required|date',
'installments.*.installment_amount' => 'required|numeric|min:0',
'installments.*.principal_amount' => 'nullable|numeric|min:0',
'installments.*.interest_amount' => 'nullable|numeric|min:0',
'installments.*.fee_amount' => 'nullable|numeric|min:0',
'installments.*.status' => 'nullable|string',
]);
try {
DB::beginTransaction();
// Calcular taxa mensal se não informada
$monthlyRate = $validated['monthly_interest_rate'] ?? null;
if (!$monthlyRate && isset($validated['annual_interest_rate'])) {
$monthlyRate = $validated['annual_interest_rate'] / 12;
}
// Criar conta passivo com todos os campos
$account = LiabilityAccount::create([
'user_id' => Auth::id(),
'name' => $validated['name'],
'contract_type' => $validated['contract_type'],
'creditor' => $validated['creditor'] ?? null,
'contract_number' => $validated['contract_number'] ?? null,
'description' => $validated['description'] ?? null,
'currency' => $validated['currency'] ?? 'EUR',
'color' => $validated['color'] ?? null,
'principal_amount' => $validated['principal_amount'],
'annual_interest_rate' => $validated['annual_interest_rate'] ?? null,
'monthly_interest_rate' => $monthlyRate,
'amortization_system' => $validated['amortization_system'] ?? 'price',
'total_installments' => $validated['total_installments'] ?? null,
'start_date' => $validated['start_date'],
'first_due_date' => $validated['first_due_date'],
'end_date' => $validated['end_date'] ?? null,
'has_grace_period' => $validated['has_grace_period'] ?? false,
'grace_period_months' => $validated['grace_period_months'] ?? 0,
'status' => LiabilityAccount::STATUS_ACTIVE,
// Campos avançados - Indexadores
'index_type' => $validated['index_type'] ?? 'fixed',
'index_spread' => $validated['index_spread'] ?? null,
'total_effective_cost' => $validated['total_effective_cost'] ?? null,
// Campos avançados - Garantias
'guarantee_type' => $validated['guarantee_type'] ?? 'none',
'guarantee_value' => $validated['guarantee_value'] ?? null,
'guarantee_description' => $validated['guarantee_description'] ?? null,
'guarantor_name' => $validated['guarantor_name'] ?? null,
// Campos avançados - Penalidades
'late_fee_percent' => $validated['late_fee_percent'] ?? null,
'daily_penalty_percent' => $validated['daily_penalty_percent'] ?? null,
'grace_days_for_penalty' => $validated['grace_days_for_penalty'] ?? 0,
// Campos avançados - Específicos
'asset_value' => $validated['asset_value'] ?? null,
'asset_description' => $validated['asset_description'] ?? null,
'residual_value' => $validated['residual_value'] ?? null,
'admin_fee_percent' => $validated['admin_fee_percent'] ?? null,
'reserve_fund_percent' => $validated['reserve_fund_percent'] ?? null,
// Campos avançados - Covenants e gestão
'covenants' => $validated['covenants'] ?? null,
'alert_days_before' => $validated['alert_days_before'] ?? 5,
'internal_responsible' => $validated['internal_responsible'] ?? null,
'internal_notes' => $validated['internal_notes'] ?? null,
'document_number' => $validated['document_number'] ?? null,
'registry_office' => $validated['registry_office'] ?? null,
]);
// Se parcelas foram enviadas, criar diretamente
if (!empty($validated['installments'])) {
foreach ($validated['installments'] as $inst) {
LiabilityInstallment::create([
'liability_account_id' => $account->id,
'installment_number' => $inst['installment_number'],
'due_date' => $inst['due_date'],
'installment_amount' => $inst['installment_amount'],
'principal_amount' => $inst['principal_amount'] ?? 0,
'interest_amount' => $inst['interest_amount'] ?? 0,
'fee_amount' => $inst['fee_amount'] ?? 0,
'status' => $inst['status'] ?? 'pending',
]);
}
} else {
// Gerar parcelas automaticamente
$this->generateInstallments($account, $validated);
}
// Recalcular totais
$account->recalculateTotals();
DB::commit();
return response()->json([
'success' => true,
'message' => 'Cuenta pasivo creada con éxito',
'data' => $account->load('installments'),
], 201);
} catch (\Exception $e) {
DB::rollBack();
return response()->json([
'success' => false,
'message' => 'Error al crear cuenta: ' . $e->getMessage(),
], 422);
}
}
/**
* Gerar parcelas automaticamente baseado no sistema de amortização
*/
private function generateInstallments(LiabilityAccount $account, array $data): void
{
$principal = $account->principal_amount;
$monthlyRate = ($account->monthly_interest_rate ?? 0) / 100;
$totalInstallments = $data['total_installments'] ?? 12;
$amortizationSystem = $account->amortization_system ?? 'price';
$hasGracePeriod = $data['has_grace_period'] ?? false;
$gracePeriodMonths = $data['grace_period_months'] ?? 0;
$insuranceAmount = $data['insurance_amount'] ?? 0;
$adminFeeAmount = $data['admin_fee_amount'] ?? 0;
$firstDueDate = new \DateTime($data['first_due_date']);
$remainingPrincipal = $principal;
// Para sistema PRICE, calcular parcela fixa
$fixedInstallment = 0;
if ($amortizationSystem === 'price' && $monthlyRate > 0) {
$fixedInstallment = $principal * ($monthlyRate * pow(1 + $monthlyRate, $totalInstallments)) /
(pow(1 + $monthlyRate, $totalInstallments) - 1);
} elseif ($amortizationSystem === 'price') {
$fixedInstallment = $principal / $totalInstallments;
}
// Para sistema SAC, calcular amortização fixa
$fixedAmortization = $principal / $totalInstallments;
for ($i = 1; $i <= $totalInstallments; $i++) {
$dueDate = clone $firstDueDate;
$dueDate->modify('+' . ($i - 1) . ' months');
// Carência
if ($hasGracePeriod && $i <= $gracePeriodMonths) {
$interestAmount = $remainingPrincipal * $monthlyRate;
$principalAmount = 0;
$installmentAmount = $interestAmount + $insuranceAmount + $adminFeeAmount;
} else {
if ($amortizationSystem === 'price') {
// Sistema PRICE - parcela fixa
$interestAmount = $remainingPrincipal * $monthlyRate;
$principalAmount = $fixedInstallment - $interestAmount;
$installmentAmount = $fixedInstallment + $insuranceAmount + $adminFeeAmount;
} elseif ($amortizationSystem === 'sac') {
// Sistema SAC - amortização constante
$principalAmount = $fixedAmortization;
$interestAmount = $remainingPrincipal * $monthlyRate;
$installmentAmount = $principalAmount + $interestAmount + $insuranceAmount + $adminFeeAmount;
} else {
// Americano - só juros, principal no final
$interestAmount = $remainingPrincipal * $monthlyRate;
$principalAmount = ($i === $totalInstallments) ? $remainingPrincipal : 0;
$installmentAmount = $interestAmount + $principalAmount + $insuranceAmount + $adminFeeAmount;
}
$remainingPrincipal -= $principalAmount;
}
LiabilityInstallment::create([
'liability_account_id' => $account->id,
'installment_number' => $i,
'due_date' => $dueDate->format('Y-m-d'),
'installment_amount' => round($installmentAmount, 2),
'principal_amount' => round(max(0, $principalAmount), 2),
'interest_amount' => round($interestAmount, 2),
'fee_amount' => round($insuranceAmount + $adminFeeAmount, 2),
'status' => 'pending',
]);
}
}
}

View File

@ -10,16 +10,25 @@
class PlanController extends Controller
{
/**
* List all active plans for pricing page
* List all plans for pricing page (including coming soon)
*/
public function index(): JsonResponse
{
$plans = Plan::active()->ordered()->get();
// Get active plans
$activePlans = Plan::active()->ordered()->get();
// Get coming soon plans (inactive but should be displayed)
$comingSoonPlans = Plan::where('is_active', false)
->whereIn('slug', ['business'])
->ordered()
->get();
$allPlans = $activePlans->merge($comingSoonPlans);
return response()->json([
'success' => true,
'data' => [
'plans' => $plans->map(function ($plan) {
'plans' => $allPlans->map(function ($plan) {
return [
'id' => $plan->id,
'slug' => $plan->slug,
@ -35,6 +44,8 @@ public function index(): JsonResponse
'limits' => $plan->limits,
'is_free' => $plan->is_free,
'is_featured' => $plan->is_featured,
'is_active' => $plan->is_active,
'coming_soon' => !$plan->is_active,
'has_trial' => $plan->has_trial,
'savings_percent' => $plan->savings_percent,
];

View File

@ -0,0 +1,192 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\SiteSetting;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
class SiteSettingsController extends Controller
{
/**
* Get all site settings
*/
public function index()
{
$settings = SiteSetting::all()->mapWithKeys(function ($setting) {
return [$setting->key => [
'value' => $setting->value['value'] ?? $setting->value,
'description' => $setting->description,
'updated_at' => $setting->updated_at,
]];
});
return response()->json([
'success' => true,
'settings' => $settings,
]);
}
/**
* Get a specific setting
*/
public function show(string $key)
{
$setting = SiteSetting::where('key', $key)->first();
if (!$setting) {
return response()->json([
'success' => false,
'message' => 'Configuración no encontrada',
], 404);
}
return response()->json([
'success' => true,
'key' => $setting->key,
'value' => $setting->value['value'] ?? $setting->value,
'description' => $setting->description,
]);
}
/**
* Update a setting
*/
public function update(Request $request, string $key)
{
$request->validate([
'value' => 'required',
'description' => 'nullable|string',
]);
$setting = SiteSetting::setValue(
$key,
$request->value,
$request->description
);
return response()->json([
'success' => true,
'message' => 'Configuración actualizada',
'setting' => [
'key' => $setting->key,
'value' => $setting->value['value'] ?? $setting->value,
'description' => $setting->description,
],
]);
}
/**
* Toggle cnxifly.com page mode
*/
public function toggleCnxiflyPage(Request $request)
{
$request->validate([
'mode' => 'required|in:live,maintenance,construction',
]);
$mode = $request->mode;
// Update database setting
SiteSetting::setValue('cnxifly_page_type', $mode, 'Tipo de página cnxifly.com');
SiteSetting::setValue('cnxifly_maintenance_mode', $mode !== 'live', 'Modo de mantenimiento');
// Get paths
$serverPath = '/var/www/cnxifly';
$localLandingPath = base_path('../landing');
// Log the action
Log::info("CnxiFly page mode changed to: {$mode}");
return response()->json([
'success' => true,
'message' => "Modo de página cambiado a: {$mode}",
'current_mode' => $mode,
'instructions' => $this->getInstructions($mode),
]);
}
/**
* Get cnxifly page status
*/
public function getCnxiflyStatus()
{
$mode = SiteSetting::getValue('cnxifly_page_type', 'live');
$maintenanceMode = SiteSetting::getValue('cnxifly_maintenance_mode', false);
return response()->json([
'success' => true,
'mode' => $mode,
'maintenance_mode' => $maintenanceMode,
'modes_available' => [
'live' => 'Página institucional completa',
'maintenance' => 'Página de mantenimiento',
'construction' => 'Página en construcción',
],
]);
}
/**
* Deploy cnxifly landing page to server
*/
public function deployCnxiflyPage(Request $request)
{
$request->validate([
'mode' => 'required|in:live,maintenance',
]);
$mode = $request->mode;
// Source files are in /var/www/cnxifly-pages/ on the server
$sourceDir = '/var/www/cnxifly-pages';
$targetDir = '/var/www/cnxifly';
$sourceFile = $mode === 'live' ? 'index.html' : 'maintenance.html';
$sourcePath = "{$sourceDir}/{$sourceFile}";
$targetPath = "{$targetDir}/index.html";
// Check if source file exists
if (!File::exists($sourcePath)) {
return response()->json([
'success' => false,
'message' => "Archivo de origen no encontrado: {$sourcePath}. Ejecute deploy-landing.sh primero.",
], 404);
}
// Copy the file
try {
File::copy($sourcePath, $targetPath);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => "Error al copiar archivo: " . $e->getMessage(),
], 500);
}
// Update setting
SiteSetting::setValue('cnxifly_page_type', $mode);
SiteSetting::setValue('cnxifly_maintenance_mode', $mode === 'maintenance');
return response()->json([
'success' => true,
'message' => "Página cnxifly.com actualizada a modo: {$mode}",
'current_mode' => $mode,
]);
}
private function getInstructions(string $mode): array
{
$sourceFile = match($mode) {
'live' => 'index.html',
'maintenance' => 'maintenance.html',
'construction' => 'maintenance.html',
};
return [
'description' => "Para aplicar el modo '{$mode}', copie el archivo {$sourceFile} al servidor",
'source_file' => $sourceFile,
'target_path' => '/var/www/cnxifly/index.html',
];
}
}

View File

@ -9,6 +9,10 @@
use App\Services\PayPalService;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use App\Models\EmailVerificationToken;
use App\Mail\AccountActivationMail;
use App\Mail\SubscriptionCancelledMail;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Log;
use Carbon\Carbon;
@ -30,18 +34,66 @@ public function status(Request $request): JsonResponse
$subscription = $user->subscriptions()->active()->with('plan')->first();
$currentPlan = $user->currentPlan();
// Get current usage
$usage = [
'accounts' => $user->accounts()->count(),
'categories' => $user->categories()->count(),
'budgets' => $user->budgets()->count(),
'transactions' => $user->transactions()->count(),
];
// Calculate usage percentages if plan has limits
$limits = $currentPlan?->limits ?? [];
$usagePercentages = [];
foreach ($usage as $resource => $count) {
$limit = $limits[$resource] ?? null;
if ($limit !== null && $limit > 0) {
$usagePercentages[$resource] = round(($count / $limit) * 100, 1);
} else {
$usagePercentages[$resource] = null; // unlimited
}
}
// Calculate guarantee period info (7 days from subscription creation)
$withinGuaranteePeriod = false;
$guaranteeDaysRemaining = 0;
$guaranteeEndsAt = null;
if ($subscription && $subscription->created_at) {
$guaranteeEndsAt = $subscription->created_at->copy()->addDays(7);
$withinGuaranteePeriod = now()->lt($guaranteeEndsAt);
$guaranteeDaysRemaining = $withinGuaranteePeriod
? (int) ceil(now()->diffInHours($guaranteeEndsAt) / 24)
: 0;
}
return response()->json([
'success' => true,
'data' => [
'has_subscription' => $subscription !== null,
'subscription' => $subscription ? [
'id' => $subscription->id,
'status' => $subscription->status,
'trial_ends_at' => $subscription->trial_ends_at,
'current_period_start' => $subscription->current_period_start,
'current_period_end' => $subscription->current_period_end,
'canceled_at' => $subscription->canceled_at,
'ends_at' => $subscription->ends_at,
'on_trial' => $subscription->isOnTrial(),
'on_grace_period' => $subscription->onGracePeriod(),
] : null,
'on_trial' => $subscription?->isOnTrial() ?? false,
'trial_ends_at' => $subscription?->trial_ends_at,
'days_until_trial_ends' => $subscription?->days_until_trial_ends,
'current_period_start' => $subscription?->current_period_start,
'current_period_end' => $subscription?->current_period_end,
'status' => $subscription?->status,
'status_label' => $subscription?->status_label,
'canceled_at' => $subscription?->canceled_at,
'on_grace_period' => $subscription?->onGracePeriod() ?? false,
'within_guarantee_period' => $withinGuaranteePeriod,
'guarantee_days_remaining' => $guaranteeDaysRemaining,
'guarantee_ends_at' => $guaranteeEndsAt?->toIso8601String(),
'plan' => $currentPlan ? [
'id' => $currentPlan->id,
'slug' => $currentPlan->slug,
@ -53,6 +105,8 @@ public function status(Request $request): JsonResponse
'features' => $currentPlan->features,
'limits' => $currentPlan->limits,
] : null,
'usage' => $usage,
'usage_percentages' => $usagePercentages,
],
]);
}
@ -144,6 +198,116 @@ public function subscribe(Request $request): JsonResponse
]);
}
/**
* Start subscription for newly registered user (public - no auth required)
* Used immediately after registration, before user is logged in
*/
public function startSubscription(Request $request): JsonResponse
{
$request->validate([
'plan_id' => 'required|integer|exists:plans,id',
'user_email' => 'required|email|exists:users,email',
]);
$user = \App\Models\User::where('email', $request->user_email)->first();
if (!$user) {
return response()->json([
'success' => false,
'message' => 'User not found',
], 404);
}
// Verify user hasn't already verified email (prevent abuse)
if ($user->email_verified_at) {
return response()->json([
'success' => false,
'message' => 'User already activated. Please login.',
], 400);
}
$plan = Plan::where('id', $request->plan_id)->where('is_active', true)->first();
if (!$plan) {
return response()->json([
'success' => false,
'message' => 'Plan not found or inactive',
], 404);
}
// All plans are paid now - no free subscriptions during registration
if ($plan->is_free || $plan->price <= 0) {
return response()->json([
'success' => false,
'message' => 'All plans require payment',
], 400);
}
// Check if PayPal is configured
if (!$this->paypal->isConfigured()) {
return response()->json([
'success' => false,
'message' => 'Payment gateway not configured',
], 500);
}
// Check if plan has PayPal plan ID
if (!$plan->paypal_plan_id) {
return response()->json([
'success' => false,
'message' => 'Plan not configured for payments yet',
], 500);
}
// Create PayPal subscription
$frontendUrl = config('app.frontend_url', 'https://webmoney.cnxifly.com');
$returnUrl = "{$frontendUrl}/payment-success?user_email={$user->email}&plan={$plan->slug}";
$cancelUrl = "{$frontendUrl}/register?payment_canceled=true";
$paypalSubscription = $this->paypal->createSubscription($plan, $returnUrl, $cancelUrl);
if (!$paypalSubscription) {
return response()->json([
'success' => false,
'message' => 'Failed to create subscription',
], 500);
}
// Find approve link
$approveUrl = collect($paypalSubscription['links'] ?? [])
->firstWhere('rel', 'approve')['href'] ?? null;
if (!$approveUrl) {
return response()->json([
'success' => false,
'message' => 'No approval URL received',
], 500);
}
// Create pending subscription in our DB
$subscription = Subscription::create([
'user_id' => $user->id,
'plan_id' => $plan->id,
'status' => Subscription::STATUS_PENDING,
'paypal_subscription_id' => $paypalSubscription['id'],
'paypal_status' => $paypalSubscription['status'],
'paypal_data' => $paypalSubscription,
'price_paid' => $plan->price,
'currency' => $plan->currency,
]);
\Illuminate\Support\Facades\Log::info("Started subscription for user {$user->email}, PayPal ID: {$paypalSubscription['id']}");
return response()->json([
'success' => true,
'data' => [
'subscription_id' => $subscription->id,
'paypal_subscription_id' => $paypalSubscription['id'],
'approve_url' => $approveUrl,
],
]);
}
/**
* Subscribe to free plan
*/
@ -224,13 +388,139 @@ public function confirm(Request $request): JsonResponse
)->markAsPaid($paypalData['id'] ?? null);
}
// Send activation email if subscription is active and user not verified yet
$activationSent = false;
if ($subscription->isActive() && !$user->email_verified_at) {
try {
$verificationToken = EmailVerificationToken::createForUser($user);
$frontendUrl = config('app.frontend_url', 'https://webmoney.cnxifly.com');
$activationUrl = "{$frontendUrl}/activate?token={$verificationToken->token}";
Mail::to($user->email)->send(new AccountActivationMail(
$user,
$activationUrl,
$subscription->plan->name
));
$activationSent = true;
Log::info("Activation email sent to {$user->email}");
} catch (\Exception $e) {
Log::error("Failed to send activation email: " . $e->getMessage());
}
}
return response()->json([
'success' => true,
'message' => 'Subscription confirmed',
'message' => $activationSent
? 'Suscripción confirmada. Revisa tu email para activar tu cuenta.'
: 'Subscription confirmed',
'data' => [
'status' => $subscription->status,
'status_label' => $subscription->status_label,
'plan' => $subscription->plan->name,
'activation_email_sent' => $activationSent,
'email_verified' => $user->email_verified_at !== null,
],
]);
}
/**
* Confirm subscription after PayPal approval (public - no auth required)
* Used for new user registration flow
*/
public function confirmPublic(Request $request): JsonResponse
{
$request->validate([
'subscription_id' => 'required|string',
'user_email' => 'required|email',
]);
$user = \App\Models\User::where('email', $request->user_email)->first();
if (!$user) {
return response()->json([
'success' => false,
'message' => 'User not found',
], 404);
}
$subscription = Subscription::where('paypal_subscription_id', $request->subscription_id)
->where('user_id', $user->id)
->first();
if (!$subscription) {
return response()->json([
'success' => false,
'message' => 'Subscription not found',
], 404);
}
// Get subscription details from PayPal
$paypalData = $this->paypal->getSubscription($request->subscription_id);
if (!$paypalData) {
return response()->json([
'success' => false,
'message' => 'Failed to verify subscription with PayPal',
], 500);
}
\Illuminate\Support\Facades\Log::info("PayPal confirmation for {$user->email}: " . json_encode($paypalData));
// Update subscription based on PayPal status
$this->updateSubscriptionFromPayPal($subscription, $paypalData);
// Cancel other active subscriptions
$user->subscriptions()
->where('id', '!=', $subscription->id)
->active()
->update([
'status' => Subscription::STATUS_CANCELED,
'canceled_at' => now(),
'ends_at' => now(),
'cancel_reason' => 'Replaced by new subscription',
]);
// Create invoice for the subscription
if ($subscription->isActive() && !$subscription->plan->is_free) {
Invoice::createForSubscription(
$subscription,
Invoice::REASON_SUBSCRIPTION_CREATE,
"{$subscription->plan->name} - Nueva suscripción"
)->markAsPaid($paypalData['id'] ?? null);
}
// Send activation email if subscription is active and user not verified yet
$activationSent = false;
if ($subscription->isActive() && !$user->email_verified_at) {
try {
$verificationToken = EmailVerificationToken::createForUser($user);
$frontendUrl = config('app.frontend_url', 'https://webmoney.cnxifly.com');
$activationUrl = "{$frontendUrl}/activate?token={$verificationToken->token}";
Mail::to($user->email)->send(new AccountActivationMail(
$user,
$activationUrl,
$subscription->plan->name
));
$activationSent = true;
\Illuminate\Support\Facades\Log::info("Activation email sent to {$user->email}");
} catch (\Exception $e) {
\Illuminate\Support\Facades\Log::error("Failed to send activation email: " . $e->getMessage());
}
}
return response()->json([
'success' => true,
'message' => $activationSent
? 'Pagamento confirmado! Verifique seu email para ativar sua conta.'
: 'Payment confirmed',
'data' => [
'status' => $subscription->status,
'status_label' => $subscription->status_label,
'plan' => $subscription->plan->name,
'activation_email_sent' => $activationSent,
],
]);
}
@ -243,6 +533,7 @@ public function cancel(Request $request): JsonResponse
$request->validate([
'reason' => 'nullable|string|max:500',
'immediately' => 'nullable|boolean',
'request_refund' => 'nullable|boolean',
]);
$user = $request->user();
@ -255,35 +546,108 @@ public function cancel(Request $request): JsonResponse
], 404);
}
// If it's a paid plan, cancel on PayPal
if ($subscription->paypal_subscription_id && !$subscription->plan->is_free) {
$canceled = $this->paypal->cancelSubscription(
$subscription->paypal_subscription_id,
$request->reason ?? 'User requested cancellation'
);
$refundResult = null;
$isWithinGuaranteePeriod = false;
// Check if within 7-day guarantee period (from subscription creation date)
if ($subscription->created_at) {
$daysSinceCreation = now()->diffInDays($subscription->created_at);
$isWithinGuaranteePeriod = $daysSinceCreation <= 7;
}
if (!$canceled) {
return response()->json([
'success' => false,
'message' => 'Failed to cancel subscription on PayPal',
], 500);
// If it's a paid plan with PayPal subscription
if ($subscription->paypal_subscription_id && !$subscription->plan->is_free) {
// If user requests refund and is within guarantee period, cancel and refund
if ($request->boolean('request_refund') && $isWithinGuaranteePeriod) {
$refundResult = $this->paypal->cancelAndRefund(
$subscription->paypal_subscription_id,
$request->reason ?? 'Refund within 7-day guarantee period'
);
if (!$refundResult['canceled']) {
return response()->json([
'success' => false,
'message' => 'Failed to cancel subscription on PayPal',
], 500);
}
Log::info('Subscription canceled with refund', [
'user_id' => $user->id,
'subscription_id' => $subscription->id,
'refund_result' => $refundResult,
]);
} else {
// Just cancel without refund
$canceled = $this->paypal->cancelSubscription(
$subscription->paypal_subscription_id,
$request->reason ?? 'User requested cancellation'
);
if (!$canceled) {
return response()->json([
'success' => false,
'message' => 'Failed to cancel subscription on PayPal',
], 500);
}
}
}
// Cancel in our DB
// Cancel in our DB - always immediately when refunded
$cancelImmediately = $request->boolean('request_refund') || $request->boolean('immediately', false);
$subscription->cancel(
$request->reason,
$request->boolean('immediately', false)
$cancelImmediately
);
// Send cancellation confirmation email
try {
$wasRefunded = $refundResult && $refundResult['refunded'];
$refundAmount = $wasRefunded && isset($refundResult['refund_amount'])
? number_format($refundResult['refund_amount'], 2) . ' ' . ($subscription->plan->currency ?? 'EUR')
: null;
Mail::to($user->email)->send(new SubscriptionCancelledMail(
$user,
$subscription->plan->name,
$wasRefunded,
$refundAmount
));
Log::info('Cancellation email sent', [
'user_id' => $user->id,
'email' => $user->email,
'refunded' => $wasRefunded,
]);
} catch (\Exception $e) {
Log::error('Failed to send cancellation email', [
'user_id' => $user->id,
'error' => $e->getMessage(),
]);
// Don't fail the cancellation just because email failed
}
// Build response message
$message = 'Subscription canceled';
if ($refundResult && $refundResult['refunded']) {
$message = 'Subscription canceled and refund processed';
} elseif ($refundResult && !$refundResult['refunded']) {
$message = 'Subscription canceled. Refund could not be processed automatically - please contact support.';
} elseif ($cancelImmediately) {
$message = 'Subscription canceled immediately';
} else {
$message = 'Subscription will be canceled at period end';
}
return response()->json([
'success' => true,
'message' => $request->boolean('immediately')
? 'Subscription canceled immediately'
: 'Subscription will be canceled at period end',
'message' => $message,
'data' => [
'status' => $subscription->status,
'ends_at' => $subscription->ends_at,
'refunded' => $refundResult['refunded'] ?? false,
'refund_id' => $refundResult['refund_id'] ?? null,
'within_guarantee_period' => $isWithinGuaranteePeriod,
],
]);
}
@ -352,6 +716,12 @@ public function paypalConfig(): JsonResponse
*/
public function webhook(Request $request): JsonResponse
{
// Log all incoming webhooks for debugging
Log::channel('single')->info('=== PAYPAL WEBHOOK RECEIVED ===', [
'event_type' => $request->input('event_type'),
'content' => $request->all(),
]);
$webhookId = config('services.paypal.webhook_id');
// Verify webhook signature (skip in sandbox for testing)
@ -519,11 +889,39 @@ private function updateSubscriptionFromPayPal(Subscription $subscription, array
switch ($status) {
case 'ACTIVE':
$subscription->status = Subscription::STATUS_ACTIVE;
if (isset($paypalData['billing_info']['next_billing_time'])) {
$subscription->current_period_end = Carbon::parse($paypalData['billing_info']['next_billing_time']);
// Always set current_period_start to now() on activation if not set
if (!$subscription->current_period_start) {
$subscription->current_period_start = now();
}
// Calculate period end based on plan interval
$plan = $subscription->plan;
if ($plan) {
$periodEnd = now();
if ($plan->interval === 'year') {
$periodEnd = now()->addYear();
} else {
$periodEnd = now()->addMonth();
}
$subscription->current_period_end = $periodEnd;
}
// Only use PayPal dates if they make sense (within reasonable range)
if (isset($paypalData['billing_info']['last_payment']['time'])) {
$subscription->current_period_start = Carbon::parse($paypalData['billing_info']['last_payment']['time']);
$lastPayment = Carbon::parse($paypalData['billing_info']['last_payment']['time']);
// Accept if within last 30 days
if ($lastPayment->gte(now()->subDays(30)) && $lastPayment->lte(now()->addDay())) {
$subscription->current_period_start = $lastPayment;
}
}
if (isset($paypalData['billing_info']['next_billing_time'])) {
$nextBilling = Carbon::parse($paypalData['billing_info']['next_billing_time']);
// Accept if within next 13 months (reasonable for monthly/yearly plans)
if ($nextBilling->gt(now()) && $nextBilling->lt(now()->addMonths(13))) {
$subscription->current_period_end = $nextBilling;
}
}
break;

View File

@ -44,6 +44,9 @@ public function index(Request $request): JsonResponse
if ($request->has('start_date') && $request->has('end_date')) {
$dateField = $request->get('date_field', 'planned_date');
$query->inPeriod($request->start_date, $request->end_date, $dateField);
} else {
// Sem filtro de data: não mostrar transações futuras
$query->where('planned_date', '<=', now()->toDateString());
}
// Busca por descrição e valores
@ -424,6 +427,9 @@ public function byWeek(Request $request): JsonResponse
// Filtro por período
if ($request->has('start_date') && $request->has('end_date')) {
$query->inPeriod($request->start_date, $request->end_date, $dateField);
} else {
// Sem filtro de data: não mostrar transações futuras
$query->where('planned_date', '<=', now()->toDateString());
}
// Busca por descrição e valores

View File

@ -0,0 +1,413 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Models\Plan;
use App\Models\Subscription;
use App\Mail\WelcomeNewUser;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail;
use Illuminate\Validation\Rules\Password;
use Carbon\Carbon;
class UserManagementController extends Controller
{
/**
* Create a new user with specified role/plan
* user_type: 'free' | 'pro' | 'admin'
*/
public function store(Request $request)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'email' => 'required|email|unique:users,email',
'password' => 'nullable|string|min:8',
'language' => 'sometimes|string|in:es,pt-BR,en',
'currency' => 'sometimes|string|size:3',
'user_type' => 'sometimes|string|in:free,pro,admin',
'send_welcome_email' => 'sometimes|boolean',
]);
// Generate random password if not provided or empty
$password = !empty($validated['password']) ? $validated['password'] : bin2hex(random_bytes(8));
$userType = $validated['user_type'] ?? 'free';
$sendWelcomeEmail = $validated['send_welcome_email'] ?? true;
$user = User::create([
'name' => $validated['name'],
'email' => $validated['email'],
'password' => Hash::make($password),
'language' => $validated['language'] ?? 'es',
'currency' => $validated['currency'] ?? 'EUR',
'email_verified_at' => now(), // Auto-verify admin-created users
'is_admin' => $userType === 'admin',
]);
$subscriptionInfo = null;
// Create Pro subscription if user_type is 'pro' or 'admin'
if (in_array($userType, ['pro', 'admin'])) {
$proPlan = Plan::where('slug', 'pro-annual')->first();
if ($proPlan) {
$subscription = Subscription::create([
'user_id' => $user->id,
'plan_id' => $proPlan->id,
'status' => Subscription::STATUS_ACTIVE,
'current_period_start' => now(),
'current_period_end' => Carbon::create(2037, 12, 31, 23, 59, 59), // "Lifetime" subscription (max timestamp)
'paypal_subscription_id' => 'ADMIN_GRANTED_' . strtoupper(bin2hex(random_bytes(8))),
'paypal_status' => 'ACTIVE',
'price_paid' => 0,
'currency' => 'EUR',
]);
$subscriptionInfo = [
'plan' => $proPlan->name,
'status' => 'active',
'expires' => 'Nunca (otorgado por admin)',
];
}
}
// Send welcome email with temporary password
$emailSent = false;
if ($sendWelcomeEmail) {
try {
Mail::to($user->email)->send(new WelcomeNewUser($user, $password));
$emailSent = true;
} catch (\Exception $e) {
\Log::error('Failed to send welcome email: ' . $e->getMessage());
}
}
return response()->json([
'success' => true,
'message' => 'Usuario creado correctamente',
'data' => [
'user' => [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'is_admin' => $user->is_admin,
'language' => $user->language,
],
'user_type' => $userType,
'subscription' => $subscriptionInfo,
'temporary_password' => isset($validated['password']) ? null : $password,
'welcome_email_sent' => $emailSent,
],
], 201);
}
/**
* List all users with their subscription info
*/
public function index(Request $request)
{
$query = User::with(['subscription.plan'])
->withCount(['accounts', 'categories', 'budgets', 'transactions']);
// Search
if ($request->has('search') && $request->search) {
$search = $request->search;
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
});
}
// Filter by subscription status
if ($request->has('subscription_status')) {
if ($request->subscription_status === 'active') {
$query->whereHas('subscription');
} elseif ($request->subscription_status === 'free') {
$query->whereDoesntHave('subscription');
}
}
// Pagination
$perPage = $request->get('per_page', 20);
$users = $query->orderBy('created_at', 'desc')->paginate($perPage);
// Transform data
$users->getCollection()->transform(function ($user) {
return [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'language' => $user->language,
'currency' => $user->currency,
'is_admin' => (bool) $user->is_admin,
'created_at' => $user->created_at,
'last_login_at' => $user->last_login_at,
'email_verified_at' => $user->email_verified_at,
'subscription' => $user->subscription ? [
'plan_name' => $user->subscription->plan->name ?? 'Unknown',
'plan_slug' => $user->subscription->plan->slug ?? 'unknown',
'status' => $user->subscription->status,
'current_period_end' => $user->subscription->current_period_end,
] : null,
'usage' => [
'accounts' => $user->accounts_count,
'categories' => $user->categories_count,
'budgets' => $user->budgets_count,
'transactions' => $user->transactions_count,
],
];
});
return response()->json([
'success' => true,
'data' => $users->items(),
'pagination' => [
'current_page' => $users->currentPage(),
'last_page' => $users->lastPage(),
'per_page' => $users->perPage(),
'total' => $users->total(),
],
]);
}
/**
* Get single user details
*/
public function show($id)
{
$user = User::with(['subscription.plan', 'subscriptions.plan'])
->withCount(['accounts', 'categories', 'budgets', 'transactions', 'goals'])
->findOrFail($id);
return response()->json([
'success' => true,
'data' => [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'language' => $user->language,
'currency' => $user->currency,
'created_at' => $user->created_at,
'last_login_at' => $user->last_login_at,
'email_verified_at' => $user->email_verified_at,
'subscription' => $user->subscription ? [
'id' => $user->subscription->id,
'plan' => $user->subscription->plan,
'status' => $user->subscription->status,
'paypal_subscription_id' => $user->subscription->paypal_subscription_id,
'current_period_start' => $user->subscription->current_period_start,
'current_period_end' => $user->subscription->current_period_end,
'canceled_at' => $user->subscription->canceled_at,
] : null,
'subscription_history' => $user->subscriptions->map(function ($sub) {
return [
'plan_name' => $sub->plan->name ?? 'Unknown',
'status' => $sub->status,
'created_at' => $sub->created_at,
'canceled_at' => $sub->canceled_at,
];
}),
'usage' => [
'accounts' => $user->accounts_count,
'categories' => $user->categories_count,
'budgets' => $user->budgets_count,
'transactions' => $user->transactions_count,
'goals' => $user->goals_count,
],
],
]);
}
/**
* Update user
*/
public function update(Request $request, $id)
{
$user = User::findOrFail($id);
// Don't allow changing main admin's admin status
if ($user->email === 'marco@cnxifly.com' && $request->has('is_admin') && !$request->is_admin) {
return response()->json([
'success' => false,
'message' => 'No se puede remover permisos del administrador principal',
], 403);
}
$validated = $request->validate([
'name' => 'sometimes|string|max:255',
'email' => 'sometimes|email|unique:users,email,' . $id,
'language' => 'sometimes|string|in:es,pt-BR,en',
'currency' => 'sometimes|string|size:3',
'is_admin' => 'sometimes|boolean',
]);
$user->update($validated);
return response()->json([
'success' => true,
'message' => 'Usuario actualizado correctamente',
'data' => [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'language' => $user->language,
'currency' => $user->currency,
'is_admin' => $user->is_admin,
],
]);
}
/**
* Reset user password (generate random)
*/
public function resetPassword($id)
{
$user = User::findOrFail($id);
// Generate random password
$newPassword = bin2hex(random_bytes(8)); // 16 chars
$user->password = Hash::make($newPassword);
$user->save();
return response()->json([
'success' => true,
'message' => 'Contraseña restablecida correctamente',
'data' => [
'temporary_password' => $newPassword,
],
]);
}
/**
* Delete user and all their data
*/
public function destroy($id)
{
$user = User::findOrFail($id);
// Don't allow deleting admin
if ($user->email === 'marco@cnxifly.com') {
return response()->json([
'success' => false,
'message' => 'No se puede eliminar el usuario administrador',
], 403);
}
// Delete user (cascade will handle related data)
$user->delete();
return response()->json([
'success' => true,
'message' => 'Usuario eliminado correctamente',
]);
}
/**
* Change user subscription plan
*/
public function changePlan(Request $request, $id)
{
$user = User::findOrFail($id);
$validated = $request->validate([
'plan' => 'required|string|in:free,pro',
]);
// If changing to free, cancel any existing subscription
if ($validated['plan'] === 'free') {
$subscription = $user->subscription;
if ($subscription) {
$subscription->update([
'status' => Subscription::STATUS_CANCELED,
'canceled_at' => now(),
]);
}
return response()->json([
'success' => true,
'message' => 'Usuario cambiado a plan Free',
'data' => [
'plan' => 'free',
'subscription' => null,
],
]);
}
// If changing to pro, create or reactivate subscription
if ($validated['plan'] === 'pro') {
$proPlan = Plan::where('slug', 'pro-annual')->first();
if (!$proPlan) {
return response()->json([
'success' => false,
'message' => 'Plan Pro no encontrado en el sistema',
], 404);
}
// Check if user has existing subscription
$subscription = $user->subscription;
if ($subscription) {
// Reactivate existing subscription
$subscription->update([
'status' => Subscription::STATUS_ACTIVE,
'canceled_at' => null,
'current_period_start' => now(),
'current_period_end' => Carbon::create(2037, 12, 31, 23, 59, 59),
]);
} else {
// Create new subscription
$subscription = Subscription::create([
'user_id' => $user->id,
'plan_id' => $proPlan->id,
'status' => Subscription::STATUS_ACTIVE,
'current_period_start' => now(),
'current_period_end' => Carbon::create(2037, 12, 31, 23, 59, 59),
'paypal_subscription_id' => 'ADMIN_GRANTED_' . strtoupper(bin2hex(random_bytes(8))),
'paypal_status' => 'ACTIVE',
'price_paid' => 0,
'currency' => 'EUR',
]);
}
return response()->json([
'success' => true,
'message' => 'Usuario cambiado a plan Pro',
'data' => [
'plan' => 'pro',
'subscription' => [
'id' => $subscription->id,
'plan_name' => $proPlan->name,
'status' => $subscription->status,
'current_period_end' => $subscription->current_period_end,
],
],
]);
}
}
/**
* Get summary statistics
*/
public function summary()
{
$totalUsers = User::count();
$activeSubscribers = Subscription::where('status', 'active')->count();
$freeUsers = $totalUsers - $activeSubscribers;
$newUsersThisMonth = User::whereMonth('created_at', now()->month)
->whereYear('created_at', now()->year)
->count();
return response()->json([
'success' => true,
'data' => [
'total_users' => $totalUsers,
'active_subscribers' => $activeSubscribers,
'free_users' => $freeUsers,
'new_users_this_month' => $newUsersThisMonth,
],
]);
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class AdminOnly
{
/**
* Admin email - only this user can access restricted features
*/
private const ADMIN_EMAIL = 'marco@cnxifly.com';
/**
* Handle an incoming request.
*/
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
if (!$user || $user->email !== self::ADMIN_EMAIL) {
return response()->json([
'success' => false,
'message' => 'Access denied. This feature is not available.',
], 403);
}
return $next($request);
}
}

View File

@ -0,0 +1,109 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class CheckPlanLimits
{
/**
* Resource type to limit mapping
*/
protected array $resourceLimits = [
'accounts' => 'accounts',
'categories' => 'categories',
'subcategories' => 'subcategories',
'budgets' => 'budgets',
'transactions' => 'transactions',
'goals' => 'goals',
];
/**
* Handle an incoming request.
*/
public function handle(Request $request, Closure $next, string $resource): Response
{
// Only check on store (create) requests
if (!in_array($request->method(), ['POST'])) {
return $next($request);
}
$user = $request->user();
if (!$user) {
return $next($request);
}
$plan = $user->currentPlan();
if (!$plan) {
return $next($request);
}
$limits = $plan->limits ?? [];
$limitKey = $this->resourceLimits[$resource] ?? null;
if (!$limitKey || !isset($limits[$limitKey])) {
return $next($request);
}
$limit = $limits[$limitKey];
// null means unlimited
if ($limit === null) {
return $next($request);
}
$currentCount = $this->getCurrentCount($user, $resource);
if ($currentCount >= $limit) {
return response()->json([
'success' => false,
'message' => $this->getLimitMessage($resource, $limit),
'error' => 'plan_limit_exceeded',
'data' => [
'resource' => $resource,
'current' => $currentCount,
'limit' => $limit,
'plan' => $plan->name,
'upgrade_url' => '/pricing',
],
], 403);
}
return $next($request);
}
/**
* Get current count for a resource
*/
protected function getCurrentCount($user, string $resource): int
{
return match ($resource) {
'accounts' => $user->accounts()->count(),
'categories' => $user->categories()->whereNull('parent_id')->count(),
'subcategories' => $user->categories()->whereNotNull('parent_id')->count(),
'budgets' => $user->budgets()->count(),
'transactions' => $user->transactions()->count(),
'goals' => $user->goals()->count(),
default => 0,
};
}
/**
* Get user-friendly limit message
*/
protected function getLimitMessage(string $resource, int $limit): string
{
$messages = [
'accounts' => "Has alcanzado el límite de {$limit} cuenta(s) de tu plan. Actualiza a Pro para cuentas ilimitadas.",
'categories' => "Has alcanzado el límite de {$limit} categorías de tu plan. Actualiza a Pro para categorías ilimitadas.",
'subcategories' => "Has alcanzado el límite de {$limit} subcategorías de tu plan. Actualiza a Pro para subcategorías ilimitadas.",
'budgets' => "Has alcanzado el límite de {$limit} presupuesto(s) de tu plan. Actualiza a Pro para presupuestos ilimitados.",
'transactions' => "Has alcanzado el límite de {$limit} transacciones de tu plan. Actualiza a Pro para transacciones ilimitadas.",
'goals' => "Has alcanzado el límite de {$limit} meta(s) de tu plan. Actualiza a Pro para metas ilimitadas.",
];
return $messages[$resource] ?? "Has alcanzado el límite de tu plan para este recurso.";
}
}

View File

@ -0,0 +1,78 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class DemoProtection
{
/**
* Rotas e métodos permitidos para usuário demo (whitelist)
* Estas rotas são operações de leitura/cálculo que não modificam dados reais
*/
protected array $allowedRoutes = [
'GET' => ['*'], // Permite todos os GETs
'POST' => [
'api/login',
'api/logout',
'api/refresh',
'api/accounts/recalculate-all', // Recálculo de saldos (não modifica dados)
'api/accounts/*/recalculate', // Recálculo de conta específica
'api/categories/match', // Matching de categorias (leitura)
'api/cost-centers/match', // Matching de centros de custo (leitura)
],
];
/**
* Handle an incoming request.
* Bloqueia operações de escrita (POST/PUT/PATCH/DELETE) para usuário demo
*/
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
// Se não está logado ou não é demo, permite tudo
if (!$user || !$user->is_demo) {
return $next($request);
}
$method = $request->method();
$path = $request->path();
// GET sempre permitido para demo
if ($method === 'GET') {
return $next($request);
}
// Verifica se a rota está na whitelist
if (isset($this->allowedRoutes[$method])) {
foreach ($this->allowedRoutes[$method] as $allowedPath) {
if ($allowedPath === '*' || $this->pathMatches($path, $allowedPath)) {
return $next($request);
}
}
}
// Bloqueia a operação para usuário demo
return response()->json([
'success' => false,
'message' => 'Usuario DEMO: Esta acción no está permitida en modo demostración. Los datos no pueden ser modificados.',
'demo_mode' => true,
], 403);
}
/**
* Verifica se o path corresponde ao padrão
*/
protected function pathMatches(string $path, string $pattern): bool
{
if ($pattern === '*') {
return true;
}
$pattern = str_replace('*', '.*', $pattern);
return (bool) preg_match('#^' . $pattern . '$#', $path);
}
}

View File

@ -0,0 +1,35 @@
<?php
namespace App\Mail;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class AccountActivationMail extends Mailable
{
use Queueable, SerializesModels;
public function __construct(
public User $user,
public string $activationUrl,
public string $planName
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: '🎉 Ativa a tua conta WEBMoney - Pagamento confirmado!',
);
}
public function content(): Content
{
return new Content(
view: 'emails.account-activation',
);
}
}

View File

@ -0,0 +1,122 @@
<?php
namespace App\Mail;
use App\Models\User;
use App\Models\Subscription;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Mail\Mailables\Address;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\App;
class SubscriptionCancelledMail extends Mailable
{
use Queueable, SerializesModels;
public User $user;
public string $planName;
public bool $wasRefunded;
public ?string $refundAmount;
public string $userLocale;
/**
* Create a new message instance.
*/
public function __construct(
User $user,
string $planName,
bool $wasRefunded = false,
?string $refundAmount = null
) {
$this->user = $user;
$this->planName = $planName;
$this->wasRefunded = $wasRefunded;
$this->refundAmount = $refundAmount;
$this->userLocale = $user->locale ?? $user->language ?? 'es';
}
/**
* Get the message envelope.
*/
public function envelope(): Envelope
{
// Set locale for translations
App::setLocale($this->userLocale);
$subject = $this->getSubject();
return new Envelope(
from: new Address('no-reply@cnxifly.com', 'WEBMoney - ConneXiFly'),
replyTo: [
new Address('support@cnxifly.com', $this->getSupportName()),
],
subject: $subject,
tags: ['subscription', 'cancellation', $this->wasRefunded ? 'refund' : 'no-refund'],
metadata: [
'user_id' => $this->user->id,
'user_email' => $this->user->email,
'plan' => $this->planName,
'refunded' => $this->wasRefunded,
],
);
}
/**
* Get the message content definition.
*/
public function content(): Content
{
// Set locale for translations
App::setLocale($this->userLocale);
return new Content(
view: 'emails.subscription-cancelled',
text: 'emails.subscription-cancelled-text',
with: [
'userName' => $this->user->name,
'userEmail' => $this->user->email,
'planName' => $this->planName,
'wasRefunded' => $this->wasRefunded,
'refundAmount' => $this->refundAmount,
'locale' => $this->userLocale,
],
);
}
/**
* Get the subject based on locale
*/
private function getSubject(): string
{
$subjects = [
'es' => $this->wasRefunded
? 'Confirmación de cancelación y reembolso - WEBMoney'
: 'Confirmación de cancelación de suscripción - WEBMoney',
'pt-BR' => $this->wasRefunded
? 'Confirmação de cancelamento e reembolso - WEBMoney'
: 'Confirmação de cancelamento de assinatura - WEBMoney',
'en' => $this->wasRefunded
? 'Cancellation and Refund Confirmation - WEBMoney'
: 'Subscription Cancellation Confirmation - WEBMoney',
];
return $subjects[$this->userLocale] ?? $subjects['es'];
}
/**
* Get support name based on locale
*/
private function getSupportName(): string
{
$names = [
'es' => 'Soporte WEBMoney',
'pt-BR' => 'Suporte WEBMoney',
'en' => 'WEBMoney Support',
];
return $names[$this->userLocale] ?? $names['es'];
}
}

View File

@ -0,0 +1,77 @@
<?php
namespace App\Mail;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Mail\Mailables\Headers;
use Illuminate\Queue\SerializesModels;
class WelcomeNewUser extends Mailable
{
use Queueable, SerializesModels;
public User $user;
public string $temporaryPassword;
public string $language;
/**
* Create a new message instance.
*/
public function __construct(User $user, string $temporaryPassword)
{
$this->user = $user;
$this->temporaryPassword = $temporaryPassword;
$this->language = $user->language ?? 'es';
}
/**
* Get the message headers.
*/
public function headers(): Headers
{
return new Headers(
text: [
'X-Priority' => '3',
'X-Mailer' => 'WebMoney Mailer',
'List-Unsubscribe' => '<mailto:unsubscribe@cnxifly.com>',
],
);
}
/**
* Get the message envelope.
*/
public function envelope(): Envelope
{
$subjects = [
'es' => 'WebMoney - Credenciales de acceso a tu cuenta',
'pt-BR' => 'WebMoney - Credenciais de acesso à sua conta',
'en' => 'WebMoney - Your account access credentials',
];
return new Envelope(
subject: $subjects[$this->language] ?? $subjects['es'],
);
}
/**
* Get the message content definition.
*/
public function content(): Content
{
return new Content(
view: 'emails.welcome-new-user',
text: 'emails.welcome-new-user-text',
with: [
'user' => $this->user,
'temporaryPassword' => $this->temporaryPassword,
'language' => $this->language,
'loginUrl' => config('app.frontend_url', 'https://webmoney.cnxifly.com') . '/login',
],
);
}
}

View File

@ -0,0 +1,351 @@
<?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 AssetAccount extends Model
{
use HasFactory, SoftDeletes;
/**
* Status do ativo
*/
public const STATUS_ACTIVE = 'active';
public const STATUS_SOLD = 'sold';
public const STATUS_DEPRECIATED = 'depreciated';
public const STATUS_WRITTEN_OFF = 'written_off';
public const STATUSES = [
self::STATUS_ACTIVE => 'Activo',
self::STATUS_SOLD => 'Vendido',
self::STATUS_DEPRECIATED => 'Depreciado',
self::STATUS_WRITTEN_OFF => 'Dado de baja',
];
/**
* Tipos de ativo
*/
public const ASSET_TYPE_REAL_ESTATE = 'real_estate';
public const ASSET_TYPE_VEHICLE = 'vehicle';
public const ASSET_TYPE_INVESTMENT = 'investment';
public const ASSET_TYPE_EQUIPMENT = 'equipment';
public const ASSET_TYPE_INVENTORY = 'inventory';
public const ASSET_TYPE_RECEIVABLE = 'receivable';
public const ASSET_TYPE_CASH = 'cash';
public const ASSET_TYPE_OTHER = 'other';
public const ASSET_TYPES = [
self::ASSET_TYPE_REAL_ESTATE => [
'name' => 'Inmueble',
'description' => 'Casa, apartamento, terreno, local comercial',
'icon' => 'home',
'is_depreciable' => true,
'fields' => ['property_type', 'address', 'property_area_m2', 'registry_number'],
],
self::ASSET_TYPE_VEHICLE => [
'name' => 'Vehículo',
'description' => 'Coche, moto, camión, maquinaria móvil',
'icon' => 'truck',
'is_depreciable' => true,
'fields' => ['vehicle_brand', 'vehicle_model', 'vehicle_year', 'vehicle_plate', 'vehicle_mileage'],
],
self::ASSET_TYPE_INVESTMENT => [
'name' => 'Inversión',
'description' => 'Acciones, fondos, bonos, criptomonedas',
'icon' => 'chart-bar',
'is_depreciable' => false,
'fields' => ['investment_type', 'institution', 'ticker', 'quantity', 'unit_price'],
],
self::ASSET_TYPE_EQUIPMENT => [
'name' => 'Equipamiento',
'description' => 'Maquinaria, herramientas, ordenadores',
'icon' => 'cog',
'is_depreciable' => true,
'fields' => ['equipment_brand', 'equipment_model', 'serial_number', 'warranty_expiry'],
],
self::ASSET_TYPE_INVENTORY => [
'name' => 'Inventario',
'description' => 'Stock, mercancías, materias primas',
'icon' => 'archive-box',
'is_depreciable' => false,
'fields' => ['quantity'],
],
self::ASSET_TYPE_RECEIVABLE => [
'name' => 'Cuenta por Cobrar',
'description' => 'Créditos, préstamos otorgados',
'icon' => 'banknotes',
'is_depreciable' => false,
'fields' => ['debtor_name', 'receivable_due_date', 'receivable_amount'],
],
self::ASSET_TYPE_CASH => [
'name' => 'Efectivo/Caja',
'description' => 'Dinero en efectivo, caja chica',
'icon' => 'currency-euro',
'is_depreciable' => false,
'fields' => [],
],
self::ASSET_TYPE_OTHER => [
'name' => 'Otro',
'description' => 'Otros tipos de activos',
'icon' => 'document-text',
'is_depreciable' => false,
'fields' => [],
],
];
/**
* Tipos de imóvel
*/
public const PROPERTY_TYPES = [
'house' => 'Casa',
'apartment' => 'Apartamento/Piso',
'land' => 'Terreno',
'commercial' => 'Local Comercial',
'industrial' => 'Nave Industrial',
'office' => 'Oficina',
'parking' => 'Plaza de Garaje',
'warehouse' => 'Almacén',
'rural' => 'Finca Rústica',
];
/**
* Tipos de investimento
*/
public const INVESTMENT_TYPES = [
'stocks' => ['name' => 'Acciones', 'description' => 'Participaciones en empresas cotizadas'],
'bonds' => ['name' => 'Bonos', 'description' => 'Títulos de deuda pública o privada'],
'funds' => ['name' => 'Fondos de Inversión', 'description' => 'Fondos mutuos, ETFs'],
'fixed_income' => ['name' => 'Renta Fija', 'description' => 'Depósitos, letras del tesoro'],
'crypto' => ['name' => 'Criptomonedas', 'description' => 'Bitcoin, Ethereum, etc.'],
'real_estate_fund' => ['name' => 'Fondo Inmobiliario', 'description' => 'REITs, SOCIMIs'],
'pension' => ['name' => 'Plan de Pensiones', 'description' => 'Ahorro para jubilación'],
'savings' => ['name' => 'Cuenta de Ahorro', 'description' => 'Cuentas remuneradas'],
];
/**
* Métodos de depreciação
*/
public const DEPRECIATION_METHODS = [
'linear' => ['name' => 'Lineal', 'description' => 'Depreciación constante cada año'],
'declining_balance' => ['name' => 'Saldo Decreciente', 'description' => 'Mayor depreciación al inicio'],
'units_production' => ['name' => 'Unidades Producidas', 'description' => 'Según uso/producción'],
'sum_years' => ['name' => 'Suma de Años', 'description' => 'Depreciación acelerada'],
];
/**
* Tipos de indexadores (para investimentos)
*/
public const INDEX_TYPES = [
'fixed' => ['name' => 'Tasa Fija', 'description' => 'Rentabilidad fija'],
'cdi' => ['name' => 'CDI', 'description' => '% del CDI (Brasil)'],
'selic' => ['name' => 'SELIC', 'description' => 'Tasa SELIC (Brasil)'],
'ipca' => ['name' => 'IPCA+', 'description' => 'Inflación + spread (Brasil)'],
'euribor' => ['name' => 'Euribor+', 'description' => 'Euribor + spread (UE)'],
'ibex' => ['name' => 'IBEX 35', 'description' => 'Índice bursátil español'],
'sp500' => ['name' => 'S&P 500', 'description' => 'Índice bursátil americano'],
];
protected $fillable = [
'user_id',
'business_id',
'asset_type',
'name',
'description',
'currency',
'color',
// Valores
'acquisition_value',
'current_value',
'acquisition_date',
// Depreciação
'is_depreciable',
'depreciation_method',
'useful_life_years',
'residual_value',
'accumulated_depreciation',
// Imóveis
'property_type',
'address',
'city',
'state',
'postal_code',
'country',
'property_area_m2',
'registry_number',
// Veículos
'vehicle_brand',
'vehicle_model',
'vehicle_year',
'vehicle_plate',
'vehicle_vin',
'vehicle_mileage',
// Investimentos
'investment_type',
'institution',
'account_number',
'quantity',
'unit_price',
'ticker',
'maturity_date',
'interest_rate',
'index_type',
// Equipamentos
'equipment_brand',
'equipment_model',
'serial_number',
'warranty_expiry',
// Recebíveis
'debtor_name',
'debtor_document',
'receivable_due_date',
'receivable_amount',
// Garantias
'is_collateral',
'collateral_for',
'linked_liability_id',
// Seguros
'has_insurance',
'insurance_company',
'insurance_policy',
'insurance_value',
'insurance_expiry',
// Gestão
'alert_days_before',
'internal_responsible',
'internal_notes',
'document_number',
// Status
'status',
'disposal_date',
'disposal_value',
'disposal_reason',
];
protected $casts = [
'acquisition_value' => 'decimal:2',
'current_value' => 'decimal:2',
'residual_value' => 'decimal:2',
'accumulated_depreciation' => 'decimal:2',
'property_area_m2' => 'decimal:2',
'useful_life_years' => 'decimal:2',
'unit_price' => 'decimal:6',
'insurance_value' => 'decimal:2',
'receivable_amount' => 'decimal:2',
'disposal_value' => 'decimal:2',
'interest_rate' => 'decimal:4',
'is_depreciable' => 'boolean',
'is_collateral' => 'boolean',
'has_insurance' => 'boolean',
'acquisition_date' => 'date',
'maturity_date' => 'date',
'warranty_expiry' => 'date',
'receivable_due_date' => 'date',
'insurance_expiry' => 'date',
'disposal_date' => 'date',
];
/**
* Relações
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function business(): BelongsTo
{
return $this->belongsTo(Business::class);
}
public function linkedLiability(): BelongsTo
{
return $this->belongsTo(LiabilityAccount::class, 'linked_liability_id');
}
/**
* Scopes
*/
public function scopeActive($query)
{
return $query->where('status', self::STATUS_ACTIVE);
}
public function scopeOfType($query, string $type)
{
return $query->where('asset_type', $type);
}
public function scopeDepreciable($query)
{
return $query->where('is_depreciable', true);
}
/**
* Accessors
*/
public function getAssetTypeNameAttribute(): string
{
return self::ASSET_TYPES[$this->asset_type]['name'] ?? $this->asset_type;
}
public function getStatusNameAttribute(): string
{
return self::STATUSES[$this->status] ?? $this->status;
}
public function getNetValueAttribute(): float
{
return $this->current_value - $this->accumulated_depreciation;
}
public function getGainLossAttribute(): float
{
return $this->current_value - $this->acquisition_value;
}
public function getGainLossPercentAttribute(): float
{
if ($this->acquisition_value == 0) return 0;
return (($this->current_value - $this->acquisition_value) / $this->acquisition_value) * 100;
}
/**
* Calcular depreciação anual (método linear)
*/
public function calculateAnnualDepreciation(): float
{
if (!$this->is_depreciable || !$this->useful_life_years || $this->useful_life_years == 0) {
return 0;
}
$depreciableValue = $this->acquisition_value - ($this->residual_value ?? 0);
return $depreciableValue / $this->useful_life_years;
}
/**
* Verificar se seguro está próximo do vencimento
*/
public function isInsuranceExpiringSoon(int $days = 30): bool
{
if (!$this->has_insurance || !$this->insurance_expiry) {
return false;
}
return $this->insurance_expiry->diffInDays(now()) <= $days;
}
}

View File

@ -0,0 +1,72 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Str;
class EmailVerificationToken extends Model
{
protected $fillable = [
'user_id',
'token',
'expires_at',
'used_at',
];
protected $casts = [
'expires_at' => 'datetime',
'used_at' => 'datetime',
];
/**
* Relationship: User
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* Create a new verification token for user
*/
public static function createForUser(User $user, int $expiresInHours = 24): self
{
// Invalidate existing tokens
self::where('user_id', $user->id)->whereNull('used_at')->delete();
return self::create([
'user_id' => $user->id,
'token' => Str::random(64),
'expires_at' => now()->addHours($expiresInHours),
]);
}
/**
* Find valid token
*/
public static function findValid(string $token): ?self
{
return self::where('token', $token)
->where('expires_at', '>', now())
->whereNull('used_at')
->first();
}
/**
* Check if token is valid
*/
public function isValid(): bool
{
return $this->expires_at > now() && $this->used_at === null;
}
/**
* Mark token as used
*/
public function markAsUsed(): void
{
$this->update(['used_at' => now()]);
}
}

View File

@ -21,16 +21,158 @@ class LiabilityAccount extends Model
public const STATUS_RENEGOTIATED = 'renegotiated';
public const STATUSES = [
self::STATUS_ACTIVE => 'Ativo',
self::STATUS_PAID_OFF => 'Quitado',
self::STATUS_DEFAULTED => 'Inadimplente',
self::STATUS_ACTIVE => 'Activo',
self::STATUS_PAID_OFF => 'Liquidado',
self::STATUS_DEFAULTED => 'En mora',
self::STATUS_RENEGOTIATED => 'Renegociado',
];
/**
* Tipos de contrato
*/
public const CONTRACT_TYPE_PERSONAL_LOAN = 'personal_loan';
public const CONTRACT_TYPE_VEHICLE = 'vehicle_financing';
public const CONTRACT_TYPE_MORTGAGE = 'mortgage';
public const CONTRACT_TYPE_CREDIT_CARD = 'credit_card';
public const CONTRACT_TYPE_CONSORTIUM = 'consortium';
public const CONTRACT_TYPE_LEASING = 'leasing';
public const CONTRACT_TYPE_OVERDRAFT = 'overdraft';
public const CONTRACT_TYPE_PAYROLL_LOAN = 'payroll_loan';
public const CONTRACT_TYPE_OTHER = 'other';
public const CONTRACT_TYPES = [
self::CONTRACT_TYPE_PERSONAL_LOAN => [
'name' => 'Préstamo Personal',
'description' => 'Préstamo con cuotas fijas (Sistema PRICE)',
'icon' => 'banknotes',
'default_amortization' => 'price',
'fields' => ['principal_amount', 'annual_interest_rate', 'total_installments', 'start_date', 'first_due_date'],
],
self::CONTRACT_TYPE_VEHICLE => [
'name' => 'Financiación de Vehículo',
'description' => 'Crédito para compra de coche o moto',
'icon' => 'truck',
'default_amortization' => 'price',
'fields' => ['principal_amount', 'annual_interest_rate', 'total_installments', 'start_date', 'first_due_date', 'asset_value'],
],
self::CONTRACT_TYPE_MORTGAGE => [
'name' => 'Hipoteca / Financiación Inmobiliaria',
'description' => 'Crédito para compra de inmueble',
'icon' => 'home',
'default_amortization' => 'sac',
'fields' => ['principal_amount', 'annual_interest_rate', 'total_installments', 'start_date', 'first_due_date', 'asset_value', 'insurance_amount'],
],
self::CONTRACT_TYPE_CREDIT_CARD => [
'name' => 'Tarjeta de Crédito',
'description' => 'Financiación de compras a plazos',
'icon' => 'credit-card',
'default_amortization' => 'price',
'fields' => ['principal_amount', 'annual_interest_rate', 'total_installments', 'first_due_date'],
],
self::CONTRACT_TYPE_CONSORTIUM => [
'name' => 'Consorcio',
'description' => 'Grupo de compras con cuotas variables',
'icon' => 'users',
'default_amortization' => 'consortium',
'fields' => ['principal_amount', 'total_installments', 'admin_fee_percent', 'start_date', 'first_due_date'],
],
self::CONTRACT_TYPE_LEASING => [
'name' => 'Leasing',
'description' => 'Arrendamiento con opción de compra',
'icon' => 'key',
'default_amortization' => 'price',
'fields' => ['principal_amount', 'annual_interest_rate', 'total_installments', 'start_date', 'first_due_date', 'residual_value'],
],
self::CONTRACT_TYPE_OVERDRAFT => [
'name' => 'Descubierto / Cheque Especial',
'description' => 'Línea de crédito rotativa',
'icon' => 'arrow-trending-down',
'default_amortization' => 'american',
'fields' => ['principal_amount', 'monthly_interest_rate'],
],
self::CONTRACT_TYPE_PAYROLL_LOAN => [
'name' => 'Préstamo con Nómina',
'description' => 'Crédito con descuento en nómina',
'icon' => 'briefcase',
'default_amortization' => 'price',
'fields' => ['principal_amount', 'annual_interest_rate', 'total_installments', 'start_date', 'first_due_date'],
],
self::CONTRACT_TYPE_OTHER => [
'name' => 'Otro',
'description' => 'Otro tipo de pasivo',
'icon' => 'document-text',
'default_amortization' => 'price',
'fields' => ['principal_amount', 'total_installments', 'first_due_date'],
],
];
/**
* Sistemas de amortização
*/
public const AMORTIZATION_PRICE = 'price';
public const AMORTIZATION_SAC = 'sac';
public const AMORTIZATION_AMERICAN = 'american';
public const AMORTIZATION_CONSORTIUM = 'consortium';
public const AMORTIZATION_SYSTEMS = [
self::AMORTIZATION_PRICE => [
'name' => 'PRICE (Cuota Fija)',
'description' => 'Cuotas iguales. Intereses decrecientes, amortización creciente.',
],
self::AMORTIZATION_SAC => [
'name' => 'SAC (Amortización Constante)',
'description' => 'Amortización fija. Cuotas e intereses decrecientes.',
],
self::AMORTIZATION_AMERICAN => [
'name' => 'Americano',
'description' => 'Solo intereses durante el plazo, principal al final.',
],
self::AMORTIZATION_CONSORTIUM => [
'name' => 'Consorcio',
'description' => 'Cuotas variables según el grupo.',
],
];
/**
* Tipos de indexadores
*/
public const INDEX_TYPES = [
'fixed' => ['name' => 'Tasa Fija', 'description' => 'Sin indexación'],
'cdi' => ['name' => 'CDI', 'description' => 'Certificado de Depósito Interbancario (Brasil)'],
'selic' => ['name' => 'SELIC', 'description' => 'Tasa básica de interés (Brasil)'],
'ipca' => ['name' => 'IPCA', 'description' => 'Índice de precios al consumidor (Brasil)'],
'igpm' => ['name' => 'IGP-M', 'description' => 'Índice general de precios (Brasil)'],
'tr' => ['name' => 'TR', 'description' => 'Tasa referencial (Brasil)'],
'euribor' => ['name' => 'Euribor', 'description' => 'Euro Interbank Offered Rate (UE)'],
'libor' => ['name' => 'LIBOR', 'description' => 'London Interbank Offered Rate'],
'sofr' => ['name' => 'SOFR', 'description' => 'Secured Overnight Financing Rate (EUA)'],
'prime' => ['name' => 'Prime Rate', 'description' => 'Tasa preferencial (EUA)'],
'ipc' => ['name' => 'IPC', 'description' => 'Índice de precios al consumidor (España)'],
'other' => ['name' => 'Otro', 'description' => 'Otro indexador'],
];
/**
* Tipos de garantia
*/
public const GUARANTEE_TYPES = [
'none' => ['name' => 'Sin garantía', 'description' => 'Préstamo sin garantía'],
'fiduciary_alienation' => ['name' => 'Alienación Fiduciaria', 'description' => 'El bien queda en garantía hasta el pago total'],
'mortgage' => ['name' => 'Hipoteca', 'description' => 'Garantía sobre inmueble'],
'pledge' => ['name' => 'Prenda', 'description' => 'Garantía sobre bien mueble'],
'guarantor' => ['name' => 'Fiador/Avalista', 'description' => 'Persona que garantiza la deuda'],
'payroll' => ['name' => 'Descuento en Nómina', 'description' => 'Descuento directo del salario'],
'investment' => ['name' => 'Inversión', 'description' => 'Garantía con inversiones/aplicaciones'],
'letter_of_credit' => ['name' => 'Carta de Crédito', 'description' => 'Garantía bancaria'],
'surety_bond' => ['name' => 'Seguro Fianza', 'description' => 'Seguro que garantiza la obligación'],
'other' => ['name' => 'Otra', 'description' => 'Otro tipo de garantía'],
];
protected $fillable = [
'user_id',
'account_id',
'name',
'contract_type',
'amortization_system',
'contract_number',
'creditor',
'description',
@ -52,11 +194,39 @@ class LiabilityAccount extends Model
'start_date',
'end_date',
'first_due_date',
'has_grace_period',
'grace_period_months',
'currency',
'color',
'icon',
'status',
'is_active',
// Campos avançados - Indexadores
'index_type',
'index_spread',
'total_effective_cost',
// Campos avançados - Garantias
'guarantee_type',
'guarantee_value',
'guarantee_description',
'guarantor_name',
// Campos avançados - Penalidades
'late_fee_percent',
'daily_penalty_percent',
'grace_days_for_penalty',
// Campos avançados - Específicos por tipo
'asset_value',
'asset_description',
'residual_value',
'admin_fee_percent',
'reserve_fund_percent',
// Campos avançados - Covenants e gestão
'covenants',
'alert_days_before',
'internal_responsible',
'internal_notes',
'document_number',
'registry_office',
];
protected $casts = [
@ -76,6 +246,21 @@ class LiabilityAccount extends Model
'end_date' => 'date',
'first_due_date' => 'date',
'is_active' => 'boolean',
'has_grace_period' => 'boolean',
'grace_period_months' => 'integer',
// Campos avançados
'index_spread' => 'decimal:4',
'total_effective_cost' => 'decimal:4',
'guarantee_value' => 'decimal:2',
'late_fee_percent' => 'decimal:2',
'daily_penalty_percent' => 'decimal:4',
'grace_days_for_penalty' => 'integer',
'asset_value' => 'decimal:2',
'residual_value' => 'decimal:2',
'admin_fee_percent' => 'decimal:2',
'reserve_fund_percent' => 'decimal:2',
'covenants' => 'array',
'alert_days_before' => 'integer',
];
protected $appends = ['progress_percentage', 'remaining_balance'];

View File

@ -22,6 +22,7 @@ class Plan extends Model
'limits',
'is_active',
'is_featured',
'is_free',
'sort_order',
'paypal_plan_id',
];
@ -32,6 +33,7 @@ class Plan extends Model
'limits' => 'array',
'is_active' => 'boolean',
'is_featured' => 'boolean',
'is_free' => 'boolean',
'trial_days' => 'integer',
'sort_order' => 'integer',
];

View File

@ -0,0 +1,52 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class SiteSetting extends Model
{
protected $fillable = [
'key',
'value',
'description',
];
protected $casts = [
'value' => 'array',
];
/**
* Get a setting value by key
*/
public static function getValue(string $key, $default = null)
{
$setting = static::where('key', $key)->first();
if (!$setting) {
return $default;
}
// If value is a simple string stored as JSON array, extract it
$value = $setting->value;
if (is_array($value) && isset($value['value'])) {
return $value['value'];
}
return $value ?? $default;
}
/**
* Set a setting value by key
*/
public static function setValue(string $key, $value, string $description = null): self
{
return static::updateOrCreate(
['key' => $key],
[
'value' => is_array($value) ? $value : ['value' => $value],
'description' => $description,
]
);
}
}

View File

@ -12,6 +12,7 @@ class Subscription extends Model
{
use HasFactory;
const STATUS_PENDING = 'pending';
const STATUS_TRIALING = 'trialing';
const STATUS_ACTIVE = 'active';
const STATUS_PAST_DUE = 'past_due';

View File

@ -142,7 +142,8 @@ public function scopePending($query)
public function scopeCompleted($query)
{
return $query->where('status', 'completed');
// Incluir 'completed' e 'effective' como transações efetivadas
return $query->whereIn('status', ['completed', 'effective']);
}
public function scopeCancelled($query)

View File

@ -31,8 +31,12 @@ class User extends Authenticatable
'country',
'timezone',
'locale',
'language',
'currency',
'password',
'is_admin',
'is_demo',
'email_verified_at',
];
/**
@ -56,6 +60,7 @@ protected function casts(): array
'email_verified_at' => 'datetime',
'password' => 'hashed',
'is_admin' => 'boolean',
'is_demo' => 'boolean',
'accept_whatsapp' => 'boolean',
'accept_emails' => 'boolean',
];
@ -91,6 +96,48 @@ public function isAdmin(): bool
return $this->is_admin === true;
}
// ==================== RESOURCE RELATIONSHIPS ====================
/**
* Get all accounts for the user
*/
public function accounts()
{
return $this->hasMany(Account::class);
}
/**
* Get all categories for the user
*/
public function categories()
{
return $this->hasMany(Category::class);
}
/**
* Get all budgets for the user
*/
public function budgets()
{
return $this->hasMany(Budget::class);
}
/**
* Get all transactions for the user
*/
public function transactions()
{
return $this->hasMany(Transaction::class);
}
/**
* Get all goals for the user
*/
public function goals()
{
return $this->hasMany(FinancialGoal::class);
}
// ==================== SUBSCRIPTION RELATIONSHIPS ====================
/**

View File

@ -0,0 +1,291 @@
<?php
namespace App\Services;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use PhpOffice\PhpSpreadsheet\Style\Alignment;
use PhpOffice\PhpSpreadsheet\Style\Border;
use PhpOffice\PhpSpreadsheet\Style\Fill;
use PhpOffice\PhpSpreadsheet\Style\NumberFormat;
use PhpOffice\PhpSpreadsheet\Cell\DataValidation;
class LiabilityTemplateService
{
/**
* Gerar template Excel para importação de passivos
*/
public function generateTemplate(): Spreadsheet
{
$spreadsheet = new Spreadsheet();
// Criar aba de Parcelas (principal)
$installmentsSheet = $spreadsheet->getActiveSheet();
$installmentsSheet->setTitle('Parcelas');
$this->createInstallmentsSheet($installmentsSheet);
// Criar aba de Instruções
$instructionsSheet = $spreadsheet->createSheet();
$instructionsSheet->setTitle('Instrucciones');
$this->createInstructionsSheet($instructionsSheet);
// Criar aba de Exemplo
$exampleSheet = $spreadsheet->createSheet();
$exampleSheet->setTitle('Ejemplo');
$this->createExampleSheet($exampleSheet);
// Voltar para primeira aba
$spreadsheet->setActiveSheetIndex(0);
return $spreadsheet;
}
/**
* Criar aba de parcelas com cabeçalho e validações
*/
private function createInstallmentsSheet($sheet): void
{
// Definir largura das colunas
$sheet->getColumnDimension('A')->setWidth(12); // Nº
$sheet->getColumnDimension('B')->setWidth(15); // Fecha
$sheet->getColumnDimension('C')->setWidth(15); // Cuota
$sheet->getColumnDimension('D')->setWidth(15); // Intereses
$sheet->getColumnDimension('E')->setWidth(15); // Capital
$sheet->getColumnDimension('F')->setWidth(15); // Tasas/Seguros
$sheet->getColumnDimension('G')->setWidth(15); // Estado
$sheet->getColumnDimension('H')->setWidth(30); // Observaciones
// Cabeçalho principal - título
$sheet->setCellValue('A1', 'PLANTILLA DE IMPORTACIÓN - CUENTA PASIVO');
$sheet->mergeCells('A1:H1');
$sheet->getStyle('A1')->applyFromArray([
'font' => ['bold' => true, 'size' => 16, 'color' => ['rgb' => 'FFFFFF']],
'fill' => ['fillType' => Fill::FILL_SOLID, 'color' => ['rgb' => '1E40AF']],
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER],
]);
$sheet->getRowDimension(1)->setRowHeight(30);
// Subtítulo
$sheet->setCellValue('A2', 'Rellene las columnas con los datos de sus cuotas. Las columnas marcadas con * son obligatorias.');
$sheet->mergeCells('A2:H2');
$sheet->getStyle('A2')->applyFromArray([
'font' => ['italic' => true, 'size' => 10, 'color' => ['rgb' => '6B7280']],
]);
// Cabeçalhos das colunas
$headers = [
'A3' => 'Nº Cuota *',
'B3' => 'Fecha Venc. *',
'C3' => 'Valor Cuota *',
'D3' => 'Intereses',
'E3' => 'Capital',
'F3' => 'Tasas/Seguros',
'G3' => 'Estado',
'H3' => 'Observaciones',
];
foreach ($headers as $cell => $value) {
$sheet->setCellValue($cell, $value);
}
// Estilo do cabeçalho
$sheet->getStyle('A3:H3')->applyFromArray([
'font' => ['bold' => true, 'color' => ['rgb' => 'FFFFFF']],
'fill' => ['fillType' => Fill::FILL_SOLID, 'color' => ['rgb' => '3B82F6']],
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER],
'borders' => [
'allBorders' => ['borderStyle' => Border::BORDER_THIN, 'color' => ['rgb' => '1E40AF']],
],
]);
// Dicas sob o cabeçalho
$tips = [
'A4' => '1, 2, 3...',
'B4' => 'DD/MM/AAAA',
'C4' => '0.00',
'D4' => '0.00',
'E4' => '0.00',
'F4' => '0.00',
'G4' => 'Pendiente/Pagado',
'H4' => 'Texto libre',
];
foreach ($tips as $cell => $value) {
$sheet->setCellValue($cell, $value);
}
$sheet->getStyle('A4:H4')->applyFromArray([
'font' => ['italic' => true, 'size' => 9, 'color' => ['rgb' => '9CA3AF']],
'fill' => ['fillType' => Fill::FILL_SOLID, 'color' => ['rgb' => 'F3F4F6']],
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER],
]);
// Área de dados (linhas 5-64 para até 60 parcelas)
for ($row = 5; $row <= 64; $row++) {
// Número da parcela
$sheet->setCellValue("A{$row}", $row - 4);
// Aplicar formato de número nas colunas de valores
$sheet->getStyle("C{$row}:F{$row}")->getNumberFormat()
->setFormatCode(NumberFormat::FORMAT_NUMBER_COMMA_SEPARATED1);
// Aplicar formato de data na coluna B
$sheet->getStyle("B{$row}")->getNumberFormat()
->setFormatCode('DD/MM/YYYY');
// Adicionar validação de lista para Estado
$validation = $sheet->getCell("G{$row}")->getDataValidation();
$validation->setType(DataValidation::TYPE_LIST);
$validation->setErrorStyle(DataValidation::STYLE_INFORMATION);
$validation->setAllowBlank(true);
$validation->setShowDropDown(true);
$validation->setFormula1('"Pendiente,Pagado,Vencido"');
// Bordas leves
$sheet->getStyle("A{$row}:H{$row}")->applyFromArray([
'borders' => [
'allBorders' => ['borderStyle' => Border::BORDER_THIN, 'color' => ['rgb' => 'E5E7EB']],
],
]);
// Alternar cores das linhas
if (($row - 5) % 2 == 1) {
$sheet->getStyle("A{$row}:H{$row}")->applyFromArray([
'fill' => ['fillType' => Fill::FILL_SOLID, 'color' => ['rgb' => 'F9FAFB']],
]);
}
}
// Congelar painel no cabeçalho
$sheet->freezePane('A5');
}
/**
* Criar aba de instruções
*/
private function createInstructionsSheet($sheet): void
{
$sheet->getColumnDimension('A')->setWidth(80);
$instructions = [
['INSTRUCCIONES DE USO', true, '1E40AF'],
['', false, 'FFFFFF'],
['1. DATOS OBLIGATORIOS', true, '059669'],
[' • Nº Cuota: Número secuencial de la cuota (1, 2, 3...)', false, '000000'],
[' • Fecha Venc.: Fecha de vencimiento en formato DD/MM/AAAA', false, '000000'],
[' • Valor Cuota: Valor total de la cuota a pagar', false, '000000'],
['', false, 'FFFFFF'],
['2. DATOS OPCIONALES (recomendados)', true, '059669'],
[' • Intereses: Parte de la cuota correspondiente a intereses', false, '000000'],
[' • Capital: Parte de la cuota correspondiente a amortización', false, '000000'],
[' • Tasas/Seguros: Otros cargos incluidos en la cuota', false, '000000'],
[' • Estado: Pendiente, Pagado o Vencido', false, '000000'],
[' • Observaciones: Notas adicionales', false, '000000'],
['', false, 'FFFFFF'],
['3. TIPOS DE CONTRATOS SOPORTADOS', true, '059669'],
[' • Préstamo Personal (Sistema PRICE - cuota fija)', false, '000000'],
[' • Financiación de Vehículo', false, '000000'],
[' • Financiación Inmobiliaria (Sistema SAC o PRICE)', false, '000000'],
[' • Consorcio (cuotas variables)', false, '000000'],
[' • Leasing', false, '000000'],
['', false, 'FFFFFF'],
['4. SISTEMA PRICE (Cuota Fija)', true, '059669'],
[' En este sistema:', false, '000000'],
[' • La cuota es CONSTANTE todos los meses', false, '000000'],
[' • Los intereses DISMINUYEN cada mes', false, '000000'],
[' • La amortización AUMENTA cada mes', false, '000000'],
[' • Cuota = Intereses + Capital + Tasas', false, '000000'],
['', false, 'FFFFFF'],
['5. SISTEMA SAC (Amortización Constante)', true, '059669'],
[' En este sistema:', false, '000000'],
[' • La amortización es CONSTANTE todos los meses', false, '000000'],
[' • Los intereses DISMINUYEN cada mes', false, '000000'],
[' • La cuota DISMINUYE cada mes', false, '000000'],
['', false, 'FFFFFF'],
['6. CUOTA DE CARENCIA', true, '059669'],
[' Algunos contratos tienen una primera cuota de carencia:', false, '000000'],
[' • Solo se pagan intereses (sin amortización de capital)', false, '000000'],
[' • El capital amortizado en esta cuota debe ser 0', false, '000000'],
['', false, 'FFFFFF'],
['7. ESTADOS VÁLIDOS', true, '059669'],
[' • Pendiente: Cuota aún no pagada', false, '000000'],
[' • Pagado: Cuota ya abonada', false, '000000'],
[' • Vencido: Cuota no pagada y pasada la fecha de vencimiento', false, '000000'],
];
$row = 1;
foreach ($instructions as $item) {
$sheet->setCellValue("A{$row}", $item[0]);
$style = ['font' => ['color' => ['rgb' => $item[2]]]];
if ($item[1]) {
$style['font']['bold'] = true;
$style['font']['size'] = 12;
}
$sheet->getStyle("A{$row}")->applyFromArray($style);
$row++;
}
}
/**
* Criar aba de exemplo preenchida
*/
private function createExampleSheet($sheet): void
{
// Copiar estrutura da aba de parcelas
$this->createInstallmentsSheet($sheet);
// Dados de exemplo (Empréstimo PRICE típico)
$exampleData = [
[1, '05/06/2025', 20.85, 20.85, 0.00, 0.00, 'Pagado', 'Cuota de carencia (solo intereses)'],
[2, '05/07/2025', 122.00, 48.33, 73.67, 0.00, 'Pagado', ''],
[3, '05/08/2025', 122.00, 47.68, 74.32, 0.00, 'Pagado', ''],
[4, '05/09/2025', 122.00, 47.01, 74.99, 0.00, 'Pagado', ''],
[5, '05/10/2025', 122.00, 46.35, 75.65, 0.00, 'Pagado', ''],
[6, '05/11/2025', 122.00, 45.68, 76.32, 0.00, 'Pagado', ''],
[7, '05/12/2025', 122.00, 45.00, 77.00, 0.00, 'Pendiente', ''],
[8, '05/01/2026', 122.00, 44.31, 77.69, 0.00, 'Pendiente', ''],
[9, '05/02/2026', 122.00, 43.62, 78.38, 0.00, 'Pendiente', ''],
[10, '05/03/2026', 122.00, 42.93, 79.07, 0.00, 'Pendiente', ''],
];
$row = 5;
foreach ($exampleData as $data) {
$sheet->setCellValue("A{$row}", $data[0]);
$sheet->setCellValue("B{$row}", $data[1]);
$sheet->setCellValue("C{$row}", $data[2]);
$sheet->setCellValue("D{$row}", $data[3]);
$sheet->setCellValue("E{$row}", $data[4]);
$sheet->setCellValue("F{$row}", $data[5]);
$sheet->setCellValue("G{$row}", $data[6]);
$sheet->setCellValue("H{$row}", $data[7]);
$row++;
}
// Destacar que é exemplo
$sheet->setCellValue('A1', 'EJEMPLO - PRÉSTAMO PERSONAL (Sistema PRICE)');
$sheet->getStyle('A1')->applyFromArray([
'font' => ['bold' => true, 'size' => 16, 'color' => ['rgb' => 'FFFFFF']],
'fill' => ['fillType' => Fill::FILL_SOLID, 'color' => ['rgb' => '059669']],
]);
}
/**
* Salvar template em arquivo
*/
public function saveTemplate(string $path): void
{
$spreadsheet = $this->generateTemplate();
$writer = new Xlsx($spreadsheet);
$writer->save($path);
}
/**
* Obter caminho do template
*/
public static function getTemplatePath(): string
{
return storage_path('app/templates/passivo_template.xlsx');
}
}

View File

@ -9,8 +9,8 @@
class PayPalService
{
private string $clientId;
private string $clientSecret;
private ?string $clientId;
private ?string $clientSecret;
private string $baseUrl;
private bool $sandbox;
@ -29,6 +29,11 @@ public function __construct()
*/
public function getAccessToken(): ?string
{
if (!$this->isConfigured()) {
Log::warning('PayPal not configured');
return null;
}
return Cache::remember('paypal_access_token', 28800, function () {
try {
$response = Http::withBasicAuth($this->clientId, $this->clientSecret)
@ -247,7 +252,15 @@ public function cancelSubscription(string $subscriptionId, string $reason = 'Use
return true;
}
Log::error('PayPal cancel subscription failed', ['response' => $response->json()]);
// Check if subscription is already cancelled - treat as success
$responseData = $response->json();
if (isset($responseData['details'][0]['issue']) &&
$responseData['details'][0]['issue'] === 'SUBSCRIPTION_STATUS_INVALID') {
Log::info('PayPal subscription already cancelled', ['subscription_id' => $subscriptionId]);
return true;
}
Log::error('PayPal cancel subscription failed', ['response' => $responseData]);
return false;
} catch (\Exception $e) {
Log::error('PayPal cancel subscription exception', ['error' => $e->getMessage()]);
@ -338,6 +351,124 @@ public function getSubscriptionTransactions(string $subscriptionId, string $star
}
}
/**
* Refund a capture (payment)
*/
public function refundCapture(string $captureId, ?float $amount = null, ?string $currency = 'EUR', string $note = 'Refund within 7-day guarantee period'): ?array
{
$token = $this->getAccessToken();
if (!$token) return null;
try {
$body = [
'note_to_payer' => $note,
];
// If amount specified, do partial refund; otherwise full refund
if ($amount !== null) {
$body['amount'] = [
'value' => number_format($amount, 2, '.', ''),
'currency_code' => $currency,
];
}
$response = Http::withToken($token)
->post("{$this->baseUrl}/v2/payments/captures/{$captureId}/refund", $body);
if ($response->successful()) {
Log::info('PayPal refund successful', ['capture_id' => $captureId, 'response' => $response->json()]);
return $response->json();
}
Log::error('PayPal refund failed', ['capture_id' => $captureId, 'response' => $response->json()]);
return null;
} catch (\Exception $e) {
Log::error('PayPal refund exception', ['error' => $e->getMessage()]);
return null;
}
}
/**
* Get the last transaction/capture for a subscription to refund
*/
public function getLastSubscriptionCapture(string $subscriptionId): ?string
{
$token = $this->getAccessToken();
if (!$token) return null;
try {
// Get transactions from last 30 days
$startTime = now()->subDays(30)->toIso8601String();
$endTime = now()->toIso8601String();
$response = Http::withToken($token)
->get("{$this->baseUrl}/v1/billing/subscriptions/{$subscriptionId}/transactions", [
'start_time' => $startTime,
'end_time' => $endTime,
]);
if ($response->successful()) {
$transactions = $response->json('transactions') ?? [];
// Find the most recent COMPLETED transaction
foreach ($transactions as $transaction) {
if (($transaction['status'] ?? '') === 'COMPLETED' && !empty($transaction['id'])) {
Log::info('Found capture for refund', ['subscription_id' => $subscriptionId, 'capture_id' => $transaction['id']]);
return $transaction['id'];
}
}
}
Log::warning('No capture found for subscription', ['subscription_id' => $subscriptionId]);
return null;
} catch (\Exception $e) {
Log::error('PayPal get capture exception', ['error' => $e->getMessage()]);
return null;
}
}
/**
* Cancel subscription and refund if within guarantee period
*/
public function cancelAndRefund(string $subscriptionId, string $reason = 'Refund within 7-day guarantee'): array
{
$result = [
'canceled' => false,
'refunded' => false,
'refund_id' => null,
'refund_amount' => null,
'error' => null,
];
// First, get capture ID for refund
$captureId = $this->getLastSubscriptionCapture($subscriptionId);
// Cancel the subscription
$canceled = $this->cancelSubscription($subscriptionId, $reason);
$result['canceled'] = $canceled;
if (!$canceled) {
$result['error'] = 'Failed to cancel subscription';
return $result;
}
// Process refund if we have a capture
if ($captureId) {
$refund = $this->refundCapture($captureId, null, 'EUR', $reason);
if ($refund) {
$result['refunded'] = true;
$result['refund_id'] = $refund['id'] ?? null;
$result['refund_amount'] = $refund['amount']['value'] ?? null;
} else {
$result['error'] = 'Subscription canceled but refund failed';
}
} else {
$result['error'] = 'Subscription canceled but no payment found to refund';
}
return $result;
}
/**
* Check if PayPal is configured
*/
@ -349,7 +480,7 @@ public function isConfigured(): bool
/**
* Get client ID for frontend
*/
public function getClientId(): string
public function getClientId(): ?string
{
return $this->clientId;
}

View File

@ -18,6 +18,14 @@
// Alias para rate limiting
$middleware->alias([
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
'plan.limits' => \App\Http\Middleware\CheckPlanLimits::class,
'admin.only' => \App\Http\Middleware\AdminOnly::class,
'demo.protect' => \App\Http\Middleware\DemoProtection::class,
]);
// Aplicar proteção demo em todas as rotas de API autenticadas
$middleware->api(append: [
\App\Http\Middleware\DemoProtection::class,
]);
})
->withExceptions(function (Exceptions $exceptions): void {

View File

@ -54,6 +54,18 @@
'url' => env('APP_URL', 'http://localhost'),
/*
|--------------------------------------------------------------------------
| Frontend URL
|--------------------------------------------------------------------------
|
| This URL is used for generating links to the frontend application,
| such as password reset links and email verification links.
|
*/
'frontend_url' => env('FRONTEND_URL', 'https://webmoney.cnxifly.com'),
/*
|--------------------------------------------------------------------------
| Application Timezone

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('site_settings', function (Blueprint $table) {
$table->id();
$table->string('key')->unique();
$table->json('value')->nullable();
$table->string('description')->nullable();
$table->timestamps();
});
// Insert default settings
\App\Models\SiteSetting::setValue('cnxifly_maintenance_mode', false, 'Modo de mantenimiento para cnxifly.com');
\App\Models\SiteSetting::setValue('cnxifly_page_type', 'live', 'Tipo de página: live, maintenance, construction');
}
public function down(): void
{
Schema::dropIfExists('site_settings');
}
};

View File

@ -0,0 +1,39 @@
<?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::table('liability_accounts', function (Blueprint $table) {
// Adicionar tipo de contrato e sistema de amortização após 'name'
$table->string('contract_type', 50)->default('other')->after('name');
$table->string('amortization_system', 20)->default('price')->after('contract_type');
// Adicionar campos de carência após end_date
$table->boolean('has_grace_period')->default(false)->after('first_due_date');
$table->unsignedTinyInteger('grace_period_months')->default(0)->after('has_grace_period');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('liability_accounts', function (Blueprint $table) {
$table->dropColumn([
'contract_type',
'amortization_system',
'has_grace_period',
'grace_period_months',
]);
});
}
};

View File

@ -0,0 +1,124 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* Adiciona campos avançados para gestão completa de passivos:
* - Indexadores (CDI, IPCA, Euribor, etc.)
* - Garantias (tipo, valor, descrição)
* - Penalidades (multa, mora)
* - Campos específicos por tipo de contrato
*/
public function up(): void
{
Schema::table('liability_accounts', function (Blueprint $table) {
// ============================================
// INDEXADORES E TAXAS
// ============================================
// Tipo de indexador (CDI, IPCA, SELIC, Euribor, Fixed, etc.)
$table->string('index_type', 30)->nullable()->after('annual_interest_rate');
// Spread sobre o indexador (ex: CDI + 2.5%)
$table->decimal('index_spread', 6, 4)->nullable()->after('index_type');
// CET - Custo Efetivo Total (%)
$table->decimal('total_effective_cost', 8, 4)->nullable()->after('index_spread');
// ============================================
// GARANTIAS
// ============================================
// Tipo de garantia (none, fiduciary_alienation, mortgage, guarantor, pledge, etc.)
$table->string('guarantee_type', 50)->nullable()->after('total_effective_cost');
// Valor da garantia
$table->decimal('guarantee_value', 15, 2)->nullable()->after('guarantee_type');
// Descrição da garantia (ex: "Imóvel Rua X, Matrícula 12345")
$table->string('guarantee_description', 500)->nullable()->after('guarantee_value');
// Nome do fiador (se aplicável)
$table->string('guarantor_name', 150)->nullable()->after('guarantee_description');
// ============================================
// PENALIDADES E MULTAS
// ============================================
// Multa por atraso (%)
$table->decimal('late_fee_percent', 5, 2)->nullable()->after('guarantor_name');
// Mora diária (%)
$table->decimal('daily_penalty_percent', 5, 4)->nullable()->after('late_fee_percent');
// Dias de carência antes de aplicar multa
$table->unsignedTinyInteger('grace_days_for_penalty')->default(0)->after('daily_penalty_percent');
// ============================================
// CAMPOS ESPECÍFICOS POR TIPO
// ============================================
// Valor do bem (para financiamentos e leasing)
$table->decimal('asset_value', 15, 2)->nullable()->after('grace_days_for_penalty');
// Descrição do bem
$table->string('asset_description', 300)->nullable()->after('asset_value');
// Valor residual (para leasing)
$table->decimal('residual_value', 15, 2)->nullable()->after('asset_description');
// Taxa de administração (% para consórcio)
$table->decimal('admin_fee_percent', 5, 2)->nullable()->after('residual_value');
// Fundo de reserva (% para consórcio)
$table->decimal('reserve_fund_percent', 5, 2)->nullable()->after('admin_fee_percent');
// ============================================
// COVENANTS E RESTRIÇÕES
// ============================================
// Covenants financeiros (JSON: [{name, condition, value}])
$table->json('covenants')->nullable()->after('reserve_fund_percent');
// ============================================
// ALERTAS E GESTÃO
// ============================================
// Dias de antecedência para alerta de vencimento
$table->unsignedTinyInteger('alert_days_before')->default(5)->after('covenants');
// Responsável interno
$table->string('internal_responsible', 150)->nullable()->after('alert_days_before');
// Notas/observações internas
$table->text('internal_notes')->nullable()->after('internal_responsible');
// ============================================
// DOCUMENTAÇÃO
// ============================================
// Número do documento/escritura
$table->string('document_number', 100)->nullable()->after('internal_notes');
// Cartório/Registro
$table->string('registry_office', 200)->nullable()->after('document_number');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('liability_accounts', function (Blueprint $table) {
$table->dropColumn([
'index_type',
'index_spread',
'total_effective_cost',
'guarantee_type',
'guarantee_value',
'guarantee_description',
'guarantor_name',
'late_fee_percent',
'daily_penalty_percent',
'grace_days_for_penalty',
'asset_value',
'asset_description',
'residual_value',
'admin_fee_percent',
'reserve_fund_percent',
'covenants',
'alert_days_before',
'internal_responsible',
'internal_notes',
'document_number',
'registry_office',
]);
});
}
};

View File

@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('language', 10)->default('es')->after('locale');
$table->string('currency', 3)->default('EUR')->after('language');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn(['language', 'currency']);
});
}
};

View File

@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('email_verification_tokens', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->string('token', 64)->unique();
$table->timestamp('expires_at');
$table->timestamp('used_at')->nullable();
$table->timestamps();
$table->index(['token', 'expires_at']);
});
}
public function down(): void
{
Schema::dropIfExists('email_verification_tokens');
}
};

View File

@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// Add 'pending' to status enum
DB::statement("ALTER TABLE subscriptions MODIFY COLUMN status ENUM('pending', 'trialing', 'active', 'past_due', 'canceled', 'expired') NOT NULL DEFAULT 'trialing'");
}
/**
* Reverse the migrations.
*/
public function down(): void
{
// Remove 'pending' from status enum
DB::statement("ALTER TABLE subscriptions MODIFY COLUMN status ENUM('trialing', 'active', 'past_due', 'canceled', 'expired') NOT NULL DEFAULT 'trialing'");
}
};

View File

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

View File

@ -0,0 +1,122 @@
<?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('asset_accounts', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->unsignedBigInteger('business_id')->nullable(); // Sem FK por enquanto
// Tipo de ativo
$table->string('asset_type', 50); // real_estate, vehicle, investment, equipment, inventory, receivable, other
// Dados básicos
$table->string('name');
$table->text('description')->nullable();
$table->string('currency', 3)->default('EUR');
$table->string('color', 7)->default('#3B82F6');
// Valores
$table->decimal('acquisition_value', 15, 2)->default(0); // Valor de aquisição
$table->decimal('current_value', 15, 2)->default(0); // Valor atual/de mercado
$table->date('acquisition_date')->nullable();
// Depreciação (para ativos depreciáveis)
$table->boolean('is_depreciable')->default(false);
$table->string('depreciation_method', 30)->nullable(); // linear, declining_balance, units_production
$table->decimal('useful_life_years', 5, 2)->nullable();
$table->decimal('residual_value', 15, 2)->nullable();
$table->decimal('accumulated_depreciation', 15, 2)->default(0);
// Para imóveis
$table->string('property_type', 50)->nullable(); // house, apartment, land, commercial, industrial
$table->text('address')->nullable();
$table->string('city', 100)->nullable();
$table->string('state', 100)->nullable();
$table->string('postal_code', 20)->nullable();
$table->string('country', 2)->nullable();
$table->decimal('property_area_m2', 12, 2)->nullable();
$table->string('registry_number', 100)->nullable();
// Para veículos
$table->string('vehicle_brand', 100)->nullable();
$table->string('vehicle_model', 100)->nullable();
$table->year('vehicle_year')->nullable();
$table->string('vehicle_plate', 20)->nullable();
$table->string('vehicle_vin', 50)->nullable();
$table->integer('vehicle_mileage')->nullable();
// Para investimentos
$table->string('investment_type', 50)->nullable(); // stocks, bonds, funds, crypto, savings, fixed_income
$table->string('institution', 100)->nullable();
$table->string('account_number', 100)->nullable();
$table->integer('quantity')->nullable(); // Quantidade de ativos (ações, cotas)
$table->decimal('unit_price', 15, 6)->nullable();
$table->string('ticker', 20)->nullable();
$table->date('maturity_date')->nullable();
$table->decimal('interest_rate', 8, 4)->nullable();
$table->string('index_type', 30)->nullable();
// Para equipamentos/maquinário
$table->string('equipment_brand', 100)->nullable();
$table->string('equipment_model', 100)->nullable();
$table->string('serial_number', 100)->nullable();
$table->date('warranty_expiry')->nullable();
// Para recebíveis
$table->string('debtor_name', 200)->nullable();
$table->string('debtor_document', 50)->nullable();
$table->date('receivable_due_date')->nullable();
$table->decimal('receivable_amount', 15, 2)->nullable();
// Garantias (se o ativo serve como garantia)
$table->boolean('is_collateral')->default(false);
$table->string('collateral_for', 200)->nullable();
$table->foreignId('linked_liability_id')->nullable()->constrained('liability_accounts')->onDelete('set null');
// Seguros
$table->boolean('has_insurance')->default(false);
$table->string('insurance_company', 100)->nullable();
$table->string('insurance_policy', 100)->nullable();
$table->decimal('insurance_value', 15, 2)->nullable();
$table->date('insurance_expiry')->nullable();
// Gestão
$table->integer('alert_days_before')->default(30);
$table->string('internal_responsible', 200)->nullable();
$table->text('internal_notes')->nullable();
$table->string('document_number', 100)->nullable();
// Status
$table->string('status', 30)->default('active'); // active, sold, depreciated, written_off
$table->date('disposal_date')->nullable();
$table->decimal('disposal_value', 15, 2)->nullable();
$table->string('disposal_reason', 200)->nullable();
$table->timestamps();
$table->softDeletes();
// Índices
$table->index(['user_id', 'asset_type']);
$table->index(['user_id', 'status']);
$table->index(['business_id', 'asset_type']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('asset_accounts');
}
};

View File

@ -0,0 +1,320 @@
<?php
namespace Database\Seeders;
use App\Models\User;
use App\Models\LiabilityAccount;
use App\Models\AssetAccount;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash;
use Carbon\Carbon;
class DemoUserSeeder extends Seeder
{
public function run(): void
{
// 1. Criar usuário DEMO
$user = User::updateOrCreate(
['email' => 'demo@webmoney.com'],
[
'name' => 'Usuario Demo',
'first_name' => 'Demo',
'last_name' => 'WebMoney',
'password' => Hash::make('DEMO'),
'is_demo' => true,
'is_admin' => false,
'email_verified_at' => now(),
'country' => 'ES',
'timezone' => 'Europe/Madrid',
'locale' => 'es_ES',
'language' => 'es',
'currency' => 'EUR',
]
);
$this->command->info("✓ Usuario demo creado: demo@webmoney.com / DEMO");
// 2. Criar passivos de exemplo
$this->createSampleLiabilities($user);
// 3. Criar ativos de exemplo
$this->createSampleAssets($user);
}
protected function createSampleLiabilities(User $user): void
{
$liabilities = [
[
'contract_type' => 'personal_loan',
'name' => 'Préstamo Personal Santander',
'creditor' => 'Banco Santander',
'contract_number' => 'PL-2024-001234',
'description' => 'Préstamo personal para reforma del hogar',
'currency' => 'EUR',
'principal_amount' => 15000.00,
'total_pending' => 12500.00,
'total_paid' => 2500.00,
'annual_interest_rate' => 7.5,
'monthly_interest_rate' => 0.625,
'amortization_system' => 'price',
'total_installments' => 48,
'paid_installments' => 12,
'start_date' => '2024-01-15',
'first_due_date' => '2024-02-15',
'index_type' => 'fixed',
'alert_days_before' => 5,
'color' => '#3B82F6',
'status' => 'active',
],
[
'contract_type' => 'vehicle',
'name' => 'Financiación Coche BMW X3',
'creditor' => 'BMW Financial Services',
'contract_number' => 'VF-2024-BMW-5678',
'description' => 'Financiación vehículo BMW X3 xDrive20d',
'currency' => 'EUR',
'principal_amount' => 45000.00,
'total_pending' => 38750.00,
'total_paid' => 6250.00,
'annual_interest_rate' => 5.9,
'amortization_system' => 'price',
'total_installments' => 60,
'paid_installments' => 10,
'start_date' => '2024-03-01',
'first_due_date' => '2024-04-01',
'guarantee_type' => 'fiduciary_alienation',
'guarantee_value' => 45000.00,
'index_type' => 'fixed',
'color' => '#10B981',
'status' => 'active',
],
[
'contract_type' => 'mortgage',
'name' => 'Hipoteca Vivienda Principal',
'creditor' => 'CaixaBank',
'contract_number' => 'HIP-2020-987654',
'description' => 'Hipoteca vivienda familiar - Madrid Centro',
'currency' => 'EUR',
'principal_amount' => 250000.00,
'total_pending' => 215000.00,
'total_paid' => 35000.00,
'annual_interest_rate' => 2.5,
'amortization_system' => 'sac',
'total_installments' => 300,
'paid_installments' => 48,
'start_date' => '2020-06-15',
'first_due_date' => '2020-07-15',
'guarantee_type' => 'mortgage_guarantee',
'guarantee_value' => 350000.00,
'index_type' => 'euribor',
'index_spread' => 0.80,
'color' => '#F59E0B',
'status' => 'active',
],
[
'contract_type' => 'credit_card',
'name' => 'Tarjeta Visa Oro BBVA',
'creditor' => 'BBVA',
'contract_number' => 'CC-VISA-4242',
'currency' => 'EUR',
'principal_amount' => 5000.00,
'total_pending' => 3250.00,
'total_paid' => 1750.00,
'annual_interest_rate' => 18.99,
'amortization_system' => 'revolving',
'total_installments' => 0,
'paid_installments' => 0,
'start_date' => '2023-01-01',
'first_due_date' => '2023-02-01',
'color' => '#EF4444',
'status' => 'active',
],
[
'contract_type' => 'leasing',
'name' => 'Leasing Maquinaria Industrial',
'creditor' => 'Banco Sabadell Leasing',
'contract_number' => 'LEA-2024-MAQ-456',
'currency' => 'EUR',
'principal_amount' => 120000.00,
'total_pending' => 105000.00,
'total_paid' => 15000.00,
'annual_interest_rate' => 4.5,
'amortization_system' => 'price',
'total_installments' => 48,
'paid_installments' => 6,
'start_date' => '2024-05-01',
'first_due_date' => '2024-06-01',
'color' => '#06B6D4',
'status' => 'active',
],
];
foreach ($liabilities as $data) {
LiabilityAccount::updateOrCreate(
['user_id' => $user->id, 'contract_number' => $data['contract_number']],
array_merge($data, ['user_id' => $user->id])
);
}
$this->command->info("" . count($liabilities) . " pasivos de ejemplo creados");
}
protected function createSampleAssets(User $user): void
{
$assets = [
[
'asset_type' => 'real_estate',
'name' => 'Vivienda Principal Madrid',
'description' => 'Piso de 120m² en el centro de Madrid',
'currency' => 'EUR',
'acquisition_value' => 350000.00,
'current_value' => 420000.00,
'acquisition_date' => '2020-06-15',
'property_type' => 'apartment',
'address' => 'Calle Mayor 45, 3º A',
'city' => 'Madrid',
'postal_code' => '28013',
'country' => 'ES',
'property_area_m2' => 120.00,
'color' => '#3B82F6',
],
[
'asset_type' => 'real_estate',
'name' => 'Local Comercial Barcelona',
'description' => 'Local comercial en zona turística',
'currency' => 'EUR',
'acquisition_value' => 180000.00,
'current_value' => 210000.00,
'acquisition_date' => '2019-03-20',
'property_type' => 'commercial',
'city' => 'Barcelona',
'property_area_m2' => 85.00,
'color' => '#10B981',
],
[
'asset_type' => 'vehicle',
'name' => 'BMW X3 xDrive20d',
'description' => 'SUV premium para uso personal',
'currency' => 'EUR',
'acquisition_value' => 55000.00,
'current_value' => 42000.00,
'acquisition_date' => '2022-09-01',
'vehicle_brand' => 'BMW',
'vehicle_model' => 'X3 xDrive20d',
'vehicle_year' => 2022,
'vehicle_plate' => '1234 ABC',
'vehicle_mileage' => 45000,
'color' => '#F59E0B',
],
[
'asset_type' => 'investment',
'name' => 'Cartera Acciones IBEX',
'description' => 'Cartera diversificada de acciones españolas',
'currency' => 'EUR',
'acquisition_value' => 25000.00,
'current_value' => 28500.00,
'acquisition_date' => '2023-01-15',
'investment_type' => 'stocks',
'institution' => 'Interactive Brokers',
'ticker' => 'IBEX35',
'color' => '#8B5CF6',
],
[
'asset_type' => 'investment',
'name' => 'Fondo Indexado S&P 500',
'description' => 'ETF que replica el índice S&P 500',
'currency' => 'USD',
'acquisition_value' => 15000.00,
'current_value' => 18200.00,
'acquisition_date' => '2022-06-01',
'investment_type' => 'etf',
'institution' => 'Vanguard',
'ticker' => 'VOO',
'color' => '#06B6D4',
],
[
'asset_type' => 'investment',
'name' => 'Depósito a Plazo Fijo',
'currency' => 'EUR',
'acquisition_value' => 50000.00,
'current_value' => 51500.00,
'acquisition_date' => '2024-01-15',
'investment_type' => 'fixed_deposit',
'institution' => 'Openbank',
'interest_rate' => 3.00,
'maturity_date' => '2025-01-15',
'color' => '#14B8A6',
],
[
'asset_type' => 'investment',
'name' => 'Bitcoin (BTC)',
'currency' => 'EUR',
'acquisition_value' => 10000.00,
'current_value' => 15800.00,
'acquisition_date' => '2023-08-01',
'investment_type' => 'crypto',
'institution' => 'Binance',
'ticker' => 'BTC',
'color' => '#F97316',
],
[
'asset_type' => 'equipment',
'name' => 'MacBook Pro 16"',
'description' => 'Ordenador portátil profesional',
'currency' => 'EUR',
'acquisition_value' => 3500.00,
'current_value' => 2800.00,
'acquisition_date' => '2023-03-15',
'equipment_brand' => 'Apple',
'equipment_model' => 'MacBook Pro 16 M2 Pro',
'serial_number' => 'C02XL234H1',
'warranty_expiry' => '2026-03-15',
'color' => '#64748B',
],
[
'asset_type' => 'receivable',
'name' => 'Factura Cliente ABC Corp',
'description' => 'Factura pendiente de cobro',
'currency' => 'EUR',
'acquisition_value' => 12500.00,
'current_value' => 12500.00,
'acquisition_date' => '2024-11-15',
'debtor_name' => 'ABC Corporation S.L.',
'receivable_due_date' => '2025-01-15',
'receivable_amount' => 12500.00,
'color' => '#84CC16',
],
[
'asset_type' => 'cash',
'name' => 'Reserva de Emergencia',
'description' => 'Fondo de emergencia en cuenta de ahorro',
'currency' => 'EUR',
'acquisition_value' => 20000.00,
'current_value' => 20500.00,
'acquisition_date' => '2023-01-01',
'institution' => 'ING Direct',
'interest_rate' => 2.50,
'color' => '#22C55E',
],
[
'asset_type' => 'other',
'name' => 'Colección de Arte',
'description' => 'Obras de arte y antigüedades',
'currency' => 'EUR',
'acquisition_value' => 35000.00,
'current_value' => 42000.00,
'acquisition_date' => '2021-01-01',
'color' => '#A855F7',
],
];
foreach ($assets as $data) {
AssetAccount::updateOrCreate(
['user_id' => $user->id, 'name' => $data['name']],
array_merge($data, ['user_id' => $user->id, 'status' => 'active'])
);
}
$this->command->info("" . count($assets) . " activos de ejemplo creados");
}
}

View File

@ -0,0 +1,166 @@
@extends('emails.layouts.base')
@php $locale = $locale ?? 'pt-BR'; @endphp
@section('title')
@if($locale === 'pt-BR')
Ativação de Conta
@elseif($locale === 'en')
Account Activation
@else
Activación de Cuenta
@endif
@endsection
@section('content')
@if($locale === 'pt-BR')
{{-- Portuguese (Brazil) --}}
<p class="greeting">Olá, {{ $user->name }}</p>
<div class="content">
<p>O seu pagamento foi processado com sucesso e a sua subscrição do plano <strong>{{ $planName }}</strong> está ativa.</p>
<div class="status-card success">
<table cellpadding="0" cellspacing="0" border="0" width="100%">
<tr>
<td width="52" valign="top">
<div style="width: 40px; height: 40px; background-color: #c6f6d5; border-radius: 50%; text-align: center; line-height: 40px; font-size: 18px;"></div>
</td>
<td valign="top">
<p class="status-title">Pagamento Confirmado</p>
</td>
</tr>
</table>
<p style="margin-top: 16px;">A sua subscrição está pronta para ser ativada.</p>
</div>
<div class="status-card info">
<p class="status-title" style="color: #2d3748; margin-bottom: 16px;">Detalhes da Subscrição</p>
<ul class="info-list">
<li><strong>Plano:</strong> {{ $planName }}</li>
<li><strong>Email:</strong> {{ $user->email }}</li>
</ul>
</div>
<p>Para começar a utilizar o WEBMoney, ative a sua conta clicando no botão abaixo:</p>
<div class="btn-container">
<a href="{{ $activationUrl }}" class="btn btn-primary">ATIVAR CONTA</a>
</div>
<div class="status-card warning">
<table cellpadding="0" cellspacing="0" border="0" width="100%">
<tr>
<td width="52" valign="top">
<div style="width: 40px; height: 40px; background-color: #feebc8; border-radius: 50%; text-align: center; line-height: 40px; font-size: 18px;">!</div>
</td>
<td valign="top">
<p class="status-title">Validade do Link</p>
</td>
</tr>
</table>
<p style="margin-top: 12px;">Este link é válido por 24 horas. Após este período, será necessário solicitar um novo link de ativação.</p>
</div>
</div>
@elseif($locale === 'en')
{{-- English --}}
<p class="greeting">Hello, {{ $user->name }}</p>
<div class="content">
<p>Your payment has been successfully processed and your <strong>{{ $planName }}</strong> subscription is active.</p>
<div class="status-card success">
<table cellpadding="0" cellspacing="0" border="0" width="100%">
<tr>
<td width="52" valign="top">
<div style="width: 40px; height: 40px; background-color: #c6f6d5; border-radius: 50%; text-align: center; line-height: 40px; font-size: 18px;"></div>
</td>
<td valign="top">
<p class="status-title">Payment Confirmed</p>
</td>
</tr>
</table>
<p style="margin-top: 16px;">Your subscription is ready to be activated.</p>
</div>
<div class="status-card info">
<p class="status-title" style="color: #2d3748; margin-bottom: 16px;">Subscription Details</p>
<ul class="info-list">
<li><strong>Plan:</strong> {{ $planName }}</li>
<li><strong>Email:</strong> {{ $user->email }}</li>
</ul>
</div>
<p>To start using WEBMoney, activate your account by clicking the button below:</p>
<div class="btn-container">
<a href="{{ $activationUrl }}" class="btn btn-primary">ACTIVATE ACCOUNT</a>
</div>
<div class="status-card warning">
<table cellpadding="0" cellspacing="0" border="0" width="100%">
<tr>
<td width="52" valign="top">
<div style="width: 40px; height: 40px; background-color: #feebc8; border-radius: 50%; text-align: center; line-height: 40px; font-size: 18px;">!</div>
</td>
<td valign="top">
<p class="status-title">Link Validity</p>
</td>
</tr>
</table>
<p style="margin-top: 12px;">This link is valid for 24 hours. After this period, you will need to request a new activation link.</p>
</div>
</div>
@else
{{-- Spanish (default) --}}
<p class="greeting">Hola, {{ $user->name }}</p>
<div class="content">
<p>Tu pago ha sido procesado con éxito y tu suscripción al plan <strong>{{ $planName }}</strong> está activa.</p>
<div class="status-card success">
<table cellpadding="0" cellspacing="0" border="0" width="100%">
<tr>
<td width="52" valign="top">
<div style="width: 40px; height: 40px; background-color: #c6f6d5; border-radius: 50%; text-align: center; line-height: 40px; font-size: 18px;"></div>
</td>
<td valign="top">
<p class="status-title">Pago Confirmado</p>
</td>
</tr>
</table>
<p style="margin-top: 16px;">Tu suscripción está lista para ser activada.</p>
</div>
<div class="status-card info">
<p class="status-title" style="color: #2d3748; margin-bottom: 16px;">Detalles de la Suscripción</p>
<ul class="info-list">
<li><strong>Plan:</strong> {{ $planName }}</li>
<li><strong>Email:</strong> {{ $user->email }}</li>
</ul>
</div>
<p>Para comenzar a usar WEBMoney, activa tu cuenta haciendo clic en el botón:</p>
<div class="btn-container">
<a href="{{ $activationUrl }}" class="btn btn-primary">ACTIVAR CUENTA</a>
</div>
<div class="status-card warning">
<table cellpadding="0" cellspacing="0" border="0" width="100%">
<tr>
<td width="52" valign="top">
<div style="width: 40px; height: 40px; background-color: #feebc8; border-radius: 50%; text-align: center; line-height: 40px; font-size: 18px;">!</div>
</td>
<td valign="top">
<p class="status-title">Validez del Enlace</p>
</td>
</tr>
</table>
<p style="margin-top: 12px;">Este enlace es válido por 24 horas. Después de este período, deberás solicitar un nuevo enlace de activación.</p>
</div>
</div>
@endif
@endsection

View File

@ -1,388 +1,461 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" lang="pt-BR">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="x-apple-disable-message-reformatting" />
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no" />
<title>WEBMoney - Alerta de Pagamentos</title>
<!--[if mso]>
<style type="text/css">
body, table, td {font-family: Arial, Helvetica, sans-serif !important;}
</style>
<![endif]-->
<style type="text/css">
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
background-color: #f5f5f5;
}
.container {
background-color: #ffffff;
border-radius: 8px;
padding: 30px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.header {
text-align: center;
border-bottom: 2px solid #0f172a;
padding-bottom: 20px;
margin-bottom: 20px;
}
.header h1 {
color: #0f172a;
margin: 0;
font-size: 24px;
}
.summary-box {
background: linear-gradient(135deg, #1e293b 0%, #0f172a 100%);
color: white;
padding: 20px;
border-radius: 8px;
margin: 20px 0;
}
.summary-row {
display: flex;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid rgba(255,255,255,0.1);
}
.summary-row:last-child {
border-bottom: none;
}
.summary-label {
color: #94a3b8;
}
.summary-value {
font-weight: bold;
}
.shortage {
background-color: #dc2626;
color: white;
padding: 15px;
border-radius: 8px;
text-align: center;
margin: 20px 0;
}
.shortage h3 {
margin: 0 0 5px 0;
}
.shortage .amount {
font-size: 28px;
font-weight: bold;
}
.section {
margin: 25px 0;
}
.section-title {
font-size: 18px;
color: #0f172a;
border-bottom: 2px solid #e2e8f0;
padding-bottom: 10px;
margin-bottom: 15px;
}
.item {
background-color: #f8fafc;
border-left: 4px solid #64748b;
padding: 12px 15px;
margin: 10px 0;
border-radius: 0 4px 4px 0;
}
.item.overdue {
border-left-color: #dc2626;
background-color: #fef2f2;
}
.item.tomorrow {
border-left-color: #f59e0b;
background-color: #fffbeb;
}
.item.payable {
border-left-color: #22c55e;
background-color: #f0fdf4;
}
.item.unpayable {
border-left-color: #dc2626;
background-color: #fef2f2;
}
.item-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.item-description {
font-weight: 600;
color: #1e293b;
}
.item-amount {
font-weight: bold;
color: #dc2626;
}
.item-details {
font-size: 13px;
color: #64748b;
margin-top: 5px;
}
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 12px;
font-size: 11px;
font-weight: bold;
text-transform: uppercase;
}
.badge-overdue {
background-color: #dc2626;
color: white;
}
.badge-tomorrow {
background-color: #f59e0b;
color: white;
}
.badge-ok {
background-color: #22c55e;
color: white;
}
.account-balance {
display: flex;
justify-content: space-between;
padding: 10px 15px;
background-color: #f8fafc;
margin: 5px 0;
border-radius: 4px;
}
.account-name {
font-weight: 500;
}
.balance-positive {
color: #22c55e;
font-weight: bold;
}
.balance-negative {
color: #dc2626;
font-weight: bold;
}
.transfer-suggestion {
background-color: #eff6ff;
border: 1px solid #3b82f6;
border-radius: 8px;
padding: 15px;
margin: 10px 0;
}
.transfer-arrow {
text-align: center;
font-size: 20px;
color: #3b82f6;
}
.footer {
text-align: center;
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #e2e8f0;
color: #64748b;
font-size: 13px;
}
.cta-button {
display: inline-block;
background-color: #3b82f6;
color: white;
padding: 12px 24px;
text-decoration: none;
border-radius: 6px;
font-weight: bold;
margin: 20px 0;
}
.cta-button:hover {
background-color: #2563eb;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>💰 WEBMoney - Alerta de Pagamentos</h1>
<p>Olá, {{ $userName }}!</p>
</div>
@extends('emails.layouts.base')
<!-- Summary Box -->
<div class="summary-box">
<div class="summary-row">
<span class="summary-label">💳 Saldo Total Disponível</span>
<span class="summary-value">{{ number_format($totalAvailable, 2, ',', '.') }} {{ $currency }}</span>
</div>
<div class="summary-row">
<span class="summary-label">📋 Total a Pagar</span>
<span class="summary-value">{{ number_format($totalDue, 2, ',', '.') }} {{ $currency }}</span>
@php $locale = $locale ?? 'pt-BR'; @endphp
@section('title')
@if($locale === 'pt-BR')
Alerta de Pagamentos
@elseif($locale === 'en')
Payment Alert
@else
Alerta de Pagos
@endif
@endsection
@section('content')
@if($locale === 'pt-BR')
{{-- Portuguese (Brazil) --}}
<p class="greeting">Olá, {{ $user->name }}</p>
<div class="content">
<p>Este é o seu resumo de pagamentos para os próximos dias.</p>
{{-- Summary Box --}}
<div class="status-card info" style="background-color: #1a1a2e; border-color: #4361ee;">
<p class="status-title" style="color: #ffffff; margin-bottom: 16px;">Resumo Financeiro</p>
<table cellpadding="0" cellspacing="0" border="0" width="100%" style="color: #ffffff;">
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid rgba(255,255,255,0.1);">
<span style="color: #94a3b8;">Saldo Disponível</span>
</td>
<td style="padding: 8px 0; border-bottom: 1px solid rgba(255,255,255,0.1); text-align: right; font-weight: 600;">
{{ $currency }} {{ number_format($totalBalance, 2, ',', '.') }}
</td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid rgba(255,255,255,0.1);">
<span style="color: #94a3b8;">Total a Pagar</span>
</td>
<td style="padding: 8px 0; border-bottom: 1px solid rgba(255,255,255,0.1); text-align: right; font-weight: 600; color: #f87171;">
{{ $currency }} {{ number_format($totalDue, 2, ',', '.') }}
</td>
</tr>
<tr>
<td style="padding: 8px 0;">
<span style="color: #94a3b8;">Pagamentos Pendentes</span>
</td>
<td style="padding: 8px 0; text-align: right; font-weight: 600;">
{{ $totalPayments }}
</td>
</tr>
</table>
</div>
{{-- Shortage Alert --}}
@if($shortage > 0)
<div class="summary-row" style="color: #fca5a5;">
<span class="summary-label">⚠️ Falta</span>
<span class="summary-value">{{ number_format($shortage, 2, ',', '.') }} {{ $currency }}</span>
</div>
@else
<div class="summary-row" style="color: #86efac;">
<span class="summary-label"> Situação</span>
<span class="summary-value">Saldo suficiente!</span>
</div>
<div class="status-card warning" style="background-color: #fef2f2; border-color: #dc2626;">
<table cellpadding="0" cellspacing="0" border="0" width="100%">
<tr>
<td width="52" valign="top">
<div style="width: 40px; height: 40px; background-color: #fecaca; border-radius: 50%; text-align: center; line-height: 40px; font-size: 18px; color: #dc2626;">!</div>
</td>
<td valign="top">
<p class="status-title" style="color: #dc2626;">Saldo Insuficiente</p>
</td>
</tr>
</table>
<p style="margin-top: 12px; color: #991b1b; text-align: center;">
<span style="font-size: 28px; font-weight: 700; display: block;">{{ $currency }} {{ number_format($shortage, 2, ',', '.') }}</span>
<span style="font-size: 13px;">em falta para cobrir todos os pagamentos</span>
</p>
</div>
@endif
</div>
@if($shortage > 0)
<div class="shortage">
<h3>⚠️ SALDO INSUFICIENTE</h3>
<div class="amount">-{{ number_format($shortage, 2, ',', '.') }} {{ $currency }}</div>
<p style="margin: 10px 0 0 0; font-size: 14px;">Você não tem saldo suficiente para cobrir todos os pagamentos.</p>
</div>
@endif
<!-- Account Balances -->
<div class="section">
<h2 class="section-title">💳 Saldo das Contas</h2>
@foreach($accountBalances as $account)
<div class="account-balance">
<span class="account-name">{{ $account['name'] }}</span>
<span class="{{ $account['balance'] >= 0 ? 'balance-positive' : 'balance-negative' }}">
{{ number_format($account['balance'], 2, ',', '.') }} {{ $account['currency'] }}
</span>
{{-- Overdue Payments --}}
@if(count($overduePayments) > 0)
<div style="margin-top: 24px;">
<p style="font-size: 14px; font-weight: 600; color: #dc2626; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 2px solid #fecaca;">
Pagamentos em Atraso
</p>
@foreach($overduePayments as $payment)
<div style="background-color: #fef2f2; border-left: 4px solid #dc2626; padding: 12px 16px; margin: 8px 0; border-radius: 0 4px 4px 0;">
<table cellpadding="0" cellspacing="0" border="0" width="100%">
<tr>
<td>
<span style="font-weight: 600; color: #1e293b;">{{ $payment['description'] }}</span>
</td>
<td style="text-align: right;">
<span style="font-weight: 700; color: #dc2626;">{{ $currency }} {{ number_format($payment['amount'], 2, ',', '.') }}</span>
</td>
</tr>
<tr>
<td colspan="2" style="padding-top: 4px;">
<span style="font-size: 12px; color: #64748b;">Venceu em {{ \Carbon\Carbon::parse($payment['due_date'])->format('d/m/Y') }}</span>
<span style="display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 10px; font-weight: 700; text-transform: uppercase; background-color: #dc2626; color: white; margin-left: 8px;">ATRASADO</span>
</td>
</tr>
</table>
</div>
@endforeach
</div>
@endif
{{-- Tomorrow Payments --}}
@if(count($tomorrowPayments) > 0)
<div style="margin-top: 24px;">
<p style="font-size: 14px; font-weight: 600; color: #f59e0b; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 2px solid #fde68a;">
Vencem Amanhã
</p>
@foreach($tomorrowPayments as $payment)
<div style="background-color: #fffbeb; border-left: 4px solid #f59e0b; padding: 12px 16px; margin: 8px 0; border-radius: 0 4px 4px 0;">
<table cellpadding="0" cellspacing="0" border="0" width="100%">
<tr>
<td>
<span style="font-weight: 600; color: #1e293b;">{{ $payment['description'] }}</span>
</td>
<td style="text-align: right;">
<span style="font-weight: 700; color: #d97706;">{{ $currency }} {{ number_format($payment['amount'], 2, ',', '.') }}</span>
</td>
</tr>
<tr>
<td colspan="2" style="padding-top: 4px;">
<span style="font-size: 12px; color: #64748b;">{{ \Carbon\Carbon::parse($payment['due_date'])->format('d/m/Y') }}</span>
<span style="display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 10px; font-weight: 700; text-transform: uppercase; background-color: #f59e0b; color: white; margin-left: 8px;">AMANHÃ</span>
</td>
</tr>
</table>
</div>
@endforeach
</div>
@endif
{{-- Upcoming Payments --}}
@if(count($upcomingPayments) > 0)
<div style="margin-top: 24px;">
<p style="font-size: 14px; font-weight: 600; color: #64748b; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 2px solid #e2e8f0;">
Próximos Pagamentos
</p>
@foreach($upcomingPayments as $payment)
<div style="background-color: #f8fafc; border-left: 4px solid #64748b; padding: 12px 16px; margin: 8px 0; border-radius: 0 4px 4px 0;">
<table cellpadding="0" cellspacing="0" border="0" width="100%">
<tr>
<td>
<span style="font-weight: 600; color: #1e293b;">{{ $payment['description'] }}</span>
</td>
<td style="text-align: right;">
<span style="font-weight: 700; color: #475569;">{{ $currency }} {{ number_format($payment['amount'], 2, ',', '.') }}</span>
</td>
</tr>
<tr>
<td colspan="2" style="padding-top: 4px;">
<span style="font-size: 12px; color: #64748b;">{{ \Carbon\Carbon::parse($payment['due_date'])->format('d/m/Y') }}</span>
</td>
</tr>
</table>
</div>
@endforeach
</div>
@endif
<div class="divider"></div>
<div class="btn-container">
<a href="https://webmoney.cnxifly.com/dashboard" class="btn btn-primary">VER DASHBOARD</a>
</div>
@endforeach
</div>
<!-- Overdue Items -->
@if(count($overdueItems) > 0)
<div class="section">
<h2 class="section-title">🔴 Pagamentos Vencidos ({{ count($overdueItems) }})</h2>
@foreach($overdueItems as $item)
<div class="item overdue">
<div class="item-header">
<span class="item-description">{{ $item['description'] }}</span>
<span class="item-amount">{{ number_format($item['amount'], 2, ',', '.') }} {{ $item['currency'] }}</span>
</div>
<div class="item-details">
<span class="badge badge-overdue">{{ $item['days_overdue'] }} dias de atraso</span>
Venceu em {{ \Carbon\Carbon::parse($item['due_date'])->format('d/m/Y') }}
@if($item['account_name'])
Conta: {{ $item['account_name'] }}
@endif
</div>
@elseif($locale === 'en')
{{-- English --}}
<p class="greeting">Hello, {{ $user->name }}</p>
<div class="content">
<p>Here is your payment summary for the coming days.</p>
{{-- Summary Box --}}
<div class="status-card info" style="background-color: #1a1a2e; border-color: #4361ee;">
<p class="status-title" style="color: #ffffff; margin-bottom: 16px;">Financial Summary</p>
<table cellpadding="0" cellspacing="0" border="0" width="100%" style="color: #ffffff;">
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid rgba(255,255,255,0.1);">
<span style="color: #94a3b8;">Available Balance</span>
</td>
<td style="padding: 8px 0; border-bottom: 1px solid rgba(255,255,255,0.1); text-align: right; font-weight: 600;">
{{ $currency }} {{ number_format($totalBalance, 2, '.', ',') }}
</td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid rgba(255,255,255,0.1);">
<span style="color: #94a3b8;">Total Due</span>
</td>
<td style="padding: 8px 0; border-bottom: 1px solid rgba(255,255,255,0.1); text-align: right; font-weight: 600; color: #f87171;">
{{ $currency }} {{ number_format($totalDue, 2, '.', ',') }}
</td>
</tr>
<tr>
<td style="padding: 8px 0;">
<span style="color: #94a3b8;">Pending Payments</span>
</td>
<td style="padding: 8px 0; text-align: right; font-weight: 600;">
{{ $totalPayments }}
</td>
</tr>
</table>
</div>
@endforeach
</div>
@endif
<!-- Tomorrow Items -->
@if(count($tomorrowItems) > 0)
<div class="section">
<h2 class="section-title">🟡 Vencem Amanhã ({{ count($tomorrowItems) }})</h2>
@foreach($tomorrowItems as $item)
<div class="item tomorrow">
<div class="item-header">
<span class="item-description">{{ $item['description'] }}</span>
<span class="item-amount">{{ number_format($item['amount'], 2, ',', '.') }} {{ $item['currency'] }}</span>
@if($shortage > 0)
<div class="status-card warning" style="background-color: #fef2f2; border-color: #dc2626;">
<table cellpadding="0" cellspacing="0" border="0" width="100%">
<tr>
<td width="52" valign="top">
<div style="width: 40px; height: 40px; background-color: #fecaca; border-radius: 50%; text-align: center; line-height: 40px; font-size: 18px; color: #dc2626;">!</div>
</td>
<td valign="top">
<p class="status-title" style="color: #dc2626;">Insufficient Balance</p>
</td>
</tr>
</table>
<p style="margin-top: 12px; color: #991b1b; text-align: center;">
<span style="font-size: 28px; font-weight: 700; display: block;">{{ $currency }} {{ number_format($shortage, 2, '.', ',') }}</span>
<span style="font-size: 13px;">short to cover all payments</span>
</p>
</div>
<div class="item-details">
<span class="badge badge-tomorrow">Amanhã</span>
{{ \Carbon\Carbon::parse($item['due_date'])->format('d/m/Y') }}
@if($item['account_name'])
Conta: {{ $item['account_name'] }}
@endif
@endif
@if(count($overduePayments) > 0)
<div style="margin-top: 24px;">
<p style="font-size: 14px; font-weight: 600; color: #dc2626; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 2px solid #fecaca;">
Overdue Payments
</p>
@foreach($overduePayments as $payment)
<div style="background-color: #fef2f2; border-left: 4px solid #dc2626; padding: 12px 16px; margin: 8px 0; border-radius: 0 4px 4px 0;">
<table cellpadding="0" cellspacing="0" border="0" width="100%">
<tr>
<td>
<span style="font-weight: 600; color: #1e293b;">{{ $payment['description'] }}</span>
</td>
<td style="text-align: right;">
<span style="font-weight: 700; color: #dc2626;">{{ $currency }} {{ number_format($payment['amount'], 2, '.', ',') }}</span>
</td>
</tr>
<tr>
<td colspan="2" style="padding-top: 4px;">
<span style="font-size: 12px; color: #64748b;">Due {{ \Carbon\Carbon::parse($payment['due_date'])->format('M d, Y') }}</span>
<span style="display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 10px; font-weight: 700; text-transform: uppercase; background-color: #dc2626; color: white; margin-left: 8px;">OVERDUE</span>
</td>
</tr>
</table>
</div>
@endforeach
</div>
@endif
@if(count($tomorrowPayments) > 0)
<div style="margin-top: 24px;">
<p style="font-size: 14px; font-weight: 600; color: #f59e0b; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 2px solid #fde68a;">
Due Tomorrow
</p>
@foreach($tomorrowPayments as $payment)
<div style="background-color: #fffbeb; border-left: 4px solid #f59e0b; padding: 12px 16px; margin: 8px 0; border-radius: 0 4px 4px 0;">
<table cellpadding="0" cellspacing="0" border="0" width="100%">
<tr>
<td>
<span style="font-weight: 600; color: #1e293b;">{{ $payment['description'] }}</span>
</td>
<td style="text-align: right;">
<span style="font-weight: 700; color: #d97706;">{{ $currency }} {{ number_format($payment['amount'], 2, '.', ',') }}</span>
</td>
</tr>
<tr>
<td colspan="2" style="padding-top: 4px;">
<span style="font-size: 12px; color: #64748b;">{{ \Carbon\Carbon::parse($payment['due_date'])->format('M d, Y') }}</span>
<span style="display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 10px; font-weight: 700; text-transform: uppercase; background-color: #f59e0b; color: white; margin-left: 8px;">TOMORROW</span>
</td>
</tr>
</table>
</div>
@endforeach
</div>
@endif
@if(count($upcomingPayments) > 0)
<div style="margin-top: 24px;">
<p style="font-size: 14px; font-weight: 600; color: #64748b; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 2px solid #e2e8f0;">
Upcoming Payments
</p>
@foreach($upcomingPayments as $payment)
<div style="background-color: #f8fafc; border-left: 4px solid #64748b; padding: 12px 16px; margin: 8px 0; border-radius: 0 4px 4px 0;">
<table cellpadding="0" cellspacing="0" border="0" width="100%">
<tr>
<td>
<span style="font-weight: 600; color: #1e293b;">{{ $payment['description'] }}</span>
</td>
<td style="text-align: right;">
<span style="font-weight: 700; color: #475569;">{{ $currency }} {{ number_format($payment['amount'], 2, '.', ',') }}</span>
</td>
</tr>
<tr>
<td colspan="2" style="padding-top: 4px;">
<span style="font-size: 12px; color: #64748b;">{{ \Carbon\Carbon::parse($payment['due_date'])->format('M d, Y') }}</span>
</td>
</tr>
</table>
</div>
@endforeach
</div>
@endif
<div class="divider"></div>
<div class="btn-container">
<a href="https://webmoney.cnxifly.com/dashboard" class="btn btn-primary">VIEW DASHBOARD</a>
</div>
@endforeach
</div>
@endif
<!-- Payable Items -->
@if(count($payableItems) > 0)
<div class="section">
<h2 class="section-title"> Pagamentos Possíveis ({{ count($payableItems) }})</h2>
<p style="color: #64748b; font-size: 14px;">Com base no saldo atual, você consegue pagar:</p>
@foreach($payableItems as $item)
<div class="item payable">
<div class="item-header">
<span class="item-description">{{ $item['description'] }}</span>
<span class="item-amount" style="color: #22c55e;">{{ number_format($item['amount'], 2, ',', '.') }} {{ $item['currency'] }}</span>
</div>
<div class="item-details">
<span class="badge badge-ok"> Pode pagar</span>
@if($item['account_name'])
Conta: {{ $item['account_name'] }}
@endif
</div>
@else
{{-- Spanish (default) --}}
<p class="greeting">Hola, {{ $user->name }}</p>
<div class="content">
<p>Este es tu resumen de pagos para los próximos días.</p>
{{-- Summary Box --}}
<div class="status-card info" style="background-color: #1a1a2e; border-color: #4361ee;">
<p class="status-title" style="color: #ffffff; margin-bottom: 16px;">Resumen Financiero</p>
<table cellpadding="0" cellspacing="0" border="0" width="100%" style="color: #ffffff;">
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid rgba(255,255,255,0.1);">
<span style="color: #94a3b8;">Saldo Disponible</span>
</td>
<td style="padding: 8px 0; border-bottom: 1px solid rgba(255,255,255,0.1); text-align: right; font-weight: 600;">
{{ $currency }} {{ number_format($totalBalance, 2, ',', '.') }}
</td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid rgba(255,255,255,0.1);">
<span style="color: #94a3b8;">Total a Pagar</span>
</td>
<td style="padding: 8px 0; border-bottom: 1px solid rgba(255,255,255,0.1); text-align: right; font-weight: 600; color: #f87171;">
{{ $currency }} {{ number_format($totalDue, 2, ',', '.') }}
</td>
</tr>
<tr>
<td style="padding: 8px 0;">
<span style="color: #94a3b8;">Pagos Pendientes</span>
</td>
<td style="padding: 8px 0; text-align: right; font-weight: 600;">
{{ $totalPayments }}
</td>
</tr>
</table>
</div>
@endforeach
</div>
@endif
<!-- Unpayable Items -->
@if(count($unpayableItems) > 0)
<div class="section">
<h2 class="section-title"> Sem Saldo Suficiente ({{ count($unpayableItems) }})</h2>
<p style="color: #64748b; font-size: 14px;">Não saldo disponível para estes pagamentos:</p>
@foreach($unpayableItems as $item)
<div class="item unpayable">
<div class="item-header">
<span class="item-description">{{ $item['description'] }}</span>
<span class="item-amount">{{ number_format($item['amount'], 2, ',', '.') }} {{ $item['currency'] }}</span>
@if($shortage > 0)
<div class="status-card warning" style="background-color: #fef2f2; border-color: #dc2626;">
<table cellpadding="0" cellspacing="0" border="0" width="100%">
<tr>
<td width="52" valign="top">
<div style="width: 40px; height: 40px; background-color: #fecaca; border-radius: 50%; text-align: center; line-height: 40px; font-size: 18px; color: #dc2626;">!</div>
</td>
<td valign="top">
<p class="status-title" style="color: #dc2626;">Saldo Insuficiente</p>
</td>
</tr>
</table>
<p style="margin-top: 12px; color: #991b1b; text-align: center;">
<span style="font-size: 28px; font-weight: 700; display: block;">{{ $currency }} {{ number_format($shortage, 2, ',', '.') }}</span>
<span style="font-size: 13px;">faltan para cubrir todos los pagos</span>
</p>
</div>
<div class="item-details">
<span class="badge badge-overdue"> Sem saldo</span>
@if($item['account_name'])
Conta: {{ $item['account_name'] }}
@endif
@endif
@if(count($overduePayments) > 0)
<div style="margin-top: 24px;">
<p style="font-size: 14px; font-weight: 600; color: #dc2626; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 2px solid #fecaca;">
Pagos Vencidos
</p>
@foreach($overduePayments as $payment)
<div style="background-color: #fef2f2; border-left: 4px solid #dc2626; padding: 12px 16px; margin: 8px 0; border-radius: 0 4px 4px 0;">
<table cellpadding="0" cellspacing="0" border="0" width="100%">
<tr>
<td>
<span style="font-weight: 600; color: #1e293b;">{{ $payment['description'] }}</span>
</td>
<td style="text-align: right;">
<span style="font-weight: 700; color: #dc2626;">{{ $currency }} {{ number_format($payment['amount'], 2, ',', '.') }}</span>
</td>
</tr>
<tr>
<td colspan="2" style="padding-top: 4px;">
<span style="font-size: 12px; color: #64748b;">Venció el {{ \Carbon\Carbon::parse($payment['due_date'])->format('d/m/Y') }}</span>
<span style="display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 10px; font-weight: 700; text-transform: uppercase; background-color: #dc2626; color: white; margin-left: 8px;">VENCIDO</span>
</td>
</tr>
</table>
</div>
@endforeach
</div>
@endif
@if(count($tomorrowPayments) > 0)
<div style="margin-top: 24px;">
<p style="font-size: 14px; font-weight: 600; color: #f59e0b; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 2px solid #fde68a;">
Vencen Mañana
</p>
@foreach($tomorrowPayments as $payment)
<div style="background-color: #fffbeb; border-left: 4px solid #f59e0b; padding: 12px 16px; margin: 8px 0; border-radius: 0 4px 4px 0;">
<table cellpadding="0" cellspacing="0" border="0" width="100%">
<tr>
<td>
<span style="font-weight: 600; color: #1e293b;">{{ $payment['description'] }}</span>
</td>
<td style="text-align: right;">
<span style="font-weight: 700; color: #d97706;">{{ $currency }} {{ number_format($payment['amount'], 2, ',', '.') }}</span>
</td>
</tr>
<tr>
<td colspan="2" style="padding-top: 4px;">
<span style="font-size: 12px; color: #64748b;">{{ \Carbon\Carbon::parse($payment['due_date'])->format('d/m/Y') }}</span>
<span style="display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 10px; font-weight: 700; text-transform: uppercase; background-color: #f59e0b; color: white; margin-left: 8px;">MAÑANA</span>
</td>
</tr>
</table>
</div>
@endforeach
</div>
@endif
@if(count($upcomingPayments) > 0)
<div style="margin-top: 24px;">
<p style="font-size: 14px; font-weight: 600; color: #64748b; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 2px solid #e2e8f0;">
Próximos Pagos
</p>
@foreach($upcomingPayments as $payment)
<div style="background-color: #f8fafc; border-left: 4px solid #64748b; padding: 12px 16px; margin: 8px 0; border-radius: 0 4px 4px 0;">
<table cellpadding="0" cellspacing="0" border="0" width="100%">
<tr>
<td>
<span style="font-weight: 600; color: #1e293b;">{{ $payment['description'] }}</span>
</td>
<td style="text-align: right;">
<span style="font-weight: 700; color: #475569;">{{ $currency }} {{ number_format($payment['amount'], 2, ',', '.') }}</span>
</td>
</tr>
<tr>
<td colspan="2" style="padding-top: 4px;">
<span style="font-size: 12px; color: #64748b;">{{ \Carbon\Carbon::parse($payment['due_date'])->format('d/m/Y') }}</span>
</td>
</tr>
</table>
</div>
@endforeach
</div>
@endif
<div class="divider"></div>
<div class="btn-container">
<a href="https://webmoney.cnxifly.com/dashboard" class="btn btn-primary">VER DASHBOARD</a>
</div>
@endforeach
</div>
@endif
<!-- Transfer Suggestions -->
@if(count($transferSuggestions) > 0)
<div class="section">
<h2 class="section-title">💱 Sugestões de Transferência</h2>
<p style="color: #64748b; font-size: 14px;">Para cobrir os pagamentos, considere transferir entre suas contas:</p>
@foreach($transferSuggestions as $transfer)
<div class="transfer-suggestion">
<div style="display: flex; align-items: center; justify-content: space-between;">
<div>
<strong>{{ $transfer['from_account'] }}</strong>
<div style="font-size: 12px; color: #64748b;">Origem</div>
</div>
<div class="transfer-arrow"></div>
<div style="text-align: right;">
<strong>{{ $transfer['to_account'] }}</strong>
<div style="font-size: 12px; color: #64748b;">Destino</div>
</div>
</div>
<div style="text-align: center; margin-top: 10px; font-size: 20px; font-weight: bold; color: #3b82f6;">
{{ number_format($transfer['amount'], 2, ',', '.') }} {{ $currency }}
</div>
<div style="text-align: center; font-size: 12px; color: #64748b;">{{ $transfer['reason'] }}</div>
</div>
@endforeach
</div>
@endif
<div style="text-align: center;">
<a href="https://webmoney.cnxifly.com/transactions" class="cta-button">
Acessar WEBMoney
</a>
</div>
<div class="footer">
<p>Este email foi enviado automaticamente pelo sistema WEBMoney para {{ $userName }}.</p>
<p>Você recebe esta mensagem porque ativou as notificações de pagamentos.</p>
<p>Para desativar estas notificações, acesse <a href="https://webmoney.cnxifly.com/preferences" style="color: #3b82f6;">Preferências</a>.</p>
<p style="margin-top: 15px; font-size: 11px; color: #94a3b8;">
WEBMoney - ConneXiFly<br />
Serviço de gestão financeira pessoal<br />
Madrid, Espanha
</p>
<p style="font-size: 11px; color: #94a3b8;">&copy; {{ date('Y') }} WEBMoney - Todos os direitos reservados</p>
</div>
</div>
</body>
</html>
@endif
@endsection

View File

@ -0,0 +1,412 @@
<!DOCTYPE html>
<html lang="{{ $locale ?? 'es' }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>@yield('title') - WEBMoney</title>
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<![endif]-->
<style>
/* Reset */
body, table, td, p, a, li, blockquote {
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
table, td {
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
img {
-ms-interpolation-mode: bicubic;
border: 0;
height: auto;
line-height: 100%;
outline: none;
text-decoration: none;
}
/* Base styles */
body {
margin: 0 !important;
padding: 0 !important;
background-color: #f8f9fa;
font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, Roboto, 'Helvetica Neue', Arial, sans-serif;
}
/* Container */
.email-wrapper {
width: 100%;
background-color: #f8f9fa;
padding: 48px 16px;
}
.email-container {
max-width: 580px;
margin: 0 auto;
background-color: #ffffff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
border: 1px solid #e9ecef;
}
/* Header */
.email-header {
background-color: #1a1a2e;
padding: 32px 40px;
text-align: center;
border-bottom: 3px solid #4361ee;
}
.logo {
font-size: 24px;
font-weight: 700;
color: #ffffff;
letter-spacing: -0.3px;
margin: 0;
text-transform: uppercase;
}
.logo-accent {
color: #4361ee;
}
/* Body */
.email-body {
padding: 40px;
}
.greeting {
font-size: 20px;
font-weight: 600;
color: #1a1a2e;
margin: 0 0 24px 0;
line-height: 1.4;
}
.content p {
font-size: 15px;
line-height: 1.7;
color: #495057;
margin: 0 0 16px 0;
}
.content strong {
color: #1a1a2e;
font-weight: 600;
}
/* Status Card */
.status-card {
border-radius: 8px;
padding: 24px;
margin: 24px 0;
border: 1px solid;
}
.status-card.success {
background-color: #f8fff8;
border-color: #c6f6d5;
}
.status-card.info {
background-color: #f7fafc;
border-color: #e2e8f0;
}
.status-card.warning {
background-color: #fffaf0;
border-color: #feebc8;
}
.status-header {
display: flex;
align-items: center;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid rgba(0,0,0,0.06);
}
.status-icon {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
margin-right: 12px;
}
.status-card.success .status-icon {
background-color: #c6f6d5;
}
.status-card.info .status-icon {
background-color: #e2e8f0;
}
.status-card.warning .status-icon {
background-color: #feebc8;
}
.status-title {
font-size: 15px;
font-weight: 600;
margin: 0;
}
.status-card.success .status-title {
color: #22543d;
}
.status-card.info .status-title {
color: #2d3748;
}
.status-card.warning .status-title {
color: #744210;
}
.status-card p {
font-size: 14px;
margin: 0;
line-height: 1.6;
}
.status-card.success p {
color: #276749;
}
.status-card.info p {
color: #4a5568;
}
.status-card.warning p {
color: #975a16;
}
/* Amount */
.amount-value {
font-size: 32px;
font-weight: 700;
color: #22543d;
display: block;
margin: 16px 0;
letter-spacing: -0.5px;
}
/* List */
.info-list {
list-style: none;
padding: 0;
margin: 0;
}
.info-list li {
position: relative;
padding: 8px 0 8px 24px;
font-size: 14px;
color: #4a5568;
line-height: 1.5;
}
.info-list li::before {
content: "";
position: absolute;
left: 0;
top: 14px;
width: 6px;
height: 6px;
background-color: #4361ee;
border-radius: 50%;
}
/* Buttons */
.btn-container {
text-align: center;
margin: 32px 0;
}
.btn {
display: inline-block;
padding: 14px 32px;
font-size: 14px;
font-weight: 600;
text-decoration: none;
border-radius: 6px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.btn-primary {
background-color: #4361ee;
color: #ffffff !important;
}
/* Divider */
.divider {
height: 1px;
background-color: #e9ecef;
margin: 32px 0;
}
/* Footer */
.email-footer {
background-color: #f8f9fa;
padding: 32px 40px;
border-top: 1px solid #e9ecef;
}
.footer-brand {
text-align: center;
margin-bottom: 20px;
}
.footer-brand-name {
font-size: 14px;
font-weight: 700;
color: #1a1a2e;
text-transform: uppercase;
letter-spacing: 1px;
}
.footer-tagline {
font-size: 12px;
color: #6c757d;
margin-top: 4px;
}
.footer-links {
text-align: center;
margin: 16px 0;
}
.footer-links a {
color: #4361ee;
text-decoration: none;
font-size: 12px;
margin: 0 12px;
font-weight: 500;
}
.footer-legal {
text-align: center;
font-size: 11px;
color: #adb5bd;
line-height: 1.6;
margin-top: 20px;
}
.footer-legal a {
color: #6c757d;
text-decoration: underline;
}
/* Responsive */
@media only screen and (max-width: 600px) {
.email-wrapper {
padding: 24px 12px;
}
.email-header {
padding: 24px 20px;
}
.email-body {
padding: 28px 20px;
}
.email-footer {
padding: 24px 20px;
}
.logo {
font-size: 20px;
}
.greeting {
font-size: 18px;
}
.btn {
padding: 12px 28px;
font-size: 13px;
}
.amount-value {
font-size: 28px;
}
.status-card {
padding: 20px;
}
}
</style>
</head>
<body>
<div class="email-wrapper">
<div class="email-container">
<!-- Header -->
<div class="email-header">
<h1 class="logo">WEB<span class="logo-accent">Money</span></h1>
</div>
<!-- Body -->
<div class="email-body">
@yield('content')
</div>
<!-- Footer -->
<div class="email-footer">
<div class="footer-brand">
<div class="footer-brand-name">WEBMoney</div>
<div class="footer-tagline">
@if(($locale ?? 'es') === 'pt-BR')
Gestão Financeira Pessoal
@elseif(($locale ?? 'es') === 'en')
Personal Finance Management
@else
Gestión Financiera Personal
@endif
</div>
</div>
<div class="footer-links">
<a href="{{ config('app.frontend_url', 'https://webmoney.cnxifly.com') }}">
@if(($locale ?? 'es') === 'pt-BR')
Acessar Conta
@elseif(($locale ?? 'es') === 'en')
Access Account
@else
Acceder a Cuenta
@endif
</a>
<a href="{{ config('app.frontend_url', 'https://webmoney.cnxifly.com') }}/support">
@if(($locale ?? 'es') === 'pt-BR')
Central de Ajuda
@elseif(($locale ?? 'es') === 'en')
Help Center
@else
Centro de Ayuda
@endif
</a>
</div>
<div class="footer-legal">
@if(($locale ?? 'es') === 'pt-BR')
Este email foi enviado por WEBMoney, um serviço de ConneXiFly.<br>
© {{ date('Y') }} ConneXiFly. Todos os direitos reservados.
@elseif(($locale ?? 'es') === 'en')
This email was sent by WEBMoney, a ConneXiFly service.<br>
© {{ date('Y') }} ConneXiFly. All rights reserved.
@else
Este correo fue enviado por WEBMoney, un servicio de ConneXiFly.<br>
© {{ date('Y') }} ConneXiFly. Todos los derechos reservados.
@endif
</div>
</div>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,96 @@
@if($locale === 'pt-BR')
CANCELAMENTO DE ASSINATURA - WEBMONEY
=====================================
Olá, {{ $userName }}!
Confirmamos o cancelamento da sua assinatura do plano {{ $planName }}.
@if($wasRefunded)
REEMBOLSO PROCESSADO
-----------------------
Processamos um reembolso total no valor de: {{ $refundAmount }}
O reembolso será creditado na sua forma de pagamento original em até 5-10 dias úteis, dependendo do seu banco.
@endif
O QUE ACONTECE AGORA?
---------------------
Seu acesso premium foi encerrado imediatamente
Você ainda pode acessar sua conta com funcionalidades básicas
Seus dados foram preservados caso queira voltar
Sentimos muito -lo partir! Se tiver qualquer dúvida ou feedback, não hesite em nos contatar.
Se mudar de ideia, você sempre pode assinar novamente:
{{ config('app.frontend_url', 'https://webmoney.cnxifly.com') }}/pricing
---
Este email foi enviado para {{ $userEmail }}
© {{ date('Y') }} WEBMoney - ConneXiFly
Precisa de ajuda? Responda este email.
@elseif($locale === 'en')
SUBSCRIPTION CANCELLATION - WEBMONEY
====================================
Hello, {{ $userName }}!
We confirm the cancellation of your {{ $planName }} subscription.
@if($wasRefunded)
REFUND PROCESSED
-------------------
We have processed a full refund of: {{ $refundAmount }}
The refund will be credited to your original payment method within 5-10 business days, depending on your bank.
@endif
WHAT HAPPENS NOW?
-----------------
Your premium access has ended immediately
You can still access your account with basic features
Your data has been preserved in case you want to return
We're sorry to see you go! If you have any questions or feedback, please don't hesitate to contact us.
If you change your mind, you can always subscribe again:
{{ config('app.frontend_url', 'https://webmoney.cnxifly.com') }}/pricing
---
This email was sent to {{ $userEmail }}
© {{ date('Y') }} WEBMoney - ConneXiFly
Need help? Reply to this email.
@else
CANCELACIÓN DE SUSCRIPCIÓN - WEBMONEY
=====================================
¡Hola, {{ $userName }}!
Confirmamos la cancelación de tu suscripción al plan {{ $planName }}.
@if($wasRefunded)
REEMBOLSO PROCESADO
----------------------
Hemos procesado un reembolso total por el valor de: {{ $refundAmount }}
El reembolso se acreditará en tu método de pago original en un plazo de 5-10 días hábiles, dependiendo de tu banco.
@endif
¿QUÉ SUCEDE AHORA?
------------------
Tu acceso premium ha finalizado inmediatamente
Puedes seguir accediendo a tu cuenta con funciones básicas
Tus datos han sido preservados por si deseas volver
¡Sentimos mucho verte partir! Si tienes alguna pregunta o comentario, no dudes en contactarnos.
Si cambias de opinión, siempre puedes volver a suscribirte:
{{ config('app.frontend_url', 'https://webmoney.cnxifly.com') }}/pricing
---
Este correo fue enviado a {{ $userEmail }}
© {{ date('Y') }} WEBMoney - ConneXiFly
¿Necesitas ayuda? Responde a este correo.
@endif

View File

@ -0,0 +1,164 @@
@extends('emails.layouts.base')
@section('title')
@if($locale === 'pt-BR')
Cancelamento de Assinatura
@elseif($locale === 'en')
Subscription Cancellation
@else
Cancelación de Suscripción
@endif
@endsection
@section('content')
@if($locale === 'pt-BR')
{{-- Portuguese (Brazil) --}}
<p class="greeting">Olá, {{ $userName }}</p>
<div class="content">
<p>Confirmamos o cancelamento da sua assinatura do plano <strong>{{ $planName }}</strong>.</p>
@if($wasRefunded)
<div class="status-card success">
<table cellpadding="0" cellspacing="0" border="0" width="100%">
<tr>
<td width="52" valign="top">
<div style="width: 40px; height: 40px; background-color: #c6f6d5; border-radius: 50%; text-align: center; line-height: 40px; font-size: 18px;"></div>
</td>
<td valign="top">
<p class="status-title">Reembolso Processado</p>
</td>
</tr>
</table>
<p style="margin-top: 16px;">Valor reembolsado conforme garantia de 7 dias:</p>
<span class="amount-value">{{ $refundAmount }}</span>
<p style="font-size: 13px; color: #48bb78; margin-top: 8px;">
Prazo: 5-10 dias úteis para crédito na forma de pagamento original.
</p>
</div>
@endif
<div class="status-card info">
<p class="status-title" style="color: #2d3748; margin-bottom: 16px;">Informações Importantes</p>
<ul class="info-list">
<li>Acesso premium encerrado</li>
<li>Conta disponível com funcionalidades básicas</li>
<li>Seus dados foram preservados</li>
<li>Reativação disponível a qualquer momento</li>
</ul>
</div>
<div class="divider"></div>
<p style="text-align: center; color: #6c757d; font-size: 14px;">
Agradecemos por ter sido nosso cliente.
</p>
<div class="btn-container">
<a href="{{ config('app.frontend_url', 'https://webmoney.cnxifly.com') }}/pricing" class="btn btn-primary">
VER PLANOS
</a>
</div>
</div>
@elseif($locale === 'en')
{{-- English --}}
<p class="greeting">Hello, {{ $userName }}</p>
<div class="content">
<p>We confirm the cancellation of your <strong>{{ $planName }}</strong> subscription.</p>
@if($wasRefunded)
<div class="status-card success">
<table cellpadding="0" cellspacing="0" border="0" width="100%">
<tr>
<td width="52" valign="top">
<div style="width: 40px; height: 40px; background-color: #c6f6d5; border-radius: 50%; text-align: center; line-height: 40px; font-size: 18px;"></div>
</td>
<td valign="top">
<p class="status-title">Refund Processed</p>
</td>
</tr>
</table>
<p style="margin-top: 16px;">Amount refunded per our 7-day guarantee:</p>
<span class="amount-value">{{ $refundAmount }}</span>
<p style="font-size: 13px; color: #48bb78; margin-top: 8px;">
Timeline: 5-10 business days to credit your original payment method.
</p>
</div>
@endif
<div class="status-card info">
<p class="status-title" style="color: #2d3748; margin-bottom: 16px;">Important Information</p>
<ul class="info-list">
<li>Premium access ended</li>
<li>Account available with basic features</li>
<li>Your data has been preserved</li>
<li>Reactivation available anytime</li>
</ul>
</div>
<div class="divider"></div>
<p style="text-align: center; color: #6c757d; font-size: 14px;">
Thank you for being our customer.
</p>
<div class="btn-container">
<a href="{{ config('app.frontend_url', 'https://webmoney.cnxifly.com') }}/pricing" class="btn btn-primary">
VIEW PLANS
</a>
</div>
</div>
@else
{{-- Spanish (default) --}}
<p class="greeting">Hola, {{ $userName }}</p>
<div class="content">
<p>Confirmamos la cancelación de tu suscripción al plan <strong>{{ $planName }}</strong>.</p>
@if($wasRefunded)
<div class="status-card success">
<table cellpadding="0" cellspacing="0" border="0" width="100%">
<tr>
<td width="52" valign="top">
<div style="width: 40px; height: 40px; background-color: #c6f6d5; border-radius: 50%; text-align: center; line-height: 40px; font-size: 18px;"></div>
</td>
<td valign="top">
<p class="status-title">Reembolso Procesado</p>
</td>
</tr>
</table>
<p style="margin-top: 16px;">Monto reembolsado según garantía de 7 días:</p>
<span class="amount-value">{{ $refundAmount }}</span>
<p style="font-size: 13px; color: #48bb78; margin-top: 8px;">
Plazo: 5-10 días hábiles para acreditar en tu método de pago original.
</p>
</div>
@endif
<div class="status-card info">
<p class="status-title" style="color: #2d3748; margin-bottom: 16px;">Información Importante</p>
<ul class="info-list">
<li>Acceso premium finalizado</li>
<li>Cuenta disponible con funciones básicas</li>
<li>Tus datos han sido preservados</li>
<li>Reactivación disponible en cualquier momento</li>
</ul>
</div>
<div class="divider"></div>
<p style="text-align: center; color: #6c757d; font-size: 14px;">
Gracias por haber sido nuestro cliente.
</p>
<div class="btn-container">
<a href="{{ config('app.frontend_url', 'https://webmoney.cnxifly.com') }}/pricing" class="btn btn-primary">
VER PLANES
</a>
</div>
</div>
@endif
@endsection

View File

@ -0,0 +1,66 @@
@if($language === 'pt-BR')
{{-- PORTUGUÊS --}}
WebMoney - Credenciais de Acesso
================================
Olá, {{ $user->name }}!
Sua conta WebMoney foi criada com sucesso.
SUAS CREDENCIAIS DE ACESSO:
---------------------------
Email: {{ $user->email }}
Senha: {{ $temporaryPassword }}
IMPORTANTE: Por motivos de segurança, recomendamos que altere sua senha após o primeiro login.
Acesse sua conta em: {{ $loginUrl }}
---
Equipe WebMoney
https://webmoney.cnxifly.com
@elseif($language === 'en')
{{-- ENGLISH --}}
WebMoney - Access Credentials
=============================
Hello, {{ $user->name }}!
Your WebMoney account has been successfully created.
YOUR ACCESS CREDENTIALS:
------------------------
Email: {{ $user->email }}
Password: {{ $temporaryPassword }}
IMPORTANT: For security reasons, we recommend changing your password after your first login.
Access your account at: {{ $loginUrl }}
---
WebMoney Team
https://webmoney.cnxifly.com
@else
{{-- ESPAÑOL --}}
WebMoney - Credenciales de Acceso
=================================
Hola, {{ $user->name }}!
Tu cuenta de WebMoney ha sido creada exitosamente.
TUS CREDENCIALES DE ACCESO:
---------------------------
Email: {{ $user->email }}
Contraseña: {{ $temporaryPassword }}
IMPORTANTE: Por motivos de seguridad, te recomendamos cambiar tu contraseña después del primer inicio de sesión.
Accede a tu cuenta en: {{ $loginUrl }}
---
Equipo WebMoney
https://webmoney.cnxifly.com
@endif

View File

@ -0,0 +1,181 @@
@extends('emails.layouts.base')
@php $locale = $language ?? 'es'; @endphp
@section('title')
@if($locale === 'pt-BR')
Bem-vindo ao WEBMoney
@elseif($locale === 'en')
Welcome to WEBMoney
@else
Bienvenido a WEBMoney
@endif
@endsection
@section('content')
@if($locale === 'pt-BR')
{{-- Portuguese (Brazil) --}}
<p class="greeting">Olá, {{ $user->name }}</p>
<div class="content">
<p>Bem-vindo ao WEBMoney! A sua conta foi criada com sucesso.</p>
<div class="status-card info" style="background-color: #1a1a2e; border-color: #4361ee;">
<p class="status-title" style="color: #ffffff; margin-bottom: 16px;">Credenciais de Acesso</p>
<table cellpadding="0" cellspacing="0" border="0" width="100%" style="margin-top: 12px;">
<tr>
<td style="padding: 12px; background: rgba(255,255,255,0.1); border-radius: 4px; margin-bottom: 8px;">
<span style="color: #94a3b8; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px;">Email</span><br>
<span style="color: #ffffff; font-size: 16px; font-weight: 600; font-family: monospace;">{{ $user->email }}</span>
</td>
</tr>
<tr><td style="height: 8px;"></td></tr>
<tr>
<td style="padding: 12px; background: rgba(255,255,255,0.1); border-radius: 4px;">
<span style="color: #94a3b8; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px;">Palavra-passe</span><br>
<span style="color: #ffffff; font-size: 16px; font-weight: 600; font-family: monospace;">{{ $password }}</span>
</td>
</tr>
</table>
</div>
<div class="status-card warning">
<table cellpadding="0" cellspacing="0" border="0" width="100%">
<tr>
<td width="52" valign="top">
<div style="width: 40px; height: 40px; background-color: #feebc8; border-radius: 50%; text-align: center; line-height: 40px; font-size: 18px;">!</div>
</td>
<td valign="top">
<p class="status-title">Segurança</p>
</td>
</tr>
</table>
<p style="margin-top: 12px;">Recomendamos que altere a sua palavra-passe após o primeiro login. Guarde estas credenciais em local seguro.</p>
</div>
<div class="status-card info">
<p class="status-title" style="color: #2d3748; margin-bottom: 16px;">O que pode fazer</p>
<ul class="info-list">
<li>Registar receitas e despesas</li>
<li>Visualizar relatórios e gráficos</li>
<li>Gerir o seu orçamento mensal</li>
<li>Exportar dados em múltiplos formatos</li>
</ul>
</div>
<div class="btn-container">
<a href="https://webmoney.cnxifly.com/login" class="btn btn-primary">INICIAR SESSÃO</a>
</div>
</div>
@elseif($locale === 'en')
{{-- English --}}
<p class="greeting">Hello, {{ $user->name }}</p>
<div class="content">
<p>Welcome to WEBMoney! Your account has been successfully created.</p>
<div class="status-card info" style="background-color: #1a1a2e; border-color: #4361ee;">
<p class="status-title" style="color: #ffffff; margin-bottom: 16px;">Access Credentials</p>
<table cellpadding="0" cellspacing="0" border="0" width="100%" style="margin-top: 12px;">
<tr>
<td style="padding: 12px; background: rgba(255,255,255,0.1); border-radius: 4px; margin-bottom: 8px;">
<span style="color: #94a3b8; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px;">Email</span><br>
<span style="color: #ffffff; font-size: 16px; font-weight: 600; font-family: monospace;">{{ $user->email }}</span>
</td>
</tr>
<tr><td style="height: 8px;"></td></tr>
<tr>
<td style="padding: 12px; background: rgba(255,255,255,0.1); border-radius: 4px;">
<span style="color: #94a3b8; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px;">Password</span><br>
<span style="color: #ffffff; font-size: 16px; font-weight: 600; font-family: monospace;">{{ $password }}</span>
</td>
</tr>
</table>
</div>
<div class="status-card warning">
<table cellpadding="0" cellspacing="0" border="0" width="100%">
<tr>
<td width="52" valign="top">
<div style="width: 40px; height: 40px; background-color: #feebc8; border-radius: 50%; text-align: center; line-height: 40px; font-size: 18px;">!</div>
</td>
<td valign="top">
<p class="status-title">Security</p>
</td>
</tr>
</table>
<p style="margin-top: 12px;">We recommend changing your password after your first login. Keep these credentials in a safe place.</p>
</div>
<div class="status-card info">
<p class="status-title" style="color: #2d3748; margin-bottom: 16px;">What you can do</p>
<ul class="info-list">
<li>Record income and expenses</li>
<li>View reports and charts</li>
<li>Manage your monthly budget</li>
<li>Export data in multiple formats</li>
</ul>
</div>
<div class="btn-container">
<a href="https://webmoney.cnxifly.com/login" class="btn btn-primary">SIGN IN</a>
</div>
</div>
@else
{{-- Spanish (default) --}}
<p class="greeting">Hola, {{ $user->name }}</p>
<div class="content">
<p>Bienvenido a WEBMoney. Tu cuenta ha sido creada exitosamente.</p>
<div class="status-card info" style="background-color: #1a1a2e; border-color: #4361ee;">
<p class="status-title" style="color: #ffffff; margin-bottom: 16px;">Credenciales de Acceso</p>
<table cellpadding="0" cellspacing="0" border="0" width="100%" style="margin-top: 12px;">
<tr>
<td style="padding: 12px; background: rgba(255,255,255,0.1); border-radius: 4px; margin-bottom: 8px;">
<span style="color: #94a3b8; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px;">Email</span><br>
<span style="color: #ffffff; font-size: 16px; font-weight: 600; font-family: monospace;">{{ $user->email }}</span>
</td>
</tr>
<tr><td style="height: 8px;"></td></tr>
<tr>
<td style="padding: 12px; background: rgba(255,255,255,0.1); border-radius: 4px;">
<span style="color: #94a3b8; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px;">Contraseña</span><br>
<span style="color: #ffffff; font-size: 16px; font-weight: 600; font-family: monospace;">{{ $password }}</span>
</td>
</tr>
</table>
</div>
<div class="status-card warning">
<table cellpadding="0" cellspacing="0" border="0" width="100%">
<tr>
<td width="52" valign="top">
<div style="width: 40px; height: 40px; background-color: #feebc8; border-radius: 50%; text-align: center; line-height: 40px; font-size: 18px;">!</div>
</td>
<td valign="top">
<p class="status-title">Seguridad</p>
</td>
</tr>
</table>
<p style="margin-top: 12px;">Recomendamos cambiar tu contraseña después del primer inicio de sesión. Guarda estas credenciales en un lugar seguro.</p>
</div>
<div class="status-card info">
<p class="status-title" style="color: #2d3748; margin-bottom: 16px;">Qué puedes hacer</p>
<ul class="info-list">
<li>Registrar ingresos y gastos</li>
<li>Visualizar reportes y gráficos</li>
<li>Gestionar tu presupuesto mensual</li>
<li>Exportar datos en múltiples formatos</li>
</ul>
</div>
<div class="btn-container">
<a href="https://webmoney.cnxifly.com/login" class="btn btn-primary">INICIAR SESIÓN</a>
</div>
</div>
@endif
@endsection

View File

@ -1,113 +1,142 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>Bienvenido a WEBMoney</title>
<!--[if mso]>
<style type="text/css">
body, table, td {font-family: Arial, Helvetica, sans-serif !important;}
</style>
<![endif]-->
</head>
<body style="margin: 0; padding: 0; background-color: #f4f4f4; font-family: Arial, Helvetica, sans-serif;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #f4f4f4;">
<tr>
<td style="padding: 20px 0;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="600" style="margin: 0 auto; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
<!-- Header -->
@extends('emails.layouts.base')
@php $locale = $locale ?? 'es'; @endphp
@section('title')
@if($locale === 'pt-BR')
Bem-vindo ao WEBMoney
@elseif($locale === 'en')
Welcome to WEBMoney
@else
Bienvenido a WEBMoney
@endif
@endsection
@section('content')
@if($locale === 'pt-BR')
{{-- Portuguese (Brazil) --}}
<p class="greeting">Olá, {{ $userName }}</p>
<div class="content">
<p>A sua conta no WEBMoney foi criada com sucesso. pode começar a gerir as suas finanças pessoais de forma fácil e segura.</p>
<div class="status-card success">
<table cellpadding="0" cellspacing="0" border="0" width="100%">
<tr>
<td style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 40px 30px; text-align: center; border-radius: 8px 8px 0 0;">
<h1 style="margin: 0; color: #ffffff; font-size: 28px; font-weight: 700;">
🎉 ¡Bienvenido a WEBMoney!
</h1>
<td width="52" valign="top">
<div style="width: 40px; height: 40px; background-color: #c6f6d5; border-radius: 50%; text-align: center; line-height: 40px; font-size: 18px;"></div>
</td>
</tr>
<!-- Content -->
<tr>
<td style="padding: 40px 30px;">
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.6;">
Hola <strong>{{ $userName }}</strong>,
</p>
<p style="margin: 0 0 20px; color: #555555; font-size: 16px; line-height: 1.6;">
¡Gracias por registrarte en <strong>WEBMoney</strong>! Tu cuenta ha sido creada exitosamente y ya puedes comenzar a gestionar tus finanzas personales de manera fácil y segura.
</p>
<div style="background-color: #f8f9fa; border-left: 4px solid #667eea; padding: 20px; margin: 30px 0; border-radius: 4px;">
<p style="margin: 0 0 10px; color: #333333; font-size: 14px; font-weight: 600;">
📧 Cuenta registrada:
</p>
<p style="margin: 0; color: #667eea; font-size: 16px; font-weight: 700;">
{{ $userEmail }}
</p>
</div>
<h3 style="margin: 30px 0 15px; color: #333333; font-size: 18px; font-weight: 600;">
¿Qué puedes hacer ahora?
</h3>
<ul style="margin: 0 0 30px; padding-left: 20px; color: #555555; font-size: 15px; line-height: 1.8;">
<li>📊 Registrar tus ingresos y gastos</li>
<li>📈 Visualizar reportes y gráficos</li>
<li>💰 Gestionar tu presupuesto mensual</li>
<li>📄 Exportar datos en múltiples formatos</li>
</ul>
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin: 30px 0;">
<tr>
<td style="text-align: center;">
<a href="https://webmoney.cnxifly.com/login" style="display: inline-block; padding: 15px 40px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #ffffff; text-decoration: none; border-radius: 6px; font-size: 16px; font-weight: 600;">
Iniciar Sesión Ahora
</a>
</td>
</tr>
</table>
<p style="margin: 30px 0 10px; color: #666666; font-size: 14px; line-height: 1.6;">
Si tienes alguna pregunta o necesitas ayuda, no dudes en contactarnos respondiendo a este email.
</p>
<p style="margin: 0; color: #666666; font-size: 14px; line-height: 1.6;">
¡Gracias por confiar en nosotros!
</p>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="background-color: #f8f9fa; padding: 30px; text-align: center; border-radius: 0 0 8px 8px;">
<p style="margin: 0 0 10px; color: #666666; font-size: 14px;">
<strong>WEBMoney</strong> - Tu gestor financiero personal
</p>
<p style="margin: 0 0 15px; color: #999999; font-size: 12px;">
ConneXiFly · webmoney.cnxifly.com
</p>
<p style="margin: 0; color: #999999; font-size: 11px;">
Este es un correo automático. Por favor, no respondas directamente a este mensaje.
<br>
Para soporte, escríbenos a: <a href="mailto:support@cnxifly.com" style="color: #667eea; text-decoration: none;">support@cnxifly.com</a>
</p>
<td valign="top">
<p class="status-title">Conta Registada</p>
</td>
</tr>
</table>
<p style="margin-top: 16px;"><strong>Email:</strong> {{ $userEmail }}</p>
</div>
<div class="status-card info">
<p class="status-title" style="color: #2d3748; margin-bottom: 16px;">Funcionalidades Disponíveis</p>
<ul class="info-list">
<li>Registar receitas e despesas</li>
<li>Visualizar relatórios e gráficos</li>
<li>Gerir o seu orçamento mensal</li>
<li>Exportar dados em múltiplos formatos</li>
</ul>
</div>
<div class="btn-container">
<a href="https://webmoney.cnxifly.com/login" class="btn btn-primary">INICIAR SESSÃO</a>
</div>
<div class="divider"></div>
<p style="text-align: center; color: #6c757d; font-size: 14px;">
Obrigado por confiar em nós.
</p>
</div>
<!-- Email Footer (outside box) -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="600" style="margin: 20px auto 0;">
@elseif($locale === 'en')
{{-- English --}}
<p class="greeting">Hello, {{ $userName }}</p>
<div class="content">
<p>Your WEBMoney account has been successfully created. You can now start managing your personal finances easily and securely.</p>
<div class="status-card success">
<table cellpadding="0" cellspacing="0" border="0" width="100%">
<tr>
<td style="text-align: center; padding: 20px;">
<p style="margin: 0; color: #999999; font-size: 11px; line-height: 1.6;">
© 2025 ConneXiFly. Todos los derechos reservados.
<br>
Si no solicitaste esta cuenta, ignora este correo.
</p>
<td width="52" valign="top">
<div style="width: 40px; height: 40px; background-color: #c6f6d5; border-radius: 50%; text-align: center; line-height: 40px; font-size: 18px;"></div>
</td>
<td valign="top">
<p class="status-title">Account Registered</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
<p style="margin-top: 16px;"><strong>Email:</strong> {{ $userEmail }}</p>
</div>
<div class="status-card info">
<p class="status-title" style="color: #2d3748; margin-bottom: 16px;">Available Features</p>
<ul class="info-list">
<li>Record income and expenses</li>
<li>View reports and charts</li>
<li>Manage your monthly budget</li>
<li>Export data in multiple formats</li>
</ul>
</div>
<div class="btn-container">
<a href="https://webmoney.cnxifly.com/login" class="btn btn-primary">SIGN IN</a>
</div>
<div class="divider"></div>
<p style="text-align: center; color: #6c757d; font-size: 14px;">
Thank you for trusting us.
</p>
</div>
@else
{{-- Spanish (default) --}}
<p class="greeting">Hola, {{ $userName }}</p>
<div class="content">
<p>Tu cuenta en WEBMoney ha sido creada exitosamente. Ya puedes comenzar a gestionar tus finanzas personales de manera fácil y segura.</p>
<div class="status-card success">
<table cellpadding="0" cellspacing="0" border="0" width="100%">
<tr>
<td width="52" valign="top">
<div style="width: 40px; height: 40px; background-color: #c6f6d5; border-radius: 50%; text-align: center; line-height: 40px; font-size: 18px;"></div>
</td>
<td valign="top">
<p class="status-title">Cuenta Registrada</p>
</td>
</tr>
</table>
<p style="margin-top: 16px;"><strong>Email:</strong> {{ $userEmail }}</p>
</div>
<div class="status-card info">
<p class="status-title" style="color: #2d3748; margin-bottom: 16px;">Funcionalidades Disponibles</p>
<ul class="info-list">
<li>Registrar ingresos y gastos</li>
<li>Visualizar reportes y gráficos</li>
<li>Gestionar tu presupuesto mensual</li>
<li>Exportar datos en múltiples formatos</li>
</ul>
</div>
<div class="btn-container">
<a href="https://webmoney.cnxifly.com/login" class="btn btn-primary">INICIAR SESIÓN</a>
</div>
<div class="divider"></div>
<p style="text-align: center; color: #6c757d; font-size: 14px;">
Gracias por confiar en nosotros.
</p>
</div>
@endif
@endsection

View File

@ -8,6 +8,7 @@
use App\Http\Controllers\Api\CostCenterController;
use App\Http\Controllers\Api\CategoryController;
use App\Http\Controllers\Api\LiabilityAccountController;
use App\Http\Controllers\Api\AssetAccountController;
use App\Http\Controllers\Api\TransactionController;
use App\Http\Controllers\Api\ImportController;
use App\Http\Controllers\Api\TransferDetectionController;
@ -24,11 +25,18 @@
use App\Http\Controllers\Api\UserPreferenceController;
use App\Http\Controllers\Api\PlanController;
use App\Http\Controllers\Api\SubscriptionController;
use App\Http\Controllers\Api\UserManagementController;
use App\Http\Controllers\Api\SiteSettingsController;
// Public routes with rate limiting
Route::post('/register', [AuthController::class, 'register'])->middleware('throttle:register');
Route::post('/login', [AuthController::class, 'login'])->middleware('throttle:login');
// Account activation (public)
Route::post('/activate', [AuthController::class, 'activateAccount']);
Route::post('/resend-activation', [AuthController::class, 'resendActivation']);
Route::post('/cancel-registration', [AuthController::class, 'cancelRegistration']);
// Plans (public - for pricing page)
Route::get('/plans', [PlanController::class, 'index']);
Route::get('/plans/{slug}', [PlanController::class, 'show']);
@ -39,6 +47,12 @@
// PayPal webhook (public - called by PayPal)
Route::post('/paypal/webhook', [SubscriptionController::class, 'webhook']);
// Subscription start for new users (public - used after registration)
Route::post('/subscription/start', [SubscriptionController::class, 'startSubscription']);
// Subscription confirm for new users (public - called after PayPal redirect)
Route::post('/subscription/confirm-public', [SubscriptionController::class, 'confirmPublic']);
// Email testing routes (should be protected in production)
Route::post('/email/send-test', [EmailTestController::class, 'sendTest']);
Route::get('/email/anti-spam-info', [EmailTestController::class, 'getAntiSpamInfo']);
@ -63,15 +77,16 @@
Route::get('/subscription/invoices', [SubscriptionController::class, 'invoices']);
// ============================================
// Contas (Accounts)
// Contas (Accounts) - Com limite de plano
// ============================================
// Rotas específicas ANTES do apiResource
Route::post('accounts/recalculate-all', [AccountController::class, 'recalculateBalances']);
Route::post('accounts/{id}/recalculate', [AccountController::class, 'recalculateBalance']);
Route::post('accounts/{id}/adjust-balance', [AccountController::class, 'adjustBalance']);
// Resource principal
Route::apiResource('accounts', AccountController::class);
// Resource principal com middleware de limite no store
Route::post('accounts', [AccountController::class, 'store'])->middleware('plan.limits:accounts');
Route::apiResource('accounts', AccountController::class)->except(['store']);
Route::get('accounts-total', [AccountController::class, 'totalBalance']);
// ============================================
@ -83,7 +98,7 @@
Route::post('cost-centers/match', [CostCenterController::class, 'matchByText']);
// ============================================
// Categorias (Categories)
// Categorias (Categories) - Com limite de plano
// ============================================
// Rotas específicas ANTES do apiResource
Route::post('categories/categorize-batch/preview', [CategoryController::class, 'categorizeBatchPreview']);
@ -92,8 +107,9 @@
Route::post('categories/match', [CategoryController::class, 'matchByText']);
Route::post('categories/reorder', [CategoryController::class, 'reorder']);
// Resource principal
Route::apiResource('categories', CategoryController::class);
// Resource principal com middleware de limite no store
Route::post('categories', [CategoryController::class, 'store'])->middleware('plan.limits:categories');
Route::apiResource('categories', CategoryController::class)->except(['store']);
// Rotas com parâmetros (depois do apiResource)
Route::post('categories/{id}/keywords', [CategoryController::class, 'addKeyword']);
@ -104,7 +120,10 @@
// ============================================
// Rotas específicas ANTES do apiResource (para evitar conflito com {id})
Route::get('liability-accounts/pending-reconciliation', [LiabilityAccountController::class, 'pendingReconciliation']);
Route::get('liability-accounts/template', [LiabilityAccountController::class, 'downloadTemplate']);
Route::get('liability-accounts/contract-types', [LiabilityAccountController::class, 'contractTypes']);
Route::post('liability-accounts/import', [LiabilityAccountController::class, 'import']);
Route::post('liability-accounts/wizard', [LiabilityAccountController::class, 'storeWithWizard']);
Route::get('liability-summary', [LiabilityAccountController::class, 'summary']);
// Resource principal
@ -120,9 +139,25 @@
Route::delete('liability-accounts/{accountId}/installments/{installmentId}/reconcile', [LiabilityAccountController::class, 'unreconcile']);
// ============================================
// Transações (Transactions)
// Contas Ativo (Asset Accounts)
// ============================================
Route::apiResource('transactions', TransactionController::class);
// Rotas específicas ANTES do apiResource
Route::get('asset-accounts/asset-types', [AssetAccountController::class, 'assetTypes']);
Route::post('asset-accounts/wizard', [AssetAccountController::class, 'storeWithWizard']);
Route::get('asset-summary', [AssetAccountController::class, 'summary']);
// Resource principal
Route::apiResource('asset-accounts', AssetAccountController::class);
// Rotas com parâmetros
Route::put('asset-accounts/{assetAccount}/value', [AssetAccountController::class, 'updateValue']);
Route::post('asset-accounts/{assetAccount}/dispose', [AssetAccountController::class, 'dispose']);
// ============================================
// Transações (Transactions) - Com limite de plano
// ============================================
Route::post('transactions', [TransactionController::class, 'store'])->middleware('plan.limits:transactions');
Route::apiResource('transactions', TransactionController::class)->except(['store']);
Route::get('transactions-by-week', [TransactionController::class, 'byWeek']);
Route::get('transactions-summary', [TransactionController::class, 'summary']);
@ -222,43 +257,54 @@
Route::put('recurring-instances/{recurringInstance}', [RecurringTemplateController::class, 'updateInstance']);
// ============================================
// Business - Precificação de Produtos
// Business - Precificação de Produtos (Admin Only)
// ============================================
Route::middleware('admin.only')->group(function () {
// User Management (Admin Only)
Route::get('admin/users/summary', [UserManagementController::class, 'summary']);
Route::get('admin/users', [UserManagementController::class, 'index']);
Route::post('admin/users', [UserManagementController::class, 'store']);
Route::get('admin/users/{id}', [UserManagementController::class, 'show']);
Route::put('admin/users/{id}', [UserManagementController::class, 'update']);
Route::post('admin/users/{id}/reset-password', [UserManagementController::class, 'resetPassword']);
Route::post('admin/users/{id}/change-plan', [UserManagementController::class, 'changePlan']);
Route::delete('admin/users/{id}', [UserManagementController::class, 'destroy']);
// Configurações de Negócio (Markup)
Route::get('business-settings/default', [BusinessSettingController::class, 'getDefault']);
Route::apiResource('business-settings', BusinessSettingController::class);
Route::post('business-settings/{id}/recalculate-markup', [BusinessSettingController::class, 'recalculateMarkup']);
Route::post('business-settings/{id}/simulate-price', [BusinessSettingController::class, 'simulatePrice']);
// Fichas Técnicas de Produtos (CMV)
Route::get('product-sheets/categories', [ProductSheetController::class, 'categories']);
Route::get('product-sheets/item-types', [ProductSheetController::class, 'itemTypes']);
Route::apiResource('product-sheets', ProductSheetController::class);
Route::post('product-sheets/{id}/items', [ProductSheetController::class, 'addItem']);
Route::put('product-sheets/{sheetId}/items/{itemId}', [ProductSheetController::class, 'updateItem']);
Route::delete('product-sheets/{sheetId}/items/{itemId}', [ProductSheetController::class, 'removeItem']);
Route::post('product-sheets/{id}/recalculate-price', [ProductSheetController::class, 'recalculatePrice']);
Route::post('product-sheets/{id}/duplicate', [ProductSheetController::class, 'duplicate']);
// Configurações de Negócio (Markup)
Route::get('business-settings/default', [BusinessSettingController::class, 'getDefault']);
Route::apiResource('business-settings', BusinessSettingController::class);
Route::post('business-settings/{id}/recalculate-markup', [BusinessSettingController::class, 'recalculateMarkup']);
Route::post('business-settings/{id}/simulate-price', [BusinessSettingController::class, 'simulatePrice']);
// Fichas Técnicas de Serviços (CSV - Custo do Serviço Vendido)
Route::get('service-sheets/categories', [ServiceSheetController::class, 'categories']);
Route::get('service-sheets/item-types', [ServiceSheetController::class, 'itemTypes']);
Route::post('service-sheets/simulate', [ServiceSheetController::class, 'simulate']);
Route::apiResource('service-sheets', ServiceSheetController::class);
Route::post('service-sheets/{id}/items', [ServiceSheetController::class, 'addItem']);
Route::put('service-sheets/{sheetId}/items/{itemId}', [ServiceSheetController::class, 'updateItem']);
Route::delete('service-sheets/{sheetId}/items/{itemId}', [ServiceSheetController::class, 'removeItem']);
Route::post('service-sheets/{id}/duplicate', [ServiceSheetController::class, 'duplicate']);
// Fichas Técnicas de Produtos (CMV)
Route::get('product-sheets/categories', [ProductSheetController::class, 'categories']);
Route::get('product-sheets/item-types', [ProductSheetController::class, 'itemTypes']);
Route::apiResource('product-sheets', ProductSheetController::class);
Route::post('product-sheets/{id}/items', [ProductSheetController::class, 'addItem']);
Route::put('product-sheets/{sheetId}/items/{itemId}', [ProductSheetController::class, 'updateItem']);
Route::delete('product-sheets/{sheetId}/items/{itemId}', [ProductSheetController::class, 'removeItem']);
Route::post('product-sheets/{id}/recalculate-price', [ProductSheetController::class, 'recalculatePrice']);
Route::post('product-sheets/{id}/duplicate', [ProductSheetController::class, 'duplicate']);
// Fichas Técnicas de Serviços (CSV - Custo do Serviço Vendido)
Route::get('service-sheets/categories', [ServiceSheetController::class, 'categories']);
Route::get('service-sheets/item-types', [ServiceSheetController::class, 'itemTypes']);
Route::post('service-sheets/simulate', [ServiceSheetController::class, 'simulate']);
Route::apiResource('service-sheets', ServiceSheetController::class);
Route::post('service-sheets/{id}/items', [ServiceSheetController::class, 'addItem']);
Route::put('service-sheets/{sheetId}/items/{itemId}', [ServiceSheetController::class, 'updateItem']);
Route::delete('service-sheets/{sheetId}/items/{itemId}', [ServiceSheetController::class, 'removeItem']);
Route::post('service-sheets/{id}/duplicate', [ServiceSheetController::class, 'duplicate']);
// Campanhas Promocionais (Black Friday, etc.)
Route::get('campaigns/presets', [PromotionalCampaignController::class, 'presets']);
Route::post('campaigns/preview', [PromotionalCampaignController::class, 'preview']);
Route::apiResource('campaigns', PromotionalCampaignController::class);
Route::post('campaigns/{id}/duplicate', [PromotionalCampaignController::class, 'duplicate']);
Route::post('campaigns/{id}/products', [PromotionalCampaignController::class, 'addProducts']);
Route::delete('campaigns/{id}/products', [PromotionalCampaignController::class, 'removeProducts']);
Route::put('campaigns/{campaignId}/products/{productId}', [PromotionalCampaignController::class, 'updateProductDiscount']);
// Campanhas Promocionais (Black Friday, etc.)
Route::get('campaigns/presets', [PromotionalCampaignController::class, 'presets']);
Route::post('campaigns/preview', [PromotionalCampaignController::class, 'preview']);
Route::apiResource('campaigns', PromotionalCampaignController::class);
Route::post('campaigns/{id}/duplicate', [PromotionalCampaignController::class, 'duplicate']);
Route::post('campaigns/{id}/products', [PromotionalCampaignController::class, 'addProducts']);
Route::delete('campaigns/{id}/products', [PromotionalCampaignController::class, 'removeProducts']);
Route::put('campaigns/{campaignId}/products/{productId}', [PromotionalCampaignController::class, 'updateProductDiscount']);
}); // End admin.only group
// ============================================
// Metas Financieras (Financial Goals)
@ -268,12 +314,13 @@
Route::delete('financial-goals/{goalId}/contributions/{contributionId}', [FinancialGoalController::class, 'removeContribution']);
// ============================================
// Presupuestos (Budgets)
// Presupuestos (Budgets) - Com limite de plano
// ============================================
Route::get('budgets/available-categories', [BudgetController::class, 'availableCategories']);
Route::get('budgets/year-summary', [BudgetController::class, 'yearSummary']);
Route::post('budgets/copy-to-next-month', [BudgetController::class, 'copyToNextMonth']);
Route::apiResource('budgets', BudgetController::class);
Route::post('budgets', [BudgetController::class, 'store'])->middleware('plan.limits:budgets');
Route::apiResource('budgets', BudgetController::class)->except(['store']);
// ============================================
// Reportes (Reports)
@ -308,5 +355,17 @@
Route::get('preferences', [UserPreferenceController::class, 'index']);
Route::put('preferences', [UserPreferenceController::class, 'update']);
Route::post('preferences/test-notification', [UserPreferenceController::class, 'testNotification']);
// ============================================
// Site Settings (Admin only - Configurações do Site)
// ============================================
Route::middleware('admin.only')->prefix('admin/site-settings')->group(function () {
Route::get('/', [SiteSettingsController::class, 'index']);
Route::get('/cnxifly/status', [SiteSettingsController::class, 'getCnxiflyStatus']);
Route::post('/cnxifly/toggle', [SiteSettingsController::class, 'toggleCnxiflyPage']);
Route::post('/cnxifly/deploy', [SiteSettingsController::class, 'deployCnxiflyPage']);
Route::get('/{key}', [SiteSettingsController::class, 'show']);
Route::put('/{key}', [SiteSettingsController::class, 'update']);
});
});

39
deploy-landing.sh Executable file
View File

@ -0,0 +1,39 @@
#!/bin/bash
# Deploy landing page to cnxifly.com
# Usage: ./deploy-landing.sh [live|maintenance]
MODE=${1:-live}
SERVER="root@213.165.93.60"
PASSWORD="Master9354"
DEST="/var/www/cnxifly"
echo "🚀 Deploying cnxifly.com landing page in mode: $MODE"
if [ "$MODE" = "live" ]; then
SOURCE="landing/index.html"
elif [ "$MODE" = "maintenance" ]; then
SOURCE="landing/maintenance.html"
else
echo "❌ Invalid mode. Use: live or maintenance"
exit 1
fi
# Check if source file exists
if [ ! -f "$SOURCE" ]; then
echo "❌ Source file not found: $SOURCE"
exit 1
fi
echo "📁 Copying $SOURCE to server..."
# Deploy the file
sshpass -p "$PASSWORD" scp -o StrictHostKeyChecking=no "$SOURCE" "$SERVER:$DEST/index.html"
if [ $? -eq 0 ]; then
echo "✅ Landing page deployed successfully!"
echo "🌐 Visit: https://cnxifly.com"
else
echo "❌ Deploy failed!"
exit 1
fi

129
docs/SAAS_STATUS.md Normal file
View File

@ -0,0 +1,129 @@
# WebMoney SaaS - Status de Implementação
> Última atualização: 17 de Dezembro de 2025
## ✅ Concluído
### Fase 1: Estrutura de Planos
- [x] Tabela `plans` com 3 planos (Free, Pro Mensual, Pro Anual)
- [x] Tabela `subscriptions` para gerenciar assinaturas
- [x] Model Plan com relacionamentos
- [x] Model Subscription com métodos auxiliares
### Fase 2: Integração PayPal
- [x] PayPalService completo (criar/cancelar/reativar assinaturas)
- [x] Credenciais PayPal Sandbox configuradas no servidor
- [x] Planos criados no PayPal com IDs salvos no banco
- [x] FRONTEND_URL configurado para redirects
### Fase 3: Fluxo de Assinatura
- [x] Endpoint `GET /api/plans` - listar planos
- [x] Endpoint `POST /api/subscription/create` - criar assinatura
- [x] Endpoint `GET /api/subscription/status` - status atual
- [x] Endpoint `POST /api/subscription/cancel` - cancelar
- [x] Endpoint `POST /api/subscription/reactivate` - reativar
- [x] Webhooks PayPal funcionando (ID: 4UM53122EW59785)
- [x] Frontend: página de planos e checkout
- [x] Assinatura de teste criada: I-RHE4CFSL3T3N (Pro Mensual)
### Fase 4: Enforcement de Limites
- [x] Limites do plano Free definidos:
- 1 conta bancária
- 10 categorias
- 3 orçamentos
- 100 transações
- [x] Middleware `CheckPlanLimits` criado
- [x] Middleware aplicado nas rotas de criação
- [x] Endpoint `/subscription/status` retorna uso atual
- [x] Widget `PlanUsageWidget` no Dashboard
- [x] Translations (es.json, pt-BR.json)
## ✅ Testes Realizados (17/12/2025)
### Testar com Usuário Free
- [x] Criar usuário sem assinatura ✅ **FUNCIONA**
- Usuário de teste: `testfree2@webmoney.test` (ID: 4)
- Plano Free atribuído automaticamente
- [x] Validar que middleware bloqueia ao atingir limite ✅ **FUNCIONA**
- Contas: 1/1 → Bloqueia 2ª conta ✅
- Categorias: 10/10 → Bloqueia 11ª categoria ✅
- Budgets: 3/3 → Bloqueia 4º orçamento ✅
- [x] Validar widget mostra progresso corretamente ✅ **FUNCIONA**
- API retorna `usage` e `usage_percentages` corretos
- [x] Testar mensagens de erro amigáveis ✅ **FUNCIONA**
- Mensagem: "Has alcanzado el límite de X de tu plan. Actualiza a Pro para X ilimitados."
- Retorna `error: plan_limit_exceeded` com dados do limite
### Testar Usuário Pro
- [x] Admin Pro tem limites `null` (ilimitados) ✅
- [x] 173 categorias, 1204 transações sem bloqueio ✅
### Testar Criação de Usuários Admin
- [x] Endpoint POST `/api/admin/users` ✅ **FUNCIONA**
- [x] Tipos de usuário: Free, Pro, Admin ✅
- [x] Pro/Admin recebem assinatura automática de 100 anos ✅
### ⏳ Pendente - Testes de Cancelamento
- [ ] Cancelar assinatura via PayPal
- [ ] Verificar grace period funciona
- [ ] Verificar downgrade para Free após expirar
### ⏳ Pendente - Testes de Reativação
- [ ] Reativar assinatura cancelada via PayPal
- [ ] Verificar status volta para ativo
## 🔧 Melhorias Futuras (Produção)
| Prioridade | Item | Descrição |
|------------|------|-----------|
| **Alta** | PayPal Live | Trocar credenciais sandbox para produção |
| **Alta** | Histórico de pagamentos | Tabela `payments` para registrar transações |
| **Média** | Emails transacionais | Notificar renovação, falha de pagamento, etc. |
| **Média** | Página de faturamento | Frontend com histórico e faturas |
| **Baixa** | Upgrade mid-cycle | Trocar de plano durante o ciclo |
| **Baixa** | Cupons de desconto | Sistema de cupons promocionais |
## 📋 Credenciais e Configurações
### PayPal Sandbox
```
Client ID: AU-E_dptCQSa_xGUmU--0pTPuZ25AWKOFP6uvamzPQZHrg1nfRaVhEebtpJ1jU_8OKyMocglbesAwIpR
Webhook ID: 4UM53122EW59785
Mode: sandbox
```
### Planos no Banco de Dados
| ID | Slug | Preço | PayPal Plan ID |
|----|------|-------|----------------|
| 1 | free | 0.00 | - |
| 2 | pro-monthly | 9.99 | P-3FJ50989UN7098919M7A752Y |
| 3 | pro-yearly | 99.99 | P-9FN08839NE7915003M7A76JY |
### Assinatura de Teste
```
User: marco@cnxifly.com (ID: 1)
Plan: Pro Mensual (ID: 2)
PayPal ID: I-RHE4CFSL3T3N
Status: active
```
## 🚀 Como Continuar
1. **Para testar limites Free:**
```bash
# Criar usuário de teste
curl -X POST https://webmoney.cnxifly.com/api/register \
-H "Content-Type: application/json" \
-d '{"name":"Test Free","email":"testfree@test.com","password":"Test1234!"}'
```
2. **Para ir para produção:**
- Criar conta PayPal Business
- Obter credenciais Live
- Atualizar .env no servidor
- Criar planos no PayPal Live
- Atualizar IDs no banco
---
*Documento criado para referência futura. Voltar aqui quando retomar o desenvolvimento SaaS.*

View File

@ -1,5 +1,5 @@
// WebMoney Service Worker - PWA Support
const CACHE_VERSION = 'webmoney-v1.39.0';
const CACHE_VERSION = 'webmoney-v1.40.0';
const CACHE_STATIC = `${CACHE_VERSION}-static`;
const CACHE_DYNAMIC = `${CACHE_VERSION}-dynamic`;
const CACHE_IMMUTABLE = `${CACHE_VERSION}-immutable`;

View File

@ -6,6 +6,7 @@ import ProtectedRoute from './components/ProtectedRoute';
import Layout from './components/Layout';
import CookieConsent from './components/CookieConsent';
import Login from './pages/Login';
import Landing from './pages/Landing';
import Dashboard from './pages/Dashboard';
import Accounts from './pages/Accounts';
import CostCenters from './pages/CostCenters';
@ -25,6 +26,11 @@ import Preferences from './pages/Preferences';
import Profile from './pages/Profile';
import Pricing from './pages/Pricing';
import Billing from './pages/Billing';
import Users from './pages/Users';
import SiteSettings from './pages/SiteSettings';
import Register from './pages/Register';
import ActivateAccount from './pages/ActivateAccount';
import PaymentSuccess from './pages/PaymentSuccess';
function App() {
return (
@ -33,6 +39,9 @@ function App() {
<ToastProvider>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/activate" element={<ActivateAccount />} />
<Route path="/payment-success" element={<PaymentSuccess />} />
<Route
path="/dashboard"
element={
@ -221,7 +230,27 @@ function App() {
</ProtectedRoute>
}
/>
<Route path="/" element={<Navigate to="/dashboard" />} />
<Route
path="/users"
element={
<ProtectedRoute>
<Layout>
<Users />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/site-settings"
element={
<ProtectedRoute>
<Layout>
<SiteSettings />
</Layout>
</ProtectedRoute>
}
/>
<Route path="/" element={<Landing />} />
</Routes>
<CookieConsent />
</ToastProvider>

View File

@ -0,0 +1,832 @@
import React, { useState, useEffect } from 'react';
import { accountService, liabilityAccountService, assetAccountService } from '../services/api';
import { useToast } from './Toast';
const AccountWizard = ({ isOpen, onClose, onSuccess, account = null }) => {
const toast = useToast();
const [step, setStep] = useState(1);
const [loading, setLoading] = useState(false);
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
const isEditMode = !!account;
// Tipo de destino: 'account', 'asset' ou 'liability'
const [destinationType, setDestinationType] = useState('account');
// Form data unificado
const [formData, setFormData] = useState({
// Tipo de conta (etapa 1)
account_type: '', // checking, savings, credit_card, cash
// Dados básicos (etapa 2)
name: '',
description: '',
currency: 'EUR',
color: '#3B82F6',
icon: 'bi-bank',
// Dados financeiros (etapa 3)
initial_balance: '',
credit_limit: '', // Para cartão de crédito
// Dados bancários (etapa 4) - opcional
bank_name: '',
account_number: '',
// Configurações
is_active: true,
include_in_total: true,
// Para poupança (ativo)
interest_rate: '',
// Para cartão de crédito (passivo)
closing_day: '',
due_day: '',
annual_interest_rate: '',
});
// Definição dos tipos de conta
const accountTypes = {
checking: {
name: 'Cuenta Corriente',
description: 'Cuenta bancaria para operaciones diarias',
icon: 'bi-bank',
color: '#3B82F6',
destination: 'account',
},
savings: {
name: 'Cuenta de Ahorro',
description: 'Dinero guardado que genera intereses',
icon: 'bi-piggy-bank',
color: '#10B981',
destination: 'asset', // Poupança vira ativo
},
credit_card: {
name: 'Tarjeta de Crédito',
description: 'Línea de crédito rotativo',
icon: 'bi-credit-card',
color: '#EF4444',
destination: 'liability', // Cartão vira passivo
},
cash: {
name: 'Efectivo',
description: 'Dinero en mano o caja chica',
icon: 'bi-cash-stack',
color: '#F59E0B',
destination: 'account',
},
};
useEffect(() => {
const handleResize = () => setIsMobile(window.innerWidth < 768);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
useEffect(() => {
if (isOpen) {
if (account) {
loadAccountData(account);
} else {
resetForm();
}
}
}, [isOpen, account]);
// Atualizar destino quando tipo muda
useEffect(() => {
if (formData.account_type && accountTypes[formData.account_type]) {
const typeConfig = accountTypes[formData.account_type];
setDestinationType(typeConfig.destination);
// Atualizar ícone e cor padrão
if (!isEditMode) {
setFormData(prev => ({
...prev,
icon: typeConfig.icon,
color: typeConfig.color,
}));
}
}
}, [formData.account_type]);
const loadAccountData = (accountData) => {
// Determinar o tipo baseado nos dados
let accountType = accountData.type || accountData.account_type || 'checking';
// Se for um ativo de investimento/poupança
if (accountData.asset_type === 'cash' || accountData.investment_type === 'savings') {
accountType = 'savings';
}
// Se for um passivo de cartão
if (accountData.contract_type === 'credit_card') {
accountType = 'credit_card';
}
setStep(2);
setFormData({
account_type: accountType,
name: accountData.name || '',
description: accountData.description || '',
currency: accountData.currency || 'EUR',
color: accountData.color || '#3B82F6',
icon: accountData.icon || 'bi-bank',
initial_balance: accountData.initial_balance || accountData.current_balance || accountData.current_value || '',
credit_limit: accountData.credit_limit || accountData.principal_amount || '',
bank_name: accountData.bank_name || accountData.creditor || '',
account_number: accountData.account_number || accountData.contract_number || '',
is_active: accountData.is_active !== false,
include_in_total: accountData.include_in_total !== false,
interest_rate: accountData.interest_rate || '',
closing_day: accountData.closing_day || '',
due_day: accountData.due_day || '',
annual_interest_rate: accountData.annual_interest_rate || '',
});
};
const resetForm = () => {
setStep(1);
setDestinationType('account');
setFormData({
account_type: '',
name: '',
description: '',
currency: 'EUR',
color: '#3B82F6',
icon: 'bi-bank',
initial_balance: '',
credit_limit: '',
bank_name: '',
account_number: '',
is_active: true,
include_in_total: true,
interest_rate: '',
closing_day: '',
due_day: '',
annual_interest_rate: '',
});
};
const handleChange = (e) => {
const { name, value, type, checked } = e.target;
setFormData(prev => ({
...prev,
[name]: type === 'checkbox' ? checked : value,
}));
};
const selectAccountType = (type) => {
setFormData(prev => ({ ...prev, account_type: type }));
setStep(2);
};
const validateStep = () => {
switch (step) {
case 1:
return !!formData.account_type;
case 2:
return !!formData.name?.trim();
case 3:
if (destinationType === 'liability') {
return formData.credit_limit > 0;
}
return true; // Saldo inicial pode ser 0
case 4:
return true; // Dados bancários são opcionais
default:
return true;
}
};
const nextStep = () => {
if (validateStep()) {
setStep(prev => Math.min(prev + 1, 4));
}
};
const prevStep = () => {
setStep(prev => Math.max(prev - 1, 1));
};
const handleSubmit = async () => {
setLoading(true);
try {
let response;
if (destinationType === 'asset') {
// Criar como Ativo (Poupança)
const assetData = {
asset_type: 'cash', // Tipo cash para poupança
investment_type: 'savings',
name: formData.name,
description: formData.description,
currency: formData.currency,
color: formData.color,
acquisition_value: parseFloat(formData.initial_balance) || 0,
current_value: parseFloat(formData.initial_balance) || 0,
acquisition_date: new Date().toISOString().split('T')[0],
institution: formData.bank_name,
account_number: formData.account_number,
interest_rate: parseFloat(formData.interest_rate) || 0,
};
if (isEditMode && account) {
response = await assetAccountService.update(account.id, assetData);
} else {
response = await assetAccountService.createWithWizard(assetData);
}
} else if (destinationType === 'liability') {
// Criar como Passivo (Cartão de Crédito)
const liabilityData = {
contract_type: 'credit_card',
name: formData.name,
description: formData.description,
currency: formData.currency,
color: formData.color,
icon: formData.icon,
creditor: formData.bank_name,
contract_number: formData.account_number,
principal_amount: parseFloat(formData.credit_limit) || 0,
total_pending: parseFloat(formData.initial_balance) || 0,
annual_interest_rate: parseFloat(formData.annual_interest_rate) || 0,
amortization_system: 'revolving',
start_date: new Date().toISOString().split('T')[0],
closing_day: parseInt(formData.closing_day) || null,
due_day: parseInt(formData.due_day) || null,
};
if (isEditMode && account) {
response = await liabilityAccountService.update(account.id, liabilityData);
} else {
response = await liabilityAccountService.storeWithWizard(liabilityData);
}
} else {
// Criar como Conta Normal
const accountData = {
type: formData.account_type,
name: formData.name,
description: formData.description,
currency: formData.currency,
color: formData.color,
icon: formData.icon,
bank_name: formData.bank_name,
account_number: formData.account_number,
initial_balance: parseFloat(formData.initial_balance) || 0,
credit_limit: formData.account_type === 'credit_card' ? parseFloat(formData.credit_limit) || null : null,
is_active: formData.is_active,
include_in_total: formData.include_in_total,
};
if (isEditMode && account) {
response = await accountService.update(account.id, accountData);
} else {
response = await accountService.create(accountData);
}
}
if (response.success) {
onSuccess?.(response.data, destinationType);
onClose();
}
} catch (error) {
console.error('Error saving account:', error);
toast.error(error.response?.data?.message || 'Error al guardar la cuenta');
} finally {
setLoading(false);
}
};
const getTotalSteps = () => {
return 4;
};
if (!isOpen) return null;
return (
<div className="modal fade show d-block" style={{ backgroundColor: 'rgba(0,0,0,0.8)' }}>
<div className={`modal-dialog ${isMobile ? 'modal-fullscreen' : 'modal-lg'} modal-dialog-centered modal-dialog-scrollable`}>
<div className="modal-content" style={{ background: isMobile ? '#0f172a' : '#1e293b', border: '1px solid #334155' }}>
{/* Header */}
<div className={`modal-header border-0 ${isMobile ? 'py-2' : ''}`} style={{ backgroundColor: '#334155' }}>
<h5 className={`modal-title text-white ${isMobile ? 'fs-6' : ''}`}>
<i className={`bi ${isEditMode ? 'bi-pencil' : 'bi-plus-circle'} me-2`}></i>
{isEditMode ? 'Editar Cuenta' : 'Nueva Cuenta'} - Paso {step}/{getTotalSteps()}
</h5>
<button type="button" className="btn-close btn-close-white" onClick={onClose}></button>
</div>
{/* Progress */}
<div className="progress" style={{ height: '4px', borderRadius: 0 }}>
<div
className="progress-bar bg-primary"
style={{ width: `${(step / getTotalSteps()) * 100}%` }}
></div>
</div>
{/* Body */}
<div className={`modal-body ${isMobile ? 'p-3' : 'p-4'}`} style={{ color: '#fff' }}>
{/* Step 1: Tipo de Conta */}
{step === 1 && (
<div>
<h5 className="mb-4">
<i className="bi bi-wallet2 me-2"></i>
¿Qué tipo de cuenta deseas crear?
</h5>
<div className="row g-3">
{Object.entries(accountTypes).map(([key, config]) => (
<div key={key} className="col-md-6">
<div
className={`card h-100 cursor-pointer ${formData.account_type === key ? 'border-primary' : 'border-secondary'}`}
style={{
backgroundColor: formData.account_type === key ? config.color + '20' : '#0f172a',
cursor: 'pointer',
transition: 'all 0.2s',
}}
onClick={() => selectAccountType(key)}
>
<div className="card-body text-center p-4">
<div
className="rounded-circle d-inline-flex align-items-center justify-content-center mb-3"
style={{
width: '60px',
height: '60px',
backgroundColor: config.color + '30',
}}
>
<i className={`bi ${config.icon} fs-3`} style={{ color: config.color }}></i>
</div>
<h6 className="text-white mb-2">{config.name}</h6>
<small className="text-slate-400">{config.description}</small>
{/* Badge indicando destino */}
<div className="mt-3">
{config.destination === 'asset' && (
<span className="badge bg-success">
<i className="bi bi-graph-up me-1"></i>
Se registra como Activo
</span>
)}
{config.destination === 'liability' && (
<span className="badge bg-danger">
<i className="bi bi-graph-down me-1"></i>
Se registra como Pasivo
</span>
)}
{config.destination === 'account' && (
<span className="badge bg-primary">
<i className="bi bi-wallet2 me-1"></i>
Cuenta Estándar
</span>
)}
</div>
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* Step 2: Dados Básicos */}
{step === 2 && (
<div>
<h5 className="mb-4">
<i className={`bi ${accountTypes[formData.account_type]?.icon || 'bi-info-circle'} me-2`}></i>
Información Básica
</h5>
<div className="row g-3">
<div className="col-12">
<label className="form-label">
Nombre de la Cuenta <span className="text-danger">*</span>
</label>
<input
type="text"
name="name"
className="form-control bg-dark text-white border-secondary"
value={formData.name}
onChange={handleChange}
placeholder={`Ej: ${accountTypes[formData.account_type]?.name || 'Mi cuenta'}`}
autoFocus
/>
</div>
<div className="col-md-6">
<label className="form-label">Moneda</label>
<select
name="currency"
className="form-select bg-dark text-white border-secondary"
value={formData.currency}
onChange={handleChange}
>
<option value="EUR">EUR - Euro</option>
<option value="USD">USD - Dólar</option>
<option value="BRL">BRL - Real</option>
<option value="GBP">GBP - Libra</option>
</select>
</div>
<div className="col-md-6">
<label className="form-label">Color</label>
<div className="d-flex gap-2 flex-wrap">
{['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#06B6D4', '#6B7280'].map(color => (
<div
key={color}
className={`rounded-circle ${formData.color === color ? 'ring ring-white' : ''}`}
style={{
width: '32px',
height: '32px',
backgroundColor: color,
cursor: 'pointer',
border: formData.color === color ? '3px solid white' : '2px solid transparent',
}}
onClick={() => setFormData(prev => ({ ...prev, color }))}
/>
))}
</div>
</div>
<div className="col-12">
<label className="form-label">Descripción (opcional)</label>
<textarea
name="description"
className="form-control bg-dark text-white border-secondary"
value={formData.description}
onChange={handleChange}
rows={2}
placeholder="Descripción o notas adicionales..."
/>
</div>
</div>
</div>
)}
{/* Step 3: Dados Financeiros */}
{step === 3 && (
<div>
<h5 className="mb-4">
<i className="bi bi-currency-euro me-2"></i>
Información Financiera
</h5>
<div className="row g-3">
{/* Saldo/Valor Inicial */}
<div className="col-md-6">
<label className="form-label">
{destinationType === 'liability' ? 'Deuda Actual' : 'Saldo Inicial'}
{destinationType === 'liability' && <span className="text-danger">*</span>}
</label>
<div className="input-group">
<span className="input-group-text bg-dark text-white border-secondary">
{formData.currency === 'EUR' ? '€' : formData.currency === 'USD' ? '$' : 'R$'}
</span>
<input
type="number"
name="initial_balance"
className="form-control bg-dark text-white border-secondary"
value={formData.initial_balance}
onChange={handleChange}
placeholder="0.00"
step="0.01"
/>
</div>
<small className="text-slate-400">
{destinationType === 'liability'
? 'Monto que debes actualmente en esta tarjeta'
: 'Saldo actual de la cuenta'}
</small>
</div>
{/* Limite de Crédito (só para cartão) */}
{(formData.account_type === 'credit_card' || destinationType === 'liability') && (
<div className="col-md-6">
<label className="form-label">
Límite de Crédito <span className="text-danger">*</span>
</label>
<div className="input-group">
<span className="input-group-text bg-dark text-white border-secondary">
{formData.currency === 'EUR' ? '€' : formData.currency === 'USD' ? '$' : 'R$'}
</span>
<input
type="number"
name="credit_limit"
className="form-control bg-dark text-white border-secondary"
value={formData.credit_limit}
onChange={handleChange}
placeholder="5000.00"
step="0.01"
/>
</div>
</div>
)}
{/* Taxa de Juros (para poupança ou cartão) */}
{(destinationType === 'asset' || destinationType === 'liability') && (
<div className="col-md-6">
<label className="form-label">
Tasa de Interés Anual (%)
</label>
<div className="input-group">
<input
type="number"
name={destinationType === 'asset' ? 'interest_rate' : 'annual_interest_rate'}
className="form-control bg-dark text-white border-secondary"
value={destinationType === 'asset' ? formData.interest_rate : formData.annual_interest_rate}
onChange={handleChange}
placeholder={destinationType === 'asset' ? '2.5' : '18.99'}
step="0.01"
/>
<span className="input-group-text bg-dark text-white border-secondary">%</span>
</div>
<small className="text-slate-400">
{destinationType === 'asset'
? 'Rendimiento anual de la cuenta'
: 'Tasa de interés por financiamiento'}
</small>
</div>
)}
{/* Dias de fechamento e vencimento (cartão) */}
{destinationType === 'liability' && (
<>
<div className="col-md-6">
<label className="form-label">Día de Cierre</label>
<input
type="number"
name="closing_day"
className="form-control bg-dark text-white border-secondary"
value={formData.closing_day}
onChange={handleChange}
placeholder="15"
min="1"
max="31"
/>
<small className="text-slate-400">Día del mes que cierra la factura</small>
</div>
<div className="col-md-6">
<label className="form-label">Día de Vencimiento</label>
<input
type="number"
name="due_day"
className="form-control bg-dark text-white border-secondary"
value={formData.due_day}
onChange={handleChange}
placeholder="25"
min="1"
max="31"
/>
<small className="text-slate-400">Día de pago de la factura</small>
</div>
</>
)}
</div>
</div>
)}
{/* Step 4: Dados Bancários */}
{step === 4 && (
<div>
<h5 className="mb-4">
<i className="bi bi-building me-2"></i>
Información Bancaria (Opcional)
</h5>
<div className="row g-3">
<div className="col-md-6">
<label className="form-label">
{destinationType === 'liability' ? 'Emisor de la Tarjeta' : 'Nombre del Banco'}
</label>
<input
type="text"
name="bank_name"
className="form-control bg-dark text-white border-secondary"
value={formData.bank_name}
onChange={handleChange}
placeholder={destinationType === 'liability' ? 'Ej: BBVA, Santander' : 'Ej: Santander, BBVA'}
/>
</div>
<div className="col-md-6">
<label className="form-label">
{destinationType === 'liability' ? 'Últimos 4 Dígitos' : 'Número de Cuenta'}
</label>
<input
type="text"
name="account_number"
className="form-control bg-dark text-white border-secondary"
value={formData.account_number}
onChange={handleChange}
placeholder={destinationType === 'liability' ? '****1234' : 'ES00 0000 0000 0000'}
/>
</div>
{destinationType === 'account' && (
<>
<div className="col-12">
<hr className="border-secondary my-4" />
<h6 className="text-white mb-3">
<i className="bi bi-gear me-2"></i>
Configuración
</h6>
</div>
<div className="col-md-6">
<div className="form-check form-switch">
<input
type="checkbox"
name="is_active"
className="form-check-input"
checked={formData.is_active}
onChange={handleChange}
id="is_active"
/>
<label className="form-check-label text-white" htmlFor="is_active">
Cuenta Activa
</label>
</div>
<small className="text-slate-400">Cuentas inactivas no aparecen en transacciones</small>
</div>
<div className="col-md-6">
<div className="form-check form-switch">
<input
type="checkbox"
name="include_in_total"
className="form-check-input"
checked={formData.include_in_total}
onChange={handleChange}
id="include_in_total"
/>
<label className="form-check-label text-white" htmlFor="include_in_total">
Incluir en Total
</label>
</div>
<small className="text-slate-400">Suma el saldo al patrimonio total</small>
</div>
</>
)}
</div>
{/* Resumen */}
<div className="mt-4 p-3 rounded" style={{ backgroundColor: '#0f172a' }}>
<h6 className="text-white mb-3">
<i className="bi bi-check-circle me-2"></i>
Resumen
</h6>
<div className="row g-2">
<div className="col-6">
<small className="text-slate-400">Tipo:</small>
<div className="text-white">{accountTypes[formData.account_type]?.name}</div>
</div>
<div className="col-6">
<small className="text-slate-400">Nombre:</small>
<div className="text-white">{formData.name || '-'}</div>
</div>
<div className="col-6">
<small className="text-slate-400">
{destinationType === 'liability' ? 'Deuda:' : 'Saldo:'}
</small>
<div className="text-white">
{formData.currency === 'EUR' ? '€' : formData.currency === 'USD' ? '$' : 'R$'}
{parseFloat(formData.initial_balance || 0).toFixed(2)}
</div>
</div>
{formData.credit_limit && (
<div className="col-6">
<small className="text-slate-400">Límite:</small>
<div className="text-white">
{formData.currency === 'EUR' ? '€' : formData.currency === 'USD' ? '$' : 'R$'}
{parseFloat(formData.credit_limit).toFixed(2)}
</div>
</div>
)}
<div className="col-12">
<small className="text-slate-400">Se guardará como:</small>
<div>
{destinationType === 'asset' && (
<span className="badge bg-success">
<i className="bi bi-graph-up me-1"></i>
Activo Financiero
</span>
)}
{destinationType === 'liability' && (
<span className="badge bg-danger">
<i className="bi bi-graph-down me-1"></i>
Pasivo (Deuda)
</span>
)}
{destinationType === 'account' && (
<span className="badge bg-primary">
<i className="bi bi-wallet2 me-1"></i>
Cuenta Estándar
</span>
)}
</div>
</div>
</div>
</div>
</div>
)}
</div>
{/* Footer */}
<div className={`modal-footer border-0 ${isMobile ? 'flex-column' : ''}`}>
{isMobile ? (
<>
{step === getTotalSteps() && (
<button
type="button"
className="btn btn-primary w-100 mb-2"
onClick={handleSubmit}
disabled={loading || !validateStep()}
>
{loading ? (
<>
<span className="spinner-border spinner-border-sm me-2"></span>
Guardando...
</>
) : (
<>
<i className="bi bi-check-lg me-1"></i>
{isEditMode ? 'Guardar Cambios' : 'Crear Cuenta'}
</>
)}
</button>
)}
<div className="d-flex gap-2 w-100">
{step > 1 && (
<button type="button" className="btn btn-outline-secondary flex-fill" onClick={prevStep}>
<i className="bi bi-arrow-left me-1"></i>
Anterior
</button>
)}
{step < getTotalSteps() && (
<button
type="button"
className="btn btn-primary flex-fill"
onClick={nextStep}
disabled={!validateStep()}
>
Siguiente
<i className="bi bi-arrow-right ms-1"></i>
</button>
)}
<button type="button" className="btn btn-outline-secondary flex-fill" onClick={onClose}>
Cancelar
</button>
</div>
</>
) : (
<>
<button type="button" className="btn btn-secondary" onClick={onClose}>
Cancelar
</button>
{step > 1 && (
<button type="button" className="btn btn-outline-light" onClick={prevStep}>
<i className="bi bi-arrow-left me-1"></i>
Anterior
</button>
)}
{step < getTotalSteps() ? (
<button
type="button"
className="btn btn-primary"
onClick={nextStep}
disabled={!validateStep()}
>
Siguiente
<i className="bi bi-arrow-right ms-1"></i>
</button>
) : (
<button
type="button"
className="btn btn-success"
onClick={handleSubmit}
disabled={loading || !validateStep()}
>
{loading ? (
<>
<span className="spinner-border spinner-border-sm me-2"></span>
Guardando...
</>
) : (
<>
<i className="bi bi-check-lg me-1"></i>
{isEditMode ? 'Guardar Cambios' : 'Crear Cuenta'}
</>
)}
</button>
)}
</>
)}
</div>
</div>
</div>
</div>
);
};
export default AccountWizard;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -13,6 +13,10 @@ const Layout = ({ children }) => {
const { t, i18n } = useTranslation();
const { date } = useFormatters();
// Admin email - only this user can see business module
const ADMIN_EMAIL = 'marco@cnxifly.com';
const isAdmin = user?.email === ADMIN_EMAIL;
// Mobile: sidebar oculta por padrão | Desktop: expandida
const isMobile = () => window.innerWidth < 768;
const [sidebarOpen, setSidebarOpen] = useState(false); // Mobile: inicia fechada
@ -47,8 +51,9 @@ const Layout = ({ children }) => {
};
const [expandedGroups, setExpandedGroups] = useState({
movements: true,
planning: true,
registrations: false,
movements: false,
planning: false,
settings: false,
});
@ -68,6 +73,16 @@ const Layout = ({ children }) => {
const menuStructure = [
{ type: 'item', path: '/dashboard', icon: 'bi-speedometer2', label: t('nav.dashboard') },
{ type: 'item', path: '/accounts', icon: 'bi-wallet2', label: t('nav.accounts') },
{
type: 'group',
id: 'registrations',
icon: 'bi-folder',
label: t('nav.registrations'),
items: [
{ path: '/categories', icon: 'bi-tags', label: t('nav.categories') },
{ path: '/cost-centers', icon: 'bi-building', label: t('nav.costCenters') },
]
},
{
type: 'group',
id: 'movements',
@ -81,8 +96,8 @@ const Layout = ({ children }) => {
{ path: '/refunds', icon: 'bi-receipt-cutoff', label: t('nav.refunds') },
]
},
{ type: 'item', path: '/liabilities', icon: 'bi-bank', label: t('nav.liabilities') },
{ type: 'item', path: '/business', icon: 'bi-briefcase', label: t('nav.business') },
// Business module - only visible to admin
...(isAdmin ? [{ type: 'item', path: '/business', icon: 'bi-briefcase', label: t('nav.business') }] : []),
{
type: 'group',
id: 'planning',
@ -101,12 +116,15 @@ const Layout = ({ children }) => {
icon: 'bi-gear',
label: t('nav.settings'),
items: [
{ path: '/categories', icon: 'bi-tags', label: t('nav.categories') },
{ path: '/cost-centers', icon: 'bi-building', label: t('nav.costCenters') },
{ path: '/preferences', icon: 'bi-sliders', label: t('nav.preferences') },
{ path: '/profile', icon: 'bi-person-circle', label: t('nav.profile') },
{ path: '/billing', icon: 'bi-credit-card', label: t('nav.billing') },
{ path: '/pricing', icon: 'bi-tags-fill', label: t('nav.pricing') },
// Admin only: User management and Site Settings
...(isAdmin ? [
{ path: '/users', icon: 'bi-people', label: t('nav.users') },
{ path: '/site-settings', icon: 'bi-globe', label: t('nav.siteSettings', 'Sitio Web') },
] : []),
]
},
];

File diff suppressed because it is too large Load Diff

View File

@ -4,6 +4,9 @@ import { useAuth } from '../context/AuthContext';
const ProtectedRoute = ({ children }) => {
const { isAuthenticated, loading } = useAuth();
// Verificar também o token no localStorage (para casos de navegação imediata após registro)
const hasToken = !!localStorage.getItem('token');
if (loading) {
return (
@ -15,7 +18,7 @@ const ProtectedRoute = ({ children }) => {
);
}
return isAuthenticated ? children : <Navigate to="/login" />;
return (isAuthenticated || hasToken) ? children : <Navigate to="/login" />;
};
export default ProtectedRoute;

View File

@ -15,7 +15,9 @@ export const ToastProvider = ({ children }) => {
const addToast = useCallback((message, type = 'info', duration = 5000) => {
const id = Date.now();
const toast = { id, message, type, duration };
// Map 'error' to 'danger' for backward compatibility
const mappedType = type === 'error' ? 'danger' : type;
const toast = { id, message, type: mappedType, duration };
setToasts((prev) => [...prev, toast]);
@ -48,8 +50,11 @@ export const ToastProvider = ({ children }) => {
return addToast(message, 'info', duration);
}, [addToast]);
// Alias for backward compatibility
const showToast = addToast;
return (
<ToastContext.Provider value={{ addToast, removeToast, success, error, warning, info }}>
<ToastContext.Provider value={{ addToast, showToast, removeToast, success, error, warning, info }}>
{children}
<ToastContainer toasts={toasts} removeToast={removeToast} />
</ToastContext.Provider>

View File

@ -0,0 +1,129 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import api from '../../services/api';
export default function PlanUsageWidget() {
const { t } = useTranslation();
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadUsage();
}, []);
const loadUsage = async () => {
try {
const response = await api.get('/subscription/status');
if (response.data.success) {
setData(response.data.data);
}
} catch (error) {
console.error('Error loading plan usage:', error);
} finally {
setLoading(false);
}
};
if (loading) {
return (
<div className="card mb-3">
<div className="card-body text-center py-3">
<div className="spinner-border spinner-border-sm text-primary" role="status">
<span className="visually-hidden">Loading...</span>
</div>
</div>
</div>
);
}
if (!data || !data.plan) {
return null;
}
const { plan, usage, usage_percentages } = data;
const limits = plan.limits || {};
// Only show for free plan or plans with limits
const hasLimits = Object.values(limits).some(v => v !== null);
if (!hasLimits) {
return null;
}
const resources = [
{ key: 'accounts', icon: 'bi-wallet2', label: t('dashboard.accounts', 'Cuentas') },
{ key: 'categories', icon: 'bi-tags', label: t('dashboard.categories', 'Categorías') },
{ key: 'budgets', icon: 'bi-pie-chart', label: t('dashboard.budgets', 'Presupuestos') },
{ key: 'transactions', icon: 'bi-receipt', label: t('dashboard.transactions', 'Transacciones') },
];
const getProgressColor = (percentage) => {
if (percentage === null) return 'bg-success';
if (percentage >= 100) return 'bg-danger';
if (percentage >= 80) return 'bg-warning';
return 'bg-primary';
};
const hasWarning = Object.values(usage_percentages).some(p => p !== null && p >= 80);
const hasLimit = Object.values(usage_percentages).some(p => p !== null && p >= 100);
return (
<div className={`card mb-3 ${hasLimit ? 'border-danger' : hasWarning ? 'border-warning' : ''}`}>
<div className="card-header d-flex justify-content-between align-items-center py-2">
<span className="fw-semibold">
<i className="bi bi-speedometer2 me-2"></i>
{t('planUsage.title', 'Uso del Plan')} - {plan.name}
</span>
{plan.is_free && (
<Link to="/pricing" className="btn btn-sm btn-primary">
<i className="bi bi-arrow-up-circle me-1"></i>
{t('planUsage.upgrade', 'Mejorar')}
</Link>
)}
</div>
<div className="card-body py-2">
{hasLimit && (
<div className="alert alert-danger py-2 mb-3">
<i className="bi bi-exclamation-triangle-fill me-2"></i>
{t('planUsage.limitReached', 'Has alcanzado el límite de tu plan. Actualiza a Pro para continuar.')}
</div>
)}
<div className="row g-2">
{resources.map(({ key, icon, label }) => {
const current = usage[key] || 0;
const limit = limits[key];
const percentage = usage_percentages[key];
if (limit === null || limit === undefined) {
return null; // Don't show unlimited resources
}
return (
<div key={key} className="col-6 col-md-3">
<div className="text-center">
<div className="d-flex justify-content-between align-items-center mb-1">
<small className="text-muted">
<i className={`bi ${icon} me-1`}></i>
{label}
</small>
<small className={percentage >= 100 ? 'text-danger fw-bold' : percentage >= 80 ? 'text-warning' : ''}>
{current}/{limit}
</small>
</div>
<div className="progress" style={{ height: '6px' }}>
<div
className={`progress-bar ${getProgressColor(percentage)}`}
role="progressbar"
style={{ width: `${Math.min(percentage || 0, 100)}%` }}
/>
</div>
</div>
</div>
);
})}
</div>
</div>
</div>
);
}

View File

@ -25,10 +25,9 @@ export const AuthProvider = ({ children }) => {
};
const register = async (userData) => {
// Register but don't set user - user needs PayPal payment + email activation first
const response = await authService.register(userData);
if (response.success) {
setUser(response.data.user);
}
// Don't set user here - wait for activation
return response;
};

View File

@ -44,6 +44,8 @@
"details": "Details",
"clearFilters": "Clear Filters",
"select": "Select",
"selection": "Selection",
"selectIcon": "Select Icon",
"refresh": "Refresh",
"filters": "Filters",
"processing": "Processing...",
@ -65,7 +67,10 @@
"incomes": "Income",
"expenses": "Expenses",
"balance": "Balance",
"current": "Current"
"current": "Current",
"continue": "Continue",
"creating": "Creating...",
"remove": "Remove"
},
"auth": {
"login": "Login",
@ -77,7 +82,56 @@
"loginSuccess": "Successfully logged in",
"loginError": "Login error",
"logoutSuccess": "Successfully logged out",
"invalidCredentials": "Invalid credentials"
"invalidCredentials": "Invalid credentials",
"createAccount": "Create your account",
"backToLogin": "Back to login",
"goToLogin": "Go to login"
},
"login": {
"noAccount": "Don't have an account?",
"createAccount": "Create one here",
"noSubscription": "You don't have an active subscription. Please complete the payment."
},
"errors": {
"connection": "Connection error. Please try again.",
"resendFailed": "Error resending email",
"subscriptionFailed": "Error creating subscription. Please try again."
},
"register": {
"selectPlan": "Select a plan",
"repeatPassword": "Repeat your password",
"continueToPayment": "Continue to payment",
"createAccount": "Create Account",
"alreadyHaveAccount": "Already have an account?",
"loginHere": "Login here",
"paymentCanceled": "Payment was canceled. You can try again."
},
"activate": {
"activating": "Activating your account...",
"pleaseWait": "Please wait while we activate your account.",
"successTitle": "Account Activated!",
"success": "Your account has been successfully activated. You can now use WEBMoney!",
"errorTitle": "Activation Error",
"error": "Could not activate your account. The link may have expired.",
"invalidLink": "Invalid activation link.",
"redirecting": "Redirecting in {{seconds}} seconds...",
"goToDashboard": "Go to Dashboard",
"checkEmail": "Check your email",
"checkEmailMessage": "We sent an activation email to {{email}}. Click the link to activate your account.",
"didntReceive": "Didn't receive the email?",
"resend": "Resend email",
"resendSuccess": "Email resent successfully!"
},
"payment": {
"confirming": "Confirming payment...",
"pleaseWait": "Please wait while we process your payment.",
"successTitle": "Payment Confirmed!",
"successMessage": "Your subscription has been successfully confirmed.",
"checkYourEmail": "Check your email!",
"activationSent": "We sent an activation email to {{email}}. Click the link to activate your account and start using WEBMoney.",
"errorTitle": "Payment Error",
"error": "Error confirming payment",
"noSubscriptionId": "Subscription ID not found"
},
"nav": {
"dashboard": "Dashboard",
@ -269,12 +323,18 @@
"deleteWarning": "All subcategories will also be deleted.",
"categoryName": "Category Name",
"parentCategory": "Parent Category",
"noParent": "No parent category (root)",
"noParent": "No parent category",
"selectParent": "More categories...",
"subcategories": "Subcategories",
"keywords": "Keywords",
"addKeyword": "Add Keyword",
"keywordPlaceholder": "Type a keyword",
"keywordHelp": "Keywords help automatically categorize transactions",
"keywordPlaceholder": "Type and press Enter...",
"keywordHelp": "E.g.: \"RESTAURANT\", \"PIZZA\" - Transactions with these words are auto-categorized",
"noKeywords": "No keywords. Transactions will be categorized manually.",
"namePlaceholder": "E.g.: Food, Transport...",
"descPlaceholder": "Describe this category...",
"visualSettings": "Appearance",
"autoCategorizationLabel": "Auto-categorization",
"types": {
"income": "Income",
"expense": "Expense",
@ -318,20 +378,23 @@
"expand": "Expand",
"collapse": "Collapse",
"createSubcategory": "Create Subcategory",
"batchCategorize": "Batch Categorize",
"batchCategorize": "Auto Categorize",
"batchDescription": "Automatically categorize transactions using keywords",
"analyzingTransactions": "Analyzing transactions...",
"uncategorized": "Uncategorized",
"willCategorize": "Will Be Categorized",
"willSkip": "Will Be Skipped",
"totalKeywords": "Active Keywords",
"willSkip": "No Match",
"totalKeywords": "Keywords",
"previewTitle": "Categorization Preview",
"matchedKeyword": "Matched Keyword",
"executeBatch": "Execute Categorization",
"matchedKeyword": "Keyword",
"executeBatch": "Categorize",
"batchSuccess": "transactions categorized successfully",
"batchError": "Error categorizing transactions",
"nothingToCategorize": "No transactions to categorize",
"batchPreviewError": "Error loading preview",
"previewError": "Error loading preview",
"noMatchesFound": "No transactions match the configured keywords",
"noMatchesFound": "Add keywords to categories to enable auto-categorization",
"noMatchesFoundTitle": "No matches found",
"categorized": "categorized",
"category": "Category"
},
@ -349,8 +412,13 @@
"budget": "Budget",
"keywords": "Keywords",
"addKeyword": "Add Keyword",
"keywordPlaceholder": "Type a keyword",
"keywordHelp": "Keywords help automatically assign transactions",
"keywordPlaceholder": "Type and press Enter...",
"keywordHelp": "E.g.: \"UBER\", \"iFood\" - Transactions with these words are auto-assigned",
"noKeywords": "No keywords. Transactions will be assigned manually.",
"namePlaceholder": "E.g.: Project Alpha, Marketing Dept...",
"descPlaceholder": "Describe the purpose of this cost center...",
"visualSettings": "Appearance",
"autoAssignLabel": "Auto-assign",
"createSuccess": "Cost center created successfully",
"updateSuccess": "Cost center updated successfully",
"deleteSuccess": "Cost center deleted successfully",
@ -509,6 +577,8 @@
"status": {
"label": "Status",
"pending": "Pending",
"effective": "Effective",
"scheduled": "Scheduled",
"completed": "Completed",
"cancelled": "Cancelled"
},
@ -1704,6 +1774,7 @@
"subcategory": "Subcategory",
"allCategory": "All category",
"selectCategory": "Select a category",
"general": "General",
"amount": "Amount",
"month": "Month",
"budgeted": "Budgeted",
@ -1727,6 +1798,57 @@
"yearly": "Yearly",
"isCumulative": "Cumulative Budget",
"isCumulativeHelp": "Accumulates expenses from the beginning of the year to the current period",
"total": "Total",
"wizard": {
"title": "Budget Wizard",
"button": "Wizard",
"step1": "Mode",
"step2": "Categories",
"step3": "Values",
"step4": "Confirm",
"quickStart": "Quick Start with Templates",
"manual": "Create Manually",
"manualDesc": "Choose categories and amounts",
"copy": "Copy from Another Month",
"copyDesc": "Reuse existing budgets",
"loadBudgets": "Load Budgets",
"noSourceBudgets": "No budgets in this period to copy",
"selectCategories": "Select Categories",
"categoriesSelected": "categories selected",
"setAmounts": "Set Amounts",
"history": "History",
"useHistory": "Use historical average",
"confirm": "Confirm Budgets",
"budgets": "Budgets",
"periods": "Periods",
"periodHelp": "Defines the automatic creation frequency for budgets",
"createBudgets": "Create Budgets",
"createBudget": "Create Budget",
"successCount": "{{count}} budget(s) created successfully",
"errorCount": "{{count}} budget(s) could not be created (already exist)",
"fillRequired": "Please fill in the required fields",
"updated": "Budget updated successfully",
"created": "Budget created successfully",
"selectAtLeast": "Select at least one category",
"templates": {
"basic": {
"name": "Basic Budget",
"desc": "Essential for monthly control"
},
"family": {
"name": "Family Budget",
"desc": "Complete for families"
},
"individual": {
"name": "Individual Budget",
"desc": "For single person"
},
"complete": {
"name": "Complete Budget",
"desc": "All categories"
}
}
},
"alert": {
"exceeded": "Budget exceeded!",
"warning": "Warning: near limit",
@ -2019,6 +2141,7 @@
"lastNamePlaceholder": "Your last name",
"lastNameRequired": "Last name is required",
"name": "Name",
"namePlaceholder": "Your name",
"email": "Email",
"phone": "Phone",
"phoneRequired": "Phone number is required",
@ -2053,6 +2176,7 @@
"billedAnnually": "Billed annually €{{price}}",
"save": "Save {{percent}}%",
"trialDays": "{{days}}-day free trial",
"trial": "trial",
"mostPopular": "Most Popular",
"currentPlan": "Current Plan",
"startFree": "Start Free",
@ -2065,6 +2189,16 @@
"securePayment": "Secure payment",
"cancelAnytime": "Cancel anytime",
"paypalSecure": "Secure payment with PayPal",
"comingSoon": "Coming Soon",
"forPymes": "Tools for SMEs",
"features": {
"multiUsers": "Multiple users",
"integratedBilling": "Integrated billing",
"advancedReports": "Advanced reports",
"apiAccess": "API access",
"prioritySupport": "Priority support",
"dedicatedManager": "Dedicated account manager"
},
"faq": {
"title": "Frequently Asked Questions",
"q1": "Can I change plans at any time?",
@ -2102,7 +2236,8 @@
"trialing": "Trial",
"canceled": "Canceled",
"expired": "Expired",
"past_due": "Past Due"
"past_due": "Past Due",
"pending": "Pending"
},
"invoiceStatus": {
"paid": "Paid",
@ -2116,12 +2251,120 @@
"subscriptionConfirmed": "Subscription confirmed successfully!",
"confirmError": "Error confirming subscription",
"subscriptionCanceled": "Subscription canceled",
"subscriptionCanceledRefunded": "Subscription canceled and refund processed",
"cancelError": "Error canceling subscription",
"cancelConfirmTitle": "Cancel subscription?",
"cancelConfirmMessage": "Are you sure you want to cancel your subscription?",
"cancelNote1": "You'll keep access until the end of the current period",
"cancelNote2": "Your data will not be deleted",
"cancelNote3": "You can reactivate your subscription at any time",
"confirmCancel": "Yes, Cancel"
"confirmCancel": "Yes, Cancel",
"guaranteePeriod": "Guarantee Period",
"guaranteeMessage": "You have {{days}} day(s) remaining in the 7-day guarantee period. You can cancel and receive a full refund.",
"guaranteeBadge": "Guarantee: {{days}} days",
"requestRefund": "Request full refund",
"refundNote": "The refund will be processed by PayPal within 5-10 business days.",
"cancelAndRefund": "Cancel and Refund"
},
"landing": {
"nav": {
"features": "Features",
"pricing": "Pricing",
"faq": "FAQ",
"login": "Login",
"register": "Start Now"
},
"hero": {
"title": "Take Control of Your Finances",
"subtitle": "Intelligent financial management for individuals and businesses. Track income, expenses, and achieve your financial goals.",
"cta": "Start Now",
"learnMore": "Learn More",
"secure": "100% Secure"
},
"features": {
"title": "Everything You Need",
"subtitle": "Powerful tools to manage your money",
"accounts": {
"title": "Multiple Accounts",
"description": "Manage bank accounts, cards, and cash in one place"
},
"analytics": {
"title": "Detailed Reports",
"description": "Charts and analysis to understand your spending"
},
"categories": {
"title": "Smart Categories",
"description": "Automatic categorization with keywords and subcategories"
},
"import": {
"title": "Bank Import",
"description": "Import statements from Excel, CSV, OFX, and PDF"
},
"recurring": {
"title": "Recurring Transactions",
"description": "Automate bills, subscriptions and recurring income"
},
"security": {
"title": "Total Security",
"description": "Bank-level encryption to protect your data"
}
},
"pricing": {
"title": "Simple Plans, Fair Prices",
"subtitle": "Choose the plan that fits your needs",
"monthly": "Monthly",
"annual": "Annual",
"popular": "Most Popular",
"month": "month",
"year": "year",
"free": "Free",
"startFree": "Start Free",
"subscribe": "Subscribe Now",
"billedAnnually": "Billed annually €{{price}}",
"comingSoon": "Coming Soon",
"forPymes": "Tools for SMEs",
"features": {
"oneAccount": "1 bank account",
"tenCategories": "10 categories",
"hundredSubcategories": "100 subcategories",
"thousandTransactions": "1,000 transactions",
"unlimitedAccounts": "Unlimited accounts",
"unlimitedCategories": "Unlimited categories",
"unlimitedTransactions": "Unlimited transactions",
"multiUsers": "Multiple users",
"integratedBilling": "Integrated billing",
"advancedReports": "Advanced reports",
"cashFlow": "Cash flow management",
"budgetControl": "Budget control by project",
"businessModule": "Business module",
"prioritySupport": "Priority support"
},
"goldTeaser": {
"title": "GOLD Plan Coming Soon",
"description": "Direct online synchronization with your bank. Connect your accounts and see your transactions automatically updated in real-time."
}
},
"faq": {
"title": "Frequently Asked Questions",
"q1": "Is my data secure?",
"a1": "Yes! We use bank-level encryption (SSL/TLS) and your data is stored on secure servers with regular backups. We never share your information with third parties.",
"q2": "Can I cancel anytime?",
"a2": "Yes, you can cancel your subscription at any time without fees. You'll keep access until the end of the period you already paid.",
"q3": "Which banks are compatible?",
"a3": "You can import statements from any bank that exports to Excel, CSV, OFX, or PDF. We have predefined mappings for major banks.",
"q4": "How does the 7-day guarantee work?",
"a4": "You pay via PayPal and get immediate full access to all features. If you're not satisfied, cancel within 7 days and receive a full refund, no questions asked."
},
"cta": {
"title": "Ready to Transform Your Finances?",
"subtitle": "Join thousands of users who have already taken control of their money.",
"button": "Create Free Account"
},
"footer": {
"rights": "All rights reserved.",
"privacy": "Privacy Policy",
"terms": "Terms of Use",
"contact": "Contact"
}
}
}

View File

@ -45,6 +45,8 @@
"details": "Detalles",
"clearFilters": "Limpiar Filtros",
"select": "Seleccionar",
"selection": "Selección",
"selectIcon": "Seleccionar Icono",
"refresh": "Actualizar",
"filters": "Filtros",
"processing": "Procesando...",
@ -66,7 +68,10 @@
"incomes": "Ingresos",
"expenses": "Gastos",
"balance": "Balance",
"current": "Actual"
"current": "Actual",
"continue": "Continuar",
"creating": "Creando...",
"remove": "Eliminar"
},
"auth": {
"login": "Iniciar Sesión",
@ -78,7 +83,56 @@
"loginSuccess": "Sesión iniciada correctamente",
"loginError": "Error al iniciar sesión",
"logoutSuccess": "Sesión cerrada correctamente",
"invalidCredentials": "Credenciales inválidas"
"invalidCredentials": "Credenciales inválidas",
"createAccount": "Crea tu cuenta",
"backToLogin": "Volver al login",
"goToLogin": "Ir al login"
},
"login": {
"noAccount": "¿No tienes cuenta?",
"createAccount": "Crea una aquí",
"noSubscription": "No tienes una suscripción activa. Por favor, completa el pago."
},
"errors": {
"connection": "Error de conexión. Intenta de nuevo.",
"resendFailed": "Error al reenviar email",
"subscriptionFailed": "Error al crear suscripción. Intenta de nuevo."
},
"register": {
"selectPlan": "Selecciona un plan",
"repeatPassword": "Repite tu contraseña",
"continueToPayment": "Continuar al pago",
"createAccount": "Crear Cuenta",
"alreadyHaveAccount": "¿Ya tienes cuenta?",
"loginHere": "Inicia sesión aquí",
"paymentCanceled": "El pago fue cancelado. Puedes intentarlo de nuevo."
},
"activate": {
"activating": "Activando tu cuenta...",
"pleaseWait": "Por favor, espera mientras activamos tu cuenta.",
"successTitle": "¡Cuenta Activada!",
"success": "Tu cuenta ha sido activada exitosamente. ¡Ya puedes usar WEBMoney!",
"errorTitle": "Error de Activación",
"error": "No se pudo activar tu cuenta. El enlace puede haber expirado.",
"invalidLink": "Enlace de activación inválido.",
"redirecting": "Redirigiendo en {{seconds}} segundos...",
"goToDashboard": "Ir al Panel",
"checkEmail": "Revisa tu email",
"checkEmailMessage": "Hemos enviado un email de activación a {{email}}. Haz clic en el enlace para activar tu cuenta.",
"didntReceive": "¿No recibiste el email?",
"resend": "Reenviar email",
"resendSuccess": "¡Email reenviado exitosamente!"
},
"payment": {
"confirming": "Confirmando pago...",
"pleaseWait": "Por favor, espera mientras procesamos tu pago.",
"successTitle": "¡Pago Confirmado!",
"successMessage": "Tu suscripción ha sido confirmada exitosamente.",
"checkYourEmail": "¡Revisa tu email!",
"activationSent": "Hemos enviado un email de activación a {{email}}. Haz clic en el enlace para activar tu cuenta y comenzar a usar WEBMoney.",
"errorTitle": "Error de Pago",
"error": "Error al confirmar el pago",
"noSubscriptionId": "ID de suscripción no encontrado"
},
"nav": {
"dashboard": "Panel",
@ -86,6 +140,7 @@
"liabilities": "Pasivos",
"transactions": "Transacciones",
"movements": "Movimientos",
"registrations": "Registros",
"import": "Importar",
"duplicates": "Duplicados",
"transfers": "Transferencias",
@ -103,7 +158,8 @@
"goals": "Metas",
"budgets": "Presupuestos",
"billing": "Facturación",
"pricing": "Planes"
"pricing": "Planes",
"users": "Usuarios"
},
"dashboard": {
"title": "Panel de Control",
@ -270,12 +326,18 @@
"deleteWarning": "Se eliminarán también todas las subcategorías.",
"categoryName": "Nombre de la Categoría",
"parentCategory": "Categoría Padre",
"noParent": "Sin categoría padre (raíz)",
"noParent": "Sin categoría padre",
"selectParent": "Más categorías...",
"subcategories": "Subcategorías",
"keywords": "Palabras Clave",
"addKeyword": "Agregar Palabra Clave",
"keywordPlaceholder": "Escribe una palabra clave",
"keywordHelp": "Las palabras clave ayudan a categorizar transacciones automáticamente",
"keywordPlaceholder": "Escribe y presiona Enter...",
"keywordHelp": "Ej: \"RESTAURANTE\", \"PIZZA\" - Transacciones con estas palabras se categorizan automáticamente",
"noKeywords": "Sin palabras clave. Las transacciones se categorizarán manualmente.",
"namePlaceholder": "Ej: Alimentación, Transporte...",
"descPlaceholder": "Describe esta categoría...",
"visualSettings": "Apariencia",
"autoCategorizationLabel": "Auto-categorización",
"types": {
"income": "Ingreso",
"expense": "Gasto",
@ -319,20 +381,23 @@
"expand": "Expandir",
"collapse": "Contraer",
"createSubcategory": "Crear Subcategoría",
"batchCategorize": "Categorizar en Lote",
"batchCategorize": "Categorización Automática",
"batchDescription": "Categoriza transacciones automáticamente usando palabras clave",
"analyzingTransactions": "Analizando transacciones...",
"uncategorized": "Sin Categoría",
"willCategorize": "Serán Categorizadas",
"willSkip": "Serán Ignoradas",
"totalKeywords": "Keywords Activas",
"willSkip": "Sin Correspondencia",
"totalKeywords": "Palabras Clave",
"previewTitle": "Vista Previa de Categorización",
"matchedKeyword": "Keyword Encontrada",
"executeBatch": "Ejecutar Categorización",
"matchedKeyword": "Keyword",
"executeBatch": "Categorizar",
"batchSuccess": "transacciones categorizadas con éxito",
"batchError": "Error al categorizar transacciones",
"nothingToCategorize": "No hay transacciones para categorizar",
"batchPreviewError": "Error al cargar preview",
"previewError": "Error al cargar preview",
"noMatchesFound": "Ninguna transacción corresponde a las palabras clave configuradas",
"noMatchesFound": "Añade palabras clave a las categorías para permitir categorización automática",
"noMatchesFoundTitle": "Ninguna correspondencia encontrada",
"categorized": "categorizadas",
"category": "Categoría"
},
@ -350,8 +415,13 @@
"budget": "Presupuesto",
"keywords": "Palabras Clave",
"addKeyword": "Agregar Palabra Clave",
"keywordPlaceholder": "Escribe una palabra clave",
"keywordHelp": "Las palabras clave ayudan a asignar transacciones automáticamente",
"keywordPlaceholder": "Escribe y presiona Enter...",
"keywordHelp": "Ej: \"UBER\", \"iFood\" - Transacciones con estas palabras se asignan automáticamente",
"noKeywords": "Sin palabras clave. Las transacciones se asignarán manualmente.",
"namePlaceholder": "Ej: Proyecto Alpha, Dpto. Marketing...",
"descPlaceholder": "Describe el propósito de este centro de costo...",
"visualSettings": "Apariencia",
"autoAssignLabel": "Auto-asignación",
"createSuccess": "Centro de costo creado correctamente",
"updateSuccess": "Centro de costo actualizado correctamente",
"deleteSuccess": "Centro de costo eliminado correctamente",
@ -365,6 +435,8 @@
"liabilities": {
"title": "Cuentas Pasivo",
"subtitle": "Gestión de préstamos y financiamientos",
"manage": "Gestionar Pasivos",
"noLiabilities": "No hay pasivos registrados",
"importContract": "Importar Contrato",
"import": "Importar",
"importInfo": "Selecciona un archivo Excel (.xlsx) con la tabla de cuotas. El archivo debe contener columnas para: Número de Cuota, Fecha de Vencimiento, Valor de Cuota, Intereses, Capital y Estado.",
@ -513,6 +585,8 @@
"status": {
"label": "Estado",
"pending": "Pendiente",
"effective": "Efectiva",
"scheduled": "Programada",
"completed": "Completada",
"cancelled": "Cancelada"
},
@ -1756,6 +1830,7 @@
"subcategory": "Subcategoría",
"allCategory": "Toda la categoría",
"selectCategory": "Seleccionar categoría",
"general": "General",
"amount": "Monto",
"spent": "Gastado",
"budgeted": "Presupuestado",
@ -1780,6 +1855,57 @@
"yearly": "Anual",
"isCumulative": "Presupuesto Acumulativo",
"isCumulativeHelp": "Acumula gastos desde inicio de año hasta el período actual",
"total": "Total",
"wizard": {
"title": "Asistente de Presupuestos",
"button": "Asistente",
"step1": "Modo",
"step2": "Categorías",
"step3": "Valores",
"step4": "Confirmar",
"quickStart": "Inicio Rápido con Plantillas",
"manual": "Crear Manualmente",
"manualDesc": "Elige categorías y valores",
"copy": "Copiar de Otro Mes",
"copyDesc": "Reutiliza presupuestos existentes",
"loadBudgets": "Cargar Presupuestos",
"noSourceBudgets": "No hay presupuestos en este período para copiar",
"selectCategories": "Selecciona las Categorías",
"categoriesSelected": "categorías seleccionadas",
"setAmounts": "Define los Valores",
"history": "Hist.",
"useHistory": "Usar promedio histórico",
"confirm": "Confirma los Presupuestos",
"budgets": "Presupuestos",
"periods": "Períodos",
"periodHelp": "Define la frecuencia de creación automática de presupuestos",
"createBudgets": "Crear Presupuestos",
"createBudget": "Crear Presupuesto",
"successCount": "{{count}} presupuesto(s) creado(s) con éxito",
"errorCount": "{{count}} presupuesto(s) no pudieron ser creados (ya existen)",
"fillRequired": "Complete los campos obligatorios",
"updated": "Presupuesto actualizado con éxito",
"created": "Presupuesto creado con éxito",
"selectAtLeast": "Seleccione al menos una categoría",
"templates": {
"basic": {
"name": "Presupuesto Básico",
"desc": "Esencial para control mensual"
},
"family": {
"name": "Presupuesto Familiar",
"desc": "Completo para familias"
},
"individual": {
"name": "Presupuesto Individual",
"desc": "Para persona soltera"
},
"complete": {
"name": "Presupuesto Completo",
"desc": "Todas las categorías"
}
}
},
"summary": {
"totalBudget": "Presupuesto Total",
"totalSpent": "Gastado",
@ -2007,6 +2133,7 @@
"lastNamePlaceholder": "Tus apellidos",
"lastNameRequired": "Los apellidos son obligatorios",
"name": "Nombre",
"namePlaceholder": "Tu nombre",
"email": "Correo electrónico",
"phone": "Teléfono",
"phoneRequired": "El teléfono es obligatorio",
@ -2041,6 +2168,7 @@
"billedAnnually": "Facturado anualmente €{{price}}",
"save": "Ahorra {{percent}}%",
"trialDays": "{{days}} días de prueba gratis",
"trial": "de prueba",
"mostPopular": "Más Popular",
"currentPlan": "Plan Actual",
"startFree": "Comenzar Gratis",
@ -2053,6 +2181,16 @@
"securePayment": "Pago seguro",
"cancelAnytime": "Cancela cuando quieras",
"paypalSecure": "Pago seguro con PayPal",
"comingSoon": "Próximamente",
"forPymes": "Herramientas para PyMEs",
"features": {
"multiUsers": "Múltiples usuarios",
"integratedBilling": "Facturación integrada",
"advancedReports": "Reportes avanzados",
"apiAccess": "Acceso a API",
"prioritySupport": "Soporte prioritario",
"dedicatedManager": "Gestor de cuenta dedicado"
},
"faq": {
"title": "Preguntas Frecuentes",
"q1": "¿Puedo cambiar de plan en cualquier momento?",
@ -2063,6 +2201,16 @@
"a3": "Ofrecemos una garantía de devolución de 30 días. Si no estás satisfecho, contáctanos para un reembolso completo."
}
},
"planUsage": {
"title": "Uso del Plan",
"upgrade": "Mejorar",
"limitReached": "Has alcanzado el límite de tu plan. Actualiza a Pro para continuar.",
"limitWarning": "Estás cerca del límite de tu plan.",
"accounts": "Cuentas",
"categories": "Categorías",
"budgets": "Presupuestos",
"transactions": "Transacciones"
},
"billing": {
"title": "Facturación",
"currentPlan": "Plan Actual",
@ -2090,7 +2238,8 @@
"trialing": "En Prueba",
"canceled": "Cancelada",
"expired": "Expirada",
"past_due": "Pago Pendiente"
"past_due": "Pago Pendiente",
"pending": "Pendiente"
},
"invoiceStatus": {
"paid": "Pagada",
@ -2104,12 +2253,120 @@
"subscriptionConfirmed": "¡Suscripción confirmada con éxito!",
"confirmError": "Error al confirmar la suscripción",
"subscriptionCanceled": "Suscripción cancelada",
"subscriptionCanceledRefunded": "Suscripción cancelada y reembolso procesado",
"cancelError": "Error al cancelar la suscripción",
"cancelConfirmTitle": "¿Cancelar suscripción?",
"cancelConfirmMessage": "¿Estás seguro de que deseas cancelar tu suscripción?",
"cancelNote1": "Mantendrás acceso hasta el final del período actual",
"cancelNote2": "Tus datos no se eliminarán",
"cancelNote3": "Puedes reactivar tu suscripción en cualquier momento",
"confirmCancel": "Sí, Cancelar"
"confirmCancel": "Sí, Cancelar",
"guaranteePeriod": "Período de Garantía",
"guaranteeMessage": "Te quedan {{days}} día(s) en el período de garantía de 7 días. Puedes cancelar y recibir un reembolso total.",
"guaranteeBadge": "Garantía: {{days}} días",
"requestRefund": "Solicitar reembolso total",
"refundNote": "El reembolso será procesado por PayPal en 5-10 días hábiles.",
"cancelAndRefund": "Cancelar y Reembolsar"
},
"landing": {
"nav": {
"features": "Funcionalidades",
"pricing": "Precios",
"faq": "FAQ",
"login": "Iniciar Sesión",
"register": "Empezar Ahora"
},
"hero": {
"title": "Toma el Control de tus Finanzas",
"subtitle": "Gestión financiera inteligente para personas y empresas. Controla ingresos, gastos y alcanza tus metas financieras.",
"cta": "Comenzar Ahora",
"learnMore": "Saber Más",
"secure": "100% Seguro"
},
"features": {
"title": "Todo lo que Necesitas",
"subtitle": "Herramientas potentes para gestionar tu dinero",
"accounts": {
"title": "Múltiples Cuentas",
"description": "Gestiona cuentas bancarias, tarjetas y efectivo en un solo lugar"
},
"analytics": {
"title": "Reportes Detallados",
"description": "Gráficos y análisis para entender tus gastos"
},
"categories": {
"title": "Categorías Inteligentes",
"description": "Categorización automática con palabras clave y subcategorías"
},
"import": {
"title": "Importación Bancaria",
"description": "Importa extractos de Excel, CSV, OFX y PDF"
},
"recurring": {
"title": "Transacciones Recurrentes",
"description": "Automatiza facturas, suscripciones e ingresos recurrentes"
},
"security": {
"title": "Seguridad Total",
"description": "Cifrado de nivel bancario para proteger tus datos"
}
},
"pricing": {
"title": "Planes Simples, Precios Justos",
"subtitle": "Elige el plan que se adapte a tus necesidades",
"monthly": "Mensual",
"annual": "Anual",
"popular": "Más Popular",
"month": "mes",
"year": "año",
"free": "Gratis",
"startFree": "Comenzar Ahora",
"subscribe": "Suscribirse Ahora",
"billedAnnually": "Facturado anualmente €{{price}}",
"comingSoon": "Próximamente",
"forPymes": "Herramientas para PyMEs",
"features": {
"oneAccount": "1 cuenta bancaria",
"tenCategories": "10 categorías",
"hundredSubcategories": "100 subcategorías",
"thousandTransactions": "1.000 transacciones",
"unlimitedAccounts": "Cuentas ilimitadas",
"unlimitedCategories": "Categorías ilimitadas",
"unlimitedTransactions": "Transacciones ilimitadas",
"multiUsers": "Múltiples usuarios",
"integratedBilling": "Facturación integrada",
"advancedReports": "Reportes avanzados",
"cashFlow": "Gestión de flujo de caja",
"budgetControl": "Control de presupuesto por proyecto",
"businessModule": "Módulo de negocios",
"prioritySupport": "Soporte prioritario"
},
"goldTeaser": {
"title": "Plan GOLD Próximamente",
"description": "Sincronización online directa con tu banco. Conecta tus cuentas y mira tus transacciones actualizadas automáticamente en tiempo real."
}
},
"faq": {
"title": "Preguntas Frecuentes",
"q1": "¿Mis datos están seguros?",
"a1": "¡Sí! Utilizamos cifrado de nivel bancario (SSL/TLS) y tus datos se almacenan en servidores seguros con copias de seguridad regulares. Nunca compartimos tu información con terceros.",
"q2": "¿Puedo cancelar cuando quiera?",
"a2": "Sí, puedes cancelar tu suscripción en cualquier momento sin cargos. Mantendrás el acceso hasta el final del período que ya pagaste.",
"q3": "¿Qué bancos son compatibles?",
"a3": "Puedes importar extractos de cualquier banco que exporte a Excel, CSV, OFX o PDF. Tenemos mapeos predefinidos para los principales bancos.",
"q4": "¿Cómo funciona la garantía de 7 días?",
"a4": "Pagas con PayPal y obtienes acceso completo inmediato a todas las funcionalidades. Si no estás satisfecho, cancela en los primeros 7 días y recibirás un reembolso total, sin preguntas."
},
"cta": {
"title": "¿Listo para Transformar tus Finanzas?",
"subtitle": "Únete a miles de usuarios que ya tomaron el control de su dinero.",
"button": "Comenzar Ahora"
},
"footer": {
"rights": "Todos los derechos reservados.",
"privacy": "Política de Privacidad",
"terms": "Términos de Uso",
"contact": "Contacto"
}
}
}

View File

@ -45,6 +45,8 @@
"details": "Detalhes",
"clearFilters": "Limpar Filtros",
"select": "Selecionar",
"selection": "Seleção",
"selectIcon": "Selecionar Ícone",
"refresh": "Atualizar",
"filters": "Filtros",
"processing": "Processando...",
@ -67,7 +69,10 @@
"incomes": "Receitas",
"expenses": "Despesas",
"balance": "Saldo",
"current": "Atual"
"current": "Atual",
"continue": "Continuar",
"creating": "Criando...",
"remove": "Remover"
},
"auth": {
"login": "Entrar",
@ -79,7 +84,56 @@
"loginSuccess": "Login realizado com sucesso",
"loginError": "Erro ao fazer login",
"logoutSuccess": "Logout realizado com sucesso",
"invalidCredentials": "Credenciais inválidas"
"invalidCredentials": "Credenciais inválidas",
"createAccount": "Crie sua conta",
"backToLogin": "Voltar para login",
"goToLogin": "Ir para login"
},
"login": {
"noAccount": "Não tem uma conta?",
"createAccount": "Crie uma aqui",
"noSubscription": "Você não possui uma assinatura ativa. Por favor, complete o pagamento."
},
"errors": {
"connection": "Erro de conexão. Tente novamente.",
"resendFailed": "Erro ao reenviar email",
"subscriptionFailed": "Erro ao criar assinatura. Tente novamente."
},
"register": {
"selectPlan": "Selecione um plano",
"repeatPassword": "Repita sua senha",
"continueToPayment": "Continuar para pagamento",
"createAccount": "Criar Conta",
"alreadyHaveAccount": "Já tem uma conta?",
"loginHere": "Entre aqui",
"paymentCanceled": "O pagamento foi cancelado. Você pode tentar novamente."
},
"activate": {
"activating": "Ativando sua conta...",
"pleaseWait": "Por favor, aguarde enquanto ativamos sua conta.",
"successTitle": "Conta Ativada!",
"success": "Sua conta foi ativada com sucesso. Agora você pode usar o WEBMoney!",
"errorTitle": "Erro na Ativação",
"error": "Não foi possível ativar sua conta. O link pode ter expirado.",
"invalidLink": "Link de ativação inválido.",
"redirecting": "Redirecionando em {{seconds}} segundos...",
"goToDashboard": "Ir para o Painel",
"checkEmail": "Verifique seu email",
"checkEmailMessage": "Enviamos um email de ativação para {{email}}. Clique no link para ativar sua conta.",
"didntReceive": "Não recebeu o email?",
"resend": "Reenviar email",
"resendSuccess": "Email reenviado com sucesso!"
},
"payment": {
"confirming": "Confirmando pagamento...",
"pleaseWait": "Por favor, aguarde enquanto processamos seu pagamento.",
"successTitle": "Pagamento Confirmado!",
"successMessage": "Sua assinatura foi confirmada com sucesso.",
"checkYourEmail": "Verifique seu email!",
"activationSent": "Enviamos um email de ativação para {{email}}. Clique no link para ativar sua conta e começar a usar o WEBMoney.",
"errorTitle": "Erro no Pagamento",
"error": "Erro ao confirmar pagamento",
"noSubscriptionId": "ID da assinatura não encontrado"
},
"nav": {
"dashboard": "Painel",
@ -87,6 +141,7 @@
"liabilities": "Passivos",
"transactions": "Transações",
"movements": "Movimentações",
"registrations": "Cadastros",
"import": "Importar",
"duplicates": "Duplicatas",
"transfers": "Transferências",
@ -104,7 +159,8 @@
"goals": "Metas",
"budgets": "Orçamentos",
"billing": "Faturamento",
"pricing": "Planos"
"pricing": "Planos",
"users": "Usuários"
},
"dashboard": {
"title": "Painel de Controle",
@ -272,12 +328,18 @@
"deleteWarning": "Todas as subcategorias também serão excluídas.",
"categoryName": "Nome da Categoria",
"parentCategory": "Categoria Pai",
"noParent": "Sem categoria pai (raiz)",
"noParent": "Sem categoria pai",
"selectParent": "Mais categorias...",
"subcategories": "Subcategorias",
"keywords": "Palavras-chave",
"addKeyword": "Adicionar Palavra-chave",
"keywordPlaceholder": "Digite uma palavra-chave",
"keywordHelp": "Palavras-chave ajudam a categorizar transações automaticamente",
"keywordPlaceholder": "Digite e pressione Enter...",
"keywordHelp": "Ex: \"RESTAURANTE\", \"PIZZA\" - Transações com essas palavras são categorizadas automaticamente",
"noKeywords": "Sem palavras-chave. Transações serão categorizadas manualmente.",
"namePlaceholder": "Ex: Alimentação, Transporte...",
"descPlaceholder": "Descreva esta categoria...",
"visualSettings": "Aparência",
"autoCategorizationLabel": "Auto-categorização",
"types": {
"income": "Receita",
"expense": "Despesa",
@ -321,20 +383,23 @@
"expand": "Expandir",
"collapse": "Recolher",
"createSubcategory": "Criar Subcategoria",
"batchCategorize": "Categorizar em Lote",
"batchCategorize": "Categorização Automática",
"batchDescription": "Categorize transações automaticamente usando palavras-chave",
"analyzingTransactions": "Analisando transações...",
"uncategorized": "Sem Categoria",
"willCategorize": "Serão Categorizadas",
"willSkip": "Serão Ignoradas",
"totalKeywords": "Keywords Ativas",
"willSkip": "Sem Correspondência",
"totalKeywords": "Palavras-chave",
"previewTitle": "Prévia da Categorização",
"matchedKeyword": "Keyword Encontrada",
"executeBatch": "Executar Categorização",
"matchedKeyword": "Keyword",
"executeBatch": "Categorizar",
"batchSuccess": "transações categorizadas com sucesso",
"batchError": "Erro ao categorizar transações",
"nothingToCategorize": "Nenhuma transação para categorizar",
"batchPreviewError": "Erro ao carregar preview",
"previewError": "Erro ao carregar preview",
"noMatchesFound": "Nenhuma transação corresponde às palavras-chave configuradas",
"noMatchesFound": "Adicione palavras-chave às categorias para permitir categorização automática",
"noMatchesFoundTitle": "Nenhuma correspondência encontrada",
"categorized": "categorizadas",
"category": "Categoria"
},
@ -352,8 +417,13 @@
"budget": "Orçamento",
"keywords": "Palavras-chave",
"addKeyword": "Adicionar Palavra-chave",
"keywordPlaceholder": "Digite uma palavra-chave",
"keywordHelp": "Palavras-chave ajudam a atribuir transações automaticamente",
"keywordPlaceholder": "Digite e pressione Enter...",
"keywordHelp": "Ex: \"UBER\", \"iFood\" - Transações com essas palavras são atribuídas automaticamente",
"noKeywords": "Sem palavras-chave. Transações serão atribuídas manualmente.",
"namePlaceholder": "Ex: Projeto Alpha, Dpto. Marketing...",
"descPlaceholder": "Descreva o propósito deste centro de custo...",
"visualSettings": "Aparência",
"autoAssignLabel": "Auto-atribuição",
"createSuccess": "Centro de custo criado com sucesso",
"updateSuccess": "Centro de custo atualizado com sucesso",
"deleteSuccess": "Centro de custo excluído com sucesso",
@ -367,6 +437,8 @@
"liabilities": {
"title": "Contas Passivo",
"subtitle": "Gerenciamento de empréstimos e financiamentos",
"manage": "Gerenciar Passivos",
"noLiabilities": "Nenhum passivo cadastrado",
"importContract": "Importar Contrato",
"import": "Importar",
"importInfo": "Selecione um arquivo Excel (.xlsx) com a tabela de parcelas. O arquivo deve conter colunas para: Número da Parcela, Data de Vencimento, Valor da Cota, Juros, Capital e Estado.",
@ -515,6 +587,8 @@
"status": {
"label": "Status",
"pending": "Pendente",
"effective": "Efetivada",
"scheduled": "Agendada",
"completed": "Concluída",
"cancelled": "Cancelada"
},
@ -1710,6 +1784,7 @@
"subcategory": "Subcategoria",
"allCategory": "Toda a categoria",
"selectCategory": "Selecione uma categoria",
"general": "Geral",
"amount": "Valor",
"month": "Mês",
"budgeted": "Orçado",
@ -1731,6 +1806,57 @@
"yearly": "Anual",
"isCumulative": "Orçamento Cumulativo",
"isCumulativeHelp": "Acumula gastos desde o início do ano até o período atual",
"total": "Total",
"wizard": {
"title": "Assistente de Orçamentos",
"button": "Assistente",
"step1": "Modo",
"step2": "Categorias",
"step3": "Valores",
"step4": "Confirmar",
"quickStart": "Início Rápido com Templates",
"manual": "Criar Manualmente",
"manualDesc": "Escolha categorias e valores",
"copy": "Copiar de Outro Mês",
"copyDesc": "Reutilize orçamentos existentes",
"loadBudgets": "Carregar Orçamentos",
"noSourceBudgets": "Não há orçamentos neste período para copiar",
"selectCategories": "Selecione as Categorias",
"categoriesSelected": "categorias selecionadas",
"setAmounts": "Defina os Valores",
"history": "Hist.",
"useHistory": "Usar média histórica",
"confirm": "Confirme os Orçamentos",
"budgets": "Orçamentos",
"periods": "Períodos",
"periodHelp": "Define a frequência de criação automática dos orçamentos",
"createBudgets": "Criar Orçamentos",
"createBudget": "Criar Orçamento",
"successCount": "{{count}} orçamento(s) criado(s) com sucesso",
"errorCount": "{{count}} orçamento(s) não puderam ser criados (já existem)",
"fillRequired": "Preencha os campos obrigatórios",
"updated": "Orçamento atualizado com sucesso",
"created": "Orçamento criado com sucesso",
"selectAtLeast": "Selecione pelo menos uma categoria",
"templates": {
"basic": {
"name": "Orçamento Básico",
"desc": "Essencial para controle mensal"
},
"family": {
"name": "Orçamento Familiar",
"desc": "Completo para famílias"
},
"individual": {
"name": "Orçamento Individual",
"desc": "Para pessoa solteira"
},
"complete": {
"name": "Orçamento Completo",
"desc": "Todas as categorias"
}
}
},
"alert": {
"exceeded": "Orçamento excedido!",
"warning": "Atenção: próximo do limite",
@ -2025,6 +2151,7 @@
"lastNamePlaceholder": "Seu sobrenome",
"lastNameRequired": "O sobrenome é obrigatório",
"name": "Nome",
"namePlaceholder": "Seu nome",
"email": "E-mail",
"phone": "Telefone",
"phoneRequired": "O telefone é obrigatório",
@ -2059,6 +2186,7 @@
"billedAnnually": "Cobrado anualmente €{{price}}",
"save": "Economize {{percent}}%",
"trialDays": "{{days}} dias de teste grátis",
"trial": "de teste",
"mostPopular": "Mais Popular",
"currentPlan": "Plano Atual",
"startFree": "Começar Grátis",
@ -2071,6 +2199,16 @@
"securePayment": "Pagamento seguro",
"cancelAnytime": "Cancele quando quiser",
"paypalSecure": "Pagamento seguro com PayPal",
"comingSoon": "Em Breve",
"forPymes": "Ferramentas para PMEs",
"features": {
"multiUsers": "Múltiplos usuários",
"integratedBilling": "Faturamento integrado",
"advancedReports": "Relatórios avançados",
"apiAccess": "Acesso à API",
"prioritySupport": "Suporte prioritário",
"dedicatedManager": "Gerente de conta dedicado"
},
"faq": {
"title": "Perguntas Frequentes",
"q1": "Posso mudar de plano a qualquer momento?",
@ -2081,6 +2219,16 @@
"a3": "Oferecemos garantia de devolução de 30 dias. Se não estiver satisfeito, entre em contato para reembolso completo."
}
},
"planUsage": {
"title": "Uso do Plano",
"upgrade": "Fazer Upgrade",
"limitReached": "Você atingiu o limite do seu plano. Atualize para Pro para continuar.",
"limitWarning": "Você está perto do limite do seu plano.",
"accounts": "Contas",
"categories": "Categorias",
"budgets": "Orçamentos",
"transactions": "Transações"
},
"billing": {
"title": "Faturamento",
"currentPlan": "Plano Atual",
@ -2108,7 +2256,8 @@
"trialing": "Em Teste",
"canceled": "Cancelada",
"expired": "Expirada",
"past_due": "Pagamento Pendente"
"past_due": "Pagamento Pendente",
"pending": "Pendente"
},
"invoiceStatus": {
"paid": "Paga",
@ -2122,12 +2271,120 @@
"subscriptionConfirmed": "Assinatura confirmada com sucesso!",
"confirmError": "Erro ao confirmar assinatura",
"subscriptionCanceled": "Assinatura cancelada",
"subscriptionCanceledRefunded": "Assinatura cancelada e reembolso processado",
"cancelError": "Erro ao cancelar assinatura",
"cancelConfirmTitle": "Cancelar assinatura?",
"cancelConfirmMessage": "Tem certeza que deseja cancelar sua assinatura?",
"cancelNote1": "Você manterá acesso até o final do período atual",
"cancelNote2": "Seus dados não serão excluídos",
"cancelNote3": "Você pode reativar sua assinatura a qualquer momento",
"confirmCancel": "Sim, Cancelar"
"confirmCancel": "Sim, Cancelar",
"guaranteePeriod": "Período de Garantia",
"guaranteeMessage": "Você ainda tem {{days}} dia(s) restantes no período de garantia de 7 dias. Você pode cancelar e receber um reembolso total.",
"guaranteeBadge": "Garantia: {{days}} dias",
"requestRefund": "Solicitar reembolso total",
"refundNote": "O reembolso será processado pelo PayPal em 5-10 dias úteis.",
"cancelAndRefund": "Cancelar e Reembolsar"
},
"landing": {
"nav": {
"features": "Recursos",
"pricing": "Preços",
"faq": "FAQ",
"login": "Entrar",
"register": "Começar Agora"
},
"hero": {
"title": "Assuma o Controle das suas Finanças",
"subtitle": "Gestão financeira inteligente para pessoas e empresas. Acompanhe receitas, despesas e alcance seus objetivos financeiros.",
"cta": "Começar Agora",
"learnMore": "Saiba Mais",
"secure": "100% Seguro"
},
"features": {
"title": "Tudo que Você Precisa",
"subtitle": "Ferramentas poderosas para gerenciar seu dinheiro",
"accounts": {
"title": "Múltiplas Contas",
"description": "Gerencie contas bancárias, cartões e dinheiro em um só lugar"
},
"analytics": {
"title": "Relatórios Detalhados",
"description": "Gráficos e análises para entender seus gastos"
},
"categories": {
"title": "Categorias Inteligentes",
"description": "Categorização automática com palavras-chave e subcategorias"
},
"import": {
"title": "Importação Bancária",
"description": "Importe extratos de Excel, CSV, OFX e PDF"
},
"recurring": {
"title": "Transações Recorrentes",
"description": "Automatize contas, assinaturas e receitas recorrentes"
},
"security": {
"title": "Segurança Total",
"description": "Criptografia de nível bancário para proteger seus dados"
}
},
"pricing": {
"title": "Planos Simples, Preços Justos",
"subtitle": "Escolha o plano que atende às suas necessidades",
"monthly": "Mensal",
"annual": "Anual",
"popular": "Mais Popular",
"month": "mês",
"year": "ano",
"free": "Grátis",
"startFree": "Começar Grátis",
"subscribe": "Assinar Agora",
"billedAnnually": "Cobrado anualmente €{{price}}",
"comingSoon": "Em Breve",
"forPymes": "Ferramentas para PMEs",
"features": {
"oneAccount": "1 conta bancária",
"tenCategories": "10 categorias",
"hundredSubcategories": "100 subcategorias",
"thousandTransactions": "1.000 transações",
"unlimitedAccounts": "Contas ilimitadas",
"unlimitedCategories": "Categorias ilimitadas",
"unlimitedTransactions": "Transações ilimitadas",
"multiUsers": "Múltiplos usuários",
"integratedBilling": "Faturamento integrado",
"advancedReports": "Relatórios avançados",
"cashFlow": "Gestão de fluxo de caixa",
"budgetControl": "Controle de orçamento por projeto",
"businessModule": "Módulo de negócios",
"prioritySupport": "Suporte prioritário"
},
"goldTeaser": {
"title": "Plano GOLD Em Breve",
"description": "Sincronização online direta com seu banco. Conecte suas contas e veja suas transações atualizadas automaticamente em tempo real."
}
},
"faq": {
"title": "Perguntas Frequentes",
"q1": "Meus dados estão seguros?",
"a1": "Sim! Usamos criptografia de nível bancário (SSL/TLS) e seus dados são armazenados em servidores seguros com backups regulares. Nunca compartilhamos suas informações com terceiros.",
"q2": "Posso cancelar quando quiser?",
"a2": "Sim, você pode cancelar sua assinatura a qualquer momento sem taxas. Você manterá o acesso até o final do período que já pagou.",
"q3": "Quais bancos são compatíveis?",
"a3": "Você pode importar extratos de qualquer banco que exporte para Excel, CSV, OFX ou PDF. Temos mapeamentos predefinidos para os principais bancos.",
"q4": "Como funciona a garantia de 7 dias?",
"a4": "Você paga via PayPal e obtém acesso completo imediato a todos os recursos. Se não ficar satisfeito, cancele em até 7 dias e receba reembolso total, sem perguntas."
},
"cta": {
"title": "Pronto para Transformar suas Finanças?",
"subtitle": "Junte-se a milhares de usuários que já assumiram o controle do seu dinheiro.",
"button": "Criar Conta Grátis"
},
"footer": {
"rights": "Todos os direitos reservados.",
"privacy": "Política de Privacidade",
"terms": "Termos de Uso",
"contact": "Contato"
}
}
}

View File

@ -1899,6 +1899,16 @@ input[type="color"]::-webkit-color-swatch {
color: #94a3b8;
}
.txn-status-badge.effective {
background: rgba(16, 185, 129, 0.15);
color: #10b981;
}
.txn-status-badge.scheduled {
background: rgba(59, 130, 246, 0.15);
color: #3b82f6;
}
/* Actions dropdown */
.txn-actions-btn {
width: 28px;

View File

@ -1,12 +1,14 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { accountService, liabilityAccountService } from '../services/api';
import { accountService, liabilityAccountService, assetAccountService } from '../services/api';
import { useToast } from '../components/Toast';
import { ConfirmModal } from '../components/Modal';
import IconSelector from '../components/IconSelector';
import CurrencySelector from '../components/CurrencySelector';
import { useFormatters } from '../hooks';
import AssetWizard from '../components/AssetWizard';
import AccountWizard from '../components/AccountWizard';
const Accounts = () => {
const { t } = useTranslation();
@ -15,10 +17,17 @@ const Accounts = () => {
const { currency: formatCurrencyHook } = useFormatters();
const [accounts, setAccounts] = useState([]);
const [liabilityAccounts, setLiabilityAccounts] = useState([]);
const [assetAccounts, setAssetAccounts] = useState([]);
const [loading, setLoading] = useState(true);
const [showModal, setShowModal] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [showAdjustModal, setShowAdjustModal] = useState(false);
const [showAssetWizard, setShowAssetWizard] = useState(false);
const [showAccountWizard, setShowAccountWizard] = useState(false);
const [editingAccount, setEditingAccount] = useState(null);
const [showAssetDetail, setShowAssetDetail] = useState(false);
const [selectedAsset, setSelectedAsset] = useState(null);
const [editingAsset, setEditingAsset] = useState(null);
const [adjustAccount, setAdjustAccount] = useState(null);
const [targetBalance, setTargetBalance] = useState('');
const [adjusting, setAdjusting] = useState(false);
@ -27,6 +36,7 @@ const Accounts = () => {
const [recalculating, setRecalculating] = useState(false);
const [filter, setFilter] = useState({ type: '', is_active: '' });
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
const [activeTab, setActiveTab] = useState('accounts'); // 'accounts', 'liabilities' ou 'assets'
const [formData, setFormData] = useState({
name: '',
@ -68,9 +78,10 @@ const Accounts = () => {
if (filter.is_active !== '') params.is_active = filter.is_active;
// Carregar contas normais e passivas em paralelo
const [accountsResponse, liabilityResponse] = await Promise.all([
const [accountsResponse, liabilityResponse, assetResponse] = await Promise.all([
accountService.getAll(params),
liabilityAccountService.getAll({ is_active: filter.is_active || undefined })
liabilityAccountService.getAll({ is_active: filter.is_active || undefined }),
assetAccountService.getAll({ status: filter.is_active === '1' ? 'active' : undefined })
]);
if (accountsResponse.success) {
@ -79,6 +90,9 @@ const Accounts = () => {
if (liabilityResponse.success) {
setLiabilityAccounts(liabilityResponse.data);
}
if (assetResponse.success) {
setAssetAccounts(assetResponse.data);
}
} catch (error) {
toast.error(t('accounts.loadError'));
} finally {
@ -253,7 +267,56 @@ const Accounts = () => {
}
};
// Calcula totais agrupados por moeda (incluindo passivos como valor negativo)
// Abrir modal de detalhes do ativo
const handleOpenAssetDetail = async (asset) => {
try {
const response = await assetAccountService.getById(asset.id);
if (response.success) {
setSelectedAsset(response.data);
setShowAssetDetail(true);
}
} catch (error) {
toast.error('Erro ao carregar detalhes do ativo');
}
};
// Fechar modal de detalhes do ativo
const handleCloseAssetDetail = () => {
setShowAssetDetail(false);
setSelectedAsset(null);
};
// Editar ativo
const handleEditAsset = () => {
setEditingAsset(selectedAsset);
setShowAssetDetail(false);
setShowAssetWizard(true);
};
// Callback após salvar/criar ativo
const handleAssetSuccess = (assetData) => {
loadAccounts();
setEditingAsset(null);
toast.success(editingAsset ? 'Activo actualizado con éxito' : 'Activo creado con éxito');
};
// Callback após salvar/criar conta via wizard
const handleAccountWizardSuccess = (data, destinationType) => {
loadAccounts();
setEditingAccount(null);
const isEditing = !!editingAccount;
if (destinationType === 'asset') {
toast.success(isEditing ? 'Cuenta de ahorro actualizada' : 'Cuenta de ahorro creada como activo');
} else if (destinationType === 'liability') {
toast.success(isEditing ? 'Tarjeta de crédito actualizada' : 'Tarjeta de crédito creada como pasivo');
} else {
toast.success(isEditing ? t('accounts.updateSuccess') : t('accounts.createSuccess'));
}
};
// Calcula totais agrupados por moeda (incluindo passivos como valor negativo e ativos como positivo)
const getTotalsByCurrency = () => {
const totals = {};
@ -280,19 +343,49 @@ const Accounts = () => {
totals[currency] -= parseFloat(acc.remaining_balance || 0);
});
// Ativos (como valor positivo - current_value)
assetAccounts
.filter(acc => acc.status === 'active')
.forEach(acc => {
const currency = acc.currency || 'EUR';
if (!totals[currency]) {
totals[currency] = 0;
}
// Somar o valor atual do ativo
totals[currency] += parseFloat(acc.current_value || 0);
});
return totals;
};
// Total de contas ativas (normais + passivas)
// Total de contas ativas (normais + passivas + ativos)
const getTotalActiveAccounts = () => {
const normalActive = accounts.filter(a => a.is_active).length;
const liabilityActive = liabilityAccounts.filter(a => a.is_active).length;
return normalActive + liabilityActive;
const assetActive = assetAccounts.filter(a => a.status === 'active').length;
return normalActive + liabilityActive + assetActive;
};
// Total de todas as contas
const getTotalAccounts = () => {
return accounts.length + liabilityAccounts.length;
return accounts.length + liabilityAccounts.length + assetAccounts.length;
};
// Total de ativos por moeda
const getAssetTotalsByCurrency = () => {
const totals = {};
assetAccounts
.filter(acc => acc.status === 'active')
.forEach(acc => {
const currency = acc.currency || 'EUR';
if (!totals[currency]) {
totals[currency] = { current: 0, acquisition: 0, count: 0 };
}
totals[currency].current += parseFloat(acc.current_value || 0);
totals[currency].acquisition += parseFloat(acc.acquisition_value || 0);
totals[currency].count++;
});
return totals;
};
return (
@ -307,7 +400,7 @@ const Accounts = () => {
{!isMobile && <p className="text-slate-400 mb-0">{t('accounts.title')}</p>}
</div>
<div className="d-flex gap-2">
{!isMobile && (
{!isMobile && activeTab === 'accounts' && (
<button
className="btn btn-outline-secondary"
onClick={handleRecalculateBalances}
@ -322,14 +415,82 @@ const Accounts = () => {
{t('accounts.recalculate')}
</button>
)}
<button className={`btn btn-primary ${isMobile ? 'btn-sm' : ''}`} onClick={() => handleOpenModal()}>
<i className="bi bi-plus-lg me-2"></i>
{isMobile ? t('common.add') : t('accounts.newAccount')}
</button>
{activeTab === 'accounts' && (
<button className={`btn btn-primary ${isMobile ? 'btn-sm' : ''}`} onClick={() => setShowAccountWizard(true)}>
<i className="bi bi-plus-lg me-2"></i>
{isMobile ? t('common.add') : t('accounts.newAccount')}
</button>
)}
{activeTab === 'liabilities' && (
<button className={`btn btn-warning ${isMobile ? 'btn-sm' : ''}`} onClick={() => navigate('/liabilities')}>
<i className="bi bi-gear me-2"></i>
{isMobile ? t('common.manage') : t('liabilities.manage')}
</button>
)}
{activeTab === 'assets' && (
<button className={`btn btn-success ${isMobile ? 'btn-sm' : ''}`} onClick={() => setShowAssetWizard(true)}>
<i className="bi bi-plus-lg me-2"></i>
{isMobile ? t('common.add') : 'Nuevo Activo'}
</button>
)}
</div>
</div>
{/* Summary Cards */}
{/* Tabs - Mobile Optimized */}
<ul className={`nav nav-tabs mb-4 ${isMobile ? 'nav-fill flex-nowrap overflow-auto' : ''}`} style={{ borderBottom: '1px solid #334155' }}>
<li className="nav-item">
<button
className={`nav-link ${activeTab === 'accounts' ? 'active bg-primary text-white' : 'text-slate-400'} ${isMobile ? 'px-2 py-2' : ''}`}
onClick={() => setActiveTab('accounts')}
style={{
border: 'none',
borderRadius: '0.5rem 0.5rem 0 0',
backgroundColor: activeTab === 'accounts' ? undefined : 'transparent',
fontSize: isMobile ? '0.75rem' : undefined,
whiteSpace: 'nowrap'
}}
>
<i className={`bi bi-wallet2 ${isMobile ? '' : 'me-2'}`}></i>
{isMobile ? '' : t('nav.accounts')} ({accounts.length})
</button>
</li>
<li className="nav-item">
<button
className={`nav-link ${activeTab === 'liabilities' ? 'active bg-warning text-dark' : 'text-slate-400'} ${isMobile ? 'px-2 py-2' : ''}`}
onClick={() => setActiveTab('liabilities')}
style={{
border: 'none',
borderRadius: '0.5rem 0.5rem 0 0',
backgroundColor: activeTab === 'liabilities' ? undefined : 'transparent',
fontSize: isMobile ? '0.75rem' : undefined,
whiteSpace: 'nowrap'
}}
>
<i className={`bi bi-bank ${isMobile ? '' : 'me-2'}`}></i>
{isMobile ? '' : t('nav.liabilities')} ({liabilityAccounts.length})
</button>
</li>
<li className="nav-item">
<button
className={`nav-link ${activeTab === 'assets' ? 'active bg-success text-white' : 'text-slate-400'} ${isMobile ? 'px-2 py-2' : ''}`}
onClick={() => setActiveTab('assets')}
style={{
border: 'none',
borderRadius: '0.5rem 0.5rem 0 0',
backgroundColor: activeTab === 'assets' ? undefined : 'transparent',
fontSize: isMobile ? '0.75rem' : undefined,
whiteSpace: 'nowrap'
}}
>
<i className={`bi bi-graph-up-arrow ${isMobile ? '' : 'me-2'}`}></i>
{isMobile ? '' : 'Activos'} ({assetAccounts.length})
</button>
</li>
</ul>
{activeTab === 'accounts' && (
<>
{/* Summary Cards */}
<div className={`row ${isMobile ? 'g-2 mb-3' : 'mb-4'}`}>
{/* Total por Moeda */}
<div className="col-md-6">
@ -442,7 +603,7 @@ const Accounts = () => {
<div className="text-center py-5">
<i className="bi bi-wallet2 display-1 text-slate-600"></i>
<p className="text-slate-400 mt-3">{t('accounts.noAccounts')}</p>
<button className="btn btn-primary" onClick={() => handleOpenModal()}>
<button className="btn btn-primary" onClick={() => setShowAccountWizard(true)}>
<i className="bi bi-plus-lg me-2"></i>
{t('accounts.newAccount')}
</button>
@ -488,7 +649,10 @@ const Accounts = () => {
</button>
<button
className="btn btn-link text-info p-1"
onClick={() => handleOpenModal(account)}
onClick={() => {
setEditingAccount(account);
setShowAccountWizard(true);
}}
style={{ fontSize: '1rem' }}
>
<i className="bi bi-pencil"></i>
@ -590,7 +754,10 @@ const Accounts = () => {
</button>
<button
className="btn btn-link text-info p-1 me-1"
onClick={() => handleOpenModal(account)}
onClick={() => {
setEditingAccount(account);
setShowAccountWizard(true);
}}
title={t('common.edit')}
>
<i className="bi bi-pencil"></i>
@ -611,23 +778,15 @@ const Accounts = () => {
)}
</div>
</div>
</>
)}
{/* Liability Accounts Section */}
{liabilityAccounts.length > 0 && (filter.type === '' || filter.type === 'liability') && (
<div className="card border-0 mt-4" style={{ background: '#1e293b' }}>
<div className={`card-header border-0 d-flex justify-content-between align-items-center ${isMobile ? 'py-2 px-3' : ''}`} style={{ background: '#1e293b', borderBottom: '1px solid #334155' }}>
<h5 className={`mb-0 text-white ${isMobile ? 'fs-6' : ''}`}>
<i className="bi bi-file-earmark-text me-2 text-danger"></i>
{t('liabilities.title')}
</h5>
<button
className="btn btn-sm btn-outline-light"
onClick={() => navigate('/liabilities')}
>
<i className="bi bi-box-arrow-up-right me-1"></i>
{t('common.details')}
</button>
</div>
{/* Tab de Passivos */}
{activeTab === 'liabilities' && (
<>
{/* Liability Accounts Section */}
{liabilityAccounts.length > 0 ? (
<div className="card border-0" style={{ background: '#1e293b' }}>
<div className={`card-body ${isMobile ? 'p-2' : 'p-0'}`}>
{isMobile ? (
// Mobile: Cards Layout
@ -798,7 +957,244 @@ const Accounts = () => {
)}
</div>
</div>
) : (
<div className="card border-0" style={{ background: '#1e293b' }}>
<div className="card-body text-center py-5">
<i className="bi bi-bank display-1 text-slate-600"></i>
<p className="text-slate-400 mt-3">{t('liabilities.noLiabilities')}</p>
<button className="btn btn-primary" onClick={() => navigate('/liabilities')}>
<i className="bi bi-plus-lg me-2"></i>
{t('liabilities.import')}
</button>
</div>
</div>
)}
</>
)}
{/* Tab de Ativos */}
{activeTab === 'assets' && (
<>
{/* Summary Cards de Ativos */}
<div className={`row ${isMobile ? 'g-2 mb-3' : 'mb-4'}`}>
{Object.entries(getAssetTotalsByCurrency()).map(([currency, data]) => (
<div className="col-md-4" key={currency}>
<div className={`card border-0 ${!isMobile ? 'h-100' : ''}`} style={{ background: '#1e293b' }}>
<div className={`card-body ${isMobile ? 'p-3' : ''}`}>
<div className="d-flex justify-content-between align-items-start">
<div>
<p className={`text-slate-400 mb-1 ${isMobile ? '' : 'small'}`} style={isMobile ? { fontSize: '0.7rem' } : undefined}>
Activos ({currency}) - {data.count} items
</p>
<h4 className={`mb-0 text-success ${isMobile ? 'fs-5' : ''}`}>
{formatCurrency(data.current, currency)}
</h4>
{data.current !== data.acquisition && (
<small className={`${data.current > data.acquisition ? 'text-success' : 'text-danger'}`}>
<i className={`bi ${data.current > data.acquisition ? 'bi-arrow-up' : 'bi-arrow-down'} me-1`}></i>
{((data.current - data.acquisition) / data.acquisition * 100).toFixed(1)}% desde compra
</small>
)}
</div>
<div className={`rounded-circle bg-success bg-opacity-25 ${isMobile ? 'p-2' : 'p-3'}`}>
<i className={`bi bi-graph-up-arrow text-success ${isMobile ? 'fs-5' : 'fs-4'}`}></i>
</div>
</div>
</div>
</div>
</div>
))}
{Object.keys(getAssetTotalsByCurrency()).length === 0 && (
<div className="col-12">
<div className="card border-0" style={{ background: '#1e293b' }}>
<div className={`card-body ${isMobile ? 'p-3' : ''} text-center`}>
<i className="bi bi-graph-up-arrow text-slate-600 fs-1 mb-2"></i>
<p className="text-slate-400 mb-0">No hay activos registrados</p>
</div>
</div>
</div>
)}
</div>
{/* Lista de Ativos */}
<div className="card border-0" style={{ background: '#1e293b' }}>
<div className={`card-body ${isMobile ? 'p-2' : 'p-0'}`}>
{assetAccounts.length === 0 ? (
<div className="text-center py-5">
<i className="bi bi-graph-up-arrow display-1 text-slate-600"></i>
<p className="text-slate-400 mt-3">No hay activos registrados</p>
<button className="btn btn-success" onClick={() => setShowAssetWizard(true)}>
<i className="bi bi-plus-lg me-2"></i>
Crear Activo
</button>
</div>
) : isMobile ? (
// Mobile: Cards Layout para Ativos
<div className="d-flex flex-column gap-2">
{assetAccounts.map((asset) => (
<div
key={`asset-${asset.id}`}
className="card border-secondary"
style={{ cursor: 'pointer', background: '#0f172a' }}
>
<div className="card-body p-3">
{/* Header with Icon and Name */}
<div className="d-flex align-items-start gap-2 mb-2">
<div
className="rounded-circle d-flex align-items-center justify-content-center flex-shrink-0"
style={{
width: '35px',
height: '35px',
backgroundColor: (asset.color || '#10B981') + '25',
}}
>
<i className={`bi bi-${asset.asset_type === 'real_estate' ? 'house' : asset.asset_type === 'vehicle' ? 'truck' : asset.asset_type === 'investment' ? 'graph-up' : 'box'} fs-6`} style={{ color: asset.color || '#10B981' }}></i>
</div>
<div className="flex-grow-1" style={{ minWidth: 0 }}>
<div className="text-white fw-medium mb-1" style={{ fontSize: '0.85rem' }}>
{asset.name}
</div>
<div className="text-slate-400" style={{ fontSize: '0.65rem' }}>
{assetAccountService.statuses[asset.asset_type] || asset.asset_type}
</div>
</div>
<div>
<span className={`badge ${asset.status === 'active' ? 'bg-success' : 'bg-secondary'}`} style={{ fontSize: '0.65rem' }}>
{asset.status === 'active' ? 'Activo' : asset.status}
</span>
</div>
</div>
{/* Values */}
<div className="d-flex justify-content-between align-items-center pt-2" style={{ borderTop: '1px solid #334155' }}>
<div>
<div className="text-slate-400" style={{ fontSize: '0.65rem' }}>Valor Actual</div>
<div className="text-success fw-bold" style={{ fontSize: '0.9rem' }}>
{formatCurrency(asset.current_value, asset.currency)}
</div>
</div>
<div className="text-end">
<div className="text-slate-400" style={{ fontSize: '0.65rem' }}>Rentabilidad</div>
<div className={`fw-medium ${parseFloat(asset.current_value) >= parseFloat(asset.acquisition_value) ? 'text-success' : 'text-danger'}`} style={{ fontSize: '0.8rem' }}>
{asset.acquisition_value > 0 ? (
<>
<i className={`bi ${parseFloat(asset.current_value) >= parseFloat(asset.acquisition_value) ? 'bi-arrow-up' : 'bi-arrow-down'} me-1`}></i>
{(((parseFloat(asset.current_value) - parseFloat(asset.acquisition_value)) / parseFloat(asset.acquisition_value)) * 100).toFixed(1)}%
</>
) : '-'}
</div>
</div>
</div>
</div>
</div>
))}
</div>
) : (
// Desktop: Table Layout para Ativos
<div className="table-responsive">
<table className="table table-hover mb-0" style={{ '--bs-table-bg': 'transparent', backgroundColor: 'transparent' }}>
<thead style={{ backgroundColor: 'transparent' }}>
<tr style={{ borderBottom: '1px solid #334155', backgroundColor: 'transparent' }}>
<th className="text-slate-400 fw-normal py-3 ps-4" style={{ backgroundColor: 'transparent' }}>Nombre</th>
<th className="text-slate-400 fw-normal py-3" style={{ backgroundColor: 'transparent' }}>Tipo</th>
<th className="text-slate-400 fw-normal py-3 text-end" style={{ backgroundColor: 'transparent' }}>Adquisición</th>
<th className="text-slate-400 fw-normal py-3 text-end" style={{ backgroundColor: 'transparent' }}>Valor Actual</th>
<th className="text-slate-400 fw-normal py-3 text-center" style={{ backgroundColor: 'transparent' }}>Rentabilidad</th>
<th className="text-slate-400 fw-normal py-3 text-center" style={{ backgroundColor: 'transparent' }}>{t('common.status')}</th>
</tr>
</thead>
<tbody style={{ backgroundColor: 'transparent' }}>
{assetAccounts.map((asset) => {
const gainLoss = parseFloat(asset.current_value) - parseFloat(asset.acquisition_value);
const gainLossPercent = asset.acquisition_value > 0 ? (gainLoss / parseFloat(asset.acquisition_value)) * 100 : 0;
return (
<tr
key={`asset-${asset.id}`}
style={{ borderBottom: '1px solid #334155', backgroundColor: 'transparent', cursor: 'pointer' }}
onClick={() => handleOpenAssetDetail(asset)}
className="asset-row-hover"
>
<td className="py-3 ps-4" style={{ backgroundColor: 'transparent' }}>
<div className="d-flex align-items-center">
<div
className="rounded-circle d-flex align-items-center justify-content-center me-3"
style={{
width: '40px',
height: '40px',
backgroundColor: (asset.color || '#10B981') + '25',
}}
>
<i className={`bi bi-${asset.asset_type === 'real_estate' ? 'house' : asset.asset_type === 'vehicle' ? 'truck' : asset.asset_type === 'investment' ? 'graph-up' : 'box'}`} style={{ color: asset.color || '#10B981' }}></i>
</div>
<div>
<div className="text-white fw-medium">{asset.name}</div>
{asset.description && (
<small className="text-slate-400">{asset.description.substring(0, 30)}</small>
)}
</div>
</div>
</td>
<td className="py-3" style={{ backgroundColor: 'transparent' }}>
<span className="badge" style={{ backgroundColor: '#334155' }}>
{asset.asset_type === 'real_estate' ? 'Inmueble' :
asset.asset_type === 'vehicle' ? 'Vehículo' :
asset.asset_type === 'investment' ? 'Inversión' :
asset.asset_type === 'equipment' ? 'Equipamiento' :
asset.asset_type === 'receivable' ? 'Crédito' :
asset.asset_type === 'cash' ? 'Efectivo' :
asset.asset_type}
</span>
</td>
<td className="py-3 text-end text-slate-300" style={{ backgroundColor: 'transparent' }}>
{formatCurrency(asset.acquisition_value, asset.currency)}
</td>
<td className="py-3 text-end fw-medium text-success" style={{ backgroundColor: 'transparent' }}>
{formatCurrency(asset.current_value, asset.currency)}
</td>
<td className="py-3 text-center" style={{ backgroundColor: 'transparent' }}>
<span className={`fw-medium ${gainLoss >= 0 ? 'text-success' : 'text-danger'}`}>
<i className={`bi ${gainLoss >= 0 ? 'bi-arrow-up' : 'bi-arrow-down'} me-1`}></i>
{gainLossPercent.toFixed(1)}%
</span>
</td>
<td className="py-3 text-center" style={{ backgroundColor: 'transparent' }}>
<span className={`badge ${asset.status === 'active' ? 'bg-success' : 'bg-secondary'}`}>
{asset.status === 'active' ? 'Activo' : asset.status === 'disposed' ? 'Vendido' : asset.status}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
</div>
</>
)}
{/* Asset Wizard Modal */}
<AssetWizard
isOpen={showAssetWizard}
onClose={() => {
setShowAssetWizard(false);
setEditingAsset(null);
}}
onSuccess={handleAssetSuccess}
asset={editingAsset}
/>
{/* Account Wizard Modal */}
<AccountWizard
isOpen={showAccountWizard}
onClose={() => {
setShowAccountWizard(false);
setEditingAccount(null);
}}
onSuccess={handleAccountWizardSuccess}
account={editingAccount}
/>
{/* Modal de Criar/Editar */}
{showModal && (
@ -1088,6 +1484,259 @@ const Accounts = () => {
</div>
)}
{/* Modal de Detalhes do Ativo */}
{showAssetDetail && selectedAsset && (
<div className="modal fade show d-block" style={{ backgroundColor: 'rgba(0,0,0,0.8)' }}>
<div className="modal-dialog modal-lg modal-dialog-centered">
<div className="modal-content" style={{ backgroundColor: '#1e293b', border: '1px solid #334155' }}>
<div className="modal-header border-0">
<div className="d-flex align-items-center">
<div
className="rounded-circle d-flex align-items-center justify-content-center me-3"
style={{
width: '48px',
height: '48px',
backgroundColor: (selectedAsset.color || '#10B981') + '25',
}}
>
<i className={`bi bi-${selectedAsset.asset_type === 'real_estate' ? 'house' : selectedAsset.asset_type === 'vehicle' ? 'truck' : selectedAsset.asset_type === 'investment' ? 'graph-up' : 'box'} fs-4`} style={{ color: selectedAsset.color || '#10B981' }}></i>
</div>
<div>
<h5 className="modal-title text-white mb-0">{selectedAsset.name}</h5>
{selectedAsset.description && (
<small className="text-slate-400">{selectedAsset.description}</small>
)}
</div>
</div>
<button type="button" className="btn-close btn-close-white" onClick={handleCloseAssetDetail}></button>
</div>
<div className="modal-body">
{/* Valores principais */}
<div className="row g-3 mb-4">
<div className="col-md-4">
<div className="p-3 rounded" style={{ backgroundColor: '#0f172a' }}>
<div className="text-slate-400 small mb-1">Valor de Adquisición</div>
<div className="text-white fs-5 fw-bold">
{formatCurrency(selectedAsset.acquisition_value, selectedAsset.currency)}
</div>
</div>
</div>
<div className="col-md-4">
<div className="p-3 rounded" style={{ backgroundColor: '#0f172a' }}>
<div className="text-slate-400 small mb-1">Valor Actual</div>
<div className="text-success fs-5 fw-bold">
{formatCurrency(selectedAsset.current_value, selectedAsset.currency)}
</div>
</div>
</div>
<div className="col-md-4">
<div className="p-3 rounded" style={{ backgroundColor: '#0f172a' }}>
<div className="text-slate-400 small mb-1">Rentabilidad</div>
{(() => {
const gainLoss = parseFloat(selectedAsset.current_value) - parseFloat(selectedAsset.acquisition_value);
const gainLossPercent = selectedAsset.acquisition_value > 0 ? (gainLoss / parseFloat(selectedAsset.acquisition_value)) * 100 : 0;
return (
<div className={`fs-5 fw-bold ${gainLoss >= 0 ? 'text-success' : 'text-danger'}`}>
<i className={`bi ${gainLoss >= 0 ? 'bi-arrow-up' : 'bi-arrow-down'} me-1`}></i>
{formatCurrency(Math.abs(gainLoss), selectedAsset.currency)} ({gainLossPercent.toFixed(1)}%)
</div>
);
})()}
</div>
</div>
</div>
{/* Informações detalhadas */}
<div className="row g-3">
<div className="col-12">
<h6 className="text-white mb-3">
<i className="bi bi-info-circle me-2"></i>
Información del Activo
</h6>
</div>
<div className="col-md-6">
<div className="mb-3">
<span className="text-slate-400 small">Tipo de Activo</span>
<div className="text-white">
{selectedAsset.asset_type === 'real_estate' ? 'Inmueble' :
selectedAsset.asset_type === 'vehicle' ? 'Vehículo' :
selectedAsset.asset_type === 'investment' ? 'Inversión' :
selectedAsset.asset_type === 'equipment' ? 'Equipamiento' :
selectedAsset.asset_type === 'receivable' ? 'Crédito por Cobrar' :
selectedAsset.asset_type === 'cash' ? 'Efectivo' :
selectedAsset.asset_type}
</div>
</div>
</div>
<div className="col-md-6">
<div className="mb-3">
<span className="text-slate-400 small">Estado</span>
<div>
<span className={`badge ${selectedAsset.status === 'active' ? 'bg-success' : 'bg-secondary'}`}>
{selectedAsset.status === 'active' ? 'Activo' : selectedAsset.status === 'disposed' ? 'Vendido' : selectedAsset.status}
</span>
</div>
</div>
</div>
{selectedAsset.acquisition_date && (
<div className="col-md-6">
<div className="mb-3">
<span className="text-slate-400 small">Fecha de Adquisición</span>
<div className="text-white">
{new Date(selectedAsset.acquisition_date).toLocaleDateString('es-ES', { day: '2-digit', month: 'long', year: 'numeric' })}
</div>
</div>
</div>
)}
{/* Campos específicos por tipo */}
{selectedAsset.asset_type === 'real_estate' && (
<>
{selectedAsset.address && (
<div className="col-md-6">
<div className="mb-3">
<span className="text-slate-400 small">Dirección</span>
<div className="text-white">{selectedAsset.address}</div>
</div>
</div>
)}
{selectedAsset.city && (
<div className="col-md-6">
<div className="mb-3">
<span className="text-slate-400 small">Ciudad</span>
<div className="text-white">{selectedAsset.city}</div>
</div>
</div>
)}
{selectedAsset.property_area_m2 && (
<div className="col-md-6">
<div className="mb-3">
<span className="text-slate-400 small">Área</span>
<div className="text-white">{selectedAsset.property_area_m2} </div>
</div>
</div>
)}
</>
)}
{selectedAsset.asset_type === 'vehicle' && (
<>
{selectedAsset.vehicle_brand && (
<div className="col-md-6">
<div className="mb-3">
<span className="text-slate-400 small">Marca/Modelo</span>
<div className="text-white">{selectedAsset.vehicle_brand} {selectedAsset.vehicle_model}</div>
</div>
</div>
)}
{selectedAsset.vehicle_year && (
<div className="col-md-6">
<div className="mb-3">
<span className="text-slate-400 small">Año</span>
<div className="text-white">{selectedAsset.vehicle_year}</div>
</div>
</div>
)}
{selectedAsset.vehicle_plate && (
<div className="col-md-6">
<div className="mb-3">
<span className="text-slate-400 small">Matrícula</span>
<div className="text-white">{selectedAsset.vehicle_plate}</div>
</div>
</div>
)}
{selectedAsset.vehicle_mileage && (
<div className="col-md-6">
<div className="mb-3">
<span className="text-slate-400 small">Kilometraje</span>
<div className="text-white">{selectedAsset.vehicle_mileage.toLocaleString()} km</div>
</div>
</div>
)}
</>
)}
{selectedAsset.asset_type === 'investment' && (
<>
{selectedAsset.investment_type && (
<div className="col-md-6">
<div className="mb-3">
<span className="text-slate-400 small">Tipo de Inversión</span>
<div className="text-white">
{selectedAsset.investment_type === 'stocks' ? 'Acciones' :
selectedAsset.investment_type === 'bonds' ? 'Bonos' :
selectedAsset.investment_type === 'etf' ? 'ETF' :
selectedAsset.investment_type === 'mutual_fund' ? 'Fondo Mutuo' :
selectedAsset.investment_type === 'crypto' ? 'Criptomoneda' :
selectedAsset.investment_type === 'fixed_deposit' ? 'Depósito a Plazo' :
selectedAsset.investment_type}
</div>
</div>
</div>
)}
{selectedAsset.institution && (
<div className="col-md-6">
<div className="mb-3">
<span className="text-slate-400 small">Institución</span>
<div className="text-white">{selectedAsset.institution}</div>
</div>
</div>
)}
{selectedAsset.ticker && (
<div className="col-md-6">
<div className="mb-3">
<span className="text-slate-400 small">Ticker/Símbolo</span>
<div className="text-white">{selectedAsset.ticker}</div>
</div>
</div>
)}
{selectedAsset.quantity && (
<div className="col-md-6">
<div className="mb-3">
<span className="text-slate-400 small">Cantidad</span>
<div className="text-white">{selectedAsset.quantity}</div>
</div>
</div>
)}
{selectedAsset.interest_rate && (
<div className="col-md-6">
<div className="mb-3">
<span className="text-slate-400 small">Tasa de Interés</span>
<div className="text-white">{selectedAsset.interest_rate}%</div>
</div>
</div>
)}
{selectedAsset.maturity_date && (
<div className="col-md-6">
<div className="mb-3">
<span className="text-slate-400 small">Fecha de Vencimiento</span>
<div className="text-white">
{new Date(selectedAsset.maturity_date).toLocaleDateString('es-ES', { day: '2-digit', month: 'long', year: 'numeric' })}
</div>
</div>
</div>
)}
</>
)}
</div>
</div>
<div className="modal-footer border-0">
<button type="button" className="btn btn-primary me-2" onClick={handleEditAsset}>
<i className="bi bi-pencil me-2"></i>
Editar
</button>
<button type="button" className="btn btn-secondary" onClick={handleCloseAssetDetail}>
Cerrar
</button>
</div>
</div>
</div>
</div>
)}
{/* Modal de Confirmação de Exclusão */}
<ConfirmModal
show={showDeleteModal}

View File

@ -0,0 +1,125 @@
import React, { useState, useEffect } from 'react';
import { useSearchParams, useNavigate, Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import api from '../services/api';
import logo from '../assets/logo-white.png';
const ActivateAccount = () => {
const { t } = useTranslation();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const [status, setStatus] = useState('loading'); // loading, success, error
const [message, setMessage] = useState('');
const [countdown, setCountdown] = useState(5);
useEffect(() => {
const token = searchParams.get('token');
if (!token) {
setStatus('error');
setMessage(t('activate.invalidLink'));
return;
}
activateAccount(token);
}, [searchParams]);
useEffect(() => {
if (status === 'success' && countdown > 0) {
const timer = setTimeout(() => setCountdown(countdown - 1), 1000);
return () => clearTimeout(timer);
} else if (status === 'success' && countdown === 0) {
navigate('/dashboard');
}
}, [status, countdown, navigate]);
const activateAccount = async (token) => {
try {
const response = await api.post('/activate', { token });
if (response.data.success) {
// Save token and user to localStorage
localStorage.setItem('token', response.data.data.token);
localStorage.setItem('user', JSON.stringify(response.data.data.user));
setStatus('success');
setMessage(t('activate.success'));
} else {
setStatus('error');
setMessage(response.data.message || t('activate.error'));
}
} catch (error) {
setStatus('error');
setMessage(error.response?.data?.message || t('activate.error'));
}
};
return (
<div className="min-vh-100 d-flex align-items-center justify-content-center"
style={{ background: 'linear-gradient(135deg, #1a1a2e 0%, #16213e 100%)' }}>
<div className="container">
<div className="row justify-content-center">
<div className="col-md-6 col-lg-5">
<div className="card shadow-lg border-0">
<div className="card-body p-5 text-center">
<Link to="/">
<img src={logo} alt="WEBMoney" className="mb-4" style={{ height: '80px', width: 'auto' }} />
</Link>
{status === 'loading' && (
<>
<div className="spinner-border text-primary mb-4" role="status">
<span className="visually-hidden">{t('common.loading')}</span>
</div>
<h4 className="mb-3">{t('activate.activating')}</h4>
<p className="text-muted">{t('activate.pleaseWait')}</p>
</>
)}
{status === 'success' && (
<>
<div className="mb-4">
<i className="bi bi-check-circle-fill text-success" style={{ fontSize: '80px' }}></i>
</div>
<h4 className="mb-3 text-success">{t('activate.successTitle')}</h4>
<p className="text-muted mb-4">{message}</p>
<div className="alert alert-info">
<i className="bi bi-clock me-2"></i>
{t('activate.redirecting', { seconds: countdown })}
</div>
<Link to="/dashboard" className="btn btn-primary btn-lg w-100">
<i className="bi bi-speedometer2 me-2"></i>
{t('activate.goToDashboard')}
</Link>
</>
)}
{status === 'error' && (
<>
<div className="mb-4">
<i className="bi bi-x-circle-fill text-danger" style={{ fontSize: '80px' }}></i>
</div>
<h4 className="mb-3 text-danger">{t('activate.errorTitle')}</h4>
<p className="text-muted mb-4">{message}</p>
<div className="d-grid gap-2">
<Link to="/login" className="btn btn-primary btn-lg">
<i className="bi bi-box-arrow-in-right me-2"></i>
{t('auth.login')}
</Link>
<Link to="/" className="btn btn-outline-secondary">
<i className="bi bi-house me-2"></i>
{t('common.back')}
</Link>
</div>
</>
)}
</div>
</div>
</div>
</div>
</div>
</div>
);
};
export default ActivateAccount;

View File

@ -16,6 +16,9 @@ export default function Billing() {
const [loading, setLoading] = useState(true);
const [canceling, setCanceling] = useState(false);
const [showCancelModal, setShowCancelModal] = useState(false);
const [requestRefund, setRequestRefund] = useState(false);
const [withinGuaranteePeriod, setWithinGuaranteePeriod] = useState(false);
const [guaranteeDaysRemaining, setGuaranteeDaysRemaining] = useState(0);
useEffect(() => {
// Handle subscription confirmation from PayPal return
@ -59,6 +62,8 @@ export default function Billing() {
if (statusResponse.data.success) {
setSubscription(statusResponse.data.data.subscription);
setPlan(statusResponse.data.data.plan);
setWithinGuaranteePeriod(statusResponse.data.data.within_guarantee_period || false);
setGuaranteeDaysRemaining(statusResponse.data.data.guarantee_days_remaining || 0);
}
// Load invoices
@ -79,11 +84,17 @@ export default function Billing() {
const handleCancelSubscription = async () => {
try {
setCanceling(true);
const response = await api.post('/subscription/cancel');
const response = await api.post('/subscription/cancel', {
request_refund: requestRefund && withinGuaranteePeriod,
});
if (response.data.success) {
showToast(t('billing.subscriptionCanceled'), 'success');
const message = response.data.data?.refunded
? t('billing.subscriptionCanceledRefunded', 'Assinatura cancelada e reembolso processado')
: t('billing.subscriptionCanceled');
showToast(message, 'success');
setShowCancelModal(false);
setRequestRefund(false);
loadData();
}
} catch (error) {
@ -162,6 +173,13 @@ export default function Billing() {
{t(`billing.status.${subscription.status}`)}
</span>
{withinGuaranteePeriod && (
<span className="badge bg-info me-2">
<i className="bi bi-shield-check me-1"></i>
{t('billing.guaranteeBadge', { days: guaranteeDaysRemaining, defaultValue: `Garantia: ${guaranteeDaysRemaining} dias` })}
</span>
)}
{subscription.status === 'trialing' && subscription.trial_ends_at && (
<small className="text-muted">
{t('billing.trialEnds', { date: formatDate(subscription.trial_ends_at) })}
@ -242,7 +260,7 @@ export default function Billing() {
{plan.features?.slice(0, Math.ceil(plan.features.length / 2)).map((feature, idx) => (
<li key={idx} className="mb-2">
<i className="bi bi-check-circle-fill text-success me-2"></i>
{feature}
{t(feature, feature)}
</li>
))}
</ul>
@ -252,7 +270,7 @@ export default function Billing() {
{plan.features?.slice(Math.ceil(plan.features.length / 2)).map((feature, idx) => (
<li key={idx} className="mb-2">
<i className="bi bi-check-circle-fill text-success me-2"></i>
{feature}
{t(feature, feature)}
</li>
))}
</ul>
@ -318,7 +336,7 @@ export default function Billing() {
<th>{t('billing.date')}</th>
<th>{t('billing.description')}</th>
<th className="text-end">{t('billing.amount')}</th>
<th>{t('billing.status')}</th>
<th>{t('common.status')}</th>
<th></th>
</tr>
</thead>
@ -326,12 +344,12 @@ export default function Billing() {
{invoices.map((invoice) => (
<tr key={invoice.id}>
<td>
<code>{invoice.invoice_number}</code>
<code>{invoice.number || '-'}</code>
</td>
<td>{formatDate(invoice.invoice_date)}</td>
<td>{formatDate(invoice.paid_at || invoice.created_at)}</td>
<td>{invoice.description || '-'}</td>
<td className="text-end">
{formatCurrency(invoice.total_amount, invoice.currency)}
{invoice.formatted_total || formatCurrency(invoice.total, invoice.currency)}
</td>
<td>
<span className={`badge ${getInvoiceStatusBadge(invoice.status)}`}>
@ -369,28 +387,60 @@ export default function Billing() {
<button
type="button"
className="btn-close"
onClick={() => setShowCancelModal(false)}
onClick={() => { setShowCancelModal(false); setRequestRefund(false); }}
></button>
</div>
<div className="modal-body">
<p>{t('billing.cancelConfirmMessage')}</p>
<ul className="text-muted">
{withinGuaranteePeriod ? (
<>
<div className="alert alert-info">
<i className="bi bi-info-circle me-2"></i>
<strong>{t('billing.guaranteePeriod', 'Período de garantia')}</strong>
<p className="mb-0 mt-1">
{t('billing.guaranteeMessage', {
days: guaranteeDaysRemaining,
defaultValue: `Você ainda tem ${guaranteeDaysRemaining} dia(s) restantes no período de garantia de 7 dias. Você pode cancelar e receber um reembolso total.`
})}
</p>
</div>
<div className="form-check mb-3">
<input
className="form-check-input"
type="checkbox"
id="requestRefund"
checked={requestRefund}
onChange={(e) => setRequestRefund(e.target.checked)}
/>
<label className="form-check-label" htmlFor="requestRefund">
<strong>{t('billing.requestRefund', 'Solicitar reembolso total')}</strong>
<small className="d-block text-muted">
{t('billing.refundNote', 'O reembolso será processado pela PayPal em até 5-10 dias úteis.')}
</small>
</label>
</div>
</>
) : (
<p>{t('billing.cancelConfirmMessage')}</p>
)}
<ul className="text-muted small">
<li>{t('billing.cancelNote1')}</li>
<li>{t('billing.cancelNote2')}</li>
<li>{t('billing.cancelNote3')}</li>
{!requestRefund && <li>{t('billing.cancelNote3')}</li>}
</ul>
</div>
<div className="modal-footer">
<button
type="button"
className="btn btn-secondary"
onClick={() => setShowCancelModal(false)}
onClick={() => { setShowCancelModal(false); setRequestRefund(false); }}
>
{t('common.cancel')}
</button>
<button
type="button"
className="btn btn-danger"
className={`btn ${requestRefund ? 'btn-warning' : 'btn-danger'}`}
onClick={handleCancelSubscription}
disabled={canceling}
>
@ -399,6 +449,8 @@ export default function Billing() {
<span className="spinner-border spinner-border-sm me-2"></span>
{t('common.processing')}
</>
) : requestRefund ? (
t('billing.cancelAndRefund', 'Cancelar e Reembolsar')
) : (
t('billing.confirmCancel')
)}

View File

@ -4,6 +4,7 @@ import { budgetService, categoryService, costCenterService } from '../services/a
import useFormatters from '../hooks/useFormatters';
import { getCurrencyByCode } from '../config/currencies';
import ConfirmModal from '../components/ConfirmModal';
import BudgetWizard from '../components/BudgetWizard';
const Budgets = () => {
const { t } = useTranslation();
@ -21,14 +22,7 @@ const Budgets = () => {
const [deleteBudget, setDeleteBudget] = useState(null);
const [yearSummary, setYearSummary] = useState(null);
const [primaryCurrency, setPrimaryCurrency] = useState('EUR');
const [formData, setFormData] = useState({
category_id: '',
subcategory_id: '',
cost_center_id: '',
amount: '',
period_type: 'monthly',
is_cumulative: false,
});
const [showWizard, setShowWizard] = useState(false);
// Meses con i18n
const getMonths = () => [
@ -92,29 +86,6 @@ const Budgets = () => {
}
};
const handleSubmit = async (e) => {
e.preventDefault();
try {
const data = {
...formData,
year,
month,
};
if (editingBudget) {
await budgetService.update(editingBudget.id, data);
} else {
await budgetService.create(data);
}
setShowModal(false);
setEditingBudget(null);
resetForm();
loadData();
} catch (error) {
console.error('Error saving budget:', error);
}
};
const handleDelete = async () => {
if (!deleteBudget) return;
try {
@ -128,11 +99,6 @@ const Budgets = () => {
const handleEdit = (budget) => {
setEditingBudget(budget);
setFormData({
category_id: budget.category_id,
subcategory_id: budget.subcategory_id || '',
amount: budget.amount,
});
setShowModal(true);
};
@ -151,20 +117,8 @@ const Budgets = () => {
}
};
const resetForm = () => {
setFormData({
category_id: '',
subcategory_id: '',
cost_center_id: '',
amount: '',
period_type: 'monthly',
is_cumulative: false,
});
};
const openNewBudget = () => {
setEditingBudget(null);
resetForm();
setShowModal(true);
};
@ -246,6 +200,14 @@ const Budgets = () => {
<i className="bi bi-copy me-1"></i>
{t('budgets.copyToNext')}
</button>
<button
className="btn btn-outline-primary"
onClick={() => setShowWizard(true)}
title={t('budgets.wizard.title') || 'Assistente de Orçamentos'}
>
<i className="bi bi-magic me-1"></i>
{t('budgets.wizard.button') || 'Assistente'}
</button>
<button className="btn btn-primary" onClick={openNewBudget}>
<i className="bi bi-plus-lg me-1"></i>
{t('budgets.addBudget')}
@ -586,280 +548,19 @@ const Budgets = () => {
</div>
)}
{/* Budget Form Modal */}
{showModal && (
<div className="modal show d-block" style={{ backgroundColor: 'rgba(0,0,0,0.7)' }}>
<div className="modal-dialog modal-dialog-centered">
<div className="modal-content border-0" style={{ background: '#1e293b' }}>
<div className="modal-header border-0">
<h5 className="modal-title text-white">
<i className={`bi ${editingBudget ? 'bi-pencil' : 'bi-plus-circle'} me-2`}></i>
{editingBudget ? t('budgets.editBudget') : t('budgets.newBudget')}
</h5>
<button
type="button"
className="btn-close btn-close-white"
onClick={() => setShowModal(false)}
></button>
</div>
<form onSubmit={handleSubmit}>
<div className="modal-body" style={{ maxHeight: '70vh', overflowY: 'auto' }}>
<p className="text-slate-400 small mb-3">
<i className="bi bi-calendar3 me-1"></i>
{months.find(m => m.value === month)?.label} {year}
</p>
{/* Category Selection - Grid style like transactions */}
<div className="mb-3">
<label className="form-label text-slate-400">{t('budgets.category')} *</label>
{editingBudget ? (
<input
type="text"
className="form-control bg-dark border-secondary text-white"
value={editingBudget.category?.name || ''}
disabled
/>
) : (
<>
{availableCategories.length === 0 ? (
<div className="alert alert-warning py-2 mb-0">
<i className="bi bi-info-circle me-2"></i>
{t('budgets.allCategoriesUsed')}
</div>
) : (
<div className="category-grid" style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(120px, 1fr))',
gap: '8px',
maxHeight: '200px',
overflowY: 'auto',
padding: '4px'
}}>
{availableCategories.map(cat => (
<div
key={cat.id}
onClick={() => setFormData({...formData, category_id: cat.id, subcategory_id: ''})}
className={`p-2 rounded text-center cursor-pointer ${
formData.category_id == cat.id
? 'border border-primary'
: 'border border-secondary'
}`}
style={{
background: formData.category_id == cat.id ? 'rgba(59, 130, 246, 0.2)' : '#0f172a',
cursor: 'pointer',
transition: 'all 0.2s'
}}
>
<i
className={`bi ${cat.icon || 'bi-tag'} d-block mb-1`}
style={{
fontSize: '1.5rem',
color: cat.color || '#6b7280'
}}
></i>
<small className="text-white d-block text-truncate" title={cat.name}>
{cat.name}
</small>
</div>
))}
</div>
)}
</>
)}
</div>
{/* Subcategory Selection - Only if category selected and has subcategories */}
{formData.category_id && !editingBudget && (() => {
const selectedCategory = availableCategories.find(c => c.id == formData.category_id);
const subcategories = selectedCategory?.subcategories || [];
if (subcategories.length > 0) {
return (
<div className="mb-3">
<label className="form-label text-slate-400">
{t('budgets.subcategory')}
<small className="text-muted ms-2">({t('common.optional')})</small>
</label>
<div className="category-grid" style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(120px, 1fr))',
gap: '8px',
maxHeight: '200px',
overflowY: 'auto',
padding: '4px'
}}>
{/* Option: All category (no subcategory) */}
<div
onClick={() => setFormData({...formData, subcategory_id: ''})}
className={`p-2 rounded text-center cursor-pointer ${
!formData.subcategory_id
? 'border border-primary'
: 'border border-secondary'
}`}
style={{
background: !formData.subcategory_id ? 'rgba(59, 130, 246, 0.2)' : '#0f172a',
cursor: 'pointer',
transition: 'all 0.2s'
}}
>
<i
className={`bi ${selectedCategory?.icon || 'bi-tag'} d-block mb-1`}
style={{
fontSize: '1.5rem',
color: selectedCategory?.color || '#6b7280'
}}
></i>
<small className="text-white d-block text-truncate">
{t('budgets.allCategory')}
</small>
</div>
{/* Subcategories */}
{subcategories.map(sub => (
<div
key={sub.id}
onClick={() => setFormData({...formData, subcategory_id: sub.id})}
className={`p-2 rounded text-center cursor-pointer ${
formData.subcategory_id == sub.id
? 'border border-primary'
: 'border border-secondary'
}`}
style={{
background: formData.subcategory_id == sub.id ? 'rgba(59, 130, 246, 0.2)' : '#0f172a',
cursor: 'pointer',
transition: 'all 0.2s'
}}
>
<i
className={`bi ${sub.icon || 'bi-tag'} d-block mb-1`}
style={{
fontSize: '1.5rem',
color: sub.color || '#6b7280'
}}
></i>
<small className="text-white d-block text-truncate" title={sub.name}>
{sub.name}
</small>
</div>
))}
</div>
</div>
);
}
return null;
})()}
{/* Cost Center Selection - Only if editing or creating new */}
{!editingBudget && costCenters.length > 0 && (
<div className="mb-3">
<label className="form-label text-slate-400">
{t('budgets.costCenter')}
<small className="text-muted ms-2">({t('common.optional')})</small>
</label>
<select
className="form-select bg-dark border-secondary text-white"
value={formData.cost_center_id || ''}
onChange={(e) => setFormData({...formData, cost_center_id: e.target.value})}
>
<option value="">{t('budgets.noCostCenter')}</option>
{costCenters.map(cc => (
<option key={cc.id} value={cc.id}>
{cc.name}
</option>
))}
</select>
</div>
)}
{/* Amount */}
<div className="mb-3">
<label className="form-label text-slate-400">{t('budgets.amount')} *</label>
<div className="input-group">
<span className="input-group-text bg-dark border-secondary text-white">
{getCurrencyByCode(primaryCurrency)?.symbol || '€'}
</span>
<input
type="number"
step="0.01"
min="0.01"
className="form-control bg-dark border-secondary text-white"
value={formData.amount}
onChange={(e) => setFormData({...formData, amount: e.target.value})}
placeholder="0.00"
required
/>
</div>
</div>
{/* Period Type */}
{!editingBudget && (
<div className="mb-3">
<label className="form-label text-slate-400">{t('budgets.periodType')}</label>
<select
className="form-select bg-dark border-secondary text-white"
value={formData.period_type || 'monthly'}
onChange={(e) => setFormData({...formData, period_type: e.target.value})}
>
<option value="monthly">{t('budgets.monthly')}</option>
<option value="bimestral">{t('budgets.bimestral')}</option>
<option value="trimestral">{t('budgets.trimestral')}</option>
<option value="semestral">{t('budgets.semestral')}</option>
<option value="yearly">{t('budgets.yearly')}</option>
</select>
</div>
)}
{/* Cumulative Option */}
{!editingBudget && (
<div className="mb-3">
<div className="form-check form-switch">
<input
className="form-check-input"
type="checkbox"
id="isCumulativeCheck"
checked={formData.is_cumulative || false}
onChange={(e) => setFormData({...formData, is_cumulative: e.target.checked})}
/>
<label className="form-check-label text-slate-400" htmlFor="isCumulativeCheck">
{t('budgets.isCumulative')}
<small className="d-block text-muted">{t('budgets.isCumulativeHelp')}</small>
</label>
</div>
</div>
)}
{/* Info about auto-propagation */}
{!editingBudget && (
<div className="alert alert-info py-2 mb-0">
<small>
<i className="bi bi-info-circle me-1"></i>
{t('budgets.autoPropagateInfo')}
</small>
</div>
)}
</div>
<div className="modal-footer border-0">
<button
type="button"
className="btn btn-outline-secondary"
onClick={() => setShowModal(false)}
>
{t('common.cancel')}
</button>
<button
type="submit"
className="btn btn-primary"
disabled={!editingBudget && (!formData.category_id || !formData.amount)}
>
<i className="bi bi-check-lg me-1"></i>
{t('common.save')}
</button>
</div>
</form>
</div>
</div>
</div>
)}
{/* Budget Form Modal - Using BudgetWizard with mode='single' */}
<BudgetWizard
isOpen={showModal}
onClose={() => {
setShowModal(false);
setEditingBudget(null);
}}
onSuccess={loadData}
year={year}
month={month}
mode="single"
editBudget={editingBudget}
/>
{/* Delete Confirmation */}
<ConfirmModal
@ -871,6 +572,15 @@ const Budgets = () => {
confirmText={t('common.delete')}
variant="danger"
/>
{/* Budget Wizard */}
<BudgetWizard
isOpen={showWizard}
onClose={() => setShowWizard(false)}
onSuccess={loadData}
year={year}
month={month}
/>
</div>
);
};

View File

@ -473,40 +473,84 @@ const Categories = () => {
)}
</div>
{/* Modal de Criar/Editar */}
{/* Modal de Criar/Editar - Design Elegante */}
{showModal && (
<div className="modal show d-block" style={{ backgroundColor: 'rgba(0,0,0,0.7)' }}>
<div className="modal-dialog modal-lg modal-dialog-centered">
<div className="modal-content" style={{ background: '#1e293b' }}>
<div className="modal-header border-bottom" style={{ borderColor: '#334155 !important' }}>
<h5 className="modal-title text-white">
<i className={`bi ${selectedItem ? 'bi-pencil' : 'bi-plus-circle'} me-2`}></i>
{selectedItem ? t('categories.editCategory') : formData.parent_id ? t('categories.createSubcategory') : t('categories.newCategory')}
</h5>
<div className="modal show d-block" style={{ backgroundColor: 'rgba(0,0,0,0.8)' }}>
<div className="modal-dialog modal-dialog-centered modal-lg">
<div className="modal-content border-0" style={{ background: '#1e293b', maxHeight: '90vh' }}>
{/* Header elegante */}
<div className="modal-header border-0 pb-0">
<div>
<h5 className="modal-title text-white mb-1">
<i className={`bi ${selectedItem ? 'bi-pencil-square' : formData.parent_id ? 'bi-diagram-3' : 'bi-plus-circle-dotted'} me-2 text-info`}></i>
{selectedItem ? t('categories.editCategory') : formData.parent_id ? t('categories.createSubcategory') : t('categories.newCategory')}
</h5>
<p className="text-slate-400 mb-0 small">
{formData.parent_id
? `${t('categories.parentCategory')}: ${flatCategories.find(c => c.id == formData.parent_id)?.name || ''}`
: t('categories.title')
}
</p>
</div>
<button type="button" className="btn-close btn-close-white" onClick={handleCloseModal}></button>
</div>
<form onSubmit={handleSubmit}>
<div className="modal-body">
<div className="row g-3">
{/* Nome */}
<div className="col-md-6">
<label className="form-label text-slate-300">{t('common.name')} *</label>
<div className="modal-body pt-3" style={{ maxHeight: '65vh', overflowY: 'auto' }}>
{/* Preview Card */}
<div className="mb-4 p-3 rounded-3" style={{ background: '#0f172a' }}>
<div className="d-flex align-items-center">
<div
className="rounded-circle d-flex align-items-center justify-content-center me-3"
style={{
width: 50,
height: 50,
background: `${formData.color}25`,
border: `2px solid ${formData.color}`,
}}
>
<i className={`bi ${formData.icon}`} style={{ fontSize: '1.3rem', color: formData.color }}></i>
</div>
<div>
<h6 className="text-white mb-0">{formData.name || t('categories.newCategory')}</h6>
<small className="text-slate-400">{formData.description || t('common.description')}</small>
</div>
<div className="ms-auto">
<span className={`badge bg-${getTypeColor(formData.type)} bg-opacity-25 text-${getTypeColor(formData.type)}`}>
{categoryTypes[formData.type]}
</span>
</div>
</div>
</div>
{/* Nome e Tipo - Linha principal */}
<div className="row g-3 mb-4">
<div className="col-md-8">
<label className="form-label text-white fw-medium mb-2">
<i className="bi bi-type me-2 text-primary"></i>
{t('common.name')} *
</label>
<input
type="text"
className="form-control bg-dark text-white border-secondary"
className="form-control bg-dark text-white border-0"
style={{ background: '#0f172a' }}
name="name"
value={formData.name}
onChange={handleChange}
placeholder="Ex: Alimentação, Moradia..."
placeholder={t('categories.namePlaceholder') || 'Ex: Alimentación, Transporte...'}
required
autoFocus
/>
</div>
{/* Tipo */}
<div className="col-md-3">
<label className="form-label text-slate-300">{t('common.type')} *</label>
<div className="col-md-4">
<label className="form-label text-white fw-medium mb-2">
<i className="bi bi-arrow-left-right me-2 text-warning"></i>
{t('common.type')} *
</label>
<select
className="form-select bg-dark text-white border-secondary"
className="form-select bg-dark text-white border-0"
style={{ background: '#0f172a' }}
name="type"
value={formData.type}
onChange={handleChange}
@ -517,145 +561,218 @@ const Categories = () => {
))}
</select>
</div>
</div>
{/* Categoria Pai */}
<div className="col-md-3">
<label className="form-label text-slate-300">{t('categories.parentCategory')}</label>
<select
className="form-select bg-dark text-white border-secondary"
name="parent_id"
value={formData.parent_id}
onChange={handleChange}
>
<option value="">{t('categories.noParent')}</option>
{flatCategories
.filter(c => c.id !== selectedItem?.id)
.map((cat) => (
<option key={cat.id} value={cat.id}>{cat.name}</option>
))}
</select>
</div>
{/* Cor */}
<div className="col-md-3">
<label className="form-label text-slate-300">{t('common.color')}</label>
<input
type="color"
className="form-control form-control-color bg-dark border-secondary w-100"
name="color"
value={formData.color}
onChange={handleChange}
/>
</div>
{/* Ícone */}
<div className="col-md-5">
<label className="form-label text-slate-300">{t('common.icon')}</label>
<IconSelector
value={formData.icon}
onChange={(icon) => setFormData(prev => ({ ...prev, icon }))}
type="category"
/>
</div>
{/* Status */}
<div className="col-md-4">
<label className="form-label text-slate-300">&nbsp;</label>
<div className="form-check mt-2">
<input
type="checkbox"
className="form-check-input"
id="is_active"
name="is_active"
checked={formData.is_active}
onChange={handleChange}
/>
<label className="form-check-label text-slate-300" htmlFor="is_active">
{t('common.active')}
</label>
{/* Visual - Cor e Ícone */}
<div className="mb-4">
<label className="form-label text-white fw-medium mb-2">
<i className="bi bi-palette me-2 text-success"></i>
{t('categories.visualSettings') || 'Aparência'}
</label>
<div className="row g-3">
<div className="col-4">
<div className="p-3 rounded text-center" style={{ background: '#0f172a' }}>
<label className="text-slate-400 small d-block mb-2">{t('common.color')}</label>
<input
type="color"
className="form-control form-control-color mx-auto border-0"
style={{ width: 50, height: 50, cursor: 'pointer', background: 'transparent' }}
name="color"
value={formData.color}
onChange={handleChange}
/>
</div>
</div>
<div className="col-8">
<div className="p-3 rounded h-100" style={{ background: '#0f172a' }}>
<label className="text-slate-400 small d-block mb-2">{t('common.icon')}</label>
<IconSelector
value={formData.icon}
onChange={(icon) => setFormData(prev => ({ ...prev, icon }))}
type="category"
/>
</div>
</div>
</div>
</div>
{/* Descrição */}
<div className="col-12">
<label className="form-label text-slate-300">{t('common.description')}</label>
<textarea
className="form-control bg-dark text-white border-secondary"
name="description"
value={formData.description}
onChange={handleChange}
rows="2"
placeholder="Descreva esta categoria..."
></textarea>
</div>
{/* Palavras-chave */}
<div className="col-12">
<label className="form-label text-slate-300">
<i className="bi bi-key me-1"></i>
{t('categories.keywordHelp')}
{/* Categoria Pai (se não for edição de uma categoria raiz) */}
{!selectedItem?.children?.length && (
<div className="mb-4">
<label className="form-label text-white fw-medium mb-2">
<i className="bi bi-diagram-2 me-2 text-info"></i>
{t('categories.parentCategory')}
<span className="badge bg-secondary ms-2" style={{ fontSize: '0.65rem' }}>{t('common.optional')}</span>
</label>
<div className="row g-2">
<div className="col-4 col-md-3">
<div
onClick={() => setFormData({...formData, parent_id: ''})}
className="p-2 rounded text-center h-100 d-flex flex-column justify-content-center"
style={{
background: !formData.parent_id ? 'rgba(59, 130, 246, 0.15)' : '#0f172a',
cursor: 'pointer',
border: !formData.parent_id ? '2px solid #3b82f6' : '2px solid transparent',
minHeight: 60
}}
>
<i className="bi bi-app d-block mb-1 text-slate-400"></i>
<small className="text-white">{t('categories.noParent')}</small>
</div>
</div>
{flatCategories
.filter(c => c.id !== selectedItem?.id && (c.type === formData.type || c.type === 'both'))
.slice(0, 7)
.map(cat => (
<div key={cat.id} className="col-4 col-md-3">
<div
onClick={() => setFormData({...formData, parent_id: cat.id.toString()})}
className="p-2 rounded text-center h-100 d-flex flex-column justify-content-center"
style={{
background: formData.parent_id == cat.id ? 'rgba(59, 130, 246, 0.15)' : '#0f172a',
cursor: 'pointer',
border: formData.parent_id == cat.id ? '2px solid #3b82f6' : '2px solid transparent',
minHeight: 60
}}
>
<i className={`bi ${cat.icon || 'bi-tag'} d-block mb-1`} style={{ color: cat.color || '#6b7280' }}></i>
<small className="text-white text-truncate" title={cat.name}>{cat.name}</small>
</div>
</div>
))}
</div>
{flatCategories.filter(c => c.id !== selectedItem?.id && (c.type === formData.type || c.type === 'both')).length > 7 && (
<select
className="form-select bg-dark text-white border-0 mt-2"
style={{ background: '#0f172a' }}
name="parent_id"
value={formData.parent_id}
onChange={handleChange}
>
<option value="">{t('categories.selectParent') || 'Mais categorias...'}</option>
{flatCategories
.filter(c => c.id !== selectedItem?.id && (c.type === formData.type || c.type === 'both'))
.map((cat) => (
<option key={cat.id} value={cat.id}>{cat.name}</option>
))}
</select>
)}
</div>
)}
{/* Descrição */}
<div className="mb-4">
<label className="form-label text-white fw-medium mb-2">
<i className="bi bi-text-paragraph me-2 text-secondary"></i>
{t('common.description')}
<span className="badge bg-secondary ms-2" style={{ fontSize: '0.65rem' }}>{t('common.optional')}</span>
</label>
<textarea
className="form-control bg-dark text-white border-0"
style={{ background: '#0f172a' }}
name="description"
value={formData.description}
onChange={handleChange}
rows="2"
placeholder={t('categories.descPlaceholder') || 'Descreva esta categoria...'}
></textarea>
</div>
{/* Palavras-chave - Seção destacada */}
<div className="mb-3">
<label className="form-label text-white fw-medium mb-2">
<i className="bi bi-key me-2 text-warning"></i>
{t('categories.keywords')}
<span className="badge bg-warning text-dark ms-2" style={{ fontSize: '0.65rem' }}>
{t('categories.autoCategorizationLabel') || 'Auto-categorização'}
</span>
</label>
<div className="p-3 rounded" style={{ background: '#0f172a' }}>
<div className="input-group mb-2">
<input
type="text"
className="form-control bg-dark text-white border-secondary"
className="form-control bg-dark text-white border-0"
style={{ background: '#1e293b' }}
value={newKeyword}
onChange={(e) => setNewKeyword(e.target.value)}
onKeyPress={handleKeywordKeyPress}
placeholder="Digite uma palavra-chave e pressione Enter..."
placeholder={t('categories.keywordPlaceholder') || 'Digite e pressione Enter...'}
/>
<button
type="button"
className="btn btn-outline-info"
className="btn btn-info px-3"
onClick={handleAddKeyword}
>
<i className="bi bi-plus"></i>
<i className="bi bi-plus-lg"></i>
</button>
</div>
<div className="d-flex flex-wrap gap-2">
{formData.keywords.map((keyword, index) => (
<span
key={index}
className="badge d-flex align-items-center"
className="badge d-flex align-items-center py-2 px-3"
style={{
backgroundColor: formData.color + '25',
color: formData.color,
fontSize: '0.85rem'
}}
>
{keyword}
<button
type="button"
className="btn-close btn-close-white ms-2"
style={{ fontSize: '8px' }}
className="btn-close ms-2"
style={{ fontSize: '8px', filter: 'brightness(1.5)' }}
onClick={() => handleRemoveKeyword(keyword)}
></button>
</span>
))}
{formData.keywords.length === 0 && (
<small className="text-slate-500">
{t('common.noData')}
<i className="bi bi-info-circle me-1"></i>
{t('categories.noKeywords') || 'Nenhuma palavra-chave. Transações serão categorizadas manualmente.'}
</small>
)}
</div>
<small className="text-slate-500 mt-2 d-block">
Ex: "RESTAURANTE", "PIZZA", "HAMBURGUER" - Para a categoria Alimentação
</small>
</div>
<small className="text-slate-500 mt-2 d-block">
<i className="bi bi-lightbulb me-1"></i>
{t('categories.keywordHelp') || 'Ex: "RESTAURANTE", "PIZZA" - Transações com essas palavras serão categorizadas automaticamente'}
</small>
</div>
{/* Status */}
<div className="form-check form-switch">
<input
type="checkbox"
className="form-check-input"
id="is_active"
name="is_active"
checked={formData.is_active}
onChange={handleChange}
role="switch"
/>
<label className="form-check-label text-white" htmlFor="is_active">
<i className={`bi ${formData.is_active ? 'bi-check-circle text-success' : 'bi-x-circle text-secondary'} me-2`}></i>
{formData.is_active ? t('common.active') : t('common.inactive')}
</label>
</div>
</div>
<div className="modal-footer border-top" style={{ borderColor: '#334155 !important' }}>
<button type="button" className="btn btn-outline-light" onClick={handleCloseModal}>
{/* Footer elegante */}
<div className="modal-footer border-0">
<button type="button" className="btn btn-outline-secondary px-4" onClick={handleCloseModal}>
<i className="bi bi-x-lg me-2"></i>
{t('common.cancel')}
</button>
<button type="submit" className="btn btn-info" disabled={saving}>
<button type="submit" className="btn btn-info px-4" disabled={saving || !formData.name.trim()}>
{saving ? (
<>
<span className="spinner-border spinner-border-sm me-2"></span>
{t('common.loading')}
{t('common.saving')}
</>
) : (
<>
<i className="bi bi-check-lg me-2"></i>
<i className={`bi ${selectedItem ? 'bi-check-lg' : 'bi-plus-lg'} me-2`}></i>
{selectedItem ? t('common.save') : t('common.create')}
</>
)}
@ -678,116 +795,145 @@ const Categories = () => {
loading={saving}
/>
{/* Modal de Categorização em Lote */}
{/* Modal de Categorização em Lote - Design Elegante */}
{showBatchModal && (
<div className="modal show d-block" style={{ backgroundColor: 'rgba(0,0,0,0.7)' }}>
<div className="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable">
<div className="modal-content border-0" style={{ background: '#1e293b' }}>
<div className="modal-header border-secondary">
<h5 className="modal-title text-white">
<i className="bi bi-lightning-charge me-2 text-warning"></i>
{t('categories.batchCategorize') || 'Categorização em Lote'}
</h5>
<div className="modal show d-block" style={{ backgroundColor: 'rgba(0,0,0,0.8)' }}>
<div className="modal-dialog modal-lg modal-dialog-centered" style={{ maxWidth: '700px' }}>
<div className="modal-content border-0" style={{ background: '#1e293b', maxHeight: '90vh' }}>
{/* Header elegante */}
<div className="modal-header border-0 pb-0">
<div>
<h5 className="modal-title text-white mb-1">
<i className="bi bi-lightning-charge-fill me-2 text-warning"></i>
{t('categories.batchCategorize') || 'Categorização Automática'}
</h5>
<p className="text-slate-400 mb-0 small">
{t('categories.batchDescription') || 'Categorize transações automaticamente usando palavras-chave'}
</p>
</div>
<button type="button" className="btn-close btn-close-white" onClick={handleCloseBatchModal}></button>
</div>
<div className="modal-body">
<div className="modal-body pt-3" style={{ maxHeight: '65vh', overflowY: 'auto' }}>
{loadingBatch ? (
<div className="text-center py-5">
<div className="spinner-border text-info" role="status">
<div className="spinner-border text-warning" role="status" style={{ width: '3rem', height: '3rem' }}>
<span className="visually-hidden">Loading...</span>
</div>
<p className="text-slate-400 mt-3">{t('common.loading') || 'Carregando...'}</p>
<p className="text-slate-400 mt-3 mb-0">{t('categories.analyzingTransactions') || 'Analisando transações...'}</p>
</div>
) : batchPreview ? (
<>
{/* Resumo */}
<div className="row mb-4">
<div className="col-md-3">
<div className="card border-0" style={{ background: '#0f172a' }}>
<div className="card-body text-center">
<h3 className="text-warning mb-1">{batchPreview.total_uncategorized}</h3>
<small className="text-slate-400">{t('categories.uncategorized') || 'Sem categoria'}</small>
{/* Cards de Resumo */}
<div className="row g-3 mb-4">
<div className="col-6 col-md-3">
<div className="p-3 rounded-3 text-center" style={{ background: '#0f172a' }}>
<div className="rounded-circle d-inline-flex align-items-center justify-content-center mb-2"
style={{ width: 40, height: 40, background: 'rgba(234, 179, 8, 0.2)' }}>
<i className="bi bi-question-circle text-warning"></i>
</div>
<h4 className="text-warning mb-0">{batchPreview.total_uncategorized}</h4>
<small className="text-slate-500">{t('categories.uncategorized') || 'Sem categoria'}</small>
</div>
</div>
<div className="col-md-3">
<div className="card border-0" style={{ background: '#0f172a' }}>
<div className="card-body text-center">
<h3 className="text-success mb-1">{batchPreview.would_categorize}</h3>
<small className="text-slate-400">{t('categories.willCategorize') || 'Serão categorizadas'}</small>
<div className="col-6 col-md-3">
<div className="p-3 rounded-3 text-center" style={{ background: '#0f172a' }}>
<div className="rounded-circle d-inline-flex align-items-center justify-content-center mb-2"
style={{ width: 40, height: 40, background: 'rgba(34, 197, 94, 0.2)' }}>
<i className="bi bi-check-circle text-success"></i>
</div>
<h4 className="text-success mb-0">{batchPreview.would_categorize}</h4>
<small className="text-slate-500">{t('categories.willCategorize') || 'Serão categorizadas'}</small>
</div>
</div>
<div className="col-md-3">
<div className="card border-0" style={{ background: '#0f172a' }}>
<div className="card-body text-center">
<h3 className="text-secondary mb-1">{batchPreview.would_skip}</h3>
<small className="text-slate-400">{t('categories.willSkip') || 'Sem correspondência'}</small>
<div className="col-6 col-md-3">
<div className="p-3 rounded-3 text-center" style={{ background: '#0f172a' }}>
<div className="rounded-circle d-inline-flex align-items-center justify-content-center mb-2"
style={{ width: 40, height: 40, background: 'rgba(148, 163, 184, 0.2)' }}>
<i className="bi bi-dash-circle text-slate-400"></i>
</div>
<h4 className="text-slate-400 mb-0">{batchPreview.would_skip}</h4>
<small className="text-slate-500">{t('categories.willSkip') || 'Sem correspondência'}</small>
</div>
</div>
<div className="col-md-3">
<div className="card border-0" style={{ background: '#0f172a' }}>
<div className="card-body text-center">
<h3 className="text-info mb-1">{batchPreview.total_keywords}</h3>
<small className="text-slate-400">{t('categories.totalKeywords') || 'Palavras-chave'}</small>
<div className="col-6 col-md-3">
<div className="p-3 rounded-3 text-center" style={{ background: '#0f172a' }}>
<div className="rounded-circle d-inline-flex align-items-center justify-content-center mb-2"
style={{ width: 40, height: 40, background: 'rgba(59, 130, 246, 0.2)' }}>
<i className="bi bi-key text-info"></i>
</div>
<h4 className="text-info mb-0">{batchPreview.total_keywords}</h4>
<small className="text-slate-500">{t('categories.totalKeywords') || 'Palavras-chave'}</small>
</div>
</div>
</div>
{/* Preview */}
{/* Preview Table */}
{batchPreview.preview.length > 0 ? (
<>
<h6 className="text-white mb-3">
<i className="bi bi-eye me-2"></i>
{t('categories.previewTitle') || 'Preview das categorizações'}
</h6>
<div className="table-responsive" style={{ maxHeight: '300px' }}>
<table className="table table-dark table-striped table-hover mb-0">
<thead style={{ position: 'sticky', top: 0, background: '#1e293b' }}>
<tr>
<th>{t('transactions.description') || 'Descrição'}</th>
<th>{t('categories.matchedKeyword') || 'Keyword'}</th>
<th>{t('categories.category') || 'Categoria'}</th>
</tr>
</thead>
<tbody>
{batchPreview.preview.map((item, index) => (
<tr key={index}>
<td className="text-truncate" style={{ maxWidth: '300px' }}>
{item.description}
</td>
<td>
<span className="badge bg-warning">{item.matched_keyword}</span>
</td>
<td className="text-info">{item.category_name}</td>
<div className="d-flex align-items-center mb-3">
<i className="bi bi-eye me-2 text-info"></i>
<h6 className="text-white mb-0">
{t('categories.previewTitle') || 'Preview das categorizações'}
</h6>
<span className="badge bg-info bg-opacity-25 text-info ms-2">
{batchPreview.preview.length} {t('common.items') || 'itens'}
</span>
</div>
<div className="rounded-3 overflow-hidden" style={{ background: '#0f172a' }}>
<div style={{ maxHeight: '250px', overflowY: 'auto' }}>
<table className="table table-dark mb-0" style={{ background: 'transparent' }}>
<thead style={{ position: 'sticky', top: 0, background: '#0f172a', zIndex: 1 }}>
<tr>
<th className="border-0 text-slate-400 fw-normal small">{t('transactions.description') || 'Descrição'}</th>
<th className="border-0 text-slate-400 fw-normal small text-center" style={{ width: '120px' }}>{t('categories.matchedKeyword') || 'Keyword'}</th>
<th className="border-0 text-slate-400 fw-normal small" style={{ width: '140px' }}>{t('categories.category') || 'Categoria'}</th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody>
{batchPreview.preview.map((item, index) => (
<tr key={index} style={{ borderColor: '#334155' }}>
<td className="text-white text-truncate border-secondary" style={{ maxWidth: '200px' }}>
{item.description}
</td>
<td className="text-center border-secondary">
<span className="badge bg-warning bg-opacity-25 text-warning">{item.matched_keyword}</span>
</td>
<td className="text-info border-secondary">{item.category_name}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</>
) : (
<div className="alert alert-warning">
<i className="bi bi-exclamation-triangle me-2"></i>
{t('categories.noMatchesFound') || 'Nenhuma transação corresponde às palavras-chave configuradas'}
<div className="p-4 rounded-3 text-center" style={{ background: '#0f172a' }}>
<i className="bi bi-search display-4 text-slate-600 mb-3 d-block"></i>
<h6 className="text-white mb-2">{t('categories.noMatchesFoundTitle') || 'Nenhuma correspondência encontrada'}</h6>
<p className="text-slate-400 mb-0 small">
{t('categories.noMatchesFound') || 'Adicione palavras-chave às categorias para permitir categorização automática'}
</p>
</div>
)}
</>
) : (
<div className="alert alert-danger">
{t('categories.previewError') || 'Erro ao carregar preview'}
<div className="p-4 rounded-3 text-center" style={{ background: '#0f172a' }}>
<i className="bi bi-exclamation-triangle display-4 text-danger mb-3 d-block"></i>
<p className="text-slate-400 mb-0">{t('categories.previewError') || 'Erro ao carregar preview'}</p>
</div>
)}
</div>
<div className="modal-footer border-secondary">
<button type="button" className="btn btn-outline-secondary" onClick={handleCloseBatchModal}>
{/* Footer elegante */}
<div className="modal-footer border-0">
<button type="button" className="btn btn-outline-secondary px-4" onClick={handleCloseBatchModal}>
<i className="bi bi-x-lg me-2"></i>
{t('common.cancel') || 'Cancelar'}
</button>
<button
type="button"
className="btn btn-warning"
className="btn btn-warning px-4"
onClick={handleExecuteBatch}
disabled={executingBatch || !batchPreview || batchPreview.would_categorize === 0}
>
@ -798,8 +944,8 @@ const Categories = () => {
</>
) : (
<>
<i className="bi bi-lightning-charge me-2"></i>
{t('categories.executeBatch') || 'Executar Categorização'}
<i className="bi bi-lightning-charge-fill me-2"></i>
{t('categories.executeBatch') || 'Categorizar'} ({batchPreview?.would_categorize || 0})
</>
)}
</button>

View File

@ -357,170 +357,242 @@ const CostCenters = () => {
</div>
)}
{/* Modal de Criar/Editar */}
{/* Modal de Criar/Editar - Design Elegante */}
{showModal && (
<div className="modal show d-block" style={{ backgroundColor: 'rgba(0,0,0,0.7)' }}>
<div className="modal-dialog modal-lg modal-dialog-centered">
<div className="modal-content" style={{ background: '#1e293b' }}>
<div className="modal-header border-bottom" style={{ borderColor: '#334155 !important' }}>
<h5 className="modal-title text-white">
<i className={`bi ${selectedItem ? 'bi-pencil' : 'bi-plus-circle'} me-2`}></i>
{selectedItem ? t('costCenters.editCostCenter') : t('costCenters.newCostCenter')}
</h5>
<div className="modal show d-block" style={{ backgroundColor: 'rgba(0,0,0,0.8)' }}>
<div className="modal-dialog modal-dialog-centered modal-lg">
<div className="modal-content border-0" style={{ background: '#1e293b', maxHeight: '90vh' }}>
{/* Header elegante */}
<div className="modal-header border-0 pb-0">
<div>
<h5 className="modal-title text-white mb-1">
<i className={`bi ${selectedItem ? 'bi-pencil-square' : 'bi-plus-circle-dotted'} me-2 text-success`}></i>
{selectedItem ? t('costCenters.editCostCenter') : t('costCenters.newCostCenter')}
</h5>
<p className="text-slate-400 mb-0 small">
{t('costCenters.title')}
</p>
</div>
<button type="button" className="btn-close btn-close-white" onClick={handleCloseModal}></button>
</div>
<form onSubmit={handleSubmit}>
<div className="modal-body">
<div className="row g-3">
{/* Nome */}
<div className="modal-body pt-3" style={{ maxHeight: '65vh', overflowY: 'auto' }}>
{/* Preview Card */}
<div className="mb-4 p-3 rounded-3" style={{ background: '#0f172a' }}>
<div className="d-flex align-items-center">
<div
className="rounded-circle d-flex align-items-center justify-content-center me-3"
style={{
width: 50,
height: 50,
background: `${formData.color}25`,
border: `2px solid ${formData.color}`,
}}
>
<i className={`bi ${formData.icon}`} style={{ fontSize: '1.3rem', color: formData.color }}></i>
</div>
<div>
<h6 className="text-white mb-0">{formData.name || t('costCenters.newCostCenter')}</h6>
<small className="text-slate-400">
{formData.code ? `${t('costCenters.code')}: ${formData.code}` : t('common.description')}
</small>
</div>
<div className="ms-auto">
{formData.is_active ? (
<span className="badge bg-success bg-opacity-25 text-success">{t('common.active')}</span>
) : (
<span className="badge bg-secondary bg-opacity-25 text-secondary">{t('common.inactive')}</span>
)}
</div>
</div>
</div>
{/* Nome e Código - Linha principal */}
<div className="row g-3 mb-4">
<div className="col-md-8">
<label className="form-label text-slate-300">{t('common.name')} *</label>
<label className="form-label text-white fw-medium mb-2">
<i className="bi bi-type me-2 text-primary"></i>
{t('common.name')} *
</label>
<input
type="text"
className="form-control bg-dark text-white border-secondary"
className="form-control bg-dark text-white border-0"
style={{ background: '#0f172a' }}
name="name"
value={formData.name}
onChange={handleChange}
placeholder="Ex: Projeto Alpha, Departamento RH..."
placeholder={t('costCenters.namePlaceholder') || 'Ej: Proyecto Alpha, Dpto. Marketing...'}
required
autoFocus
/>
</div>
{/* Código */}
<div className="col-md-4">
<label className="form-label text-slate-300">{t('costCenters.code')}</label>
<label className="form-label text-white fw-medium mb-2">
<i className="bi bi-hash me-2 text-warning"></i>
{t('costCenters.code')}
<span className="badge bg-secondary ms-2" style={{ fontSize: '0.65rem' }}>{t('common.optional')}</span>
</label>
<input
type="text"
className="form-control bg-dark text-white border-secondary"
className="form-control bg-dark text-white border-0"
style={{ background: '#0f172a' }}
name="code"
value={formData.code}
onChange={handleChange}
placeholder="Ex: CC001"
placeholder="CC001"
maxLength="20"
/>
</div>
</div>
{/* Cor */}
<div className="col-md-3">
<label className="form-label text-slate-300">{t('common.color')}</label>
<input
type="color"
className="form-control form-control-color bg-dark border-secondary w-100"
name="color"
value={formData.color}
onChange={handleChange}
/>
</div>
{/* Ícone */}
<div className="col-md-5">
<label className="form-label text-slate-300">{t('common.icon')}</label>
<IconSelector
value={formData.icon}
onChange={(icon) => setFormData(prev => ({ ...prev, icon }))}
type="costCenter"
/>
</div>
{/* Status */}
<div className="col-md-4">
<label className="form-label text-slate-300">&nbsp;</label>
<div className="form-check mt-2">
<input
type="checkbox"
className="form-check-input"
id="is_active"
name="is_active"
checked={formData.is_active}
onChange={handleChange}
/>
<label className="form-check-label text-slate-300" htmlFor="is_active">
{t('common.active')}
</label>
{/* Visual - Cor e Ícone */}
<div className="mb-4">
<label className="form-label text-white fw-medium mb-2">
<i className="bi bi-palette me-2 text-success"></i>
{t('costCenters.visualSettings') || t('categories.visualSettings') || 'Apariencia'}
</label>
<div className="row g-3">
<div className="col-4">
<div className="p-3 rounded text-center" style={{ background: '#0f172a' }}>
<label className="text-slate-400 small d-block mb-2">{t('common.color')}</label>
<input
type="color"
className="form-control form-control-color mx-auto border-0"
style={{ width: 50, height: 50, cursor: 'pointer', background: 'transparent' }}
name="color"
value={formData.color}
onChange={handleChange}
/>
</div>
</div>
<div className="col-8">
<div className="p-3 rounded h-100" style={{ background: '#0f172a' }}>
<label className="text-slate-400 small d-block mb-2">{t('common.icon')}</label>
<IconSelector
value={formData.icon}
onChange={(icon) => setFormData(prev => ({ ...prev, icon }))}
type="costCenter"
/>
</div>
</div>
</div>
</div>
{/* Descrição */}
<div className="col-12">
<label className="form-label text-slate-300">{t('common.description')}</label>
<textarea
className="form-control bg-dark text-white border-secondary"
name="description"
value={formData.description}
onChange={handleChange}
rows="2"
placeholder="Descreva o propósito deste centro de custo..."
></textarea>
</div>
{/* Descrição */}
<div className="mb-4">
<label className="form-label text-white fw-medium mb-2">
<i className="bi bi-text-paragraph me-2 text-secondary"></i>
{t('common.description')}
<span className="badge bg-secondary ms-2" style={{ fontSize: '0.65rem' }}>{t('common.optional')}</span>
</label>
<textarea
className="form-control bg-dark text-white border-0"
style={{ background: '#0f172a' }}
name="description"
value={formData.description}
onChange={handleChange}
rows="2"
placeholder={t('costCenters.descPlaceholder') || 'Describe el propósito de este centro de costo...'}
></textarea>
</div>
{/* Palavras-chave */}
<div className="col-12">
<label className="form-label text-slate-300">
<i className="bi bi-key me-1"></i>
{t('costCenters.keywordHelp')}
</label>
{/* Palavras-chave - Seção destacada */}
<div className="mb-3">
<label className="form-label text-white fw-medium mb-2">
<i className="bi bi-key me-2 text-warning"></i>
{t('costCenters.keywords')}
<span className="badge bg-warning text-dark ms-2" style={{ fontSize: '0.65rem' }}>
{t('costCenters.autoAssignLabel') || 'Auto-asignación'}
</span>
</label>
<div className="p-3 rounded" style={{ background: '#0f172a' }}>
<div className="input-group mb-2">
<input
type="text"
className="form-control bg-dark text-white border-secondary"
className="form-control bg-dark text-white border-0"
style={{ background: '#1e293b' }}
value={newKeyword}
onChange={(e) => setNewKeyword(e.target.value)}
onKeyPress={handleKeywordKeyPress}
placeholder="Digite uma palavra-chave e pressione Enter..."
placeholder={t('costCenters.keywordPlaceholder') || 'Escribe y presiona Enter...'}
/>
<button
type="button"
className="btn btn-outline-success"
className="btn btn-success px-3"
onClick={handleAddKeyword}
>
<i className="bi bi-plus"></i>
<i className="bi bi-plus-lg"></i>
</button>
</div>
<div className="d-flex flex-wrap gap-2">
{formData.keywords.map((keyword, index) => (
<span
key={index}
className="badge d-flex align-items-center"
className="badge d-flex align-items-center py-2 px-3"
style={{
backgroundColor: formData.color + '25',
color: formData.color,
fontSize: '0.85rem'
}}
>
{keyword}
<button
type="button"
className="btn-close btn-close-white ms-2"
style={{ fontSize: '8px' }}
className="btn-close ms-2"
style={{ fontSize: '8px', filter: 'brightness(1.5)' }}
onClick={() => handleRemoveKeyword(keyword)}
></button>
</span>
))}
{formData.keywords.length === 0 && (
<small className="text-slate-500">
{t('common.noData')}
<i className="bi bi-info-circle me-1"></i>
{t('costCenters.noKeywords') || 'Sin palabras clave. Las transacciones se asignarán manualmente.'}
</small>
)}
</div>
<small className="text-slate-500 mt-2 d-block">
Ex: "UBER", "iFood", "Supermercado XYZ" - Quando estas palavras aparecerem na
descrição de uma transação, este centro de custo será sugerido automaticamente.
</small>
</div>
<small className="text-slate-500 mt-2 d-block">
<i className="bi bi-lightbulb me-1"></i>
{t('costCenters.keywordHelp')}
</small>
</div>
{/* Status */}
<div className="form-check form-switch">
<input
type="checkbox"
className="form-check-input"
id="is_active"
name="is_active"
checked={formData.is_active}
onChange={handleChange}
role="switch"
/>
<label className="form-check-label text-white" htmlFor="is_active">
<i className={`bi ${formData.is_active ? 'bi-check-circle text-success' : 'bi-x-circle text-secondary'} me-2`}></i>
{formData.is_active ? t('common.active') : t('common.inactive')}
</label>
</div>
</div>
<div className="modal-footer border-top" style={{ borderColor: '#334155 !important' }}>
<button type="button" className="btn btn-outline-light" onClick={handleCloseModal}>
{/* Footer elegante */}
<div className="modal-footer border-0">
<button type="button" className="btn btn-outline-secondary px-4" onClick={handleCloseModal}>
<i className="bi bi-x-lg me-2"></i>
{t('common.cancel')}
</button>
<button type="submit" className="btn btn-success" disabled={saving}>
<button type="submit" className="btn btn-success px-4" disabled={saving || !formData.name.trim()}>
{saving ? (
<>
<span className="spinner-border spinner-border-sm me-2"></span>
{t('common.loading')}
{t('common.saving')}
</>
) : (
<>
<i className="bi bi-check-lg me-2"></i>
<i className={`bi ${selectedItem ? 'bi-check-lg' : 'bi-plus-lg'} me-2`}></i>
{selectedItem ? t('common.save') : t('common.create')}
</>
)}

View File

@ -10,6 +10,7 @@ import OverpaymentsAnalysis from '../components/dashboard/OverpaymentsAnalysis';
import CalendarWidget from '../components/dashboard/CalendarWidget';
import UpcomingWidget from '../components/dashboard/UpcomingWidget';
import OverdueWidget from '../components/dashboard/OverdueWidget';
import PlanUsageWidget from '../components/dashboard/PlanUsageWidget';
const Dashboard = () => {
const { user } = useAuth();
@ -230,6 +231,9 @@ const Dashboard = () => {
return (
<div className="dashboard-container">
{/* Plan Usage Widget - Show for free plan */}
<PlanUsageWidget />
{/* Header */}
<div className="d-flex justify-content-between align-items-center mb-4">
<div>

View File

@ -228,6 +228,26 @@ const FinancialHealth = () => {
}],
} : null;
// Data sufficiency status
const dataStatus = data.data_status;
const showDataWarning = dataStatus && !dataStatus.has_sufficient_data;
// Helper para obtener ícono y cor do alerta baseado no nível
const getDataWarningStyle = (level) => {
switch (level) {
case 'no_data':
return { icon: 'bi-database-x', color: 'danger', bg: 'rgba(239, 68, 68, 0.1)' };
case 'insufficient':
return { icon: 'bi-exclamation-triangle', color: 'warning', bg: 'rgba(245, 158, 11, 0.1)' };
case 'limited':
return { icon: 'bi-info-circle', color: 'info', bg: 'rgba(59, 130, 246, 0.1)' };
case 'outdated':
return { icon: 'bi-clock-history', color: 'secondary', bg: 'rgba(100, 116, 139, 0.1)' };
default:
return { icon: 'bi-info-circle', color: 'info', bg: 'rgba(59, 130, 246, 0.1)' };
}
};
return (
<div className="financial-health-container">
{/* Header */}
@ -250,6 +270,46 @@ const FinancialHealth = () => {
</div>
</div>
{/* Data Sufficiency Warning */}
{showDataWarning && (
<div
className={`alert alert-${getDataWarningStyle(dataStatus.level).color} d-flex align-items-start mb-4`}
style={{
background: getDataWarningStyle(dataStatus.level).bg,
border: 'none',
}}
>
<i className={`bi ${getDataWarningStyle(dataStatus.level).icon} me-3 fs-4`}></i>
<div className="flex-grow-1">
<h6 className="alert-heading mb-1">
{dataStatus.level === 'no_data' && t('financialHealth.dataWarning.noData', 'Datos Insuficientes')}
{dataStatus.level === 'insufficient' && t('financialHealth.dataWarning.insufficient', 'Análisis Limitado')}
{dataStatus.level === 'limited' && t('financialHealth.dataWarning.limited', 'Pocos Datos')}
{dataStatus.level === 'outdated' && t('financialHealth.dataWarning.outdated', 'Datos Desactualizados')}
</h6>
<p className="mb-2 small">{dataStatus.message}</p>
<div className="d-flex gap-3 small">
<span>
<i className="bi bi-wallet2 me-1"></i>
{dataStatus.counts?.accounts || 0} {t('financialHealth.dataWarning.accounts', 'cuentas')}
</span>
<span>
<i className="bi bi-arrow-left-right me-1"></i>
{dataStatus.counts?.transactions || 0} {t('financialHealth.dataWarning.transactions', 'transacciones')}
</span>
<span>
<i className="bi bi-calendar-check me-1"></i>
{dataStatus.counts?.recent_transactions || 0} {t('financialHealth.dataWarning.recentTransactions', 'últimos 30 días')}
</span>
</div>
</div>
<a href="/accounts" className="btn btn-sm btn-outline-light ms-3">
<i className="bi bi-plus-lg me-1"></i>
{t('financialHealth.dataWarning.addData', 'Añadir Datos')}
</a>
</div>
)}
{/* Tabs */}
<ul className="nav nav-pills mb-4 gap-2">
{tabs.map(tab => (

View File

@ -0,0 +1,443 @@
/* Landing Page Styles */
/* Variables */
:root {
--landing-primary: #3b82f6;
--landing-primary-dark: #1e40af;
--landing-secondary: #10b981;
--landing-dark: #0f172a;
--landing-dark-lighter: #1e293b;
--landing-text: #f1f5f9;
--landing-text-muted: #94a3b8;
--landing-border: #334155;
}
.landing-page {
background: var(--landing-dark);
color: var(--landing-text);
min-height: 100vh;
}
/* Navbar */
.landing-navbar {
background: rgba(15, 23, 42, 0.95);
backdrop-filter: blur(10px);
border-bottom: 1px solid var(--landing-border);
padding: 1rem 0;
transition: all 0.3s ease;
}
.landing-navbar.scrolled {
padding: 0.5rem 0;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
}
.landing-navbar .nav-link {
color: var(--landing-text-muted) !important;
font-weight: 500;
padding: 0.5rem 1rem !important;
transition: color 0.3s ease;
background: none;
border: none;
}
.landing-navbar .nav-link:hover {
color: var(--landing-text) !important;
}
/* Hero Section */
.hero-section {
position: relative;
overflow: hidden;
}
.hero-bg {
position: absolute;
inset: 0;
background: radial-gradient(ellipse at 20% 20%, rgba(59, 130, 246, 0.15), transparent 50%),
radial-gradient(ellipse at 80% 80%, rgba(16, 185, 129, 0.1), transparent 50%);
}
.hero-section .container {
position: relative;
z-index: 1;
}
/* Hero Title Animation */
.hero-title-wrapper {
line-height: 1.1;
}
.hero-title-wrapper h1 {
line-height: 1.1;
}
.hero-title-wrapper h1:first-child {
padding-left: 0;
}
.hero-title-wrapper h1:last-child {
padding-left: 3.5rem;
}
@media (min-width: 768px) {
.hero-title-wrapper h1:last-child {
padding-left: 5rem;
}
}
.hero-title-highlight {
background: linear-gradient(135deg, var(--landing-primary), var(--landing-secondary));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
animation: glow 3s ease-in-out infinite, colorShift 5s ease-in-out infinite;
}
@keyframes glow {
0%, 100% {
filter: drop-shadow(0 0 5px rgba(59, 130, 246, 0.3));
}
50% {
filter: drop-shadow(0 0 20px rgba(16, 185, 129, 0.5)) drop-shadow(0 0 40px rgba(59, 130, 246, 0.3));
}
}
@keyframes colorShift {
0%, 100% {
background: linear-gradient(135deg, var(--landing-primary), var(--landing-secondary));
-webkit-background-clip: text;
background-clip: text;
}
50% {
background: linear-gradient(135deg, var(--landing-secondary), var(--landing-primary));
-webkit-background-clip: text;
background-clip: text;
}
}
/* Dashboard Preview */
.hero-image {
perspective: 1000px;
}
.dashboard-preview {
background: var(--landing-dark-lighter);
border-radius: 16px;
border: 1px solid var(--landing-border);
overflow: hidden;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
transform: rotateY(-5deg) rotateX(5deg);
transition: transform 0.5s ease;
}
.dashboard-preview:hover {
transform: rotateY(0) rotateX(0);
}
.preview-header {
background: var(--landing-dark);
padding: 12px 16px;
border-bottom: 1px solid var(--landing-border);
}
.preview-dots {
display: flex;
gap: 8px;
}
.preview-dots .dot {
width: 12px;
height: 12px;
border-radius: 50%;
}
.preview-dots .dot.red { background: #ef4444; }
.preview-dots .dot.yellow { background: #f59e0b; }
.preview-dots .dot.green { background: #22c55e; }
.preview-content {
padding: 24px;
display: flex;
flex-direction: column;
gap: 16px;
}
.preview-card {
background: var(--landing-dark);
border-radius: 12px;
padding: 20px;
display: flex;
align-items: center;
gap: 16px;
font-size: 1.25rem;
font-weight: 600;
}
.preview-card i {
font-size: 2rem;
}
.preview-card.balance i { color: var(--landing-primary); }
.preview-card.income i { color: #22c55e; }
.preview-card.expense i { color: #ef4444; }
.preview-card.income span { color: #22c55e; }
.preview-card.expense span { color: #ef4444; }
/* Features Section */
.features-section {
background: var(--landing-dark-lighter);
}
.feature-card {
background: var(--landing-dark);
border-radius: 16px;
padding: 32px;
border: 1px solid var(--landing-border);
transition: transform 0.3s ease, border-color 0.3s ease;
}
.feature-card:hover {
transform: translateY(-8px);
border-color: var(--landing-primary);
}
.feature-icon {
width: 64px;
height: 64px;
background: linear-gradient(135deg, var(--landing-primary), var(--landing-secondary));
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 24px;
}
.feature-icon i {
font-size: 28px;
color: white;
}
.feature-card h4 {
margin-bottom: 12px;
color: var(--landing-text);
}
/* Pricing Section */
.pricing-section {
background: var(--landing-dark);
}
.pricing-card {
background: var(--landing-dark-lighter);
border-radius: 16px;
padding: 32px;
border: 1px solid var(--landing-border);
transition: transform 0.3s ease, border-color 0.3s ease;
display: flex;
flex-direction: column;
}
.pricing-card:hover {
transform: translateY(-8px);
}
.pricing-card.featured {
border-color: var(--landing-primary);
box-shadow: 0 0 40px rgba(59, 130, 246, 0.2);
position: relative;
}
.featured-badge {
position: absolute;
top: -12px;
left: 50%;
transform: translateX(-50%);
background: linear-gradient(135deg, var(--landing-primary), var(--landing-primary-dark));
color: white;
padding: 6px 20px;
border-radius: 20px;
font-size: 0.875rem;
font-weight: 600;
}
.coming-soon-badge {
position: absolute;
top: -12px;
left: 50%;
transform: translateX(-50%);
background: linear-gradient(135deg, #6366f1, #4f46e5);
color: white;
padding: 6px 20px;
border-radius: 20px;
font-size: 0.875rem;
font-weight: 600;
white-space: nowrap;
}
.pricing-card.coming-soon {
opacity: 0.9;
border: 2px dashed rgba(99, 102, 241, 0.5);
}
.pricing-card.coming-soon .coming-soon-text {
font-size: 3rem;
color: #6366f1;
}
.pricing-card.coming-soon .pricing-features li {
opacity: 0.7;
}
/* Gold Plan Teaser */
.gold-teaser {
background: linear-gradient(135deg, rgba(234, 179, 8, 0.1) 0%, rgba(161, 98, 7, 0.2) 100%);
border: 2px solid rgba(234, 179, 8, 0.3);
backdrop-filter: blur(10px);
}
.gold-teaser h4 {
font-weight: 700;
text-transform: uppercase;
letter-spacing: 1px;
}
.pricing-header {
text-align: center;
margin-bottom: 24px;
padding-bottom: 24px;
border-bottom: 1px solid var(--landing-border);
}
.pricing-header h3 {
font-size: 1.5rem;
margin-bottom: 16px;
}
.price {
display: flex;
align-items: baseline;
justify-content: center;
gap: 4px;
}
.price .currency {
font-size: 1.5rem;
font-weight: 600;
color: var(--landing-text-muted);
}
.price .amount {
font-size: 3rem;
font-weight: 700;
color: var(--landing-text);
}
.price .period {
font-size: 1rem;
color: var(--landing-text-muted);
}
.billing-note {
font-size: 0.875rem;
color: var(--landing-text-muted);
margin-top: 8px;
}
.pricing-features {
list-style: none;
padding: 0;
margin: 0 0 24px 0;
flex-grow: 1;
}
.pricing-features li {
padding: 12px 0;
border-bottom: 1px solid var(--landing-border);
font-size: 0.95rem;
}
.pricing-features li:last-child {
border-bottom: none;
}
.pricing-footer {
margin-top: auto;
}
/* FAQ Section */
.faq-section {
background: var(--landing-dark-lighter);
}
.faq-section .accordion-item {
background: var(--landing-dark);
border: 1px solid var(--landing-border);
margin-bottom: 12px;
border-radius: 12px !important;
overflow: hidden;
}
.faq-section .accordion-button {
background: var(--landing-dark);
color: var(--landing-text);
font-weight: 500;
padding: 20px 24px;
}
.faq-section .accordion-button:not(.collapsed) {
background: var(--landing-dark);
color: var(--landing-primary);
}
.faq-section .accordion-button::after {
filter: invert(1);
}
.faq-section .accordion-button:focus {
box-shadow: none;
border-color: var(--landing-primary);
}
.faq-section .accordion-body {
background: var(--landing-dark);
color: var(--landing-text-muted);
padding: 0 24px 20px 24px;
}
/* CTA Section */
.cta-section {
background: linear-gradient(135deg, var(--landing-primary), var(--landing-primary-dark));
}
/* Footer */
.landing-footer {
background: var(--landing-dark);
border-top: 1px solid var(--landing-border);
}
.landing-footer a {
text-decoration: none;
transition: color 0.3s ease;
}
.landing-footer a:hover {
color: var(--landing-primary) !important;
}
/* Responsive */
@media (max-width: 768px) {
.hero-section .row {
min-height: auto;
padding-top: 100px;
}
.price .amount {
font-size: 2.5rem;
}
.feature-card {
padding: 24px;
}
}

View File

@ -0,0 +1,428 @@
import React, { useState, useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../context/AuthContext';
import api from '../services/api';
import logo from '../assets/logo-white.png';
import './Landing.css';
export default function Landing() {
const { t, i18n } = useTranslation();
const navigate = useNavigate();
const { user, isAuthenticated } = useAuth();
const [plans, setPlans] = useState([]);
const [loading, setLoading] = useState(true);
// Se já está autenticado, vai para dashboard
useEffect(() => {
if (isAuthenticated && user) {
navigate('/dashboard');
}
}, [isAuthenticated, user, navigate]);
useEffect(() => {
loadPlans();
}, []);
const loadPlans = async () => {
try {
const response = await api.get('/plans');
if (response.data.success) {
setPlans(response.data.data.plans);
}
} catch (error) {
console.error('Error loading plans:', error);
} finally {
setLoading(false);
}
};
const scrollToSection = (id) => {
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth' });
};
const features = [
{
icon: 'bi-wallet2',
title: t('landing.features.accounts.title'),
description: t('landing.features.accounts.description'),
},
{
icon: 'bi-graph-up-arrow',
title: t('landing.features.analytics.title'),
description: t('landing.features.analytics.description'),
},
{
icon: 'bi-tags',
title: t('landing.features.categories.title'),
description: t('landing.features.categories.description'),
},
{
icon: 'bi-cloud-upload',
title: t('landing.features.import.title'),
description: t('landing.features.import.description'),
},
{
icon: 'bi-arrow-repeat',
title: t('landing.features.recurring.title'),
description: t('landing.features.recurring.description'),
},
{
icon: 'bi-shield-check',
title: t('landing.features.security.title'),
description: t('landing.features.security.description'),
},
];
const faqs = [
{
question: t('landing.faq.q1'),
answer: t('landing.faq.a1'),
},
{
question: t('landing.faq.q2'),
answer: t('landing.faq.a2'),
},
{
question: t('landing.faq.q3'),
answer: t('landing.faq.a3'),
},
{
question: t('landing.faq.q4'),
answer: t('landing.faq.a4'),
},
];
return (
<div className="landing-page">
{/* Navigation */}
<nav className="navbar navbar-expand-lg navbar-dark fixed-top landing-navbar">
<div className="container">
<Link to="/" className="navbar-brand d-flex align-items-center">
<img src={logo} alt="WebMoney" height="40" className="me-2" />
<span className="fw-bold">WebMoney</span>
</Link>
<button
className="navbar-toggler"
type="button"
data-bs-toggle="collapse"
data-bs-target="#navbarNav"
>
<span className="navbar-toggler-icon"></span>
</button>
<div className="collapse navbar-collapse" id="navbarNav">
<ul className="navbar-nav mx-auto">
<li className="nav-item">
<button className="nav-link btn btn-link" onClick={() => scrollToSection('features')}>
{t('landing.nav.features')}
</button>
</li>
<li className="nav-item">
<button className="nav-link btn btn-link" onClick={() => scrollToSection('pricing')}>
{t('landing.nav.pricing')}
</button>
</li>
<li className="nav-item">
<button className="nav-link btn btn-link" onClick={() => scrollToSection('faq')}>
{t('landing.nav.faq')}
</button>
</li>
</ul>
<div className="d-flex align-items-center gap-3">
<Link to="/login" className="btn btn-outline-light">
{t('landing.nav.login')}
</Link>
<Link to="/register" className="btn btn-primary">
{t('landing.nav.register')}
</Link>
</div>
</div>
</div>
</nav>
{/* Hero Section */}
<section className="hero-section">
<div className="hero-bg"></div>
<div className="container">
<div className="row align-items-center min-vh-100 py-5">
<div className="col-lg-6">
<div className="hero-title-wrapper mb-4">
<h1 className="display-3 fw-bold text-white mb-0">
{i18n.language === 'es' ? 'Toma el Control' : i18n.language === 'pt-BR' ? 'Assuma o Controle' : 'Take Control'}
</h1>
<h1 className="display-3 fw-bold mb-0 hero-title-highlight">
{i18n.language === 'es' ? 'de tus Finanzas' : i18n.language === 'pt-BR' ? 'das suas Finanças' : 'of Your Finances'}
</h1>
</div>
<p className="lead text-white-50 mb-4">
{t('landing.hero.subtitle')}
</p>
<div className="d-flex gap-3 flex-wrap">
<Link to="/register" className="btn btn-primary btn-lg">
<i className="bi bi-rocket-takeoff me-2"></i>
{t('landing.hero.cta')}
</Link>
<button
className="btn btn-outline-light btn-lg"
onClick={() => scrollToSection('features')}
>
{t('landing.hero.learnMore')}
</button>
</div>
{/* Trust badges */}
<div className="mt-5 d-flex align-items-center gap-4 text-white-50">
<div className="d-flex align-items-center">
<i className="bi bi-shield-check fs-4 me-2 text-success"></i>
<span>{t('landing.hero.secure')}</span>
</div>
<div className="d-flex align-items-center">
<i className="bi bi-credit-card fs-4 me-2 text-primary"></i>
<span>PayPal</span>
</div>
</div>
</div>
<div className="col-lg-6 d-none d-lg-block">
<div className="hero-image">
<div className="dashboard-preview">
<div className="preview-header">
<div className="preview-dots">
<span className="dot red"></span>
<span className="dot yellow"></span>
<span className="dot green"></span>
</div>
</div>
<div className="preview-content">
<div className="preview-card balance">
<i className="bi bi-wallet2"></i>
<span>12,450.00</span>
</div>
<div className="preview-card income">
<i className="bi bi-arrow-down-circle"></i>
<span>+3,200</span>
</div>
<div className="preview-card expense">
<i className="bi bi-arrow-up-circle"></i>
<span>-1,850</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
{/* Features Section */}
<section id="features" className="features-section py-5">
<div className="container py-5">
<div className="text-center mb-5">
<h2 className="display-5 fw-bold">{t('landing.features.title')}</h2>
<p className="lead text-muted">{t('landing.features.subtitle')}</p>
</div>
<div className="row g-4">
{features.map((feature, index) => (
<div key={index} className="col-md-6 col-lg-4">
<div className="feature-card h-100">
<div className="feature-icon">
<i className={`bi ${feature.icon}`}></i>
</div>
<h4>{feature.title}</h4>
<p className="text-muted">{feature.description}</p>
</div>
</div>
))}
</div>
</div>
</section>
{/* Pricing Section */}
<section id="pricing" className="pricing-section py-5">
<div className="container py-5">
<div className="text-center mb-5">
<h2 className="display-5 fw-bold">{t('landing.pricing.title')}</h2>
<p className="lead text-muted">{t('landing.pricing.subtitle')}</p>
</div>
{loading ? (
<div className="text-center py-5">
<div className="spinner-border text-primary" role="status">
<span className="visually-hidden">Loading...</span>
</div>
</div>
) : (
<div className="row justify-content-center g-4">
{plans.map((plan) => (
<div key={plan.id} className="col-lg-3 col-md-6">
<div className={`pricing-card h-100 ${plan.is_featured ? 'featured' : ''} ${plan.coming_soon ? 'coming-soon' : ''}`}>
{plan.is_featured && (
<div className="featured-badge">
<i className="bi bi-star-fill me-1"></i>
{t('landing.pricing.popular')}
</div>
)}
{plan.coming_soon && (
<div className="coming-soon-badge">
<i className="bi bi-clock me-1"></i>
{t('landing.pricing.comingSoon')}
</div>
)}
<div className="pricing-header">
<h3>{plan.name}</h3>
<div className="price">
{plan.is_free ? (
<span className="amount">{t('landing.pricing.free')}</span>
) : plan.coming_soon ? (
<span className="amount coming-soon-text">
<i className="bi bi-rocket-takeoff"></i>
</span>
) : (
<>
<span className="currency"></span>
<span className="amount">{plan.monthly_price?.toFixed(2) || plan.price}</span>
<span className="period">/{t('landing.pricing.month')}</span>
</>
)}
</div>
{plan.billing_period === 'annual' && !plan.is_free && !plan.coming_soon && (
<p className="billing-note">
{t('landing.pricing.billedAnnually', { price: plan.price })}
</p>
)}
{plan.coming_soon && (
<p className="billing-note">
{t('landing.pricing.forPymes')}
</p>
)}
</div>
<ul className="pricing-features">
{(plan.features || []).map((feature, idx) => (
<li key={idx}>
<i className="bi bi-check-circle-fill text-success me-2"></i>
{feature.startsWith('landing.pricing.features.') ? t(feature) : feature}
</li>
))}
</ul>
<div className="pricing-footer">
{plan.coming_soon ? (
<button className="btn btn-secondary w-100" disabled>
<i className="bi bi-bell me-2"></i>
{t('landing.pricing.comingSoon')}
</button>
) : (
<Link
to={`/register?plan=${plan.slug}`}
className={`btn w-100 ${plan.is_featured ? 'btn-primary' : 'btn-outline-primary'}`}
>
{plan.is_free ? t('landing.pricing.startFree') : t('landing.pricing.subscribe')}
</Link>
)}
</div>
</div>
</div>
))}
</div>
)}
{/* Gold Plan Teaser */}
<div className="row justify-content-center mt-5">
<div className="col-lg-8">
<div className="gold-teaser text-center p-4 rounded-4">
<div className="d-flex align-items-center justify-content-center gap-3 mb-3">
<i className="bi bi-stars text-warning fs-2"></i>
<h4 className="mb-0 text-warning">{t('landing.pricing.goldTeaser.title')}</h4>
<i className="bi bi-stars text-warning fs-2"></i>
</div>
<p className="mb-0 text-light">
{t('landing.pricing.goldTeaser.description')}
</p>
</div>
</div>
</div>
</div>
</section>
{/* FAQ Section */}
<section id="faq" className="faq-section py-5">
<div className="container py-5">
<div className="text-center mb-5">
<h2 className="display-5 fw-bold">{t('landing.faq.title')}</h2>
</div>
<div className="row justify-content-center">
<div className="col-lg-8">
<div className="accordion" id="faqAccordion">
{faqs.map((faq, index) => (
<div key={index} className="accordion-item">
<h2 className="accordion-header">
<button
className={`accordion-button ${index !== 0 ? 'collapsed' : ''}`}
type="button"
data-bs-toggle="collapse"
data-bs-target={`#faq${index}`}
>
{faq.question}
</button>
</h2>
<div
id={`faq${index}`}
className={`accordion-collapse collapse ${index === 0 ? 'show' : ''}`}
data-bs-parent="#faqAccordion"
>
<div className="accordion-body">
{faq.answer}
</div>
</div>
</div>
))}
</div>
</div>
</div>
</div>
</section>
{/* CTA Section */}
<section className="cta-section py-5">
<div className="container py-5 text-center">
<h2 className="display-5 fw-bold text-white mb-4">
{t('landing.cta.title')}
</h2>
<p className="lead text-white-50 mb-4">
{t('landing.cta.subtitle')}
</p>
<Link to="/register" className="btn btn-light btn-lg">
<i className="bi bi-person-plus me-2"></i>
{t('landing.cta.button')}
</Link>
</div>
</section>
{/* Footer */}
<footer className="landing-footer py-4">
<div className="container">
<div className="row align-items-center">
<div className="col-md-6">
<div className="d-flex align-items-center">
<img src={logo} alt="WebMoney" height="30" className="me-2" />
<span className="text-muted">© 2025 WebMoney. {t('landing.footer.rights')}</span>
</div>
</div>
<div className="col-md-6 text-md-end mt-3 mt-md-0">
<a href="#" className="text-muted me-3">{t('landing.footer.privacy')}</a>
<a href="#" className="text-muted me-3">{t('landing.footer.terms')}</a>
<a href="mailto:support@webmoney.app" className="text-muted">{t('landing.footer.contact')}</a>
</div>
</div>
</div>
</footer>
</div>
);
}

View File

@ -4,6 +4,7 @@ import { liabilityAccountService } from '../services/api';
import { useToast } from '../components/Toast';
import { ConfirmModal } from '../components/Modal';
import { useFormatters } from '../hooks';
import LiabilityWizard from '../components/LiabilityWizard';
const LiabilityAccounts = () => {
const { t } = useTranslation();
@ -24,6 +25,7 @@ const LiabilityAccounts = () => {
const [summary, setSummary] = useState(null);
const [loading, setLoading] = useState(true);
const [showImportModal, setShowImportModal] = useState(false);
const [showWizardModal, setShowWizardModal] = useState(false);
const [showDetailModal, setShowDetailModal] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [showReconcileModal, setShowReconcileModal] = useState(false);
@ -70,6 +72,31 @@ const LiabilityAccounts = () => {
}
};
// Download template Excel
const handleDownloadTemplate = async () => {
try {
const blob = await liabilityAccountService.downloadTemplate();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'plantilla_importacion_pasivo.xlsx';
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
toast.success('Plantilla descargada');
} catch (error) {
toast.error('Error al descargar plantilla');
}
};
// Wizard success handler
const handleWizardSuccess = (newAccount) => {
loadAccounts();
setSelectedAccount(newAccount);
setShowDetailModal(true);
};
const handleOpenImportModal = () => {
setImportForm({
file: null,
@ -366,13 +393,44 @@ const LiabilityAccounts = () => {
{t('liabilities.subtitle')}
</small>
</div>
<button
className={`btn btn-primary ${isMobile ? 'w-100' : ''}`}
onClick={handleOpenImportModal}
>
<i className="bi bi-upload me-2"></i>
{isMobile ? t('common.import') : t('liabilities.importContract')}
</button>
<div className={`d-flex gap-2 ${isMobile ? 'flex-column' : ''}`}>
{/* Botão Criar com Wizard */}
<button
className={`btn btn-success ${isMobile ? 'w-100' : ''}`}
onClick={() => setShowWizardModal(true)}
>
<i className="bi bi-plus-circle me-2"></i>
{isMobile ? 'Crear' : 'Crear Pasivo'}
</button>
{/* Dropdown para importação */}
<div className="dropdown">
<button
className={`btn btn-primary dropdown-toggle ${isMobile ? 'w-100' : ''}`}
type="button"
data-bs-toggle="dropdown"
aria-expanded="false"
>
<i className="bi bi-upload me-2"></i>
{isMobile ? t('common.import') : t('liabilities.importContract')}
</button>
<ul className="dropdown-menu dropdown-menu-end">
<li>
<button className="dropdown-item" onClick={handleOpenImportModal}>
<i className="bi bi-file-earmark-excel me-2 text-success"></i>
Importar desde Excel
</button>
</li>
<li><hr className="dropdown-divider" /></li>
<li>
<button className="dropdown-item" onClick={handleDownloadTemplate}>
<i className="bi bi-download me-2 text-primary"></i>
Descargar Plantilla
</button>
</li>
</ul>
</div>
</div>
</div>
{/* Summary Cards */}
@ -488,10 +546,20 @@ const LiabilityAccounts = () => {
<i className="bi bi-inbox fs-1 text-muted mb-3 d-block"></i>
<h5 className="text-muted">{t('liabilities.noContracts')}</h5>
<p className="text-muted mb-3">{t('liabilities.importHint')}</p>
<button className="btn btn-primary" onClick={handleOpenImportModal}>
<i className="bi bi-upload me-2"></i>
{t('liabilities.importContract')}
</button>
<div className="d-flex gap-2 justify-content-center flex-wrap">
<button className="btn btn-success" onClick={() => setShowWizardModal(true)}>
<i className="bi bi-plus-circle me-2"></i>
Crear Pasivo
</button>
<button className="btn btn-primary" onClick={handleOpenImportModal}>
<i className="bi bi-upload me-2"></i>
{t('liabilities.importContract')}
</button>
<button className="btn btn-outline-primary" onClick={handleDownloadTemplate}>
<i className="bi bi-download me-2"></i>
Descargar Plantilla
</button>
</div>
</div>
</div>
) : (
@ -1465,6 +1533,13 @@ const LiabilityAccounts = () => {
</div>
</div>
)}
{/* Liability Wizard Modal */}
<LiabilityWizard
isOpen={showWizardModal}
onClose={() => setShowWizardModal(false)}
onSuccess={handleWizardSuccess}
/>
</div>
);
};

View File

@ -1,10 +1,13 @@
import React, { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../context/AuthContext';
import Footer from '../components/Footer';
import api from '../services/api';
import logo from '../assets/logo-white.png';
const Login = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const { login } = useAuth();
const [formData, setFormData] = useState({
@ -13,6 +16,9 @@ const Login = () => {
});
const [errors, setErrors] = useState({});
const [loading, setLoading] = useState(false);
const [needsActivation, setNeedsActivation] = useState(false);
const [resendingEmail, setResendingEmail] = useState(false);
const [resendSuccess, setResendSuccess] = useState(false);
const handleChange = (e) => {
setFormData({
@ -23,12 +29,19 @@ const Login = () => {
if (errors[e.target.name]) {
setErrors({ ...errors, [e.target.name]: null });
}
// Reset activation state when email changes
if (e.target.name === 'email') {
setNeedsActivation(false);
setResendSuccess(false);
}
};
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
setErrors({});
setNeedsActivation(false);
setResendSuccess(false);
try {
const response = await login(formData);
@ -36,18 +49,42 @@ const Login = () => {
navigate('/dashboard');
}
} catch (error) {
if (error.response?.data?.errors) {
setErrors(error.response.data.errors);
} else if (error.response?.data?.message) {
setErrors({ general: error.response.data.message });
const errorData = error.response?.data;
// Check if it's an activation error
if (errorData?.error === 'email_not_verified') {
setNeedsActivation(true);
setErrors({ general: errorData.message });
} else if (errorData?.error === 'no_subscription') {
setErrors({ general: t('login.noSubscription', 'Você não possui uma assinatura ativa. Por favor, complete o pagamento.') });
} else if (errorData?.errors) {
setErrors(errorData.errors);
} else if (errorData?.message) {
setErrors({ general: errorData.message });
} else {
setErrors({ general: 'Error de conexión. Intenta nuevamente.' });
setErrors({ general: t('errors.connection', 'Erro de conexão. Tente novamente.') });
}
} finally {
setLoading(false);
}
};
const handleResendActivation = async () => {
setResendingEmail(true);
setResendSuccess(false);
try {
const response = await api.post('/resend-activation', { email: formData.email });
if (response.data.success) {
setResendSuccess(true);
}
} catch (error) {
const message = error.response?.data?.message || t('errors.resendFailed', 'Erro ao reenviar email');
setErrors({ general: message });
} finally {
setResendingEmail(false);
}
};
return (
<div className="container">
<div className="row justify-content-center align-items-center min-vh-100">
@ -55,22 +92,49 @@ const Login = () => {
<div className="card shadow-lg border-0">
<div className="card-body p-5">
<div className="text-center mb-4">
<img src={logo} alt="WebMoney" className="mb-3" style={{ height: '80px', width: 'auto' }} />
<Link to="/">
<img src={logo} alt="WebMoney" className="mb-3" style={{ height: '80px', width: 'auto' }} />
</Link>
<h2 className="fw-bold text-primary">WebMoney</h2>
<p className="text-muted">Gestión Financiera Inteligente</p>
<p className="text-muted">{t('landing.hero.subtitle', 'Gestión Financiera Inteligente')}</p>
</div>
{errors.general && (
<div className="alert alert-danger" role="alert">
<i className="bi bi-exclamation-circle me-2"></i>
<div className={`alert ${needsActivation ? 'alert-warning' : 'alert-danger'}`} role="alert">
<i className={`bi ${needsActivation ? 'bi-envelope-exclamation' : 'bi-exclamation-circle'} me-2`}></i>
{errors.general}
</div>
)}
{needsActivation && (
<div className="mb-3">
{resendSuccess ? (
<div className="alert alert-success">
<i className="bi bi-check-circle me-2"></i>
{t('activate.resendSuccess', 'Email de ativação reenviado! Verifique sua caixa de entrada.')}
</div>
) : (
<button
type="button"
className="btn btn-outline-warning w-100"
onClick={handleResendActivation}
disabled={resendingEmail || !formData.email}
>
{resendingEmail ? (
<span className="spinner-border spinner-border-sm me-2"></span>
) : (
<i className="bi bi-envelope me-2"></i>
)}
{t('activate.resend', 'Reenviar email de ativação')}
</button>
)}
</div>
)}
<form onSubmit={handleSubmit}>
<div className="mb-3">
<label htmlFor="email" className="form-label">
Email
{t('auth.email', 'Email')}
</label>
<input
type="email"
@ -90,7 +154,7 @@ const Login = () => {
<div className="mb-3">
<label htmlFor="password" className="form-label">
Contraseña
{t('auth.password', 'Contraseña')}
</label>
<input
type="password"
@ -116,13 +180,22 @@ const Login = () => {
{loading ? (
<>
<span className="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
Iniciando sesión...
{t('common.processing', 'Procesando...')}
</>
) : (
'Iniciar Sesión'
t('auth.login', 'Iniciar Sesión')
)}
</button>
</form>
<div className="text-center mt-4">
<p className="mb-0">
{t('login.noAccount', '¿No tienes cuenta?')}{' '}
<Link to="/register" className="text-decoration-none fw-semibold">
{t('login.createAccount', 'Crea una aquí')}
</Link>
</p>
</div>
</div>
</div>

View File

@ -0,0 +1,160 @@
import React, { useState, useEffect } from 'react';
import { Link, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import api from '../services/api';
import logo from '../assets/logo-white.png';
const PaymentSuccess = () => {
const { t } = useTranslation();
const [searchParams] = useSearchParams();
const [status, setStatus] = useState('loading'); // loading, success, error
const [message, setMessage] = useState('');
const [userEmail, setUserEmail] = useState('');
useEffect(() => {
const confirmPayment = async () => {
const subscriptionId = searchParams.get('subscription_id');
const email = searchParams.get('user_email') || sessionStorage.getItem('pendingActivationEmail');
if (email) {
setUserEmail(email);
}
if (!subscriptionId) {
setStatus('error');
setMessage(t('payment.noSubscriptionId', 'ID da assinatura não encontrado'));
return;
}
try {
// Confirm the subscription with PayPal
const response = await api.post('/subscription/confirm-public', {
subscription_id: subscriptionId,
user_email: email,
});
if (response.data.success) {
setStatus('success');
setMessage(response.data.message || t('payment.success', 'Pagamento confirmado!'));
// Clean up session storage
sessionStorage.removeItem('pendingActivationEmail');
} else {
setStatus('error');
setMessage(response.data.message || t('payment.error', 'Erro ao confirmar pagamento'));
}
} catch (error) {
console.error('Payment confirmation error:', error);
setStatus('error');
setMessage(error.response?.data?.message || t('payment.error', 'Erro ao confirmar pagamento'));
}
};
confirmPayment();
}, [searchParams, t]);
const renderContent = () => {
switch (status) {
case 'loading':
return (
<>
<div className="mb-4">
<div className="spinner-border text-primary" style={{ width: '3rem', height: '3rem' }} role="status">
<span className="visually-hidden">Loading...</span>
</div>
</div>
<h3 className="fw-bold mb-3">
{t('payment.confirming', 'Confirmando pagamento...')}
</h3>
<p className="text-muted">
{t('payment.pleaseWait', 'Por favor, aguarde enquanto processamos seu pagamento.')}
</p>
</>
);
case 'success':
return (
<>
<div className="mb-4">
<div className="bg-success bg-opacity-10 rounded-circle d-inline-flex p-3">
<i className="bi bi-check-circle-fill text-success" style={{ fontSize: '3rem' }}></i>
</div>
</div>
<h3 className="fw-bold text-success mb-3">
{t('payment.successTitle', 'Pagamento Confirmado!')}
</h3>
<p className="text-muted mb-4">
{t('payment.successMessage', 'Sua assinatura foi confirmada com sucesso.')}
</p>
<div className="alert alert-info text-start">
<i className="bi bi-envelope-check me-2"></i>
<strong>{t('payment.checkYourEmail', 'Verifique seu email!')}</strong>
<p className="mb-0 mt-2">
{t('payment.activationSent', 'Enviamos um email de ativação para {{email}}. Clique no link para ativar sua conta e começar a usar o WEBMoney.', { email: userEmail || 'seu email' })}
</p>
</div>
<div className="mt-4">
<Link to="/login" className="btn btn-primary">
<i className="bi bi-box-arrow-in-right me-2"></i>
{t('auth.goToLogin', 'Ir para Login')}
</Link>
</div>
</>
);
case 'error':
return (
<>
<div className="mb-4">
<div className="bg-danger bg-opacity-10 rounded-circle d-inline-flex p-3">
<i className="bi bi-exclamation-circle-fill text-danger" style={{ fontSize: '3rem' }}></i>
</div>
</div>
<h3 className="fw-bold text-danger mb-3">
{t('payment.errorTitle', 'Erro no Pagamento')}
</h3>
<p className="text-muted mb-4">
{message}
</p>
<div className="d-flex gap-2 justify-content-center">
<Link to="/register" className="btn btn-outline-primary">
<i className="bi bi-arrow-left me-2"></i>
{t('common.tryAgain', 'Tentar novamente')}
</Link>
<Link to="/" className="btn btn-outline-secondary">
<i className="bi bi-house me-2"></i>
{t('common.backToHome', 'Voltar ao início')}
</Link>
</div>
</>
);
default:
return null;
}
};
return (
<div className="container">
<div className="row justify-content-center align-items-center min-vh-100">
<div className="col-md-6">
<div className="card shadow-lg border-0">
<div className="card-body p-5 text-center">
<div className="mb-4">
<Link to="/">
<img src={logo} alt="WebMoney" style={{ height: '60px', width: 'auto' }} />
</Link>
</div>
{renderContent()}
</div>
</div>
</div>
</div>
</div>
);
};
export default PaymentSuccess;

View File

@ -1,12 +1,15 @@
import React, { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import React, { useState, useEffect } from 'react';
import { Link, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import Footer from '../components/Footer';
import api, { authService } from '../services/api';
import logo from '../assets/logo-white.png';
const Register = () => {
const navigate = useNavigate();
const { register } = useAuth();
const { t } = useTranslation();
const [searchParams, setSearchParams] = useSearchParams();
const [plans, setPlans] = useState([]);
const [selectedPlan, setSelectedPlan] = useState(null);
const [formData, setFormData] = useState({
name: '',
email: '',
@ -15,6 +18,65 @@ const Register = () => {
});
const [errors, setErrors] = useState({});
const [loading, setLoading] = useState(false);
const [loadingPlans, setLoadingPlans] = useState(true);
const [registrationComplete, setRegistrationComplete] = useState(false);
const [registeredEmail, setRegisteredEmail] = useState('');
const [resendingEmail, setResendingEmail] = useState(false);
const [resendSuccess, setResendSuccess] = useState(false);
const [paymentCanceled, setPaymentCanceled] = useState(false);
// Check if payment was canceled
useEffect(() => {
const canceled = searchParams.get('payment_canceled');
const email = sessionStorage.getItem('pendingActivationEmail');
if (canceled === 'true' && email) {
setPaymentCanceled(true);
// Clean up the unactivated account
api.post('/cancel-registration', { email })
.then(() => {
sessionStorage.removeItem('pendingActivationEmail');
// Clean URL
setSearchParams({});
})
.catch((err) => {
console.error('Error canceling registration:', err);
});
}
}, [searchParams, setSearchParams]);
// Carregar planos
useEffect(() => {
const fetchPlans = async () => {
try {
const response = await api.get('/plans');
// API returns { success: true, data: { plans: [...] } }
const allPlans = response.data.data?.plans || response.data.plans || response.data.data || [];
// Filtrar apenas planos ativos
const activePlans = Array.isArray(allPlans) ? allPlans.filter(p => p.is_active) : [];
setPlans(activePlans);
// Verificar se há plano na URL
const planSlug = searchParams.get('plan');
if (planSlug && activePlans.length > 0) {
const plan = activePlans.find(p => p.slug === planSlug);
if (plan) {
setSelectedPlan(plan);
}
}
// Se só houver um plano, selecioná-lo automaticamente
if (activePlans.length === 1) {
setSelectedPlan(activePlans[0]);
}
} catch (error) {
console.error('Error loading plans:', error);
} finally {
setLoadingPlans(false);
}
};
fetchPlans();
}, [searchParams]);
const handleChange = (e) => {
setFormData({
@ -29,13 +91,43 @@ const Register = () => {
const handleSubmit = async (e) => {
e.preventDefault();
if (!selectedPlan) {
setErrors({ general: t('register.selectPlan', 'Selecione um plano') });
return;
}
setLoading(true);
setErrors({});
try {
const response = await register(formData);
if (response.success) {
navigate('/dashboard');
// Step 1: Register user (won't login - needs activation)
const response = await api.post('/register', {
...formData,
plan_id: selectedPlan.id,
});
if (response.data.success) {
// Step 2: Create PayPal subscription via public endpoint
try {
const subscriptionResponse = await api.post('/subscription/start', {
plan_id: selectedPlan.id,
user_email: formData.email,
});
if (subscriptionResponse.data.data?.approve_url) {
// Save email for later reference when user returns
sessionStorage.setItem('pendingActivationEmail', formData.email);
// Redirect to PayPal
window.location.href = subscriptionResponse.data.data.approve_url;
return;
}
} catch (subError) {
console.error('Subscription error:', subError);
setErrors({
general: t('errors.subscriptionFailed', 'Erro ao criar assinatura. Tente novamente.')
});
}
}
} catch (error) {
if (error.response?.data?.errors) {
@ -43,25 +135,154 @@ const Register = () => {
} else if (error.response?.data?.message) {
setErrors({ general: error.response.data.message });
} else {
setErrors({ general: 'Error de conexión. Intenta nuevamente.' });
setErrors({ general: t('errors.connection', 'Erro de conexão. Tente novamente.') });
}
} finally {
setLoading(false);
}
};
const handleResendEmail = async () => {
setResendingEmail(true);
setResendSuccess(false);
try {
await authService.resendActivation(registeredEmail);
setResendSuccess(true);
setTimeout(() => setResendSuccess(false), 5000);
} catch (error) {
console.error('Error resending email:', error);
} finally {
setResendingEmail(false);
}
};
// Show activation pending screen
if (registrationComplete) {
return (
<div className="container">
<div className="row justify-content-center align-items-center min-vh-100">
<div className="col-md-6">
<div className="card shadow-lg border-0">
<div className="card-body p-5 text-center">
<div className="mb-4">
<Link to="/">
<img src={logo} alt="WebMoney" style={{ height: '60px', width: 'auto' }} />
</Link>
</div>
<div className="mb-4">
<div className="bg-success bg-opacity-10 rounded-circle d-inline-flex p-3 mb-3">
<i className="bi bi-envelope-check text-success" style={{ fontSize: '3rem' }}></i>
</div>
<h3 className="fw-bold text-success">
{t('activate.checkEmail', 'Verifique seu email')}
</h3>
</div>
<p className="text-muted mb-4">
{t('activate.checkEmailMessage', 'Enviamos um email de ativação para {{email}}. Clique no link para ativar sua conta.', { email: registeredEmail })}
</p>
<div className="alert alert-info">
<i className="bi bi-info-circle me-2"></i>
{t('activate.didntReceive', 'Não recebeu o email?')}
</div>
<button
className="btn btn-outline-primary"
onClick={handleResendEmail}
disabled={resendingEmail}
>
{resendingEmail ? (
<span className="spinner-border spinner-border-sm me-2"></span>
) : (
<i className="bi bi-arrow-repeat me-2"></i>
)}
{t('activate.resend', 'Reenviar email')}
</button>
{resendSuccess && (
<div className="alert alert-success mt-3">
<i className="bi bi-check-circle me-2"></i>
{t('activate.resendSuccess', 'Email reenviado com sucesso!')}
</div>
)}
<hr className="my-4" />
<Link to="/login" className="text-decoration-none">
<i className="bi bi-arrow-left me-1"></i>
{t('auth.backToLogin', 'Voltar para login')}
</Link>
</div>
</div>
</div>
</div>
</div>
);
}
return (
<div className="container">
<div className="row justify-content-center align-items-center min-vh-100">
<div className="col-md-6">
<div className="col-lg-8">
<div className="card shadow-lg border-0">
<div className="card-body p-5">
<div className="text-center mb-4">
<img src={logo} alt="WebMoney" className="mb-3" style={{ height: '80px', width: 'auto' }} />
<Link to="/">
<img src={logo} alt="WebMoney" className="mb-3" style={{ height: '80px', width: 'auto' }} />
</Link>
<h2 className="fw-bold text-primary">WebMoney</h2>
<p className="text-muted">Crie sua conta</p>
<p className="text-muted">{t('auth.createAccount', 'Crea tu cuenta')}</p>
</div>
{/* Plan Selection */}
{!loadingPlans && plans.length > 0 && (
<div className="mb-4">
<label className="form-label fw-semibold">
<i className="bi bi-box me-2"></i>
{t('register.selectPlan', 'Selecciona un plan')}
</label>
<div className="row g-3">
{plans.map((plan) => (
<div key={plan.id} className="col-md-4">
<div
className={`card h-100 cursor-pointer ${selectedPlan?.id === plan.id ? 'border-primary bg-primary bg-opacity-10' : ''}`}
style={{ cursor: 'pointer' }}
onClick={() => setSelectedPlan(plan)}
>
<div className="card-body text-center p-3">
<h6 className="card-title mb-1">{plan.name}</h6>
<p className="h5 mb-1 text-primary">
{plan.price > 0 ? `${plan.price}` : t('pricing.free', 'Gratis')}
{plan.price > 0 && <small className="text-muted fs-6">/{plan.billing_period === 'yearly' ? t('pricing.year', 'año') : t('pricing.month', 'mes')}</small>}
</p>
{plan.trial_days > 0 && (
<small className="text-success">
<i className="bi bi-gift me-1"></i>
{plan.trial_days} {t('common.days', 'días')} {t('pricing.trial', 'de prueba')}
</small>
)}
{selectedPlan?.id === plan.id && (
<div className="mt-2">
<i className="bi bi-check-circle-fill text-primary"></i>
</div>
)}
</div>
</div>
</div>
))}
</div>
</div>
)}
{paymentCanceled && (
<div className="alert alert-warning" role="alert">
<i className="bi bi-exclamation-triangle me-2"></i>
{t('register.paymentCanceled', 'O pagamento foi cancelado. Você pode tentar novamente.')}
</div>
)}
{errors.general && (
<div className="alert alert-danger" role="alert">
<i className="bi bi-exclamation-circle me-2"></i>
@ -70,106 +291,122 @@ const Register = () => {
)}
<form onSubmit={handleSubmit}>
<div className="mb-3">
<label htmlFor="name" className="form-label">
Nombre completo
</label>
<input
type="text"
className={`form-control ${errors.name ? 'is-invalid' : ''}`}
id="name"
name="name"
value={formData.name}
onChange={handleChange}
placeholder="Tu nombre"
required
/>
{errors.name && (
<div className="invalid-feedback">{errors.name}</div>
)}
<div className="row">
<div className="col-md-6 mb-3">
<label htmlFor="name" className="form-label">
{t('profile.name', 'Nombre completo')}
</label>
<input
type="text"
className={`form-control ${errors.name ? 'is-invalid' : ''}`}
id="name"
name="name"
value={formData.name}
onChange={handleChange}
placeholder={t('profile.namePlaceholder', 'Tu nombre')}
required
/>
{errors.name && (
<div className="invalid-feedback">{errors.name}</div>
)}
</div>
<div className="col-md-6 mb-3">
<label htmlFor="email" className="form-label">
{t('auth.email', 'Email')}
</label>
<input
type="email"
className={`form-control ${errors.email ? 'is-invalid' : ''}`}
id="email"
name="email"
autoComplete="email"
value={formData.email}
onChange={handleChange}
placeholder="tu@email.com"
required
/>
{errors.email && (
<div className="invalid-feedback">{errors.email}</div>
)}
</div>
</div>
<div className="mb-3">
<label htmlFor="email" className="form-label">
Email
</label>
<input
type="email"
className={`form-control ${errors.email ? 'is-invalid' : ''}`}
id="email"
name="email"
autoComplete="email"
value={formData.email}
onChange={handleChange}
placeholder="tu@email.com"
required
/>
{errors.email && (
<div className="invalid-feedback">{errors.email}</div>
)}
</div>
<div className="row">
<div className="col-md-6 mb-3">
<label htmlFor="password" className="form-label">
{t('auth.password', 'Contraseña')}
</label>
<input
type="password"
className={`form-control ${errors.password ? 'is-invalid' : ''}`}
id="password"
name="password"
autoComplete="new-password"
value={formData.password}
onChange={handleChange}
placeholder={t('profile.passwordHint', 'Mínimo 8 caracteres')}
required
/>
{errors.password && (
<div className="invalid-feedback">{errors.password}</div>
)}
</div>
<div className="mb-3">
<label htmlFor="password" className="form-label">
Contraseña
</label>
<input
type="password"
className={`form-control ${errors.password ? 'is-invalid' : ''}`}
id="password"
name="password"
autoComplete="new-password"
value={formData.password}
onChange={handleChange}
placeholder="Mínimo 8 caracteres"
required
/>
{errors.password && (
<div className="invalid-feedback">{errors.password}</div>
)}
</div>
<div className="mb-3">
<label htmlFor="password_confirmation" className="form-label">
Confirmar contraseña
</label>
<input
type="password"
className={`form-control ${errors.password_confirmation ? 'is-invalid' : ''}`}
id="password_confirmation"
name="password_confirmation"
autoComplete="new-password"
value={formData.password_confirmation}
onChange={handleChange}
placeholder="Repite tu contraseña"
required
/>
{errors.password_confirmation && (
<div className="invalid-feedback">{errors.password_confirmation}</div>
)}
<div className="col-md-6 mb-3">
<label htmlFor="password_confirmation" className="form-label">
{t('profile.confirmPassword', 'Confirmar contraseña')}
</label>
<input
type="password"
className={`form-control ${errors.password_confirmation ? 'is-invalid' : ''}`}
id="password_confirmation"
name="password_confirmation"
autoComplete="new-password"
value={formData.password_confirmation}
onChange={handleChange}
placeholder={t('register.repeatPassword', 'Repite tu contraseña')}
required
/>
{errors.password_confirmation && (
<div className="invalid-feedback">{errors.password_confirmation}</div>
)}
</div>
</div>
<button
type="submit"
className="btn btn-primary w-100 py-2"
className="btn btn-primary w-100 py-2 mt-3"
disabled={loading}
>
{loading ? (
<>
<span className="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
Registrando...
{t('common.processing', 'Procesando...')}
</>
) : selectedPlan && selectedPlan.price > 0 ? (
<>
<i className="bi bi-credit-card me-2"></i>
{t('register.continueToPayment', 'Continuar al pago')}
</>
) : (
'Crear Cuenta'
t('register.createAccount', 'Crear Cuenta')
)}
</button>
{selectedPlan && selectedPlan.price > 0 && (
<p className="text-center text-muted mt-2 small">
<i className="bi bi-shield-check me-1"></i>
{t('pricing.paypalSecure', 'Pago seguro con PayPal')}
</p>
)}
</form>
<div className="text-center mt-4">
<p className="mb-0">
¿Ya tienes cuenta?{' '}
{t('register.alreadyHaveAccount', '¿Ya tienes cuenta?')}{' '}
<Link to="/login" className="text-decoration-none fw-semibold">
Inicia sesión aquí
{t('register.loginHere', 'Inicia sesión aquí')}
</Link>
</p>
</div>

View File

@ -0,0 +1,310 @@
import { useState, useEffect } from 'react';
import api from '../services/api';
import { useToast } from '../components/Toast';
export default function SiteSettings() {
const toast = useToast();
const [loading, setLoading] = useState(true);
const [deploying, setDeploying] = useState(false);
const [status, setStatus] = useState({
mode: 'live',
maintenance_mode: false,
modes_available: {
live: 'Página institucional completa',
maintenance: 'Página de mantenimiento',
}
});
useEffect(() => {
fetchStatus();
}, []);
const fetchStatus = async () => {
try {
const response = await api.get('/admin/site-settings/cnxifly/status');
setStatus(response.data);
} catch (error) {
console.error('Error fetching status:', error);
toast.error('Error al obtener el estado del sitio');
} finally {
setLoading(false);
}
};
const handleToggleMode = async (newMode) => {
try {
setDeploying(true);
const response = await api.post('/admin/site-settings/cnxifly/toggle', { mode: newMode });
if (response.data.success) {
toast.success(response.data.message);
setStatus(prev => ({
...prev,
mode: newMode,
maintenance_mode: newMode !== 'live'
}));
}
} catch (error) {
console.error('Error toggling mode:', error);
toast.error('Error al cambiar el modo');
} finally {
setDeploying(false);
}
};
const handleDeploy = async (mode) => {
if (!confirm(`¿Está seguro de desplegar la página en modo "${mode}"? Esto actualizará cnxifly.com`)) {
return;
}
try {
setDeploying(true);
const response = await api.post('/admin/site-settings/cnxifly/deploy', { mode });
if (response.data.success) {
toast.success(`Configuración actualizada. Use el comando de deploy para aplicar los cambios.`);
// Log deploy instructions to console for copying
console.log('Deploy command:', response.data.deploy_instructions?.command);
setStatus(prev => ({
...prev,
mode: mode,
maintenance_mode: mode === 'maintenance'
}));
}
} catch (error) {
console.error('Error deploying:', error);
toast.error('Error al desplegar la página');
} finally {
setDeploying(false);
}
};
const getModeColor = (mode, isActive) => {
if (!isActive) return 'bg-gray-700 border-gray-600 hover:border-gray-500';
switch (mode) {
case 'live':
return 'bg-green-900/30 border-green-500 ring-2 ring-green-500/30';
case 'maintenance':
return 'bg-yellow-900/30 border-yellow-500 ring-2 ring-yellow-500/30';
case 'construction':
return 'bg-orange-900/30 border-orange-500 ring-2 ring-orange-500/30';
default:
return 'bg-gray-700 border-gray-600';
}
};
const getModeTextColor = (mode) => {
switch (mode) {
case 'live':
return 'text-green-400';
case 'maintenance':
return 'text-yellow-400';
case 'construction':
return 'text-orange-400';
default:
return 'text-gray-400';
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<i className="bi bi-arrow-repeat text-4xl text-blue-500 animate-spin"></i>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white flex items-center gap-3">
<i className="bi bi-globe text-3xl text-blue-400"></i>
Configuración del Sitio cnxifly.com
</h1>
<p className="text-gray-400 mt-1">
Controle el estado de la página institucional de ConneXiFly
</p>
</div>
<button
onClick={fetchStatus}
className="btn btn-secondary flex items-center gap-2"
disabled={loading}
>
<i className={`bi bi-arrow-repeat ${loading ? 'animate-spin' : ''}`}></i>
Actualizar
</button>
</div>
{/* Current Status */}
<div className="bg-gray-800 rounded-xl p-6 border border-gray-700">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-lg font-semibold text-white">Estado Actual</h2>
<p className="text-gray-400 text-sm">El modo seleccionado determina qué página se muestra</p>
</div>
<div className={`flex items-center gap-2 px-4 py-2 rounded-full ${getModeColor(status.mode, true)}`}>
<i className={`bi ${status.mode === 'live' ? 'bi-rocket-takeoff' : 'bi-tools'}`}></i>
<span className={`font-medium capitalize ${getModeTextColor(status.mode)}`}>
{status.mode}
</span>
</div>
</div>
{/* Mode Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Live Mode */}
<div
className={`p-5 rounded-xl border-2 cursor-pointer transition-all ${getModeColor('live', status.mode === 'live')}`}
onClick={() => !deploying && handleToggleMode('live')}
>
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className={`p-3 rounded-lg ${status.mode === 'live' ? 'bg-green-500/20' : 'bg-gray-600'}`}>
<i className={`bi bi-rocket-takeoff text-2xl ${status.mode === 'live' ? 'text-green-400' : 'text-gray-400'}`}></i>
</div>
<div>
<h3 className="font-semibold text-white">Página en Vivo</h3>
<p className="text-sm text-gray-400">
Página institucional completa con todos los productos
</p>
</div>
</div>
{status.mode === 'live' && (
<i className="bi bi-check-circle-fill text-green-400 text-xl"></i>
)}
</div>
<div className="mt-4 text-sm text-gray-500">
Muestra: WebMoney, EZPool, precios, registro y contacto
</div>
</div>
{/* Maintenance Mode */}
<div
className={`p-5 rounded-xl border-2 cursor-pointer transition-all ${getModeColor('maintenance', status.mode === 'maintenance')}`}
onClick={() => !deploying && handleToggleMode('maintenance')}
>
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className={`p-3 rounded-lg ${status.mode === 'maintenance' ? 'bg-yellow-500/20' : 'bg-gray-600'}`}>
<i className={`bi bi-tools text-2xl ${status.mode === 'maintenance' ? 'text-yellow-400' : 'text-gray-400'}`}></i>
</div>
<div>
<h3 className="font-semibold text-white">En Mantenimiento</h3>
<p className="text-sm text-gray-400">
Página simple informando mantenimiento
</p>
</div>
</div>
{status.mode === 'maintenance' && (
<i className="bi bi-check-circle-fill text-yellow-400 text-xl"></i>
)}
</div>
<div className="mt-4 text-sm text-gray-500">
Muestra: Mensaje de mantenimiento con links a los productos
</div>
</div>
</div>
</div>
{/* Deploy Section */}
<div className="bg-gray-800 rounded-xl p-6 border border-gray-700">
<div className="flex items-center gap-3 mb-4">
<i className="bi bi-server text-2xl text-blue-400"></i>
<div>
<h2 className="text-lg font-semibold text-white">Despliegue</h2>
<p className="text-gray-400 text-sm">Aplique los cambios al servidor de producción</p>
</div>
</div>
<div className="bg-gray-900/50 rounded-lg p-4 mb-4">
<div className="flex items-start gap-3">
<i className="bi bi-exclamation-triangle text-yellow-400 text-xl flex-shrink-0 mt-0.5"></i>
<div className="text-sm text-gray-300">
<p className="font-medium text-yellow-400 mb-1">Importante</p>
<p>
Los cambios se guardan en la configuración, pero para aplicarlos al servidor debe ejecutar
el comando de deploy. La página actual en cnxifly.com no cambiará hasta que se despliegue.
</p>
</div>
</div>
</div>
<div className="flex gap-3">
<button
onClick={() => handleDeploy('live')}
disabled={deploying}
className="flex-1 py-3 px-4 bg-green-600 hover:bg-green-700 text-white font-medium rounded-lg flex items-center justify-center gap-2 transition-colors disabled:opacity-50"
>
{deploying ? (
<i className="bi bi-arrow-repeat animate-spin"></i>
) : (
<i className="bi bi-rocket-takeoff"></i>
)}
Desplegar Página Completa
</button>
<button
onClick={() => handleDeploy('maintenance')}
disabled={deploying}
className="flex-1 py-3 px-4 bg-yellow-600 hover:bg-yellow-700 text-white font-medium rounded-lg flex items-center justify-center gap-2 transition-colors disabled:opacity-50"
>
{deploying ? (
<i className="bi bi-arrow-repeat animate-spin"></i>
) : (
<i className="bi bi-tools"></i>
)}
Desplegar Mantenimiento
</button>
</div>
</div>
{/* Preview Links */}
<div className="bg-gray-800 rounded-xl p-6 border border-gray-700">
<h2 className="text-lg font-semibold text-white mb-4">Enlaces de Vista Previa</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<a
href="https://cnxifly.com"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 p-4 bg-gray-700/50 rounded-lg hover:bg-gray-700 transition-colors"
>
<i className="bi bi-globe text-blue-400 text-xl"></i>
<div>
<p className="font-medium text-white">cnxifly.com</p>
<p className="text-sm text-gray-400">Página actual en producción</p>
</div>
</a>
<a
href="https://webmoney.cnxifly.com"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 p-4 bg-gray-700/50 rounded-lg hover:bg-gray-700 transition-colors"
>
<i className="bi bi-wallet2 text-green-400 text-xl"></i>
<div>
<p className="font-medium text-white">webmoney.cnxifly.com</p>
<p className="text-sm text-gray-400">Aplicación WebMoney</p>
</div>
</a>
<a
href="https://ezpool.cnxifly.com"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 p-4 bg-gray-700/50 rounded-lg hover:bg-gray-700 transition-colors"
>
<i className="bi bi-droplet-half text-cyan-400 text-xl"></i>
<div>
<p className="font-medium text-white">ezpool.cnxifly.com</p>
<p className="text-sm text-gray-400">Aplicación EZPool (En breve)</p>
</div>
</a>
</div>
</div>
</div>
);
}

View File

@ -928,11 +928,15 @@ export default function Transactions() {
pending: 'warning',
completed: 'success',
cancelled: 'secondary',
effective: 'success',
scheduled: 'primary',
};
const labels = {
pending: t('transactions.status.pending'),
completed: t('transactions.status.completed'),
cancelled: t('transactions.status.cancelled'),
effective: t('transactions.status.effective'),
scheduled: t('transactions.status.scheduled'),
};
return <span className={`badge bg-${colors[status]}`}>{labels[status]}</span>;
};

1176
frontend/src/pages/Users.jsx Normal file

File diff suppressed because it is too large Load Diff

View File

@ -41,6 +41,7 @@ api.interceptors.response.use(
// Auth Services
export const authService = {
register: async (userData) => {
// Register user but DON'T save token - user needs to pay and activate via email first
const response = await api.post('/register', userData);
return response.data;
},
@ -54,6 +55,22 @@ export const authService = {
return response.data;
},
// Activate account from email link
activateAccount: async (token) => {
const response = await api.post('/activate', { token });
if (response.data.success) {
localStorage.setItem('token', response.data.data.token);
localStorage.setItem('user', JSON.stringify(response.data.data.user));
}
return response.data;
},
// Resend activation email
resendActivation: async (email) => {
const response = await api.post('/resend-activation', { email });
return response.data;
},
logout: async () => {
try {
await api.post('/logout');
@ -400,20 +417,116 @@ export const liabilityAccountService = {
// Status disponíveis para contas
statuses: {
active: 'Ativo',
paid_off: 'Quitado',
defaulted: 'Inadimplente',
active: 'Activo',
paid_off: 'Liquidado',
defaulted: 'En mora',
renegotiated: 'Renegociado',
},
// Status disponíveis para parcelas
installmentStatuses: {
pending: 'Pendente',
paid: 'Pago',
pending: 'Pendiente',
paid: 'Pagado',
partial: 'Parcial',
overdue: 'Atrasado',
overdue: 'Vencido',
cancelled: 'Cancelado',
},
// Download template Excel
downloadTemplate: async () => {
const response = await api.get('/liability-accounts/template', {
responseType: 'blob',
});
return response.data;
},
// Obter tipos de contrato
getContractTypes: async () => {
const response = await api.get('/liability-accounts/contract-types');
return response.data;
},
// Criar conta passivo via wizard
createWithWizard: async (data) => {
const response = await api.post('/liability-accounts/wizard', data);
return response.data;
},
};
// ============================================
// Asset Account Services (Contas Ativo)
// ============================================
export const assetAccountService = {
// Listar todos os ativos
getAll: async (params = {}) => {
const response = await api.get('/asset-accounts', { params });
return response.data;
},
// Obter um ativo específico
getById: async (id) => {
const response = await api.get(`/asset-accounts/${id}`);
return response.data;
},
// Criar novo ativo manualmente
create: async (data) => {
const response = await api.post('/asset-accounts', data);
return response.data;
},
// Atualizar ativo
update: async (id, data) => {
const response = await api.put(`/asset-accounts/${id}`, data);
return response.data;
},
// Excluir ativo
delete: async (id) => {
const response = await api.delete(`/asset-accounts/${id}`);
return response.data;
},
// Obter tipos de ativos e opções
getAssetTypes: async () => {
const response = await api.get('/asset-accounts/asset-types');
return response.data;
},
// Criar ativo via wizard
createWithWizard: async (data) => {
const response = await api.post('/asset-accounts/wizard', data);
return response.data;
},
// Obter resumo geral
getSummary: async () => {
const response = await api.get('/asset-summary');
return response.data;
},
// Atualizar valor de mercado
updateValue: async (id, currentValue, note = null) => {
const response = await api.put(`/asset-accounts/${id}/value`, {
current_value: currentValue,
note: note,
});
return response.data;
},
// Registrar venda/baixa
dispose: async (id, data) => {
const response = await api.post(`/asset-accounts/${id}/dispose`, data);
return response.data;
},
// Status disponíveis
statuses: {
active: 'Activo',
sold: 'Vendido',
depreciated: 'Depreciado',
written_off: 'Dado de baja',
},
};
// ============================================

1055
landing/index.html Normal file

File diff suppressed because it is too large Load Diff

190
landing/maintenance.html Normal file
View File

@ -0,0 +1,190 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ConneXiFly - En Mantenimiento</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<style>
:root {
--primary: #3b82f6;
--secondary: #10b981;
--dark: #0f172a;
--dark-lighter: #1e293b;
--text: #f1f5f9;
--text-muted: #94a3b8;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: "Inter", -apple-system, BlinkMacSystemFont, sans-serif;
background: radial-gradient(ellipse at 20% 20%, rgba(59, 130, 246, 0.15), transparent 50%),
radial-gradient(ellipse at 80% 80%, rgba(16, 185, 129, 0.1), transparent 50%),
var(--dark);
color: var(--text);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
-webkit-font-smoothing: antialiased;
}
.container {
text-align: center;
padding: 2rem;
max-width: 600px;
}
.logo-container {
margin-bottom: 3rem;
}
.logo {
font-size: 3rem;
font-weight: 800;
background: linear-gradient(135deg, var(--primary), var(--secondary));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
display: inline-flex;
align-items: center;
gap: 0.75rem;
}
.logo i {
font-size: 2.5rem;
color: var(--primary);
-webkit-text-fill-color: var(--primary);
}
.maintenance-icon {
font-size: 5rem;
margin-bottom: 2rem;
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.05); opacity: 0.8; }
}
.maintenance-icon i {
background: linear-gradient(135deg, var(--primary), var(--secondary));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
h1 {
font-size: 2.5rem;
font-weight: 800;
margin-bottom: 1rem;
}
.subtitle {
font-size: 1.25rem;
color: var(--text-muted);
margin-bottom: 2rem;
line-height: 1.6;
}
.products-preview {
display: flex;
justify-content: center;
gap: 1.5rem;
margin-top: 3rem;
flex-wrap: wrap;
}
.product-link {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem 1.5rem;
background: var(--dark-lighter);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 1rem;
color: var(--text);
text-decoration: none;
transition: all 0.3s ease;
}
.product-link:hover {
transform: translateY(-3px);
border-color: var(--primary);
box-shadow: 0 10px 30px rgba(59, 130, 246, 0.2);
}
.product-link i {
font-size: 1.5rem;
}
.product-link.webmoney i {
color: var(--primary);
}
.product-link.ezpool i {
color: var(--secondary);
}
.product-link span {
font-weight: 600;
}
.contact {
margin-top: 3rem;
color: var(--text-muted);
}
.contact a {
color: var(--primary);
text-decoration: none;
}
.contact a:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<div class="container">
<div class="logo-container">
<div class="logo">
<i class="bi bi-lightning-charge-fill"></i>
ConneXiFly
</div>
</div>
<div class="maintenance-icon">
<i class="bi bi-gear-wide-connected"></i>
</div>
<h1>Estamos en Mantenimiento</h1>
<p class="subtitle">
Estamos trabajando para traerte una mejor experiencia.
Volveremos muy pronto con novedades increíbles.
</p>
<div class="products-preview">
<a href="https://webmoney.cnxifly.com" class="product-link webmoney">
<i class="bi bi-wallet2"></i>
<span>WebMoney</span>
</a>
<a href="https://ezpool.cnxifly.com" class="product-link ezpool">
<i class="bi bi-droplet-half"></i>
<span>EZPool</span>
</a>
</div>
<div class="contact">
<p>¿Necesitas ayuda? <a href="mailto:admin@cnxifly.com">admin@cnxifly.com</a></p>
</div>
</div>
</body>
</html>