diff --git a/app/Enums/Domain/DomainProvisioningStatus.php b/app/Enums/Domain/DomainProvisioningStatus.php new file mode 100644 index 0000000..df6bfad --- /dev/null +++ b/app/Enums/Domain/DomainProvisioningStatus.php @@ -0,0 +1,38 @@ + 'Chờ xử lý', + self::REGISTRAR_CREATED => 'Đã tạo tại nhà đăng ký', + self::PENDING_KYC => 'Chờ xác minh KYC', + self::ACTIVE => 'Đang hoạt động', + self::CANCELLED => 'Đã hủy', + self::REFUNDED => 'Đã hoàn tiền', + }; + } + + /** Valid forward transitions. */ + public function canTransitionTo(self $next): bool + { + return match ($this) { + self::PENDING => in_array($next, [self::REGISTRAR_CREATED, self::PENDING_KYC, self::REFUNDED]), + self::REGISTRAR_CREATED => in_array($next, [self::PENDING_KYC, self::ACTIVE, self::CANCELLED]), + self::PENDING_KYC => in_array($next, [self::ACTIVE, self::CANCELLED]), + self::ACTIVE => in_array($next, [self::CANCELLED]), + self::CANCELLED, + self::REFUNDED => false, + }; + } +} diff --git a/app/Http/Controllers/Api/Admin/Customer/CustomerController.php b/app/Http/Controllers/Api/Admin/Customer/CustomerController.php index 21c68a0..6aba7ae 100644 --- a/app/Http/Controllers/Api/Admin/Customer/CustomerController.php +++ b/app/Http/Controllers/Api/Admin/Customer/CustomerController.php @@ -5,10 +5,11 @@ namespace App\Http\Controllers\Api\Admin\Customer; use App\Http\Controllers\Controller; use App\Http\Requests\CreateCustomerRequest; use App\Http\Resources\Api\Admin\CustomerResource; -use App\Jobs\Mail\PasswordRecoverJob; +use App\Mail\Customer\ManualCustomerEmail; use App\Models\Customer; use App\Models\Invoice; use App\Models\Order; +use App\Models\Template; use App\Models\Transaction; use App\Models\Log; use App\Repositories\Customer\CustomerRepositoryInterface; @@ -25,6 +26,7 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Mail; use Illuminate\Support\Str; use App\Enums\Invoice\InvoiceStatus; use App\Enums\Order\OrderStatus; @@ -233,11 +235,31 @@ class CustomerController extends Controller public function trash(Request $request): JsonResponse { - $query = $this->customerRepository->onlyTrashed([], ['hosting', 'vps', 'domain']); + $perPage = min((int) $request->get('per_page', 15), 500); + + $query = Customer::onlyTrashed() + ->with(['hosting', 'vps', 'domain']); + + if ($filter = $request->get('filter')) { + $query->where(function ($q) use ($filter) { + $filter = trim($filter); + $q->where('fullname', 'LIKE', "%{$filter}%") + ->orWhere('email', 'LIKE', "%{$filter}%") + ->orWhere('phone', 'LIKE', "%{$filter}%"); + }); + } + + $paginator = $query->paginate($perPage); return response()->json([ 'error' => false, - 'data' => CustomerResource::collection($query->get()) + 'data' => [ + 'items' => CustomerResource::collection($paginator), + 'total' => $paginator->total(), + 'per_page' => $paginator->perPage(), + 'current_page' => $paginator->currentPage(), + 'last_page' => $paginator->lastPage(), + ], ]); } @@ -462,6 +484,105 @@ class CustomerController extends Controller } } + public function sendEmail(Request $request, string $uuid): JsonResponse + { + $data = $request->validate([ + 'template_id' => 'nullable|integer|exists:template,id', + 'subject' => 'required_without:template_id|nullable|string|max:255', + 'message' => 'required_without:template_id|nullable|string', + 'to' => 'nullable|string|max:1000', + 'cc' => 'nullable|string|max:1000', + 'bcc' => 'nullable|string|max:1000', + ]); + + $customer = $this->customerRepository->findBy('uuid', $uuid); + + if (!$customer) { + return response()->json([ + 'error' => true, + 'message' => "Can't find customer by UUID #" . $uuid, + ], 404); + } + + $template = null; + $subject = trim((string) ($data['subject'] ?? '')); + $message = trim((string) ($data['message'] ?? '')); + + if (!empty($data['template_id'])) { + $template = Template::query() + ->whereKey($data['template_id']) + ->where('status', 1) + ->first(); + + if (!$template) { + return response()->json([ + 'error' => true, + 'message' => 'Template không tồn tại hoặc đang bị tắt.', + ], 422); + } + + $subject = trim((string) $template->subject); + $message = trim((string) $template->message); + } + + if ($subject === '' || $message === '') { + return response()->json([ + 'error' => true, + 'message' => 'Subject và content không được để trống.', + ], 422); + } + + $to = $this->parseEmailRecipients($data['to'] ?? null); + if ($to === []) { + $to = [$customer->email]; + } + + $cc = $this->parseEmailRecipients($data['cc'] ?? null); + $bcc = $this->parseEmailRecipients($data['bcc'] ?? null); + + if ($to === []) { + return response()->json([ + 'error' => true, + 'message' => 'Không có địa chỉ email nhận hợp lệ.', + ], 422); + } + + try { + $mailer = Mail::to($to); + if ($cc !== []) { + $mailer->cc($cc); + } + if ($bcc !== []) { + $mailer->bcc($bcc); + } + + $mailer->send(new ManualCustomerEmail($subject, $message)); + + $admin = Auth::guard('admin')->user(); + Log::create([ + 'agent' => 'admin', + 'client_ip' => $request->ip(), + 'description' => sprintf( + '%s gửi email cho customer %s%s', + $admin->username ?? $admin->email ?? 'Admin', + $customer->email, + $template ? sprintf(' bằng template #%s', $template->id) : '', + ), + 'customer_id' => $customer->id, + ]); + + return response()->json([ + 'error' => false, + 'message' => 'Đã gửi email cho khách hàng.', + ]); + } catch (\Throwable $e) { + return response()->json([ + 'error' => true, + 'message' => $e->getMessage(), + ], 500); + } + } + public function addCredit(Request $request, string $uuid): JsonResponse { $request->validate([ @@ -510,6 +631,20 @@ class CustomerController extends Controller } } + private function parseEmailRecipients(?string $value): array + { + if (!$value) { + return []; + } + + return collect(preg_split('/[,;]+/', $value) ?: []) + ->map(fn($email) => trim((string) $email)) + ->filter(fn($email) => $email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL)) + ->unique() + ->values() + ->all(); + } + public function addTotal(Request $request, string $uuid): JsonResponse { $request->validate([ diff --git a/app/Http/Controllers/Api/Admin/Domain/AdminDomainOrderController.php b/app/Http/Controllers/Api/Admin/Domain/AdminDomainOrderController.php new file mode 100644 index 0000000..2d763c5 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/Domain/AdminDomainOrderController.php @@ -0,0 +1,291 @@ +repo->countPending(); + + return response()->json(['error' => false, 'data' => ['count' => $count]]); + } + + public function index(Request $request): JsonResponse + { + $orders = $this->repo->getDomainOrders( + $request->only(['status', 'customer', 'domain', 'from', 'to', 'per_page']) + ); + + $items = collect($orders->items())->map(fn ($order) => $this->formatOrder($order)); + + return response()->json([ + 'error' => false, + 'data' => [ + 'items' => $items, + 'total' => $orders->total(), + 'per_page' => $orders->perPage(), + 'current_page' => $orders->currentPage(), + 'last_page' => $orders->lastPage(), + ], + ]); + } + + public function show(int $id): JsonResponse + { + $order = $this->repo->findDomainOrder($id); + + if (!$order) { + return response()->json([ + 'error' => true, + 'message' => 'Không tìm thấy đơn hàng domain.', + ], 404); + } + + return response()->json([ + 'error' => false, + 'data' => $this->formatOrder($order), + ]); + } + + public function confirmRegistration(ConfirmRegistrationRequest $request, int $id): JsonResponse + { + $order = $this->repo->findDomainOrder($id); + + if (!$order) { + return response()->json([ + 'error' => true, + 'message' => 'Không tìm thấy đơn hàng domain.', + ], 404); + } + + try { + $service = $this->service->confirmRegistration($order, $request->validated()); + + return response()->json([ + 'error' => false, + 'message' => 'Xác nhận đăng ký tên miền thành công.', + 'data' => $this->formatService($service), + ]); + } catch (\InvalidArgumentException $e) { + return response()->json(['error' => true, 'message' => $e->getMessage()], 422); + } catch (\Throwable $e) { + Log::error('AdminDomainOrderController@confirmRegistration', ['error' => $e->getMessage(), 'id' => $id]); + + return response()->json(['error' => true, 'message' => 'Xác nhận thất bại. Vui lòng thử lại.'], 500); + } + } + + public function updateKyc(UpdateKycUrlRequest $request, int $id): JsonResponse + { + $order = $this->repo->findDomainOrder($id); + + if (!$order) { + return response()->json([ + 'error' => true, + 'message' => 'Không tìm thấy đơn hàng domain.', + ], 404); + } + + try { + $service = $this->service->updateKycUrl($order, $request->validated()['kyc_url']); + + return response()->json([ + 'error' => false, + 'message' => 'Cập nhật link KYC thành công.', + 'data' => $this->formatService($service), + ]); + } catch (\InvalidArgumentException $e) { + return response()->json(['error' => true, 'message' => $e->getMessage()], 422); + } catch (\Throwable $e) { + Log::error('AdminDomainOrderController@updateKyc', ['error' => $e->getMessage(), 'id' => $id]); + + return response()->json(['error' => true, 'message' => 'Cập nhật KYC thất bại. Vui lòng thử lại.'], 500); + } + } + + public function activate(int $id): JsonResponse + { + $order = $this->repo->findDomainOrder($id); + + if (!$order) { + return response()->json([ + 'error' => true, + 'message' => 'Không tìm thấy đơn hàng domain.', + ], 404); + } + + try { + $service = $this->service->activate($order); + + $this->notifyCustomerDomainStatus($order, 'approved'); + + return response()->json([ + 'error' => false, + 'message' => 'Kích hoạt dịch vụ tên miền thành công.', + 'data' => $this->formatService($service), + ]); + } catch (\InvalidArgumentException $e) { + return response()->json(['error' => true, 'message' => $e->getMessage()], 422); + } catch (\Throwable $e) { + Log::error('AdminDomainOrderController@activate', ['error' => $e->getMessage(), 'id' => $id]); + + return response()->json(['error' => true, 'message' => 'Kích hoạt thất bại. Vui lòng thử lại.'], 500); + } + } + + public function rejectAndRefund(RejectAndRefundRequest $request, int $id): JsonResponse + { + $order = $this->repo->findDomainOrder($id); + + if (!$order) { + return response()->json([ + 'error' => true, + 'message' => 'Không tìm thấy đơn hàng domain.', + ], 404); + } + + try { + $service = $this->service->rejectAndRefund($order, $request->validated()['reason']); + + $this->notifyCustomerDomainStatus($order, 'rejected', $request->validated()['reason']); + + return response()->json([ + 'error' => false, + 'message' => 'Từ chối và hoàn tiền thành công.', + 'data' => $this->formatService($service), + ]); + } catch (\InvalidArgumentException $e) { + return response()->json(['error' => true, 'message' => $e->getMessage()], 422); + } catch (\Throwable $e) { + Log::error('AdminDomainOrderController@rejectAndRefund', ['error' => $e->getMessage(), 'id' => $id]); + + return response()->json(['error' => true, 'message' => 'Hoàn tiền thất bại. Vui lòng thử lại.'], 500); + } + } + + // ========================================================================= + // Private helpers + // ========================================================================= + + private function notifyCustomerDomainStatus(Order $order, string $status, string $reason = ''): void + { + $customer = $order->customer; + $domainName = $order->orderItems->first()?->domain_name ?? ''; + $orderNum = $order->order_num; + + if (!$customer) { + return; + } + + [$title, $body, $templateName] = $status === 'approved' + ? ["Tên miền {$domainName} đã được duyệt", "Tên miền {$domainName} (#{$orderNum}) đã được kích hoạt thành công.", 'Domain Order Approved'] + : ["Tên miền {$domainName} bị từ chối", "Tên miền {$domainName} (#{$orderNum}) đã bị từ chối. Lý do: {$reason}. Số dư đã được hoàn lại.", 'Domain Order Rejected']; + + Notification::notifyCustomer($customer, [ + 'type' => 'domain_order_' . $status, + 'title' => $title, + 'body' => $body, + 'link' => '/don-hang', + ]); + + try { + $setting = Setting::where('key', 'setting')->first(); + $settingValue = $setting ? json_decode((string) $setting->value, true) : []; + + $template = Template::where('name', $templateName)->first(); + if (!$template) { + return; + } + + Mail::to($customer->email)->send(new DomainOrderStatusMail($template, [ + 'company_name' => $settingValue['company'] ?? config('app.name'), + 'company_logo' => $settingValue['logo'] ?? '', + 'domain_name' => $domainName, + 'client_name' => $customer->fullname, + 'order_num' => $orderNum, + 'reason' => $reason, + ])); + } catch (\Throwable $e) { + Log::error('AdminDomainOrderController: không thể gửi email khách hàng: ' . $e->getMessage()); + } + } + + // ========================================================================= + // Private formatters + // ========================================================================= + + private function formatOrder(Order $order): array + { + $item = $order->orderItems->first(); + $service = $item?->service; + + return [ + 'id' => $order->id, + 'order_num' => $order->order_num, + 'status' => $order->status, + 'total' => $order->total, + 'payment_method' => $order->payment_method, + 'created_at' => $order->created_at?->toIso8601String(), + 'customer' => $order->customer ? [ + 'id' => $order->customer->id, + 'uuid' => $order->customer->uuid, + 'fullname' => $order->customer->fullname, + 'email' => $order->customer->email, + ] : null, + 'domain_name' => $item?->domain_name, + 'months' => $item?->months, + 'invoice' => $order->invoice ? [ + 'id' => $order->invoice->id, + 'invoice_number' => $order->invoice->invoice_number, + 'status' => $order->invoice->status, + 'total' => $order->invoice->total, + 'paid_at' => $order->invoice->paid_at?->toIso8601String(), + ] : null, + 'service' => $service ? $this->formatService($service) : null, + ]; + } + + private function formatService(Service $service): array + { + return [ + 'id' => $service->id, + 'uuid' => $service->uuid, + 'status' => $service->status, + 'server_name' => $service->server_name, + 'registrar_reference' => $service->registrar_reference, + 'kyc_url' => $service->kyc_url, + 'rejection_reason' => $service->rejection_reason, + 'auto_renew' => $service->auto_renew, + 'started_at' => $service->started_at?->toIso8601String(), + 'expires_at' => $service->expires_at?->toIso8601String(), + 'refunded_at' => $service->refunded_at?->toIso8601String(), + 'cancelled_at' => $service->cancelled_at?->toIso8601String(), + 'created_at' => $service->created_at?->toIso8601String(), + ]; + } +} diff --git a/app/Http/Controllers/Api/Admin/Domain/AdminDomainPricingController.php b/app/Http/Controllers/Api/Admin/Domain/AdminDomainPricingController.php new file mode 100644 index 0000000..f1c492d --- /dev/null +++ b/app/Http/Controllers/Api/Admin/Domain/AdminDomainPricingController.php @@ -0,0 +1,137 @@ + [ + ['tld' => '.vn', 'setupFee' => 'Miễn phí', 'firstYear' => 400000, 'renewal' => 400000], + ['tld' => '.com.vn', 'setupFee' => 'Miễn phí', 'firstYear' => 300000, 'renewal' => 300000], + ['tld' => '.net.vn', 'setupFee' => 'Miễn phí', 'firstYear' => 300000, 'renewal' => 300000], + ['tld' => '.org.vn', 'setupFee' => 'Miễn phí', 'firstYear' => 300000, 'renewal' => 300000], + ['tld' => '.com', 'setupFee' => 'Miễn phí', 'firstYear' => 320000, 'renewal' => 340000], + ['tld' => '.net', 'setupFee' => 'Miễn phí', 'firstYear' => 369000, 'renewal' => 409000], + ['tld' => '.org', 'setupFee' => 'Miễn phí', 'firstYear' => 339000, 'renewal' => 369000], + ['tld' => '.biz', 'setupFee' => 'Miễn phí', 'firstYear' => 399000, 'renewal' => 409000], + ['tld' => '.info', 'setupFee' => 'Miễn phí', 'firstYear' => 490000, 'renewal' => 509000], + ['tld' => '.pro', 'setupFee' => 'Miễn phí', 'firstYear' => 469000, 'renewal' => 489000], + ['tld' => '.name', 'setupFee' => 'Miễn phí', 'firstYear' => 259000, 'renewal' => 259000], + ['tld' => '.ws', 'setupFee' => 'Miễn phí', 'firstYear' => 909000, 'renewal' => 909000], + ['tld' => '.tv', 'setupFee' => 'Miễn phí', 'firstYear' => 765000, 'renewal' => 819000], + ['tld' => '.mobi', 'setupFee' => 'Miễn phí', 'firstYear' => 589000, 'renewal' => 589000], + ['tld' => '.uk', 'setupFee' => 'Miễn phí', 'firstYear' => 537000, 'renewal' => 537000], + ['tld' => '.asia', 'setupFee' => 'Miễn phí', 'firstYear' => 380000, 'renewal' => 452000], + ['tld' => '.me', 'setupFee' => 'Miễn phí', 'firstYear' => 1029000, 'renewal' => 1029000], + ['tld' => '.us', 'setupFee' => 'Miễn phí', 'firstYear' => 323000, 'renewal' => 323000], + ], + 'services' => [ + ['name' => 'Chứng thực Rapid SSL', 'setupFee' => 230000, 'maintenance' => 750000, 'note' => '01 Domain'], + ['name' => 'Chứng thực Quick SSL', 'setupFee' => 230000, 'maintenance' => 2300000, 'note' => '01 Domain'], + ['name' => 'Chứng thực Wildcard SSL', 'setupFee' => 230000, 'maintenance' => 3300000, 'note' => 'Unlimited Subdomain'], + ['name' => 'Chứng thực True BusinessID', 'setupFee' => 540000, 'maintenance' => 3900000, 'note' => '01 Domain'], + ['name' => 'Chứng thực True BusinessID WildCard', 'setupFee' => 900000, 'maintenance' => 15000000, 'note' => 'Unlimited Subdomain'], + ['name' => 'Đăng ký DNS riêng', 'setupFee' => 200000, 'maintenance' => 'Miễn phí', 'note' => "dns1.tenmiencuaban.com\ndns2.tenmiencuaban.com"], + ['name' => 'Bảo mật thông tin tên miền', 'setupFee' => 'Miễn phí', 'maintenance' => 90000, 'note' => 'Toàn bộ thông tin Whois tên miền của bạn sẽ được giấu kín hoàn toàn với mọi công cụ Whois'], + ['name' => 'Khóa tên miền', 'setupFee' => 'Miễn phí', 'maintenance' => 'Miễn phí', 'note' => 'Tăng cường bảo mật cho tên miền'], + ], + ]; + } + + /** + * GET /administrator/domain-pricing + */ + public function index(): JsonResponse + { + $setting = Setting::where('key', 'domain_pricing')->first(); + + if ($setting && !empty($setting->value)) { + $value = json_decode((string) $setting->value, true); + if (is_array($value) && !empty($value)) { + return response()->json([ + 'error' => false, + 'data' => $value, + 'message' => 'Lấy bảng giá tên miền thành công.', + ]); + } + } + + return response()->json([ + 'error' => false, + 'data' => $this->getDefaultData(), + 'message' => 'Lấy bảng giá tên miền thành công (mặc định).', + ]); + } + + /** + * PUT /administrator/domain-pricing + */ + public function update(Request $request): JsonResponse + { + $validator = Validator::make($request->all(), [ + 'domains' => 'sometimes|array', + 'services' => 'sometimes|array', + ]); + + if ($validator->fails()) { + return response()->json([ + 'error' => true, + 'message' => $validator->errors()->first(), + ], 422); + } + + $current = Setting::where('key', 'domain_pricing')->first(); + $existing = $current ? json_decode((string) $current->value, true) : $this->getDefaultData(); + + if ($request->has('domains')) { + $existing['domains'] = $request->input('domains'); + } + if ($request->has('services')) { + $existing['services'] = $request->input('services'); + } + + Setting::updateOrCreate( + ['key' => 'domain_pricing'], + ['value' => json_encode($existing, JSON_UNESCAPED_UNICODE)] + ); + + return response()->json([ + 'error' => false, + 'message' => 'Cập nhật bảng giá tên miền thành công.', + 'data' => $existing, + ]); + } + + /** + * POST /administrator/domain-pricing/reset + * Reset to default values. + */ + public function reset(): JsonResponse + { + $default = $this->getDefaultData(); + + Setting::updateOrCreate( + ['key' => 'domain_pricing'], + ['value' => json_encode($default, JSON_UNESCAPED_UNICODE)] + ); + + return response()->json([ + 'error' => false, + 'message' => 'Đã reset bảng giá tên miền về mặc định.', + 'data' => $default, + ]); + } +} diff --git a/app/Http/Controllers/Api/Admin/Domain/InetDomainController.php b/app/Http/Controllers/Api/Admin/Domain/InetDomainController.php new file mode 100644 index 0000000..582a052 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/Domain/InetDomainController.php @@ -0,0 +1,275 @@ +first(); + $value = $setting ? json_decode((string) $setting->value, true) : []; + return $value['inet_api_key'] ?? $value['api_inet'] ?? null; + } + + /** + * Headers mặc định cho iNET API + */ + private function defaultHeaders(): array + { + $apiKey = $this->getApiKey(); + + if (! $apiKey) { + return []; + } + + return [ + 'Authorization' => 'Bearer ' . $apiKey, + 'Accept' => 'application/json', + ]; + } + + /** + * GET /administrator/inet-domain/whois/{domain} + * Tra cứu thông tin WHOIS của tên miền. + */ + public function whois(string $domain, Request $request): JsonResponse + { + $apiKey = $this->getApiKey(); + + if (! $apiKey) { + return response()->json([ + 'error' => true, + 'message' => 'Chưa cấu hình API key iNET (api_inet).', + ], 400); + } + + $url = self::BASE_URL . '/whois/' . urlencode($domain); + + if ($request->boolean('refreshCache')) { + $url .= '?refreshCache=true'; + } + + try { + $response = Http::withHeaders($this->defaultHeaders()) + ->timeout(30) + ->get($url); + + return $this->proxyResponse($response); + } catch (\Throwable $e) { + return response()->json([ + 'error' => true, + 'message' => 'Lỗi kết nối iNET API: ' . $e->getMessage(), + ], 500); + } + } + + /** + * GET /administrator/inet-domain/check-available + * Kiểm tra tên miền có khả dụng không. + */ + public function checkAvailable(Request $request): JsonResponse + { + $apiKey = $this->getApiKey(); + + if (! $apiKey) { + return response()->json([ + 'error' => true, + 'message' => 'Chưa cấu hình API key iNET (api_inet).', + ], 400); + } + + $validator = Validator::make($request->all(), [ + 'name' => 'required|string|max:255', + ]); + + if ($validator->fails()) { + return response()->json([ + 'error' => true, + 'message' => $validator->errors()->first(), + ], 422); + } + + $url = self::BASE_URL . '/check-available?name=' . urlencode($request->input('name')); + + try { + $response = Http::withHeaders($this->defaultHeaders()) + ->timeout(30) + ->get($url); + + return $this->proxyResponse($response); + } catch (\Throwable $e) { + return response()->json([ + 'error' => true, + 'message' => 'Lỗi kết nối iNET API: ' . $e->getMessage(), + ], 500); + } + } + + /** + * POST /administrator/inet-domain/bulk-check-available + * Kiểm tra nhiều tên miền cùng lúc. + */ + public function bulkCheckAvailable(Request $request): JsonResponse + { + $apiKey = $this->getApiKey(); + + if (! $apiKey) { + return response()->json([ + 'error' => true, + 'message' => 'Chưa cấu hình API key iNET (api_inet).', + ], 400); + } + + $validator = Validator::make($request->all(), [ + 'domains' => 'required|array|min:1', + 'domains.*' => 'required|string|max:255', + ]); + + if ($validator->fails()) { + return response()->json([ + 'error' => true, + 'message' => $validator->errors()->first(), + ], 422); + } + + try { + $response = Http::withHeaders([ + ...$this->defaultHeaders(), + 'Content-Type' => 'application/json', + ]) + ->timeout(30) + ->post(self::BASE_URL . '/bulk-check-available', [ + 'domains' => $request->input('domains'), + ]); + + return $this->proxyResponse($response); + } catch (\Throwable $e) { + return response()->json([ + 'error' => true, + 'message' => 'Lỗi kết nối iNET API: ' . $e->getMessage(), + ], 500); + } + } + + /** + * POST /administrator/inet-domain/ai-suggest + * Gợi ý tên miền bằng AI. + */ + public function aiSuggest(Request $request): JsonResponse + { + $apiKey = $this->getApiKey(); + + if (! $apiKey) { + return response()->json([ + 'error' => true, + 'message' => 'Chưa cấu hình API key iNET (api_inet).', + ], 400); + } + + $validator = Validator::make($request->all(), [ + 'tlds' => 'required|array|min:1', + 'tlds.*' => 'required|string|max:50', + 'message' => 'required|string|max:500', + 'maxLength' => 'nullable|integer|min:1|max:100', + ]); + + if ($validator->fails()) { + return response()->json([ + 'error' => true, + 'message' => $validator->errors()->first(), + ], 422); + } + + $payload = [ + 'tlds' => $request->input('tlds'), + 'message' => $request->input('message'), + ]; + + if ($request->has('maxLength')) { + $payload['maxLength'] = $request->input('maxLength'); + } + + try { + $response = Http::withHeaders([ + ...$this->defaultHeaders(), + 'Content-Type' => 'application/json', + ]) + ->timeout(30) + ->post(self::BASE_URL . '/ai-suggest', $payload); + + return $this->proxyResponse($response); + } catch (\Throwable $e) { + return response()->json([ + 'error' => true, + 'message' => 'Lỗi kết nối iNET API: ' . $e->getMessage(), + ], 500); + } + } + + /** + * GET /administrator/inet-domain/suffixes + * Lấy danh sách đuôi tên miền (TLD) và bảng giá. + */ + public function suffixes(): JsonResponse + { + $apiKey = $this->getApiKey(); + + if (! $apiKey) { + return response()->json([ + 'error' => true, + 'message' => 'Chưa cấu hình API key iNET (api_inet).', + ], 400); + } + + try { + $response = Http::withHeaders($this->defaultHeaders()) + ->timeout(30) + ->get(self::BASE_URL . '/suffixes'); + + return $this->proxyResponse($response); + } catch (\Throwable $e) { + return response()->json([ + 'error' => true, + 'message' => 'Lỗi kết nối iNET API: ' . $e->getMessage(), + ], 500); + } + } + + /** + * Xử lý response từ iNET API và trả về client. + */ + private function proxyResponse(\Illuminate\Http\Client\Response $response): JsonResponse + { + $status = $response->status(); + + if ($response->failed()) { + $body = $response->json() ?? ['message' => $response->body()]; + return response()->json([ + 'error' => true, + 'message' => $body['message'] ?? 'iNET API trả về lỗi HTTP ' . $status, + 'data' => $body, + ], $status >= 400 && $status < 600 ? $status : 500); + } + + $body = $response->json(); + + return response()->json([ + 'error' => false, + 'data' => $body, + ]); + } +} diff --git a/app/Http/Controllers/Api/Admin/Media/MediaController.php b/app/Http/Controllers/Api/Admin/Media/MediaController.php new file mode 100644 index 0000000..de8f492 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/Media/MediaController.php @@ -0,0 +1,95 @@ +validate([ + 'keyword' => ['sometimes', 'nullable', 'string', 'max:255'], + 'type' => ['sometimes', 'nullable', 'in:all,image,file'], + 'page' => ['sometimes', 'integer', 'min:1'], + 'per_page' => ['sometimes', 'integer', 'min:1', 'max:100'], + ]); + + $keyword = Str::lower((string) ($validated['keyword'] ?? '')); + $type = $validated['type'] ?? 'all'; + $page = (int) ($validated['page'] ?? 1); + $perPage = (int) ($validated['per_page'] ?? 24); + + $files = collect(Storage::disk('local')->allFiles()) + ->filter(fn (string $path) => ! Str::startsWith(basename($path), '.')) + ->map(fn (string $path) => $this->formatFile($path)) + ->filter(function (array $file) use ($keyword, $type) { + if ($type === 'image' && ! $file['is_image']) { + return false; + } + + if ($type === 'file' && $file['is_image']) { + return false; + } + + if ($keyword === '') { + return true; + } + + return Str::contains(Str::lower($file['name']), $keyword) + || Str::contains(Str::lower($file['path']), $keyword) + || Str::contains(Str::lower((string) $file['mime_type']), $keyword); + }) + ->sortByDesc('last_modified') + ->values(); + + $total = $files->count(); + $items = $files->slice(($page - 1) * $perPage, $perPage)->values(); + + return response()->json([ + 'error' => false, + 'data' => [ + 'items' => $items, + 'current_page' => $page, + 'per_page' => $perPage, + 'total' => $total, + 'last_page' => (int) ceil($total / $perPage), + ], + ]); + } + + private function formatFile(string $path): array + { + $disk = Storage::disk('local'); + $mimeType = $disk->mimeType($path) ?: 'application/octet-stream'; + $isPublic = Str::startsWith($path, 'public/'); + $publicPath = $isPublic ? Str::after($path, 'public/') : null; + + return [ + 'id' => sha1($path), + 'disk' => $isPublic ? 'public' : 'local', + 'name' => basename($path), + 'path' => $path, + 'directory' => trim(dirname($path), '.'), + 'mime_type' => $mimeType, + 'is_image' => in_array($mimeType, self::IMAGE_MIME_TYPES, true), + 'size' => $disk->size($path), + 'last_modified' => date(DATE_ATOM, $disk->lastModified($path)), + 'url' => $publicPath ? Storage::disk('public')->url($publicPath) : null, + ]; + } +} diff --git a/app/Http/Controllers/Api/Admin/Setting/SettingController.php b/app/Http/Controllers/Api/Admin/Setting/SettingController.php index 67961fc..8f1f667 100644 --- a/app/Http/Controllers/Api/Admin/Setting/SettingController.php +++ b/app/Http/Controllers/Api/Admin/Setting/SettingController.php @@ -50,6 +50,7 @@ class SettingController extends Controller { $validator = Validator::make($request->all(), [ 'setting' => 'sometimes|array', + 'setting.api_inet' => 'sometimes|string|nullable', 'smtp' => 'sometimes|array', 'smtp.host' => 'sometimes|string', 'smtp.port' => 'sometimes|integer', diff --git a/app/Http/Controllers/Api/Client/ClientController.php b/app/Http/Controllers/Api/Client/ClientController.php index 51d7167..66181a3 100644 --- a/app/Http/Controllers/Api/Client/ClientController.php +++ b/app/Http/Controllers/Api/Client/ClientController.php @@ -193,9 +193,30 @@ class ClientController extends Controller $client = $result['client']; $this->activityLogger->logAuth('register', 'customer', $client->id, $client->email); + return response()->json([ + 'error' => false, + 'message' => 'Đăng ký thành công. Vui lòng kiểm tra email và nhập mã xác thực để kích hoạt tài khoản.', + ]); + } + + public function verifyEmail(Request $request) + { + $request->validate([ + 'email' => ['required', 'email', 'max:255'], + 'code' => ['required', 'string', 'size:6'], + ]); + + $result = $this->authService->verifyEmail( + $request->input('email'), + $request->input('code'), + ); + + $client = $result['client']; + $this->activityLogger->logAuth('email_verified', 'customer', $client->id, $client->email); + return response()->json([ 'error' => false, - 'data' => [ + 'data' => [ 'client' => new ClientResource($client->loadCount(['vps', 'hosting', 'domain'])), 'token' => $result['token'], 'refresh_token' => $result['refresh_token'], @@ -204,6 +225,20 @@ class ClientController extends Controller ]); } + public function resendVerifyCode(Request $request) + { + $request->validate([ + 'email' => ['required', 'email', 'max:255'], + ]); + + $this->authService->resendVerifyCode($request->input('email')); + + return response()->json([ + 'error' => false, + 'message' => 'Nếu email tồn tại và chưa xác thực, mã xác thực mới đã được gửi.', + ]); + } + /** * @OA\Post( * path="/client/logout", diff --git a/app/Http/Controllers/Api/Client/Domain/DomainOrderController.php b/app/Http/Controllers/Api/Client/Domain/DomainOrderController.php new file mode 100644 index 0000000..303f9ad --- /dev/null +++ b/app/Http/Controllers/Api/Client/Domain/DomainOrderController.php @@ -0,0 +1,106 @@ +domainOrderService->placeOrder($request->validated(), $request->user()); + + $this->notifyAdminNewDomainOrder($request->user(), $result); + + return response()->json([ + 'success' => true, + 'message' => 'Đặt mua tên miền thành công.', + 'notice' => 'Việc đặt mua không đồng nghĩa tên miền đã được đăng ký thành công. Đơn hàng sẽ được nhân viên kiểm tra và xử lý. Nếu tên miền không còn khả dụng, số dư đã thanh toán sẽ được hoàn lại vào tài khoản.', + 'data' => [ + 'order_num' => $result['order']->order_num, + 'domain_name' => $result['order']->orderItems->first()?->domain_name, + 'status' => 'pending', + 'total' => $result['invoice']->total, + 'invoice_number' => $result['invoice']->invoice_number, + ], + ], 201); + } catch (\InvalidArgumentException $e) { + return response()->json([ + 'success' => false, + 'message' => $e->getMessage(), + ], 422); + } catch (\Throwable $e) { + Log::error('DomainOrderController@store', [ + 'error' => $e->getMessage(), + 'customer_id' => $request->user()?->id, + 'data' => $request->validated(), + ]); + + return response()->json([ + 'success' => false, + 'message' => 'Đặt mua tên miền thất bại. Vui lòng thử lại.', + ], 500); + } + } + + private function notifyAdminNewDomainOrder($customer, array $result): void + { + $domainName = $result['order']->orderItems->first()?->domain_name ?? ''; + $orderNum = $result['order']->order_num; + $total = number_format((int) $result['invoice']->total) . ' VNĐ'; + + Notification::notifyAdmins([ + 'type' => 'domain_order_new', + 'title' => 'Đơn đăng ký tên miền mới', + 'body' => "Khách hàng {$customer->fullname} vừa đặt mua tên miền {$domainName} (#{$orderNum})", + 'link' => '/domain-orders', + ]); + + try { + $setting = Setting::where('key', 'setting')->first(); + $settingValue = $setting ? json_decode((string) $setting->value, true) : []; + $adminEmail = $settingValue['admin_email'] ?? null; + + if (empty($adminEmail)) { + return; + } + + $template = Template::where('name', 'Domain Order New')->first(); + if (!$template) { + return; + } + + $adminUrl = rtrim(env('ADMIN_URL', config('app.url')), '/') . '/domain-orders'; + + Mail::to($adminEmail)->send(new DomainOrderNewNotification($template, [ + 'company_name' => $settingValue['company'] ?? config('app.name'), + 'company_logo' => $settingValue['logo'] ?? '', + 'domain_name' => $domainName, + 'customer_name' => $customer->fullname, + 'customer_email' => $customer->email, + 'order_num' => $orderNum, + 'order_total' => $total, + 'created_at' => now()->format('d/m/Y H:i:s'), + 'admin_url' => $adminUrl, + ])); + } catch (\Throwable $e) { + Log::error('DomainOrderController: không thể gửi email admin: ' . $e->getMessage()); + } + } +} diff --git a/app/Http/Controllers/Api/Client/Domain/DomainPricingController.php b/app/Http/Controllers/Api/Client/Domain/DomainPricingController.php new file mode 100644 index 0000000..8b36057 --- /dev/null +++ b/app/Http/Controllers/Api/Client/Domain/DomainPricingController.php @@ -0,0 +1,77 @@ + [ + ['tld' => '.vn', 'setupFee' => 'Miễn phí', 'firstYear' => 400000, 'renewal' => 400000], + ['tld' => '.com.vn', 'setupFee' => 'Miễn phí', 'firstYear' => 300000, 'renewal' => 300000], + ['tld' => '.net.vn', 'setupFee' => 'Miễn phí', 'firstYear' => 300000, 'renewal' => 300000], + ['tld' => '.org.vn', 'setupFee' => 'Miễn phí', 'firstYear' => 300000, 'renewal' => 300000], + ['tld' => '.com', 'setupFee' => 'Miễn phí', 'firstYear' => 320000, 'renewal' => 340000], + ['tld' => '.net', 'setupFee' => 'Miễn phí', 'firstYear' => 369000, 'renewal' => 409000], + ['tld' => '.org', 'setupFee' => 'Miễn phí', 'firstYear' => 339000, 'renewal' => 369000], + ['tld' => '.biz', 'setupFee' => 'Miễn phí', 'firstYear' => 399000, 'renewal' => 409000], + ['tld' => '.info', 'setupFee' => 'Miễn phí', 'firstYear' => 490000, 'renewal' => 509000], + ['tld' => '.pro', 'setupFee' => 'Miễn phí', 'firstYear' => 469000, 'renewal' => 489000], + ['tld' => '.name', 'setupFee' => 'Miễn phí', 'firstYear' => 259000, 'renewal' => 259000], + ['tld' => '.ws', 'setupFee' => 'Miễn phí', 'firstYear' => 909000, 'renewal' => 909000], + ['tld' => '.tv', 'setupFee' => 'Miễn phí', 'firstYear' => 765000, 'renewal' => 819000], + ['tld' => '.mobi', 'setupFee' => 'Miễn phí', 'firstYear' => 589000, 'renewal' => 589000], + ['tld' => '.uk', 'setupFee' => 'Miễn phí', 'firstYear' => 537000, 'renewal' => 537000], + ['tld' => '.asia', 'setupFee' => 'Miễn phí', 'firstYear' => 380000, 'renewal' => 452000], + ['tld' => '.me', 'setupFee' => 'Miễn phí', 'firstYear' => 1029000, 'renewal' => 1029000], + ['tld' => '.us', 'setupFee' => 'Miễn phí', 'firstYear' => 323000, 'renewal' => 323000], + ], + 'services' => [ + ['name' => 'Chứng thực Rapid SSL', 'setupFee' => 230000, 'maintenance' => 750000, 'note' => '01 Domain'], + ['name' => 'Chứng thực Quick SSL', 'setupFee' => 230000, 'maintenance' => 2300000, 'note' => '01 Domain'], + ['name' => 'Chứng thực Wildcard SSL', 'setupFee' => 230000, 'maintenance' => 3300000, 'note' => 'Unlimited Subdomain'], + ['name' => 'Chứng thực True BusinessID', 'setupFee' => 540000, 'maintenance' => 3900000, 'note' => '01 Domain'], + ['name' => 'Chứng thực True BusinessID WildCard', 'setupFee' => 900000, 'maintenance' => 15000000, 'note' => 'Unlimited Subdomain'], + ['name' => 'Đăng ký DNS riêng', 'setupFee' => 200000, 'maintenance' => 'Miễn phí', 'note' => "dns1.tenmiencuaban.com\ndns2.tenmiencuaban.com"], + ['name' => 'Bảo mật thông tin tên miền', 'setupFee' => 'Miễn phí', 'maintenance' => 90000, 'note' => 'Toàn bộ thông tin Whois tên miền của bạn sẽ được giấu kín hoàn toàn với mọi công cụ Whois'], + ['name' => 'Khóa tên miền', 'setupFee' => 'Miễn phí', 'maintenance' => 'Miễn phí', 'note' => 'Tăng cường bảo mật cho tên miền'], + ], + ]; + } + + /** + * GET /v1/domain-pricing + * Returns domain pricing list and addon services. + * Public endpoint (no auth required). + */ + public function index(): JsonResponse + { + $setting = Setting::where('key', 'domain_pricing')->first(); + + if ($setting && !empty($setting->value)) { + $value = json_decode((string) $setting->value, true); + if (is_array($value) && !empty($value)) { + return response()->json([ + 'error' => false, + 'data' => $value, + 'message' => 'Lấy bảng giá tên miền thành công.', + ]); + } + } + + // Fallback to default data + return response()->json([ + 'error' => false, + 'data' => $this->getDefaultDomainPricing(), + 'message' => 'Lấy bảng giá tên miền thành công (mặc định).', + ]); + } +} diff --git a/app/Http/Middleware/CheckPermission.php b/app/Http/Middleware/CheckPermission.php index 340755e..8fb53d5 100644 --- a/app/Http/Middleware/CheckPermission.php +++ b/app/Http/Middleware/CheckPermission.php @@ -14,8 +14,8 @@ class CheckPermission if (!$user) { return response()->json([ 'error' => true, - 'message' => 'Vui lòng đăng nhập để thực hiện!', - ], 401); + 'message' => 'Bạn không có quyền thực hiện hành động này.', + ], 403); } // Full Administrator luôn có tất cả quyền diff --git a/app/Http/Requests/Api/Admin/Domain/ConfirmRegistrationRequest.php b/app/Http/Requests/Api/Admin/Domain/ConfirmRegistrationRequest.php new file mode 100644 index 0000000..917a02e --- /dev/null +++ b/app/Http/Requests/Api/Admin/Domain/ConfirmRegistrationRequest.php @@ -0,0 +1,24 @@ + ['nullable', 'integer', 'exists:dns_manager_servers,id'], + 'kyc_url' => ['nullable', 'url', 'max:2048'], + 'registrar_reference' => ['nullable', 'string', 'max:255'], + 'registered_at' => ['nullable', 'date'], + 'expires_at' => ['nullable', 'date'], + ]; + } +} diff --git a/app/Http/Requests/Api/Admin/Domain/RejectAndRefundRequest.php b/app/Http/Requests/Api/Admin/Domain/RejectAndRefundRequest.php new file mode 100644 index 0000000..7ba67ac --- /dev/null +++ b/app/Http/Requests/Api/Admin/Domain/RejectAndRefundRequest.php @@ -0,0 +1,20 @@ + ['required', 'string', 'max:1000'], + ]; + } +} diff --git a/app/Http/Requests/Api/Admin/Domain/UpdateKycUrlRequest.php b/app/Http/Requests/Api/Admin/Domain/UpdateKycUrlRequest.php new file mode 100644 index 0000000..387dea2 --- /dev/null +++ b/app/Http/Requests/Api/Admin/Domain/UpdateKycUrlRequest.php @@ -0,0 +1,20 @@ + ['required', 'url', 'max:2048'], + ]; + } +} diff --git a/app/Http/Requests/Api/Client/Domain/StoreDomainOrderRequest.php b/app/Http/Requests/Api/Client/Domain/StoreDomainOrderRequest.php new file mode 100644 index 0000000..7d8ec57 --- /dev/null +++ b/app/Http/Requests/Api/Client/Domain/StoreDomainOrderRequest.php @@ -0,0 +1,30 @@ + ['required', 'string'], + 'domain_name' => ['required', 'string', 'max:255'], + 'years' => ['nullable', 'integer', 'min:1', 'max:3'], + ]; + } + + /** Default years to 1 if omitted. */ + protected function prepareForValidation(): void + { + if (!$this->has('years') || $this->years === null) { + $this->merge(['years' => 1]); + } + } +} diff --git a/app/Listeners/Mail/CheckMailEnabled.php b/app/Listeners/Mail/CheckMailEnabled.php index c8cdb07..35bdab2 100644 --- a/app/Listeners/Mail/CheckMailEnabled.php +++ b/app/Listeners/Mail/CheckMailEnabled.php @@ -19,7 +19,7 @@ class CheckMailEnabled $setting = Setting::where('key', 'email_settings')->first(); $config = $setting ? json_decode($setting->value, true) : []; - if (empty($config['mail_enabled'])) { + if (array_key_exists('mail_enabled', $config) && !$config['mail_enabled']) { Log::info('Mail sending blocked: mail_enabled is off.', [ 'subject' => $event->message->getSubject(), ]); diff --git a/app/Mail/Admin/DomainOrderNewNotification.php b/app/Mail/Admin/DomainOrderNewNotification.php new file mode 100644 index 0000000..ed64fe0 --- /dev/null +++ b/app/Mail/Admin/DomainOrderNewNotification.php @@ -0,0 +1,43 @@ +data['domain_name'] ?? '', $this->template->subject), + ); + } + + public function content(): Content + { + $message = str_replace( + ['{\$company_name}', '{\$company_logo}', '{\$domain_name}', '{\$customer_name}', '{\$customer_email}', '{\$order_num}', '{\$order_total}', '{\$created_at}', '{\$admin_url}'], + [$this->data['company_name'] ?? '', $this->data['company_logo'] ?? '', $this->data['domain_name'] ?? '', $this->data['customer_name'] ?? '', $this->data['customer_email'] ?? '', $this->data['order_num'] ?? '', $this->data['order_total'] ?? '', $this->data['created_at'] ?? '', $this->data['admin_url'] ?? ''], + $this->template->message + ); + + return new Content(view: 'emails.template', with: ['template' => $message]); + } + + public function attachments(): array + { + return []; + } +} diff --git a/app/Mail/Customer/DomainOrderStatusMail.php b/app/Mail/Customer/DomainOrderStatusMail.php new file mode 100644 index 0000000..8ab28e9 --- /dev/null +++ b/app/Mail/Customer/DomainOrderStatusMail.php @@ -0,0 +1,43 @@ +data['domain_name'] ?? '', $this->template->subject), + ); + } + + public function content(): Content + { + $message = str_replace( + ['{\$company_name}', '{\$company_logo}', '{\$domain_name}', '{\$client_name}', '{\$order_num}', '{\$reason}'], + [$this->data['company_name'] ?? '', $this->data['company_logo'] ?? '', $this->data['domain_name'] ?? '', $this->data['client_name'] ?? '', $this->data['order_num'] ?? '', $this->data['reason'] ?? ''], + $this->template->message + ); + + return new Content(view: 'emails.template', with: ['template' => $message]); + } + + public function attachments(): array + { + return []; + } +} diff --git a/app/Mail/Customer/ManualCustomerEmail.php b/app/Mail/Customer/ManualCustomerEmail.php new file mode 100644 index 0000000..56eea90 --- /dev/null +++ b/app/Mail/Customer/ManualCustomerEmail.php @@ -0,0 +1,34 @@ +emailSubject); + } + + public function content(): Content + { + return new Content(htmlString: $this->htmlContent); + } + + public function attachments(): array + { + return []; + } +} diff --git a/app/Mail/Customer/VerifyAccount.php b/app/Mail/Customer/VerifyAccount.php new file mode 100644 index 0000000..74d76f1 --- /dev/null +++ b/app/Mail/Customer/VerifyAccount.php @@ -0,0 +1,63 @@ +payload()['company_name'] ?? config('app.name', 'The Gioi So'); + + return new Envelope( + subject: '[' . $appName . '] Mã xác thực tài khoản của bạn', + ); + } + + public function content(): Content + { + $data = $this->payload(); + + return new Content( + view: 'emails.customer.verify-account-welcome', + with: [ + 'appName' => $data['company_name'] ?? config('app.name', 'The Gioi So'), + 'fullName' => $data['fullname'] ?? 'Quý khách', + 'verifyCode' => $data['verify_code'] ?? '', + 'supportEmail' => $data['support_email'] ?? config('mail.support_email', 'support@thegioiso.com'), + 'supportPhone' => $data['support_phone'] ?? config('mail.support_phone', '1900.xxxx'), + ], + ); + } + + public function attachments(): array + { + return []; + } + + private function payload(): array + { + try { + $setting = Setting::where('key', 'setting')->first(); + $settingValue = $setting ? json_decode((string) $setting->value, true) : []; + } catch (\Throwable) { + $settingValue = []; + } + + return array_merge([ + 'company_name' => $settingValue['company'] ?? config('app.name', 'The Gioi So'), + 'support_email' => $settingValue['email'] ?? config('mail.support_email', 'support@thegioiso.com'), + 'support_phone' => $settingValue['hotline'] ?? ($settingValue['phone'] ?? config('mail.support_phone', '1900.xxxx')), + ], $this->data); + } +} diff --git a/app/Models/Customer.php b/app/Models/Customer.php index ba2b924..7bf024f 100644 --- a/app/Models/Customer.php +++ b/app/Models/Customer.php @@ -38,6 +38,8 @@ class Customer extends Authenticatable implements JWTSubject 'email', 'email_verified_at', 'email_verify', + 'email_verify_code', + 'email_verify_code_expired_at', 'email_hash', 'email_marketing', 'receive_deposit', diff --git a/app/Models/Service.php b/app/Models/Service.php index 63327bb..b79b5bf 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -28,6 +28,7 @@ class Service extends Model 'expires_at' => 'datetime', 'suspended_at' => 'datetime', 'cancelled_at' => 'datetime', + 'refunded_at' => 'datetime', 'retry_requested_at' => 'datetime', ]; diff --git a/app/Providers/MailConfigServiceProvider.php b/app/Providers/MailConfigServiceProvider.php index 7d35c60..5219e65 100644 --- a/app/Providers/MailConfigServiceProvider.php +++ b/app/Providers/MailConfigServiceProvider.php @@ -52,18 +52,29 @@ class MailConfigServiceProvider extends ServiceProvider $password = $decrypted ?: $mail->password; } - return [ - 'driver' => $mail->transport ?? 'smtp', + $transport = $mail->transport ?? 'smtp'; + $encryption = $mail->encryption ?? config('mail.mailers.smtp.encryption'); + + if ($encryption === 0 || $encryption === '0' || $encryption === 'starttls') { + $encryption = 'tls'; + } + + $config = config('mail'); + $config['default'] = $transport; + $config['from'] = [ + 'address' => $mail->user ?? config('mail.from.address', 'no-reply@example.com'), + 'name' => $company, + ]; + $config['mailers'][$transport] = array_merge($config['mailers'][$transport] ?? [], [ + 'transport' => $transport, 'host' => $mail->host ?? '', 'port' => $mail->port ?? 587, - 'from' => [ - 'address' => $mail->user ?? 'no-reply@example.com', - 'name' => $company, - ], - 'encryption' => $mail->encryption === 0 ? 'tls' : $mail->encryption, + 'encryption' => $encryption, 'username' => $mail->user ?? '', 'password' => $password, - ]; + ]); + + return $config; }); if ($config) { diff --git a/app/Repositories/Api/Domain/DomainOrderRepository.php b/app/Repositories/Api/Domain/DomainOrderRepository.php new file mode 100644 index 0000000..1e9c6e3 --- /dev/null +++ b/app/Repositories/Api/Domain/DomainOrderRepository.php @@ -0,0 +1,91 @@ +model->newQuery() + ->with(['customer', 'orderItems.product', 'orderItems.service', 'invoice']) + ->whereHas('orderItems.product', fn ($q) => + $q->where('product_type', ProductTypeEnums::DOMAIN->value) + ) + ->orderByDesc('created_at'); + + if (!empty($filters['status'])) { + $query->whereHas('orderItems.service', fn ($q) => + $q->where('status', $filters['status']) + ); + } + + if (!empty($filters['customer'])) { + $s = $filters['customer']; + $query->whereHas('customer', fn ($q) => + $q->where('fullname', 'like', "%{$s}%") + ->orWhere('email', 'like', "%{$s}%") + ); + } + + if (!empty($filters['domain'])) { + $d = $filters['domain']; + $query->whereHas('orderItems', fn ($q) => + $q->where('domain_name', 'like', "%{$d}%") + ); + } + + if (!empty($filters['from'])) { + $query->where('created_at', '>=', $filters['from']); + } + + if (!empty($filters['to'])) { + $query->where('created_at', '<=', $filters['to'] . ' 23:59:59'); + } + + return $query->paginate($perPage); + } + + public function findDomainOrder(int $id): ?Order + { + return $this->model->newQuery() + ->with(['customer', 'orderItems.product', 'orderItems.service', 'invoice.invoiceItems']) + ->whereHas('orderItems.product', fn ($q) => + $q->where('product_type', ProductTypeEnums::DOMAIN->value) + ) + ->find($id); + } + + public function countPending(): int + { + return $this->model->newQuery() + ->whereHas('orderItems.product', fn ($q) => + $q->where('product_type', ProductTypeEnums::DOMAIN->value) + ) + ->whereHas('orderItems.service', fn ($q) => + $q->where('status', 'pending') + ) + ->count(); + } + + public function hasPendingOrActiveDomainByName(string $normalizedDomain): bool + { + return Service::whereHas('orderItem', fn ($q) => + $q->where('domain_name', $normalizedDomain) + ) + ->whereNotIn('status', ['cancelled', 'refunded', 'terminated']) + ->exists(); + } +} diff --git a/app/Services/Api/Client/ClientAuthService.php b/app/Services/Api/Client/ClientAuthService.php index 7ca466e..985de37 100644 --- a/app/Services/Api/Client/ClientAuthService.php +++ b/app/Services/Api/Client/ClientAuthService.php @@ -2,6 +2,7 @@ namespace App\Services\Api\Client; +use App\Mail\Customer\VerifyAccount as VerifyAccountMail; use App\Mail\Customer\Welcome as WelcomeMail; use App\Models\RefreshToken; use App\Repositories\Api\Client\ClientRepositoryInterface; @@ -39,12 +40,18 @@ class ClientAuthService if ((int)$client->status !== 1) { $statusLabel = match ((int)$client->status) { - 0 => 'chờ xác thực', + 0 => 'PENDING_VERIFY', 2 => 'tạm ngưng hoạt động', 3 => 'bị khoá', default => 'vô hiệu hoá', }; + if ($statusLabel === 'PENDING_VERIFY') { + throw ValidationException::withMessages([ + 'email' => ['PENDING_VERIFY'], + ]); + } + throw ValidationException::withMessages([ 'email' => ["Tài khoản của bạn đã {$statusLabel}, vui lòng liên hệ hotline."], ]); @@ -60,18 +67,53 @@ class ClientAuthService } /** - * Đăng ký khách hàng mới và tạo access + refresh token + * Đăng ký khách hàng mới — status=0 (chờ xác thực OTP), chưa cấp token. */ public function registerClient(array $data): array { + $code = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT); + $client = $this->clientRepository->create([ - 'fullname' => $data['fullname'], - 'phone' => $data['phone'], - 'email' => $data['email'], - 'password' => bcrypt($data['password']), + 'fullname' => $data['fullname'], + 'phone' => $data['phone'], + 'email' => $data['email'], + 'password' => bcrypt($data['password']), + 'status' => 0, + 'email_verify_code' => $code, + 'email_verify_code_expired_at' => now()->addMinutes(5), ]); - $token = JWTAuth::fromUser($client); + $this->sendVerifyEmail($client, $code); + + return ['client' => $client]; + } + + /** + * Xác thực OTP — kích hoạt tài khoản, trả về token. + */ + public function verifyEmail(string $email, string $code): array + { + $client = \App\Models\Customer::whereRaw('LOWER(email) = ?', [strtolower(trim($email))])->first(); + + if (! $client || $client->email_verify_code !== $code) { + throw ValidationException::withMessages([ + 'code' => ['Mã xác thực không chính xác.'], + ]); + } + + if (! $client->email_verify_code_expired_at || now()->gt($client->email_verify_code_expired_at)) { + throw ValidationException::withMessages([ + 'code' => ['Mã xác thực đã hết hạn, vui lòng yêu cầu gửi lại.'], + ]); + } + + $client->status = 1; + $client->email_verify_code = null; + $client->email_verify_code_expired_at = null; + $client->email_verified_at = now(); + $client->save(); + + $token = JWTAuth::fromUser($client); $refreshToken = $this->createRefreshToken($client->id); $this->sendWelcomeEmail($client); @@ -84,7 +126,50 @@ class ClientAuthService } /** - * Gửi email chào mừng cho khách hàng mới đăng ký. + * Gửi lại OTP xác thực (rate limit bởi caller nếu cần). + */ + public function resendVerifyCode(string $email): void + { + $client = \App\Models\Customer::whereRaw('LOWER(email) = ?', [strtolower(trim($email))])->first(); + + if (! $client || $client->status !== 0) { + return; + } + + // Nếu mã cũ còn hạn thì không gửi lại — phải đợi hết hạn + if ($client->email_verify_code_expired_at && now()->lt($client->email_verify_code_expired_at)) { + throw ValidationException::withMessages([ + 'email' => ['Mã xác thực vẫn còn hiệu lực, vui lòng kiểm tra email của bạn.'], + ]); + } + + $code = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT); + + $client->email_verify_code = $code; + $client->email_verify_code_expired_at = now()->addMinutes(5); + $client->save(); + + $this->sendVerifyEmail($client, $code); + } + + protected function sendVerifyEmail($client, string $code): void + { + try { + Mail::to($client->email)->send(new VerifyAccountMail([ + 'fullname' => $client->fullname, + 'email' => $client->email, + 'verify_code' => $code, + ])); + } catch (\Throwable $e) { + Log::warning('Send verify email failed: ' . $e->getMessage(), [ + 'customer_id' => $client->id, + 'email' => $client->email, + ]); + } + } + + /** + * Gửi email chào mừng sau khi xác thực tài khoản thành công. */ protected function sendWelcomeEmail($client): void { diff --git a/app/Services/Api/Domain/DomainOrderService.php b/app/Services/Api/Domain/DomainOrderService.php new file mode 100644 index 0000000..383ff2a --- /dev/null +++ b/app/Services/Api/Domain/DomainOrderService.php @@ -0,0 +1,413 @@ +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; + } +} diff --git a/app/Services/InvoicePdfService.php b/app/Services/InvoicePdfService.php index 18ed325..0f2dd49 100644 --- a/app/Services/InvoicePdfService.php +++ b/app/Services/InvoicePdfService.php @@ -24,7 +24,7 @@ class InvoicePdfService 'companyAddress' => $settingValue->address ?? '', 'companyPhone' => $settingValue->hotline ?? ($settingValue->tel ?? ''), 'companyEmail' => $settingValue->email ?? '', - 'companyLogo' => $settingValue->logo ?? '', + 'companyLogo' => $this->resolvePdfImagePath($settingValue->logo ?? ''), 'companyTaxCode' => $settingValue->tax ?? '', 'brandColor' => $settingValue->brand_color ?? '#1a56db', 'cfg' => [ @@ -50,4 +50,27 @@ class InvoicePdfService { return 'Hoa-don-' . $invoice->invoice_number . '.pdf'; } + + private function resolvePdfImagePath(?string $path): string + { + if (!$path) { + return ''; + } + + $urlPath = parse_url($path, PHP_URL_PATH) ?: $path; + + if (str_starts_with($urlPath, '/storage/')) { + $localPath = storage_path('app/public/' . ltrim(substr($urlPath, strlen('/storage/')), '/')); + + return file_exists($localPath) ? $localPath : $path; + } + + if (str_starts_with($path, 'storage/')) { + $localPath = storage_path('app/public/' . substr($path, strlen('storage/'))); + + return file_exists($localPath) ? $localPath : $path; + } + + return $path; + } } diff --git a/app/Services/WebDesignQuotePdfService.php b/app/Services/WebDesignQuotePdfService.php index bc8da50..f63a3a2 100644 --- a/app/Services/WebDesignQuotePdfService.php +++ b/app/Services/WebDesignQuotePdfService.php @@ -26,7 +26,7 @@ class WebDesignQuotePdfService 'companyAddress' => $settingValue->address ?? '', 'companyPhone' => $settingValue->hotline ?? ($settingValue->tel ?? ''), 'companyEmail' => $settingValue->email ?? '', - 'companyLogo' => $settingValue->logo ?? '', + 'companyLogo' => $this->resolvePdfImagePath($settingValue->logo ?? ''), 'companyTaxCode' => $settingValue->tax ?? '', 'brandColor' => $settingValue->brand_color ?? '#1a56db', ]; @@ -40,4 +40,27 @@ class WebDesignQuotePdfService { return 'Bao-gia-' . $quote->quote_number . '.pdf'; } + + private function resolvePdfImagePath(?string $path): string + { + if (!$path) { + return ''; + } + + $urlPath = parse_url($path, PHP_URL_PATH) ?: $path; + + if (str_starts_with($urlPath, '/storage/')) { + $localPath = storage_path('app/public/' . ltrim(substr($urlPath, strlen('/storage/')), '/')); + + return file_exists($localPath) ? $localPath : $path; + } + + if (str_starts_with($path, 'storage/')) { + $localPath = storage_path('app/public/' . substr($path, strlen('storage/'))); + + return file_exists($localPath) ? $localPath : $path; + } + + return $path; + } } diff --git a/database/factories/CustomerFactory.php b/database/factories/CustomerFactory.php index e68159e..2392aa8 100644 --- a/database/factories/CustomerFactory.php +++ b/database/factories/CustomerFactory.php @@ -17,12 +17,11 @@ class CustomerFactory extends Factory public function definition() { return [ - 'firstname' => $this->faker->name(), - 'lastname' => $this->faker->name(), + 'fullname' => $this->faker->name(), 'phone' => $this->faker->unique()->phoneNumber(), - 'email' => $this->faker->unique()->email(), + 'email' => $this->faker->unique()->safeEmail(), 'email_verify' => now(), - 'password' => '$2y$10$2Tcx805kaC.Ug/IUD34pW.R.D0l0Gpy.IEeXkrjhBr1uu048pEFp.' + 'password' => '$2y$10$2Tcx805kaC.Ug/IUD34pW.R.D0l0Gpy.IEeXkrjhBr1uu048pEFp.', ]; } } diff --git a/database/factories/ProductFactory.php b/database/factories/ProductFactory.php new file mode 100644 index 0000000..4ad37a8 --- /dev/null +++ b/database/factories/ProductFactory.php @@ -0,0 +1,40 @@ + + */ +class ProductFactory extends Factory +{ + public function definition(): array + { + $slug = Str::slug($this->faker->unique()->words(3, true)); + + $categoryId = DB::table('product_categories')->insertGetId([ + 'name' => 'Test Category ' . $this->faker->unique()->numberBetween(1, 9999), + 'slug' => 'cat-' . $slug, + 'type' => null, // null so boot() hook does not override product_type + 'is_active' => true, + 'sort_order' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + return [ + 'product_categories_id' => $categoryId, + 'product_type' => 'domain', + 'name' => $this->faker->unique()->words(2, true), + 'slug' => $slug, + 'is_active' => true, + 'is_unlimited' => true, + 'setup_fee' => 0, + 'trial_days' => 0, + 'sort_order' => 0, + ]; + } +} diff --git a/database/migrations/2026_05_13_000001_add_soft_deletes_to_ip_pools_table.php b/database/migrations/2026_05_13_000001_add_soft_deletes_to_ip_pools_table.php index b87f4e5..15c6a96 100644 --- a/database/migrations/2026_05_13_000001_add_soft_deletes_to_ip_pools_table.php +++ b/database/migrations/2026_05_13_000001_add_soft_deletes_to_ip_pools_table.php @@ -7,9 +7,11 @@ use Illuminate\Support\Facades\Schema; return new class extends Migration { public function up(): void { - Schema::table('ip_pools', function (Blueprint $table) { - $table->softDeletes(); - }); + if (!Schema::hasColumn('ip_pools', 'deleted_at')) { + Schema::table('ip_pools', function (Blueprint $table) { + $table->softDeletes(); + }); + } } public function down(): void diff --git a/database/migrations/2026_05_29_000001_add_uuid_to_ip_pools_table.php b/database/migrations/2026_05_29_000001_add_uuid_to_ip_pools_table.php index d8c65c5..a7f3acd 100644 --- a/database/migrations/2026_05_29_000001_add_uuid_to_ip_pools_table.php +++ b/database/migrations/2026_05_29_000001_add_uuid_to_ip_pools_table.php @@ -9,9 +9,11 @@ use Illuminate\Support\Str; return new class extends Migration { public function up(): void { - Schema::table('ip_pools', function (Blueprint $table) { - $table->uuid('uuid')->nullable()->unique()->after('id'); - }); + if (!Schema::hasColumn('ip_pools', 'uuid')) { + Schema::table('ip_pools', function (Blueprint $table) { + $table->uuid('uuid')->nullable()->unique()->after('id'); + }); + } // Backfill existing rows DB::table('ip_pools')->whereNull('uuid')->orderBy('id')->each(function ($row) { diff --git a/database/migrations/2026_05_29_110056_update_column_ip_pool_table.php b/database/migrations/2026_05_29_110056_update_column_ip_pool_table.php index 1ac0037..c7da7ae 100644 --- a/database/migrations/2026_05_29_110056_update_column_ip_pool_table.php +++ b/database/migrations/2026_05_29_110056_update_column_ip_pool_table.php @@ -2,6 +2,7 @@ use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; return new class extends Migration { @@ -11,18 +12,25 @@ return new class extends Migration { public function up(): void { Schema::table('ip_pools', function (Blueprint $table) { - $table->string('gateway')->nullable()->after('ip_address'); - $table->unsignedTinyInteger('cidr')->default(24)->after('gateway'); - - $table - ->enum('status', ['free', 'assigned', 'reserved']) - ->default('free') - ->after('module_network_id'); - - $table->foreignId('service_id')->nullable()->after('status')->constrained('services')->nullOnDelete(); - - $table->index(['status', 'module_network_id']); + if (!Schema::hasColumn('ip_pools', 'gateway')) { + $table->string('gateway')->nullable()->after('ip_address'); + } + if (!Schema::hasColumn('ip_pools', 'cidr')) { + $table->unsignedTinyInteger('cidr')->default(24)->after('gateway'); + } + if (!Schema::hasColumn('ip_pools', 'status')) { + $table->enum('status', ['free', 'assigned', 'reserved'])->default('free')->after('module_network_id'); + } + if (!Schema::hasColumn('ip_pools', 'service_id')) { + $table->foreignId('service_id')->nullable()->after('status')->constrained('services')->nullOnDelete(); + } }); + + if (!collect(\DB::select("SHOW INDEX FROM ip_pools WHERE Key_name = 'ip_pools_status_module_network_id_index'"))->count()) { + Schema::table('ip_pools', function (Blueprint $table) { + $table->index(['status', 'module_network_id']); + }); + } } /** diff --git a/database/migrations/2026_07_10_000001_add_domain_name_to_order_items.php b/database/migrations/2026_07_10_000001_add_domain_name_to_order_items.php new file mode 100644 index 0000000..3fa200d --- /dev/null +++ b/database/migrations/2026_07_10_000001_add_domain_name_to_order_items.php @@ -0,0 +1,23 @@ +string('domain_name', 255)->nullable()->after('server_name')->index(); + }); + } + + public function down(): void + { + Schema::table('order_items', function (Blueprint $table) { + $table->dropIndex(['domain_name']); + $table->dropColumn('domain_name'); + }); + } +}; diff --git a/database/migrations/2026_07_10_000002_add_domain_fields_to_services.php b/database/migrations/2026_07_10_000002_add_domain_fields_to_services.php new file mode 100644 index 0000000..c940583 --- /dev/null +++ b/database/migrations/2026_07_10_000002_add_domain_fields_to_services.php @@ -0,0 +1,25 @@ +string('registrar_reference', 255)->nullable()->after('note'); + $table->text('kyc_url')->nullable()->after('registrar_reference'); + $table->text('rejection_reason')->nullable()->after('kyc_url'); + $table->timestamp('refunded_at')->nullable()->after('rejection_reason'); + }); + } + + public function down(): void + { + Schema::table('services', function (Blueprint $table) { + $table->dropColumn(['registrar_reference', 'kyc_url', 'rejection_reason', 'refunded_at']); + }); + } +}; diff --git a/database/migrations/2026_07_10_000003_add_email_verify_code_to_customers_table.php b/database/migrations/2026_07_10_000003_add_email_verify_code_to_customers_table.php new file mode 100644 index 0000000..0b639f7 --- /dev/null +++ b/database/migrations/2026_07_10_000003_add_email_verify_code_to_customers_table.php @@ -0,0 +1,23 @@ +string('email_verify_code', 6)->nullable()->after('email_verify'); + $table->timestamp('email_verify_code_expired_at')->nullable()->after('email_verify_code'); + }); + } + + public function down(): void + { + Schema::table('customers', function (Blueprint $table) { + $table->dropColumn(['email_verify_code', 'email_verify_code_expired_at']); + }); + } +}; diff --git a/database/seeders/RolesAndPermissions.php b/database/seeders/RolesAndPermissions.php index f056ec9..be4427d 100644 --- a/database/seeders/RolesAndPermissions.php +++ b/database/seeders/RolesAndPermissions.php @@ -125,6 +125,13 @@ class RolesAndPermissions extends Seeder 'auto-banking.manager', 'system-log.manager', + // Tên miền + 'domain.view', + 'domain.create', + 'domain.edit', + 'domain.destroy', + 'domain.manager', + // Vai trò & Phân quyền 'role.view', 'role.create', diff --git a/database/seeders/Setting.php b/database/seeders/Setting.php index d74475b..1edab95 100644 --- a/database/seeders/Setting.php +++ b/database/seeders/Setting.php @@ -60,6 +60,37 @@ class Setting extends Seeder 'password' => '', 'status' => 0 ], JSON_THROW_ON_ERROR) + ], + [ + 'key' => 'domain_pricing', + 'value' => json_encode([ + 'domains' => [ + ['tld' => '.com', 'setupFee' => 'Miễn phí', 'firstYear' => 320000, 'renewal' => 340000], + ['tld' => '.net', 'setupFee' => 'Miễn phí', 'firstYear' => 369000, 'renewal' => 409000], + ['tld' => '.org', 'setupFee' => 'Miễn phí', 'firstYear' => 339000, 'renewal' => 369000], + ['tld' => '.biz', 'setupFee' => 'Miễn phí', 'firstYear' => 399000, 'renewal' => 409000], + ['tld' => '.info', 'setupFee' => 'Miễn phí', 'firstYear' => 490000, 'renewal' => 509000], + ['tld' => '.Pro', 'setupFee' => 'Miễn phí', 'firstYear' => 469000, 'renewal' => 489000], + ['tld' => '.name', 'setupFee' => 'Miễn phí', 'firstYear' => 259000, 'renewal' => 259000], + ['tld' => '.ws', 'setupFee' => 'Miễn phí', 'firstYear' => 909000, 'renewal' => 909000], + ['tld' => '.tv', 'setupFee' => 'Miễn phí', 'firstYear' => 765000, 'renewal' => 819000], + ['tld' => '.mobi', 'setupFee' => 'Miễn phí', 'firstYear' => 589000, 'renewal' => 589000], + ['tld' => '.uk', 'setupFee' => 'Miễn phí', 'firstYear' => 537000, 'renewal' => 537000], + ['tld' => '.asia', 'setupFee' => 'Miễn phí', 'firstYear' => 380000, 'renewal' => 452000], + ['tld' => '.me', 'setupFee' => 'Miễn phí', 'firstYear' => 1029000, 'renewal' => 1029000], + ['tld' => '.us', 'setupFee' => 'Miễn phí', 'firstYear' => 323000, 'renewal' => 323000], + ], + 'services' => [ + ['name' => 'Chứng thực Rapid SSL', 'setupFee' => 230000, 'maintenance' => 750000, 'note' => '01 Domain'], + ['name' => 'Chứng thực Quick SSL', 'setupFee' => 230000, 'maintenance' => 2300000, 'note' => '01 Domain'], + ['name' => 'Chứng thực Wildcard SSL', 'setupFee' => 230000, 'maintenance' => 3300000, 'note' => 'Unlimited Subdomain'], + ['name' => 'Chứng thực True BusinessID', 'setupFee' => 540000, 'maintenance' => 3900000, 'note' => '01 Domain'], + ['name' => 'Chứng thực True BusinessID WildCard', 'setupFee' => 900000, 'maintenance' => 15000000, 'note' => 'Unlimited Subdomain'], + ['name' => 'Đăng ký DNS riêng', 'setupFee' => 200000, 'maintenance' => 'Miễn phí', 'note' => "dns1.tenmiencuaban.com\ndns2.tenmiencuaban.com"], + ['name' => 'Bảo mật thông tin tên miền', 'setupFee' => 'Miễn phí', 'maintenance' => 90000, 'note' => 'Toàn bộ thông tin Whois tên miền của bạn sẽ được giấu kín hoàn toàn với mọi công cụ Whois'], + ['name' => 'Khóa tên miền', 'setupFee' => 'Miễn phí', 'maintenance' => 'Miễn phí', 'note' => 'Tăng cường bảo mật cho tên miền'], + ], + ], JSON_THROW_ON_ERROR) ] ]); diff --git a/database/seeders/TemplateSeeder.php b/database/seeders/TemplateSeeder.php index 15b7e6a..5adad07 100644 --- a/database/seeders/TemplateSeeder.php +++ b/database/seeders/TemplateSeeder.php @@ -51,6 +51,27 @@ class TemplateSeeder extends Seeder 'type_id' => 0, 'status' => 1 ], + [ + 'name' => 'Domain Order New', + 'subject' => '[ADMIN] Đơn đăng ký tên miền mới - {$domain_name}', + 'message' => '
|
|
|
+
|
+
+
|
+
Tài khoản của Quý khách đã được tạo thành công
-- Xin chào {{ $fullName }}, -
-- Chào mừng Quý Khách đến với Thế Giới Số Portal {{ $appName }}! - Tài khoản của Quý khách đã được khởi tạo thành công và sẵn sàng sử dụng. -
-- Tại {{ $appName }}, Quý khách có thể: -
-- Cảm ơn Quý khách đã tin tưởng và đồng hành cùng {{ $appName }}. - Chúc Quý khách có những trải nghiệm tuyệt vời! -
-
+
|
+
+ {{-- Thay bằng đường dẫn logo thực tế --}}
+
+ |
+
+
+ HÓA ĐƠN GIÁ TRỊ GIA TĂNG+(VAT INVOICE)+ ++ Ngày (Date) 09 + tháng (month) 07 + năm (year) 2026 + + ++ Mã CQT (Code): + + |
+
+
+ + Ký hiệu (Serial): + 1C26TCP + + ++ Số (No.): + <Chưa cấp số> + + |
+
| + Số tài khoản (Bank account): + | ++ 7379868 Ngân hàng TMCP Á Châu (ACB) Chi nhánh TP. Hồ Chí Minh + | +
| + | + 797379868 Ngân hàng TMCP Quân Đội (MB) Chi nhánh Phú Nhuận + | +
+
+ | STT (No) |
+
+ Tên hàng hóa, dịch vụ + (Name of goods and services) + |
+ Đơn vị tính (Unit) |
+ Số lượng (Quantity) |
+ Đơn giá (Unit price) |
+ Thành tiền (Amount) |
+
|---|---|---|---|---|---|
| 1 | +Phí đăng ký và duy trì tên miền quốc tế .COM | +Năm | +2,00 | +340.000,00 | +680.000 | +
| + Cộng tiền hàng (Total amount excl. VAT): + | +680.000 | +||||
| + Thuế suất GTGT (VAT rate): + | +10% | ++ Tiền thuế GTGT (VAT amount): + | +68.000 | +||
| + Tổng tiền thanh toán (Total amount): + | +748.000 | +||||
| + Số tiền viết bằng chữ + (Total amount in words): + Bảy trăm bốn mươi tám nghìn đồng chẵn. + | +|||||
|
+
+ Người mua hàng (Buyer)
+
+
+
+ (Chữ ký số nếu có)
+ + (Digital signature if any) + |
+
+
+
+ Người bán hàng (Seller)
+
+
+
+ (Chữ ký điện tử, Chữ ký số)
+
+ + (E-signature, Digital signature) +
+ Signature Valid
+ + Ký bởi (Signed By): + Ký ngày (Signing Date): + |
+
|
- @if(($cfg['show_logo'] ?? true) && $companyLogo)
- |
-
- HÓA ĐƠN ĐẶT HÀNG-(INVOICE ORDER)-
- Ngày {{ $invoice->created_at?->format('d') ?? '—' }}
- tháng {{ $invoice->created_at?->format('m') ?? '—' }}
- năm {{ $invoice->created_at?->format('Y') ?? '—' }}
-
- |
-
-
- -{{-- ══ COMPANY ══ --}} -@if($cfg['show_company_info'] ?? true) -
-@endif - -{{-- ══ BUYER ══ --}} -@if($cfg['show_customer_info'] ?? true) -
-
-@endif
-{{-- ══ ITEMS TABLE ══ --}}
-@if($cfg['show_items_table'] ?? true)
-
- Họ tên người mua hàng (Buyer): {{ $customer?->fullname ?? '—' }}
-
- @if($customer?->company)
- Tên đơn vị: {{ $customer->company }}
- @endif
- @if($customer?->identity_number)
- MST / CMND: {{ $customer->identity_number }}
- @endif
- @if($customer?->address)
- Địa chỉ (Address): {{ $customer->address }}
- @endif
-
+
+
+ + {{ $companyName }} +-
- Số tiền viết bằng chữ (Total amount in words):
- {{ \App\Helpers\NumberToWords::convert((int) $invoice->total) }}.
-
-@endif
+
+ Mã số thuế (Tax code): {{ $companyTaxCode }}
+
-{{-- ══ NOTE ══ --}}
-@if(($cfg['show_note'] ?? true) && $invoice->note)
-Ghi chú: {{ $invoice->note }}
-@endif
+
+ Địa chỉ (Address):
+ {{ $companyAddress }}
+
-{{-- ══ SIGNATURE ══ --}}
-
+ Tên cửa hàng (Shop name):
+
-{{-- ══ FOOTER ══ --}}
-
+
+
+
+ + +
+
+
+ Họ tên người mua hàng (Buyer):
+ {{ $customer?->fullname ?? '' }}
+
+
+
+ Tên đơn vị (Company's name):
+ {{ $customer?->company ?? '' }}
+
+
+
+ Mã số thuế (Tax code): {{ $customer?->identity_number ?? '' }}
+
+
+
+ Địa chỉ (Address):
+ {{ $customer?->address ?? '' }}
+
+
+
+ Hình thức thanh toán (Payment method):
+ {{ $invoice->payment_method ? strtoupper($invoice->payment_method) : 'TM/CK' }}
+
+
+
+
+
+
+
-
+Báo giá thiết kế website-Kính gửi Quý khách hàng
++ +
+
{{ $companyName }}+ + @if ($companyTaxCode) +Mã số thuế (Tax code): {{ $companyTaxCode }}
+ @endif
+
+ @if ($companyAddress)
+ Địa chỉ (Address): {{ $companyAddress }}
+ @endif
+
+ @if ($companyPhone)
+ Điện thoại (Tel): {{ $companyPhone }}
+ @endif
+
+ @if ($companyEmail)
+ Email: {{ $companyEmail }}
+ @endif
+
+
+
+ @@ -182,11 +286,9 @@ ul.ref-list { margin: 2px 0 0 14px; } Website mẫu: |
- |
+ @endforeach Chi tiết báo giá
+
+
| ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||