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 { // 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}"); } // 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; } /** * 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]); } // 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}."); } }