where('product_type', ProductTypeEnums::DOMAIN->value) ->where('is_active', true) ->whereNull('deleted_at') ->firstOrFail(); $domainName = $this->normalizeDomain($data['domain_name']); $years = max(1, (int) ($data['years'] ?? 1)); $pricing = $this->findDomainPricing($domainName); $price = (int) $pricing['firstYear'] * $years; $months = $years * 12; return DB::transaction(function () use ($customer, $product, $domainName, $price, $months, $years) { $lockedCustomer = Customer::lockForUpdate()->find($customer->id); if ((int) $lockedCustomer->total < $price) { throw new \InvalidArgumentException('Số dư không đủ để đặt mua tên miền. Vui lòng nạp thêm tiền vào tài khoản.'); } if ($this->repo->hasPendingOrActiveDomainByName($domainName)) { throw new \InvalidArgumentException('Tên miền đang có đơn xử lý hoặc đã được kích hoạt. Vui lòng chọn tên miền khác.'); } if (DnsDomain::where('domain_name', $domainName)->exists()) { throw new \InvalidArgumentException('Tên miền đã được đăng ký trong hệ thống. Vui lòng chọn tên miền khác.'); } $lockedCustomer->decrement('total', $price); $order = Order::create([ 'order_num' => OrderHelper::generateOrderNumber(), 'customer_id' => $lockedCustomer->id, 'sub_total' => $price, 'discount_amount' => 0, 'total' => $price, 'status' => OrderStatus::PROCESSING->value, 'payment_method' => 'balance', 'client_ip' => request()?->ip(), 'note' => "Đăng ký tên miền: {$domainName} ({$years} năm)", ]); $orderItem = OrderItem::create([ 'order_id' => $order->id, 'product_id' => $product->id, 'domain_name' => $domainName, 'billing_cycle' => 'yearly', 'months' => $months, 'quantity' => 1, 'price' => $price, 'server_name' => $domainName, ]); $invoice = Invoice::create([ 'invoice_number' => OrderHelper::generateOrderNumber('INV'), 'customer_id' => $lockedCustomer->id, 'order_id' => $order->id, 'invoice_type' => 1, // new 'sub_total' => $price, 'discount_amount' => 0, 'tax' => 0, 'total' => $price, 'status' => InvoiceStatus::PAID->value, 'payment_method' => 'balance', 'paid_at' => now(), 'due_date' => now()->toDateString(), 'client_ip' => request()?->ip(), ]); InvoiceItem::create([ 'invoice_id' => $invoice->id, 'product_id' => $product->id, 'type' => 'domain', 'description' => "Đăng ký tên miền {$domainName} ({$years} năm)", 'months' => $months, 'quantity' => 1, 'unit_price' => $price, 'total_price' => $price, ]); Transaction::create([ 'uuid' => (string) Str::uuid(), 'public_code' => 'DOM-' . now()->format('Ymd') . '-' . strtoupper(Str::random(6)), 'trans' => $invoice->invoice_number, 'amount' => $price, 'customer_id' => $lockedCustomer->id, 'status' => TransactionStatus::COMPLETED->value, 'type' => 'invoice', 'note' => "Thanh toán đăng ký tên miền {$domainName} ({$years} năm) - {$invoice->invoice_number} (balance)", ]); CustomerActivityLog::create([ 'agent' => 'api', 'client_ip' => request()?->ip(), 'customer_id' => $lockedCustomer->id, 'description' => "Đặt mua tên miền {$domainName} ({$years} năm). Số tiền: " . number_format($price) . " VNĐ. Mã đơn hàng: {$order->order_num}.", ]); $service = Service::create([ 'customer_id' => $lockedCustomer->id, 'product_id' => $product->id, 'order_item_id' => $orderItem->id, 'invoice_id' => $invoice->id, 'server_name' => $domainName, 'status' => DomainProvisioningStatus::PENDING->value, 'auto_renew' => false, ]); return compact('order', 'invoice', 'service'); }); } // ========================================================================= // Admin: Confirm registration → create DNS domain entry // ========================================================================= public function confirmRegistration(Order $order, array $data): Service { return DB::transaction(function () use ($order, $data) { $lockedOrder = Order::lockForUpdate()->find($order->id); $orderItem = $lockedOrder->orderItems() ->whereHas('product', fn ($q) => $q->where('product_type', ProductTypeEnums::DOMAIN->value)) ->with('service') ->firstOrFail(); $service = $orderItem->service; if (!$service || $service->status !== DomainProvisioningStatus::PENDING->value) { throw new \InvalidArgumentException('Đơn hàng không ở trạng thái chờ xử lý. Không thể xác nhận đăng ký.'); } $domainName = strtolower(trim($orderItem->domain_name)); if (DnsDomain::where('domain_name', $domainName)->exists()) { throw new \InvalidArgumentException("Tên miền {$domainName} đã tồn tại trong DNS Manager."); } $dnsDomain = DnsDomain::create([ 'domain_name' => $domainName, 'dns_manager_server_id' => $data['dns_manager_server_id'] ?? 0, 'is_created' => 1, ]); $dnsDomain->customer()->syncWithoutDetaching([$lockedOrder->customer_id]); $nextStatus = !empty($data['kyc_url']) ? DomainProvisioningStatus::PENDING_KYC->value : DomainProvisioningStatus::REGISTRAR_CREATED->value; $serviceUpdate = [ 'status' => $nextStatus, 'registrar_reference' => $data['registrar_reference'] ?? null, 'kyc_url' => $data['kyc_url'] ?? null, ]; if (!empty($data['registered_at'])) { $serviceUpdate['started_at'] = $data['registered_at']; } if (!empty($data['expires_at'])) { $serviceUpdate['expires_at'] = $data['expires_at']; } $service->update($serviceUpdate); return $service->fresh(); }); } // ========================================================================= // Admin: Update KYC URL // ========================================================================= public function updateKycUrl(Order $order, string $kycUrl): Service { return DB::transaction(function () use ($order, $kycUrl) { $orderItem = $order->orderItems() ->whereHas('product', fn ($q) => $q->where('product_type', ProductTypeEnums::DOMAIN->value)) ->with('service') ->firstOrFail(); $service = $orderItem->service; if (!$service) { throw new \InvalidArgumentException('Không tìm thấy dịch vụ cho đơn hàng này.'); } $allowedStatuses = [ DomainProvisioningStatus::REGISTRAR_CREATED->value, DomainProvisioningStatus::PENDING_KYC->value, ]; if (!in_array($service->status, $allowedStatuses, true)) { throw new \InvalidArgumentException('Chỉ có thể cập nhật KYC khi tên miền đã được tạo tại nhà đăng ký hoặc đang chờ KYC.'); } $service->update([ 'kyc_url' => $kycUrl, 'status' => DomainProvisioningStatus::PENDING_KYC->value, ]); return $service->fresh(); }); } // ========================================================================= // Admin: Activate domain service // ========================================================================= public function activate(Order $order): Service { return DB::transaction(function () use ($order) { $orderItem = $order->orderItems() ->whereHas('product', fn ($q) => $q->where('product_type', ProductTypeEnums::DOMAIN->value)) ->with('service') ->firstOrFail(); $service = $orderItem->service; if (!$service) { throw new \InvalidArgumentException('Không tìm thấy dịch vụ cho đơn hàng này.'); } $activatableStatuses = [ DomainProvisioningStatus::REGISTRAR_CREATED->value, DomainProvisioningStatus::PENDING_KYC->value, ]; if (!in_array($service->status, $activatableStatuses, true)) { throw new \InvalidArgumentException('Chỉ có thể kích hoạt tên miền sau khi đã tạo tại nhà đăng ký (hoặc sau khi KYC).'); } $service->update([ 'status' => DomainProvisioningStatus::ACTIVE->value, 'started_at' => $service->started_at ?? now(), ]); $order->update(['status' => OrderStatus::ACTIVE->value]); return $service->fresh(); }); } // ========================================================================= // Admin: Reject and refund // ========================================================================= public function rejectAndRefund(Order $order, string $reason): Service { return DB::transaction(function () use ($order, $reason) { $lockedOrder = Order::lockForUpdate()->find($order->id); $orderItem = $lockedOrder->orderItems() ->whereHas('product', fn ($q) => $q->where('product_type', ProductTypeEnums::DOMAIN->value)) ->with('service') ->firstOrFail(); $service = $orderItem->service; if (!$service || $service->status !== DomainProvisioningStatus::PENDING->value) { throw new \InvalidArgumentException('Chỉ có thể từ chối đơn hàng đang chờ xử lý.'); } // Anti-double-refund guard if ($service->refunded_at !== null) { throw new \InvalidArgumentException('Đơn hàng này đã được hoàn tiền trước đó.'); } $invoice = $lockedOrder->invoice; if (!$invoice || (int) $invoice->status !== InvoiceStatus::PAID->value) { throw new \InvalidArgumentException('Không tìm thấy hóa đơn đã thanh toán.'); } $refundAmount = (int) $invoice->total; // snapshot — NOT recalculated $lockedCustomer = Customer::lockForUpdate()->find($lockedOrder->customer_id); if (!$lockedCustomer) { throw new \InvalidArgumentException('Không tìm thấy khách hàng.'); } $lockedCustomer->increment('total', $refundAmount); $invoice->update(['status' => InvoiceStatus::REFUNDED->value]); $lockedOrder->update(['status' => OrderStatus::CANCELLED->value]); $service->update([ 'status' => DomainProvisioningStatus::REFUNDED->value, 'rejection_reason' => $reason, 'refunded_at' => now(), 'cancelled_at' => now(), ]); Transaction::create([ 'uuid' => (string) Str::uuid(), 'public_code' => 'DOM-REF-' . now()->format('Ymd') . '-' . strtoupper(Str::random(6)), 'trans' => 'REF-' . $invoice->invoice_number, 'amount' => $refundAmount, 'customer_id' => $lockedCustomer->id, 'status' => TransactionStatus::COMPLETED->value, 'type' => 'refund', 'note' => "Hoàn tiền đăng ký tên miền {$orderItem->domain_name}: {$reason} (balance)", ]); CustomerActivityLog::create([ 'agent' => 'api', 'client_ip' => request()?->ip(), 'customer_id' => $lockedCustomer->id, 'description' => "Hoàn tiền đăng ký tên miền {$orderItem->domain_name}. Số tiền: " . number_format($refundAmount) . " VNĐ. Lý do: {$reason}.", ]); return $service->fresh(); }); } // ========================================================================= // Private helpers // ========================================================================= /** * Find pricing for the given domain using longest-match TLD. * TLDs in settings are stored with a leading dot (e.g. ".com.vn"). * * @throws \InvalidArgumentException if no matching TLD found */ private function findDomainPricing(string $domain): array { $setting = Setting::where('key', 'domain_pricing')->first(); if (!$setting || empty($setting->value)) { throw new \InvalidArgumentException('Không tìm thấy cấu hình giá tên miền.'); } $pricingData = is_string($setting->value) ? json_decode($setting->value, true) : (array) $setting->value; $domains = $pricingData['domains'] ?? []; // Longest-match: sort TLDs by length descending so ".com.vn" beats ".vn" usort($domains, fn ($a, $b) => strlen($b['tld']) - strlen($a['tld'])); foreach ($domains as $entry) { $tld = ltrim($entry['tld'], '.'); if (str_ends_with($domain, '.' . $tld)) { return $entry; } } throw new \InvalidArgumentException("Tên miền không được hỗ trợ. Vui lòng chọn đuôi tên miền có trong danh sách."); } /** * Normalize: lowercase, trim, strip protocol and path. * "HTTPS://My-Site.COM.VN/path" → "my-site.com.vn" */ private function normalizeDomain(string $domain): string { $domain = strtolower(trim($domain)); // Strip protocol $domain = preg_replace('#^https?://#', '', $domain); // Strip path/query after first slash $domain = explode('/', $domain)[0]; // Strip www. prefix $domain = preg_replace('#^www\.#', '', $domain); return $domain; } }