feat: complete pre-launch audit — frontend polish, churn prevention, login history, financial reports, configurable checkout

Includes all work from phases 6-9+ and frontend polish rounds 1 & 2:

- Login history with device trust, new device notifications, session management
- Churn prevention: cancellation surveys, winback campaigns with email sequences
- Financial reports: revenue, P&L, tax, aging, refund, subscription reports with PDF/CSV/JSON export
- Configurable checkout: plan config groups/options, build-your-own VPS
- Frontend polish: fix broken legal links, add SEO meta tags, favicon, font display=swap,
  Head titles on all 14 marketing pages, mobile responsive fixes, AuthLayout legal footer,
  remove false 24/7 claims, hide empty stats, correct uptime SLA to 99.9%,
  GameServers notify buttons linked to /contact, 301 redirects for /terms and /privacy
- WHMCS migration scripts
- Update legal page effective dates to March 16, 2026

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Dev
2026-03-16 11:39:25 -04:00
parent 5be235d35e
commit b4ef90465c
187 changed files with 27317 additions and 1840 deletions

View File

@@ -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,
],
]);

View File

@@ -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,
])

View File

@@ -0,0 +1,342 @@
<?php
declare(strict_types=1);
use App\Models\Plan;
use App\Models\PlanConfigGroup;
use App\Models\PlanConfigOption;
use App\Models\PlanConfigValue;
use App\Models\SubscriptionConfigSelection;
use App\Models\User;
use Database\Seeders\RoleAndPermissionSeeder;
use Laravel\Cashier\Subscription;
beforeEach(function (): void {
$this->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']);
});
});

View File

@@ -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);

View File

@@ -0,0 +1,391 @@
<?php
declare(strict_types=1);
use App\Models\Invoice;
use App\Models\PaymentTransaction;
use App\Models\Plan;
use App\Models\PlanPrice;
use App\Models\Service;
use App\Models\User;
use Database\Seeders\RoleAndPermissionSeeder;
use Illuminate\Support\Facades\Cache;
use Laravel\Cashier\Subscription;
beforeEach(function (): void {
$this->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')
);
});
});

View File

@@ -0,0 +1,400 @@
<?php
declare(strict_types=1);
use App\Models\Invoice;
use App\Models\PaymentTransaction;
use App\Models\User;
use Database\Seeders\RoleAndPermissionSeeder;
use Laravel\Cashier\Subscription;
beforeEach(function (): void {
$this->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)
);
});
});

View File

@@ -0,0 +1,408 @@
<?php
declare(strict_types=1);
use App\Events\SubscriptionCancelled;
use App\Events\SubscriptionCreated;
use App\Listeners\HandleSubscriptionCancelled;
use App\Listeners\HandleSubscriptionCreated;
use App\Models\CancellationSurvey;
use App\Models\User;
use App\Models\WinbackCampaign;
use App\Models\WinbackRecipient;
use App\Notifications\WinbackEmailNotification;
use Database\Seeders\RoleAndPermissionSeeder;
use Illuminate\Support\Facades\Notification;
use Laravel\Cashier\Subscription;
beforeEach(function (): void {
$this->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)
);
});
});

View File

@@ -0,0 +1,182 @@
<?php
declare(strict_types=1);
use App\Events\SubscriptionCancelled;
use App\Models\CancellationSurvey;
use App\Models\Plan;
use App\Models\User;
use App\Services\Billing\BillingServiceFactory;
use App\Services\Billing\BillingServiceInterface;
use Database\Seeders\RoleAndPermissionSeeder;
beforeEach(function (): void {
$this->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);
});

View File

@@ -0,0 +1,429 @@
<?php
declare(strict_types=1);
use App\Models\Plan;
use App\Models\PlanConfigGroup;
use App\Models\PlanConfigOption;
use App\Models\PlanConfigValue;
use App\Models\User;
use App\Services\Billing\BillingServiceFactory;
use App\Services\Billing\BillingServiceInterface;
use Database\Seeders\RoleAndPermissionSeeder;
beforeEach(function (): void {
$this->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);
});
});

View File

@@ -0,0 +1,312 @@
<?php
declare(strict_types=1);
use App\Listeners\RecordFailedLogin;
use App\Listeners\RecordLoginHistory;
use App\Models\LoginHistory;
use App\Models\User;
use App\Notifications\NewDeviceLoginNotification;
use Database\Seeders\RoleAndPermissionSeeder;
use Illuminate\Auth\Events\Failed;
use Illuminate\Auth\Events\Login;
use Illuminate\Support\Facades\Notification;
beforeEach(function (): void {
$this->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);
});
});

View File

@@ -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 () {

View File

@@ -0,0 +1,209 @@
<?php
declare(strict_types=1);
use App\Models\LoginHistory;
use App\Models\TrustedDevice;
use App\Models\User;
use Database\Seeders\RoleAndPermissionSeeder;
beforeEach(function (): void {
$this->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();
});

View File

@@ -1,5 +1,5 @@
<?php
pest()->extend(Tests\TestCase::class)
->use(Illuminate\Foundation\Testing\RefreshDatabase::class)
->use(Illuminate\Foundation\Testing\DatabaseTransactions::class)
->in('Feature');