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>
392 lines
14 KiB
PHP
392 lines
14 KiB
PHP
<?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')
|
|
);
|
|
});
|
|
});
|