php-commerce/tests/Feature/DunningServiceTest.php
Snider a774f4e285 refactor: migrate namespace from Core\Commerce to Core\Mod\Commerce
Align commerce module with the monorepo module structure by updating
all namespaces to use the Core\Mod\Commerce convention. This change
supports the recent monorepo separation and ensures consistency with
other modules.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 16:23:12 +00:00

561 lines
20 KiB
PHP

<?php
use Carbon\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Notification;
use Core\Mod\Commerce\Models\Invoice;
use Core\Mod\Commerce\Models\Subscription;
use Core\Mod\Commerce\Notifications\AccountSuspended;
use Core\Mod\Commerce\Notifications\PaymentFailed;
use Core\Mod\Commerce\Notifications\SubscriptionCancelled;
use Core\Mod\Commerce\Notifications\SubscriptionPaused;
use Core\Mod\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');
});
});
});