-
+
+
+
+
+
+
-
+ Simple, transparent pricing
+
+
+
+ Find the Perfect Plan
+
+
+
+ Enterprise-grade infrastructure with transparent, predictable pricing. No hidden fees, no surprises.
+
+
+
+
+
+
+
+
+
+
+
+
+ Preset Plans
+
+
+
+ Build Your Own
+
+
+
+
+
+
+
+
+
+
+ {{ st.label }}
+
+
+
+
+
+
+
+
+
+
{{ cycle.label }}
-
+
+
+
+
+
+
+ Save {{ currentDiscount }}% with {{ getCycleLabel() }} billing
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Custom Configuration Coming Soon
+
+
+ Build Your Own is not yet available for this service type. Switch to Preset Plans or choose a different service.
+
+
+
+
+
+
+
+
+
- -{{ cycle.discount }}%
-
-
+
+
+
+ Most Popular
+
+
+
+
+
+ Best Value
+
+
+
+
+
+
+
+
+
+
+ $
+ {{ getCyclePrice(plan) }}
+ {{ getCycleSuffix() }}
+
+
+ ${{ getMonthlyEquivalent(plan) }}/mo equivalent
+
+
+ Billed monthly
+
+
+
+
+ $
+ 0
+ /mo base
+
+
+ Configure options to build your price
+
+
+
+
+ Custom Pricing
+
+
+ Tailored to your needs
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ humanizeFeatureKey(feat.key) }}
+ {{ feat.value }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Plans Coming Soon
+
+
+ We're finalizing our plans. Check back soon or sign up to be notified.
+
+
+
+
+
+
+
+
+
+
+
+ 14-Day Money-Back Guarantee
+ Try risk-free. Not satisfied? Get a full refund, no questions asked.
+
+
+
-
-
-
-
+
+
+
+
+
-
-
- Popular
-
-
+ Compare Plans
+
+
+ Detailed Comparison
+
+
+ See exactly what each plan includes so you can make the right choice.
+
+
-
-
-
-
+
+
+
+
+
-
-
-
-
-
-
- {{ plan.name }}
-
-
- {{ plan.description || 'High performance hosting' }}
-
-
-
-
-
-
- $
-
-
- {{ getCyclePrice(plan) }}
-
-
- /{{ selectedCycle === 'monthly' ? 'month' : getCycleLabel().toLowerCase() }}
-
-
-
- ${{ getMonthlyEquivalent(plan) }}/mo equivalent
-
-
-
-
-
-
-
-
+
+ {{ plan.name }}
-
-
-
- {{ feat.value }}
-
-
-
-
-
-
-
- Choose Plan
-
-
-
-
-
-
-
-
-
- Plans Coming Soon
-
- We're finalizing our plans. Check back soon or sign up to be notified.
-
-
-
-
-
-
-
-
- All plans include a 30-day money-back guarantee
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Features
- Plan Comparison
-
-
-
- {{ plan.name }}
-
-
-
-
-
- ${{ getCyclePrice(plan) }}/{{ selectedCycle === 'monthly' ? 'Month' : getCycleLabel() }}
-
-
-
-
+
+
+
+ ${{ getCyclePrice(plan) }}{{ getCycleSuffix() }}
+
+
+ Configurable
+
+
+ Custom
+
+
+
+
+
-
-
-
- {{ row.feature }}
-
-
+
-
- {{ planData.value }}
-
-
-
-
-
-
-
-
-
-
-
+ {{ row.feature }}
+
+
-
- Choose Plan
-
-
-
-
-
-
-
-
+
+ {{ planData.value }}
+
+
+
+
+
-
-
-
-
-
-
- FAQ's
-
-
- Let us help answer the most common questions.
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ FAQ
+
+
+ Frequently Asked Questions
+
+
+ Everything you need to know about our plans and billing.
+
+
+
+
+
+
+
+
+ {{ faq.question }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Ready to get started?
+
+
+ Deploy in under 60 seconds. No credit card required for your trial.
+
+
+
+
+
-
+
+
+ @include('reports._header')
+
+
Aging Summary
+
+
+
+ Age Bracket
+ Count
+ Amount
+
+
+
+
+ 0 - 30 Days
+ {{ $data['current']['count'] ?? 0 }}
+ ${{ number_format($data['current']['amount'] ?? 0, 2) }}
+
+
+ 31 - 60 Days
+ {{ $data['days_31_60']['count'] ?? 0 }}
+ ${{ number_format($data['days_31_60']['amount'] ?? 0, 2) }}
+
+
+ 61 - 90 Days
+ {{ $data['days_61_90']['count'] ?? 0 }}
+ ${{ number_format($data['days_61_90']['amount'] ?? 0, 2) }}
+
+
+ 90+ Days
+ {{ $data['days_90_plus']['count'] ?? 0 }}
+ ${{ number_format($data['days_90_plus']['amount'] ?? 0, 2) }}
+
+
+ Total
+ {{ $data['total']['count'] ?? 0 }}
+ ${{ number_format($data['total']['amount'] ?? 0, 2) }}
+
+
+
+
+ @if(!empty($data['invoices']))
+
Outstanding Invoices
+
+
+
+ Invoice #
+ Customer
+ Amount
+ Due Date
+ Days Overdue
+
+
+
+ @foreach($data['invoices'] as $inv)
+
+ {{ $inv['invoice_number'] }}
+ {{ $inv['customer'] }}
+ ${{ number_format($inv['amount'], 2) }}
+ {{ $inv['due_date'] }}
+
+
+ {{ $inv['days_overdue'] }}
+
+
+
+ @endforeach
+
+
+ @endif
+
+
+
+
diff --git a/website/resources/views/reports/profit_loss.blade.php b/website/resources/views/reports/profit_loss.blade.php
new file mode 100644
index 0000000..95c4d55
--- /dev/null
+++ b/website/resources/views/reports/profit_loss.blade.php
@@ -0,0 +1,69 @@
+
+
+
+
+
{{ $meta['title'] ?? 'Profit & Loss Report' }}
+
+
+
+ @include('reports._header')
+
+
+
+
+ Gross Revenue
+ ${{ number_format($data['revenue'] ?? 0, 2) }}
+
+
+
+
+ Refunds
+ (${{ number_format($data['refunds'] ?? 0, 2) }})
+
+
+ Payment Gateway Fees
+ (${{ number_format($data['gateway_fees'] ?? 0, 2) }})
+
+
+ Infrastructure Cost (Est.)
+ (${{ number_format($data['infrastructure_cost'] ?? 0, 2) }})
+
+
+
+ Net Profit
+
+ ${{ number_format($data['net_profit'] ?? 0, 2) }}
+
+
+
+
+
+
{{ $data['margin_percentage'] ?? 0 }}%
+
Profit Margin
+
+
+
+
+
diff --git a/website/resources/views/reports/refund.blade.php b/website/resources/views/reports/refund.blade.php
new file mode 100644
index 0000000..8a20f6d
--- /dev/null
+++ b/website/resources/views/reports/refund.blade.php
@@ -0,0 +1,91 @@
+
+
+
+
+
{{ $meta['title'] ?? 'Refund Report' }}
+
+
+
+ @include('reports._header')
+
+
+
+
+
+
${{ number_format($data['total_refunds'] ?? 0, 2) }}
+
Total Refunds
+
+
+
+
+
+
{{ $data['refund_count'] ?? 0 }}
+
Refund Count
+
+
+
+
+
+ @if(!empty($data['by_gateway']))
+
Refunds by Gateway
+
+
+
+ Gateway
+ Amount
+ Count
+
+
+
+ @foreach($data['by_gateway'] as $row)
+
+ {{ ucfirst($row['gateway']) }}
+ ${{ number_format($row['amount'], 2) }}
+ {{ $row['count'] }}
+
+ @endforeach
+
+
+ @endif
+
+ @if(!empty($data['by_month']))
+
Refunds by Month
+
+
+
+ Month
+ Amount
+ Count
+
+
+
+ @foreach($data['by_month'] as $row)
+
+ {{ $row['month'] }}
+ ${{ number_format($row['amount'], 2) }}
+ {{ $row['count'] }}
+
+ @endforeach
+
+
+ @endif
+
+
+
+
diff --git a/website/resources/views/reports/revenue.blade.php b/website/resources/views/reports/revenue.blade.php
new file mode 100644
index 0000000..82ff6cc
--- /dev/null
+++ b/website/resources/views/reports/revenue.blade.php
@@ -0,0 +1,112 @@
+
+
+
+
+
{{ $meta['title'] ?? 'Revenue Report' }}
+
+
+
+ @include('reports._header')
+
+
+
${{ number_format($data['total_revenue'] ?? 0, 2) }}
+
Total Revenue
+
+
+ @if(!empty($data['by_period']))
+
Revenue by Period
+
+
+
+ Period
+ Amount
+
+
+
+ @foreach($data['by_period'] as $row)
+
+ {{ $row['period'] }}
+ ${{ number_format($row['amount'], 2) }}
+
+ @endforeach
+
+
+ @endif
+
+ @if(!empty($data['by_service_type']))
+
Revenue by Service Type
+
+
+
+ Service Type
+ Amount
+
+
+
+ @foreach($data['by_service_type'] as $row)
+
+ {{ ucfirst($row['type']) }}
+ ${{ number_format($row['amount'], 2) }}
+
+ @endforeach
+
+
+ @endif
+
+ @if(!empty($data['by_gateway']))
+
Revenue by Gateway
+
+
+
+ Gateway
+ Amount
+
+
+
+ @foreach($data['by_gateway'] as $row)
+
+ {{ ucfirst($row['gateway']) }}
+ ${{ number_format($row['amount'], 2) }}
+
+ @endforeach
+
+
+ @endif
+
+ @if(!empty($data['by_plan']))
+
Revenue by Plan
+
+
+
+ Plan
+ Amount
+
+
+
+ @foreach($data['by_plan'] as $row)
+
+ {{ $row['plan'] }}
+ ${{ number_format($row['amount'], 2) }}
+
+ @endforeach
+
+
+ @endif
+
+
+
+
diff --git a/website/resources/views/reports/subscription.blade.php b/website/resources/views/reports/subscription.blade.php
new file mode 100644
index 0000000..dca1ad4
--- /dev/null
+++ b/website/resources/views/reports/subscription.blade.php
@@ -0,0 +1,108 @@
+
+
+
+
+
{{ $meta['title'] ?? 'Subscription Report' }}
+
+
+
+ @include('reports._header')
+
+
+
+
+
+
{{ $data['new_subscriptions'] ?? 0 }}
+
New Subscriptions
+
+
+
+
+
{{ $data['cancelled_subscriptions'] ?? 0 }}
+
Cancelled
+
+
+
+
+
+ {{ $data['churn_rate'] ?? 0 }}%
+
+
Churn Rate
+
+
+
+
+
+
Monthly Recurring Revenue
+
+
+
+ Metric
+ Value
+
+
+
+
+ MRR at Start
+ ${{ number_format($data['mrr_start'] ?? 0, 2) }}
+
+
+ MRR at End
+ ${{ number_format($data['mrr_end'] ?? 0, 2) }}
+
+
+ MRR Change
+
+ {{ ($data['mrr_change'] ?? 0) >= 0 ? '+' : '' }}${{ number_format($data['mrr_change'] ?? 0, 2) }}
+
+
+
+
+
+ @if(!empty($data['by_plan']))
+
Breakdown by Plan
+
+
+
+ Plan
+ New
+ Cancelled
+ Active
+
+
+
+ @foreach($data['by_plan'] as $row)
+
+ {{ $row['plan'] }}
+ {{ $row['new'] }}
+ {{ $row['cancelled'] }}
+ {{ $row['active'] }}
+
+ @endforeach
+
+
+ @endif
+
+
+
+
diff --git a/website/resources/views/reports/tax.blade.php b/website/resources/views/reports/tax.blade.php
new file mode 100644
index 0000000..5934437
--- /dev/null
+++ b/website/resources/views/reports/tax.blade.php
@@ -0,0 +1,77 @@
+
+
+
+
+
{{ $meta['title'] ?? 'Tax Report' }}
+
+
+
+ @include('reports._header')
+
+
+
${{ number_format($data['total_tax'] ?? 0, 2) }}
+
Total Tax Collected
+
+
+ @if(!empty($data['by_country']))
+
Tax by Country
+
+
+
+ Country
+ Tax Amount
+ Invoice Count
+
+
+
+ @foreach($data['by_country'] as $row)
+
+ {{ $row['country'] }}
+ ${{ number_format($row['amount'], 2) }}
+ {{ $row['invoice_count'] }}
+
+ @endforeach
+
+
+ @endif
+
+ @if(!empty($data['by_region']))
+
Tax by Region
+
+
+
+ Region
+ Country
+ Tax Amount
+
+
+
+ @foreach($data['by_region'] as $row)
+
+ {{ $row['region'] }}
+ {{ $row['country'] }}
+ ${{ number_format($row['amount'], 2) }}
+
+ @endforeach
+
+
+ @endif
+
+
+
+
diff --git a/website/routes/account.php b/website/routes/account.php
index 9bc186d..167554e 100644
--- a/website/routes/account.php
+++ b/website/routes/account.php
@@ -5,12 +5,15 @@ 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\LoginHistoryController;
use App\Http\Controllers\Account\NotificationController;
use App\Http\Controllers\Account\PlanController;
use App\Http\Controllers\Account\ProfileController;
use App\Http\Controllers\Account\ServiceController;
+use App\Http\Controllers\Account\SessionController;
use App\Http\Controllers\Account\SubscriptionController;
use App\Http\Controllers\Account\TicketController;
+use App\Http\Controllers\Account\TrustedDeviceController;
use App\Http\Controllers\Account\UpgradeController;
use App\Http\Controllers\Account\VpsController;
use Illuminate\Support\Facades\Route;
@@ -21,10 +24,18 @@ Route::post('/impersonate/stop', [\App\Http\Controllers\Admin\ImpersonationContr
Route::get('/dashboard', [DashboardController::class, 'index'])->name('account.dashboard');
Route::get('/profile', [ProfileController::class, 'show'])->name('account.profile');
+Route::get('/login-history', [LoginHistoryController::class, 'index'])->name('login-history.index');
Route::put('/profile', [ProfileController::class, 'update'])->name('account.profile.update');
Route::put('/profile/password', [ProfileController::class, 'updatePassword'])->name('account.profile.password');
Route::put('/profile/billing', [ProfileController::class, 'updateBilling'])->name('account.profile.billing');
+// Trusted Devices
+Route::delete('/profile/trusted-devices/{device}', [TrustedDeviceController::class, 'destroy'])->name('trusted-devices.destroy');
+Route::delete('/profile/trusted-devices', [TrustedDeviceController::class, 'destroyAll'])->name('trusted-devices.destroy-all');
+
+// Sessions
+Route::delete('/profile/sessions', [SessionController::class, 'destroy'])->name('sessions.destroy');
+
// Plans
Route::get('/plans', [PlanController::class, 'index'])->name('account.plans.index');
Route::get('/plans/{plan}', [PlanController::class, 'show'])->name('account.plans.show');
@@ -33,6 +44,9 @@ Route::get('/plans/{plan}', [PlanController::class, 'show'])->name('account.plan
Route::post('/checkout/apply-coupon', [CheckoutController::class, 'applyCoupon'])->name('account.checkout.apply-coupon');
Route::get('/checkout/paypal/callback', [CheckoutController::class, 'paypalCallback'])->name('account.checkout.paypal-callback');
Route::get('/checkout/paypal/cancel', [CheckoutController::class, 'paypalCancel'])->name('account.checkout.paypal-cancel');
+Route::get('/checkout/custom/{serviceType}', [CheckoutController::class, 'showCustom'])
+ ->where('serviceType', 'vps|mysql|game')
+ ->name('checkout.custom');
Route::get('/checkout/{plan}', [CheckoutController::class, 'show'])->name('account.checkout.show');
Route::post('/checkout/{plan}', [CheckoutController::class, 'store'])->name('account.checkout.store');
diff --git a/website/routes/admin.php b/website/routes/admin.php
index 81a0f09..cd85125 100644
--- a/website/routes/admin.php
+++ b/website/routes/admin.php
@@ -3,6 +3,8 @@
declare(strict_types=1);
use App\Http\Controllers\Admin\AuditLogController;
+use App\Http\Controllers\Admin\CancellationSurveyController;
+use App\Http\Controllers\Admin\ConfigGroupController;
use App\Http\Controllers\Admin\CouponController;
use App\Http\Controllers\Admin\CustomerController;
use App\Http\Controllers\Admin\DashboardController;
@@ -11,10 +13,13 @@ use App\Http\Controllers\Admin\ImpersonationController;
use App\Http\Controllers\Admin\InvoiceController;
use App\Http\Controllers\Admin\OrderController;
use App\Http\Controllers\Admin\PlanController;
+use App\Http\Controllers\Admin\ReportController;
use App\Http\Controllers\Admin\ServiceController;
use App\Http\Controllers\Admin\SettingsController;
use App\Http\Controllers\Admin\TaxRateController;
use App\Http\Controllers\Admin\TicketController as AdminTicketController;
+use App\Http\Controllers\Admin\TransactionController;
+use App\Http\Controllers\Admin\WinbackCampaignController;
use Illuminate\Support\Facades\Route;
Route::get('/dashboard', [DashboardController::class, 'index'])->name('admin.dashboard');
@@ -26,6 +31,7 @@ Route::delete('customers/{user}/purge', [CustomerController::class, 'purge'])->n
Route::post('customers/{user}/reset-password', [CustomerController::class, 'resetPassword'])->name('customers.reset-password');
Route::post('customers/{user}/send-notification', [CustomerController::class, 'sendNotification'])->name('customers.send-notification');
Route::post('customers/{user}/place-order', [CustomerController::class, 'placeOrder'])->name('customers.place-order');
+Route::post('customers/{user}/force-logout', [CustomerController::class, 'forceLogout'])->name('customers.force-logout');
Route::resource('plans', PlanController::class)->names([
'index' => 'admin.plans.index',
@@ -49,6 +55,8 @@ Route::get('invoices/{invoice}/download', [InvoiceController::class, 'download']
Route::post('invoices/{invoice}/void', [InvoiceController::class, 'void'])->name('invoices.void');
Route::post('invoices/{invoice}/resend', [InvoiceController::class, 'resend'])->name('invoices.resend');
+Route::resource('transactions', TransactionController::class)->only(['index', 'show']);
+
Route::get('coupons/redemptions', [CouponController::class, 'redemptions'])->name('admin.coupons.redemptions');
Route::resource('coupons', CouponController::class)->names([
'index' => 'admin.coupons.index',
@@ -83,6 +91,11 @@ Route::put('email-templates/{emailTemplate}', [EmailTemplateController::class, '
Route::post('email-templates/{emailTemplate}/preview', [EmailTemplateController::class, 'preview'])->name('admin.email-templates.preview');
Route::post('email-templates/{emailTemplate}/reset', [EmailTemplateController::class, 'resetToDefault'])->name('admin.email-templates.reset');
+// Financial Reports
+Route::get('reports', [ReportController::class, 'index'])->name('reports.index');
+Route::post('reports/generate', [ReportController::class, 'generate'])->name('reports.generate');
+Route::post('reports/export', [ReportController::class, 'export'])->name('reports.export');
+
Route::get('audit-logs/export', [AuditLogController::class, 'export'])->name('audit-logs.export');
Route::get('audit-logs', [AuditLogController::class, 'index'])->name('audit-logs.index');
@@ -99,6 +112,25 @@ Route::resource('tickets', AdminTicketController::class)->only(['index', 'show']
Route::post('tickets/{ticket}/reply', [AdminTicketController::class, 'reply'])->name('admin.tickets.reply');
Route::put('tickets/{ticket}/status', [AdminTicketController::class, 'updateStatus'])->name('admin.tickets.status');
+// Win-back Campaigns
+Route::resource('winback-campaigns', WinbackCampaignController::class);
+
+// Cancellation Surveys
+Route::get('cancellation-surveys', [CancellationSurveyController::class, 'index'])->name('cancellation-surveys.index');
+
+// Config Groups
+Route::resource('config-groups', ConfigGroupController::class)
+ ->except(['show'])
+ ->names([
+ 'index' => 'admin.config-groups.index',
+ 'create' => 'admin.config-groups.create',
+ 'store' => 'admin.config-groups.store',
+ 'edit' => 'admin.config-groups.edit',
+ 'update' => 'admin.config-groups.update',
+ 'destroy' => 'admin.config-groups.destroy',
+ ])
+ ->parameters(['config-groups' => 'configGroup']);
+
// Impersonation
Route::post('impersonate/{user}', [ImpersonationController::class, 'start'])->name('impersonate.start');
Route::post('impersonate/stop', [ImpersonationController::class, 'stop'])->name('impersonate.stop');
diff --git a/website/routes/console.php b/website/routes/console.php
index 41d0d0c..5e10729 100644
--- a/website/routes/console.php
+++ b/website/routes/console.php
@@ -11,3 +11,4 @@ Artisan::command('inspire', function () {
Schedule::command('billing:process-dunning')->daily()->at('06:00');
Schedule::command('tickets:process-emails')->everyTwoMinutes()->withoutOverlapping();
Schedule::command('provisioning:retry')->everyThirtyMinutes();
+Schedule::command('winback:process')->dailyAt('10:00');
diff --git a/website/routes/marketing.php b/website/routes/marketing.php
index b4e41a5..4245ee3 100644
--- a/website/routes/marketing.php
+++ b/website/routes/marketing.php
@@ -4,10 +4,26 @@ declare(strict_types=1);
use App\Http\Controllers\Marketing\ContactController;
use App\Models\Plan;
+use App\Models\Service;
+use App\Models\User;
+use App\Models\WinbackRecipient;
+use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Route;
use Inertia\Inertia;
-Route::get('/', fn () => Inertia::render('Marketing/Home'))->name('home');
+Route::get('/', function () {
+ $data = Cache::remember('marketing:home-stats', 300, fn () => [
+ 'customerCount' => User::role('customer')->count(),
+ 'serviceCount' => Service::where('status', 'active')->count(),
+ 'startingPrices' => [
+ 'vps' => Plan::where('service_type', 'vps')->where('status', 'active')->min('price'),
+ 'dedicated' => Plan::where('service_type', 'dedicated')->where('status', 'active')->min('price'),
+ 'hosting' => Plan::where('service_type', 'hosting')->where('status', 'active')->min('price'),
+ ],
+ ]);
+
+ return Inertia::render('Marketing/Home', $data);
+})->name('home');
Route::redirect('/products', '/', 301);
Route::get('/vps-hosting', function () {
$plans = Plan::query()
@@ -60,17 +76,35 @@ Route::get('/game-servers', function () {
Route::redirect('/battlefield-acp', '/game-servers', 301);
Route::get('/pricing', function () {
$plans = Plan::query()
+ ->public()
->with('prices')
- ->where('status', 'active')
+ ->withCount('configGroups')
+ ->orderBy('service_type')
->orderBy('sort_order')
->orderBy('price')
->get();
+ $byoConfigGroups = \App\Models\PlanConfigGroup::buildYourOwn()
+ ->active()
+ ->with(['options' => fn ($q) => $q->active()->orderBy('sort_order'), 'options.values'])
+ ->orderBy('sort_order')
+ ->get()
+ ->keyBy('service_type');
+
return Inertia::render('Marketing/Pricing', [
'plans' => $plans,
+ 'groupedPlans' => $plans->groupBy('service_type'),
+ 'byoConfigGroups' => $byoConfigGroups,
]);
})->name('pricing');
-Route::get('/about', fn () => Inertia::render('Marketing/About'))->name('about');
+Route::get('/about', function () {
+ $data = Cache::remember('marketing:about-stats', 300, fn () => [
+ 'customerCount' => User::role('customer')->count(),
+ 'serviceCount' => Service::where('status', 'active')->count(),
+ ]);
+
+ return Inertia::render('Marketing/About', $data);
+})->name('about');
Route::get('/contact', fn () => Inertia::render('Marketing/Contact'))->name('contact');
Route::post('/contact', [ContactController::class, 'store'])->name('contact.store');
@@ -80,4 +114,13 @@ Route::get('/api-docs', fn () => Inertia::render('Marketing/ApiDocs'))->name('ap
Route::get('/terms-of-service', fn () => Inertia::render('Marketing/TermsOfService'))->name('terms');
Route::get('/privacy-policy', fn () => Inertia::render('Marketing/PrivacyPolicy'))->name('privacy');
Route::get('/acceptable-use', fn () => Inertia::render('Marketing/AcceptableUse'))->name('aup');
+Route::redirect('/terms', '/terms-of-service', 301);
+Route::redirect('/privacy', '/privacy-policy', 301);
Route::get('/sla', fn () => Inertia::render('Marketing/Sla'))->name('sla');
+
+// Win-back unsubscribe (signed URL, no auth required)
+Route::get('/unsubscribe/winback/{recipient}', function (WinbackRecipient $recipient) {
+ $recipient->update(['unsubscribed_at' => now()]);
+
+ return Inertia::render('Marketing/Unsubscribed');
+})->name('winback.unsubscribe')->middleware('signed');
diff --git a/website/tests/Feature/Account/VpsControllerTest.php b/website/tests/Feature/Account/VpsControllerTest.php
index 728734e..5860ac0 100644
--- a/website/tests/Feature/Account/VpsControllerTest.php
+++ b/website/tests/Feature/Account/VpsControllerTest.php
@@ -18,10 +18,10 @@ beforeEach(function (): void {
$this->plan = Plan::factory()->create([
'service_type' => 'vps',
'status' => 'active',
- 'features' => [
- 'virtfusion_package_id' => 1,
- 'virtfusion_user_id' => 1,
- 'virtfusion_hypervisor_id' => 1,
+ 'features' => [],
+ 'provisioning_config' => [
+ 'package_id' => 1,
+ 'hypervisor_id' => 1,
],
]);
diff --git a/website/tests/Feature/Admin/AdminPanelTest.php b/website/tests/Feature/Admin/AdminPanelTest.php
index c885535..ddaab39 100644
--- a/website/tests/Feature/Admin/AdminPanelTest.php
+++ b/website/tests/Feature/Admin/AdminPanelTest.php
@@ -30,12 +30,28 @@ describe('Dashboard', function (): void {
->assertInertia(fn ($page) => $page
->component('Admin/Dashboard')
->has('totalCustomers')
+ ->has('newCustomersThisMonth')
->has('mrr')
- ->has('totalRevenue')
+ ->has('mrrChangePercent')
+ ->has('arr')
->has('activeServices')
- ->has('recentInvoices')
- ->has('recentSubscriptions')
- ->has('popularPlans')
+ ->has('serviceBreakdown')
+ ->has('totalTransactionRevenue')
+ ->has('estimatedFees')
+ ->has('netRevenue')
+ ->has('revenueThisMonth')
+ ->has('overdueCount')
+ ->has('overdueAmount')
+ ->has('currentChurnRate')
+ ->has('churnHealthStatus')
+ ->has('revenueByMonth')
+ ->missing('recentInvoices')
+ ->missing('recentSubscriptions')
+ ->missing('popularPlans')
+ ->missing('totalRevenue')
+ ->missing('customerGrowth')
+ ->missing('churnData')
+ ->missing('overdueInvoices')
);
});
@@ -194,6 +210,9 @@ describe('Plan Management', function (): void {
['key' => 'cpu', 'value' => '2 vCPU'],
['key' => 'ram', 'value' => '4GB'],
],
+ 'provisioning_config' => [
+ 'package_id' => 1,
+ ],
'stock_quantity' => 100,
'sort_order' => 1,
])
diff --git a/website/tests/Feature/Admin/ConfigGroupTest.php b/website/tests/Feature/Admin/ConfigGroupTest.php
new file mode 100644
index 0000000..c840477
--- /dev/null
+++ b/website/tests/Feature/Admin/ConfigGroupTest.php
@@ -0,0 +1,342 @@
+seed(RoleAndPermissionSeeder::class);
+ $this->adminUrl = 'http://'.config('app.domains.admin');
+});
+
+describe('PlanConfigGroup Model', function (): void {
+ it('creates a preset group with options and values', function (): void {
+ $group = PlanConfigGroup::factory()->create(['mode' => 'preset']);
+ $option = PlanConfigOption::factory()->create([
+ 'group_id' => $group->id,
+ 'type' => 'dropdown',
+ 'name' => 'RAM',
+ ]);
+ $value = PlanConfigValue::factory()->withPrice(15.00)->create([
+ 'option_id' => $option->id,
+ 'label' => '64 GB',
+ ]);
+
+ expect($group->options)->toHaveCount(1);
+ expect($option->values)->toHaveCount(1);
+ expect($value->getPriceForCycle('monthly'))->toBe(15.0);
+ });
+
+ it('scopes preset and build_your_own groups', function (): void {
+ PlanConfigGroup::factory()->create(['mode' => 'preset']);
+ PlanConfigGroup::factory()->buildYourOwn('vps')->create();
+ PlanConfigGroup::factory()->buildYourOwn('game')->create();
+
+ expect(PlanConfigGroup::preset()->count())->toBe(1);
+ expect(PlanConfigGroup::buildYourOwn()->count())->toBe(2);
+ expect(PlanConfigGroup::buildYourOwn()->forServiceType('vps')->count())->toBe(1);
+ });
+
+ it('attaches to plans via pivot', function (): void {
+ $group = PlanConfigGroup::factory()->create();
+ $plan = Plan::factory()->create();
+ $group->plans()->attach($plan->id);
+
+ expect($group->plans)->toHaveCount(1);
+ expect($plan->configGroups)->toHaveCount(1);
+ });
+
+ it('soft deletes and restores', function (): void {
+ $group = PlanConfigGroup::factory()->create();
+ $group->delete();
+
+ expect(PlanConfigGroup::count())->toBe(0);
+ expect(PlanConfigGroup::withTrashed()->count())->toBe(1);
+
+ $group->restore();
+ expect(PlanConfigGroup::count())->toBe(1);
+ });
+
+ it('filters active groups', function (): void {
+ PlanConfigGroup::factory()->create(['is_active' => true]);
+ PlanConfigGroup::factory()->inactive()->create();
+
+ expect(PlanConfigGroup::active()->count())->toBe(1);
+ });
+});
+
+describe('PlanConfigOption Model', function (): void {
+ it('calculates slider price correctly', function (): void {
+ $option = PlanConfigOption::factory()->slider(2.00, 0.003)->create([
+ 'name' => 'CPU Cores',
+ 'unit_label' => 'cores',
+ ]);
+
+ expect($option->calculatePrice(4, 'monthly'))->toBe(8.0);
+ expect($option->getHourlyPrice(4))->toBe(0.012);
+ expect($option->isSlider())->toBeTrue();
+ });
+
+ it('calculates price for different billing cycles', function (): void {
+ $option = PlanConfigOption::factory()->create([
+ 'type' => 'quantity',
+ 'monthly_price' => 3.00,
+ 'quarterly_price' => 8.55,
+ 'semi_annual_price' => 16.20,
+ 'annual_price' => 30.60,
+ ]);
+
+ expect($option->calculatePrice(2, 'monthly'))->toBe(6.0);
+ expect($option->calculatePrice(2, 'quarterly'))->toBe(17.1);
+ expect($option->calculatePrice(2, 'annual'))->toBe(61.2);
+ });
+
+ it('identifies option types correctly', function (): void {
+ $dropdown = PlanConfigOption::factory()->create(['type' => 'dropdown']);
+ $slider = PlanConfigOption::factory()->slider(1.00)->create();
+ $checkbox = PlanConfigOption::factory()->checkbox()->create();
+ $text = PlanConfigOption::factory()->create(['type' => 'text']);
+
+ expect($dropdown->isDropdown())->toBeTrue();
+ expect($dropdown->isSlider())->toBeFalse();
+ expect($slider->isSlider())->toBeTrue();
+ expect($checkbox->isCheckbox())->toBeTrue();
+ expect($text->isText())->toBeTrue();
+ });
+
+ it('filters active options', function (): void {
+ $group = PlanConfigGroup::factory()->create();
+ PlanConfigOption::factory()->create(['group_id' => $group->id, 'is_active' => true]);
+ PlanConfigOption::factory()->create(['group_id' => $group->id, 'is_active' => false]);
+
+ expect(PlanConfigOption::active()->count())->toBe(1);
+ });
+});
+
+describe('PlanConfigValue Model', function (): void {
+ it('returns price for each billing cycle', function (): void {
+ $value = PlanConfigValue::factory()->create([
+ 'monthly_price' => 15.00,
+ 'quarterly_price' => 42.75,
+ 'semi_annual_price' => 81.00,
+ 'annual_price' => 153.00,
+ ]);
+
+ expect($value->getPriceForCycle('monthly'))->toBe(15.0);
+ expect($value->getPriceForCycle('quarterly'))->toBe(42.75);
+ expect($value->getPriceForCycle('semi_annual'))->toBe(81.0);
+ expect($value->getPriceForCycle('annual'))->toBe(153.0);
+ });
+});
+
+describe('Plan Model Updates', function (): void {
+ it('treats internal status as available', function (): void {
+ $active = Plan::factory()->create(['status' => 'active']);
+ $internal = Plan::factory()->create(['status' => 'internal']);
+ $hidden = Plan::factory()->create(['status' => 'hidden']);
+
+ expect($active->isAvailable())->toBeTrue();
+ expect($internal->isAvailable())->toBeTrue();
+ expect($hidden->isAvailable())->toBeFalse();
+ });
+
+ it('excludes internal and hidden from public scope', function (): void {
+ Plan::factory()->create(['status' => 'active']);
+ Plan::factory()->create(['status' => 'internal']);
+ Plan::factory()->create(['status' => 'hidden']);
+
+ expect(Plan::public()->count())->toBe(1);
+ });
+});
+
+describe('Admin Config Group CRUD', function (): void {
+ it('lists config groups', function (): void {
+ $admin = User::factory()->admin()->create();
+ PlanConfigGroup::factory()->count(3)->create();
+
+ $this->actingAs($admin)
+ ->get($this->adminUrl.'/config-groups')
+ ->assertOk()
+ ->assertInertia(fn ($page) => $page
+ ->component('Admin/ConfigGroups/Index')
+ ->has('configGroups', 3)
+ ->has('filters')
+ );
+ });
+
+ it('creates preset config group with options and values', function (): void {
+ $admin = User::factory()->admin()->create();
+ $plan = Plan::factory()->create(['status' => 'active']);
+
+ $this->actingAs($admin)
+ ->post($this->adminUrl.'/config-groups', [
+ 'name' => 'VPS RAM Options',
+ 'description' => 'Choose your RAM',
+ 'mode' => 'preset',
+ 'service_type' => null,
+ 'is_active' => true,
+ 'plan_ids' => [$plan->id],
+ 'options' => [
+ [
+ 'name' => 'RAM',
+ 'description' => 'Memory allocation',
+ 'type' => 'dropdown',
+ 'provisioning_key' => 'ram',
+ 'required' => true,
+ 'is_active' => true,
+ 'values' => [
+ [
+ 'label' => '8 GB',
+ 'value' => '8',
+ 'monthly_price' => 10.00,
+ 'quarterly_price' => 28.50,
+ 'semi_annual_price' => 54.00,
+ 'annual_price' => 102.00,
+ 'is_default' => true,
+ ],
+ [
+ 'label' => '16 GB',
+ 'value' => '16',
+ 'monthly_price' => 20.00,
+ 'quarterly_price' => 57.00,
+ 'semi_annual_price' => 108.00,
+ 'annual_price' => 204.00,
+ 'is_default' => false,
+ ],
+ ],
+ ],
+ ],
+ ])
+ ->assertRedirect();
+
+ $group = PlanConfigGroup::query()->where('name', 'VPS RAM Options')->first();
+ expect($group)->not->toBeNull();
+ expect($group->mode)->toBe('preset');
+ expect($group->plans)->toHaveCount(1);
+ expect($group->plans->first()->id)->toBe($plan->id);
+ expect($group->options)->toHaveCount(1);
+ expect($group->options->first()->name)->toBe('RAM');
+ expect($group->options->first()->provisioning_key)->toBe('ram');
+ expect($group->options->first()->values)->toHaveCount(2);
+ expect($group->options->first()->values->first()->label)->toBe('8 GB');
+ });
+
+ it('creates BYO config group with slider options', function (): void {
+ $admin = User::factory()->admin()->create();
+
+ $this->actingAs($admin)
+ ->post($this->adminUrl.'/config-groups', [
+ 'name' => 'Custom VPS Builder',
+ 'mode' => 'build_your_own',
+ 'service_type' => 'vps',
+ 'is_active' => true,
+ 'options' => [
+ [
+ 'name' => 'CPU Cores',
+ 'type' => 'slider',
+ 'provisioning_key' => 'cpu',
+ 'required' => true,
+ 'is_active' => true,
+ 'min_qty' => 1,
+ 'max_qty' => 32,
+ 'step' => 1,
+ 'unit_label' => 'cores',
+ 'monthly_price' => 5.00,
+ 'hourly_price' => 0.0075,
+ ],
+ ],
+ ])
+ ->assertRedirect();
+
+ $group = PlanConfigGroup::query()->where('name', 'Custom VPS Builder')->first();
+ expect($group)->not->toBeNull();
+ expect($group->mode)->toBe('build_your_own');
+ expect($group->service_type)->toBe('vps');
+ expect($group->options->first()->provisioning_key)->toBe('cpu');
+ expect($group->options->first()->type)->toBe('slider');
+ expect($group->options->first()->min_qty)->toBe(1);
+ expect($group->options->first()->max_qty)->toBe(32);
+ });
+
+ it('updates a config group', function (): void {
+ $admin = User::factory()->admin()->create();
+ $group = PlanConfigGroup::factory()->create(['name' => 'Old Name']);
+ $option = PlanConfigOption::factory()->create([
+ 'group_id' => $group->id,
+ 'type' => 'dropdown',
+ 'name' => 'RAM',
+ ]);
+
+ $this->actingAs($admin)
+ ->put($this->adminUrl.'/config-groups/'.$group->id, [
+ 'name' => 'Updated Name',
+ 'mode' => $group->mode,
+ 'is_active' => true,
+ 'options' => [
+ [
+ 'id' => $option->id,
+ 'name' => 'RAM',
+ 'type' => 'dropdown',
+ 'required' => false,
+ 'is_active' => true,
+ ],
+ ],
+ ])
+ ->assertRedirect();
+
+ $group->refresh();
+ expect($group->name)->toBe('Updated Name');
+ });
+
+ it('archives config group with existing selections', function (): void {
+ $admin = User::factory()->admin()->create();
+ $customer = User::factory()->customer()->create();
+ $group = PlanConfigGroup::factory()->create(['is_active' => true]);
+ $option = PlanConfigOption::factory()->create(['group_id' => $group->id]);
+
+ $subscription = Subscription::factory()->create(['user_id' => $customer->id]);
+
+ SubscriptionConfigSelection::query()->create([
+ 'subscription_id' => $subscription->id,
+ 'option_id' => $option->id,
+ 'value_id' => null,
+ 'quantity' => 4,
+ 'locked_price' => 20.00,
+ 'billing_cycle' => 'monthly',
+ 'is_custom_build' => false,
+ ]);
+
+ $this->actingAs($admin)
+ ->delete($this->adminUrl.'/config-groups/'.$group->id)
+ ->assertRedirect();
+
+ // Should soft-delete and deactivate, not hard delete
+ expect(PlanConfigGroup::query()->find($group->id))->toBeNull();
+ $trashedGroup = PlanConfigGroup::withTrashed()->find($group->id);
+ expect($trashedGroup)->not->toBeNull();
+ expect($trashedGroup->is_active)->toBeFalse();
+ });
+
+ it('prevents customer from accessing config groups', function (): void {
+ $customer = User::factory()->customer()->create();
+
+ $this->actingAs($customer)
+ ->get($this->adminUrl.'/config-groups')
+ ->assertForbidden();
+ });
+
+ it('validates required fields', function (): void {
+ $admin = User::factory()->admin()->create();
+
+ $this->actingAs($admin)
+ ->post($this->adminUrl.'/config-groups', [])
+ ->assertSessionHasErrors(['name', 'mode', 'options']);
+ });
+});
diff --git a/website/tests/Feature/Admin/CouponRedemptionTest.php b/website/tests/Feature/Admin/CouponRedemptionTest.php
index c6ee668..21f6d55 100644
--- a/website/tests/Feature/Admin/CouponRedemptionTest.php
+++ b/website/tests/Feature/Admin/CouponRedemptionTest.php
@@ -6,10 +6,10 @@ use App\Models\Coupon;
use App\Models\CouponRedemption;
use App\Models\User;
use Database\Seeders\RoleAndPermissionSeeder;
-use Illuminate\Foundation\Testing\RefreshDatabase;
+// DatabaseTransactions is applied globally in Pest.php
use Laravel\Cashier\Subscription;
-uses(RefreshDatabase::class);
+// uses DatabaseTransactions from Pest.php
beforeEach(function (): void {
$this->seed(RoleAndPermissionSeeder::class);
diff --git a/website/tests/Feature/Admin/DashboardStatsTest.php b/website/tests/Feature/Admin/DashboardStatsTest.php
new file mode 100644
index 0000000..d365f28
--- /dev/null
+++ b/website/tests/Feature/Admin/DashboardStatsTest.php
@@ -0,0 +1,391 @@
+seed(RoleAndPermissionSeeder::class);
+ Cache::flush();
+ $this->admin = User::factory()->admin()->create();
+ $this->adminUrl = 'http://'.config('app.domains.admin');
+});
+
+describe('Customer count and delta', function (): void {
+ it('returns correct totalCustomers and newCustomersThisMonth', function (): void {
+ // Customer from last month
+ User::factory()->customer()->create([
+ 'created_at' => now()->subMonth(),
+ ]);
+ // Two customers this month
+ User::factory()->customer()->count(2)->create([
+ 'created_at' => now(),
+ ]);
+
+ $this->actingAs($this->admin)
+ ->get($this->adminUrl.'/dashboard')
+ ->assertOk()
+ ->assertInertia(fn ($page) => $page
+ ->where('totalCustomers', 3)
+ ->where('newCustomersThisMonth', 2)
+ );
+ });
+});
+
+describe('MRR normalization', function (): void {
+ it('normalizes MRR across billing cycles', function (): void {
+ $user = User::factory()->customer()->create();
+
+ // Monthly plan at $30/mo → contributes $30 MRR
+ $monthlyPlan = Plan::factory()->create(['billing_cycle' => 'monthly', 'price' => 30]);
+ PlanPrice::create([
+ 'plan_id' => $monthlyPlan->id,
+ 'billing_cycle' => 'monthly',
+ 'price' => 30.00,
+ ]);
+ Subscription::create([
+ 'user_id' => $user->id,
+ 'type' => 'default',
+ 'stripe_id' => 'sub_monthly_'.uniqid(),
+ 'stripe_status' => 'active',
+ 'stripe_price' => 'price_monthly',
+ 'plan_id' => $monthlyPlan->id,
+ 'billing_cycle' => 'monthly',
+ ]);
+
+ // Quarterly plan at $90/quarter → contributes $30 MRR
+ $quarterlyPlan = Plan::factory()->create(['billing_cycle' => 'quarterly', 'price' => 90]);
+ PlanPrice::create([
+ 'plan_id' => $quarterlyPlan->id,
+ 'billing_cycle' => 'quarterly',
+ 'price' => 90.00,
+ ]);
+ Subscription::create([
+ 'user_id' => $user->id,
+ 'type' => 'default',
+ 'stripe_id' => 'sub_quarterly_'.uniqid(),
+ 'stripe_status' => 'active',
+ 'stripe_price' => 'price_quarterly',
+ 'plan_id' => $quarterlyPlan->id,
+ 'billing_cycle' => 'quarterly',
+ ]);
+
+ // Annual plan at $120/year → contributes $10 MRR
+ $annualPlan = Plan::factory()->create(['billing_cycle' => 'annual', 'price' => 120]);
+ PlanPrice::create([
+ 'plan_id' => $annualPlan->id,
+ 'billing_cycle' => 'annual',
+ 'price' => 120.00,
+ ]);
+ Subscription::create([
+ 'user_id' => $user->id,
+ 'type' => 'default',
+ 'stripe_id' => 'sub_annual_'.uniqid(),
+ 'stripe_status' => 'active',
+ 'stripe_price' => 'price_annual',
+ 'plan_id' => $annualPlan->id,
+ 'billing_cycle' => 'annual',
+ ]);
+
+ // Expected MRR: $30 + $30 + $10 = $70
+ $this->actingAs($this->admin)
+ ->get($this->adminUrl.'/dashboard')
+ ->assertOk()
+ ->assertInertia(fn ($page) => $page
+ ->where('mrr', fn ($mrr) => (float) $mrr === 70.0)
+ ->where('arr', fn ($arr) => (float) $arr === 840.0)
+ );
+ });
+});
+
+describe('MRR month-over-month change', function (): void {
+ it('calculates mrrChangePercent when previous month data exists', function (): void {
+ $user = User::factory()->customer()->create();
+
+ // Subscription created before last month (will count for both current and previous MRR)
+ $plan = Plan::factory()->create(['billing_cycle' => 'monthly', 'price' => 50]);
+ PlanPrice::create([
+ 'plan_id' => $plan->id,
+ 'billing_cycle' => 'monthly',
+ 'price' => 50.00,
+ ]);
+ Subscription::create([
+ 'user_id' => $user->id,
+ 'type' => 'default',
+ 'stripe_id' => 'sub_old_'.uniqid(),
+ 'stripe_status' => 'active',
+ 'stripe_price' => 'price_old',
+ 'plan_id' => $plan->id,
+ 'billing_cycle' => 'monthly',
+ 'created_at' => now()->subMonths(2),
+ ]);
+
+ // New subscription this month (only counts for current MRR)
+ $plan2 = Plan::factory()->create(['billing_cycle' => 'monthly', 'price' => 50]);
+ PlanPrice::create([
+ 'plan_id' => $plan2->id,
+ 'billing_cycle' => 'monthly',
+ 'price' => 50.00,
+ ]);
+ Subscription::create([
+ 'user_id' => $user->id,
+ 'type' => 'default',
+ 'stripe_id' => 'sub_new_'.uniqid(),
+ 'stripe_status' => 'active',
+ 'stripe_price' => 'price_new',
+ 'plan_id' => $plan2->id,
+ 'billing_cycle' => 'monthly',
+ 'created_at' => now(),
+ ]);
+
+ // Current MRR: $100, Previous MRR: $50 → change: +100%
+ $this->actingAs($this->admin)
+ ->get($this->adminUrl.'/dashboard')
+ ->assertOk()
+ ->assertInertia(fn ($page) => $page
+ ->where('mrr', fn ($mrr) => (float) $mrr === 100.0)
+ ->where('mrrChangePercent', fn ($v) => (float) $v === 100.0)
+ );
+ });
+
+ it('returns null mrrChangePercent when no previous month data', function (): void {
+ $user = User::factory()->customer()->create();
+
+ // Subscription created this month only
+ $plan = Plan::factory()->create(['billing_cycle' => 'monthly', 'price' => 50]);
+ PlanPrice::create([
+ 'plan_id' => $plan->id,
+ 'billing_cycle' => 'monthly',
+ 'price' => 50.00,
+ ]);
+ Subscription::create([
+ 'user_id' => $user->id,
+ 'type' => 'default',
+ 'stripe_id' => 'sub_new_'.uniqid(),
+ 'stripe_status' => 'active',
+ 'stripe_price' => 'price_new',
+ 'plan_id' => $plan->id,
+ 'billing_cycle' => 'monthly',
+ 'created_at' => now(),
+ ]);
+
+ $this->actingAs($this->admin)
+ ->get($this->adminUrl.'/dashboard')
+ ->assertOk()
+ ->assertInertia(fn ($page) => $page
+ ->where('mrrChangePercent', null)
+ );
+ });
+});
+
+describe('Fee estimation by gateway', function (): void {
+ it('estimates fees correctly for Stripe and PayPal transactions', function (): void {
+ $user = User::factory()->customer()->create();
+
+ // Stripe: $100 → fee = ($100 * 0.029) + $0.30 = $3.20
+ PaymentTransaction::create([
+ 'user_id' => $user->id,
+ 'gateway' => 'stripe',
+ 'gateway_transaction_id' => 'txn_stripe_'.uniqid(),
+ 'amount' => 100.00,
+ 'currency' => 'USD',
+ 'status' => 'succeeded',
+ 'payment_method' => 'card',
+ 'description' => 'Test stripe payment',
+ ]);
+
+ // PayPal: $200 → fee = ($200 * 0.0349) + $0.49 = $7.47
+ PaymentTransaction::create([
+ 'user_id' => $user->id,
+ 'gateway' => 'paypal',
+ 'gateway_transaction_id' => 'txn_paypal_'.uniqid(),
+ 'amount' => 200.00,
+ 'currency' => 'USD',
+ 'status' => 'succeeded',
+ 'payment_method' => 'paypal',
+ 'description' => 'Test paypal payment',
+ ]);
+
+ // Total fees: $3.20 + $7.47 = $10.67
+ $this->actingAs($this->admin)
+ ->get($this->adminUrl.'/dashboard')
+ ->assertOk()
+ ->assertInertia(fn ($page) => $page
+ ->where('totalTransactionRevenue', fn ($v) => (float) $v === 300.0)
+ ->where('estimatedFees', 10.67)
+ );
+ });
+});
+
+describe('Net revenue', function (): void {
+ it('calculates net revenue as total minus fees', function (): void {
+ $user = User::factory()->customer()->create();
+
+ PaymentTransaction::create([
+ 'user_id' => $user->id,
+ 'gateway' => 'stripe',
+ 'gateway_transaction_id' => 'txn_'.uniqid(),
+ 'amount' => 100.00,
+ 'currency' => 'USD',
+ 'status' => 'succeeded',
+ 'payment_method' => 'card',
+ 'description' => 'Payment',
+ ]);
+
+ // Fee: ($100 * 0.029) + $0.30 = $3.20
+ // Net: $100 - $3.20 = $96.80
+ $this->actingAs($this->admin)
+ ->get($this->adminUrl.'/dashboard')
+ ->assertOk()
+ ->assertInertia(fn ($page) => $page
+ ->where('netRevenue', fn ($v) => (float) $v === 96.8)
+ );
+ });
+});
+
+describe('Service breakdown', function (): void {
+ it('returns correct service counts by type', function (): void {
+ $customer = User::factory()->customer()->create();
+
+ Service::factory()->count(3)->create([
+ 'user_id' => $customer->id,
+ 'service_type' => 'vps',
+ 'status' => 'active',
+ ]);
+ Service::factory()->count(2)->create([
+ 'user_id' => $customer->id,
+ 'service_type' => 'dedicated',
+ 'status' => 'active',
+ ]);
+ // Suspended service should not appear
+ Service::factory()->create([
+ 'user_id' => $customer->id,
+ 'service_type' => 'vps',
+ 'status' => 'suspended',
+ ]);
+
+ $this->actingAs($this->admin)
+ ->get($this->adminUrl.'/dashboard')
+ ->assertOk()
+ ->assertInertia(fn ($page) => $page
+ ->where('activeServices', 5)
+ ->where('serviceBreakdown.vps', 3)
+ ->where('serviceBreakdown.dedicated', 2)
+ );
+ });
+});
+
+describe('Overdue tracking', function (): void {
+ it('returns correct overdue count and amount', function (): void {
+ $customer = User::factory()->customer()->create();
+
+ Invoice::factory()->count(2)->create([
+ 'user_id' => $customer->id,
+ 'status' => 'overdue',
+ 'total' => 50.00,
+ ]);
+ // Paid invoice should not count
+ Invoice::factory()->create([
+ 'user_id' => $customer->id,
+ 'status' => 'paid',
+ 'total' => 100.00,
+ ]);
+
+ $this->actingAs($this->admin)
+ ->get($this->adminUrl.'/dashboard')
+ ->assertOk()
+ ->assertInertia(fn ($page) => $page
+ ->where('overdueCount', 2)
+ ->where('overdueAmount', fn ($v) => (float) $v === 100.0)
+ );
+ });
+});
+
+describe('Churn health status', function (): void {
+ it('returns healthy when churn rate is below 3%', function (): void {
+ // No subscriptions → 0% churn → healthy
+ $this->actingAs($this->admin)
+ ->get($this->adminUrl.'/dashboard')
+ ->assertOk()
+ ->assertInertia(fn ($page) => $page
+ ->where('currentChurnRate', 0)
+ ->where('churnHealthStatus', 'healthy')
+ );
+ });
+
+ it('returns watch when churn rate is between 3% and 7%', function (): void {
+ $user = User::factory()->customer()->create();
+
+ // Create 20 subscriptions before this month
+ for ($i = 0; $i < 20; $i++) {
+ Subscription::create([
+ 'user_id' => $user->id,
+ 'type' => 'default',
+ 'stripe_id' => 'sub_base_'.$i.'_'.uniqid(),
+ 'stripe_status' => 'active',
+ 'stripe_price' => 'price_base',
+ 'created_at' => now()->subMonths(2),
+ ]);
+ }
+
+ // Cancel 1 this month → 5% churn → watch
+ Subscription::create([
+ 'user_id' => $user->id,
+ 'type' => 'default',
+ 'stripe_id' => 'sub_cancelled_'.uniqid(),
+ 'stripe_status' => 'canceled',
+ 'stripe_price' => 'price_cancelled',
+ 'created_at' => now()->subMonths(2),
+ 'cancelled_at' => now(),
+ ]);
+
+ $this->actingAs($this->admin)
+ ->get($this->adminUrl.'/dashboard')
+ ->assertOk()
+ ->assertInertia(fn ($page) => $page
+ ->where('churnHealthStatus', 'watch')
+ );
+ });
+
+ it('returns high when churn rate exceeds 7%', function (): void {
+ $user = User::factory()->customer()->create();
+
+ // Create 10 subscriptions before this month
+ for ($i = 0; $i < 10; $i++) {
+ Subscription::create([
+ 'user_id' => $user->id,
+ 'type' => 'default',
+ 'stripe_id' => 'sub_base_'.$i.'_'.uniqid(),
+ 'stripe_status' => 'active',
+ 'stripe_price' => 'price_base',
+ 'created_at' => now()->subMonths(2),
+ ]);
+ }
+
+ // Cancel 1 this month → ~9.1% churn (1/11) → high
+ Subscription::create([
+ 'user_id' => $user->id,
+ 'type' => 'default',
+ 'stripe_id' => 'sub_cancelled_'.uniqid(),
+ 'stripe_status' => 'canceled',
+ 'stripe_price' => 'price_cancelled',
+ 'created_at' => now()->subMonths(2),
+ 'cancelled_at' => now(),
+ ]);
+
+ $this->actingAs($this->admin)
+ ->get($this->adminUrl.'/dashboard')
+ ->assertOk()
+ ->assertInertia(fn ($page) => $page
+ ->where('churnHealthStatus', 'high')
+ );
+ });
+});
diff --git a/website/tests/Feature/Admin/FinancialReportsTest.php b/website/tests/Feature/Admin/FinancialReportsTest.php
new file mode 100644
index 0000000..6df35a4
--- /dev/null
+++ b/website/tests/Feature/Admin/FinancialReportsTest.php
@@ -0,0 +1,400 @@
+seed(RoleAndPermissionSeeder::class);
+ $this->adminUrl = 'http://'.config('app.domains.admin');
+});
+
+// ---------------------------------------------------------------------------
+// Reports Index
+// ---------------------------------------------------------------------------
+describe('Reports Index', function (): void {
+ it('loads the reports index page for admin', function (): void {
+ $admin = User::factory()->admin()->create();
+
+ $this->actingAs($admin)
+ ->get($this->adminUrl.'/reports')
+ ->assertOk()
+ ->assertInertia(fn ($page) => $page->component('Admin/Reports/Index'));
+ });
+
+ it('denies customer access to reports', function (): void {
+ $customer = User::factory()->customer()->create();
+
+ $this->actingAs($customer)
+ ->get($this->adminUrl.'/reports')
+ ->assertForbidden();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Revenue Report
+// ---------------------------------------------------------------------------
+describe('Revenue Report', function (): void {
+ it('returns correct structure for revenue report', function (): void {
+ $admin = User::factory()->admin()->create();
+
+ $this->actingAs($admin)
+ ->post($this->adminUrl.'/reports/generate', [
+ 'report_type' => 'revenue',
+ 'start_date' => now()->subMonth()->toDateString(),
+ 'end_date' => now()->toDateString(),
+ ])
+ ->assertOk()
+ ->assertInertia(fn ($page) => $page
+ ->component('Admin/Reports/Show')
+ ->where('reportType', 'revenue')
+ ->has('reportData.total_revenue')
+ ->has('reportData.by_period')
+ ->has('reportData.by_service_type')
+ ->has('reportData.by_gateway')
+ ->has('reportData.by_plan')
+ ->has('reportMeta.title')
+ ->has('reportMeta.generated_at')
+ );
+ });
+
+ it('filters revenue by date range', function (): void {
+ $admin = User::factory()->admin()->create();
+ $customer = User::factory()->customer()->create();
+
+ // Create transactions in different months (use forceCreate to bypass $fillable for created_at)
+ PaymentTransaction::forceCreate([
+ 'user_id' => $customer->id,
+ 'gateway' => 'stripe',
+ 'amount' => 100.00,
+ 'currency' => 'USD',
+ 'status' => 'succeeded',
+ 'created_at' => now()->subDays(5),
+ ]);
+
+ PaymentTransaction::forceCreate([
+ 'user_id' => $customer->id,
+ 'gateway' => 'stripe',
+ 'amount' => 200.00,
+ 'currency' => 'USD',
+ 'status' => 'succeeded',
+ 'created_at' => now()->subMonths(3),
+ ]);
+
+ $this->actingAs($admin)
+ ->post($this->adminUrl.'/reports/generate', [
+ 'report_type' => 'revenue',
+ 'start_date' => now()->subDays(10)->toDateString(),
+ 'end_date' => now()->toDateString(),
+ ])
+ ->assertOk()
+ ->assertInertia(fn ($page) => $page
+ ->where('reportData.total_revenue', 100)
+ );
+ });
+
+ it('filters revenue by service type', function (): void {
+ $admin = User::factory()->admin()->create();
+
+ $this->actingAs($admin)
+ ->post($this->adminUrl.'/reports/generate', [
+ 'report_type' => 'revenue',
+ 'start_date' => now()->subMonth()->toDateString(),
+ 'end_date' => now()->toDateString(),
+ 'service_type' => 'vps',
+ ])
+ ->assertOk()
+ ->assertInertia(fn ($page) => $page
+ ->component('Admin/Reports/Show')
+ ->where('reportType', 'revenue')
+ ->where('reportMeta.service_type', 'vps')
+ );
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Profit & Loss Report
+// ---------------------------------------------------------------------------
+describe('Profit & Loss Report', function (): void {
+ it('returns correct structure for profit loss report', function (): void {
+ $admin = User::factory()->admin()->create();
+
+ $this->actingAs($admin)
+ ->post($this->adminUrl.'/reports/generate', [
+ 'report_type' => 'profit_loss',
+ 'start_date' => now()->subMonth()->toDateString(),
+ 'end_date' => now()->toDateString(),
+ ])
+ ->assertOk()
+ ->assertInertia(fn ($page) => $page
+ ->component('Admin/Reports/Show')
+ ->where('reportType', 'profit_loss')
+ ->has('reportData.revenue')
+ ->has('reportData.refunds')
+ ->has('reportData.gateway_fees')
+ ->has('reportData.infrastructure_cost')
+ ->has('reportData.net_profit')
+ ->has('reportData.margin_percentage')
+ );
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Tax Report
+// ---------------------------------------------------------------------------
+describe('Tax Report', function (): void {
+ it('returns correct structure for tax report', function (): void {
+ $admin = User::factory()->admin()->create();
+
+ $this->actingAs($admin)
+ ->post($this->adminUrl.'/reports/generate', [
+ 'report_type' => 'tax',
+ 'start_date' => now()->subMonth()->toDateString(),
+ 'end_date' => now()->toDateString(),
+ ])
+ ->assertOk()
+ ->assertInertia(fn ($page) => $page
+ ->component('Admin/Reports/Show')
+ ->where('reportType', 'tax')
+ ->has('reportData.total_tax')
+ ->has('reportData.by_country')
+ ->has('reportData.by_region')
+ );
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Aging Report
+// ---------------------------------------------------------------------------
+describe('Aging Report', function (): void {
+ it('returns correct structure for aging report', function (): void {
+ $admin = User::factory()->admin()->create();
+
+ $this->actingAs($admin)
+ ->post($this->adminUrl.'/reports/generate', [
+ 'report_type' => 'aging',
+ ])
+ ->assertOk()
+ ->assertInertia(fn ($page) => $page
+ ->component('Admin/Reports/Show')
+ ->where('reportType', 'aging')
+ ->has('reportData.current')
+ ->has('reportData.days_31_60')
+ ->has('reportData.days_61_90')
+ ->has('reportData.days_90_plus')
+ ->has('reportData.total')
+ ->has('reportData.invoices')
+ );
+ });
+
+ it('works without date range parameters', function (): void {
+ $admin = User::factory()->admin()->create();
+ $customer = User::factory()->customer()->create();
+
+ Invoice::factory()->create([
+ 'user_id' => $customer->id,
+ 'status' => 'overdue',
+ 'due_date' => now()->subDays(45),
+ ]);
+
+ $this->actingAs($admin)
+ ->post($this->adminUrl.'/reports/generate', [
+ 'report_type' => 'aging',
+ ])
+ ->assertOk()
+ ->assertInertia(fn ($page) => $page
+ ->where('reportData.total.count', 1)
+ );
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Refund Report
+// ---------------------------------------------------------------------------
+describe('Refund Report', function (): void {
+ it('returns correct structure for refund report', function (): void {
+ $admin = User::factory()->admin()->create();
+
+ $this->actingAs($admin)
+ ->post($this->adminUrl.'/reports/generate', [
+ 'report_type' => 'refund',
+ 'start_date' => now()->subMonth()->toDateString(),
+ 'end_date' => now()->toDateString(),
+ ])
+ ->assertOk()
+ ->assertInertia(fn ($page) => $page
+ ->component('Admin/Reports/Show')
+ ->where('reportType', 'refund')
+ ->has('reportData.total_refunds')
+ ->has('reportData.refund_count')
+ ->has('reportData.by_gateway')
+ ->has('reportData.by_month')
+ );
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Subscription Report
+// ---------------------------------------------------------------------------
+describe('Subscription Report', function (): void {
+ it('returns correct structure for subscription report', function (): void {
+ $admin = User::factory()->admin()->create();
+
+ $this->actingAs($admin)
+ ->post($this->adminUrl.'/reports/generate', [
+ 'report_type' => 'subscription',
+ 'start_date' => now()->subMonth()->toDateString(),
+ 'end_date' => now()->toDateString(),
+ ])
+ ->assertOk()
+ ->assertInertia(fn ($page) => $page
+ ->component('Admin/Reports/Show')
+ ->where('reportType', 'subscription')
+ ->has('reportData.new_subscriptions')
+ ->has('reportData.cancelled_subscriptions')
+ ->has('reportData.churn_rate')
+ ->has('reportData.mrr_start')
+ ->has('reportData.mrr_end')
+ ->has('reportData.mrr_change')
+ ->has('reportData.by_plan')
+ );
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Export
+// ---------------------------------------------------------------------------
+describe('Report Export', function (): void {
+ it('exports PDF with correct content type', function (): void {
+ $admin = User::factory()->admin()->create();
+
+ $response = $this->actingAs($admin)
+ ->post($this->adminUrl.'/reports/export', [
+ 'report_type' => 'revenue',
+ 'start_date' => now()->subMonth()->toDateString(),
+ 'end_date' => now()->toDateString(),
+ 'format' => 'pdf',
+ ]);
+
+ $response->assertOk();
+ expect($response->headers->get('content-type'))->toContain('pdf');
+ });
+
+ it('exports CSV with correct content type', function (): void {
+ $admin = User::factory()->admin()->create();
+
+ $response = $this->actingAs($admin)
+ ->post($this->adminUrl.'/reports/export', [
+ 'report_type' => 'profit_loss',
+ 'start_date' => now()->subMonth()->toDateString(),
+ 'end_date' => now()->toDateString(),
+ 'format' => 'csv',
+ ]);
+
+ $response->assertOk();
+ expect($response->headers->get('content-type'))->toContain('text/csv');
+ });
+
+ it('exports JSON with correct content type', function (): void {
+ $admin = User::factory()->admin()->create();
+
+ $response = $this->actingAs($admin)
+ ->post($this->adminUrl.'/reports/export', [
+ 'report_type' => 'aging',
+ 'format' => 'json',
+ ]);
+
+ $response->assertOk();
+ $response->assertJsonStructure([
+ 'report_type',
+ 'meta',
+ 'data',
+ ]);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Validation
+// ---------------------------------------------------------------------------
+describe('Validation', function (): void {
+ it('rejects invalid report type', function (): void {
+ $admin = User::factory()->admin()->create();
+
+ $this->actingAs($admin)
+ ->post($this->adminUrl.'/reports/generate', [
+ 'report_type' => 'invalid_type',
+ 'start_date' => now()->subMonth()->toDateString(),
+ 'end_date' => now()->toDateString(),
+ ])
+ ->assertSessionHasErrors('report_type');
+ });
+
+ it('rejects end date before start date', function (): void {
+ $admin = User::factory()->admin()->create();
+
+ $this->actingAs($admin)
+ ->post($this->adminUrl.'/reports/generate', [
+ 'report_type' => 'revenue',
+ 'start_date' => now()->toDateString(),
+ 'end_date' => now()->subMonth()->toDateString(),
+ ])
+ ->assertSessionHasErrors('end_date');
+ });
+
+ it('requires start and end date for non-aging reports', function (): void {
+ $admin = User::factory()->admin()->create();
+
+ $this->actingAs($admin)
+ ->post($this->adminUrl.'/reports/generate', [
+ 'report_type' => 'revenue',
+ ])
+ ->assertSessionHasErrors(['start_date', 'end_date']);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Empty Data
+// ---------------------------------------------------------------------------
+describe('Empty Data', function (): void {
+ it('returns zeros not errors with no data', function (): void {
+ $admin = User::factory()->admin()->create();
+
+ $this->actingAs($admin)
+ ->post($this->adminUrl.'/reports/generate', [
+ 'report_type' => 'revenue',
+ 'start_date' => now()->subMonth()->toDateString(),
+ 'end_date' => now()->toDateString(),
+ ])
+ ->assertOk()
+ ->assertInertia(fn ($page) => $page
+ ->where('reportData.total_revenue', 0)
+ ->where('reportData.by_period', [])
+ ->where('reportData.by_service_type', [])
+ ->where('reportData.by_gateway', [])
+ ->where('reportData.by_plan', [])
+ );
+ });
+
+ it('returns zero profit loss with no transactions', function (): void {
+ $admin = User::factory()->admin()->create();
+
+ $this->actingAs($admin)
+ ->post($this->adminUrl.'/reports/generate', [
+ 'report_type' => 'profit_loss',
+ 'start_date' => now()->subMonth()->toDateString(),
+ 'end_date' => now()->toDateString(),
+ ])
+ ->assertOk()
+ ->assertInertia(fn ($page) => $page
+ ->where('reportData.revenue', 0)
+ ->where('reportData.refunds', 0)
+ ->where('reportData.gateway_fees', 0)
+ ->where('reportData.net_profit', 0)
+ ->where('reportData.margin_percentage', 0)
+ );
+ });
+});
diff --git a/website/tests/Feature/Admin/WinbackCampaignTest.php b/website/tests/Feature/Admin/WinbackCampaignTest.php
new file mode 100644
index 0000000..e1b01ad
--- /dev/null
+++ b/website/tests/Feature/Admin/WinbackCampaignTest.php
@@ -0,0 +1,408 @@
+seed(RoleAndPermissionSeeder::class);
+ $this->adminUrl = 'http://'.config('app.domains.admin');
+ $this->marketingUrl = 'http://'.config('app.domains.marketing');
+});
+
+// ---------------------------------------------------------------------------
+// Win-back enrollment on cancellation
+// ---------------------------------------------------------------------------
+describe('Win-back Enrollment', function (): void {
+ it('enrolls user in matching campaign on cancellation', function (): void {
+ Notification::fake();
+
+ $user = User::factory()->customer()->create();
+ $subscription = Subscription::factory()->create(['user_id' => $user->id]);
+ $campaign = WinbackCampaign::factory()->active()->forReason('Too expensive')->create();
+
+ $event = new SubscriptionCancelled($user, $subscription, 'Too expensive');
+ $listener = app(HandleSubscriptionCancelled::class);
+ $listener->handle($event);
+
+ expect(WinbackRecipient::where('campaign_id', $campaign->id)->where('user_id', $user->id)->exists())
+ ->toBeTrue();
+ });
+
+ it('does not create recipient when no matching campaign exists', function (): void {
+ Notification::fake();
+
+ $user = User::factory()->customer()->create();
+ $subscription = Subscription::factory()->create(['user_id' => $user->id]);
+ WinbackCampaign::factory()->active()->forReason('Missing features')->create();
+
+ $event = new SubscriptionCancelled($user, $subscription, 'Too expensive');
+ $listener = app(HandleSubscriptionCancelled::class);
+ $listener->handle($event);
+
+ expect(WinbackRecipient::where('user_id', $user->id)->exists())->toBeFalse();
+ });
+
+ it('uses catch-all campaign when reason is null', function (): void {
+ Notification::fake();
+
+ $user = User::factory()->customer()->create();
+ $subscription = Subscription::factory()->create(['user_id' => $user->id]);
+ $campaign = WinbackCampaign::factory()->active()->catchAll()->create();
+
+ $event = new SubscriptionCancelled($user, $subscription, null);
+ $listener = app(HandleSubscriptionCancelled::class);
+ $listener->handle($event);
+
+ expect(WinbackRecipient::where('campaign_id', $campaign->id)->where('user_id', $user->id)->exists())
+ ->toBeTrue();
+ });
+
+ it('does not enroll in paused campaigns', function (): void {
+ Notification::fake();
+
+ $user = User::factory()->customer()->create();
+ $subscription = Subscription::factory()->create(['user_id' => $user->id]);
+ WinbackCampaign::factory()->paused()->forReason('Too expensive')->create();
+
+ $event = new SubscriptionCancelled($user, $subscription, 'Too expensive');
+ $listener = app(HandleSubscriptionCancelled::class);
+ $listener->handle($event);
+
+ expect(WinbackRecipient::where('user_id', $user->id)->exists())->toBeFalse();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Reactivation on new subscription
+// ---------------------------------------------------------------------------
+describe('Reactivation', function (): void {
+ it('marks recipients as reactivated when user creates new subscription', function (): void {
+ Notification::fake();
+
+ $user = User::factory()->customer()->create();
+ $subscription = Subscription::factory()->create(['user_id' => $user->id]);
+ $campaign = WinbackCampaign::factory()->active()->create();
+ $recipient = WinbackRecipient::factory()->create([
+ 'campaign_id' => $campaign->id,
+ 'user_id' => $user->id,
+ ]);
+
+ $event = new SubscriptionCreated($user, $subscription);
+ $listener = app(HandleSubscriptionCreated::class);
+ $listener->handle($event);
+
+ $recipient->refresh();
+ expect($recipient->reactivated)->toBeTrue();
+ expect($recipient->reactivated_at)->not->toBeNull();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// ProcessWinbackCampaigns command
+// ---------------------------------------------------------------------------
+describe('ProcessWinbackCampaigns Command', function (): void {
+ it('sends scheduled emails', function (): void {
+ Notification::fake();
+
+ $campaign = WinbackCampaign::factory()->active()->create([
+ 'email_sequence' => [
+ ['delay_days' => 1, 'subject' => 'We miss you', 'body' => 'Come back!'],
+ ['delay_days' => 3, 'subject' => 'Special offer', 'body' => 'Here is a deal.'],
+ ],
+ ]);
+
+ $user = User::factory()->customer()->create();
+ $recipient = WinbackRecipient::factory()->create([
+ 'campaign_id' => $campaign->id,
+ 'user_id' => $user->id,
+ 'current_email_index' => 0,
+ 'last_email_sent_at' => null,
+ ]);
+
+ // Backdate the created_at using DB query to bypass Eloquent timestamp management
+ \Illuminate\Support\Facades\DB::table('winback_recipients')
+ ->where('id', $recipient->id)
+ ->update(['created_at' => now()->subDays(2)]);
+
+ $this->artisan('winback:process')->assertSuccessful();
+
+ Notification::assertSentTo($user, WinbackEmailNotification::class);
+
+ $recipient->refresh();
+ expect($recipient->current_email_index)->toBe(1);
+ expect($recipient->last_email_sent_at)->not->toBeNull();
+ });
+
+ it('skips unsubscribed recipients', function (): void {
+ Notification::fake();
+
+ $campaign = WinbackCampaign::factory()->active()->create([
+ 'email_sequence' => [
+ ['delay_days' => 0, 'subject' => 'Test', 'body' => 'Test body'],
+ ],
+ ]);
+
+ $user = User::factory()->customer()->create();
+ WinbackRecipient::factory()->unsubscribed()->create([
+ 'campaign_id' => $campaign->id,
+ 'user_id' => $user->id,
+ 'current_email_index' => 0,
+ 'created_at' => now()->subDay(),
+ ]);
+
+ $this->artisan('winback:process')->assertSuccessful();
+
+ Notification::assertNotSentTo($user, WinbackEmailNotification::class);
+ });
+
+ it('skips reactivated recipients', function (): void {
+ Notification::fake();
+
+ $campaign = WinbackCampaign::factory()->active()->create([
+ 'email_sequence' => [
+ ['delay_days' => 0, 'subject' => 'Test', 'body' => 'Test body'],
+ ],
+ ]);
+
+ $user = User::factory()->customer()->create();
+ WinbackRecipient::factory()->reactivated()->create([
+ 'campaign_id' => $campaign->id,
+ 'user_id' => $user->id,
+ 'current_email_index' => 0,
+ 'created_at' => now()->subDay(),
+ ]);
+
+ $this->artisan('winback:process')->assertSuccessful();
+
+ Notification::assertNotSentTo($user, WinbackEmailNotification::class);
+ });
+
+ it('skips recipients when delay has not elapsed', function (): void {
+ Notification::fake();
+
+ $campaign = WinbackCampaign::factory()->active()->create([
+ 'email_sequence' => [
+ ['delay_days' => 7, 'subject' => 'Test', 'body' => 'Test body'],
+ ],
+ ]);
+
+ $user = User::factory()->customer()->create();
+ WinbackRecipient::factory()->create([
+ 'campaign_id' => $campaign->id,
+ 'user_id' => $user->id,
+ 'current_email_index' => 0,
+ 'created_at' => now(), // enrolled just now, need 7 days
+ ]);
+
+ $this->artisan('winback:process')->assertSuccessful();
+
+ Notification::assertNotSentTo($user, WinbackEmailNotification::class);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Unsubscribe endpoint
+// ---------------------------------------------------------------------------
+describe('Unsubscribe', function (): void {
+ it('unsubscribes recipient with valid signed URL', function (): void {
+ $campaign = WinbackCampaign::factory()->active()->create();
+ $user = User::factory()->create();
+ $recipient = WinbackRecipient::factory()->create([
+ 'campaign_id' => $campaign->id,
+ 'user_id' => $user->id,
+ ]);
+
+ // Generate a signed URL using the marketing domain as the base
+ // so the signature matches when we make the test request
+ $signedUrl = \Illuminate\Support\Facades\URL::forceRootUrl($this->marketingUrl);
+
+ $url = \Illuminate\Support\Facades\URL::signedRoute('winback.unsubscribe', [
+ 'recipient' => $recipient->id,
+ ]);
+
+ // Reset to default
+ \Illuminate\Support\Facades\URL::forceRootUrl(null);
+
+ $this->get($url)->assertOk();
+
+ $recipient->refresh();
+ expect($recipient->unsubscribed_at)->not->toBeNull();
+ });
+
+ it('rejects unsigned unsubscribe URL', function (): void {
+ $campaign = WinbackCampaign::factory()->active()->create();
+ $user = User::factory()->create();
+ $recipient = WinbackRecipient::factory()->create([
+ 'campaign_id' => $campaign->id,
+ 'user_id' => $user->id,
+ ]);
+
+ $this->get($this->marketingUrl."/unsubscribe/winback/{$recipient->id}")
+ ->assertForbidden();
+
+ $recipient->refresh();
+ expect($recipient->unsubscribed_at)->toBeNull();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Admin CRUD
+// ---------------------------------------------------------------------------
+describe('Admin Campaign CRUD', function (): void {
+ it('allows admin to view campaigns index', function (): void {
+ $admin = User::factory()->admin()->create();
+ WinbackCampaign::factory()->count(3)->create();
+
+ $this->actingAs($admin)
+ ->get($this->adminUrl.'/winback-campaigns')
+ ->assertOk()
+ ->assertInertia(fn ($page) => $page
+ ->component('Admin/WinbackCampaigns/Index')
+ ->has('campaigns.data', 3)
+ );
+ });
+
+ it('allows admin to create a campaign', function (): void {
+ $admin = User::factory()->admin()->create();
+
+ $this->actingAs($admin)
+ ->post($this->adminUrl.'/winback-campaigns', [
+ 'name' => 'Test Win-back',
+ 'cancellation_reason' => 'Too expensive',
+ 'email_sequence' => [
+ ['delay_days' => 1, 'subject' => 'Come back!', 'body' => 'We miss you.'],
+ ],
+ 'offer_type' => 'discount',
+ 'offer_value' => 20,
+ 'offer_duration_days' => 30,
+ 'coupon_code' => 'COMEBACK20',
+ 'status' => 'active',
+ ])
+ ->assertRedirect();
+
+ expect(WinbackCampaign::where('name', 'Test Win-back')->exists())->toBeTrue();
+ });
+
+ it('allows admin to update a campaign', function (): void {
+ $admin = User::factory()->admin()->create();
+ $campaign = WinbackCampaign::factory()->create();
+
+ $this->actingAs($admin)
+ ->put($this->adminUrl."/winback-campaigns/{$campaign->id}", [
+ 'name' => 'Updated Campaign',
+ 'cancellation_reason' => null,
+ 'email_sequence' => [
+ ['delay_days' => 2, 'subject' => 'Updated subject', 'body' => 'Updated body.'],
+ ],
+ 'offer_type' => 'none',
+ 'offer_value' => null,
+ 'offer_duration_days' => null,
+ 'coupon_code' => null,
+ 'status' => 'paused',
+ ])
+ ->assertRedirect();
+
+ $campaign->refresh();
+ expect($campaign->name)->toBe('Updated Campaign');
+ expect($campaign->status)->toBe('paused');
+ });
+
+ it('allows admin to view campaign details with analytics', function (): void {
+ $admin = User::factory()->admin()->create();
+ $campaign = WinbackCampaign::factory()->create();
+ WinbackRecipient::factory()->count(3)->create(['campaign_id' => $campaign->id]);
+
+ $this->actingAs($admin)
+ ->get($this->adminUrl."/winback-campaigns/{$campaign->id}")
+ ->assertOk()
+ ->assertInertia(fn ($page) => $page
+ ->component('Admin/WinbackCampaigns/Show')
+ ->has('campaign')
+ ->has('recipients')
+ ->has('analytics')
+ );
+ });
+
+ it('allows admin to archive a campaign', function (): void {
+ $admin = User::factory()->admin()->create();
+ $campaign = WinbackCampaign::factory()->active()->create();
+
+ $this->actingAs($admin)
+ ->delete($this->adminUrl."/winback-campaigns/{$campaign->id}")
+ ->assertRedirect();
+
+ $campaign->refresh();
+ expect($campaign->status)->toBe('archived');
+ });
+
+ it('validates email sequence requires at least one email', function (): void {
+ $admin = User::factory()->admin()->create();
+
+ $this->actingAs($admin)
+ ->post($this->adminUrl.'/winback-campaigns', [
+ 'name' => 'Test',
+ 'email_sequence' => [],
+ 'offer_type' => 'none',
+ 'status' => 'active',
+ ])
+ ->assertSessionHasErrors(['email_sequence']);
+ });
+
+ it('prevents customer from accessing campaign pages', function (): void {
+ $customer = User::factory()->customer()->create();
+
+ $this->actingAs($customer)
+ ->get($this->adminUrl.'/winback-campaigns')
+ ->assertForbidden();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Admin Cancellation Surveys
+// ---------------------------------------------------------------------------
+describe('Admin Cancellation Surveys', function (): void {
+ it('allows admin to view cancellation surveys', function (): void {
+ $admin = User::factory()->admin()->create();
+ CancellationSurvey::factory()->count(3)->create();
+
+ $this->actingAs($admin)
+ ->get($this->adminUrl.'/cancellation-surveys')
+ ->assertOk()
+ ->assertInertia(fn ($page) => $page
+ ->component('Admin/CancellationSurveys/Index')
+ ->has('surveys.data', 3)
+ ->has('reasonBreakdown')
+ ->has('monthlyTrend')
+ ->has('totalSurveys')
+ ->has('topReason')
+ ->has('wouldReturnRate')
+ ->has('availableReasons')
+ );
+ });
+
+ it('filters surveys by reason', function (): void {
+ $admin = User::factory()->admin()->create();
+ CancellationSurvey::factory()->create(['cancellation_reason' => 'Too expensive']);
+ CancellationSurvey::factory()->create(['cancellation_reason' => 'Missing features']);
+ CancellationSurvey::factory()->create(['cancellation_reason' => 'Too expensive']);
+
+ $this->actingAs($admin)
+ ->get($this->adminUrl.'/cancellation-surveys?reason=Too+expensive')
+ ->assertOk()
+ ->assertInertia(fn ($page) => $page
+ ->has('surveys.data', 2)
+ );
+ });
+});
diff --git a/website/tests/Feature/CancellationSurveyTest.php b/website/tests/Feature/CancellationSurveyTest.php
new file mode 100644
index 0000000..1952066
--- /dev/null
+++ b/website/tests/Feature/CancellationSurveyTest.php
@@ -0,0 +1,182 @@
+seed(RoleAndPermissionSeeder::class);
+ $this->accountUrl = 'http://'.config('app.domains.account');
+});
+
+it('persists cancellation survey with reason, feedback, and would_return', function (): void {
+ Event::fake([SubscriptionCancelled::class]);
+
+ $user = User::factory()->customer()->create();
+ $plan = Plan::factory()->create();
+ $subscription = $user->subscriptions()->create([
+ 'type' => 'default',
+ 'stripe_id' => 'sub_test_'.uniqid(),
+ 'stripe_status' => 'active',
+ 'stripe_price' => 'price_test',
+ 'plan_id' => $plan->id,
+ ]);
+
+ $mockService = Mockery::mock(BillingServiceInterface::class);
+ $mockService->shouldReceive('cancelSubscription')->once()->andReturn(true);
+
+ $mockFactory = Mockery::mock(BillingServiceFactory::class);
+ $mockFactory->shouldReceive('make')->with('stripe')->andReturn($mockService);
+ $this->app->instance(BillingServiceFactory::class, $mockFactory);
+
+ $this->actingAs($user)
+ ->post($this->accountUrl."/subscriptions/{$subscription->id}/cancel", [
+ 'immediately' => false,
+ 'reason' => 'Too expensive',
+ 'feedback' => 'The price increased too much for my budget.',
+ 'would_return' => 'maybe',
+ ])
+ ->assertRedirect();
+
+ $survey = CancellationSurvey::where('subscription_id', $subscription->id)->first();
+
+ expect($survey)->not->toBeNull();
+ expect($survey->user_id)->toBe($user->id);
+ expect($survey->subscription_id)->toBe($subscription->id);
+ expect($survey->cancellation_reason)->toBe('Too expensive');
+ expect($survey->cancellation_feedback)->toBe('The price increased too much for my budget.');
+ expect($survey->would_return)->toBe('maybe');
+ expect($survey->created_at)->not->toBeNull();
+
+ Event::assertDispatched(SubscriptionCancelled::class, function ($event) use ($user, $subscription): bool {
+ return $event->user->id === $user->id
+ && $event->subscription->id === $subscription->id
+ && $event->cancellationReason === 'Too expensive';
+ });
+});
+
+it('persists cancellation survey without optional fields (backward compat)', function (): void {
+ Event::fake([SubscriptionCancelled::class]);
+
+ $user = User::factory()->customer()->create();
+ $plan = Plan::factory()->create();
+ $subscription = $user->subscriptions()->create([
+ 'type' => 'default',
+ 'stripe_id' => 'sub_test_'.uniqid(),
+ 'stripe_status' => 'active',
+ 'stripe_price' => 'price_test',
+ 'plan_id' => $plan->id,
+ ]);
+
+ $mockService = Mockery::mock(BillingServiceInterface::class);
+ $mockService->shouldReceive('cancelSubscription')->once()->andReturn(true);
+
+ $mockFactory = Mockery::mock(BillingServiceFactory::class);
+ $mockFactory->shouldReceive('make')->with('stripe')->andReturn($mockService);
+ $this->app->instance(BillingServiceFactory::class, $mockFactory);
+
+ $this->actingAs($user)
+ ->post($this->accountUrl."/subscriptions/{$subscription->id}/cancel", [
+ 'immediately' => true,
+ ])
+ ->assertRedirect();
+
+ $survey = CancellationSurvey::where('subscription_id', $subscription->id)->first();
+
+ expect($survey)->not->toBeNull();
+ expect($survey->cancellation_reason)->toBe('');
+ expect($survey->cancellation_feedback)->toBeNull();
+ expect($survey->would_return)->toBeNull();
+
+ Event::assertDispatched(SubscriptionCancelled::class, function ($event): bool {
+ return $event->cancellationReason === null;
+ });
+});
+
+it('creates survey belonging to the correct user and subscription', function (): void {
+ Event::fake([SubscriptionCancelled::class]);
+
+ $user = User::factory()->customer()->create();
+ $otherUser = User::factory()->customer()->create();
+ $plan = Plan::factory()->create();
+
+ $subscription = $user->subscriptions()->create([
+ 'type' => 'default',
+ 'stripe_id' => 'sub_test_'.uniqid(),
+ 'stripe_status' => 'active',
+ 'stripe_price' => 'price_test',
+ 'plan_id' => $plan->id,
+ ]);
+
+ $otherSubscription = $otherUser->subscriptions()->create([
+ 'type' => 'default',
+ 'stripe_id' => 'sub_test_'.uniqid(),
+ 'stripe_status' => 'active',
+ 'stripe_price' => 'price_test',
+ 'plan_id' => $plan->id,
+ ]);
+
+ $mockService = Mockery::mock(BillingServiceInterface::class);
+ $mockService->shouldReceive('cancelSubscription')->once()->andReturn(true);
+
+ $mockFactory = Mockery::mock(BillingServiceFactory::class);
+ $mockFactory->shouldReceive('make')->with('stripe')->andReturn($mockService);
+ $this->app->instance(BillingServiceFactory::class, $mockFactory);
+
+ $this->actingAs($user)
+ ->post($this->accountUrl."/subscriptions/{$subscription->id}/cancel", [
+ 'reason' => 'No longer needed',
+ 'would_return' => 'yes',
+ ])
+ ->assertRedirect();
+
+ $survey = CancellationSurvey::first();
+
+ expect($survey->user_id)->toBe($user->id);
+ expect($survey->subscription_id)->toBe($subscription->id);
+
+ // Verify relationships load correctly
+ expect($survey->user->id)->toBe($user->id);
+ expect($survey->subscription->id)->toBe($subscription->id);
+
+ // No survey for the other user
+ expect(CancellationSurvey::where('user_id', $otherUser->id)->count())->toBe(0);
+});
+
+it('does not create survey when cancellation fails', function (): void {
+ Event::fake([SubscriptionCancelled::class]);
+
+ $user = User::factory()->customer()->create();
+ $plan = Plan::factory()->create();
+ $subscription = $user->subscriptions()->create([
+ 'type' => 'default',
+ 'stripe_id' => 'sub_test_'.uniqid(),
+ 'stripe_status' => 'active',
+ 'stripe_price' => 'price_test',
+ 'plan_id' => $plan->id,
+ ]);
+
+ $mockService = Mockery::mock(BillingServiceInterface::class);
+ $mockService->shouldReceive('cancelSubscription')->once()->andReturn(false);
+
+ $mockFactory = Mockery::mock(BillingServiceFactory::class);
+ $mockFactory->shouldReceive('make')->with('stripe')->andReturn($mockService);
+ $this->app->instance(BillingServiceFactory::class, $mockFactory);
+
+ $this->actingAs($user)
+ ->post($this->accountUrl."/subscriptions/{$subscription->id}/cancel", [
+ 'reason' => 'Too expensive',
+ 'feedback' => 'Price is too high.',
+ 'would_return' => 'no',
+ ])
+ ->assertRedirect();
+
+ expect(CancellationSurvey::count())->toBe(0);
+ Event::assertNotDispatched(SubscriptionCancelled::class);
+});
diff --git a/website/tests/Feature/ConfigurableCheckoutTest.php b/website/tests/Feature/ConfigurableCheckoutTest.php
new file mode 100644
index 0000000..59a6829
--- /dev/null
+++ b/website/tests/Feature/ConfigurableCheckoutTest.php
@@ -0,0 +1,429 @@
+seed(RoleAndPermissionSeeder::class);
+ $this->accountUrl = 'http://'.config('app.domains.account');
+
+ // Mock the billing service so checkout pages don't hit Stripe
+ $mockService = Mockery::mock(BillingServiceInterface::class);
+ $mockService->shouldReceive('getPaymentMethods')->andReturn([]);
+
+ $mockFactory = Mockery::mock(BillingServiceFactory::class);
+ $mockFactory->shouldReceive('make')->with('stripe')->andReturn($mockService);
+ $this->app->instance(BillingServiceFactory::class, $mockFactory);
+});
+
+describe('Preset Plan Checkout', function (): void {
+ it('loads config groups on checkout page', function (): void {
+ $user = User::factory()->customer()->create();
+ $plan = Plan::factory()->create(['service_type' => 'dedicated', 'status' => 'active']);
+
+ $group = PlanConfigGroup::factory()->create([
+ 'name' => 'Test Network',
+ 'mode' => 'preset',
+ 'service_type' => 'dedicated',
+ 'is_active' => true,
+ ]);
+ $group->plans()->attach($plan);
+
+ $option = PlanConfigOption::factory()->create([
+ 'group_id' => $group->id,
+ 'name' => 'Port Speed',
+ 'type' => 'radio',
+ 'is_active' => true,
+ ]);
+
+ PlanConfigValue::factory()->create([
+ 'option_id' => $option->id,
+ 'label' => '1Gbit',
+ 'value' => '1gbit',
+ 'monthly_price' => 0,
+ 'is_default' => true,
+ ]);
+
+ PlanConfigValue::factory()->create([
+ 'option_id' => $option->id,
+ 'label' => '10Gbit',
+ 'value' => '10gbit',
+ 'monthly_price' => 85.00,
+ ]);
+
+ // Mock createSetupIntent to avoid Stripe calls
+ $user->createOrGetStripeCustomer = null;
+
+ $response = $this->actingAs($user)
+ ->get($this->accountUrl."/checkout/{$plan->id}");
+
+ $response->assertOk();
+ $response->assertInertia(fn ($page) => $page
+ ->component('Checkout/Show')
+ ->has('configGroups', 1)
+ ->where('configGroups.0.name', 'Test Network')
+ ->has('configGroups.0.options', 1)
+ ->where('configGroups.0.options.0.name', 'Port Speed')
+ ->has('configGroups.0.options.0.values', 2)
+ );
+ });
+
+ it('excludes inactive options from checkout page', function (): void {
+ $user = User::factory()->customer()->create();
+ $plan = Plan::factory()->create(['service_type' => 'dedicated', 'status' => 'active']);
+
+ $group = PlanConfigGroup::factory()->create([
+ 'mode' => 'preset',
+ 'is_active' => true,
+ ]);
+ $group->plans()->attach($plan);
+
+ PlanConfigOption::factory()->create([
+ 'group_id' => $group->id,
+ 'name' => 'Active Option',
+ 'is_active' => true,
+ ]);
+
+ PlanConfigOption::factory()->create([
+ 'group_id' => $group->id,
+ 'name' => 'Inactive Option',
+ 'is_active' => false,
+ ]);
+
+ $response = $this->actingAs($user)
+ ->get($this->accountUrl."/checkout/{$plan->id}");
+
+ $response->assertOk();
+ $response->assertInertia(fn ($page) => $page
+ ->component('Checkout/Show')
+ ->has('configGroups', 1)
+ ->has('configGroups.0.options', 1)
+ ->where('configGroups.0.options.0.name', 'Active Option')
+ );
+ });
+
+ it('excludes inactive groups from checkout page', function (): void {
+ $user = User::factory()->customer()->create();
+ $plan = Plan::factory()->create(['service_type' => 'dedicated', 'status' => 'active']);
+
+ $activeGroup = PlanConfigGroup::factory()->create([
+ 'name' => 'Active Group',
+ 'mode' => 'preset',
+ 'is_active' => true,
+ ]);
+ $activeGroup->plans()->attach($plan);
+
+ PlanConfigOption::factory()->create([
+ 'group_id' => $activeGroup->id,
+ 'name' => 'Some Option',
+ 'is_active' => true,
+ ]);
+
+ $inactiveGroup = PlanConfigGroup::factory()->create([
+ 'name' => 'Inactive Group',
+ 'mode' => 'preset',
+ 'is_active' => false,
+ ]);
+ $inactiveGroup->plans()->attach($plan);
+
+ PlanConfigOption::factory()->create([
+ 'group_id' => $inactiveGroup->id,
+ 'name' => 'Hidden Option',
+ 'is_active' => true,
+ ]);
+
+ $response = $this->actingAs($user)
+ ->get($this->accountUrl."/checkout/{$plan->id}");
+
+ $response->assertOk();
+ $response->assertInertia(fn ($page) => $page
+ ->component('Checkout/Show')
+ ->has('configGroups', 1)
+ ->where('configGroups.0.name', 'Active Group')
+ );
+ });
+});
+
+describe('BYO Checkout', function (): void {
+ it('loads configurator for VPS service type', function (): void {
+ $user = User::factory()->customer()->create();
+
+ // Create the internal custom plan
+ $plan = Plan::factory()->create([
+ 'slug' => 'vps-custom',
+ 'service_type' => 'vps',
+ 'status' => 'internal',
+ 'price' => 0,
+ ]);
+
+ $group = PlanConfigGroup::factory()->buildYourOwn('vps')->create([
+ 'name' => 'VPS Builder',
+ 'is_active' => true,
+ ]);
+
+ PlanConfigOption::factory()->slider(2.00, 0.003)->create([
+ 'group_id' => $group->id,
+ 'name' => 'CPU Cores',
+ 'provisioning_key' => 'cpu_cores',
+ 'min_qty' => 1,
+ 'max_qty' => 16,
+ 'unit_label' => 'cores',
+ ]);
+
+ PlanConfigOption::factory()->slider(1.00, 0.0015)->create([
+ 'group_id' => $group->id,
+ 'name' => 'RAM',
+ 'provisioning_key' => 'ram_gb',
+ 'min_qty' => 1,
+ 'max_qty' => 64,
+ 'unit_label' => 'GB',
+ ]);
+
+ $response = $this->actingAs($user)
+ ->get($this->accountUrl.'/checkout/custom/vps');
+
+ $response->assertOk();
+ $response->assertInertia(fn ($page) => $page
+ ->component('Checkout/Show')
+ ->has('configGroups', 1)
+ ->where('configGroups.0.name', 'VPS Builder')
+ ->where('configGroups.0.mode', 'build_your_own')
+ ->has('configGroups.0.options', 2)
+ ->where('mode', 'custom')
+ );
+ });
+
+ it('loads configurator for MySQL service type', function (): void {
+ $user = User::factory()->customer()->create();
+
+ Plan::factory()->create([
+ 'slug' => 'mysql-custom',
+ 'service_type' => 'mysql',
+ 'status' => 'internal',
+ 'price' => 0,
+ ]);
+
+ $group = PlanConfigGroup::factory()->buildYourOwn('mysql')->create([
+ 'name' => 'MySQL Builder',
+ 'is_active' => true,
+ ]);
+
+ PlanConfigOption::factory()->slider(0.20, 0.0003)->create([
+ 'group_id' => $group->id,
+ 'name' => 'Storage',
+ 'provisioning_key' => 'storage_gb',
+ ]);
+
+ $response = $this->actingAs($user)
+ ->get($this->accountUrl.'/checkout/custom/mysql');
+
+ $response->assertOk();
+ $response->assertInertia(fn ($page) => $page
+ ->component('Checkout/Show')
+ ->where('mode', 'custom')
+ ->has('configGroups', 1)
+ ->where('configGroups.0.mode', 'build_your_own')
+ );
+ });
+
+ it('returns 404 for unsupported service type', function (): void {
+ $user = User::factory()->customer()->create();
+
+ $response = $this->actingAs($user)
+ ->get($this->accountUrl.'/checkout/custom/dedicated');
+
+ $response->assertNotFound();
+ });
+
+ it('returns 404 when no BYO group exists for service type', function (): void {
+ $user = User::factory()->customer()->create();
+
+ // Create the custom plan but no BYO group
+ Plan::factory()->create([
+ 'slug' => 'game-custom',
+ 'service_type' => 'game',
+ 'status' => 'internal',
+ 'price' => 0,
+ ]);
+
+ $response = $this->actingAs($user)
+ ->get($this->accountUrl.'/checkout/custom/game');
+
+ $response->assertNotFound();
+ });
+});
+
+describe('Authentication', function (): void {
+ it('redirects unauthenticated user from preset checkout', function (): void {
+ $plan = Plan::factory()->create(['status' => 'active']);
+
+ $response = $this->get($this->accountUrl."/checkout/{$plan->id}");
+
+ $response->assertRedirect();
+ });
+
+ it('redirects unauthenticated user from BYO checkout', function (): void {
+ $response = $this->get($this->accountUrl.'/checkout/custom/vps');
+
+ $response->assertRedirect();
+ });
+});
+
+describe('Seeder Integration', function (): void {
+ it('seeds all BYO groups with correct options', function (): void {
+ $this->seed(\Database\Seeders\PlanSeeder::class);
+ $this->seed(\Database\Seeders\ConfigOptionSeeder::class);
+
+ // VPS Builder
+ $vpsBuilder = PlanConfigGroup::where('name', 'VPS Builder')->first();
+ expect($vpsBuilder)->not->toBeNull();
+ expect($vpsBuilder->mode)->toBe('build_your_own');
+ expect($vpsBuilder->service_type)->toBe('vps');
+ expect($vpsBuilder->options)->toHaveCount(3);
+
+ $cpuOption = $vpsBuilder->options->firstWhere('name', 'CPU Cores');
+ expect($cpuOption->type)->toBe('slider');
+ expect($cpuOption->provisioning_key)->toBe('cpu_cores');
+ expect($cpuOption->min_qty)->toBe(1);
+ expect($cpuOption->max_qty)->toBe(16);
+ expect((float) $cpuOption->monthly_price)->toBe(2.00);
+ expect((float) $cpuOption->hourly_price)->toBe(0.003);
+
+ // MySQL Builder
+ $mysqlBuilder = PlanConfigGroup::where('name', 'MySQL Builder')->first();
+ expect($mysqlBuilder)->not->toBeNull();
+ expect($mysqlBuilder->options)->toHaveCount(2);
+
+ // Game Server Builder
+ $gameBuilder = PlanConfigGroup::where('name', 'Game Server Builder')->first();
+ expect($gameBuilder)->not->toBeNull();
+ expect($gameBuilder->options)->toHaveCount(3);
+ });
+
+ it('seeds preset groups with correct values and plan attachments', function (): void {
+ $this->seed(\Database\Seeders\PlanSeeder::class);
+ $this->seed(\Database\Seeders\ConfigOptionSeeder::class);
+
+ // Dedicated RAM - DDR4 attached to 4 servers
+ $ramGroup = PlanConfigGroup::where('name', 'Dedicated RAM - DDR4')->first();
+ expect($ramGroup)->not->toBeNull();
+ expect($ramGroup->plans)->toHaveCount(4);
+
+ $ramOption = $ramGroup->options->first();
+ expect($ramOption->name)->toBe('DDR4 ECC RAM');
+ expect($ramOption->type)->toBe('dropdown');
+ expect($ramOption->required)->toBeTrue();
+ expect($ramOption->values)->toHaveCount(8);
+
+ // Check the 32 GB default value
+ $defaultValue = $ramOption->values->firstWhere('label', '32 GB');
+ expect($defaultValue->is_default)->toBeTrue();
+ expect((float) $defaultValue->monthly_price)->toBe(0.00);
+
+ // Check the 512 GB value
+ $maxValue = $ramOption->values->firstWhere('label', '512 GB');
+ expect((float) $maxValue->monthly_price)->toBe(120.00);
+
+ // Verify plan slugs are attached
+ $attachedSlugs = $ramGroup->plans->pluck('slug')->sort()->values()->toArray();
+ expect($attachedSlugs)->toBe(['dell-r440', 'dell-r540', 'dell-r640', 'dell-r740']);
+ });
+
+ it('applies correct discount pricing for billing cycles', function (): void {
+ $this->seed(\Database\Seeders\PlanSeeder::class);
+ $this->seed(\Database\Seeders\ConfigOptionSeeder::class);
+
+ // Check a value with $25 monthly (Semi-Managed from Server Management)
+ $mgmtGroup = PlanConfigGroup::where('name', 'Server Management')->first();
+ $mgmtOption = $mgmtGroup->options->first();
+ $semiManaged = $mgmtOption->values->firstWhere('value', 'semi_managed');
+
+ // quarterly = 25 * 3 * 0.95 = 71.25
+ expect((float) $semiManaged->quarterly_price)->toBe(71.25);
+ // semi_annual = 25 * 6 * 0.90 = 135.00
+ expect((float) $semiManaged->semi_annual_price)->toBe(135.00);
+ // annual = 25 * 12 * 0.85 = 255.00
+ expect((float) $semiManaged->annual_price)->toBe(255.00);
+ });
+
+ it('is idempotent when run multiple times', function (): void {
+ $this->seed(\Database\Seeders\PlanSeeder::class);
+ $this->seed(\Database\Seeders\ConfigOptionSeeder::class);
+ $this->seed(\Database\Seeders\ConfigOptionSeeder::class);
+
+ // Should not create duplicates
+ expect(PlanConfigGroup::where('name', 'VPS Builder')->count())->toBe(1);
+ expect(PlanConfigGroup::where('name', 'Dedicated RAM - DDR4')->count())->toBe(1);
+ expect(PlanConfigGroup::where('name', 'M.2 NVMe')->count())->toBe(1);
+
+ // Options should not be duplicated
+ $vpsBuilder = PlanConfigGroup::where('name', 'VPS Builder')->first();
+ expect($vpsBuilder->options)->toHaveCount(3);
+ });
+
+ it('seeds Dedicated Network group with radio and dropdown options', function (): void {
+ $this->seed(\Database\Seeders\PlanSeeder::class);
+ $this->seed(\Database\Seeders\ConfigOptionSeeder::class);
+
+ $networkGroup = PlanConfigGroup::where('name', 'Dedicated Network')->first();
+ expect($networkGroup)->not->toBeNull();
+ expect($networkGroup->options)->toHaveCount(3);
+
+ $portSpeed = $networkGroup->options->firstWhere('name', 'Network Port Speed');
+ expect($portSpeed->type)->toBe('radio');
+ expect($portSpeed->required)->toBeTrue();
+ expect($portSpeed->values)->toHaveCount(5);
+
+ $publicBw = $networkGroup->options->firstWhere('name', 'Public Bandwidth');
+ expect($publicBw->type)->toBe('dropdown');
+ expect($publicBw->values)->toHaveCount(4);
+
+ $privateBw = $networkGroup->options->firstWhere('name', 'Private Bandwidth');
+ expect($privateBw->type)->toBe('radio');
+ expect($privateBw->values)->toHaveCount(2);
+ });
+
+ it('seeds M.2 NVMe group attached to 14th gen servers', function (): void {
+ $this->seed(\Database\Seeders\PlanSeeder::class);
+ $this->seed(\Database\Seeders\ConfigOptionSeeder::class);
+
+ $nvmeGroup = PlanConfigGroup::where('name', 'M.2 NVMe')->first();
+ expect($nvmeGroup)->not->toBeNull();
+ expect($nvmeGroup->plans)->toHaveCount(4);
+
+ $option = $nvmeGroup->options->first();
+ expect($option->type)->toBe('dropdown');
+ expect($option->values)->toHaveCount(4);
+
+ // Check that 2x 2TB is the most expensive at $75
+ $maxValue = $option->values->firstWhere('label', '2x 2TB');
+ expect((float) $maxValue->monthly_price)->toBe(75.00);
+ });
+
+ it('seeds VPS Add-ons with quantity and checkbox options', function (): void {
+ $this->seed(\Database\Seeders\PlanSeeder::class);
+ $this->seed(\Database\Seeders\ConfigOptionSeeder::class);
+
+ $addonsGroup = PlanConfigGroup::where('name', 'VPS Add-ons')->first();
+ expect($addonsGroup)->not->toBeNull();
+ expect($addonsGroup->options)->toHaveCount(2);
+
+ $ipv4 = $addonsGroup->options->firstWhere('name', 'IPv4 Addresses');
+ expect($ipv4->type)->toBe('quantity');
+ expect($ipv4->min_qty)->toBe(1);
+ expect($ipv4->max_qty)->toBe(8);
+ expect((float) $ipv4->monthly_price)->toBe(3.00);
+
+ $windows = $addonsGroup->options->firstWhere('name', 'Windows License');
+ expect($windows->type)->toBe('checkbox');
+ expect($windows->values)->toHaveCount(1);
+ });
+});
diff --git a/website/tests/Feature/LoginHistoryTest.php b/website/tests/Feature/LoginHistoryTest.php
new file mode 100644
index 0000000..c1175ba
--- /dev/null
+++ b/website/tests/Feature/LoginHistoryTest.php
@@ -0,0 +1,312 @@
+seed(RoleAndPermissionSeeder::class);
+ $this->accountUrl = 'http://'.config('app.domains.account');
+ $this->adminUrl = 'http://'.config('app.domains.admin');
+});
+
+// ---------------------------------------------------------------------------
+// Device Hash
+// ---------------------------------------------------------------------------
+describe('Device Hash', function (): void {
+ it('generates consistent device hash for same inputs', function (): void {
+ $hash1 = LoginHistory::generateDeviceHash('Mozilla/5.0 Chrome', '192.168.1.100');
+ $hash2 = LoginHistory::generateDeviceHash('Mozilla/5.0 Chrome', '192.168.1.100');
+
+ expect($hash1)->toBe($hash2)
+ ->and(strlen($hash1))->toBe(64);
+ });
+
+ it('groups similar IPs using first 3 octets', function (): void {
+ $hash1 = LoginHistory::generateDeviceHash('Mozilla/5.0 Chrome', '192.168.1.100');
+ $hash2 = LoginHistory::generateDeviceHash('Mozilla/5.0 Chrome', '192.168.1.200');
+
+ expect($hash1)->toBe($hash2);
+ });
+
+ it('produces different hash for different user agents', function (): void {
+ $hash1 = LoginHistory::generateDeviceHash('Mozilla/5.0 Chrome', '192.168.1.100');
+ $hash2 = LoginHistory::generateDeviceHash('Mozilla/5.0 Firefox', '192.168.1.100');
+
+ expect($hash1)->not->toBe($hash2);
+ });
+
+ it('handles IPv6 addresses gracefully', function (): void {
+ $hash = LoginHistory::generateDeviceHash('Mozilla/5.0', '2001:0db8:85a3:0000:0000:8a2e:0370:7334');
+
+ expect($hash)->toBeString()
+ ->and(strlen($hash))->toBe(64);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Login Recording
+// ---------------------------------------------------------------------------
+describe('Login Recording', function (): void {
+ it('creates a history record on successful login', function (): void {
+ Notification::fake();
+ $user = User::factory()->customer()->create();
+
+ $event = new Login('web', $user, false);
+ $listener = app(RecordLoginHistory::class);
+ $listener->handle($event);
+
+ expect(LoginHistory::query()->count())->toBe(1);
+
+ $record = LoginHistory::query()->first();
+ expect($record->user_id)->toBe($user->id)
+ ->and($record->success)->toBeTrue()
+ ->and($record->device_hash)->not->toBeNull();
+ });
+
+ it('creates a record with success=false on failed login', function (): void {
+ $user = User::factory()->customer()->create();
+
+ $event = new Failed('web', $user, ['email' => $user->email, 'password' => 'wrong']);
+ $listener = app(RecordFailedLogin::class);
+ $listener->handle($event);
+
+ expect(LoginHistory::query()->count())->toBe(1);
+
+ $record = LoginHistory::query()->first();
+ expect($record->user_id)->toBe($user->id)
+ ->and($record->success)->toBeFalse();
+ });
+
+ it('skips failed login recording when user not found', function (): void {
+ $event = new Failed('web', null, ['email' => 'nonexistent@example.com', 'password' => 'wrong']);
+ $listener = app(RecordFailedLogin::class);
+ $listener->handle($event);
+
+ expect(LoginHistory::query()->count())->toBe(0);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// New Device Detection
+// ---------------------------------------------------------------------------
+describe('New Device Detection', function (): void {
+ it('does not flag first-ever login as new device', function (): void {
+ Notification::fake();
+ $user = User::factory()->customer()->create();
+
+ $event = new Login('web', $user, false);
+ $listener = app(RecordLoginHistory::class);
+ $listener->handle($event);
+
+ $record = LoginHistory::query()->first();
+ expect($record->is_new_device)->toBeFalse();
+ });
+
+ it('flags login from different device as new', function (): void {
+ Notification::fake();
+ $user = User::factory()->customer()->create();
+
+ // Create initial login history
+ LoginHistory::factory()->create([
+ 'user_id' => $user->id,
+ 'device_hash' => LoginHistory::generateDeviceHash('OldBrowser/1.0', '10.0.0.1'),
+ ]);
+
+ // New login event with different user agent (from request)
+ $event = new Login('web', $user, false);
+ $listener = app(RecordLoginHistory::class);
+ $listener->handle($event);
+
+ $newRecord = LoginHistory::query()
+ ->where('user_id', $user->id)
+ ->latest('id')
+ ->first();
+
+ expect($newRecord->is_new_device)->toBeTrue();
+ });
+
+ it('does not flag same device as new', function (): void {
+ Notification::fake();
+ $user = User::factory()->customer()->create();
+
+ // First login
+ $event = new Login('web', $user, false);
+ $listener = app(RecordLoginHistory::class);
+ $listener->handle($event);
+
+ // Second login from same device (same request context)
+ $listener->handle($event);
+
+ $records = LoginHistory::query()->where('user_id', $user->id)->get();
+ expect($records)->toHaveCount(2);
+ expect($records[0]->is_new_device)->toBeFalse();
+ expect($records[1]->is_new_device)->toBeFalse();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Notifications
+// ---------------------------------------------------------------------------
+describe('New Device Notification', function (): void {
+ it('sends notification when new device detected with previous logins', function (): void {
+ Notification::fake();
+ $user = User::factory()->customer()->create();
+
+ // Create a prior login with a different device hash
+ LoginHistory::factory()->create([
+ 'user_id' => $user->id,
+ 'device_hash' => LoginHistory::generateDeviceHash('OldBrowser/1.0', '10.0.0.1'),
+ ]);
+
+ $event = new Login('web', $user, false);
+ $listener = app(RecordLoginHistory::class);
+ $listener->handle($event);
+
+ Notification::assertSentTo($user, NewDeviceLoginNotification::class);
+ });
+
+ it('does not send notification on first-ever login', function (): void {
+ Notification::fake();
+ $user = User::factory()->customer()->create();
+
+ $event = new Login('web', $user, false);
+ $listener = app(RecordLoginHistory::class);
+ $listener->handle($event);
+
+ Notification::assertNotSentTo($user, NewDeviceLoginNotification::class);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Customer Pages
+// ---------------------------------------------------------------------------
+describe('Customer Login History Page', function (): void {
+ it('allows customer to view their login history page', function (): void {
+ $customer = User::factory()->customer()->create();
+
+ LoginHistory::factory()->count(3)->create(['user_id' => $customer->id]);
+
+ $this->actingAs($customer)
+ ->get($this->accountUrl.'/login-history')
+ ->assertOk()
+ ->assertInertia(fn ($page) => $page
+ ->component('Profile/LoginHistory')
+ ->has('loginHistories.data', 3)
+ );
+ });
+
+ it('only shows the current users login history', function (): void {
+ $customer = User::factory()->customer()->create();
+ $other = User::factory()->customer()->create();
+
+ LoginHistory::factory()->count(2)->create(['user_id' => $customer->id]);
+ LoginHistory::factory()->count(5)->create(['user_id' => $other->id]);
+
+ $this->actingAs($customer)
+ ->get($this->accountUrl.'/login-history')
+ ->assertOk()
+ ->assertInertia(fn ($page) => $page
+ ->has('loginHistories.data', 2)
+ );
+ });
+
+ it('requires authentication to view login history', function (): void {
+ $this->get($this->accountUrl.'/login-history')
+ ->assertRedirect();
+ });
+
+ it('paginates login history entries', function (): void {
+ $customer = User::factory()->customer()->create();
+
+ LoginHistory::factory()->count(20)->create(['user_id' => $customer->id]);
+
+ $this->actingAs($customer)
+ ->get($this->accountUrl.'/login-history')
+ ->assertOk()
+ ->assertInertia(fn ($page) => $page
+ ->has('loginHistories.data', 15)
+ ->where('loginHistories.total', 20)
+ ->where('loginHistories.last_page', 2)
+ );
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Profile Security Tab
+// ---------------------------------------------------------------------------
+describe('Profile Security Tab', function (): void {
+ it('includes login histories in profile page props', function (): void {
+ $customer = User::factory()->customer()->create();
+
+ LoginHistory::factory()->count(5)->create(['user_id' => $customer->id]);
+
+ $this->actingAs($customer)
+ ->get($this->accountUrl.'/profile')
+ ->assertOk()
+ ->assertInertia(fn ($page) => $page
+ ->has('loginHistories', 5)
+ );
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Admin Customer Show
+// ---------------------------------------------------------------------------
+describe('Admin Customer Show', function (): void {
+ it('includes login histories on admin customer show page', function (): void {
+ $admin = User::factory()->admin()->create();
+ $customer = User::factory()->customer()->create();
+
+ LoginHistory::factory()->count(10)->create(['user_id' => $customer->id]);
+
+ $this->actingAs($admin)
+ ->get($this->adminUrl.'/customers/'.$customer->id)
+ ->assertOk()
+ ->assertInertia(fn ($page) => $page
+ ->component('Admin/Customers/Show')
+ ->has('loginHistories', 10)
+ );
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Scopes
+// ---------------------------------------------------------------------------
+describe('Scopes', function (): void {
+ it('filters successful logins', function (): void {
+ $user = User::factory()->customer()->create();
+
+ LoginHistory::factory()->count(3)->create(['user_id' => $user->id, 'success' => true]);
+ LoginHistory::factory()->count(2)->failed()->create(['user_id' => $user->id]);
+
+ expect(LoginHistory::query()->successful()->count())->toBe(3);
+ });
+
+ it('filters failed logins', function (): void {
+ $user = User::factory()->customer()->create();
+
+ LoginHistory::factory()->count(3)->create(['user_id' => $user->id, 'success' => true]);
+ LoginHistory::factory()->count(2)->failed()->create(['user_id' => $user->id]);
+
+ expect(LoginHistory::query()->failed()->count())->toBe(2);
+ });
+
+ it('filters by user id', function (): void {
+ $user1 = User::factory()->customer()->create();
+ $user2 = User::factory()->customer()->create();
+
+ LoginHistory::factory()->count(3)->create(['user_id' => $user1->id]);
+ LoginHistory::factory()->count(5)->create(['user_id' => $user2->id]);
+
+ expect(LoginHistory::query()->forUser($user1->id)->count())->toBe(3);
+ });
+});
diff --git a/website/tests/Feature/PlanPriceTest.php b/website/tests/Feature/PlanPriceTest.php
index 46ce3e8..d3983dc 100644
--- a/website/tests/Feature/PlanPriceTest.php
+++ b/website/tests/Feature/PlanPriceTest.php
@@ -110,7 +110,7 @@ it('archives old VPS plan slugs', function () {
$this->seed(\Database\Seeders\PlanSeeder::class);
$oldPlan = Plan::where('slug', 'vps-nano')->first();
- expect($oldPlan->status)->toBe('archived');
+ expect($oldPlan->status)->toBe('inactive');
});
it('sets correct monthly base prices', function () {
diff --git a/website/tests/Feature/TrustedDeviceTest.php b/website/tests/Feature/TrustedDeviceTest.php
new file mode 100644
index 0000000..a5e21ba
--- /dev/null
+++ b/website/tests/Feature/TrustedDeviceTest.php
@@ -0,0 +1,209 @@
+seed(RoleAndPermissionSeeder::class);
+ $this->accountUrl = 'http://'.config('app.domains.account');
+ $this->adminUrl = 'http://'.config('app.domains.admin');
+
+ $this->user = User::factory()->customer()->create([
+ 'password' => bcrypt('password'),
+ ]);
+});
+
+it('creates a trusted device after 2FA with trust_device flag', function (): void {
+ // Enable 2FA for user
+ $this->user->forceFill([
+ 'two_factor_secret' => encrypt('test-secret'),
+ 'two_factor_confirmed_at' => now(),
+ ])->save();
+
+ $listener = new \App\Listeners\HandleTwoFactorAuthenticated;
+
+ // Simulate a request with trust_device
+ $request = \Illuminate\Http\Request::create('/two-factor-challenge', 'POST', [
+ 'trust_device' => true,
+ ]);
+ $request->setUserResolver(fn () => $this->user);
+
+ // Set the request in the container
+ app()->instance('request', $request);
+
+ $event = new \Laravel\Fortify\Events\ValidTwoFactorAuthenticationCodeProvided($this->user);
+ $listener->handle($event);
+
+ expect(TrustedDevice::query()->where('user_id', $this->user->id)->count())->toBe(1);
+
+ $device = TrustedDevice::query()->where('user_id', $this->user->id)->first();
+ expect($device->expires_at->isFuture())->toBeTrue();
+ expect($device->device_name)->not->toBeNull();
+});
+
+it('does not create a trusted device when trust_device flag is false', function (): void {
+ $listener = new \App\Listeners\HandleTwoFactorAuthenticated;
+
+ $request = \Illuminate\Http\Request::create('/two-factor-challenge', 'POST', [
+ 'trust_device' => false,
+ ]);
+ app()->instance('request', $request);
+
+ $event = new \Laravel\Fortify\Events\ValidTwoFactorAuthenticationCodeProvided($this->user);
+ $listener->handle($event);
+
+ expect(TrustedDevice::query()->where('user_id', $this->user->id)->count())->toBe(0);
+});
+
+it('trusted device is recognized as active and not expired', function (): void {
+ $device = TrustedDevice::factory()->create([
+ 'user_id' => $this->user->id,
+ 'expires_at' => now()->addDays(30),
+ ]);
+
+ expect($device->isExpired())->toBeFalse();
+ expect(TrustedDevice::query()->active()->where('user_id', $this->user->id)->count())->toBe(1);
+});
+
+it('expired device is not recognized as active', function (): void {
+ $device = TrustedDevice::factory()->expired()->create([
+ 'user_id' => $this->user->id,
+ ]);
+
+ expect($device->isExpired())->toBeTrue();
+ expect(TrustedDevice::query()->active()->where('user_id', $this->user->id)->count())->toBe(0);
+});
+
+it('customer removes individual trusted device', function (): void {
+ $device = TrustedDevice::factory()->create([
+ 'user_id' => $this->user->id,
+ ]);
+
+ $response = $this->actingAs($this->user)
+ ->delete("{$this->accountUrl}/profile/trusted-devices/{$device->id}");
+
+ $response->assertRedirect();
+ expect(TrustedDevice::query()->find($device->id))->toBeNull();
+});
+
+it('customer removes all trusted devices', function (): void {
+ TrustedDevice::factory()->count(3)->create([
+ 'user_id' => $this->user->id,
+ ]);
+
+ expect(TrustedDevice::query()->where('user_id', $this->user->id)->count())->toBe(3);
+
+ $response = $this->actingAs($this->user)
+ ->delete("{$this->accountUrl}/profile/trusted-devices");
+
+ $response->assertRedirect();
+ expect(TrustedDevice::query()->where('user_id', $this->user->id)->count())->toBe(0);
+});
+
+it('customer cannot remove another user\'s trusted device', function (): void {
+ $otherUser = User::factory()->customer()->create();
+
+ $device = TrustedDevice::factory()->create([
+ 'user_id' => $otherUser->id,
+ ]);
+
+ $response = $this->actingAs($this->user)
+ ->delete("{$this->accountUrl}/profile/trusted-devices/{$device->id}");
+
+ $response->assertForbidden();
+ expect(TrustedDevice::query()->find($device->id))->not->toBeNull();
+});
+
+it('customer logs out other sessions with correct password', function (): void {
+ $response = $this->actingAs($this->user)
+ ->delete("{$this->accountUrl}/profile/sessions", [
+ 'password' => 'password',
+ ]);
+
+ $response->assertRedirect();
+ $response->assertSessionHas('success');
+});
+
+it('customer cannot log out other sessions with wrong password', function (): void {
+ $response = $this->actingAs($this->user)
+ ->delete("{$this->accountUrl}/profile/sessions", [
+ 'password' => 'wrong-password',
+ ]);
+
+ $response->assertRedirect();
+ $response->assertSessionHasErrors('password');
+});
+
+it('admin force-logouts customer', function (): void {
+ $admin = User::factory()->admin()->create();
+
+ // Create trusted devices for target user
+ TrustedDevice::factory()->count(2)->create([
+ 'user_id' => $this->user->id,
+ ]);
+
+ $response = $this->actingAs($admin)
+ ->post("{$this->adminUrl}/customers/{$this->user->id}/force-logout");
+
+ $response->assertRedirect();
+ $response->assertSessionHas('success');
+
+ // Verify trusted devices were cleared
+ expect(TrustedDevice::query()->where('user_id', $this->user->id)->count())->toBe(0);
+
+ // Verify remember token was cycled
+ $this->user->refresh();
+ expect($this->user->remember_token)->not->toBeNull();
+});
+
+it('checks that trusted device lookup works for active devices', function (): void {
+ $ip = '192.168.1.100';
+ $ua = 'Mozilla/5.0 Test Browser';
+ $deviceHash = LoginHistory::generateDeviceHash($ua, $ip);
+
+ // Enable 2FA for user
+ $this->user->forceFill([
+ 'two_factor_secret' => encrypt('test-secret'),
+ 'two_factor_confirmed_at' => now(),
+ ])->save();
+
+ // Create an active trusted device
+ TrustedDevice::factory()->create([
+ 'user_id' => $this->user->id,
+ 'device_hash' => $deviceHash,
+ 'expires_at' => now()->addDays(30),
+ ]);
+
+ // Verify the trusted device exists and is active
+ $trustedDevice = TrustedDevice::query()
+ ->where('user_id', $this->user->id)
+ ->where('device_hash', $deviceHash)
+ ->active()
+ ->first();
+
+ expect($trustedDevice)->not->toBeNull();
+ expect($trustedDevice->isExpired())->toBeFalse();
+});
+
+it('expired trusted device does not match active scope', function (): void {
+ $ip = '192.168.1.100';
+ $ua = 'Mozilla/5.0 Test Browser';
+ $deviceHash = LoginHistory::generateDeviceHash($ua, $ip);
+
+ TrustedDevice::factory()->expired()->create([
+ 'user_id' => $this->user->id,
+ 'device_hash' => $deviceHash,
+ ]);
+
+ $trustedDevice = TrustedDevice::query()
+ ->where('user_id', $this->user->id)
+ ->where('device_hash', $deviceHash)
+ ->active()
+ ->first();
+
+ expect($trustedDevice)->toBeNull();
+});
diff --git a/website/tests/Pest.php b/website/tests/Pest.php
index 81acb27..55615d9 100644
--- a/website/tests/Pest.php
+++ b/website/tests/Pest.php
@@ -1,5 +1,5 @@
extend(Tests\TestCase::class)
- ->use(Illuminate\Foundation\Testing\RefreshDatabase::class)
+ ->use(Illuminate\Foundation\Testing\DatabaseTransactions::class)
->in('Feature');