orderRepo->hasPendingOrder($customer->id)) { throw new \InvalidArgumentException('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' => $product, 'base_price' => $basePrice, 'addons_meta' => $addonsMeta, 'sub_total' => $subTotal] = $this->quote($data); $couponCode = $data['coupon_code'] ?? null; $this->assertInfrastructureAvailable([$product]); $expectedTotal = $couponCode ? $subTotal - $this->couponService->preview($couponCode, $customer, $product, $data['billing_cycle'], $subTotal)['discount'] : $subTotal; if ($customer->total < $expectedTotal) { throw new \InvalidArgumentException(sprintf( 'Số dư không đủ. Hiện có: %s VNĐ, cần: %s VNĐ.', number_format($customer->total), number_format($expectedTotal) )); } return DB::transaction(function () use ($data, $customer, $product, $basePrice, $subTotal, $addonsMeta, $couponCode) { $applied = $couponCode ? $this->couponService->lockAndApply($couponCode, $customer, $product, $data['billing_cycle'], $subTotal) : null; $discount = $applied['discount'] ?? 0; $order = $this->orderRepo->create([ 'order_num' => OrderHelper::generateOrderNumber(), 'customer_id' => $customer->id, 'sub_total' => $subTotal, 'discount_amount' => $discount, 'coupon_id' => $applied['coupon']->id ?? null, 'coupon_code' => $applied['coupon']->code ?? null, 'total' => $subTotal - $discount, 'status' => OrderStatus::PENDING->value, 'client_ip' => request()?->ip(), ]); if ($applied) { $this->couponService->record($applied['coupon'], $order, $discount); } $this->orderRepo->createItem($order->id, [ 'product_id' => $product->id, 'billing_cycle' => $data['billing_cycle'], 'months' => OrderHelper::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]; }); } private function assertInfrastructureAvailable(iterable $products): void { $needsVm = collect($products)->contains(fn ($product) => in_array( ProductTypeEnums::tryFrom($product?->category?->type ?? ''), [ProductTypeEnums::VPS, ProductTypeEnums::CLOUD], true )); if ($needsVm && !$this->vmIpReservation->hasFreeIp()) { throw new \InvalidArgumentException('Hiện tại hạ tầng đang đầy vui lòng liên hệ quản trị viên'); } $this->assertHostingServerAvailable($products); } private function assertHostingServerAvailable(iterable $products): void { foreach ($products as $product) { if ($product?->product_type !== 'hosting' || ! $product->servicePlan) { continue; } if (! $this->hostingServers->hasHealthyServer($product->servicePlan)) { throw new \InvalidArgumentException('Dịch vụ hosting đang bảo trì, vui lòng thử lại sau hoặc liên hệ quản trị viên.'); } } } /** * Giá niêm yết (chu kỳ + addon) tính phía server; nguồn duy nhất cho create() và preview mã giảm giá. * * @return array{product: Product, base_price: int, addons_meta: array, sub_total: int} * @throws \InvalidArgumentException */ public function quote(array $data): array { $product = $this->productRepo->find($data['product_id']); if (!$product) { throw new \InvalidArgumentException('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 \InvalidArgumentException('Không tìm thấy giá cho chu kỳ thanh toán này.'); } $addonsMeta = $this->resolveAddons($data['addons'] ?? [], $product); return [ 'product' => $product, 'base_price' => $basePrice, 'addons_meta' => $addonsMeta, 'sub_total' => $basePrice + array_sum(array_column($addonsMeta, 'subtotal')), ]; } // ========================================================================= // pay — Step 2: Customer pays the pending order // ========================================================================= /** * Debit customer balance → mark Order+Invoice paid → provision Service records. * * @throws \InvalidArgumentException */ public function pay(Order $order, string $method): Order { if (in_array($order->status, [OrderStatus::ACTIVE->value, OrderStatus::PROCESSING->value], true)) { throw new \InvalidArgumentException('Đơn hàng đã được thanh toán hoặc đang xử lý.'); } $this->assertInfrastructureAvailable($order->orderItems->map->product); $processedOrder = DB::transaction(function () use ($order, $method) { $customer = $order->customer; if ($customer->total < $order->total) { throw new \InvalidArgumentException(sprintf( 'Số dư không đủ. Hiện có: %s VNĐ, cần: %s VNĐ.', number_format($customer->total), number_format($order->total) )); } $invoice = $order->invoice; if (! $invoice) { throw new \InvalidArgumentException('Không tìm thấy hóa đơn của đơn hàng.'); } if ((int) $invoice->status === InvoiceStatus::PAID->value) { throw new \InvalidArgumentException('Đơn hàng đã được thanh toán hoặc đang xử lý.'); } if ($order->status === OrderStatus::CANCELLED->value) { $order->update(['status' => OrderStatus::PENDING->value]); } $paidInvoice = $this->invoicePaymentService->payWithBalance($invoice, $customer, $method); $processedOrder = $order->fresh(['orderItems', 'invoice', 'customer']); return $processedOrder; }); $this->adminPaymentNotifier->notify($processedOrder->invoice, (int) $processedOrder->total, 'order'); return $processedOrder; } // ========================================================================= // Helpers // ========================================================================= /** * Enrich raw addon entries with resolved price & subtotal. * * Input: [['addon_id' => 1, 'qty' => 2], ...] * Output: [['addon_id' => 1, 'qty' => 2, 'price' => 50000, 'subtotal' => 100000], ...] * * @throws \Illuminate\Validation\ValidationException */ private function resolveAddons(array $raw, Product $product): array { $activeAddons = $product->addons()->where('status', true)->get()->keyBy('id'); return AddonQuantityValidator::resolve($raw, $activeAddons); } }