php-commerce/tests/Feature/DunningServiceTest.php

562 lines
20 KiB
PHP
Raw Normal View History

2026-01-27 00:24:22 +00:00
<?php
use Carbon\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Notification;
use Core\Commerce\Models\Invoice;
use Core\Commerce\Models\Subscription;
use Core\Commerce\Notifications\AccountSuspended;
use Core\Commerce\Notifications\PaymentFailed;
use Core\Commerce\Notifications\SubscriptionCancelled;
use Core\Commerce\Notifications\SubscriptionPaused;
use Core\Commerce\Services\DunningService;
use Core\Mod\Tenant\Models\Package;
use Core\Mod\Tenant\Models\User;
use Core\Mod\Tenant\Models\Workspace;
use Core\Mod\Tenant\Models\WorkspacePackage;
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
beforeEach(function () {
Cache::flush();
Notification::fake();
$this->user = User::factory()->create();
$this->workspace = Workspace::factory()->create();
$this->workspace->users()->attach($this->user->id, [
'role' => 'owner',
'is_default' => true,
]);
// Use existing seeded package
$this->package = Package::where('code', 'creator')->first();
$this->workspacePackage = WorkspacePackage::create([
'workspace_id' => $this->workspace->id,
'package_id' => $this->package->id,
'status' => 'active',
]);
$this->subscription = Subscription::create([
'workspace_id' => $this->workspace->id,
'workspace_package_id' => $this->workspacePackage->id,
'status' => 'active',
'gateway' => 'btcpay',
'billing_cycle' => 'monthly',
'current_period_start' => now(),
'current_period_end' => now()->addDays(30),
]);
$this->service = app(DunningService::class);
});
describe('DunningService', function () {
describe('handlePaymentFailure()', function () {
it('marks invoice as overdue and schedules retry', function () {
$invoice = Invoice::create([
'workspace_id' => $this->workspace->id,
'invoice_number' => 'INV-2025-0001',
'status' => 'sent',
'auto_charge' => true,
'subtotal' => 19.00,
'total' => 19.00,
'amount_due' => 19.00,
'currency' => 'GBP',
'issue_date' => now(),
'due_date' => now()->addDays(14),
]);
$this->service->handlePaymentFailure($invoice, $this->subscription);
$invoice->refresh();
expect($invoice->status)->toBe('overdue')
->and($invoice->charge_attempts)->toBe(1)
->and($invoice->next_charge_attempt)->not->toBeNull();
});
it('marks subscription as past due', function () {
$invoice = Invoice::create([
'workspace_id' => $this->workspace->id,
'invoice_number' => 'INV-2025-0002',
'status' => 'sent',
'auto_charge' => true,
'subtotal' => 19.00,
'total' => 19.00,
'amount_due' => 19.00,
'currency' => 'GBP',
'issue_date' => now(),
'due_date' => now()->addDays(14),
]);
$this->service->handlePaymentFailure($invoice, $this->subscription);
$this->subscription->refresh();
expect($this->subscription->status)->toBe('past_due');
});
it('sends payment failed notification', function () {
$invoice = Invoice::create([
'workspace_id' => $this->workspace->id,
'invoice_number' => 'INV-2025-0003',
'status' => 'sent',
'auto_charge' => true,
'subtotal' => 19.00,
'total' => 19.00,
'amount_due' => 19.00,
'currency' => 'GBP',
'issue_date' => now(),
'due_date' => now()->addDays(14),
]);
$this->service->handlePaymentFailure($invoice, $this->subscription);
Notification::assertSentTo($this->user, PaymentFailed::class);
});
});
describe('handlePaymentRecovery()', function () {
it('clears dunning state from invoice', function () {
$invoice = Invoice::create([
'workspace_id' => $this->workspace->id,
'invoice_number' => 'INV-2025-0004',
'status' => 'overdue',
'auto_charge' => true,
'subtotal' => 19.00,
'total' => 19.00,
'amount_due' => 19.00,
'currency' => 'GBP',
'issue_date' => now(),
'due_date' => now()->subDays(7),
'charge_attempts' => 2,
'next_charge_attempt' => now()->addDays(3),
]);
$this->service->handlePaymentRecovery($invoice, $this->subscription);
$invoice->refresh();
expect($invoice->next_charge_attempt)->toBeNull();
});
it('unpauses subscription if paused', function () {
$this->subscription->update([
'status' => 'paused',
'paused_at' => now()->subDays(5),
]);
$invoice = Invoice::create([
'workspace_id' => $this->workspace->id,
'invoice_number' => 'INV-2025-0005',
'status' => 'overdue',
'auto_charge' => true,
'subtotal' => 19.00,
'total' => 19.00,
'amount_due' => 19.00,
'currency' => 'GBP',
'issue_date' => now(),
'due_date' => now()->subDays(7),
]);
$this->service->handlePaymentRecovery($invoice, $this->subscription);
$this->subscription->refresh();
expect($this->subscription->status)->toBe('active')
->and($this->subscription->paused_at)->toBeNull();
});
it('reactivates past due subscription', function () {
$this->subscription->update(['status' => 'past_due']);
$invoice = Invoice::create([
'workspace_id' => $this->workspace->id,
'invoice_number' => 'INV-2025-0006',
'status' => 'overdue',
'auto_charge' => true,
'subtotal' => 19.00,
'total' => 19.00,
'amount_due' => 19.00,
'currency' => 'GBP',
'issue_date' => now(),
'due_date' => now()->subDays(7),
]);
$this->service->handlePaymentRecovery($invoice, $this->subscription);
$this->subscription->refresh();
expect($this->subscription->status)->toBe('active');
});
});
describe('calculateNextRetry()', function () {
it('schedules first retry after 1 day', function () {
Carbon::setTestNow('2025-01-15 12:00:00');
$nextRetry = $this->service->calculateNextRetry(0);
expect($nextRetry->toDateString())->toBe('2025-01-16');
Carbon::setTestNow();
});
it('schedules second retry after 3 days', function () {
Carbon::setTestNow('2025-01-15 12:00:00');
$nextRetry = $this->service->calculateNextRetry(1);
expect($nextRetry->toDateString())->toBe('2025-01-18');
Carbon::setTestNow();
});
it('schedules third retry after 7 days', function () {
Carbon::setTestNow('2025-01-15 12:00:00');
$nextRetry = $this->service->calculateNextRetry(2);
expect($nextRetry->toDateString())->toBe('2025-01-22');
Carbon::setTestNow();
});
it('returns null after max retries', function () {
$nextRetry = $this->service->calculateNextRetry(3);
expect($nextRetry)->toBeNull();
});
});
describe('getInvoicesDueForRetry()', function () {
it('returns invoices with scheduled retry in the past', function () {
Invoice::create([
'workspace_id' => $this->workspace->id,
'invoice_number' => 'INV-2025-0007',
'status' => 'overdue',
'auto_charge' => true,
'subtotal' => 19.00,
'total' => 19.00,
'amount_due' => 19.00,
'currency' => 'GBP',
'issue_date' => now(),
'due_date' => now()->subDays(7),
'next_charge_attempt' => now()->subHour(),
]);
$invoices = $this->service->getInvoicesDueForRetry();
expect($invoices)->toHaveCount(1);
});
it('does not return invoices with future retry date', function () {
Invoice::create([
'workspace_id' => $this->workspace->id,
'invoice_number' => 'INV-2025-0008',
'status' => 'overdue',
'auto_charge' => true,
'subtotal' => 19.00,
'total' => 19.00,
'amount_due' => 19.00,
'currency' => 'GBP',
'issue_date' => now(),
'due_date' => now()->subDays(7),
'next_charge_attempt' => now()->addHour(),
]);
$invoices = $this->service->getInvoicesDueForRetry();
expect($invoices)->toHaveCount(0);
});
it('does not return paid invoices', function () {
Invoice::create([
'workspace_id' => $this->workspace->id,
'invoice_number' => 'INV-2025-0009',
'status' => 'paid',
'auto_charge' => true,
'subtotal' => 19.00,
'total' => 19.00,
'amount_due' => 0,
'amount_paid' => 19.00,
'currency' => 'GBP',
'issue_date' => now(),
'due_date' => now()->subDays(7),
'next_charge_attempt' => now()->subHour(),
]);
$invoices = $this->service->getInvoicesDueForRetry();
expect($invoices)->toHaveCount(0);
});
});
describe('getSubscriptionsForPause()', function () {
it('returns past due subscriptions after retry period exhausted', function () {
// Subscription is past due
$this->subscription->update(['status' => 'past_due']);
// Invoice has exhausted retries (last attempt > sum of retry days)
Invoice::create([
'workspace_id' => $this->workspace->id,
'invoice_number' => 'INV-2025-0010',
'status' => 'overdue',
'auto_charge' => true,
'subtotal' => 19.00,
'total' => 19.00,
'amount_due' => 19.00,
'currency' => 'GBP',
'issue_date' => now()->subDays(20),
'due_date' => now()->subDays(20),
'charge_attempts' => 3,
'last_charge_attempt' => now()->subDays(15), // More than 11 days (1+3+7) + 1
'next_charge_attempt' => null, // No more retries
]);
$subscriptions = $this->service->getSubscriptionsForPause();
expect($subscriptions)->toHaveCount(1)
->and($subscriptions->first()->id)->toBe($this->subscription->id);
});
it('does not return already paused subscriptions', function () {
$this->subscription->update([
'status' => 'paused',
'paused_at' => now()->subDays(5),
]);
Invoice::create([
'workspace_id' => $this->workspace->id,
'invoice_number' => 'INV-2025-0011',
'status' => 'overdue',
'auto_charge' => true,
'subtotal' => 19.00,
'total' => 19.00,
'amount_due' => 19.00,
'currency' => 'GBP',
'issue_date' => now()->subDays(20),
'due_date' => now()->subDays(20),
'last_charge_attempt' => now()->subDays(15),
'next_charge_attempt' => null,
]);
$subscriptions = $this->service->getSubscriptionsForPause();
expect($subscriptions)->toHaveCount(0);
});
});
describe('pauseSubscription()', function () {
it('pauses subscription and sends notification', function () {
$this->service->pauseSubscription($this->subscription);
$this->subscription->refresh();
expect($this->subscription->status)->toBe('paused')
->and($this->subscription->paused_at)->not->toBeNull();
Notification::assertSentTo($this->user, SubscriptionPaused::class);
});
});
describe('getSubscriptionsForSuspension()', function () {
it('returns paused subscriptions after suspend threshold', function () {
$this->subscription->update([
'status' => 'paused',
'paused_at' => now()->subDays(15), // More than 14 days
]);
$subscriptions = $this->service->getSubscriptionsForSuspension();
expect($subscriptions)->toHaveCount(1);
});
it('does not return recently paused subscriptions', function () {
$this->subscription->update([
'status' => 'paused',
'paused_at' => now()->subDays(5), // Less than 14 days
]);
$subscriptions = $this->service->getSubscriptionsForSuspension();
expect($subscriptions)->toHaveCount(0);
});
});
describe('suspendWorkspace()', function () {
it('suspends workspace and sends notification', function () {
$this->subscription->update([
'status' => 'paused',
'paused_at' => now()->subDays(15),
]);
$this->service->suspendWorkspace($this->subscription);
// Verify entitlement service was called (workspace package should be suspended)
$this->workspacePackage->refresh();
expect($this->workspacePackage->status)->toBe('suspended');
Notification::assertSentTo($this->user, AccountSuspended::class);
});
});
describe('getSubscriptionsForCancellation()', function () {
it('returns paused subscriptions after cancel threshold', function () {
$this->subscription->update([
'status' => 'paused',
'paused_at' => now()->subDays(31), // More than 30 days
]);
$subscriptions = $this->service->getSubscriptionsForCancellation();
expect($subscriptions)->toHaveCount(1);
});
it('does not return recently paused subscriptions', function () {
$this->subscription->update([
'status' => 'paused',
'paused_at' => now()->subDays(20), // Less than 30 days
]);
$subscriptions = $this->service->getSubscriptionsForCancellation();
expect($subscriptions)->toHaveCount(0);
});
});
describe('cancelSubscription()', function () {
it('cancels and expires subscription', function () {
$this->subscription->update([
'status' => 'paused',
'paused_at' => now()->subDays(31),
]);
$this->service->cancelSubscription($this->subscription);
$this->subscription->refresh();
expect($this->subscription->status)->toBe('expired')
->and($this->subscription->cancelled_at)->not->toBeNull()
->and($this->subscription->cancellation_reason)->toBe('Non-payment')
->and($this->subscription->ended_at)->not->toBeNull();
});
it('sends cancellation notification', function () {
$this->subscription->update([
'status' => 'paused',
'paused_at' => now()->subDays(31),
]);
$this->service->cancelSubscription($this->subscription);
Notification::assertSentTo($this->user, SubscriptionCancelled::class);
});
});
describe('calculateInitialRetry()', function () {
it('respects initial_grace_hours config', function () {
Carbon::setTestNow('2025-01-15 12:00:00');
config(['commerce.dunning.initial_grace_hours' => 48]);
$nextRetry = $this->service->calculateInitialRetry();
// 48 hours = 2 days from now
expect($nextRetry->toDateString())->toBe('2025-01-17');
Carbon::setTestNow();
config(['commerce.dunning.initial_grace_hours' => 24]); // Reset
});
it('uses retry_days if longer than grace period', function () {
Carbon::setTestNow('2025-01-15 12:00:00');
config(['commerce.dunning.initial_grace_hours' => 12]); // 0.5 days
config(['commerce.dunning.retry_days' => [2, 4, 7]]); // First retry at 2 days
$nextRetry = $this->service->calculateInitialRetry();
// Should use 2 days (retry_days[0]) since it's longer than 12 hours
expect($nextRetry->toDateString())->toBe('2025-01-17');
Carbon::setTestNow();
config(['commerce.dunning.initial_grace_hours' => 24]); // Reset
config(['commerce.dunning.retry_days' => [1, 3, 7]]); // Reset
});
});
describe('getDunningStatus()', function () {
it('returns none status when no overdue invoices', function () {
$status = $this->service->getDunningStatus($this->subscription);
expect($status['stage'])->toBe('none')
->and($status['days_overdue'])->toBe(0)
->and($status['next_action'])->toBe('none');
});
it('returns retry status for active subscription with overdue invoice', function () {
Invoice::create([
'workspace_id' => $this->workspace->id,
'invoice_number' => 'INV-2025-0012',
'status' => 'overdue',
'auto_charge' => true,
'subtotal' => 19.00,
'total' => 19.00,
'amount_due' => 19.00,
'currency' => 'GBP',
'issue_date' => now()->subDays(10),
'due_date' => now()->subDays(5),
'next_charge_attempt' => now()->addDays(2),
]);
$status = $this->service->getDunningStatus($this->subscription);
expect($status['stage'])->toBe('retry')
->and($status['days_overdue'])->toBe(5)
->and($status['next_action'])->toBe('retry');
});
it('returns paused status for paused subscription', function () {
$this->subscription->update([
'status' => 'paused',
'paused_at' => now()->subDays(5),
]);
Invoice::create([
'workspace_id' => $this->workspace->id,
'invoice_number' => 'INV-2025-0013',
'status' => 'overdue',
'auto_charge' => true,
'subtotal' => 19.00,
'total' => 19.00,
'amount_due' => 19.00,
'currency' => 'GBP',
'issue_date' => now()->subDays(15),
'due_date' => now()->subDays(10),
]);
$status = $this->service->getDunningStatus($this->subscription);
expect($status['stage'])->toBe('paused')
->and($status['next_action'])->toBe('suspend');
});
it('returns suspended status for long-paused subscription', function () {
$this->subscription->update([
'status' => 'paused',
'paused_at' => now()->subDays(20), // More than 14 days
]);
Invoice::create([
'workspace_id' => $this->workspace->id,
'invoice_number' => 'INV-2025-0014',
'status' => 'overdue',
'auto_charge' => true,
'subtotal' => 19.00,
'total' => 19.00,
'amount_due' => 19.00,
'currency' => 'GBP',
'issue_date' => now()->subDays(25),
'due_date' => now()->subDays(20),
]);
$status = $this->service->getDunningStatus($this->subscription);
expect($status['stage'])->toBe('suspended')
->and($status['next_action'])->toBe('cancel');
});
});
});