feat(commerce): implement DunningService with 5 methods + DunningSchedule DTO (#860)
- schedule(subscription) → DunningSchedule (retry dates + suspension date) - retry(invoice) → PaymentResult - suspend(subscription) → void - notify(subscription, stage) → void (event-driven per dunning stage) - recover(subscription) → void (clears dunning after payment) Data/DunningSchedule.php + Data/PaymentResult.php as readonly DTOs. Pest tests _Good/_Bad/_Ugly per AX-10 for all 5 methods. pint/pest skipped (vendor binaries missing in sandbox). Co-authored-by: Codex <noreply@openai.com> Closes tasks.lthn.sh/view.php?id=860
This commit is contained in:
parent
20fb740d61
commit
51f9595797
4 changed files with 874 additions and 1 deletions
35
Data/DunningSchedule.php
Normal file
35
Data/DunningSchedule.php
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Commerce\Data;
|
||||
|
||||
use Carbon\Carbon;
|
||||
|
||||
/**
|
||||
* Failed-payment retry and suspension dates for a subscription.
|
||||
*/
|
||||
readonly class DunningSchedule
|
||||
{
|
||||
/**
|
||||
* @param array<int, Carbon> $retryDates
|
||||
*/
|
||||
public function __construct(
|
||||
public array $retryDates,
|
||||
public Carbon $suspensionDate,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array{retry_dates: array<int, string>, suspension_date: string}
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'retry_dates' => array_map(
|
||||
fn (Carbon $date): string => $date->toISOString(),
|
||||
$this->retryDates
|
||||
),
|
||||
'suspension_date' => $this->suspensionDate->toISOString(),
|
||||
];
|
||||
}
|
||||
}
|
||||
51
Data/PaymentResult.php
Normal file
51
Data/PaymentResult.php
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Commerce\Data;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Core\Mod\Commerce\Models\Payment;
|
||||
|
||||
/**
|
||||
* Result from an attempted automatic invoice payment retry.
|
||||
*/
|
||||
readonly class PaymentResult
|
||||
{
|
||||
public function __construct(
|
||||
public bool $successful,
|
||||
public ?Payment $payment = null,
|
||||
public ?string $reason = null,
|
||||
public int $attempts = 0,
|
||||
public ?Carbon $nextRetryAt = null,
|
||||
) {}
|
||||
|
||||
public static function successful(?Payment $payment = null, int $attempts = 0): self
|
||||
{
|
||||
return new self(
|
||||
successful: true,
|
||||
payment: $payment,
|
||||
attempts: $attempts,
|
||||
);
|
||||
}
|
||||
|
||||
public static function failed(string $reason, int $attempts = 0, ?Carbon $nextRetryAt = null): self
|
||||
{
|
||||
return new self(
|
||||
successful: false,
|
||||
reason: $reason,
|
||||
attempts: $attempts,
|
||||
nextRetryAt: $nextRetryAt,
|
||||
);
|
||||
}
|
||||
|
||||
public function succeeded(): bool
|
||||
{
|
||||
return $this->successful;
|
||||
}
|
||||
|
||||
public function isFailed(): bool
|
||||
{
|
||||
return ! $this->successful;
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,8 @@ declare(strict_types=1);
|
|||
namespace Core\Mod\Commerce\Services;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Core\Mod\Commerce\Data\DunningSchedule;
|
||||
use Core\Mod\Commerce\Data\PaymentResult;
|
||||
use Core\Mod\Commerce\Models\Invoice;
|
||||
use Core\Mod\Commerce\Models\Subscription;
|
||||
use Core\Mod\Commerce\Notifications\AccountSuspended;
|
||||
|
|
@ -14,7 +16,9 @@ use Core\Mod\Commerce\Notifications\SubscriptionCancelled;
|
|||
use Core\Mod\Commerce\Notifications\SubscriptionPaused;
|
||||
use Core\Tenant\Services\EntitlementService;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Dunning service for failed payment recovery.
|
||||
|
|
@ -34,6 +38,247 @@ class DunningService
|
|||
protected EntitlementService $entitlements,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Build and persist the failed-payment retry schedule for a subscription.
|
||||
*/
|
||||
public function schedule(Subscription $subscription): DunningSchedule
|
||||
{
|
||||
if (in_array($subscription->status, ['cancelled', 'expired'], true)) {
|
||||
throw new InvalidArgumentException('Cannot schedule dunning for an ended subscription.');
|
||||
}
|
||||
|
||||
$anchor = $this->dunningAnchor($subscription);
|
||||
$retryDays = $this->retryDays();
|
||||
$retryDates = array_map(
|
||||
fn (int $days): Carbon => $anchor->copy()->addDays($days),
|
||||
$retryDays
|
||||
);
|
||||
$suspensionDate = $anchor->copy()->addDays($this->suspendAfterDays());
|
||||
$schedule = new DunningSchedule($retryDates, $suspensionDate);
|
||||
|
||||
$metadata = $subscription->metadata ?? [];
|
||||
$metadata['dunning'] = [
|
||||
'stage' => 'scheduled',
|
||||
'started_at' => $anchor->toISOString(),
|
||||
'retry_dates' => array_map(
|
||||
fn (Carbon $date): string => $date->toISOString(),
|
||||
$retryDates
|
||||
),
|
||||
'suspension_date' => $suspensionDate->toISOString(),
|
||||
];
|
||||
|
||||
$updates = ['metadata' => $metadata];
|
||||
if (in_array($subscription->status, ['active', 'trialing'], true)) {
|
||||
$updates['status'] = 'past_due';
|
||||
}
|
||||
|
||||
$subscription->update($updates);
|
||||
|
||||
Log::info('Dunning schedule created', [
|
||||
'subscription_id' => $subscription->id,
|
||||
'workspace_id' => $subscription->workspace_id,
|
||||
'retry_dates' => $metadata['dunning']['retry_dates'],
|
||||
'suspension_date' => $metadata['dunning']['suspension_date'],
|
||||
]);
|
||||
|
||||
return $schedule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry payment for an overdue invoice.
|
||||
*/
|
||||
public function retry(Invoice $invoice): PaymentResult
|
||||
{
|
||||
if ($invoice->isPaid()) {
|
||||
$subscription = $this->findSubscriptionForInvoice($invoice);
|
||||
if ($subscription) {
|
||||
$this->recover($subscription);
|
||||
}
|
||||
|
||||
return PaymentResult::successful($invoice->payment, $invoice->charge_attempts ?? 0);
|
||||
}
|
||||
|
||||
if (! $invoice->auto_charge) {
|
||||
return PaymentResult::failed(
|
||||
'Invoice is not configured for automatic charging.',
|
||||
$invoice->charge_attempts ?? 0
|
||||
);
|
||||
}
|
||||
|
||||
$attempts = ($invoice->charge_attempts ?? 0) + 1;
|
||||
|
||||
$invoice->update([
|
||||
'status' => 'overdue',
|
||||
'charge_attempts' => $attempts,
|
||||
'last_charge_attempt' => now(),
|
||||
]);
|
||||
|
||||
try {
|
||||
$successful = $this->commerce->retryInvoicePayment($invoice->fresh());
|
||||
} catch (\Throwable $e) {
|
||||
$nextRetry = $this->calculateNextRetry($attempts);
|
||||
|
||||
$invoice->update([
|
||||
'next_charge_attempt' => $nextRetry,
|
||||
]);
|
||||
|
||||
Log::error('Payment retry exception', [
|
||||
'invoice_id' => $invoice->id,
|
||||
'attempt' => $attempts,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return PaymentResult::failed($e->getMessage(), $attempts, $nextRetry);
|
||||
}
|
||||
|
||||
$invoice->refresh();
|
||||
$subscription = $this->findSubscriptionForInvoice($invoice);
|
||||
|
||||
if ($successful) {
|
||||
$invoice->update([
|
||||
'next_charge_attempt' => null,
|
||||
]);
|
||||
|
||||
if ($subscription) {
|
||||
$this->recover($subscription);
|
||||
}
|
||||
|
||||
Log::info('Payment retry succeeded', [
|
||||
'invoice_id' => $invoice->id,
|
||||
'subscription_id' => $subscription?->id,
|
||||
'attempt' => $attempts,
|
||||
]);
|
||||
|
||||
return PaymentResult::successful($invoice->payment, $attempts);
|
||||
}
|
||||
|
||||
$nextRetry = $this->calculateNextRetry($attempts);
|
||||
$invoice->update([
|
||||
'next_charge_attempt' => $nextRetry,
|
||||
]);
|
||||
|
||||
if ($subscription) {
|
||||
$this->notify($subscription, 'retry');
|
||||
}
|
||||
|
||||
Log::info('Payment retry failed', [
|
||||
'invoice_id' => $invoice->id,
|
||||
'subscription_id' => $subscription?->id,
|
||||
'attempt' => $attempts,
|
||||
'next_retry' => $nextRetry,
|
||||
]);
|
||||
|
||||
return PaymentResult::failed('Payment retry failed.', $attempts, $nextRetry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Suspend a subscription and its workspace after dunning is exhausted.
|
||||
*/
|
||||
public function suspend(Subscription $subscription): void
|
||||
{
|
||||
if (in_array($subscription->status, ['cancelled', 'expired'], true)) {
|
||||
throw new InvalidArgumentException('Cannot suspend an ended subscription.');
|
||||
}
|
||||
|
||||
$workspace = $subscription->workspace;
|
||||
if (! $workspace) {
|
||||
throw new InvalidArgumentException('Cannot suspend a subscription without a workspace.');
|
||||
}
|
||||
|
||||
$metadata = $subscription->metadata ?? [];
|
||||
$metadata['dunning'] = array_merge($metadata['dunning'] ?? [], [
|
||||
'stage' => 'suspended',
|
||||
'suspended_at' => now()->toISOString(),
|
||||
]);
|
||||
|
||||
$subscription->update([
|
||||
'status' => 'suspended',
|
||||
'paused_at' => $subscription->paused_at ?? now(),
|
||||
'metadata' => $metadata,
|
||||
]);
|
||||
|
||||
$this->entitlements->suspendWorkspace($workspace, 'dunning');
|
||||
$this->notify($subscription->fresh(), 'suspended');
|
||||
|
||||
Log::info('Subscription suspended due to dunning', [
|
||||
'subscription_id' => $subscription->id,
|
||||
'workspace_id' => $subscription->workspace_id,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the notification for a dunning stage and dispatch a lightweight stage event.
|
||||
*/
|
||||
public function notify(Subscription $subscription, string $stage): void
|
||||
{
|
||||
$stage = $this->normaliseStage($stage);
|
||||
Event::dispatch('commerce.dunning.notified', [$subscription, $stage]);
|
||||
|
||||
if (! config('commerce.dunning.send_notifications', true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$workspace = $subscription->workspace;
|
||||
$owner = $workspace?->owner();
|
||||
|
||||
if (! $owner) {
|
||||
Log::warning('Dunning notification skipped because no workspace owner was found', [
|
||||
'subscription_id' => $subscription->id,
|
||||
'workspace_id' => $subscription->workspace_id,
|
||||
'stage' => $stage,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$notification = match ($stage) {
|
||||
'failed' => new PaymentFailed($subscription),
|
||||
'retry' => $this->retryNotification($subscription),
|
||||
'paused' => new SubscriptionPaused($subscription),
|
||||
'suspended' => new AccountSuspended($subscription),
|
||||
'cancelled' => new SubscriptionCancelled($subscription),
|
||||
};
|
||||
|
||||
$owner->notify($notification);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear dunning state once payment has recovered.
|
||||
*/
|
||||
public function recover(Subscription $subscription): void
|
||||
{
|
||||
$wasRestricted = in_array($subscription->status, ['paused', 'suspended'], true);
|
||||
$metadata = $subscription->metadata ?? [];
|
||||
unset($metadata['dunning']);
|
||||
|
||||
$updates = [
|
||||
'metadata' => $metadata,
|
||||
];
|
||||
|
||||
if (in_array($subscription->status, ['past_due', 'paused', 'suspended'], true)) {
|
||||
$updates['status'] = 'active';
|
||||
$updates['paused_at'] = null;
|
||||
}
|
||||
|
||||
$subscription->update($updates);
|
||||
|
||||
if ($subscription->workspace_id) {
|
||||
Invoice::query()
|
||||
->where('workspace_id', $subscription->workspace_id)
|
||||
->whereNotNull('next_charge_attempt')
|
||||
->update(['next_charge_attempt' => null]);
|
||||
}
|
||||
|
||||
if ($wasRestricted && $subscription->workspace) {
|
||||
$this->entitlements->reactivateWorkspace($subscription->workspace, 'dunning_recovery');
|
||||
}
|
||||
|
||||
Log::info('Dunning state cleared after payment recovery', [
|
||||
'subscription_id' => $subscription->id,
|
||||
'workspace_id' => $subscription->workspace_id,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a failed payment for an invoice.
|
||||
*
|
||||
|
|
@ -422,7 +667,96 @@ class DunningService
|
|||
|
||||
return Subscription::query()
|
||||
->where('workspace_id', $invoice->workspace_id)
|
||||
->whereIn('status', ['active', 'past_due', 'paused'])
|
||||
->whereIn('status', ['active', 'past_due', 'paused', 'suspended'])
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, int>
|
||||
*/
|
||||
protected function retryDays(): array
|
||||
{
|
||||
$retryDays = config('commerce.dunning.retry_days', [1, 3, 7]);
|
||||
|
||||
if (! is_array($retryDays)) {
|
||||
throw new InvalidArgumentException('Dunning retry days must be configured as an array.');
|
||||
}
|
||||
|
||||
return array_map(function (mixed $days): int {
|
||||
if (! is_numeric($days) || (int) $days < 1) {
|
||||
throw new InvalidArgumentException('Dunning retry days must be positive integers.');
|
||||
}
|
||||
|
||||
return (int) $days;
|
||||
}, array_values($retryDays));
|
||||
}
|
||||
|
||||
protected function suspendAfterDays(): int
|
||||
{
|
||||
$days = config('commerce.dunning.suspend_after_days', 14);
|
||||
|
||||
if (! is_numeric($days) || (int) $days < 1) {
|
||||
throw new InvalidArgumentException('Dunning suspension days must be a positive integer.');
|
||||
}
|
||||
|
||||
return (int) $days;
|
||||
}
|
||||
|
||||
protected function dunningAnchor(Subscription $subscription): Carbon
|
||||
{
|
||||
$startedAt = data_get($subscription->metadata, 'dunning.started_at');
|
||||
|
||||
if ($startedAt) {
|
||||
return Carbon::parse($startedAt);
|
||||
}
|
||||
|
||||
$invoice = $this->latestDunningInvoice($subscription);
|
||||
$anchor = $invoice?->last_charge_attempt
|
||||
?? $invoice?->due_date
|
||||
?? now();
|
||||
|
||||
return $anchor instanceof Carbon
|
||||
? $anchor->copy()
|
||||
: Carbon::parse($anchor);
|
||||
}
|
||||
|
||||
protected function latestDunningInvoice(Subscription $subscription): ?Invoice
|
||||
{
|
||||
if (! $subscription->workspace_id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Invoice::query()
|
||||
->where('workspace_id', $subscription->workspace_id)
|
||||
->whereIn('status', ['sent', 'pending', 'overdue'])
|
||||
->orderByRaw('COALESCE(last_charge_attempt, due_date, created_at) DESC')
|
||||
->first();
|
||||
}
|
||||
|
||||
protected function retryNotification(Subscription $subscription): PaymentRetry|PaymentFailed
|
||||
{
|
||||
$invoice = $this->latestDunningInvoice($subscription);
|
||||
|
||||
if (! $invoice) {
|
||||
return new PaymentFailed($subscription);
|
||||
}
|
||||
|
||||
return new PaymentRetry(
|
||||
$invoice,
|
||||
$invoice->charge_attempts ?? 0,
|
||||
count($this->retryDays())
|
||||
);
|
||||
}
|
||||
|
||||
protected function normaliseStage(string $stage): string
|
||||
{
|
||||
return match (strtolower(trim($stage))) {
|
||||
'failed', 'payment_failed', 'payment-failed' => 'failed',
|
||||
'retry', 'payment_retry', 'payment-retry' => 'retry',
|
||||
'pause', 'paused' => 'paused',
|
||||
'suspend', 'suspended', 'suspension' => 'suspended',
|
||||
'cancel', 'cancelled', 'cancellation' => 'cancelled',
|
||||
default => throw new InvalidArgumentException("Unknown dunning stage [{$stage}]."),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
453
tests/Unit/Services/DunningServiceTest.php
Normal file
453
tests/Unit/Services/DunningServiceTest.php
Normal file
|
|
@ -0,0 +1,453 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Core\Mod\Commerce\Data\DunningSchedule;
|
||||
use Core\Mod\Commerce\Data\PaymentResult;
|
||||
use Core\Mod\Commerce\Models\Invoice;
|
||||
use Core\Mod\Commerce\Models\Payment;
|
||||
use Core\Mod\Commerce\Models\Subscription;
|
||||
use Core\Mod\Commerce\Notifications\AccountSuspended;
|
||||
use Core\Mod\Commerce\Services\CommerceService;
|
||||
use Core\Mod\Commerce\Services\DunningService;
|
||||
use Core\Mod\Commerce\Services\SubscriptionService;
|
||||
use Core\Tenant\Models\User;
|
||||
use Core\Tenant\Models\Workspace;
|
||||
use Core\Tenant\Services\EntitlementService;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
beforeEach(function (): void {
|
||||
Schema::dropIfExists('payments');
|
||||
Schema::dropIfExists('invoices');
|
||||
Schema::dropIfExists('subscriptions');
|
||||
Schema::dropIfExists('user_workspace');
|
||||
Schema::dropIfExists('users');
|
||||
Schema::dropIfExists('workspaces');
|
||||
|
||||
Schema::create('workspaces', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->string('slug')->unique();
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('users', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->string('email')->unique();
|
||||
$table->string('password')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('user_workspace', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('user_id');
|
||||
$table->unsignedBigInteger('workspace_id');
|
||||
$table->string('role');
|
||||
$table->boolean('is_default')->default(false);
|
||||
$table->unsignedBigInteger('team_id')->nullable();
|
||||
$table->json('custom_permissions')->nullable();
|
||||
$table->timestamp('joined_at')->nullable();
|
||||
$table->unsignedBigInteger('invited_by')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('subscriptions', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('workspace_id')->nullable();
|
||||
$table->unsignedBigInteger('workspace_package_id')->nullable();
|
||||
$table->string('gateway')->default('stripe');
|
||||
$table->string('gateway_subscription_id')->nullable();
|
||||
$table->string('gateway_customer_id')->nullable();
|
||||
$table->string('gateway_price_id')->nullable();
|
||||
$table->string('status')->default('active');
|
||||
$table->string('billing_cycle')->default('monthly');
|
||||
$table->timestamp('current_period_start')->nullable();
|
||||
$table->timestamp('current_period_end')->nullable();
|
||||
$table->timestamp('trial_ends_at')->nullable();
|
||||
$table->boolean('cancel_at_period_end')->default(false);
|
||||
$table->timestamp('cancelled_at')->nullable();
|
||||
$table->string('cancellation_reason')->nullable();
|
||||
$table->timestamp('ended_at')->nullable();
|
||||
$table->timestamp('paused_at')->nullable();
|
||||
$table->unsignedInteger('pause_count')->default(0);
|
||||
$table->json('metadata')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('invoices', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('workspace_id')->nullable();
|
||||
$table->unsignedBigInteger('order_id')->nullable();
|
||||
$table->unsignedBigInteger('payment_id')->nullable();
|
||||
$table->string('invoice_number')->unique();
|
||||
$table->string('status')->default('sent');
|
||||
$table->string('currency', 3)->default('GBP');
|
||||
$table->decimal('subtotal', 10, 2)->default(0);
|
||||
$table->decimal('tax_amount', 10, 2)->default(0);
|
||||
$table->decimal('discount_amount', 10, 2)->default(0);
|
||||
$table->decimal('total', 10, 2)->default(0);
|
||||
$table->decimal('amount_paid', 10, 2)->default(0);
|
||||
$table->decimal('amount_due', 10, 2)->default(0);
|
||||
$table->date('issue_date')->nullable();
|
||||
$table->date('due_date')->nullable();
|
||||
$table->timestamp('paid_at')->nullable();
|
||||
$table->boolean('auto_charge')->default(true);
|
||||
$table->unsignedInteger('charge_attempts')->default(0);
|
||||
$table->timestamp('last_charge_attempt')->nullable();
|
||||
$table->timestamp('next_charge_attempt')->nullable();
|
||||
$table->json('metadata')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('payments', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('workspace_id')->nullable();
|
||||
$table->unsignedBigInteger('invoice_id')->nullable();
|
||||
$table->string('gateway')->default('stripe');
|
||||
$table->string('currency', 3)->default('GBP');
|
||||
$table->decimal('amount', 10, 2)->default(0);
|
||||
$table->decimal('fee', 10, 2)->default(0);
|
||||
$table->decimal('net_amount', 10, 2)->default(0);
|
||||
$table->string('status')->default('pending');
|
||||
$table->string('failure_reason')->nullable();
|
||||
$table->timestamp('paid_at')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Subscription::unsetEventDispatcher();
|
||||
Invoice::unsetEventDispatcher();
|
||||
Payment::unsetEventDispatcher();
|
||||
|
||||
config([
|
||||
'commerce.dunning.retry_days' => [1, 3, 7],
|
||||
'commerce.dunning.suspend_after_days' => 14,
|
||||
'commerce.dunning.send_notifications' => true,
|
||||
]);
|
||||
|
||||
$this->commerce = Mockery::mock(CommerceService::class);
|
||||
$this->subscriptions = Mockery::mock(SubscriptionService::class);
|
||||
$this->entitlements = Mockery::mock(EntitlementService::class);
|
||||
$this->service = new DunningService(
|
||||
$this->commerce,
|
||||
$this->subscriptions,
|
||||
$this->entitlements,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(function (): void {
|
||||
Carbon::setTestNow();
|
||||
Mockery::close();
|
||||
|
||||
Schema::dropIfExists('payments');
|
||||
Schema::dropIfExists('invoices');
|
||||
Schema::dropIfExists('subscriptions');
|
||||
Schema::dropIfExists('user_workspace');
|
||||
Schema::dropIfExists('users');
|
||||
Schema::dropIfExists('workspaces');
|
||||
});
|
||||
|
||||
function dunningServiceTestWorkspace(bool $withOwner = true): Workspace
|
||||
{
|
||||
$workspaceId = DB::table('workspaces')->insertGetId([
|
||||
'name' => 'Dunning Test Workspace',
|
||||
'slug' => 'dunning-test-'.uniqid(),
|
||||
'is_active' => true,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
if ($withOwner) {
|
||||
$userId = DB::table('users')->insertGetId([
|
||||
'name' => 'Dunning Owner',
|
||||
'email' => 'dunning-'.uniqid().'@example.test',
|
||||
'password' => 'secret',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
DB::table('user_workspace')->insert([
|
||||
'user_id' => $userId,
|
||||
'workspace_id' => $workspaceId,
|
||||
'role' => 'owner',
|
||||
'is_default' => true,
|
||||
'joined_at' => now(),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
return Workspace::query()->findOrFail($workspaceId);
|
||||
}
|
||||
|
||||
function dunningServiceTestSubscription(array $overrides = [], ?Workspace $workspace = null): Subscription
|
||||
{
|
||||
if (! array_key_exists('workspace_id', $overrides)) {
|
||||
$workspace ??= dunningServiceTestWorkspace();
|
||||
$overrides['workspace_id'] = $workspace->id;
|
||||
}
|
||||
|
||||
return Subscription::forceCreate(array_merge([
|
||||
'workspace_package_id' => null,
|
||||
'status' => 'active',
|
||||
'gateway' => 'stripe',
|
||||
'billing_cycle' => 'monthly',
|
||||
'current_period_start' => now(),
|
||||
'current_period_end' => now()->addDays(30),
|
||||
'metadata' => null,
|
||||
], $overrides));
|
||||
}
|
||||
|
||||
function dunningServiceTestInvoice(array $overrides = [], ?Workspace $workspace = null): Invoice
|
||||
{
|
||||
if (! array_key_exists('workspace_id', $overrides)) {
|
||||
$workspace ??= dunningServiceTestWorkspace();
|
||||
$overrides['workspace_id'] = $workspace->id;
|
||||
}
|
||||
|
||||
return Invoice::forceCreate(array_merge([
|
||||
'invoice_number' => 'INV-DUN-'.uniqid(),
|
||||
'status' => 'overdue',
|
||||
'currency' => 'GBP',
|
||||
'subtotal' => 20.00,
|
||||
'total' => 20.00,
|
||||
'amount_due' => 20.00,
|
||||
'issue_date' => now(),
|
||||
'due_date' => now()->subDay(),
|
||||
'auto_charge' => true,
|
||||
'charge_attempts' => 0,
|
||||
], $overrides));
|
||||
}
|
||||
|
||||
describe('DunningService schedule()', function (): void {
|
||||
it('Good: stores retry dates and marks an active subscription past due', function (): void {
|
||||
Carbon::setTestNow('2026-01-01 09:00:00');
|
||||
$subscription = dunningServiceTestSubscription();
|
||||
|
||||
$schedule = $this->service->schedule($subscription);
|
||||
|
||||
expect($schedule)->toBeInstanceOf(DunningSchedule::class)
|
||||
->and(array_map(fn (Carbon $date): string => $date->toDateString(), $schedule->retryDates))
|
||||
->toBe(['2026-01-02', '2026-01-04', '2026-01-08'])
|
||||
->and($schedule->suspensionDate->toDateString())->toBe('2026-01-15')
|
||||
->and($subscription->fresh()->status)->toBe('past_due')
|
||||
->and(data_get($subscription->fresh()->metadata, 'dunning.stage'))->toBe('scheduled');
|
||||
});
|
||||
|
||||
it('Bad: rejects ended subscriptions', function (): void {
|
||||
$subscription = dunningServiceTestSubscription(['status' => 'cancelled']);
|
||||
|
||||
$this->service->schedule($subscription);
|
||||
})->throws(InvalidArgumentException::class);
|
||||
|
||||
it('Ugly: preserves unrelated subscription metadata when scheduling', function (): void {
|
||||
$subscription = dunningServiceTestSubscription([
|
||||
'metadata' => ['customer_note' => 'preserve'],
|
||||
]);
|
||||
|
||||
$this->service->schedule($subscription);
|
||||
|
||||
expect($subscription->fresh()->metadata['customer_note'])->toBe('preserve')
|
||||
->and(data_get($subscription->fresh()->metadata, 'dunning.retry_dates'))->toHaveCount(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DunningService retry()', function (): void {
|
||||
it('Good: records a successful payment retry and clears dunning', function (): void {
|
||||
$workspace = dunningServiceTestWorkspace();
|
||||
$subscription = dunningServiceTestSubscription([
|
||||
'status' => 'past_due',
|
||||
'metadata' => ['dunning' => ['stage' => 'scheduled']],
|
||||
], $workspace);
|
||||
$invoice = dunningServiceTestInvoice([
|
||||
'next_charge_attempt' => now()->subMinute(),
|
||||
], $workspace);
|
||||
|
||||
$this->commerce
|
||||
->shouldReceive('retryInvoicePayment')
|
||||
->once()
|
||||
->andReturnUsing(function (Invoice $invoice): bool {
|
||||
$payment = Payment::forceCreate([
|
||||
'workspace_id' => $invoice->workspace_id,
|
||||
'invoice_id' => $invoice->id,
|
||||
'gateway' => 'stripe',
|
||||
'currency' => 'GBP',
|
||||
'amount' => 20.00,
|
||||
'net_amount' => 20.00,
|
||||
'status' => 'succeeded',
|
||||
'paid_at' => now(),
|
||||
]);
|
||||
|
||||
$invoice->markAsPaid($payment);
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
$result = $this->service->retry($invoice);
|
||||
|
||||
expect($result)->toBeInstanceOf(PaymentResult::class)
|
||||
->and($result->successful)->toBeTrue()
|
||||
->and($result->attempts)->toBe(1)
|
||||
->and($invoice->fresh()->status)->toBe('paid')
|
||||
->and($invoice->fresh()->next_charge_attempt)->toBeNull()
|
||||
->and(data_get($subscription->fresh()->metadata, 'dunning'))->toBeNull();
|
||||
});
|
||||
|
||||
it('Bad: refuses invoices that are not configured for automatic charging', function (): void {
|
||||
$invoice = dunningServiceTestInvoice(['auto_charge' => false]);
|
||||
$this->commerce->shouldNotReceive('retryInvoicePayment');
|
||||
|
||||
$result = $this->service->retry($invoice);
|
||||
|
||||
expect($result->successful)->toBeFalse()
|
||||
->and($result->reason)->toBe('Invoice is not configured for automatic charging.')
|
||||
->and($invoice->fresh()->charge_attempts)->toBe(0);
|
||||
});
|
||||
|
||||
it('Ugly: captures gateway exceptions and schedules the next retry', function (): void {
|
||||
Carbon::setTestNow('2026-01-01 09:00:00');
|
||||
$workspace = dunningServiceTestWorkspace();
|
||||
dunningServiceTestSubscription(['status' => 'past_due'], $workspace);
|
||||
$invoice = dunningServiceTestInvoice([], $workspace);
|
||||
|
||||
$this->commerce
|
||||
->shouldReceive('retryInvoicePayment')
|
||||
->once()
|
||||
->andThrow(new RuntimeException('gateway offline'));
|
||||
|
||||
$result = $this->service->retry($invoice);
|
||||
|
||||
expect($result->successful)->toBeFalse()
|
||||
->and($result->reason)->toBe('gateway offline')
|
||||
->and($result->attempts)->toBe(1)
|
||||
->and($result->nextRetryAt?->toDateString())->toBe('2026-01-04')
|
||||
->and($invoice->fresh()->next_charge_attempt?->toDateString())->toBe('2026-01-04');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DunningService suspend()', function (): void {
|
||||
it('Good: marks the subscription suspended and suspends workspace entitlements', function (): void {
|
||||
Notification::fake();
|
||||
Event::fake();
|
||||
$workspace = dunningServiceTestWorkspace();
|
||||
$subscription = dunningServiceTestSubscription(['status' => 'past_due'], $workspace);
|
||||
|
||||
$this->entitlements
|
||||
->shouldReceive('suspendWorkspace')
|
||||
->once()
|
||||
->with(Mockery::type(Workspace::class), 'dunning');
|
||||
|
||||
$this->service->suspend($subscription);
|
||||
|
||||
expect($subscription->fresh()->status)->toBe('suspended')
|
||||
->and($subscription->fresh()->paused_at)->not->toBeNull()
|
||||
->and(data_get($subscription->fresh()->metadata, 'dunning.stage'))->toBe('suspended');
|
||||
|
||||
Event::assertDispatched('commerce.dunning.notified');
|
||||
});
|
||||
|
||||
it('Bad: rejects ended subscriptions', function (): void {
|
||||
$subscription = dunningServiceTestSubscription(['status' => 'expired']);
|
||||
|
||||
$this->service->suspend($subscription);
|
||||
})->throws(InvalidArgumentException::class);
|
||||
|
||||
it('Ugly: refuses to suspend a subscription with no workspace', function (): void {
|
||||
$subscription = dunningServiceTestSubscription(['workspace_id' => null]);
|
||||
$this->entitlements->shouldNotReceive('suspendWorkspace');
|
||||
|
||||
$this->service->suspend($subscription);
|
||||
})->throws(InvalidArgumentException::class);
|
||||
});
|
||||
|
||||
describe('DunningService notify()', function (): void {
|
||||
it('Good: sends the notification mapped to the requested dunning stage', function (): void {
|
||||
Notification::fake();
|
||||
Event::fake();
|
||||
$workspace = dunningServiceTestWorkspace();
|
||||
$subscription = dunningServiceTestSubscription([], $workspace);
|
||||
$owner = User::query()->findOrFail($workspace->owner()->id);
|
||||
|
||||
$this->service->notify($subscription, 'suspended');
|
||||
|
||||
Notification::assertSentTo($owner, AccountSuspended::class);
|
||||
Event::assertDispatched('commerce.dunning.notified');
|
||||
});
|
||||
|
||||
it('Bad: rejects unknown stages', function (): void {
|
||||
$subscription = dunningServiceTestSubscription();
|
||||
|
||||
$this->service->notify($subscription, 'mystery');
|
||||
})->throws(InvalidArgumentException::class);
|
||||
|
||||
it('Ugly: dispatches the stage event even when no owner can receive email', function (): void {
|
||||
Notification::fake();
|
||||
Event::fake();
|
||||
$workspace = dunningServiceTestWorkspace(withOwner: false);
|
||||
$subscription = dunningServiceTestSubscription([], $workspace);
|
||||
|
||||
$this->service->notify($subscription, 'failed');
|
||||
|
||||
Event::assertDispatched('commerce.dunning.notified');
|
||||
Notification::assertNothingSent();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DunningService recover()', function (): void {
|
||||
it('Good: clears dunning metadata, retry dates, and workspace suspension', function (): void {
|
||||
$workspace = dunningServiceTestWorkspace();
|
||||
$subscription = dunningServiceTestSubscription([
|
||||
'status' => 'suspended',
|
||||
'paused_at' => now()->subDays(2),
|
||||
'metadata' => ['dunning' => ['stage' => 'suspended']],
|
||||
], $workspace);
|
||||
$invoice = dunningServiceTestInvoice([
|
||||
'next_charge_attempt' => now()->addDay(),
|
||||
], $workspace);
|
||||
|
||||
$this->entitlements
|
||||
->shouldReceive('reactivateWorkspace')
|
||||
->once()
|
||||
->with(Mockery::type(Workspace::class), 'dunning_recovery');
|
||||
|
||||
$this->service->recover($subscription);
|
||||
|
||||
expect($subscription->fresh()->status)->toBe('active')
|
||||
->and($subscription->fresh()->paused_at)->toBeNull()
|
||||
->and(data_get($subscription->fresh()->metadata, 'dunning'))->toBeNull()
|
||||
->and($invoice->fresh()->next_charge_attempt)->toBeNull();
|
||||
});
|
||||
|
||||
it('Bad: does not reactivate an ended subscription', function (): void {
|
||||
$subscription = dunningServiceTestSubscription([
|
||||
'status' => 'cancelled',
|
||||
'metadata' => ['dunning' => ['stage' => 'scheduled']],
|
||||
]);
|
||||
$this->entitlements->shouldNotReceive('reactivateWorkspace');
|
||||
|
||||
$this->service->recover($subscription);
|
||||
|
||||
expect($subscription->fresh()->status)->toBe('cancelled')
|
||||
->and(data_get($subscription->fresh()->metadata, 'dunning'))->toBeNull();
|
||||
});
|
||||
|
||||
it('Ugly: tolerates missing workspace and missing dunning metadata', function (): void {
|
||||
$subscription = dunningServiceTestSubscription([
|
||||
'workspace_id' => null,
|
||||
'status' => 'past_due',
|
||||
'metadata' => null,
|
||||
]);
|
||||
$this->entitlements->shouldNotReceive('reactivateWorkspace');
|
||||
|
||||
$this->service->recover($subscription);
|
||||
|
||||
expect($subscription->fresh()->status)->toBe('active')
|
||||
->and($subscription->fresh()->metadata)->toBe([]);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue