orderRepo->getOrders( $request->user()->id, $request->only(['total_price', 'pending']) ); return response()->json(['success' => true, 'data' => $orders]); } catch (\Exception $e) { return $this->error($e->getMessage()); } } /** * GET /v1/client/orders — customer's order list */ public function getClientOrders(Request $request): JsonResponse { try { $orders = Order::query() ->with(['invoice']) ->where('customer_id', $request->user()->id) ->orderByDesc('created_at') ->get(); $data = $orders->map(fn(Order $order) => [ 'id' => $order->id, 'order_num' => $order->order_num, 'status' => $order->status, 'status_text' => OrderStatus::from((int) $order->status)->label(), 'total' => (float) $order->total, 'created_at' => $this->isoDate($order->created_at), 'invoice' => $order->invoice ? [ 'id' => $order->invoice->id, 'invoice_number' => $order->invoice->invoice_number, 'status' => $order->invoice->status, 'total' => $order->invoice->total, ] : null, ]); return response()->json(['success' => true, 'data' => $data]); } catch (\Exception $e) { return $this->error($e->getMessage()); } } /** * GET /v1/client/orders/{id} — order detail */ public function getClientOrderDetail(Request $request, int $id): JsonResponse { try { $order = Order::query() ->with(['invoice', 'orderItems.product', 'customer']) ->where('customer_id', $request->user()->id) ->where('id', $id) ->first(); if (!$order) { return $this->notFound('Đơn hàng không tồn tại.'); } return response()->json(['success' => true, 'data' => $this->formatOrderDetail($order)]); } catch (\Exception $e) { return $this->error($e->getMessage()); } } /** * POST /v1/orders — create a new order * * @OA\Post(path="/orders", operationId="storeOrder", tags={"Orders"}, * security={{"bearerAuth":{}}}, * @OA\RequestBody(required=true, @OA\JsonContent( * required={"product_id","billing_cycle"}, * @OA\Property(property="product_id", type="string", example="uuid-here"), * @OA\Property(property="billing_cycle", type="string", example="annually", * enum={"monthly","quarterly","semi_annually","annually","biennially"}), * @OA\Property(property="server_name", type="string", example="my-vps-01"), * @OA\Property(property="os_id", type="integer", example=1), * @OA\Property(property="addons", type="array", @OA\Items( * @OA\Property(property="addon_id", type="integer", example=1), * @OA\Property(property="qty", type="integer", example=2) * )) * )), * @OA\Response(response=201, description="Order created successfully") * ) */ public function storeOrder(StoreOrderRequest $request): JsonResponse { try { $customer = $request->user(); $data = $request->validated(); $product = Product::where('uuid', $data['product_id'])->first(); if (!$product) { throw new \Exception('Sản phẩm không tìm thấy hoặc đã bị xóa.'); } $data['product_id'] = $product->id; $result = $this->orderService->create($data, $customer); $order = $result['order']->load(['orderItems.product', 'invoice', 'customer']); $invoice = $result['invoice']; return response()->json([ 'success' => true, 'message' => 'Tạo đơn hàng thành công.', 'notice' => 'Dịch vụ VPS sẽ được khởi tạo trong 5-10 phút sau khi thanh toán.', 'data' => [ 'order' => $this->formatOrderSummary($order), 'invoice' => $this->formatInvoiceSummary($invoice), 'items' => $this->formatOrderItems($order->orderItems), ], ], 201); } catch (\Exception $e) { return $this->error($e->getMessage()); } } /** * POST /v1/orders/{id}/pay — pay for a pending order * * @OA\Post(path="/orders/{id}/pay", operationId="payOrder", tags={"Orders"}, * security={{"bearerAuth":{}}}, * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")), * @OA\RequestBody(required=true, @OA\JsonContent( * required={"payment_method"}, * @OA\Property(property="payment_method", type="string", * enum={"credit","bank_transfer","vnpay","momo"}) * )), * @OA\Response(response=200, description="Payment successful") * ) */ public function payOrder(int $id, PayOrderRequest $request): JsonResponse { try { $order = $this->orderRepo->find($id); if (!$order) { return $this->notFound('Đơn hàng không tồn tại.'); } if ($order->customer_id !== $request->user()->id) { return response()->json(['success' => false, 'message' => 'Không có quyền truy cập.'], 403); } $this->orderService->pay($order, $request->payment_method); return response()->json(['success' => true, 'message' => 'Thanh toán thành công.']); } catch (\Exception $e) { return $this->error($e->getMessage()); } } /** * POST /v1/client/orders/{id}/retry — retry VM provisioning for a paid order * * Dispatches RetryProvisioningOrchestrator which: * 1. Searches vSphere for existing VM by server_name * 2. If found → links VM to module_vmware + services * 3. If not found → dispatches CreateVirtualMachineJob * 4. Each failure step notifies Telegram */ public function retryOrderProvisioning(Request $request, int $id): JsonResponse { try { $order = Order::where('id', $id) ->where('customer_id', $request->user()->id) ->first(); if (!$order) { return $this->notFound('Đơn hàng không tồn tại.'); } if (!$order->invoice || (int) $order->invoice->status !== InvoiceStatus::PAID->value) { return $this->error('Đơn hàng chưa được thanh toán.'); } RetryProvisioningOrchestrator::dispatch($order->id, $request->user()->id); return response()->json([ 'success' => true, 'message' => 'Đã yêu cầu khởi tạo lại dịch vụ. Hệ thống sẽ kiểm tra và xử lý trong vài phút.', ]); } catch (\Exception $e) { return $this->error($e->getMessage()); } } // ========================================================================= // Invoices // ========================================================================= /** * GET /v1/client/invoices — customer's invoice list */ public function getClientInvoices(Request $request): JsonResponse { try { $invoices = Invoice::query() ->with(['order.orderItems.product']) ->where('customer_id', $request->user()->id) ->orderByDesc('created_at') ->get(); $data = $invoices->map(fn(Invoice $invoice) => [ 'id' => $invoice->id, 'invoice_number' => $invoice->invoice_number, 'service_name' => $invoice->order?->orderItems->first()?->product?->name ?? ('Đơn hàng ' . $invoice->order?->order_num), 'total' => (float) $invoice->total, 'status' => $invoice->status, 'status_text' => InvoiceStatus::tryFrom((int) $invoice->status)?->label() ?? '-', 'created_at' => $this->isoDate($invoice->created_at), 'order_id' => $invoice->order?->id, 'order_num' => $invoice->order?->order_num, ])->values(); return response()->json(['success' => true, 'data' => $data]); } catch (\Exception $e) { return $this->error($e->getMessage()); } } /** * GET /v1/client/invoices/{id} — invoice detail */ public function getClientInvoiceDetail(Request $request, int $id): JsonResponse { try { $invoice = Invoice::query() ->with([ 'order.orderItems.product', 'order.vmWare.ipAddress', 'order.vmWare.os', 'invoiceItems', 'customer', 'attachments', ]) ->where('customer_id', $request->user()->id) ->where('id', $id) ->first(); if (!$invoice) { return $this->notFound('Hóa đơn không tồn tại.'); } return response()->json(['success' => true, 'data' => $this->formatInvoiceDetail($invoice)]); } catch (\Exception $e) { return $this->error($e->getMessage()); } } /** * GET /v1/client/invoices/order/{orderNumber} — invoice detail by order number */ public function getClientInvoiceDetailByOrderNum(Request $request, string $orderNumber): JsonResponse { try { $order = Order::query() ->with([ 'invoice.invoiceItems', 'invoice.attachments', 'invoice.customer', 'orderItems.product', 'customer', 'vmWare.ipAddress', 'vmWare.os', ]) ->where('customer_id', $request->user()->id) ->where('order_num', $orderNumber) ->first(); if (!$order?->invoice) { return $this->notFound('Hóa đơn không tồn tại.'); } // Reuse formatInvoiceDetail with already-loaded relations $invoice = $order->invoice; // Attach order + vmWare so formatter can use them $invoice->setRelation('order', $order); $invoice->setRelation('customer', $order->customer); return response()->json(['success' => true, 'data' => $this->formatInvoiceDetail($invoice)]); } catch (\Exception $e) { return $this->error($e->getMessage()); } } // ========================================================================= // Services (dich-vu) // ========================================================================= /** * GET /v1/client/dich-vu — list of customer's services */ public function getClientServices(Request $request): JsonResponse { try { $services = Service::query() ->with(['product', 'addons', 'invoice', 'orderItem', 'vmware.ipAddress', 'vmware.os']) ->where('customer_id', $request->user()->id) ->orderByDesc('created_at') ->get(); $data = $services->map(function (Service $service) { $vmware = $service->vmware; return [ 'id' => $service->id, 'service_name' => $service->product?->name ?? $service->server_name, 'server_name' => $service->server_name, 'product_type' => $service->product?->product_type, 'billing_cycle' => $service->orderItem?->billing_cycle, 'status' => $service->status, 'status_text' => $service->status_label, 'auto_renew' => (bool) $service->auto_renew, 'started_at' => $this->isoDate($service->started_at), 'expires_at' => $this->isoDate($service->expires_at), 'invoice_id' => $service->invoice_id, 'invoice_number' => $service->invoice?->invoice_number, 'addons' => $service->addons->map(fn($a) => [ 'type' => $a->addon_type, 'value' => $a->addon_value, 'price' => $a->price, ])->all(), 'vmware' => $vmware ? [ 'id' => $vmware->id, 'name' => $vmware->name, 'hostname' => $vmware->hostname, 'ip_address' => $vmware->ipAddress?->ip, 'os_name' => $vmware->os?->name, 'status' => $vmware->status, ] : null, ]; })->values(); return response()->json(['success' => true, 'data' => $data]); } catch (\Exception $e) { return $this->error($e->getMessage()); } } /** * GET /v1/client/dich-vu/{uuid} — service detail by UUID */ public function getClientServiceDetail(Request $request, string $uuid): JsonResponse { try { $service = Service::query() ->with(['product', 'addons', 'invoice', 'orderItem', 'vmware.ipAddress', 'vmware.os', 'vmware.vCenter']) ->where('uuid', $uuid) ->where('customer_id', $request->user()->id) ->first(); if (!$service) { return $this->notFound('Không tìm thấy dịch vụ.'); } $vmware = $service->vmware; $data = [ 'id' => $service->id, 'uuid' => $service->uuid, 'service_name' => $service->product?->name ?? $service->server_name, 'server_name' => $service->server_name, 'product_type' => $service->product?->product_type, 'billing_cycle' => $service->orderItem?->billing_cycle, 'status' => $service->status, 'status_text' => $service->status_label, 'auto_renew' => (bool) $service->auto_renew, 'started_at' => $this->isoDate($service->started_at), 'expires_at' => $this->isoDate($service->expires_at), 'note' => $service->note, 'invoice_id' => $service->invoice_id, 'invoice_number' => $service->invoice?->invoice_number, 'product' => $service->product ? [ 'id' => $service->product->id, 'uuid' => $service->product->uuid, 'name' => $service->product->name, 'product_type' => $service->product->product_type, 'specs' => [ 'cpu' => $service->product->cpu, 'ram' => $service->product->ram, 'disk' => $service->product->disk, 'bandwidth' => $service->product->bandwidth, ], ] : null, 'addons' => $service->addons->map(fn($a) => [ 'type' => $a->addon_type, 'value' => $a->addon_value, 'price' => $a->price, ])->all(), 'vmware' => $vmware ? [ 'id' => $vmware->id, 'name' => $vmware->name, 'hostname' => $vmware->hostname, 'ip_address' => $vmware->ipAddress?->ip, 'os_name' => $vmware->os?->name, 'status' => $vmware->status, ] : null, ]; return response()->json(['success' => true, 'data' => $data]); } catch (\Exception $e) { return $this->error($e->getMessage()); } } /** * PATCH /v1/client/dich-vu/{id}/auto-renew — toggle auto-renewal on a service */ public function updateClientServiceAutoRenew(Request $request, int $id): JsonResponse { try { $validated = $request->validate(['auto_renew' => ['required', 'boolean']]); $service = Service::query() ->where('id', $id) ->where('customer_id', $request->user()->id) ->first(); if (!$service) { return $this->notFound('Không tìm thấy dịch vụ.'); } $service->update(['auto_renew' => (bool) $validated['auto_renew']]); return response()->json([ 'success' => true, 'message' => 'Cập nhật tự động gia hạn thành công.', 'data' => ['id' => $service->id, 'auto_renew' => (bool) $service->auto_renew], ]); } catch (\Exception $e) { return $this->error($e->getMessage()); } } // ========================================================================= // 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 // ========================================================================= private function formatOrderSummary(Order $order): array { return [ 'id' => $order->id, 'order_num' => $order->order_num, 'status' => $order->status, 'status_text' => OrderStatus::from((int) $order->status)->label(), 'sub_total' => $order->sub_total, 'total' => $order->total, 'created_at' => $this->isoDate($order->created_at), ]; } private function formatInvoiceSummary(Invoice $invoice): array { return [ 'id' => $invoice->id, 'invoice_number' => $invoice->invoice_number, 'sub_total' => $invoice->sub_total, 'tax' => $invoice->tax, 'total' => $invoice->total, 'status' => $invoice->status, 'due_date' => $this->isoDate($invoice->due_date), ]; } private function formatOrderItems($items): array { return $items->map(fn($item) => [ 'id' => $item->id, 'billing_cycle' => $item->billing_cycle, 'months' => $item->months, 'server_name' => $item->server_name, 'os_id' => $item->os_id, 'price' => (float) $item->price, 'product' => $item->product ? [ 'id' => $item->product->id, 'uuid' => $item->product->uuid, 'name' => $item->product->name, 'product_type' => $item->product->product_type, 'specs' => [ 'cpu' => $item->product->cpu, 'ram' => $item->product->ram, 'disk' => $item->product->disk, 'bandwidth' => $item->product->bandwidth, ], ] : null, ])->all(); } private function formatOrderDetail(Order $order): array { $invoice = $order->invoice; return [ 'id' => $order->id, 'order_num' => $order->order_num, 'status' => $order->status, 'status_text' => OrderStatus::from((int) $order->status)->label(), 'total' => (float) $order->total, 'created_at' => $this->isoDate($order->created_at), 'customer' => [ 'id' => $order->customer?->id, 'name' => $order->customer?->fullname, 'email' => $order->customer?->email, ], 'invoice' => $invoice ? [ 'id' => $invoice->id, 'invoice_number' => $invoice->invoice_number, 'status' => $invoice->status, 'status_text' => InvoiceStatus::tryFrom((int) $invoice->status)?->label() ?? '-', 'sub_total' => $invoice->sub_total, 'tax' => $invoice->tax, 'total' => $invoice->total, 'due_date' => $this->isoDate($invoice->due_date), 'paid_at' => $this->isoDate($invoice->paid_at), ] : null, 'items' => $this->formatOrderItems($order->orderItems), ]; } private function formatInvoiceDetail(Invoice $invoice): array { $order = $invoice->order; $vmware = $order?->vmWare->first(); return [ 'id' => $invoice->id, 'invoice_number' => $invoice->invoice_number, 'invoice_type' => $invoice->invoice_type, 'status' => $invoice->status, 'status_text' => InvoiceStatus::tryFrom((int) $invoice->status)?->label() ?? '-', 'sub_total' => (float) $invoice->sub_total, 'discount_amount' => (float) ($invoice->discount_amount ?? 0), 'tax' => (float) $invoice->tax, 'total' => (float) $invoice->total, 'payment_method' => $invoice->payment_method, 'auto_renew' => (bool) $invoice->auto_renew, 'created_at' => $this->isoDate($invoice->created_at), 'due_date' => $this->isoDate($invoice->due_date), 'paid_at' => $this->isoDate($invoice->paid_at), 'customer' => $invoice->customer ? [ 'id' => $invoice->customer->id, 'name' => $invoice->customer->fullname, 'email' => $invoice->customer->email, 'address' => $invoice->customer->address, ] : null, 'order' => $order ? [ 'id' => $order->id, 'order_num' => $order->order_num, 'total' => (float) $order->total, ] : null, 'service' => ($firstItem = $order?->orderItems->first()) ? [ 'product_id' => $firstItem->product?->id, 'product_uuid' => $firstItem->product?->uuid, 'name' => $firstItem->product?->name, 'billing_cycle' => $firstItem->billing_cycle, 'server_name' => $firstItem->server_name, 'specs' => [ 'cpu' => $firstItem->product?->cpu, 'ram' => $firstItem->product?->ram, 'disk' => $firstItem->product?->disk, ], ] : null, 'items' => ($invoice->relationLoaded('invoiceItems') ? $invoice->invoiceItems : collect())->map(fn($item) => [ 'id' => $item->id, 'type' => $item->type, 'description' => $item->description, 'months' => $item->months, 'quantity' => $item->quantity, 'unit_price' => (float) $item->unit_price, 'total_price' => (float) $item->total_price, ])->all(), 'attachments' => ($invoice->relationLoaded('attachments') ? $invoice->attachments : collect())->map(fn($a) => [ 'id' => $a->id, 'filename' => $a->filename, 'url' => $a->url, 'mime_type' => $a->mime_type, ])->all(), 'vmware' => $vmware ? [ 'id' => $vmware->id, 'name' => $vmware->name, 'hostname' => $vmware->hostname, 'ip_address' => $vmware->ipAddress?->ip, 'os_name' => $vmware->os?->name, 'status' => $vmware->status, 'next_due_date' => $this->isoDate($vmware->next_due_date), ] : null, ]; } // ========================================================================= // Micro-helpers // ========================================================================= private function isoDate($value): ?string { if (empty($value)) { return null; } try { if ($value instanceof \DateTimeInterface) { return Carbon::instance($value)->toISOString(); } if (is_numeric($value)) { return Carbon::createFromTimestamp((int) $value)->toISOString(); } return Carbon::parse($value)->toISOString(); } catch (\Throwable) { return null; } } private function error(string $message, int $code = 400): JsonResponse { return response()->json(['success' => false, 'message' => $message], $code); } private function notFound(string $message): JsonResponse { return response()->json(['success' => false, 'message' => $message], 404); } }