Files
portal-api/app/Console/Commands/ServiceExpiry.php
T
baodev0andClaude Sonnet 5.5 b44a88bb52 feat(service): huỷ dịch vụ, SSL, đổi mật khẩu hosting, lịch sử thanh toán theo hình thức
- Lịch sử thanh toán hoá đơn trả thêm `method` (tự động gia hạn / số dư / admin / chuyển khoản / hoàn tiền), bỏ đuôi `(method)` khỏi ghi chú
- `DnsServer::panelUrl()` = https://<domain>/ dùng cho email cấp hosting và nút Plesk của khách
- Order item trả thêm `uuid`/`type` của dịch vụ để FE dẫn tới trang quản lý
- Yêu cầu huỷ dịch vụ, chứng chỉ SSL, lịch sử gia hạn, đổi mật khẩu hosting cho khách

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 16:40:24 +07:00

361 lines
16 KiB
PHP

<?php
namespace App\Console\Commands;
use App\Enums\Invoice\InvoiceStatus;
use App\Helpers\OrderHelper;
use App\Jobs\VirtualMachine\Action\RemoveVirtualMachine;
use App\Jobs\VirtualMachine\Action\VMJobs;
use App\Models\OrderItem;
use App\Repositories\Api\Service\ServiceRepository;
use App\Models\Invoice;
use App\Models\InvoiceItem;
use App\Models\Service;
use App\Services\AutoRenewFailureNotifier;
use App\Services\InvoicePaymentService;
use App\Services\RenewalInvoiceNotifier;
use App\Support\RenewalPricing;
use App\Support\ServiceCancellation;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use App\Support\VatNumbering;
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', 'customer'])
->whereNotNull('expires_at')
->whereBetween('expires_at', [now(), now()->addDays($renewalWindowDays)])
->where('cancel_at_expiry', false)
->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', 'customer'])
->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 {
if ($service->cancel_at_expiry) {
if ($dryRun) {
$this->line(" [dry-run:cancel] Service #{$service->id}");
} else {
ServiceCancellation::finalize($service);
Log::info('Service cancelled at expiry', ['service_id' => $service->id]);
$this->line(" [cancelled] Service #{$service->id}");
}
continue;
}
// Hướng tính tường minh (mốc quá khứ → now), absolute: true để không phụ thuộc
// việc Carbon 2 trả trị tuyệt đối — Carbon 3 trả số âm, điều kiện >= 30 sẽ
// không bao giờ đúng và không dịch vụ nào bị terminate nữa, hoàn toàn im lặng.
$daysExpired = now()->greaterThan($service->expires_at)
? (int) $service->expires_at->diffInDays(now(), absolute: true)
: 0;
// --- 30-day threshold: terminate (idempotent) ---
if ($daysExpired >= 30 + (int) config('billing.terminate_grace_days', 0)) {
if ($dryRun) {
$this->line(" [dry-run:terminate] Service #{$service->id}" . ($service->vm_id ? " + thu hồi VM #{$service->vm_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}");
$this->reclaimResources($service);
}
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}");
}
$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->autoPayRenewals($renewalWindowDays, $dryRun);
$this->info("service:expiry | Done.");
return Command::SUCCESS;
}
private function autoPayRenewals(int $windowDays, bool $dryRun): void
{
$services = Service::query()
->with('customer')
->where('auto_renew', true)
->where('cancel_at_expiry', false)
->whereNotNull('invoice_id')
->whereNotNull('expires_at')
->where('expires_at', '<=', now()->addDays($windowDays))
->whereNotIn('status', ['terminated', 'cancelled'])
->get();
foreach ($services as $service) {
$invoice = Invoice::where('id', $service->invoice_id)
->where('invoice_type', 2)
->whereIn('status', InvoicePaymentService::PAYABLE_STATUSES)
->first();
if (! $invoice || ! $service->customer) {
continue;
}
if ($dryRun) {
$this->line(" [dry-run:auto-pay] Service #{$service->id} invoice {$invoice->invoice_number}");
continue;
}
if ((int) $service->customer->total < InvoicePaymentService::outstanding($invoice)) {
$notified = app(AutoRenewFailureNotifier::class)->notifyInsufficientBalance($service, $invoice);
Log::info('Auto-renew thiếu số dư', ['service_id' => $service->id, 'invoice_id' => $invoice->id, 'notified' => $notified]);
continue;
}
try {
app(InvoicePaymentService::class)->payWithBalance($invoice, $service->customer);
Log::info('Auto-renew paid', ['service_id' => $service->id, 'invoice_id' => $invoice->id]);
$this->line(" [auto-paid] Service #{$service->id} invoice {$invoice->invoice_number}");
} catch (\InvalidArgumentException $e) {
Log::info('Auto-renew chưa trừ được tiền', ['service_id' => $service->id, 'invoice_id' => $invoice->id, 'reason' => $e->getMessage()]);
} catch (\Throwable $e) {
Log::error('service:expiry auto-pay error', ['service_id' => $service->id, 'error' => $e->getMessage()]);
$this->error(" [error] Auto-pay service #{$service->id}: " . $e->getMessage());
}
}
}
/**
* Giá gia hạn lấy theo BẢNG GIÁ HIỆN HÀNH tại thời điểm xuất hoá đơn, không đóng băng theo
* giá lúc mua — nếu không, tăng giá sẽ không bao giờ áp được cho khách cũ và khuyến mãi
* năm đầu bị gia hạn vĩnh viễn với giá khuyến mãi. Không tra được giá hiện hành (sản phẩm
* ngừng bán, chu kỳ bị tắt) thì lùi về giá cũ để vẫn xuất được hoá đơn.
*/
private function renewalPrice(OrderItem $orderItem): int
{
$legacyPrice = (int) $orderItem->price;
if (! config('billing.renewal_uses_current_price', true) || ! $orderItem->product_id || ! $orderItem->billing_cycle) {
return $legacyPrice;
}
try {
$current = app(ServiceRepository::class)->getPricing($orderItem->product_id, $orderItem->billing_cycle);
} catch (\Throwable $e) {
Log::warning('service:expiry | không tra được giá hiện hành, dùng giá cũ', [
'order_item_id' => $orderItem->id,
'error' => $e->getMessage(),
]);
return $legacyPrice;
}
if ($current === null || (int) $current <= 0) {
Log::info('service:expiry | không có giá hiện hành cho chu kỳ này, dùng giá cũ', [
'order_item_id' => $orderItem->id,
'billing_cycle' => $orderItem->billing_cycle,
]);
return $legacyPrice;
}
if ((int) $current !== $legacyPrice) {
Log::info('service:expiry | giá gia hạn đổi theo bảng giá hiện hành', [
'order_item_id' => $orderItem->id,
'old_price' => $legacyPrice,
'new_price' => (int) $current,
]);
}
return (int) $current;
}
/**
* Trả tài nguyên về hạ tầng khi dịch vụ đã chết hẳn. Trước đây terminate chỉ đổi status,
* VM vẫn nằm trên vCenter và hosting vẫn chiếm quota — công ty gánh chi phí cho dịch vụ
* không ai trả tiền.
*
* ponytail: hosting chỉ Suspend vì Http\Services\Hosting chưa có API xoá tài khoản —
* chỉ bọc delete khi nhà cung cấp mở endpoint, đừng đoán.
*/
private function reclaimResources(Service $service): void
{
if ($service->vm_id) {
dispatch(new RemoveVirtualMachine((int) $service->vm_id));
Log::info('Terminate: dispatched VM removal', ['service_id' => $service->id, 'vm_id' => $service->vm_id]);
$this->line(" [reclaim] VM #{$service->vm_id} đã xếp hàng xoá.");
return;
}
if ($service->orderItem?->product?->product_type === 'hosting') {
Log::warning('Terminate: hosting account cần thu hồi thủ công (chưa có API xoá)', [
'service_id' => $service->id,
'server_name' => $service->server_name,
]);
$this->line(" [reclaim] Hosting {$service->server_name}: cần thu hồi thủ công.");
}
}
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) {
Log::warning('service:expiry | Cannot create renewal invoice: no order item', ['service_id' => $service->id]);
return;
}
// Dịch vụ import từ WHMCS có services.invoice_id = NULL dù hoá đơn gia hạn của nó đã
// được đồng bộ sang — chỉ dựa vào invoice_id sẽ xuất trùng một hoá đơn nữa cho cùng kỳ.
// Soát thêm theo order: đơn đã có hoá đơn đang mở thì coi như đã xuất.
$openInvoiceOnOrder = Invoice::where('order_id', $orderItem->order_id)
->whereIn('status', [InvoiceStatus::UNPAID->value, InvoiceStatus::OVERDUE->value])
->first();
if ($openInvoiceOnOrder) {
// Trỏ service về hoá đơn đang mở để lần chạy sau đi vào nhánh nhanh ở trên.
if ((int) $service->invoice_id !== (int) $openInvoiceOnOrder->id) {
$service->update(['invoice_id' => $openInvoiceOnOrder->id]);
}
$this->line(" [skip] Service #{$service->id}: đơn #{$orderItem->order_id} đã có hoá đơn {$openInvoiceOnOrder->invoice_number} đang mở.");
return;
}
$renewalPrice = $this->renewalPrice($orderItem);
$months = $orderItem->months ?? 1;
$addonTotal = RenewalPricing::addonTotal($service, 1);
$price = $renewalPrice + $addonTotal;
if ($price <= 0) {
Log::warning('service:expiry | Cannot create renewal invoice: zero price', ['service_id' => $service->id]);
return;
}
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((int) config('billing.renewal_due_days', 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' => $months,
'quantity' => 1,
'unit_price' => $renewalPrice,
'total_price' => $renewalPrice,
]);
foreach (RenewalPricing::addonInvoiceItems($service, 1, $months) as $addonItem) {
InvoiceItem::create($addonItem + ['invoice_id' => $invoice->id]);
}
VatNumbering::settle($invoice);
// 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,
]);
app(RenewalInvoiceNotifier::class)->notify($invoice, $service);
$this->line(" [invoice] Renewal invoice {$invoice->invoice_number} on Order #{$orderItem->order_id} for Service #{$service->id}.");
}
}