Add service renewal functionality and command for expired services
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
<?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';
|
||||
protected $description = 'Suspend expired services, power off VMs, create renewal invoices, terminate after 30 days';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
// 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 ($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}.");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
// =========================================================================
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user