diff --git a/CHANGELOG.md b/CHANGELOG.md index 0010bb8..db9b17e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,40 @@ O formato segue [Keep a Changelog](https://keepachangelog.com/pt-BR/). Este projeto adota [Versionamento Semântico](https://semver.org/pt-BR/). +## [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 diff --git a/VERSION b/VERSION index 5a5c721..ba0a719 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.50.0 +1.51.0 diff --git a/backend/app/Console/Commands/SetupPayPalPlans.php b/backend/app/Console/Commands/SetupPayPalPlans.php new file mode 100644 index 0000000..cfc168a --- /dev/null +++ b/backend/app/Console/Commands/SetupPayPalPlans.php @@ -0,0 +1,185 @@ +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'; + + $response = \Illuminate\Support\Facades\Http::withToken($paypal->getAccessToken()) + ->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(); + } +} diff --git a/backend/app/Http/Controllers/Api/SubscriptionController.php b/backend/app/Http/Controllers/Api/SubscriptionController.php index f0af88d..be825a9 100644 --- a/backend/app/Http/Controllers/Api/SubscriptionController.php +++ b/backend/app/Http/Controllers/Api/SubscriptionController.php @@ -30,6 +30,26 @@ 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 + } + } + return response()->json([ 'success' => true, 'data' => [ @@ -53,6 +73,8 @@ public function status(Request $request): JsonResponse 'features' => $currentPlan->features, 'limits' => $currentPlan->limits, ] : null, + 'usage' => $usage, + 'usage_percentages' => $usagePercentages, ], ]); } @@ -352,6 +374,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) diff --git a/backend/app/Http/Controllers/Api/UserManagementController.php b/backend/app/Http/Controllers/Api/UserManagementController.php new file mode 100644 index 0000000..be49e7f --- /dev/null +++ b/backend/app/Http/Controllers/Api/UserManagementController.php @@ -0,0 +1,293 @@ +validate([ + 'name' => 'required|string|max:255', + 'email' => 'required|email|unique:users,email', + 'password' => 'sometimes|string|min:8', + 'language' => 'sometimes|string|in:es,pt-BR,en', + 'currency' => 'sometimes|string|size:3', + 'user_type' => 'sometimes|string|in:free,pro,admin', + ]); + + // Generate random password if not provided + $password = $validated['password'] ?? bin2hex(random_bytes(8)); + $userType = $validated['user_type'] ?? 'free'; + + $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' => now()->addYears(100), // "Lifetime" subscription + '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)', + ]; + } + } + + 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, + ], + 'user_type' => $userType, + 'subscription' => $subscriptionInfo, + 'temporary_password' => isset($validated['password']) ? null : $password, + ], + ], 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, + '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); + + $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', + ]); + + $user->update($validated); + + return response()->json([ + 'success' => true, + 'message' => 'Usuario actualizado correctamente', + 'data' => $user, + ]); + } + + /** + * 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', + ]); + } + + /** + * 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, + ], + ]); + } +} diff --git a/backend/app/Http/Middleware/AdminOnly.php b/backend/app/Http/Middleware/AdminOnly.php new file mode 100644 index 0000000..fb5f5c5 --- /dev/null +++ b/backend/app/Http/Middleware/AdminOnly.php @@ -0,0 +1,32 @@ +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); + } +} diff --git a/backend/app/Http/Middleware/CheckPlanLimits.php b/backend/app/Http/Middleware/CheckPlanLimits.php new file mode 100644 index 0000000..2b4daf8 --- /dev/null +++ b/backend/app/Http/Middleware/CheckPlanLimits.php @@ -0,0 +1,106 @@ + 'accounts', + 'categories' => 'categories', + '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()->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.", + '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."; + } +} diff --git a/backend/app/Models/Plan.php b/backend/app/Models/Plan.php index 84d0e9a..d888b72 100644 --- a/backend/app/Models/Plan.php +++ b/backend/app/Models/Plan.php @@ -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', ]; diff --git a/backend/app/Models/User.php b/backend/app/Models/User.php index 161f2fd..7c22982 100644 --- a/backend/app/Models/User.php +++ b/backend/app/Models/User.php @@ -91,6 +91,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 ==================== /** diff --git a/backend/app/Services/PayPalService.php b/backend/app/Services/PayPalService.php index b43cf5b..5e9dc4a 100644 --- a/backend/app/Services/PayPalService.php +++ b/backend/app/Services/PayPalService.php @@ -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) @@ -349,7 +354,7 @@ public function isConfigured(): bool /** * Get client ID for frontend */ - public function getClientId(): string + public function getClientId(): ?string { return $this->clientId; } diff --git a/backend/bootstrap/app.php b/backend/bootstrap/app.php index 18b9f0a..4250cc7 100644 --- a/backend/bootstrap/app.php +++ b/backend/bootstrap/app.php @@ -18,6 +18,8 @@ // 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, ]); }) ->withExceptions(function (Exceptions $exceptions): void { diff --git a/backend/routes/api.php b/backend/routes/api.php index 9d125f3..98067f1 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -24,6 +24,7 @@ use App\Http\Controllers\Api\UserPreferenceController; use App\Http\Controllers\Api\PlanController; use App\Http\Controllers\Api\SubscriptionController; +use App\Http\Controllers\Api\UserManagementController; // Public routes with rate limiting Route::post('/register', [AuthController::class, 'register'])->middleware('throttle:register'); @@ -63,15 +64,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 +85,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 +94,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']); @@ -120,9 +123,10 @@ Route::delete('liability-accounts/{accountId}/installments/{installmentId}/reconcile', [LiabilityAccountController::class, 'unreconcile']); // ============================================ - // Transações (Transactions) + // Transações (Transactions) - Com limite de plano // ============================================ - Route::apiResource('transactions', TransactionController::class); + 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 +226,53 @@ 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::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 +282,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) diff --git a/docs/SAAS_STATUS.md b/docs/SAAS_STATUS.md new file mode 100644 index 0000000..22c4cd9 --- /dev/null +++ b/docs/SAAS_STATUS.md @@ -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.* diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 0668c0e..c380ede 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -25,6 +25,7 @@ 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'; function App() { return ( @@ -221,6 +222,16 @@ function App() { } /> + + + + + + } + /> } /> diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.jsx index efbaa5b..fceebd4 100644 --- a/frontend/src/components/Layout.jsx +++ b/frontend/src/components/Layout.jsx @@ -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,6 +51,7 @@ const Layout = ({ children }) => { }; const [expandedGroups, setExpandedGroups] = useState({ + registrations: true, movements: true, planning: true, 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,12 @@ 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 + ...(isAdmin ? [{ path: '/users', icon: 'bi-people', label: t('nav.users') }] : []), ] }, ]; diff --git a/frontend/src/components/dashboard/PlanUsageWidget.jsx b/frontend/src/components/dashboard/PlanUsageWidget.jsx new file mode 100644 index 0000000..132b053 --- /dev/null +++ b/frontend/src/components/dashboard/PlanUsageWidget.jsx @@ -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 ( +
+
+
+ Loading... +
+
+
+ ); + } + + 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 ( +
+
+ + + {t('planUsage.title', 'Uso del Plan')} - {plan.name} + + {plan.is_free && ( + + + {t('planUsage.upgrade', 'Mejorar')} + + )} +
+
+ {hasLimit && ( +
+ + {t('planUsage.limitReached', 'Has alcanzado el límite de tu plan. Actualiza a Pro para continuar.')} +
+ )} + +
+ {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 ( +
+
+
+ + + {label} + + = 100 ? 'text-danger fw-bold' : percentage >= 80 ? 'text-warning' : ''}> + {current}/{limit} + +
+
+
+
+
+
+ ); + })} +
+
+
+ ); +} diff --git a/frontend/src/i18n/locales/es.json b/frontend/src/i18n/locales/es.json index b0f8e40..698d763 100644 --- a/frontend/src/i18n/locales/es.json +++ b/frontend/src/i18n/locales/es.json @@ -86,6 +86,7 @@ "liabilities": "Pasivos", "transactions": "Transacciones", "movements": "Movimientos", + "registrations": "Registros", "import": "Importar", "duplicates": "Duplicados", "transfers": "Transferencias", @@ -103,7 +104,8 @@ "goals": "Metas", "budgets": "Presupuestos", "billing": "Facturación", - "pricing": "Planes" + "pricing": "Planes", + "users": "Usuarios" }, "dashboard": { "title": "Panel de Control", @@ -365,6 +367,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.", @@ -2063,6 +2067,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", diff --git a/frontend/src/i18n/locales/pt-BR.json b/frontend/src/i18n/locales/pt-BR.json index f1c3f30..915ae2f 100644 --- a/frontend/src/i18n/locales/pt-BR.json +++ b/frontend/src/i18n/locales/pt-BR.json @@ -87,6 +87,7 @@ "liabilities": "Passivos", "transactions": "Transações", "movements": "Movimentações", + "registrations": "Cadastros", "import": "Importar", "duplicates": "Duplicatas", "transfers": "Transferências", @@ -104,7 +105,8 @@ "goals": "Metas", "budgets": "Orçamentos", "billing": "Faturamento", - "pricing": "Planos" + "pricing": "Planos", + "users": "Usuários" }, "dashboard": { "title": "Painel de Controle", @@ -367,6 +369,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.", @@ -2081,6 +2085,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", diff --git a/frontend/src/pages/Accounts.jsx b/frontend/src/pages/Accounts.jsx index ad89142..9ae8c7f 100644 --- a/frontend/src/pages/Accounts.jsx +++ b/frontend/src/pages/Accounts.jsx @@ -27,6 +27,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' ou 'liabilities' const [formData, setFormData] = useState({ name: '', @@ -307,7 +308,7 @@ const Accounts = () => { {!isMobile &&

{t('accounts.title')}

}
- {!isMobile && ( + {!isMobile && activeTab === 'accounts' && ( )} - + {activeTab === 'accounts' && ( + + )} + {activeTab === 'liabilities' && ( + + )}
- {/* Summary Cards */} + {/* Tabs */} +
    +
  • + +
  • +
  • + +
  • +
+ + {activeTab === 'accounts' && ( + <> + {/* Summary Cards */}
{/* Total por Moeda */}
@@ -611,23 +654,15 @@ const Accounts = () => { )}
+ + )} - {/* Liability Accounts Section */} - {liabilityAccounts.length > 0 && (filter.type === '' || filter.type === 'liability') && ( -
-
-
- - {t('liabilities.title')} -
- -
+ {/* Tab de Passivos */} + {activeTab === 'liabilities' && ( + <> + {/* Liability Accounts Section */} + {liabilityAccounts.length > 0 ? ( +
{isMobile ? ( // Mobile: Cards Layout @@ -798,6 +833,19 @@ const Accounts = () => { )}
+ ) : ( +
+
+ +

{t('liabilities.noLiabilities')}

+ +
+
+ )} + )} {/* Modal de Criar/Editar */} diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx index 958d454..5fb646e 100644 --- a/frontend/src/pages/Dashboard.jsx +++ b/frontend/src/pages/Dashboard.jsx @@ -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 (
+ {/* Plan Usage Widget - Show for free plan */} + + {/* Header */}
diff --git a/frontend/src/pages/Users.jsx b/frontend/src/pages/Users.jsx new file mode 100644 index 0000000..fb0b99f --- /dev/null +++ b/frontend/src/pages/Users.jsx @@ -0,0 +1,769 @@ +import React, { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import api from '../services/api'; +import { useToast } from '../components/Toast'; +import { ConfirmModal } from '../components/Modal'; + +function Users() { + const { t } = useTranslation(); + const { showToast } = useToast(); + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(true); + const [search, setSearch] = useState(''); + const [filter, setFilter] = useState('all'); // all, active, free + const [pagination, setPagination] = useState({ + current_page: 1, + last_page: 1, + total: 0, + }); + const [summary, setSummary] = useState(null); + const [selectedUser, setSelectedUser] = useState(null); + const [showDeleteModal, setShowDeleteModal] = useState(false); + const [showResetPasswordModal, setShowResetPasswordModal] = useState(false); + const [newPassword, setNewPassword] = useState(null); + const [isMobile, setIsMobile] = useState(window.innerWidth < 768); + + // Create user modal states + const [showCreateModal, setShowCreateModal] = useState(false); + const [createForm, setCreateForm] = useState({ name: '', email: '', password: '', user_type: 'free' }); + const [createdUser, setCreatedUser] = useState(null); + const [creating, setCreating] = useState(false); + + useEffect(() => { + const handleResize = () => setIsMobile(window.innerWidth < 768); + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, []); + + useEffect(() => { + fetchUsers(); + fetchSummary(); + }, [search, filter, pagination.current_page]); + + const fetchSummary = async () => { + try { + const response = await api.get('/admin/users/summary'); + if (response.data.success) { + setSummary(response.data.data); + } + } catch (error) { + console.error('Error fetching summary:', error); + } + }; + + const fetchUsers = async () => { + setLoading(true); + try { + const params = { + page: pagination.current_page, + per_page: 20, + }; + + if (search) { + params.search = search; + } + + if (filter !== 'all') { + params.subscription_status = filter; + } + + const response = await api.get('/admin/users', { params }); + if (response.data.success) { + setUsers(response.data.data); + setPagination(prev => ({ + ...prev, + ...response.data.pagination, + })); + } + } catch (error) { + showToast('Error al cargar usuarios', 'error'); + console.error(error); + } finally { + setLoading(false); + } + }; + + const handleSearch = (e) => { + e.preventDefault(); + setPagination(prev => ({ ...prev, current_page: 1 })); + }; + + const handleDeleteUser = async () => { + if (!selectedUser) return; + + try { + const response = await api.delete(`/admin/users/${selectedUser.id}`); + if (response.data.success) { + showToast('Usuario eliminado correctamente', 'success'); + setShowDeleteModal(false); + setSelectedUser(null); + fetchUsers(); + fetchSummary(); + } + } catch (error) { + showToast(error.response?.data?.message || 'Error al eliminar usuario', 'error'); + } + }; + + const handleResetPassword = async () => { + if (!selectedUser) return; + + try { + const response = await api.post(`/admin/users/${selectedUser.id}/reset-password`); + if (response.data.success) { + setNewPassword(response.data.data.temporary_password); + showToast('Contraseña restablecida', 'success'); + } + } catch (error) { + showToast('Error al restablecer contraseña', 'error'); + } + }; + + const getStatusBadge = (subscription) => { + if (!subscription) { + return Free; + } + + const statusColors = { + active: 'bg-success', + canceled: 'bg-danger', + suspended: 'bg-warning text-dark', + }; + + return ( + + {subscription.plan_name} + + ); + }; + + const handleCreateUser = async (e) => { + e.preventDefault(); + setCreating(true); + + try { + const response = await api.post('/admin/users', createForm); + if (response.data.success) { + setCreatedUser(response.data.data); + showToast('Usuario creado correctamente', 'success'); + fetchUsers(); + fetchSummary(); + } + } catch (error) { + const message = error.response?.data?.message || 'Error al crear usuario'; + showToast(message, 'error'); + } finally { + setCreating(false); + } + }; + + const resetCreateModal = () => { + setShowCreateModal(false); + setCreateForm({ name: '', email: '', password: '', user_type: 'free' }); + setCreatedUser(null); + }; + + const getUserTypeLabel = (type) => { + const labels = { + free: 'Free', + pro: 'Pro (sin suscripción)', + admin: 'Administrador', + }; + return labels[type] || type; + }; + + const getUserTypeBadge = (type) => { + const badges = { + free: 'bg-secondary', + pro: 'bg-success', + admin: 'bg-danger', + }; + return badges[type] || 'bg-secondary'; + }; + + const formatDate = (date) => { + if (!date) return '-'; + return new Date(date).toLocaleDateString('es-ES', { + year: 'numeric', + month: 'short', + day: 'numeric', + }); + }; + + return ( +
+ {/* Header */} +
+
+

+ + Gestión de Usuarios +

+ {!isMobile &&

Administra todos los usuarios del sistema

} +
+ +
+ + {/* Summary Cards */} + {summary && ( +
+
+
+
+
+
+ +
+
+
Total Usuarios
+
{summary.total_users}
+
+
+
+
+
+
+
+
+
+
+ +
+
+
Suscriptores
+
{summary.active_subscribers}
+
+
+
+
+
+
+
+
+
+
+ +
+
+
Free
+
{summary.free_users}
+
+
+
+
+
+
+
+
+
+
+ +
+
+
Nuevos este mes
+
{summary.new_users_this_month}
+
+
+
+
+
+
+ )} + + {/* Search and Filter */} +
+
+
+
+
+
+ + + + setSearch(e.target.value)} + placeholder="Buscar por nombre o email..." + className="form-control bg-dark border-secondary text-white" + /> +
+
+
+
+
+ + + +
+
+
+
+
+ + {/* Users Table */} +
+ {loading ? ( +
+
+ Cargando... +
+
+ ) : users.length === 0 ? ( +
+ +

No se encontraron usuarios

+
+ ) : ( +
+ + + + + + + + + + + + {users.map((user) => ( + + + + + + + + ))} + +
UsuarioPlanUsoRegistroAcciones
+
+
+ + {user.name?.charAt(0)?.toUpperCase() || 'U'} + +
+
+
{user.name}
+
{user.email}
+
+
+
+ {getStatusBadge(user.subscription)} + +
+ + + {user.usage?.accounts || 0} + + + + {user.usage?.transactions || 0} + +
+
+ + {formatDate(user.created_at)} + + + +
+
+ )} + + {/* Pagination */} + {pagination.last_page > 1 && ( +
+
+ Página {pagination.current_page} de {pagination.last_page} ({pagination.total} usuarios) +
+
+ + +
+
+ )} +
+ + {/* Delete Confirmation Modal */} + setShowDeleteModal(false)} + onConfirm={handleDeleteUser} + title="Eliminar Usuario" + message={`¿Estás seguro de que deseas eliminar a ${selectedUser?.name}? Esta acción no se puede deshacer y eliminará todos sus datos.`} + confirmText="Eliminar" + confirmVariant="danger" + /> + + {/* Reset Password Modal */} + {showResetPasswordModal && selectedUser && ( +
+
+
+
+
+ + Restablecer Contraseña +
+ +
+
+ {newPassword ? ( +
+

+ Nueva contraseña para {selectedUser.name}: +

+
+ {newPassword} +
+

+ + Copia esta contraseña y envíala al usuario de forma segura. +

+
+ ) : ( +

+ Se generará una contraseña temporal para {selectedUser.name}. +

+ )} +
+
+ + {!newPassword && ( + + )} +
+
+
+
+ )} + + {/* Create User Modal */} + {showCreateModal && ( +
+
+
+
+
+ + Crear Nuevo Usuario +
+ +
+ + {createdUser ? ( + <> +
+
+
+
+ +
+
¡Usuario Creado!
+

{createdUser.user.name}

+

{createdUser.user.email}

+ + {getUserTypeLabel(createdUser.user_type)} + +
+ + {createdUser.temporary_password && ( +
+

Contraseña temporal:

+ {createdUser.temporary_password} +
+ )} + + {createdUser.subscription && ( +
+ + Plan {createdUser.subscription.plan} activado. {createdUser.subscription.expires} +
+ )} + +
+ + {createdUser.user_type === 'admin' && 'Este usuario tiene permisos de administrador con acceso completo al sistema.'} + {createdUser.user_type === 'pro' && 'Este usuario tiene acceso Pro completo sin necesidad de pagar suscripción.'} + {createdUser.user_type === 'free' && 'Este usuario tiene acceso Free con los límites del plan gratuito.'} +
+ + {createdUser.temporary_password && ( +

+ + Copia esta contraseña y envíala al usuario de forma segura. +

+ )} +
+
+
+ +
+ + ) : ( +
+
+
+ + setCreateForm(prev => ({ ...prev, name: e.target.value }))} + required + placeholder="Nombre completo" + /> +
+ +
+ + setCreateForm(prev => ({ ...prev, email: e.target.value }))} + required + placeholder="email@ejemplo.com" + /> +
+ +
+ + setCreateForm(prev => ({ ...prev, password: e.target.value }))} + placeholder="Dejar vacío para generar automáticamente" + minLength={8} + /> + + Si no se proporciona, se generará una contraseña temporal automáticamente. + +
+ +
+ +
+
setCreateForm(prev => ({ ...prev, user_type: 'free' }))} + > +
+ setCreateForm(prev => ({ ...prev, user_type: 'free' }))} + /> + +
+
+ +
setCreateForm(prev => ({ ...prev, user_type: 'pro' }))} + > +
+ setCreateForm(prev => ({ ...prev, user_type: 'pro' }))} + /> + +
+
+ +
setCreateForm(prev => ({ ...prev, user_type: 'admin' }))} + > +
+ setCreateForm(prev => ({ ...prev, user_type: 'admin' }))} + /> + +
+
+
+
+
+ +
+ + +
+
+ )} +
+
+
+ )} +
+ ); +} + +export default Users;