From 6292b62315d70a19ace5b56bd2d5b6f3e476f357 Mon Sep 17 00:00:00 2001
From: marcoitaloesp-ai
Date: Thu, 18 Dec 2025 00:44:37 +0000
Subject: [PATCH] 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
---
.../app/Console/Commands/SetupPayPalPlans.php | 6 +-
.../Http/Controllers/Api/AuthController.php | 230 ++++-
.../Controllers/Api/CategoryController.php | 30 +
.../Http/Controllers/Api/PlanController.php | 17 +-
.../Api/SubscriptionController.php | 410 ++++++++-
.../app/Http/Middleware/CheckPlanLimits.php | 5 +-
backend/app/Mail/AccountActivationMail.php | 35 +
.../app/Mail/SubscriptionCancelledMail.php | 122 +++
backend/app/Models/EmailVerificationToken.php | 72 ++
backend/app/Models/Subscription.php | 1 +
backend/app/Services/PayPalService.php | 128 ++-
...create_email_verification_tokens_table.php | 27 +
...00_add_pending_status_to_subscriptions.php | 27 +
.../views/emails/account-activation.blade.php | 166 ++++
.../views/emails/due-payments-alert.blade.php | 813 ++++++++++--------
.../views/emails/layouts/base.blade.php | 412 +++++++++
.../subscription-cancelled-text.blade.php | 96 +++
.../emails/subscription-cancelled.blade.php | 164 ++++
.../views/emails/welcome-new-user.blade.php | 451 ++++------
.../resources/views/emails/welcome.blade.php | 235 ++---
backend/routes/api.php | 11 +
frontend/src/App.jsx | 4 +
frontend/src/components/ProtectedRoute.jsx | 5 +-
frontend/src/context/AuthContext.jsx | 5 +-
frontend/src/i18n/locales/en.json | 160 +++-
frontend/src/i18n/locales/es.json | 160 +++-
frontend/src/i18n/locales/pt-BR.json | 160 +++-
frontend/src/pages/ActivateAccount.jsx | 125 +++
frontend/src/pages/Billing.jsx | 80 +-
frontend/src/pages/Landing.css | 41 +
frontend/src/pages/Landing.jsx | 58 +-
frontend/src/pages/Login.jsx | 99 ++-
frontend/src/pages/PaymentSuccess.jsx | 160 ++++
frontend/src/pages/Register.jsx | 184 +++-
frontend/src/services/api.js | 17 +
35 files changed, 3741 insertions(+), 975 deletions(-)
create mode 100644 backend/app/Mail/AccountActivationMail.php
create mode 100644 backend/app/Mail/SubscriptionCancelledMail.php
create mode 100644 backend/app/Models/EmailVerificationToken.php
create mode 100644 backend/database/migrations/2025_12_17_230000_create_email_verification_tokens_table.php
create mode 100644 backend/database/migrations/2025_12_17_232000_add_pending_status_to_subscriptions.php
create mode 100644 backend/resources/views/emails/account-activation.blade.php
create mode 100644 backend/resources/views/emails/layouts/base.blade.php
create mode 100644 backend/resources/views/emails/subscription-cancelled-text.blade.php
create mode 100644 backend/resources/views/emails/subscription-cancelled.blade.php
create mode 100644 frontend/src/pages/ActivateAccount.jsx
create mode 100644 frontend/src/pages/PaymentSuccess.jsx
diff --git a/backend/app/Console/Commands/SetupPayPalPlans.php b/backend/app/Console/Commands/SetupPayPalPlans.php
index cfc168a..870db87 100644
--- a/backend/app/Console/Commands/SetupPayPalPlans.php
+++ b/backend/app/Console/Commands/SetupPayPalPlans.php
@@ -102,7 +102,11 @@ private function createProduct(PayPalService $paypal, Plan $plan): array
? 'https://api-m.sandbox.paypal.com'
: 'https://api-m.paypal.com';
- $response = \Illuminate\Support\Facades\Http::withToken($paypal->getAccessToken())
+ // 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",
diff --git a/backend/app/Http/Controllers/Api/AuthController.php b/backend/app/Http/Controllers/Api/AuthController.php
index 917ff80..e7fc6b6 100644
--- a/backend/app/Http/Controllers/Api/AuthController.php
+++ b/backend/app/Http/Controllers/Api/AuthController.php
@@ -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,14 +111,42 @@ 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
+ $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([
@@ -116,12 +157,14 @@ public function login(Request $request): JsonResponse
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
+ 'email_verified' => true,
],
'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 +173,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)
*/
diff --git a/backend/app/Http/Controllers/Api/CategoryController.php b/backend/app/Http/Controllers/Api/CategoryController.php
index efad76e..f3c9dd6 100644
--- a/backend/app/Http/Controllers/Api/CategoryController.php
+++ b/backend/app/Http/Controllers/Api/CategoryController.php
@@ -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())
diff --git a/backend/app/Http/Controllers/Api/PlanController.php b/backend/app/Http/Controllers/Api/PlanController.php
index 5a1276f..bd45d3a 100644
--- a/backend/app/Http/Controllers/Api/PlanController.php
+++ b/backend/app/Http/Controllers/Api/PlanController.php
@@ -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,
];
diff --git a/backend/app/Http/Controllers/Api/SubscriptionController.php b/backend/app/Http/Controllers/Api/SubscriptionController.php
index be825a9..b99d025 100644
--- a/backend/app/Http/Controllers/Api/SubscriptionController.php
+++ b/backend/app/Http/Controllers/Api/SubscriptionController.php
@@ -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;
@@ -50,18 +54,46 @@ public function status(Request $request): JsonResponse
}
}
+ // 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,
@@ -166,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
*/
@@ -246,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,
],
]);
}
@@ -265,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();
@@ -277,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,
],
]);
}
@@ -547,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;
diff --git a/backend/app/Http/Middleware/CheckPlanLimits.php b/backend/app/Http/Middleware/CheckPlanLimits.php
index 2b4daf8..f829a61 100644
--- a/backend/app/Http/Middleware/CheckPlanLimits.php
+++ b/backend/app/Http/Middleware/CheckPlanLimits.php
@@ -14,6 +14,7 @@ class CheckPlanLimits
protected array $resourceLimits = [
'accounts' => 'accounts',
'categories' => 'categories',
+ 'subcategories' => 'subcategories',
'budgets' => 'budgets',
'transactions' => 'transactions',
'goals' => 'goals',
@@ -80,7 +81,8 @@ protected function getCurrentCount($user, string $resource): int
{
return match ($resource) {
'accounts' => $user->accounts()->count(),
- 'categories' => $user->categories()->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(),
@@ -96,6 +98,7 @@ 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.",
diff --git a/backend/app/Mail/AccountActivationMail.php b/backend/app/Mail/AccountActivationMail.php
new file mode 100644
index 0000000..4a6b1eb
--- /dev/null
+++ b/backend/app/Mail/AccountActivationMail.php
@@ -0,0 +1,35 @@
+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'];
+ }
+}
diff --git a/backend/app/Models/EmailVerificationToken.php b/backend/app/Models/EmailVerificationToken.php
new file mode 100644
index 0000000..854e03c
--- /dev/null
+++ b/backend/app/Models/EmailVerificationToken.php
@@ -0,0 +1,72 @@
+ '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()]);
+ }
+}
diff --git a/backend/app/Models/Subscription.php b/backend/app/Models/Subscription.php
index 6e1c512..6150436 100644
--- a/backend/app/Models/Subscription.php
+++ b/backend/app/Models/Subscription.php
@@ -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';
diff --git a/backend/app/Services/PayPalService.php b/backend/app/Services/PayPalService.php
index 5e9dc4a..89ebc20 100644
--- a/backend/app/Services/PayPalService.php
+++ b/backend/app/Services/PayPalService.php
@@ -252,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()]);
@@ -343,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
*/
diff --git a/backend/database/migrations/2025_12_17_230000_create_email_verification_tokens_table.php b/backend/database/migrations/2025_12_17_230000_create_email_verification_tokens_table.php
new file mode 100644
index 0000000..29d59ed
--- /dev/null
+++ b/backend/database/migrations/2025_12_17_230000_create_email_verification_tokens_table.php
@@ -0,0 +1,27 @@
+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');
+ }
+};
diff --git a/backend/database/migrations/2025_12_17_232000_add_pending_status_to_subscriptions.php b/backend/database/migrations/2025_12_17_232000_add_pending_status_to_subscriptions.php
new file mode 100644
index 0000000..0e11d08
--- /dev/null
+++ b/backend/database/migrations/2025_12_17_232000_add_pending_status_to_subscriptions.php
@@ -0,0 +1,27 @@
+Olá, {{ $user->name }}
+
+
+
O seu pagamento foi processado com sucesso e a sua subscrição do plano {{ $planName }} está ativa.
+
+
+
+
+ |
+ ✓
+ |
+
+ Pagamento Confirmado
+ |
+
+
+
A sua subscrição está pronta para ser ativada.
+
+
+
+
Detalhes da Subscrição
+
+ - Plano: {{ $planName }}
+ - Email: {{ $user->email }}
+
+
+
+
Para começar a utilizar o WEBMoney, ative a sua conta clicando no botão abaixo:
+
+
+
+
+
+
+ |
+ !
+ |
+
+ Validade do Link
+ |
+
+
+
Este link é válido por 24 horas. Após este período, será necessário solicitar um novo link de ativação.
+
+
+
+ @elseif($locale === 'en')
+ {{-- English --}}
+ Hello, {{ $user->name }}
+
+
+
Your payment has been successfully processed and your {{ $planName }} subscription is active.
+
+
+
+
+ |
+ ✓
+ |
+
+ Payment Confirmed
+ |
+
+
+
Your subscription is ready to be activated.
+
+
+
+
Subscription Details
+
+ - Plan: {{ $planName }}
+ - Email: {{ $user->email }}
+
+
+
+
To start using WEBMoney, activate your account by clicking the button below:
+
+
+
+
+
+
+ |
+ !
+ |
+
+ Link Validity
+ |
+
+
+
This link is valid for 24 hours. After this period, you will need to request a new activation link.
+
+
+
+ @else
+ {{-- Spanish (default) --}}
+ Hola, {{ $user->name }}
+
+
+
Tu pago ha sido procesado con éxito y tu suscripción al plan {{ $planName }} está activa.
+
+
+
+
+ |
+ ✓
+ |
+
+ Pago Confirmado
+ |
+
+
+
Tu suscripción está lista para ser activada.
+
+
+
+
Detalles de la Suscripción
+
+ - Plan: {{ $planName }}
+ - Email: {{ $user->email }}
+
+
+
+
Para comenzar a usar WEBMoney, activa tu cuenta haciendo clic en el botón:
+
+
+
+
+
+
+ |
+ !
+ |
+
+ Validez del Enlace
+ |
+
+
+
Este enlace es válido por 24 horas. Después de este período, deberás solicitar un nuevo enlace de activación.
+
+
+ @endif
+@endsection
diff --git a/backend/resources/views/emails/due-payments-alert.blade.php b/backend/resources/views/emails/due-payments-alert.blade.php
index e0dc2a9..a3f66f2 100644
--- a/backend/resources/views/emails/due-payments-alert.blade.php
+++ b/backend/resources/views/emails/due-payments-alert.blade.php
@@ -1,388 +1,461 @@
-
-
-
-
-
-
-
- WEBMoney - Alerta de Pagamentos
-
-
-
-
-
-
+@extends('emails.layouts.base')
-
-
-
- 💳 Saldo Total Disponível
- {{ number_format($totalAvailable, 2, ',', '.') }} {{ $currency }}
-
-
-
📋 Total a Pagar
-
{{ number_format($totalDue, 2, ',', '.') }} {{ $currency }}
+@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) --}}
+
Olá, {{ $user->name }}
+
+
+
Este é o seu resumo de pagamentos para os próximos dias.
+
+ {{-- Summary Box --}}
+
+
Resumo Financeiro
+
+
+ |
+ Saldo Disponível
+ |
+
+ {{ $currency }} {{ number_format($totalBalance, 2, ',', '.') }}
+ |
+
+
+ |
+ Total a Pagar
+ |
+
+ {{ $currency }} {{ number_format($totalDue, 2, ',', '.') }}
+ |
+
+
+ |
+ Pagamentos Pendentes
+ |
+
+ {{ $totalPayments }}
+ |
+
+
+
+ {{-- Shortage Alert --}}
@if($shortage > 0)
-
- ⚠️ Falta
- {{ number_format($shortage, 2, ',', '.') }} {{ $currency }}
-
- @else
-
- ✅ Situação
- Saldo suficiente!
-
+
+
+
+ |
+ !
+ |
+
+ Saldo Insuficiente
+ |
+
+
+
+ {{ $currency }} {{ number_format($shortage, 2, ',', '.') }}
+ em falta para cobrir todos os pagamentos
+
+
@endif
-
-
- @if($shortage > 0)
-
-
⚠️ SALDO INSUFICIENTE
-
-{{ number_format($shortage, 2, ',', '.') }} {{ $currency }}
-
Você não tem saldo suficiente para cobrir todos os pagamentos.
-
- @endif
-
-
-
-
💳 Saldo das Contas
- @foreach($accountBalances as $account)
-
-
{{ $account['name'] }}
-
- {{ number_format($account['balance'], 2, ',', '.') }} {{ $account['currency'] }}
-
+
+ {{-- Overdue Payments --}}
+ @if(count($overduePayments) > 0)
+
+
+ Pagamentos em Atraso
+
+ @foreach($overduePayments as $payment)
+
+
+
+ |
+ {{ $payment['description'] }}
+ |
+
+ {{ $currency }} {{ number_format($payment['amount'], 2, ',', '.') }}
+ |
+
+
+ |
+ Venceu em {{ \Carbon\Carbon::parse($payment['due_date'])->format('d/m/Y') }}
+ ATRASADO
+ |
+
+
+
+ @endforeach
+
+ @endif
+
+ {{-- Tomorrow Payments --}}
+ @if(count($tomorrowPayments) > 0)
+
+
+ Vencem Amanhã
+
+ @foreach($tomorrowPayments as $payment)
+
+
+
+ |
+ {{ $payment['description'] }}
+ |
+
+ {{ $currency }} {{ number_format($payment['amount'], 2, ',', '.') }}
+ |
+
+
+ |
+ {{ \Carbon\Carbon::parse($payment['due_date'])->format('d/m/Y') }}
+ AMANHÃ
+ |
+
+
+
+ @endforeach
+
+ @endif
+
+ {{-- Upcoming Payments --}}
+ @if(count($upcomingPayments) > 0)
+
+
+ Próximos Pagamentos
+
+ @foreach($upcomingPayments as $payment)
+
+
+
+ |
+ {{ $payment['description'] }}
+ |
+
+ {{ $currency }} {{ number_format($payment['amount'], 2, ',', '.') }}
+ |
+
+
+ |
+ {{ \Carbon\Carbon::parse($payment['due_date'])->format('d/m/Y') }}
+ |
+
+
+
+ @endforeach
+
+ @endif
+
+
+
+
- @endforeach
-
- @if(count($overdueItems) > 0)
-
-
🔴 Pagamentos Vencidos ({{ count($overdueItems) }})
- @foreach($overdueItems as $item)
-
-
-
- {{ $item['days_overdue'] }} dias de atraso
- • Venceu em {{ \Carbon\Carbon::parse($item['due_date'])->format('d/m/Y') }}
- @if($item['account_name'])
- • Conta: {{ $item['account_name'] }}
- @endif
-
+ @elseif($locale === 'en')
+ {{-- English --}}
+
Hello, {{ $user->name }}
+
+
+
Here is your payment summary for the coming days.
+
+ {{-- Summary Box --}}
+
+
Financial Summary
+
+
+ |
+ Available Balance
+ |
+
+ {{ $currency }} {{ number_format($totalBalance, 2, '.', ',') }}
+ |
+
+
+ |
+ Total Due
+ |
+
+ {{ $currency }} {{ number_format($totalDue, 2, '.', ',') }}
+ |
+
+
+ |
+ Pending Payments
+ |
+
+ {{ $totalPayments }}
+ |
+
+
- @endforeach
-
- @endif
-
-
- @if(count($tomorrowItems) > 0)
-
-
🟡 Vencem Amanhã ({{ count($tomorrowItems) }})
- @foreach($tomorrowItems as $item)
-
-