193 lines
8.5 KiB
PHP
193 lines
8.5 KiB
PHP
<?php
|
|
|
|
namespace App\Console\Commands;
|
|
|
|
use App\Enums\Invoice\InvoiceStatus;
|
|
use App\Helpers\OrderHelper;
|
|
use App\Jobs\VirtualMachine\Action\VMJobs;
|
|
use App\Models\Invoice;
|
|
use App\Models\InvoiceItem;
|
|
use App\Models\Service;
|
|
use Illuminate\Console\Command;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
class ServiceExpiry extends Command
|
|
{
|
|
protected $signature = 'service:expiry {--dry-run : Preview service expiry actions without updating data}';
|
|
protected $description = 'Suspend expired services, power off VMs, create renewal invoices, terminate after 30 days';
|
|
|
|
public function handle(): int
|
|
{
|
|
$dryRun = (bool) $this->option('dry-run');
|
|
$renewalWindowDays = 7;
|
|
$upcoming = Service::query()
|
|
->with(['orderItem.product'])
|
|
->whereNotNull('expires_at')
|
|
->whereBetween('expires_at', [now(), now()->addDays($renewalWindowDays)])
|
|
->whereNotIn('status', ['terminated', 'cancelled'])
|
|
->get();
|
|
|
|
$this->info("service:expiry | Found {$upcoming->count()} service(s) expiring in {$renewalWindowDays} day(s).");
|
|
|
|
foreach ($upcoming as $service) {
|
|
try {
|
|
if ($dryRun) {
|
|
$this->createRenewalInvoiceIfMissing($service, true);
|
|
} else {
|
|
DB::transaction(fn () => $this->createRenewalInvoiceIfMissing($service));
|
|
}
|
|
} catch (\Throwable $e) {
|
|
Log::error('service:expiry upcoming renewal error', ['service_id' => $service->id, 'error' => $e->getMessage()]);
|
|
$this->error(" [error] Service #{$service->id}: " . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
// Note: 'invoice' is NOT eager-loaded — createRenewalInvoiceIfMissing queries fresh
|
|
$expired = Service::query()
|
|
->with(['orderItem.product'])
|
|
->whereNotNull('expires_at')
|
|
->where('expires_at', '<', now())
|
|
->whereNotIn('status', ['terminated', 'cancelled'])
|
|
->get();
|
|
|
|
$this->info("service:expiry | Found {$expired->count()} expired service(s).");
|
|
|
|
foreach ($expired as $service) {
|
|
try {
|
|
// Fix: diffInDays is always positive; guard with greaterThan to avoid edge case at exact boundary
|
|
$daysExpired = now()->greaterThan($service->expires_at)
|
|
? (int) now()->diffInDays($service->expires_at)
|
|
: 0;
|
|
|
|
// --- 30-day threshold: terminate (idempotent) ---
|
|
if ($daysExpired >= 30) {
|
|
if ($dryRun) {
|
|
$this->line(" [dry-run:terminate] Service #{$service->id}");
|
|
continue;
|
|
}
|
|
|
|
if ($service->status !== 'terminated') {
|
|
$service->update(['status' => 'terminated']);
|
|
Log::info('Service terminated', ['service_id' => $service->id, 'days_overdue' => $daysExpired]);
|
|
$this->line(" [terminated] Service #{$service->id}");
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// Capture before transaction so dispatch is conditional and outside the transaction
|
|
$needsVmPowerOff = $service->status !== 'suspended' && $service->vm_id;
|
|
|
|
if ($dryRun) {
|
|
$this->line(" [dry-run:suspend] Service #{$service->id}");
|
|
$this->createRenewalInvoiceIfMissing($service, true);
|
|
continue;
|
|
}
|
|
|
|
// --- Transaction: status update + invoice creation (atomic) ---
|
|
DB::transaction(function () use ($service) {
|
|
if ($service->status !== 'suspended') {
|
|
$service->update([
|
|
'status' => 'suspended',
|
|
'suspended_at' => now(),
|
|
]);
|
|
Log::info('Service suspended', ['service_id' => $service->id]);
|
|
$this->line(" [suspended] Service #{$service->id}");
|
|
}
|
|
|
|
// Optional: note auto_renew intent (auto-payment not implemented)
|
|
if ($service->auto_renew) {
|
|
Log::info('Service has auto_renew=true; renewal invoice will be created for manual or future auto-payment', ['service_id' => $service->id]);
|
|
}
|
|
|
|
$this->createRenewalInvoiceIfMissing($service);
|
|
});
|
|
|
|
if ($needsVmPowerOff) {
|
|
dispatch(new VMJobs((int) $service->vm_id, 'PowerOff', false));
|
|
Log::info('PowerOff dispatched', ['service_id' => $service->id, 'vm_id' => $service->vm_id]);
|
|
}
|
|
|
|
} catch (\Throwable $e) {
|
|
Log::error('service:expiry error', ['service_id' => $service->id, 'error' => $e->getMessage()]);
|
|
$this->error(" [error] Service #{$service->id}: " . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
$this->info("service:expiry | Done.");
|
|
return Command::SUCCESS;
|
|
}
|
|
|
|
private function createRenewalInvoiceIfMissing(Service $service, bool $dryRun = false): void
|
|
{
|
|
// Fresh DB query via invoice_id FK — does NOT rely on stale eager-loaded relation.
|
|
// invoices table has no service_id column; service->invoice_id is the correct pointer.
|
|
$hasUnpaidRenewal = $service->invoice_id && Invoice::where('id', $service->invoice_id)
|
|
->where('invoice_type', 2)
|
|
->whereIn('status', [InvoiceStatus::UNPAID->value, InvoiceStatus::OVERDUE->value])
|
|
->exists();
|
|
|
|
if ($hasUnpaidRenewal) {
|
|
$this->line(" [skip] Service #{$service->id} already has open renewal invoice.");
|
|
return;
|
|
}
|
|
|
|
$orderItem = $service->orderItem;
|
|
if (!$orderItem || (int) $orderItem->price <= 0) {
|
|
Log::warning('service:expiry | Cannot create renewal invoice: no order item or zero price', ['service_id' => $service->id]);
|
|
return;
|
|
}
|
|
|
|
$price = (int) $orderItem->price;
|
|
$months = $orderItem->months ?? 1;
|
|
|
|
if ($dryRun) {
|
|
$this->line(" [dry-run:invoice] Would create renewal invoice for Service #{$service->id} on Order #{$orderItem->order_id}, amount " . number_format($price) . ' VND.');
|
|
return;
|
|
}
|
|
|
|
// Gia hạn là một billing cycle mới của CÙNG một Order/Service — không tạo Order mới.
|
|
// Order gốc chính là order đã tạo ra service này (qua order_item -> order_id).
|
|
$periodStart = $service->expires_at && $service->expires_at->isFuture() ? $service->expires_at->copy() : now();
|
|
$periodEnd = $periodStart->copy()->addMonths($months);
|
|
|
|
$invoice = Invoice::create([
|
|
'invoice_number' => OrderHelper::generateOrderNumber('INV'),
|
|
'customer_id' => $service->customer_id,
|
|
'order_id' => $orderItem->order_id,
|
|
'invoice_type' => 2, // renewal
|
|
'sub_total' => $price,
|
|
'discount_amount' => 0,
|
|
'tax' => 0,
|
|
'total' => $price,
|
|
'status' => InvoiceStatus::UNPAID->value,
|
|
'due_date' => $service->expires_at && $service->expires_at->isFuture()
|
|
? $service->expires_at->toDateString()
|
|
: now()->addDays(7)->toDateString(),
|
|
'billing_period_start' => $periodStart->toDateString(),
|
|
'billing_period_end' => $periodEnd->toDateString(),
|
|
]);
|
|
|
|
InvoiceItem::create([
|
|
'invoice_id' => $invoice->id,
|
|
'product_id' => $service->product_id,
|
|
'type' => 'renewal',
|
|
'description' => 'Gia hạn dịch vụ: ' . ($orderItem->product?->name ?? $service->server_name ?? ''),
|
|
'months' => $orderItem->months ?? 1,
|
|
'quantity' => 1,
|
|
'unit_price' => $price,
|
|
'total_price' => $price,
|
|
]);
|
|
|
|
// Update service to point to the new renewal invoice
|
|
$service->update(['invoice_id' => $invoice->id]);
|
|
|
|
Log::info('Renewal invoice created', [
|
|
'service_id' => $service->id,
|
|
'order_id' => $orderItem->order_id,
|
|
'invoice_number' => $invoice->invoice_number,
|
|
]);
|
|
$this->line(" [invoice] Renewal invoice {$invoice->invoice_number} on Order #{$orderItem->order_id} for Service #{$service->id}.");
|
|
}
|
|
}
|