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 ($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; // --- 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); }); // --- Dispatch VMJobs OUTSIDE transaction (safe: DB is committed) --- 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): 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) ->where('status', InvoiceStatus::UNPAID->value) ->exists(); if ($hasUnpaidRenewal) { 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; $invoice = Invoice::create([ 'invoice_number' => OrderHelper::generateOrderNumber('INV'), 'customer_id' => $service->customer_id, 'order_id' => null, 'invoice_type' => 2, // renewal 'sub_total' => $price, 'discount_amount' => 0, 'tax' => 0, 'total' => $price, 'status' => InvoiceStatus::UNPAID->value, 'due_date' => now()->addDays(30)->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, 'invoice_number' => $invoice->invoice_number]); $this->line(" [invoice] Renewal {$invoice->invoice_number} for Service #{$service->id}."); } }