diff --git a/website/app/Http/Controllers/Account/NotificationController.php b/website/app/Http/Controllers/Account/NotificationController.php new file mode 100644 index 0000000..1daed92 --- /dev/null +++ b/website/app/Http/Controllers/Account/NotificationController.php @@ -0,0 +1,54 @@ +user() + ->notifications() + ->latest() + ->take(20) + ->get() + ->map(fn ($notification) => [ + 'id' => $notification->id, + 'type' => $notification->data['type'] ?? 'unknown', + 'message' => $notification->data['message'] ?? '', + 'data' => $notification->data, + 'read' => $notification->read_at !== null, + 'created_at' => $notification->created_at->diffForHumans(), + ]); + + $unreadCount = $request->user()->unreadNotifications()->count(); + + return response()->json([ + 'notifications' => $notifications, + 'unread_count' => $unreadCount, + ]); + } + + public function markAsRead(Request $request, string $id): JsonResponse + { + $notification = $request->user() + ->notifications() + ->findOrFail($id); + + $notification->markAsRead(); + + return response()->json(['success' => true]); + } + + public function markAllAsRead(Request $request): JsonResponse + { + $request->user()->unreadNotifications->markAsRead(); + + return response()->json(['success' => true]); + } +} diff --git a/website/app/Listeners/HandlePaymentFailed.php b/website/app/Listeners/HandlePaymentFailed.php index bf8fa52..67f4823 100644 --- a/website/app/Listeners/HandlePaymentFailed.php +++ b/website/app/Listeners/HandlePaymentFailed.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Listeners; use App\Events\PaymentFailed; +use App\Notifications\PaymentFailedNotification; use Illuminate\Support\Facades\Log; class HandlePaymentFailed @@ -18,5 +19,12 @@ class HandlePaymentFailed 'currency' => $event->currency, 'reason' => $event->reason, ]); + + $event->user->notify(new PaymentFailedNotification( + gateway: $event->gateway, + amount: $event->amount, + currency: $event->currency, + reason: $event->reason, + )); } } diff --git a/website/app/Listeners/HandlePaymentSucceeded.php b/website/app/Listeners/HandlePaymentSucceeded.php index b9ddee6..b458a9a 100644 --- a/website/app/Listeners/HandlePaymentSucceeded.php +++ b/website/app/Listeners/HandlePaymentSucceeded.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Listeners; use App\Events\PaymentSucceeded; +use App\Notifications\PaymentSucceededNotification; use App\Services\Billing\DunningService; use Illuminate\Support\Facades\Log; use Laravel\Cashier\Subscription; @@ -22,6 +23,8 @@ class HandlePaymentSucceeded 'amount' => $event->transaction->amount, ]); + $event->user->notify(new PaymentSucceededNotification($event->transaction)); + // Reactivate any suspended services if the user pays an overdue subscription $subscriptionId = $event->transaction->subscription_id; diff --git a/website/app/Listeners/HandleSubscriptionCancelled.php b/website/app/Listeners/HandleSubscriptionCancelled.php new file mode 100644 index 0000000..036d289 --- /dev/null +++ b/website/app/Listeners/HandleSubscriptionCancelled.php @@ -0,0 +1,22 @@ +user->id}", [ + 'subscription_id' => $event->subscription->id, + 'type' => $event->subscription->type, + ]); + + $event->user->notify(new SubscriptionCancelledNotification($event->subscription)); + } +} diff --git a/website/app/Listeners/HandleSubscriptionCreated.php b/website/app/Listeners/HandleSubscriptionCreated.php new file mode 100644 index 0000000..56d2a5b --- /dev/null +++ b/website/app/Listeners/HandleSubscriptionCreated.php @@ -0,0 +1,22 @@ +user->id}", [ + 'subscription_id' => $event->subscription->id, + 'type' => $event->subscription->type, + ]); + + $event->user->notify(new SubscriptionCreatedNotification($event->subscription)); + } +} diff --git a/website/app/Notifications/InvoiceGeneratedNotification.php b/website/app/Notifications/InvoiceGeneratedNotification.php new file mode 100644 index 0000000..2b886ee --- /dev/null +++ b/website/app/Notifications/InvoiceGeneratedNotification.php @@ -0,0 +1,63 @@ + */ + public function via(object $notifiable): array + { + return ['mail', 'database']; + } + + public function toMail(object $notifiable): MailMessage + { + $amount = number_format((float) $this->invoice->total, 2); + $currency = strtoupper($this->invoice->currency); + $invoiceUrl = 'https://'.config('app.domains.account').'/billing/invoices'; + + $mail = (new MailMessage) + ->subject("Invoice #{$this->invoice->number} - {$currency} {$amount}") + ->greeting("Hello {$notifiable->name},") + ->line('A new invoice has been generated for your account.') + ->line("Invoice: **#{$this->invoice->number}**") + ->line("Amount: **{$currency} {$amount}**"); + + if ($this->invoice->due_date) { + $dueDate = $this->invoice->due_date->format('M j, Y'); + $mail->line("Due date: **{$dueDate}**"); + } + + return $mail + ->action('View Invoices', $invoiceUrl) + ->line('Thank you for your business!'); + } + + /** @return array */ + public function toArray(object $notifiable): array + { + return [ + 'type' => 'invoice_generated', + 'invoice_id' => $this->invoice->id, + 'invoice_number' => $this->invoice->number, + 'total' => $this->invoice->total, + 'currency' => $this->invoice->currency, + 'due_date' => $this->invoice->due_date?->toDateString(), + 'message' => "Invoice #{$this->invoice->number} generated for {$this->invoice->currency} {$this->invoice->total}.", + ]; + } +} diff --git a/website/app/Notifications/PaymentFailedNotification.php b/website/app/Notifications/PaymentFailedNotification.php new file mode 100644 index 0000000..690b920 --- /dev/null +++ b/website/app/Notifications/PaymentFailedNotification.php @@ -0,0 +1,57 @@ + */ + public function via(object $notifiable): array + { + return ['mail', 'database']; + } + + public function toMail(object $notifiable): MailMessage + { + $amount = number_format($this->amount, 2); + $currency = strtoupper($this->currency); + $billingUrl = 'https://'.config('app.domains.account').'/billing'; + + return (new MailMessage) + ->subject('Payment Failed - Action Required') + ->greeting("Hello {$notifiable->name},") + ->line("We were unable to process your payment of **{$currency} {$amount}**.") + ->line("Reason: {$this->reason}") + ->line('Please update your payment method to avoid service interruption.') + ->action('Update Payment Method', $billingUrl) + ->line('If you need assistance, please contact our support team.'); + } + + /** @return array */ + public function toArray(object $notifiable): array + { + return [ + 'type' => 'payment_failed', + 'amount' => $this->amount, + 'currency' => $this->currency, + 'gateway' => $this->gateway, + 'reason' => $this->reason, + 'message' => "Payment of {$this->currency} {$this->amount} failed: {$this->reason}", + ]; + } +} diff --git a/website/app/Notifications/PaymentSucceededNotification.php b/website/app/Notifications/PaymentSucceededNotification.php new file mode 100644 index 0000000..f19bfbf --- /dev/null +++ b/website/app/Notifications/PaymentSucceededNotification.php @@ -0,0 +1,55 @@ + */ + public function via(object $notifiable): array + { + return ['mail', 'database']; + } + + public function toMail(object $notifiable): MailMessage + { + $amount = number_format((float) $this->transaction->amount, 2); + $currency = strtoupper($this->transaction->currency); + $billingUrl = 'https://'.config('app.domains.account').'/billing'; + + return (new MailMessage) + ->subject("Payment of {$currency} {$amount} Received") + ->greeting("Hello {$notifiable->name}!") + ->line("We've successfully processed your payment of **{$currency} {$amount}**.") + ->line("Payment method: {$this->transaction->payment_method}") + ->line("Transaction ID: {$this->transaction->gateway_transaction_id}") + ->action('View Billing History', $billingUrl) + ->line('Thank you for choosing EZSCALE!'); + } + + /** @return array */ + public function toArray(object $notifiable): array + { + return [ + 'type' => 'payment_succeeded', + 'transaction_id' => $this->transaction->id, + 'amount' => $this->transaction->amount, + 'currency' => $this->transaction->currency, + 'gateway' => $this->transaction->gateway, + 'message' => "Payment of {$this->transaction->currency} {$this->transaction->amount} processed successfully.", + ]; + } +} diff --git a/website/app/Notifications/ServiceProvisionedNotification.php b/website/app/Notifications/ServiceProvisionedNotification.php new file mode 100644 index 0000000..8f55899 --- /dev/null +++ b/website/app/Notifications/ServiceProvisionedNotification.php @@ -0,0 +1,61 @@ + */ + public function via(object $notifiable): array + { + return ['mail', 'database']; + } + + public function toMail(object $notifiable): MailMessage + { + $serviceUrl = 'https://'.config('app.domains.account').'/services/'.$this->service->id; + + $mail = (new MailMessage) + ->subject('Your Service is Ready!') + ->greeting("Hello {$notifiable->name}!") + ->line("Your **{$this->service->service_type}** service has been provisioned and is ready to use."); + + if ($this->service->hostname) { + $mail->line("Hostname: **{$this->service->hostname}**"); + } + + if ($this->service->ipv4_address) { + $mail->line("IP Address: **{$this->service->ipv4_address}**"); + } + + return $mail + ->action('View Service Details', $serviceUrl) + ->line('If you need help getting started, check our knowledge base or contact support.'); + } + + /** @return array */ + public function toArray(object $notifiable): array + { + return [ + 'type' => 'service_provisioned', + 'service_id' => $this->service->id, + 'service_type' => $this->service->service_type, + 'hostname' => $this->service->hostname, + 'ip_address' => $this->service->ipv4_address, + 'message' => "Your {$this->service->service_type} service is ready!", + ]; + } +} diff --git a/website/app/Notifications/SubscriptionCancelledNotification.php b/website/app/Notifications/SubscriptionCancelledNotification.php new file mode 100644 index 0000000..982fbef --- /dev/null +++ b/website/app/Notifications/SubscriptionCancelledNotification.php @@ -0,0 +1,51 @@ + */ + public function via(object $notifiable): array + { + return ['mail', 'database']; + } + + public function toMail(object $notifiable): MailMessage + { + $pricingUrl = 'https://'.config('app.domains.marketing').'/pricing'; + + return (new MailMessage) + ->subject('Subscription Cancelled') + ->greeting("Hello {$notifiable->name},") + ->line("Your subscription **{$this->subscription->type}** has been cancelled.") + ->line('Your service will remain active until the end of your current billing period.') + ->line('You can resubscribe at any time to continue using our services.') + ->action('View Plans', $pricingUrl) + ->line('We hope to see you again soon!'); + } + + /** @return array */ + public function toArray(object $notifiable): array + { + return [ + 'type' => 'subscription_cancelled', + 'subscription_id' => $this->subscription->id, + 'subscription_type' => $this->subscription->type, + 'message' => "Subscription \"{$this->subscription->type}\" has been cancelled.", + ]; + } +} diff --git a/website/app/Notifications/SubscriptionCreatedNotification.php b/website/app/Notifications/SubscriptionCreatedNotification.php new file mode 100644 index 0000000..bea73f9 --- /dev/null +++ b/website/app/Notifications/SubscriptionCreatedNotification.php @@ -0,0 +1,50 @@ + */ + public function via(object $notifiable): array + { + return ['mail', 'database']; + } + + public function toMail(object $notifiable): MailMessage + { + $dashboardUrl = 'https://'.config('app.domains.account').'/dashboard'; + + return (new MailMessage) + ->subject('Subscription Confirmed') + ->greeting("Welcome aboard, {$notifiable->name}!") + ->line("Your subscription **{$this->subscription->type}** has been created successfully.") + ->line('Your service is being provisioned and will be ready shortly.') + ->action('Go to Dashboard', $dashboardUrl) + ->line('Thank you for choosing EZSCALE!'); + } + + /** @return array */ + public function toArray(object $notifiable): array + { + return [ + 'type' => 'subscription_created', + 'subscription_id' => $this->subscription->id, + 'subscription_type' => $this->subscription->type, + 'message' => "Subscription \"{$this->subscription->type}\" created successfully.", + ]; + } +} diff --git a/website/database/migrations/2026_02_09_184044_create_notifications_table.php b/website/database/migrations/2026_02_09_184044_create_notifications_table.php new file mode 100644 index 0000000..8b655ba --- /dev/null +++ b/website/database/migrations/2026_02_09_184044_create_notifications_table.php @@ -0,0 +1,31 @@ +uuid('id')->primary(); + $table->string('type'); + $table->morphs('notifiable'); + $table->text('data'); + $table->timestamp('read_at')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('notifications'); + } +}; diff --git a/website/resources/ts/Components/NotificationBell.vue b/website/resources/ts/Components/NotificationBell.vue new file mode 100644 index 0000000..586a6dc --- /dev/null +++ b/website/resources/ts/Components/NotificationBell.vue @@ -0,0 +1,163 @@ + + + diff --git a/website/resources/ts/Layouts/AccountLayout.vue b/website/resources/ts/Layouts/AccountLayout.vue index e01a776..05bf6e6 100644 --- a/website/resources/ts/Layouts/AccountLayout.vue +++ b/website/resources/ts/Layouts/AccountLayout.vue @@ -4,6 +4,7 @@ import { computed } from 'vue' import { useTheme } from 'vuetify' import { accountNavItems } from '@/navigation/account' import FlashMessages from '@/Components/FlashMessages.vue' +import NotificationBell from '@/Components/NotificationBell.vue' import ThemeSwitcher from '@/Components/ThemeSwitcher.vue' import logoWhite from '@images/ezscale_logo_white.png' @@ -66,6 +67,7 @@ function isActive(matchPrefix: string): boolean {
+ diff --git a/website/resources/ts/Layouts/AdminLayout.vue b/website/resources/ts/Layouts/AdminLayout.vue index f7d8dc8..ed8254b 100644 --- a/website/resources/ts/Layouts/AdminLayout.vue +++ b/website/resources/ts/Layouts/AdminLayout.vue @@ -4,6 +4,7 @@ import { computed } from 'vue' import { useTheme } from 'vuetify' import { adminNavItems } from '@/navigation/admin' import FlashMessages from '@/Components/FlashMessages.vue' +import NotificationBell from '@/Components/NotificationBell.vue' import ThemeSwitcher from '@/Components/ThemeSwitcher.vue' import logoWhite from '@images/ezscale_logo_white.png' @@ -82,6 +83,7 @@ function isActive(matchPrefix: string): boolean { + diff --git a/website/resources/ts/Layouts/MarketingLayout.vue b/website/resources/ts/Layouts/MarketingLayout.vue index 341c431..6aae930 100644 --- a/website/resources/ts/Layouts/MarketingLayout.vue +++ b/website/resources/ts/Layouts/MarketingLayout.vue @@ -31,10 +31,15 @@ const footerLinks = { { title: 'Blog', href: '/blog' }, ], support: [ - { title: 'Help Center', href: '/support' }, - { title: 'Documentation', href: '/docs' }, - { title: 'API Reference', href: '/api' }, - { title: 'Status Page', href: '/status' }, + { title: 'Help Center', href: 'https://ezscale.support' }, + { title: 'Knowledge Base', href: 'https://ezscale.support/en/knowledgebase' }, + { title: 'Status Page', href: 'https://status.ezscale.cloud' }, + ], + legal: [ + { title: 'Terms of Service', href: '/terms-of-service' }, + { title: 'Privacy Policy', href: '/privacy-policy' }, + { title: 'Acceptable Use', href: '/acceptable-use' }, + { title: 'SLA', href: '/sla' }, ], } @@ -126,7 +131,7 @@ const socialLinks = [ - +
- + + + + + +
diff --git a/website/routes/account.php b/website/routes/account.php index f99960e..e5b603f 100644 --- a/website/routes/account.php +++ b/website/routes/account.php @@ -5,6 +5,7 @@ declare(strict_types=1); use App\Http\Controllers\Account\BillingController; use App\Http\Controllers\Account\CheckoutController; use App\Http\Controllers\Account\DashboardController; +use App\Http\Controllers\Account\NotificationController; use App\Http\Controllers\Account\PlanController; use App\Http\Controllers\Account\ProfileController; use App\Http\Controllers\Account\ServiceController; @@ -48,3 +49,8 @@ Route::get('/billing/invoices', [BillingController::class, 'invoices'])->name('a Route::get('/billing/invoices/{invoice}/download', [BillingController::class, 'downloadInvoice'])->name('account.billing.invoices.download'); Route::get('/billing/transactions', [BillingController::class, 'transactions'])->name('account.billing.transactions'); Route::post('/billing/setup-intent', [BillingController::class, 'setupIntent'])->name('account.billing.setup-intent'); + +// Notifications +Route::get('/notifications', [NotificationController::class, 'index'])->name('account.notifications.index'); +Route::post('/notifications/{id}/read', [NotificationController::class, 'markAsRead'])->name('account.notifications.read'); +Route::post('/notifications/read-all', [NotificationController::class, 'markAllAsRead'])->name('account.notifications.read-all'); diff --git a/website/tests/Feature/Account/CustomerAccountTest.php b/website/tests/Feature/Account/CustomerAccountTest.php new file mode 100644 index 0000000..da37fd9 --- /dev/null +++ b/website/tests/Feature/Account/CustomerAccountTest.php @@ -0,0 +1,284 @@ +seed(RoleAndPermissionSeeder::class); + $this->accountUrl = 'http://'.config('app.domains.account'); +}); + +describe('Dashboard', function (): void { + it('allows a customer to view the dashboard', function (): void { + $customer = User::factory()->customer()->create(); + + $this->actingAs($customer) + ->get($this->accountUrl.'/dashboard') + ->assertOk() + ->assertInertia(fn ($page) => $page + ->component('Dashboard') + ->has('activeServicesCount') + ->has('activeSubscriptionsCount') + ->has('pendingInvoicesAmount') + ->has('latestInvoices') + ); + }); + + it('redirects guests to login', function (): void { + $this->get($this->accountUrl.'/dashboard') + ->assertRedirect(); + }); + + it('shows correct active services count', function (): void { + $customer = User::factory()->customer()->create(); + Service::factory()->count(3)->create(['user_id' => $customer->id, 'status' => 'active']); + Service::factory()->create(['user_id' => $customer->id, 'status' => 'suspended']); + + $this->actingAs($customer) + ->get($this->accountUrl.'/dashboard') + ->assertOk() + ->assertInertia(fn ($page) => $page + ->component('Dashboard') + ->where('activeServicesCount', 3) + ); + }); + + it('shows correct pending invoices amount', function (): void { + $customer = User::factory()->customer()->create(); + Invoice::factory()->create(['user_id' => $customer->id, 'status' => 'pending', 'total' => 50.00]); + Invoice::factory()->create(['user_id' => $customer->id, 'status' => 'overdue', 'total' => 25.50]); + Invoice::factory()->create(['user_id' => $customer->id, 'status' => 'paid', 'total' => 100.00]); + + $this->actingAs($customer) + ->get($this->accountUrl.'/dashboard') + ->assertOk() + ->assertInertia(fn ($page) => $page + ->component('Dashboard') + ->where('pendingInvoicesAmount', '75.50') + ); + }); +}); + +describe('Profile', function (): void { + it('allows a customer to view their profile', function (): void { + $customer = User::factory()->customer()->create(); + + $this->actingAs($customer) + ->get($this->accountUrl.'/profile') + ->assertOk() + ->assertInertia(fn ($page) => $page + ->component('Profile/Show') + ->has('user') + ->has('twoFactorEnabled') + ); + }); + + it('allows a customer to update their profile', function (): void { + $customer = User::factory()->customer()->create(); + + $this->actingAs($customer) + ->put($this->accountUrl.'/profile', [ + 'first_name' => 'Jane', + 'last_name' => 'Smith', + 'phone' => '+1234567890', + 'company' => 'Acme Inc', + ]) + ->assertRedirect() + ->assertSessionHas('success', 'Profile updated successfully.'); + + $customer->refresh(); + expect($customer->name)->toBe('Jane Smith'); + expect($customer->phone)->toBe('+1234567890'); + expect($customer->company)->toBe('Acme Inc'); + }); + + it('validates profile update requires first and last name', function (): void { + $customer = User::factory()->customer()->create(); + + $this->actingAs($customer) + ->put($this->accountUrl.'/profile', [ + 'first_name' => '', + 'last_name' => '', + ]) + ->assertSessionHasErrors(['first_name', 'last_name']); + }); + + it('allows a customer to update their password', function (): void { + $customer = User::factory()->customer()->create(); + + $this->actingAs($customer) + ->put($this->accountUrl.'/profile/password', [ + 'current_password' => 'password', + 'password' => 'NewSecurePass1!', + 'password_confirmation' => 'NewSecurePass1!', + ]) + ->assertRedirect() + ->assertSessionHas('success', 'Password updated successfully.'); + }); + + it('rejects password update with wrong current password', function (): void { + $customer = User::factory()->customer()->create(); + + $this->actingAs($customer) + ->put($this->accountUrl.'/profile/password', [ + 'current_password' => 'wrong-password', + 'password' => 'NewSecurePass1!', + 'password_confirmation' => 'NewSecurePass1!', + ]) + ->assertSessionHasErrors('current_password'); + }); + + it('rejects password update when confirmation does not match', function (): void { + $customer = User::factory()->customer()->create(); + + $this->actingAs($customer) + ->put($this->accountUrl.'/profile/password', [ + 'current_password' => 'password', + 'password' => 'NewSecurePass1!', + 'password_confirmation' => 'DifferentPass2!', + ]) + ->assertSessionHasErrors('password'); + }); +}); + +describe('Plans', function (): void { + it('displays active plans to a customer', function (): void { + $customer = User::factory()->customer()->create(); + Plan::factory()->count(3)->create(['status' => 'active']); + Plan::factory()->create(['status' => 'inactive']); + + $this->actingAs($customer) + ->get($this->accountUrl.'/plans') + ->assertOk() + ->assertInertia(fn ($page) => $page + ->component('Plans/Index') + ->has('plansByType') + ); + }); + + it('displays a single plan detail page', function (): void { + $customer = User::factory()->customer()->create(); + $plan = Plan::factory()->create(['status' => 'active']); + + $this->actingAs($customer) + ->get($this->accountUrl.'/plans/'.$plan->id) + ->assertOk() + ->assertInertia(fn ($page) => $page + ->component('Plans/Show') + ->has('plan') + ->where('plan.id', $plan->id) + ); + }); +}); + +describe('Services', function (): void { + it('displays only the customers own services', function (): void { + $customer = User::factory()->customer()->create(); + $otherUser = User::factory()->customer()->create(); + + Service::factory()->count(2)->create(['user_id' => $customer->id]); + Service::factory()->count(3)->create(['user_id' => $otherUser->id]); + + $this->actingAs($customer) + ->get($this->accountUrl.'/services') + ->assertOk() + ->assertInertia(fn ($page) => $page + ->component('Services/Index') + ->has('services', 2) + ); + }); + + it('allows a customer to view their own service detail', function (): void { + $customer = User::factory()->customer()->create(); + $service = Service::factory()->create(['user_id' => $customer->id]); + + $this->actingAs($customer) + ->get($this->accountUrl.'/services/'.$service->id) + ->assertOk() + ->assertInertia(fn ($page) => $page + ->component('Services/Show') + ->has('service') + ->where('service.id', $service->id) + ); + }); + + it('forbids a customer from viewing another users service', function (): void { + $customer = User::factory()->customer()->create(); + $otherUser = User::factory()->customer()->create(); + $service = Service::factory()->create(['user_id' => $otherUser->id]); + + $this->actingAs($customer) + ->get($this->accountUrl.'/services/'.$service->id) + ->assertForbidden(); + }); +}); + +describe('Subscriptions', function (): void { + it('displays the subscriptions list page', function (): void { + $customer = User::factory()->customer()->create(); + + $this->actingAs($customer) + ->get($this->accountUrl.'/subscriptions') + ->assertOk() + ->assertInertia(fn ($page) => $page + ->component('Subscriptions/Index') + ->has('subscriptions') + ); + }); + + it('requires authentication to view subscriptions', function (): void { + $this->get($this->accountUrl.'/subscriptions') + ->assertRedirect(); + }); +}); + +describe('Billing', function (): void { + it('displays the billing index page', function (): void { + $customer = User::factory()->customer()->create(); + + $this->actingAs($customer) + ->get($this->accountUrl.'/billing') + ->assertOk() + ->assertInertia(fn ($page) => $page + ->component('Billing/Index') + ->has('paymentMethods') + ->has('invoices') + ->has('transactions') + ); + }); + + it('displays the invoices page with customer invoices', function (): void { + $customer = User::factory()->customer()->create(); + Invoice::factory()->count(3)->create(['user_id' => $customer->id]); + + $this->actingAs($customer) + ->get($this->accountUrl.'/billing/invoices') + ->assertOk() + ->assertInertia(fn ($page) => $page + ->component('Billing/Invoices') + ->has('invoices.data', 3) + ); + }); + + it('displays the transactions page', function (): void { + $customer = User::factory()->customer()->create(); + + $this->actingAs($customer) + ->get($this->accountUrl.'/billing/transactions') + ->assertOk() + ->assertInertia(fn ($page) => $page + ->component('Billing/Transactions') + ->has('transactions') + ); + }); + + it('requires authentication to view billing', function (): void { + $this->get($this->accountUrl.'/billing') + ->assertRedirect(); + }); +}); diff --git a/website/tests/Feature/Admin/AdminPanelTest.php b/website/tests/Feature/Admin/AdminPanelTest.php new file mode 100644 index 0000000..f574d28 --- /dev/null +++ b/website/tests/Feature/Admin/AdminPanelTest.php @@ -0,0 +1,635 @@ +seed(RoleAndPermissionSeeder::class); + $this->adminUrl = 'http://'.config('app.domains.admin'); +}); + +// --------------------------------------------------------------------------- +// Dashboard +// --------------------------------------------------------------------------- +describe('Dashboard', function (): void { + it('allows admin to access the dashboard', function (): void { + $admin = User::factory()->admin()->create(); + + $this->actingAs($admin) + ->get($this->adminUrl.'/dashboard') + ->assertOk() + ->assertInertia(fn ($page) => $page + ->component('Admin/Dashboard') + ->has('totalCustomers') + ->has('mrr') + ->has('totalRevenue') + ->has('activeServices') + ->has('recentInvoices') + ->has('recentSubscriptions') + ->has('popularPlans') + ); + }); + + it('denies customer access to the admin dashboard', function (): void { + $customer = User::factory()->customer()->create(); + + $this->actingAs($customer) + ->get($this->adminUrl.'/dashboard') + ->assertForbidden(); + }); + + it('redirects guest to login when accessing admin dashboard', function (): void { + $this->get($this->adminUrl.'/dashboard') + ->assertRedirect(); + }); +}); + +// --------------------------------------------------------------------------- +// Customer Management +// --------------------------------------------------------------------------- +describe('Customer Management', function (): void { + it('displays the customer list page', function (): void { + $admin = User::factory()->admin()->create(); + User::factory()->customer()->count(3)->create(); + + $this->actingAs($admin) + ->get($this->adminUrl.'/customers') + ->assertOk() + ->assertInertia(fn ($page) => $page + ->component('Admin/Customers/Index') + ->has('customers.data', 3) + ->has('filters') + ); + }); + + it('filters customers by search query', function (): void { + $admin = User::factory()->admin()->create(); + User::factory()->customer()->create(['name' => 'Alice Wonderland']); + User::factory()->customer()->create(['name' => 'Bob Builder']); + + $this->actingAs($admin) + ->get($this->adminUrl.'/customers?search=Alice') + ->assertOk() + ->assertInertia(fn ($page) => $page + ->component('Admin/Customers/Index') + ->has('customers.data', 1) + ); + }); + + it('shows a customer detail page with services and invoices', function (): void { + $admin = User::factory()->admin()->create(); + $customer = User::factory()->customer()->create(); + Service::factory()->create(['user_id' => $customer->id]); + Invoice::factory()->count(2)->create(['user_id' => $customer->id]); + + $this->actingAs($admin) + ->get($this->adminUrl.'/customers/'.$customer->id) + ->assertOk() + ->assertInertia(fn ($page) => $page + ->component('Admin/Customers/Show') + ->has('customer') + ->has('recentInvoices') + ->has('auditLogs') + ); + }); + + it('suspends a customer account', function (): void { + $admin = User::factory()->admin()->create(); + $customer = User::factory()->customer()->create(); + + $this->actingAs($admin) + ->post($this->adminUrl.'/customers/'.$customer->id.'/suspend') + ->assertRedirect(); + + expect($customer->fresh()->status)->toBe('suspended'); + $this->assertDatabaseHas('audit_logs', [ + 'user_id' => $customer->id, + 'admin_id' => $admin->id, + 'action' => 'suspend_account', + ]); + }); + + it('unsuspends a customer account', function (): void { + $admin = User::factory()->admin()->create(); + $customer = User::factory()->customer()->suspended()->create(); + + $this->actingAs($admin) + ->post($this->adminUrl.'/customers/'.$customer->id.'/unsuspend') + ->assertRedirect(); + + expect($customer->fresh()->status)->toBe('active'); + $this->assertDatabaseHas('audit_logs', [ + 'user_id' => $customer->id, + 'admin_id' => $admin->id, + 'action' => 'unsuspend_account', + ]); + }); +}); + +// --------------------------------------------------------------------------- +// Plan Management +// --------------------------------------------------------------------------- +describe('Plan Management', function (): void { + it('displays the plan index page', function (): void { + $admin = User::factory()->admin()->create(); + Plan::factory()->count(4)->create(); + + $this->actingAs($admin) + ->get($this->adminUrl.'/plans') + ->assertOk() + ->assertInertia(fn ($page) => $page + ->component('Admin/Plans/Index') + ->has('plans', 4) + ->has('filters') + ); + }); + + it('filters plans by service type', function (): void { + $admin = User::factory()->admin()->create(); + Plan::factory()->create(['service_type' => 'vps']); + Plan::factory()->create(['service_type' => 'dedicated']); + Plan::factory()->create(['service_type' => 'vps']); + + $this->actingAs($admin) + ->get($this->adminUrl.'/plans?service_type=vps') + ->assertOk() + ->assertInertia(fn ($page) => $page + ->component('Admin/Plans/Index') + ->has('plans', 2) + ); + }); + + it('displays the create plan page', function (): void { + $admin = User::factory()->admin()->create(); + + $this->actingAs($admin) + ->get($this->adminUrl.'/plans/create') + ->assertOk() + ->assertInertia(fn ($page) => $page + ->component('Admin/Plans/Create') + ); + }); + + it('stores a new plan', function (): void { + $admin = User::factory()->admin()->create(); + + $this->actingAs($admin) + ->post($this->adminUrl.'/plans', [ + 'name' => 'Starter VPS', + 'slug' => 'starter-vps', + 'description' => 'A basic VPS plan', + 'service_type' => 'vps', + 'price' => 9.99, + 'billing_cycle' => 'monthly', + 'features' => [ + ['key' => 'cpu', 'value' => '2 vCPU'], + ['key' => 'ram', 'value' => '4GB'], + ], + 'stock_quantity' => 100, + 'sort_order' => 1, + ]) + ->assertRedirect(); + + $this->assertDatabaseHas('plans', [ + 'name' => 'Starter VPS', + 'slug' => 'starter-vps', + 'service_type' => 'vps', + 'status' => 'active', + ]); + }); + + it('validates required fields when storing a plan', function (): void { + $admin = User::factory()->admin()->create(); + + $this->actingAs($admin) + ->post($this->adminUrl.'/plans', []) + ->assertSessionHasErrors(['name', 'slug', 'service_type', 'price', 'billing_cycle']); + }); + + it('displays the edit plan page', function (): void { + $admin = User::factory()->admin()->create(); + $plan = Plan::factory()->create(); + + $this->actingAs($admin) + ->get($this->adminUrl.'/plans/'.$plan->id.'/edit') + ->assertOk() + ->assertInertia(fn ($page) => $page + ->component('Admin/Plans/Edit') + ->has('plan') + ->has('subscribersCount') + ); + }); + + it('updates an existing plan', function (): void { + $admin = User::factory()->admin()->create(); + $plan = Plan::factory()->create(['name' => 'Old Name', 'slug' => 'old-name']); + + $this->actingAs($admin) + ->put($this->adminUrl.'/plans/'.$plan->id, [ + 'name' => 'New Name', + 'slug' => 'new-name', + 'service_type' => $plan->service_type, + 'price' => 19.99, + 'billing_cycle' => 'monthly', + 'sort_order' => 0, + ]) + ->assertRedirect(); + + expect($plan->fresh()) + ->name->toBe('New Name') + ->slug->toBe('new-name'); + }); + + it('archives a plan on destroy', function (): void { + $admin = User::factory()->admin()->create(); + $plan = Plan::factory()->create(['status' => 'active']); + + $this->actingAs($admin) + ->delete($this->adminUrl.'/plans/'.$plan->id) + ->assertRedirect(); + + expect($plan->fresh()->status)->toBe('inactive'); + }); +}); + +// --------------------------------------------------------------------------- +// Service Management +// --------------------------------------------------------------------------- +describe('Service Management', function (): void { + it('displays the service list page', function (): void { + $admin = User::factory()->admin()->create(); + $customer = User::factory()->customer()->create(); + Service::factory()->count(3)->create(['user_id' => $customer->id]); + + $this->actingAs($admin) + ->get($this->adminUrl.'/services') + ->assertOk() + ->assertInertia(fn ($page) => $page + ->component('Admin/Services/Index') + ->has('services.data', 3) + ->has('filters') + ); + }); + + it('shows a service detail page', function (): void { + $admin = User::factory()->admin()->create(); + $service = Service::factory()->create(); + + $this->actingAs($admin) + ->get($this->adminUrl.'/services/'.$service->id) + ->assertOk() + ->assertInertia(fn ($page) => $page + ->component('Admin/Services/Show') + ->has('service') + ); + }); + + it('suspends a service', function (): void { + $admin = User::factory()->admin()->create(); + $service = Service::factory()->create(['status' => 'active']); + + $this->actingAs($admin) + ->post($this->adminUrl.'/services/'.$service->id.'/suspend') + ->assertRedirect(); + + expect($service->fresh()->status)->toBe('suspended'); + $this->assertDatabaseHas('audit_logs', [ + 'user_id' => $service->user_id, + 'admin_id' => $admin->id, + 'action' => 'suspend_service', + 'resource_type' => 'service', + 'resource_id' => $service->id, + ]); + }); + + it('unsuspends a service', function (): void { + $admin = User::factory()->admin()->create(); + $service = Service::factory()->suspended()->create(); + + $this->actingAs($admin) + ->post($this->adminUrl.'/services/'.$service->id.'/unsuspend') + ->assertRedirect(); + + expect($service->fresh()->status)->toBe('active'); + $this->assertDatabaseHas('audit_logs', [ + 'action' => 'unsuspend_service', + 'resource_id' => $service->id, + ]); + }); + + it('terminates a service', function (): void { + $admin = User::factory()->admin()->create(); + $service = Service::factory()->create(['status' => 'active']); + + $this->actingAs($admin) + ->post($this->adminUrl.'/services/'.$service->id.'/terminate') + ->assertRedirect(); + + $freshService = $service->fresh(); + expect($freshService->status)->toBe('terminated'); + expect($freshService->terminated_at)->not->toBeNull(); + $this->assertDatabaseHas('audit_logs', [ + 'action' => 'terminate_service', + 'resource_id' => $service->id, + ]); + }); +}); + +// --------------------------------------------------------------------------- +// Invoice Management +// --------------------------------------------------------------------------- +describe('Invoice Management', function (): void { + it('displays the invoice list page', function (): void { + $admin = User::factory()->admin()->create(); + $customer = User::factory()->customer()->create(); + Invoice::factory()->count(5)->create(['user_id' => $customer->id]); + + $this->actingAs($admin) + ->get($this->adminUrl.'/invoices') + ->assertOk() + ->assertInertia(fn ($page) => $page + ->component('Admin/Invoices/Index') + ->has('invoices.data', 5) + ->has('filters') + ); + }); + + it('filters invoices by status', function (): void { + $admin = User::factory()->admin()->create(); + $customer = User::factory()->customer()->create(); + Invoice::factory()->count(2)->create(['user_id' => $customer->id, 'status' => 'paid']); + Invoice::factory()->count(3)->create(['user_id' => $customer->id, 'status' => 'draft']); + + $this->actingAs($admin) + ->get($this->adminUrl.'/invoices?status=paid') + ->assertOk() + ->assertInertia(fn ($page) => $page + ->component('Admin/Invoices/Index') + ->has('invoices.data', 2) + ); + }); + + it('shows an invoice detail page', function (): void { + $admin = User::factory()->admin()->create(); + $invoice = Invoice::factory()->create(); + + $this->actingAs($admin) + ->get($this->adminUrl.'/invoices/'.$invoice->id) + ->assertOk() + ->assertInertia(fn ($page) => $page + ->component('Admin/Invoices/Show') + ->has('invoice') + ); + }); + + it('voids an invoice', function (): void { + $admin = User::factory()->admin()->create(); + $invoice = Invoice::factory()->create(['status' => 'sent']); + + $this->actingAs($admin) + ->post($this->adminUrl.'/invoices/'.$invoice->id.'/void') + ->assertRedirect(); + + expect($invoice->fresh()->status)->toBe('void'); + $this->assertDatabaseHas('audit_logs', [ + 'user_id' => $invoice->user_id, + 'admin_id' => $admin->id, + 'action' => 'void_invoice', + 'resource_type' => 'invoice', + 'resource_id' => $invoice->id, + ]); + }); +}); + +// --------------------------------------------------------------------------- +// Coupon Management +// --------------------------------------------------------------------------- +describe('Coupon Management', function (): void { + it('displays the coupon index page', function (): void { + $admin = User::factory()->admin()->create(); + Coupon::factory()->count(3)->create(); + + $this->actingAs($admin) + ->get($this->adminUrl.'/coupons') + ->assertOk() + ->assertInertia(fn ($page) => $page + ->component('Admin/Coupons/Index') + ->has('coupons.data', 3) + ); + }); + + it('displays the create coupon page', function (): void { + $admin = User::factory()->admin()->create(); + + $this->actingAs($admin) + ->get($this->adminUrl.'/coupons/create') + ->assertOk() + ->assertInertia(fn ($page) => $page + ->component('Admin/Coupons/Create') + ->has('plans') + ); + }); + + it('stores a new coupon', function (): void { + $admin = User::factory()->admin()->create(); + + $this->actingAs($admin) + ->post($this->adminUrl.'/coupons', [ + 'code' => 'SAVE20', + 'type' => 'percentage', + 'value' => 20, + 'max_uses' => 100, + 'expires_at' => now()->addMonth()->toDateTimeString(), + ]) + ->assertRedirect(); + + $this->assertDatabaseHas('coupons', [ + 'code' => 'SAVE20', + 'type' => 'percentage', + 'active' => true, + ]); + }); + + it('validates required fields when storing a coupon', function (): void { + $admin = User::factory()->admin()->create(); + + $this->actingAs($admin) + ->post($this->adminUrl.'/coupons', []) + ->assertSessionHasErrors(['code', 'type', 'value']); + }); + + it('prevents duplicate coupon codes', function (): void { + $admin = User::factory()->admin()->create(); + Coupon::factory()->create(['code' => 'EXISTING']); + + $this->actingAs($admin) + ->post($this->adminUrl.'/coupons', [ + 'code' => 'EXISTING', + 'type' => 'percentage', + 'value' => 10, + ]) + ->assertSessionHasErrors(['code']); + }); + + it('displays the edit coupon page', function (): void { + $admin = User::factory()->admin()->create(); + $coupon = Coupon::factory()->create(); + + $this->actingAs($admin) + ->get($this->adminUrl.'/coupons/'.$coupon->id.'/edit') + ->assertOk() + ->assertInertia(fn ($page) => $page + ->component('Admin/Coupons/Edit') + ->has('coupon') + ->has('plans') + ->has('redemptions') + ); + }); + + it('updates an existing coupon', function (): void { + $admin = User::factory()->admin()->create(); + $coupon = Coupon::factory()->create(['code' => 'OLD10', 'value' => 10]); + + $this->actingAs($admin) + ->put($this->adminUrl.'/coupons/'.$coupon->id, [ + 'code' => 'NEW25', + 'type' => 'fixed', + 'value' => 25, + 'max_uses' => 50, + ]) + ->assertRedirect(); + + $fresh = $coupon->fresh(); + expect($fresh->code)->toBe('NEW25'); + expect((float) $fresh->value)->toBe(25.0); + }); + + it('deactivates a coupon on destroy', function (): void { + $admin = User::factory()->admin()->create(); + $coupon = Coupon::factory()->create(['active' => true]); + + $this->actingAs($admin) + ->delete($this->adminUrl.'/coupons/'.$coupon->id) + ->assertRedirect(); + + expect($coupon->fresh()->active)->toBeFalsy(); + }); +}); + +// --------------------------------------------------------------------------- +// Audit Logs +// --------------------------------------------------------------------------- +describe('Audit Logs', function (): void { + it('displays the audit log index page', function (): void { + $admin = User::factory()->admin()->create(); + AuditLog::factory()->count(5)->create(); + + $this->actingAs($admin) + ->get($this->adminUrl.'/audit-logs') + ->assertOk() + ->assertInertia(fn ($page) => $page + ->component('Admin/AuditLogs/Index') + ->has('auditLogs.data', 5) + ->has('actions') + ->has('filters') + ); + }); + + it('filters audit logs by action', function (): void { + $admin = User::factory()->admin()->create(); + AuditLog::factory()->count(2)->create(['action' => 'login']); + AuditLog::factory()->count(3)->create(['action' => 'payment_failed']); + + $this->actingAs($admin) + ->get($this->adminUrl.'/audit-logs?action=login') + ->assertOk() + ->assertInertia(fn ($page) => $page + ->component('Admin/AuditLogs/Index') + ->has('auditLogs.data', 2) + ); + }); + + it('denies customer access to audit logs', function (): void { + $customer = User::factory()->customer()->create(); + + $this->actingAs($customer) + ->get($this->adminUrl.'/audit-logs') + ->assertForbidden(); + }); +}); + +// --------------------------------------------------------------------------- +// Settings +// --------------------------------------------------------------------------- +describe('Settings', function (): void { + it('displays the settings index page', function (): void { + $admin = User::factory()->admin()->create(); + + $this->actingAs($admin) + ->get($this->adminUrl.'/settings') + ->assertOk() + ->assertInertia(fn ($page) => $page + ->component('Admin/Settings/Index') + ->has('settings') + ); + }); + + it('updates general settings', function (): void { + $admin = User::factory()->admin()->create(); + + $this->actingAs($admin) + ->put($this->adminUrl.'/settings', [ + 'group' => 'general', + 'company_name' => 'EZSCALE Cloud', + 'company_email' => 'hello@ezscale.cloud', + ]) + ->assertRedirect(); + + expect(Setting::get('company_name'))->toBe('EZSCALE Cloud'); + expect(Setting::get('company_email'))->toBe('hello@ezscale.cloud'); + }); + + it('updates billing settings', function (): void { + $admin = User::factory()->admin()->create(); + + $this->actingAs($admin) + ->put($this->adminUrl.'/settings', [ + 'group' => 'billing', + 'default_currency' => 'USD', + 'grace_period_days' => 14, + 'suspension_warning_days' => 5, + 'auto_terminate_days' => 30, + 'bandwidth_overage_rate' => 0.10, + ]) + ->assertRedirect(); + + expect(Setting::get('grace_period_days'))->toBe('14'); + }); + + it('validates settings update with invalid data', function (): void { + $admin = User::factory()->admin()->create(); + + $this->actingAs($admin) + ->put($this->adminUrl.'/settings', [ + 'group' => 'general', + 'company_email' => 'not-an-email', + ]) + ->assertSessionHasErrors(['company_email']); + }); + + it('denies customer access to settings', function (): void { + $customer = User::factory()->customer()->create(); + + $this->actingAs($customer) + ->get($this->adminUrl.'/settings') + ->assertForbidden(); + }); +});