From 64a9d603e24b25914b96b143351f1472084236b5 Mon Sep 17 00:00:00 2001 From: pbao9 Date: Wed, 15 Apr 2026 20:52:11 +0700 Subject: [PATCH] Add service renewal functionality and command for expired services --- app/Console/Commands/ServiceExpiry.php | 138 ++++++++++++++++++ app/Console/Kernel.php | 6 + .../Api/Service/OrderController.php | 136 +++++++++++++++++ .../Service/ServiceClientDetailResource.php | 1 + app/Models/Service.php | 8 +- routes/client.php | 1 + 6 files changed, 289 insertions(+), 1 deletion(-) create mode 100644 app/Console/Commands/ServiceExpiry.php diff --git a/app/Console/Commands/ServiceExpiry.php b/app/Console/Commands/ServiceExpiry.php new file mode 100644 index 0000000..cbd0cf4 --- /dev/null +++ b/app/Console/Commands/ServiceExpiry.php @@ -0,0 +1,138 @@ +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}."); + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 1b06a6c..58c41dc 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -37,6 +37,12 @@ class Kernel extends ConsoleKernel $schedule->command('vps:expiry') ->timezone("Asia/Ho_Chi_Minh") ->dailyAt("09:54"); + + /* Service lifecycle: suspend VM, renew invoice, terminate after 30 days */ + $schedule->command('service:expiry') + ->timezone("Asia/Ho_Chi_Minh") + ->everyFiveMinutes() + ->withoutOverlapping(); } /** diff --git a/app/Http/Controllers/Api/Service/OrderController.php b/app/Http/Controllers/Api/Service/OrderController.php index fa07308..fe7378a 100644 --- a/app/Http/Controllers/Api/Service/OrderController.php +++ b/app/Http/Controllers/Api/Service/OrderController.php @@ -4,10 +4,14 @@ namespace App\Http\Controllers\Api\Service; use App\Enums\Invoice\InvoiceStatus; use App\Enums\Order\OrderStatus; +use App\Helpers\OrderHelper; use App\Http\Controllers\Controller; +use App\Jobs\VirtualMachine\Action\VMJobs; +use App\Models\Log as CustomerActivityLog; use App\Http\Requests\Api\Order\PayOrderRequest; use App\Http\Requests\Api\Order\StoreOrderRequest; use App\Models\Invoice; +use App\Models\InvoiceItem; use App\Models\ModuleVmware; use App\Models\Order; use App\Models\Product; @@ -17,6 +21,7 @@ use App\Services\Api\Order\OrderService; use Carbon\Carbon; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\DB; /** * @OA\Tag(name="Orders", description="Order & Invoice management") @@ -442,6 +447,137 @@ class OrderController extends Controller } } + // ========================================================================= + // Services — renewal + // ========================================================================= + + /** + * POST /v1/client/dich-vu/{id}/renew + * + * Pay-and-activate renewal in one step: + * 1. Validate balance + * 2. Debit customer balance (inside transaction) + * 3. Create PAID renewal invoice (inside transaction) + * 4. Extend service expires_at + set status = active (inside transaction) + * 5. Dispatch VMJobs 'Start' OUTSIDE transaction to power on suspended VM + */ + public function renewService(Request $request, int $id): JsonResponse + { + try { + $customer = $request->user(); + + $service = Service::query() + ->with(['orderItem.product']) + ->where('id', $id) + ->where('customer_id', $customer->id) + ->whereNotIn('status', ['terminated', 'cancelled']) + ->first(); + + if (!$service) { + return $this->notFound('Không tìm thấy dịch vụ hoặc dịch vụ đã bị hủy.'); + } + + $orderItem = $service->orderItem; + if (!$orderItem || (int) $orderItem->price <= 0) { + return $this->error('Không thể xác định giá gia hạn dịch vụ.'); + } + + $validated = $request->validate(['months' => ['required', 'integer', 'min:1', 'max:24']]); + + $price = (int) $orderItem->price * (int) $validated['months']; + $months = (int) $validated['months']; + + // Validate balance before opening a transaction + if ($customer->total < $price) { + return $this->error(sprintf( + 'Số dư không đủ. Hiện có: %s VNĐ, cần: %s VNĐ.', + number_format($customer->total), + number_format($price) + )); + } + + $needsVmStart = $service->vm_id !== null; + + $invoice = DB::transaction(function () use ($customer, $service, $orderItem, $price, $months) { + // 1. Debit balance + $customer->total -= $price; + $customer->save(); + + // 2. Activity log + CustomerActivityLog::create([ + 'agent' => 'api', + 'client_ip' => request()?->ip(), + 'customer_id' => $customer->id, + 'description' => sprintf( + 'Gia hạn dịch vụ #%s. Số tiền: %s VNĐ. Số dư còn: %s VNĐ.', + $service->server_name ?? $service->id, + number_format($price), + number_format($customer->total) + ), + ]); + + // 3. Create PAID renewal invoice + $invoice = Invoice::create([ + 'invoice_number' => OrderHelper::generateOrderNumber('INV'), + 'customer_id' => $customer->id, + 'order_id' => null, + 'invoice_type' => 2, // renewal + 'sub_total' => $price, + 'discount_amount' => 0, + 'tax' => 0, + 'total' => $price, + 'status' => InvoiceStatus::PAID->value, + 'paid_at' => now(), + 'payment_method' => 'credit', + ]); + + 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' => $price, + 'total_price' => $price, + ]); + + // 4. Extend expires_at from today (or current expires_at if still in future) + $baseDate = $service->expires_at && $service->expires_at->isFuture() + ? $service->expires_at + : now(); + $expiresAt = Carbon::parse($baseDate)->addMonths($months); + + $service->update([ + 'status' => 'active', + 'expires_at' => $expiresAt, + 'suspended_at' => null, + 'invoice_id' => $invoice->id, + ]); + + return $invoice; + }); + + // 5. Start VM outside transaction (DB already committed) + if ($needsVmStart) { + dispatch(new VMJobs((int) $service->vm_id, 'Start', false)) + ->delay(now()->addSeconds(5)); + } + + return response()->json([ + 'success' => true, + 'message' => 'Gia hạn thành công. Dịch vụ đã được kích hoạt lại.', + 'data' => [ + 'invoice_number' => $invoice->invoice_number, + 'total' => (float) $invoice->total, + 'expires_at' => $service->fresh()?->expires_at?->toISOString(), + ], + ]); + } catch (\Exception $e) { + return $this->error($e->getMessage()); + } + } + // ========================================================================= // Private — formatters // ========================================================================= diff --git a/app/Http/Resources/Api/Client/Service/ServiceClientDetailResource.php b/app/Http/Resources/Api/Client/Service/ServiceClientDetailResource.php index f550569..af01622 100644 --- a/app/Http/Resources/Api/Client/Service/ServiceClientDetailResource.php +++ b/app/Http/Resources/Api/Client/Service/ServiceClientDetailResource.php @@ -24,6 +24,7 @@ class ServiceClientDetailResource extends JsonResource $product = $orderItem?->product; return [ + 'id' => $this->id, 'uuid' => $this->uuid, 'server_name' => $this->server_name, 'status' => $this->status, diff --git a/app/Models/Service.php b/app/Models/Service.php index 6edc0ee..5cecc86 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -3,13 +3,19 @@ namespace App\Models; use App\View\Components\Module\OsVersion; +use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\SoftDeletes; class Service extends Model { - use HasFactory, SoftDeletes; + use HasFactory, HasUuids, SoftDeletes; + + public function uniqueIds(): array + { + return ['uuid']; + } protected $table = 'services'; diff --git a/routes/client.php b/routes/client.php index 6373f94..6d5a70d 100644 --- a/routes/client.php +++ b/routes/client.php @@ -33,6 +33,7 @@ Route::prefix('v1')->as('api.')->group(function () { Route::get('invoices/order/{orderNum}', 'getClientInvoiceDetailByOrderNum')->name('invoices.detailByOrderNum'); Route::get('dich-vu', 'getClientServices')->name('services.list'); Route::patch('dich-vu/{id}/auto-renew', 'updateClientServiceAutoRenew')->name('services.autoRenew.update'); + Route::post('dich-vu/{id}/renew', 'renewService')->name('services.renew'); });