orderRepo->hasPendingOrder($customer->id)) { throw new Exception('Bạn có đơn hàng chưa thanh toán. Vui lòng thanh toán trước khi tạo đơn mới.'); } $product = $this->productRepo->find($data['product_id']); if (!$product) { throw new Exception('Sản phẩm không tồn tại.'); } $basePrice = $this->productRepo->getPricing($product->id, $data['billing_cycle']); if (is_null($basePrice)) { throw new Exception('Không tìm thấy giá cho chu kỳ thanh toán này.'); } // Resolve addons → enriched meta with price & subtotal $addonsMeta = $this->resolveAddons($data['addons'] ?? []); $addonsPrice = array_sum(array_column($addonsMeta, 'subtotal')); $total = $basePrice + $addonsPrice; if ($customer->total < $total) { throw new Exception(sprintf( 'Số dư không đủ. Hiện có: %s VNĐ, cần: %s VNĐ.', number_format($customer->total), number_format($total) )); } return DB::transaction(function () use ($data, $customer, $product, $basePrice, $total, $addonsMeta) { $order = $this->orderRepo->create([ 'order_num' => OrderHelper::generateOrderNumber(), 'customer_id' => $customer->id, 'sub_total' => $basePrice, 'discount_amount' => 0, 'total' => $total, 'status' => OrderStatus::PENDING->value, 'client_ip' => request()?->ip(), ]); $this->orderRepo->createItem($order->id, [ 'product_id' => $product->id, 'billing_cycle' => $data['billing_cycle'], 'months' => $this->cycleToMonths($data['billing_cycle']), 'server_name' => $data['server_name'] ?? null, 'os_id' => $data['os_id'] ?? null, 'quantity' => 1, 'price' => $basePrice, 'addons' => $addonsMeta, // serialised to addons_meta JSON ]); // Reload items so invoice repo can iterate them $order->load('orderItems.product'); $invoice = $this->invoiceRepo->createFromOrder($order); return ['order' => $order, 'invoice' => $invoice]; }); } // ========================================================================= // pay — Step 2: Customer pays the pending order // ========================================================================= /** * Debit customer balance → mark Order+Invoice paid → provision Service records. * * @throws Exception */ public function pay(Order $order, string $method): Order { if (in_array($order->status, [OrderStatus::ACTIVE->value, OrderStatus::PROCESSING->value], true)) { throw new Exception('Đơn hàng đã được thanh toán hoặc đang xử lý.'); } return DB::transaction(function () use ($order, $method) { $customer = $order->customer; if ($customer->total < $order->total) { throw new Exception(sprintf( 'Số dư không đủ. Hiện có: %s VNĐ, cần: %s VNĐ.', number_format($customer->total), number_format($order->total) )); } // Allow re-paying a cancelled order if ($order->status === OrderStatus::CANCELLED->value) { $order->update(['status' => OrderStatus::PENDING->value]); } if (! $order->invoice) { throw new Exception('Không tìm thấy hóa đơn của đơn hàng.'); } $this->invoicePaymentService->payWithBalance($order->invoice, $customer, $method); $processedOrder = $order->fresh(['orderItems', 'invoice', 'customer']); $this->dispatchAdminNotification($processedOrder, $customer); return $processedOrder; }); } // ========================================================================= // Private — provisionService // ========================================================================= private function provisionService(Order $order, OrderItem $item, $customer): void { $invoice = $order->invoice; $months = $item->months ?: $this->cycleToMonths($item->billing_cycle); $expiresAt = now()->addMonths($months); $service = Service::create([ 'customer_id' => $customer->id, 'product_id' => $item->product_id, 'order_item_id' => $item->id, 'invoice_id' => $invoice?->id, 'server_name' => $item->server_name, 'status' => 'pending', 'auto_renew' => false, 'started_at' => now(), 'expires_at' => $expiresAt, ]); $this->attachServiceAddons($service, $item); // VPS: requires product + os_id → dispatch VM creation job if ($item->product_id && $item->os_id) { $this->dispatchVmJob($order, $item, $service, $customer); } else { // Hosting / Domain / non-VM services activate immediately $service->update(['status' => 'active']); } } // ------------------------------------------------------------------------- /** * Create ServiceAddon rows from: * 1. Base product specs (cpu, ram, disk, bandwidth) * 2. Customer's configurable addon selections stored in addons_meta */ private function attachServiceAddons(Service $service, OrderItem $item): void { // 1. Base product specs $product = $item->product; if ($product) { foreach (['cpu' => $product->cpu, 'ram' => $product->ram, 'disk' => $product->disk, 'bandwidth' => $product->bandwidth] as $type => $value) { if ($value !== null && $value !== '') { ServiceAddon::create([ 'service_id' => $service->id, 'addon_type' => $type, 'addon_value' => (string) $value, 'price' => 0, ]); } } } // 2. Customer-selected addons $addonsMeta = is_array($item->addons_meta) ? $item->addons_meta : json_decode($item->addons_meta ?? '[]', true); foreach ((array) $addonsMeta as $meta) { $addon = Addon::find($meta['addon_id'] ?? null); if (!$addon) { continue; } ServiceAddon::create([ 'service_id' => $service->id, 'addon_type' => strtolower((string) $addon->code), 'addon_value' => (string) ($meta['qty'] ?? 1), 'price' => (int) ($meta['price'] ?? $addon->price), ]); } } // ------------------------------------------------------------------------- private function dispatchVmJob(Order $order, OrderItem $item, Service $service, $customer): void { try { if (VmProvisionLock::isRunning($customer->id)) { Log::warning('dispatchVmJob: blocked — customer already has a running VM provision', [ 'customer_id' => $customer->id, 'order_item_id' => $item->id, ]); return; } dispatch(new CreateVirtualMachineJob($item)); Log::info('dispatchVmJob: CreateVirtualMachineJob dispatched', [ 'order_id' => $order->id, 'service_id' => $service->id, 'order_item_id' => $item->id, ]); } catch (Exception $e) { Log::error('dispatchVmJob: exception', [ 'order_id' => $order->id, 'error' => $e->getMessage(), ]); } } // ========================================================================= // Helpers // ========================================================================= private function cycleToMonths(?string $cycle): int { return match ($cycle) { 'monthly' => 1, 'quarterly' => 3, 'semi_annually' => 6, 'annually' => 12, 'biennially' => 24, 'triennially' => 36, default => 1, }; } private function dispatchAdminNotification(Order $order, $customer): void { try { $item = $order->orderItems->first(); $product = $item?->product; $productType = $product?->category?->name ?? $product?->type ?? 'N/A'; AdminOrderNotificationJob::dispatch([ 'order_num' => $order->order_num, 'customer_name' => trim($customer->firstname . ' ' . $customer->lastname), 'customer_email' => $customer->email, 'client_ip' => $order->client_ip ?? request()?->ip(), 'product_name' => $product?->name ?? 'N/A', 'product_type' => $productType, 'billing_cycle' => $item?->billing_cycle ?? 'N/A', 'total' => $order->total, 'created_at' => now()->format('d/m/Y H:i:s'), ]); } catch (Exception $e) { Log::error('OrderService: failed to dispatch admin notification', [ 'order_id' => $order->id, 'error' => $e->getMessage(), ]); } } /** * Enrich raw addon entries with resolved price & subtotal. * * Input: [['addon_id' => 1, 'qty' => 2], ...] * Output: [['addon_id' => 1, 'qty' => 2, 'price' => 50000, 'subtotal' => 100000], ...] */ private function resolveAddons(array $raw): array { $resolved = []; foreach ($raw as $entry) { $addon = Addon::find($entry['addon_id'] ?? null); if (!$addon) { continue; } $qty = max(1, (int) ($entry['qty'] ?? 1)); $resolved[] = [ 'addon_id' => $addon->id, 'code' => strtolower((string) $addon->code), 'name' => $addon->name, 'qty' => $qty, 'price' => $addon->price, 'subtotal' => $addon->price * $qty, ]; } return $resolved; } }