288 lines
8.3 KiB
PHP
288 lines
8.3 KiB
PHP
<?php
|
|
|
|
namespace Core\Commerce\Console;
|
|
|
|
use Illuminate\Console\Command;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Core\Commerce\Services\DunningService;
|
|
use Core\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\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;
|
|
}
|
|
}
|