php-commerce/Console/ProcessDunning.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

288 lines
8.3 KiB
PHP

<?php
namespace Core\Mod\Commerce\Console;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Core\Mod\Commerce\Services\DunningService;
use Core\Mod\Commerce\Services\SubscriptionService;
class ProcessDunning extends Command
{
protected $signature = 'commerce:process-dunning
{--dry-run : Show what would happen without making changes}
{--stage= : Process only a specific stage (retry, pause, suspend, cancel, expire)}';
protected $description = 'Process dunning for failed payments - retry charges, pause, suspend, and cancel subscriptions';
public function __construct(
protected DunningService $dunning,
protected SubscriptionService $subscriptions
) {
parent::__construct();
}
public function handle(): int
{
if (! config('commerce.dunning.enabled', true)) {
$this->info('Dunning is disabled.');
return self::SUCCESS;
}
$dryRun = $this->option('dry-run');
$stage = $this->option('stage');
if ($dryRun) {
$this->warn('DRY RUN MODE - No changes will be made');
}
$this->info('Processing dunning...');
$this->newLine();
$results = [
'retried' => 0,
'paused' => 0,
'suspended' => 0,
'cancelled' => 0,
'expired' => 0,
];
// Process stages based on option or all
if (! $stage || $stage === 'retry') {
$results['retried'] = $this->processRetries($dryRun);
}
if (! $stage || $stage === 'pause') {
$results['paused'] = $this->processPauses($dryRun);
}
if (! $stage || $stage === 'suspend') {
$results['suspended'] = $this->processSuspensions($dryRun);
}
if (! $stage || $stage === 'cancel') {
$results['cancelled'] = $this->processCancellations($dryRun);
}
if (! $stage || $stage === 'expire') {
$results['expired'] = $this->processExpired($dryRun);
}
$this->newLine();
$this->info('Dunning Summary:');
$this->table(
['Action', 'Count'],
[
['Payment retries attempted', $results['retried']],
['Subscriptions paused', $results['paused']],
['Workspaces suspended', $results['suspended']],
['Subscriptions cancelled', $results['cancelled']],
['Subscriptions expired', $results['expired']],
]
);
Log::info('Dunning process completed', $results);
return self::SUCCESS;
}
/**
* Process payment retries for overdue invoices.
*/
protected function processRetries(bool $dryRun): int
{
$this->info('Stage 1: Payment Retries');
$invoices = $this->dunning->getInvoicesDueForRetry();
if ($invoices->isEmpty()) {
$this->line(' No invoices due for retry');
return 0;
}
$count = 0;
foreach ($invoices as $invoice) {
$this->line(" Processing invoice {$invoice->invoice_number}...");
if ($dryRun) {
$this->comment(" Would retry payment (attempt {$invoice->charge_attempts})");
$count++;
continue;
}
try {
$success = $this->dunning->retryPayment($invoice);
if ($success) {
$this->info(' Payment successful');
} else {
$this->warn(' Payment failed - next retry scheduled');
}
$count++;
} catch (\Exception $e) {
$this->error(" Error: {$e->getMessage()}");
Log::error('Dunning retry failed', [
'invoice_id' => $invoice->id,
'error' => $e->getMessage(),
]);
}
}
return $count;
}
/**
* Process subscription pauses (after max retries exhausted).
*/
protected function processPauses(bool $dryRun): int
{
$this->info('Stage 2: Subscription Pauses');
$subscriptions = $this->dunning->getSubscriptionsForPause();
if ($subscriptions->isEmpty()) {
$this->line(' No subscriptions to pause');
return 0;
}
$count = 0;
foreach ($subscriptions as $subscription) {
$this->line(" Pausing subscription {$subscription->id} (workspace {$subscription->workspace_id})...");
if ($dryRun) {
$this->comment(' Would pause subscription');
$count++;
continue;
}
try {
$this->dunning->pauseSubscription($subscription);
$this->info(' Subscription paused');
$count++;
} catch (\Exception $e) {
$this->error(" Error: {$e->getMessage()}");
Log::error('Dunning pause failed', [
'subscription_id' => $subscription->id,
'error' => $e->getMessage(),
]);
}
}
return $count;
}
/**
* Process workspace suspensions.
*/
protected function processSuspensions(bool $dryRun): int
{
$this->info('Stage 3: Workspace Suspensions');
$subscriptions = $this->dunning->getSubscriptionsForSuspension();
if ($subscriptions->isEmpty()) {
$this->line(' No workspaces to suspend');
return 0;
}
$count = 0;
foreach ($subscriptions as $subscription) {
$this->line(" Suspending workspace {$subscription->workspace_id}...");
if ($dryRun) {
$this->comment(' Would suspend workspace entitlements');
$count++;
continue;
}
try {
$this->dunning->suspendWorkspace($subscription);
$this->info(' Workspace suspended');
$count++;
} catch (\Exception $e) {
$this->error(" Error: {$e->getMessage()}");
Log::error('Dunning suspension failed', [
'subscription_id' => $subscription->id,
'error' => $e->getMessage(),
]);
}
}
return $count;
}
/**
* Process subscription cancellations.
*/
protected function processCancellations(bool $dryRun): int
{
$this->info('Stage 4: Subscription Cancellations');
$subscriptions = $this->dunning->getSubscriptionsForCancellation();
if ($subscriptions->isEmpty()) {
$this->line(' No subscriptions to cancel');
return 0;
}
$count = 0;
foreach ($subscriptions as $subscription) {
$this->line(" Cancelling subscription {$subscription->id}...");
if ($dryRun) {
$this->comment(' Would cancel subscription due to non-payment');
$count++;
continue;
}
try {
$this->dunning->cancelSubscription($subscription);
$this->info(' Subscription cancelled');
$count++;
} catch (\Exception $e) {
$this->error(" Error: {$e->getMessage()}");
Log::error('Dunning cancellation failed', [
'subscription_id' => $subscription->id,
'error' => $e->getMessage(),
]);
}
}
return $count;
}
/**
* Process expired subscriptions (cancelled with period ended).
*/
protected function processExpired(bool $dryRun): int
{
$this->info('Stage 5: Expired Subscriptions');
if ($dryRun) {
$count = \Core\Mod\Commerce\Models\Subscription::query()
->active()
->whereNotNull('cancelled_at')
->where('current_period_end', '<=', now())
->count();
$this->line(" Would expire {$count} subscriptions");
return $count;
}
$expired = $this->subscriptions->processExpired();
$this->line(" Expired {$expired} subscriptions");
return $expired;
}
}