Implement Phase 2: Billing & Subscriptions
Add complete billing system with Stripe and PayPal gateway support, checkout flow with coupon validation, subscription management (cancel/resume/swap), payment method management, invoice and transaction history, webhook handlers, dunning/suspension system with scheduled processing, and 29 new tests (53 total passing). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
75
website/tests/Feature/Billing/BillingPageTest.php
Normal file
75
website/tests/Feature/Billing/BillingPageTest.php
Normal file
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Invoice;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\RoleAndPermissionSeeder;
|
||||
|
||||
beforeEach(function (): void {
|
||||
$this->seed(RoleAndPermissionSeeder::class);
|
||||
$this->accountUrl = 'http://'.config('app.domains.account');
|
||||
});
|
||||
|
||||
it('displays the billing index page', function (): void {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->get($this->accountUrl.'/billing')
|
||||
->assertOk()
|
||||
->assertInertia(fn ($page) => $page
|
||||
->component('Billing/Index')
|
||||
->has('paymentMethods')
|
||||
->has('invoices')
|
||||
->has('transactions')
|
||||
);
|
||||
});
|
||||
|
||||
it('displays the invoices page', function (): void {
|
||||
$user = User::factory()->create();
|
||||
Invoice::factory()->count(3)->create(['user_id' => $user->id]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get($this->accountUrl.'/billing/invoices')
|
||||
->assertOk()
|
||||
->assertInertia(fn ($page) => $page
|
||||
->component('Billing/Invoices')
|
||||
->has('invoices.data', 3)
|
||||
);
|
||||
});
|
||||
|
||||
it('displays the transactions page', function (): void {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->get($this->accountUrl.'/billing/transactions')
|
||||
->assertOk()
|
||||
->assertInertia(fn ($page) => $page
|
||||
->component('Billing/Transactions')
|
||||
->has('transactions')
|
||||
);
|
||||
});
|
||||
|
||||
it('prevents downloading another users invoice', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$otherUser = User::factory()->create();
|
||||
$invoice = Invoice::factory()->create(['user_id' => $otherUser->id]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get($this->accountUrl.'/billing/invoices/'.$invoice->id.'/download')
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
it('allows downloading own invoice', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$invoice = Invoice::factory()->create(['user_id' => $user->id]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get($this->accountUrl.'/billing/invoices/'.$invoice->id.'/download')
|
||||
->assertOk();
|
||||
});
|
||||
|
||||
it('requires authentication to view billing', function (): void {
|
||||
$this->get($this->accountUrl.'/billing')
|
||||
->assertRedirect();
|
||||
});
|
||||
117
website/tests/Feature/Billing/CheckoutTest.php
Normal file
117
website/tests/Feature/Billing/CheckoutTest.php
Normal file
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Coupon;
|
||||
use App\Models\Plan;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\RoleAndPermissionSeeder;
|
||||
|
||||
beforeEach(function (): void {
|
||||
$this->seed(RoleAndPermissionSeeder::class);
|
||||
$this->accountUrl = 'http://'.config('app.domains.account');
|
||||
});
|
||||
|
||||
it('displays the checkout page for an active plan', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$plan = Plan::factory()->create(['status' => 'active']);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get($this->accountUrl.'/checkout/'.$plan->id)
|
||||
->assertOk()
|
||||
->assertInertia(fn ($page) => $page
|
||||
->component('Checkout/Show')
|
||||
->has('plan')
|
||||
->where('plan.id', $plan->id)
|
||||
);
|
||||
});
|
||||
|
||||
it('returns 404 for unavailable plan checkout', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$plan = Plan::factory()->create(['status' => 'inactive']);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get($this->accountUrl.'/checkout/'.$plan->id)
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
it('returns 404 for out of stock plan checkout', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$plan = Plan::factory()->create(['status' => 'active', 'stock_quantity' => 0]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get($this->accountUrl.'/checkout/'.$plan->id)
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
it('validates a valid coupon code', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$plan = Plan::factory()->create(['status' => 'active', 'price' => 100.00]);
|
||||
$coupon = Coupon::factory()->create([
|
||||
'code' => 'SAVE20',
|
||||
'type' => 'percentage',
|
||||
'value' => 20.00,
|
||||
'max_uses' => 100,
|
||||
'times_used' => 0,
|
||||
'expires_at' => now()->addMonth(),
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->postJson($this->accountUrl.'/checkout/apply-coupon', [
|
||||
'code' => 'SAVE20',
|
||||
'plan_id' => $plan->id,
|
||||
])
|
||||
->assertOk()
|
||||
->assertJson([
|
||||
'valid' => true,
|
||||
'discount' => 20.00,
|
||||
'new_total' => 80.00,
|
||||
'coupon_type' => 'percentage',
|
||||
'coupon_value' => '20.00',
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects an expired coupon', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$plan = Plan::factory()->create(['status' => 'active']);
|
||||
Coupon::factory()->create([
|
||||
'code' => 'EXPIRED',
|
||||
'expires_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->postJson($this->accountUrl.'/checkout/apply-coupon', [
|
||||
'code' => 'EXPIRED',
|
||||
'plan_id' => $plan->id,
|
||||
])
|
||||
->assertUnprocessable()
|
||||
->assertJson(['valid' => false]);
|
||||
});
|
||||
|
||||
it('rejects a maxed-out coupon', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$plan = Plan::factory()->create(['status' => 'active']);
|
||||
Coupon::factory()->create([
|
||||
'code' => 'MAXED',
|
||||
'max_uses' => 5,
|
||||
'times_used' => 5,
|
||||
'expires_at' => now()->addMonth(),
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->postJson($this->accountUrl.'/checkout/apply-coupon', [
|
||||
'code' => 'MAXED',
|
||||
'plan_id' => $plan->id,
|
||||
])
|
||||
->assertUnprocessable()
|
||||
->assertJson(['valid' => false]);
|
||||
});
|
||||
|
||||
it('validates checkout form requires gateway', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$plan = Plan::factory()->create(['status' => 'active']);
|
||||
|
||||
$this->actingAs($user)
|
||||
->post($this->accountUrl.'/checkout/'.$plan->id, [])
|
||||
->assertSessionHasErrors('gateway');
|
||||
});
|
||||
52
website/tests/Feature/Billing/CouponTest.php
Normal file
52
website/tests/Feature/Billing/CouponTest.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Coupon;
|
||||
|
||||
it('validates a valid coupon', function (): void {
|
||||
$coupon = Coupon::factory()->create([
|
||||
'max_uses' => 100,
|
||||
'times_used' => 50,
|
||||
'expires_at' => now()->addMonth(),
|
||||
]);
|
||||
|
||||
expect($coupon->isValid())->toBeTrue();
|
||||
});
|
||||
|
||||
it('rejects an expired coupon', function (): void {
|
||||
$coupon = Coupon::factory()->create([
|
||||
'expires_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
expect($coupon->isValid())->toBeFalse();
|
||||
});
|
||||
|
||||
it('rejects a maxed out coupon', function (): void {
|
||||
$coupon = Coupon::factory()->create([
|
||||
'max_uses' => 5,
|
||||
'times_used' => 5,
|
||||
]);
|
||||
|
||||
expect($coupon->isValid())->toBeFalse();
|
||||
});
|
||||
|
||||
it('accepts a coupon with no usage limit', function (): void {
|
||||
$coupon = Coupon::factory()->create([
|
||||
'max_uses' => null,
|
||||
'times_used' => 999,
|
||||
'expires_at' => now()->addMonth(),
|
||||
]);
|
||||
|
||||
expect($coupon->isValid())->toBeTrue();
|
||||
});
|
||||
|
||||
it('accepts a coupon with no expiry', function (): void {
|
||||
$coupon = Coupon::factory()->create([
|
||||
'max_uses' => 100,
|
||||
'times_used' => 0,
|
||||
'expires_at' => null,
|
||||
]);
|
||||
|
||||
expect($coupon->isValid())->toBeTrue();
|
||||
});
|
||||
130
website/tests/Feature/Billing/DunningTest.php
Normal file
130
website/tests/Feature/Billing/DunningTest.php
Normal file
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Plan;
|
||||
use App\Models\Service;
|
||||
use App\Models\User;
|
||||
use App\Services\Billing\DunningService;
|
||||
use Database\Seeders\RoleAndPermissionSeeder;
|
||||
use Laravel\Cashier\Subscription;
|
||||
|
||||
beforeEach(function (): void {
|
||||
$this->seed(RoleAndPermissionSeeder::class);
|
||||
});
|
||||
|
||||
it('suspends services for overdue subscriptions past the grace period', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$plan = Plan::factory()->create();
|
||||
|
||||
$subscription = Subscription::create([
|
||||
'user_id' => $user->id,
|
||||
'type' => 'default',
|
||||
'stripe_id' => 'sub_test_123',
|
||||
'stripe_status' => 'past_due',
|
||||
'stripe_price' => 'price_test_123',
|
||||
'plan_id' => $plan->id,
|
||||
]);
|
||||
|
||||
// Manually set updated_at to past the grace period
|
||||
$graceDays = config('billing.suspension.days_past_due_to_suspend');
|
||||
$subscription->update(['updated_at' => now()->subDays($graceDays + 1)]);
|
||||
|
||||
$service = Service::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'subscription_id' => $subscription->id,
|
||||
'plan_id' => $plan->id,
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
$dunningService = app(DunningService::class);
|
||||
$count = $dunningService->suspendOverdueSubscriptions();
|
||||
|
||||
expect($count)->toBe(1);
|
||||
expect($service->fresh()->status)->toBe('suspended');
|
||||
});
|
||||
|
||||
it('does not suspend services within the grace period', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$plan = Plan::factory()->create();
|
||||
|
||||
$subscription = Subscription::create([
|
||||
'user_id' => $user->id,
|
||||
'type' => 'default',
|
||||
'stripe_id' => 'sub_test_456',
|
||||
'stripe_status' => 'past_due',
|
||||
'stripe_price' => 'price_test_456',
|
||||
'plan_id' => $plan->id,
|
||||
]);
|
||||
|
||||
$service = Service::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'subscription_id' => $subscription->id,
|
||||
'plan_id' => $plan->id,
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
$dunningService = app(DunningService::class);
|
||||
$count = $dunningService->suspendOverdueSubscriptions();
|
||||
|
||||
expect($count)->toBe(0);
|
||||
expect($service->fresh()->status)->toBe('active');
|
||||
});
|
||||
|
||||
it('terminates services that have been suspended too long', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$terminateDays = config('billing.suspension.days_suspended_to_terminate');
|
||||
|
||||
$service = Service::factory()->suspended()->create([
|
||||
'user_id' => $user->id,
|
||||
'suspended_at' => now()->subDays($terminateDays + 1),
|
||||
]);
|
||||
|
||||
$dunningService = app(DunningService::class);
|
||||
$count = $dunningService->terminateLongSuspendedSubscriptions();
|
||||
|
||||
expect($count)->toBe(1);
|
||||
expect($service->fresh()->status)->toBe('terminated');
|
||||
expect($service->fresh()->auto_renew)->toBeFalse();
|
||||
});
|
||||
|
||||
it('does not terminate recently suspended services', function (): void {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$service = Service::factory()->suspended()->create([
|
||||
'user_id' => $user->id,
|
||||
'suspended_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
$dunningService = app(DunningService::class);
|
||||
$count = $dunningService->terminateLongSuspendedSubscriptions();
|
||||
|
||||
expect($count)->toBe(0);
|
||||
expect($service->fresh()->status)->toBe('suspended');
|
||||
});
|
||||
|
||||
it('reactivates suspended services when subscription becomes active', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$plan = Plan::factory()->create();
|
||||
|
||||
$subscription = Subscription::create([
|
||||
'user_id' => $user->id,
|
||||
'type' => 'default',
|
||||
'stripe_id' => 'sub_test_789',
|
||||
'stripe_status' => 'active',
|
||||
'stripe_price' => 'price_test_789',
|
||||
'plan_id' => $plan->id,
|
||||
]);
|
||||
|
||||
$service = Service::factory()->suspended()->create([
|
||||
'user_id' => $user->id,
|
||||
'subscription_id' => $subscription->id,
|
||||
'plan_id' => $plan->id,
|
||||
]);
|
||||
|
||||
$dunningService = app(DunningService::class);
|
||||
$dunningService->reactivateServicesForSubscription($subscription);
|
||||
|
||||
expect($service->fresh()->status)->toBe('active');
|
||||
expect($service->fresh()->suspended_at)->toBeNull();
|
||||
});
|
||||
62
website/tests/Feature/Billing/PlanBrowsingTest.php
Normal file
62
website/tests/Feature/Billing/PlanBrowsingTest.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Plan;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\RoleAndPermissionSeeder;
|
||||
|
||||
beforeEach(function (): void {
|
||||
$this->seed(RoleAndPermissionSeeder::class);
|
||||
$this->accountUrl = 'http://'.config('app.domains.account');
|
||||
});
|
||||
|
||||
it('displays the plans index page', function (): void {
|
||||
$user = User::factory()->create();
|
||||
Plan::factory()->count(3)->create(['status' => 'active']);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get($this->accountUrl.'/plans')
|
||||
->assertOk()
|
||||
->assertInertia(fn ($page) => $page
|
||||
->component('Plans/Index')
|
||||
->has('plansByType')
|
||||
);
|
||||
});
|
||||
|
||||
it('does not show inactive plans', function (): void {
|
||||
$user = User::factory()->create();
|
||||
Plan::factory()->create(['status' => 'active', 'service_type' => 'vps']);
|
||||
Plan::factory()->create(['status' => 'inactive', 'service_type' => 'vps']);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get($this->accountUrl.'/plans')
|
||||
->assertOk()
|
||||
->assertInertia(fn ($page) => $page
|
||||
->component('Plans/Index')
|
||||
->where('plansByType.vps', fn ($plans) => count($plans) === 1)
|
||||
);
|
||||
});
|
||||
|
||||
it('displays a single plan page', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$plan = Plan::factory()->create(['status' => 'active']);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get($this->accountUrl.'/plans/'.$plan->id)
|
||||
->assertOk()
|
||||
->assertInertia(fn ($page) => $page
|
||||
->component('Plans/Show')
|
||||
->has('plan')
|
||||
->where('plan.id', $plan->id)
|
||||
);
|
||||
});
|
||||
|
||||
it('returns 404 for inactive plan', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$plan = Plan::factory()->create(['status' => 'inactive']);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get($this->accountUrl.'/plans/'.$plan->id)
|
||||
->assertNotFound();
|
||||
});
|
||||
28
website/tests/Feature/Billing/SubscriptionManagementTest.php
Normal file
28
website/tests/Feature/Billing/SubscriptionManagementTest.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\User;
|
||||
use Database\Seeders\RoleAndPermissionSeeder;
|
||||
|
||||
beforeEach(function (): void {
|
||||
$this->seed(RoleAndPermissionSeeder::class);
|
||||
$this->accountUrl = 'http://'.config('app.domains.account');
|
||||
});
|
||||
|
||||
it('displays the subscriptions index page', function (): void {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->get($this->accountUrl.'/subscriptions')
|
||||
->assertOk()
|
||||
->assertInertia(fn ($page) => $page
|
||||
->component('Subscriptions/Index')
|
||||
->has('subscriptions')
|
||||
);
|
||||
});
|
||||
|
||||
it('requires authentication to view subscriptions', function (): void {
|
||||
$this->get($this->accountUrl.'/subscriptions')
|
||||
->assertRedirect();
|
||||
});
|
||||
Reference in New Issue
Block a user