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:
Claude Dev
2026-02-09 07:18:48 -05:00
parent 5988c6d064
commit b1e080d70c
40 changed files with 3018 additions and 1 deletions

View 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();
});

View 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');
});

View 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();
});

View 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();
});

View 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();
});

View 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();
});