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' => '
{$company_name}

Thông báo đơn đăng ký tên miền mới

Có một đơn đăng ký tên miền mới vừa được đặt tại {$company_name}.

Thông tin đơn hàng:

  • Tên miền: {$domain_name}
  • Mã đơn hàng: {$order_num}
  • Tổng tiền: {$order_total}
  • Khách hàng: {$customer_name}
  • Email: {$customer_email}
  • Thời gian: {$created_at}
XEM ĐƠN HÀNG

Lưu ý:

  • Đây là tin nhắn tự động. Vui lòng không trả lời tin nhắn này!

', + 'type_id' => 0, + 'status' => 1 + ], + [ + 'name' => 'Domain Order Approved', + 'subject' => 'Tên miền {$domain_name} đã được duyệt đăng ký', + 'message' => '
{$company_name}

Kính chào {$client_name},

Chúng tôi xin thông báo rằng đơn đăng ký tên miền của bạn đã được duyệt và kích hoạt thành công.

Thông tin tên miền:

  • Tên miền: {$domain_name}
  • Mã đơn hàng: {$order_num}

Tên miền của bạn hiện đã hoạt động. Nếu bạn có bất kỳ câu hỏi nào, vui lòng liên hệ với chúng tôi để được hỗ trợ.

Lưu ý:

  • Đây là tin nhắn tự động. Vui lòng không trả lời tin nhắn này!

', + 'type_id' => 0, + 'status' => 1 + ], + [ + 'name' => 'Domain Order Rejected', + 'subject' => 'Tên miền {$domain_name} đã bị từ chối đăng ký', + 'message' => '
{$company_name}

Kính chào {$client_name},

Chúng tôi xin thông báo rằng đơn đăng ký tên miền của bạn đã bị từ chối.

Thông tin đơn hàng:

  • Tên miền: {$domain_name}
  • Mã đơn hàng: {$order_num}
  • Lý do từ chối: {$reason}

Số dư đã thanh toán sẽ được hoàn lại vào tài khoản của bạn. Nếu bạn có bất kỳ câu hỏi nào, vui lòng liên hệ với chúng tôi để được hỗ trợ.

Lưu ý:

  • Đây là tin nhắn tự động. Vui lòng không trả lời tin nhắn này!

', + 'type_id' => 0, + 'status' => 1 + ], [ 'name' => 'Service Expiry', 'subject' => 'Thông báo dịch vụ sắp hết hạn', diff --git a/public/fonts/time-new-roman/SVN-Times-New-Roman-2-bold-italic.ttf b/public/fonts/time-new-roman/SVN-Times-New-Roman-2-bold-italic.ttf new file mode 100644 index 0000000..22f098b Binary files /dev/null and b/public/fonts/time-new-roman/SVN-Times-New-Roman-2-bold-italic.ttf differ diff --git a/public/fonts/time-new-roman/SVN-Times-New-Roman-2-bold.ttf b/public/fonts/time-new-roman/SVN-Times-New-Roman-2-bold.ttf new file mode 100644 index 0000000..0fb8ddc Binary files /dev/null and b/public/fonts/time-new-roman/SVN-Times-New-Roman-2-bold.ttf differ diff --git a/public/fonts/time-new-roman/SVN-Times-New-Roman-2-italic.ttf b/public/fonts/time-new-roman/SVN-Times-New-Roman-2-italic.ttf new file mode 100644 index 0000000..32f9a5e Binary files /dev/null and b/public/fonts/time-new-roman/SVN-Times-New-Roman-2-italic.ttf differ diff --git a/public/fonts/time-new-roman/SVN-Times-New-Roman-2.ttf b/public/fonts/time-new-roman/SVN-Times-New-Roman-2.ttf new file mode 100644 index 0000000..a9a2fc1 Binary files /dev/null and b/public/fonts/time-new-roman/SVN-Times-New-Roman-2.ttf differ diff --git a/public/fonts/time-new-roman/SVN-Times-New-Roman-Bold-Italic.ttf b/public/fonts/time-new-roman/SVN-Times-New-Roman-Bold-Italic.ttf new file mode 100644 index 0000000..231d0c3 Binary files /dev/null and b/public/fonts/time-new-roman/SVN-Times-New-Roman-Bold-Italic.ttf differ diff --git a/public/fonts/time-new-roman/SVN-Times-New-Roman-Bold.ttf b/public/fonts/time-new-roman/SVN-Times-New-Roman-Bold.ttf new file mode 100644 index 0000000..acafb4e Binary files /dev/null and b/public/fonts/time-new-roman/SVN-Times-New-Roman-Bold.ttf differ diff --git a/public/fonts/time-new-roman/SVN-Times-New-Roman-Italic.ttf b/public/fonts/time-new-roman/SVN-Times-New-Roman-Italic.ttf new file mode 100644 index 0000000..c7fb0ef Binary files /dev/null and b/public/fonts/time-new-roman/SVN-Times-New-Roman-Italic.ttf differ diff --git a/public/fonts/time-new-roman/SVN-Times-New-Roman.ttf b/public/fonts/time-new-roman/SVN-Times-New-Roman.ttf new file mode 100644 index 0000000..76151b2 Binary files /dev/null and b/public/fonts/time-new-roman/SVN-Times-New-Roman.ttf differ diff --git a/public/images/qr-code-tgs.jpg b/public/images/qr-code-tgs.jpg new file mode 100644 index 0000000..4fad336 Binary files /dev/null and b/public/images/qr-code-tgs.jpg differ diff --git a/resources/views/emails/customer/holiday-notice.blade.php b/resources/views/emails/customer/holiday-notice.blade.php new file mode 100644 index 0000000..e3c44ab --- /dev/null +++ b/resources/views/emails/customer/holiday-notice.blade.php @@ -0,0 +1,185 @@ +@php + $heroImage = $heroImage ?? 'https://client.tgs.com.vn/index.php?rp=/images/em/368_30.4-CP.png'; + $ticketUrl = $ticketUrl ?? 'https://client.tgs.com.vn/submitticket.php?step=2&deptid=5'; + $supportEmail = $supportEmail ?? 'Support@tgs.com.vn'; + $hotline = $hotline ?? '1900. 6119'; + $holidayName = $holidayName ?? 'ngày Giải phóng miền Nam 30/4 và ngày Quốc tế Lao động 1/5'; + $holidayFrom = $holidayFrom ?? 'Thứ Năm, ngày 30/04/2026'; + $holidayTo = $holidayTo ?? 'Thứ Hai, ngày 04/05/2026'; + $workAgain = $workAgain ?? 'Thứ Ba, ngày 05/05/2026'; +@endphp + + + + + + + +
+ + + + + + + + + + + + +
+ + + + + + + + + +
+ +
+ + + + + + +
+
 
+
Kính gửi: Quý Khách hàng, Quý Đối tác
+
 
+
+ Nhân dịp kỷ niệm {{ $holidayName }}, Thế Giới Số xin trân trọng gửi tới Quý Khách hàng và Quý Đối tác lời chúc sức khỏe, hạnh phúc và thành công. Chúng tôi chân thành cảm ơn sự tin tưởng và đồng hành của Quý Khách hàng, Quý Đối tác trong suốt thời gian qua. +
+
 
+
Thế Giới Số xin thông báo lịch nghỉ lễ như sau:
+
 
+
     Thời gian nghỉ lễ: Từ {{ $holidayFrom }} đến hết {{ $holidayTo }}
+
 
+
     Thời gian làm việc trở lại: {{ $workAgain }}
+
 
+
Trong thời gian nghỉ lễ, nếu có vấn đề cần hỗ trợ kỹ thuật Quý Khách hàng và Quý Đối tác vui lòng liên hệ qua các kênh sau:
+
 
+
+     + Gửi ticket qua: + + {{ $ticketUrl }} + +
+
 
+
+     + Email: + + {{ $supportEmail }} + +
+
 
+
    + Hotline: {{ $hotline }}
+
 
+
Kính chúc Quý Khách hàng và Quý Đối tác có một kỳ nghỉ lễ vui vẻ, an toàn và nhiều sức khỏe.
+
 
+
Thế Giới Số trân trọng thông báo.
+
 
+
+
+
+ + + + + + + + + + + + + + + + + + +
+ + + + + + +
 
+
 
+ + + + + + + + + + + + + + +
  + Premium HOSTING +   + Cloud SERVER +   + EMAIL DOANH NGHIỆP +   + Thiết Kế WEBSITE +  
+
 
+ + + + + + +
 
+
+
+ + + + + + + + + + + + +
 
+

+ CÔNG TY CỔ PHẦN GIẢI PHÁP CÔNG NGHỆ THẾ GIỚI SỐ
+ Địa chỉ: 128/38 Tân Hương, Phường Phú Thọ Hòa, Thành Phố Hồ Chí Minh
+ Tel: (028) 7309. 7379 - Tổng đài: {{ $hotline }}
+ Email: Support@tgs.com.vn
+ + www.TGS.com.vn & + www.thegioiso.CLOUD & + www.thegioiSERVER.com & + www.CloudEmail.vn + +

+

+ + + + 🥇Thế Giới Số🥇- Premium HOSTING • CLOUD • SERVER & WEBSITE | 15+ năm Phụng Sự 🚀 + + + +

+
 
+
+
diff --git a/resources/views/emails/customer/verify-account-welcome.blade.php b/resources/views/emails/customer/verify-account-welcome.blade.php new file mode 100644 index 0000000..848ba61 --- /dev/null +++ b/resources/views/emails/customer/verify-account-welcome.blade.php @@ -0,0 +1,178 @@ +@php + $appName = $appName ?? config('app.name', 'Thế Giới Số'); + $fullName = $fullName ?? 'Quý khách'; + $verifyCode = $verifyCode ?? ''; + $supportEmail = $supportEmail ?? 'support@tgs.com.vn'; + $supportPhone = $supportPhone ?? '1900. 6119'; +@endphp + + + + + + + +
+ + + + + + + + + + + + +
+ + + + + + + + + +
+
✉️
+

+ Xác thực tài khoản {{ $appName }} +

+
+ Vui lòng nhập mã xác thực để kích hoạt tài khoản của bạn +
+
+ + + + + + +
+
 
+
Xin chào {{ $fullName }},
+
 
+
+ Cảm ơn bạn đã đăng ký tài khoản tại {{ $appName }}. Để hoàn tất quá trình đăng ký, vui lòng sử dụng mã xác thực dưới đây: +
+
 
+
+
+ {{ $verifyCode }} +
+
+
 
+
+ Mã xác thực có hiệu lực trong 5 phút. Vui lòng không chia sẻ mã này với bất kỳ ai. +
+
 
+
+ Nếu bạn không thực hiện yêu cầu này, vui lòng bỏ qua email này. +
+
 
+
+ Email này được gửi tự động. Vui lòng không trả lời email này. +
+
 
+
+
+
+ + + + + + + + + + + + + + + + + + +
+ + + + + + +
 
+
 
+ + + + + + + + + + + + + + +
  + Premium HOSTING +   + Cloud SERVER +   + EMAIL DOANH NGHIỆP +   + Thiết Kế WEBSITE +  
+
 
+ + + + + + +
 
+
+
+ + + + + + + + + + + + +
 
+

+ CÔNG TY CỔ PHẦN GIẢI PHÁP CÔNG NGHỆ THẾ GIỚI SỐ
+ Địa chỉ: 128/38 Tân Hương, Phường Phú Thọ Hòa, Thành Phố Hồ Chí Minh
+ Tel: (028) 7309. 7379 - Tổng đài: {{ $supportPhone }}
+ Email: {{ $supportEmail }}
+ + www.TGS.com.vn & + www.thegioiso.CLOUD & + www.thegioiSERVER.com & + www.CloudEmail.vn + +

+

+ + + + 🥇Thế Giới Số🥇- Premium HOSTING • CLOUD • SERVER & WEBSITE | 15+ năm Phụng Sự 🚀 + + + +

+
 
+
+
diff --git a/resources/views/emails/customer/welcome.blade.php b/resources/views/emails/customer/welcome.blade.php index c9cc65b..ae6a565 100644 --- a/resources/views/emails/customer/welcome.blade.php +++ b/resources/views/emails/customer/welcome.blade.php @@ -1,67 +1,181 @@ - - - - - - Chào mừng đến với {{ $appName }} - - - -
-
-
-
🎉
-

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 tạo thành công

-
+@php + $appName = $appName ?? config('app.name', 'Thế Giới Số'); + $fullName = $fullName ?? 'Quý khách'; + $loginUrl = $loginUrl ?? rtrim((string) env('FRONTEND_URL', config('app.url')), '/') . '/dang-nhap'; + $supportEmail = $supportEmail ?? 'support@tgs.com.vn'; + $supportPhone = $supportPhone ?? '1900. 6119'; +@endphp -
-

- 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ể: -

-
    -
  • Khám phá và đăng ký các dịch vụ Cloud VPS, Hosting, Tên miền
  • -
  • Đặt hàng nhanh chóng, thanh toán an toàn
  • -
  • Theo dõi lịch sử đơn hàng và quản lý dịch vụ dễ dàng
  • -
  • Nhận ưu đãi và khuyến mãi độc quyền dành cho khách hàng
  • -
- - - -

- 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! -

-
- - -
-
- - + + + + + + +
+ + + + + + + + + + + + +
+ + + + + + + + + +
+
🎉
+

+ Chào mừng Quý Khách đến với {{ $appName }} +

+
+ 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 {{ $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ể:
+
 
+
    + Khám phá và đăng ký các dịch vụ Cloud VPS, Hosting, Tên miền
+
    + Đặt hàng nhanh chóng, thanh toán an toàn
+
    + Theo dõi lịch sử đơn hàng và quản lý dịch vụ dễ dàng
+
    + Nhận ưu đãi và khuyến mãi độc quyền dành cho khách hàng
+
 
+ +
 
+
+ 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. +
+
 
+
+ Email này được gửi tự động. Vui lòng không trả lời email này. +
+
 
+
+
+
+ + + + + + + + + + + + + + + + + + +
+ + + + + + +
 
+
 
+ + + + + + + + + + + + + + +
  + Premium HOSTING +   + Cloud SERVER +   + EMAIL DOANH NGHIỆP +   + Thiết Kế WEBSITE +  
+
 
+ + + + + + +
 
+
+
+ + + + + + + + + + + + +
 
+

+ CÔNG TY CỔ PHẦN GIẢI PHÁP CÔNG NGHỆ THẾ GIỚI SỐ
+ Địa chỉ: 128/38 Tân Hương, Phường Phú Thọ Hòa, Thành Phố Hồ Chí Minh
+ Tel: (028) 7309. 7379 - Tổng đài: {{ $supportPhone }}
+ Email: {{ $supportEmail }}
+ + www.TGS.com.vn & + www.thegioiso.CLOUD & + www.thegioiSERVER.com & + www.CloudEmail.vn + +

+

+ + + + 🥇Thế Giới Số🥇- Premium HOSTING • CLOUD • SERVER & WEBSITE | 15+ năm Phụng Sự 🚀 + + + +

+
 
+
+
diff --git a/resources/views/pdf/invoice-pdf.blade.php b/resources/views/pdf/invoice-pdf.blade.php new file mode 100644 index 0000000..1e14ab4 --- /dev/null +++ b/resources/views/pdf/invoice-pdf.blade.php @@ -0,0 +1,549 @@ + + + + + + + + + +
+ + + + + + + + +
+ {{-- Thay bằng đường dẫn logo thực tế --}} + Logo + +

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ố> +

+
+ +
+ +
+

+ CÔNG TY CỔ PHẦN GIẢI PHÁP CÔNG NGHỆ THẾ GIỚI SỐ +

+ +
+ Mã số thuế (Tax code): 0318443116 +
+ +
+ Địa chỉ (Address): + Số 128/38 đường Tân Hương, Phường Phú Thọ Hòa, + Thành phố Hồ Chí Minh, Việt Nam +
+ +
+ Tên cửa hàng (Shop name): +
+ + + + + + + + + + +
+ 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 +
+ +
+ QR Code +
+
+ +
+ +
+
+ Họ tên người mua hàng (Buyer): + PHẠM GIA BẢO +
+ +
+ Tên đơn vị (Company's name): + CÔNG TY TNHH MỘT THÀNH VIÊN CÔNG NGHỆ DEVRO +
+ +
+ Mã số thuế (Tax code): 079203005707 +
+ +
+ Địa chỉ (Address): + 194/19/12 Chiến Lược, Phường Bình Trị Đông, Thành Phố Hồ Chí Minh +
+ +
+ Hình thức thanh toán (Payment method): + TM/CK +
+
+
+ Watermark + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
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)
1Phí đăng ký và duy trì tên miền quốc tế .COMNăm2,00340.000,00680.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): +
+
+ + +
+ + diff --git a/resources/views/pdf/invoice.blade.php b/resources/views/pdf/invoice.blade.php index 30896a4..e709365 100644 --- a/resources/views/pdf/invoice.blade.php +++ b/resources/views/pdf/invoice.blade.php @@ -1,317 +1,599 @@ @php -$companyLogo = $companyLogo ?? ''; -$companyName = $companyName ?? ''; -$companyAddress = $companyAddress ?? ''; -$companyPhone = $companyPhone ?? ''; -$companyEmail = $companyEmail ?? ''; -$companyTaxCode = $companyTaxCode ?? ''; -$brandColor = $brandColor ?? '#1a56db'; -$cfg = array_merge([ - 'accent_color' => $brandColor, - 'show_logo' => true, - 'show_company_info' => true, - 'show_customer_info' => true, - 'show_order_info' => true, - 'show_items_table' => true, - 'show_summary' => true, - 'show_note' => true, - 'footer_text' => 'Cảm ơn quý khách đã sử dụng dịch vụ.', - 'footer_note' => 'Đây là hóa đơn điện tử được tạo tự động, không cần chữ ký số.', -], $cfg ?? []); + $companyLogo = $companyLogo ?? setting('setting.logo') ?? ''; + $companyName = $companyName ?? 'CÔNG TY CỔ PHẦN GIẢI PHÁP CÔNG NGHỆ THẾ GIỚI SỐ'; + $companyAddress = $companyAddress ?? 'Số 128/38 đường Tân Hương, Phường Phú Thọ Hòa, Thành phố Hồ Chí Minh, Việt Nam'; + $companyTaxCode = $companyTaxCode ?? '0318443116'; + $cfg = array_merge([ + 'show_logo' => true, + 'show_company_info' => true, + 'show_customer_info' => true, + 'show_items_table' => true, + 'show_summary' => true, + 'footer_text' => '', + 'footer_note' => '', + ], $cfg ?? []); + $invoiceDate = $invoice->created_at ?? now(); + $taxBase = ($invoice->total ?? 0) - ($invoice->tax ?? 0); + $taxRate = ($taxBase > 0 && ($invoice->tax ?? 0) > 0) ? round($invoice->tax / $taxBase * 100) : 0; @endphp - -Hóa đơn #{{ $invoice->invoice_number }} - + .invoice-title { + text-align: center; + } + + .invoice-title h1 { + margin: 0; + font-size: 21px; + font-weight: 700; + text-transform: uppercase; + } + + .invoice-title h2 { + margin: 2px 0; + font-size: 15px; + font-style: italic; + font-weight: 700; + } + + .invoice-title p { + margin: 3px 0; + } + + .invoice-number { + width: 190px; + padding-left: 15px; + font-size: 11px; + } + + .invoice-number p { + margin: 3px 0; + } + + .divider { + margin: 7px 0; + border: 0; + border-top: 1px solid #bbb; + } + + .company-name { + margin: 0 0 3px; + font-size: 16px; + font-weight: 700; + text-transform: uppercase; + } + + .info-row { + margin: 4px 0; + } + + .seller-section { + position: relative; + min-height: 120px; + padding-right: 120px; + } + + .qr-code { + position: absolute; + top: 8px; + right: 15px; + width: 90px; + height: 90px; + border: 1px solid #555; + text-align: center; + line-height: 90px; + } + + .bank-table { + width: 100%; + margin: 4px 0; + border-collapse: collapse; + } + + .bank-table td { + padding: 0; + vertical-align: top; + } + + .bank-table-label { + width: 155px; + } + + .product-table { + margin-top: 6px; + table-layout: auto; + } + + .product-table th, + .product-table td { + border: 1px solid #777; + } + + .product-table th { + padding: 4px; + height: 48px; + text-align: center; + vertical-align: middle; + font-weight: 700; + } + + .product-table tbody td { + padding: 1px 2px; + height: auto; + line-height: 1.2; + } + + .product-table th:first-child, + .product-table td:first-child { + width: 8mm; + padding-left: 1px; + padding-right: 1px; + } + + .text-center { + text-align: center; + } + + .text-right { + text-align: right; + } + + .italic { + font-style: italic; + } + + .bold { + font-weight: 700; + } + + .summary-label { + text-align: right; + } + + .amount-words { + padding: 5px !important; + } + + .signature-table { + margin-top: 12px; + } + + .signature-table td { + width: 50%; + height: 130px; + text-align: center; + vertical-align: top; + } + + .signature-title { + font-size: 12px; + font-weight: 700; + } + + .signature-box { + width: 260px; + margin: 35px auto 0; + padding: 8px; + color: red; + border: 1px solid #65a765; + background: #eef8ee; + text-align: left; + font-weight: 700; + line-height: 1.8; + } + + .footer { + text-align: center; + font-size: 9px; + } + + .footer p { + margin: 3px 0; + } + + .product-table-wrapper { + position: relative; + margin-top: 6px; + } + + .product-table-watermark { + position: absolute; + top: 0; + left: 50%; + + width: 430px; + height: auto; + + opacity: 0.3; + + margin-left: -215px; + + z-index: 0; + } + + .product-table { + position: relative; + z-index: 1; + width: 100%; + margin-top: 0; + table-layout: auto; + border-collapse: collapse; + } + + .product-table th, + .product-table td { + background: transparent; + } + - -
-{{-- ══ HEADER ══ --}} - - - - - - -
- @if(($cfg['show_logo'] ?? true) && $companyLogo) - {{ $companyName }} - @endif - -

HÓA ĐƠN ĐẶT HÀNG

-

(INVOICE ORDER)

- -
- + +
+
- - - - @if($order) - - - - - @endif - - - - - - - + + + + -
Mã hóa đơn:{{ $invoice->invoice_number }}
Mã đơn hàng:{{ $order->order_num }}
Hạn thanh toán:{{ $invoice->due_date ? \Carbon\Carbon::parse($invoice->due_date)->format('d/m/Y') : '—' }}
Trạng thái: - @if($invoice->status == 2)Đã thanh toán - @elseif($invoice->status == 1)Chưa thanh toán - @elseĐã hủy@endif + + @if(($cfg['show_logo'] ?? true) && $companyLogo) + {{ $companyName }} + @else + {{ $companyName }} + @endif + +

HÓA ĐƠN GIÁ TRỊ GIA TĂNG

+

(VAT INVOICE)

+ +

+ Ngày (Date) {{ $invoiceDate?->format('d') ?? '' }} + tháng (month) {{ $invoiceDate?->format('m') ?? '' }} + năm (year) {{ $invoiceDate?->format('Y') ?? '' }} +

+ +

+ Mã CQT (Code): +

+
+

+ Ký hiệu (Serial): + 1C26TCP +

+ +

+ Số (No.): + {{ $invoice->invoice_number ?? '' }} +

-
- -
- -{{-- ══ COMPANY ══ --}} -@if($cfg['show_company_info'] ?? true) - - - - - -
-

{{ $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 -
-
-@endif - -{{-- ══ BUYER ══ --}} -@if($cfg['show_customer_info'] ?? 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 - - - - -
-
- Hình thức thanh toán: - {{ $invoice->payment_method ? strtoupper($invoice->payment_method) : 'TM/CK' }} -
-
-
Email: {{ $customer?->email ?? '—' }}
-
-
-@endif -{{-- ══ ITEMS TABLE ══ --}} -@if($cfg['show_items_table'] ?? true) - - - - - - - - - - - - -@foreach($invoiceItems as $i => $item) - - - - - - - - -@endforeach +
-@if($cfg['show_summary'] ?? true) -{{-- Sub-total --}} - - - - -@if($invoice->discount_amount > 0) - - - - -@endif -@php $taxRate = ($invoice->total > 0 && $invoice->tax > 0) ? round($invoice->tax / ($invoice->total - $invoice->tax) * 100) : 0; @endphp - - - - - - - - - -@endif - -
STTTên hàng hóa, dịch vụĐơn vị tínhSố lượngĐơn giá (đ)Thành tiền (đ)
{{ $i + 1 }}{{ $item->description ?? ($item->product?->name ?? 'Dịch vụ') }}{{ ($item->months && $item->months > 0) ? 'Tháng' : 'Lần' }}{{ number_format($item->quantity ?? 1, 2, ',', '.') }}{{ number_format($item->unit_price, 0, ',', '.') }}{{ number_format($item->total_price, 0, ',', '.') }}
Cộng tiền hàng (Total amount excl. VAT){{ number_format($invoice->sub_total, 0, ',', '.') }}
Giảm giá (Discount)-{{ number_format($invoice->discount_amount, 0, ',', '.') }}
Thuế suất GTGT (VAT rate): {{ $taxRate > 0 ? $taxRate.'%' : 'Không áp dụng' }}Tiền thuế GTGT (VAT amount){{ number_format($invoice->tax, 0, ',', '.') }}
Tổng tiền thanh toán (Total amount){{ number_format($invoice->total, 0, ',', '.') }}
+
+

+ {{ $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 ══ --}} - - - - - - -
-

Người mua hàng
(Buyer's signature)

-

Ký, ghi rõ họ tên

-
-

{{ $customer?->fullname ?? '' }}

-
-

Người bán hàng
(Seller's signature)

-

Ký, đóng dấu, ghi rõ họ tên

-
-

{{ $companyName }}

-
+
+ Tên cửa hàng (Shop name): +
-{{-- ══ FOOTER ══ --}} - + + + + + + + + + +
+ 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 +
+
+ QR Code +
+
+ +
+ +
+
+ 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' }} +
+
+
+ Watermark + + + + + + + + + + + + + + + + + + + + + + @foreach($invoiceItems as $i => $item) + + + + + + + + + @endforeach + + @if($cfg['show_summary'] ?? true) + + + + + + @if($invoice->discount_amount > 0) + + + + + @endif + + + + + + + + + + + + + @endif + + + + + +
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)
{{ $i + 1 }}{{ $item->description ?? ($item->product?->name ?? 'Dịch vụ') }}{{ ($item->months && $item->months > 0) ? 'Tháng' : 'Lần' }}{{ number_format($item->quantity ?? 1, 2, ',', '.') }}{{ number_format($item->unit_price, 0, ',', '.') }}{{ number_format($item->total_price, 0, ',', '.') }}
+ Cộng tiền hàng (Total amount excl. VAT): + {{ number_format($invoice->sub_total, 0, ',', '.') }}
+ Giảm giá (Discount): + -{{ number_format($invoice->discount_amount, 0, ',', '.') }}
+ Thuế suất GTGT (VAT rate): + {{ $taxRate > 0 ? $taxRate . '%' : 'Không áp dụng' }} + Tiền thuế GTGT (VAT amount): + {{ number_format($invoice->tax, 0, ',', '.') }}
+ Tổng tiền thanh toán (Total amount): + {{ number_format($invoice->total, 0, ',', '.') }}
+ Số tiền viết bằng chữ + (Total amount in words): + {{ \App\Helpers\NumberToWords::convert((int) $invoice->total) }}. +
+
+ + + + + + + + +
+
+ Người mua hàng (Buyer) +
+ +
+ (Chữ ký số nếu có)
+ (Digital signature if any) +
+ +
+ {{ $customer?->fullname ?? '' }} +
+
+
+ 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): {{ $companyName }}
+ Ký ngày (Signing Date): +
+
+ +
diff --git a/resources/views/pdf/web-design-quote.blade.php b/resources/views/pdf/web-design-quote.blade.php index e6af940..52e3c1a 100644 --- a/resources/views/pdf/web-design-quote.blade.php +++ b/resources/views/pdf/web-design-quote.blade.php @@ -1,5 +1,6 @@ @php $brandColor = $brandColor ?? '#1a56db'; +$companyLogo = $companyLogo ?? setting('setting.logo') ?? ''; $frontendLabels = [ 'nextjs' => 'Next.js', @@ -27,14 +28,29 @@ $fmt = fn ($n) => number_format((float) $n, 0, ',', '.'); +
- @if ($companyLogo) - - @endif +
logo + logo + -
{{ $companyName }}
- @if ($companyAddress)
Địa chỉ: {{ $companyAddress }}
@endif - @if ($companyPhone)
Điện thoại: {{ $companyPhone }}
@endif - @if ($companyEmail)
Email: {{ $companyEmail }}
@endif - @if ($companyTaxCode)
MST: {{ $companyTaxCode }}
@endif +

BÁO GIÁ WEBSITE

+
Số báo giá: {{ $quote->quote_number }}
@@ -123,9 +195,41 @@ ul.ref-list { margin: 2px 0 0 14px; }
-
-

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 + + + + + + + + + + +
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
+ +
+ QR Code +

@@ -182,11 +286,9 @@ ul.ref-list { margin: 2px 0 0 14px; } Website mẫu: - + @foreach ($referenceWebsites as $ref) + {{ $ref }}
+ @endforeach @endif @@ -195,10 +297,12 @@ ul.ref-list { margin: 2px 0 0 14px; }
Chi tiết báo giá
+
+ Watermark - + @@ -220,19 +324,31 @@ ul.ref-list { margin: 2px 0 0 14px; } @endforeach + + + + + @if ($quote->discount > 0) + + + + + @endif + @if ($quote->vat_percent > 0) + + + + + @endif + + + +
STTSTT
(No)
Hạng mục SL Đơn giá ({{ $quote->currency }}){{ $fmt(($item['quantity'] ?? 1) * ($item['unit_price'] ?? 0)) }}
Tạm tính:{{ $fmt($quote->subtotal) }}
Giảm giá:-{{ $fmt($quote->discount) }}
+ VAT ({{ rtrim(rtrim(number_format($quote->vat_percent, 2, '.', ''), '0'), '.') }}%): + {{ $fmt($vatAmount) }}
Tổng cộng:{{ $fmt($quote->total) }} {{ $quote->currency }}
- - - - @if ($quote->discount > 0) - - @endif - @if ($quote->vat_percent > 0) - - @endif - -
Tạm tính:{{ $fmt($quote->subtotal) }}
Giảm giá:-{{ $fmt($quote->discount) }}
VAT ({{ rtrim(rtrim(number_format($quote->vat_percent, 2, '.', ''), '0'), '.') }}%):{{ $fmt($vatAmount) }}
Tổng cộng:{{ $fmt($quote->total) }} {{ $quote->currency }}
+
@if ($quote->note) @@ -261,5 +377,6 @@ ul.ref-list { margin: 2px 0 0 14px; } Báo giá có hiệu lực {{ $quote->valid_until ? 'đến ngày ' . $quote->valid_until->format('d/m/Y') : 'trong 30 ngày kể từ ngày lập' }}. Cảm ơn Quý khách đã quan tâm đến dịch vụ thiết kế website của {{ $companyName }}.
+
diff --git a/routes/api.php b/routes/api.php index 3cd5db1..e0525bf 100644 --- a/routes/api.php +++ b/routes/api.php @@ -3,6 +3,9 @@ use App\Http\Controllers\admin\VCenterManagerController; use App\Http\Controllers\Api\Admin\Auth\AuthController; use App\Http\Controllers\Api\Admin\Setting\SettingController as AdminSettingController; +use App\Http\Controllers\Api\Admin\Domain\AdminDomainOrderController; +use App\Http\Controllers\Api\Admin\Domain\AdminDomainPricingController; +use App\Http\Controllers\Api\Admin\Domain\InetDomainController; use App\Http\Controllers\Api\QueueMonitorController; use App\Http\Controllers\Api\Admin\Staff\StaffOnlineController; use App\Http\Controllers\Api\Admin\Customer\CustomerController; @@ -33,6 +36,7 @@ use App\Http\Controllers\Api\Admin\Transaction\AdminTransactionController; use App\Http\Controllers\Api\Admin\Ticket\TicketController as AdminTicketController; use App\Http\Controllers\Api\Admin\Docs\PostCategoryController as AdminPostCategoryController; use App\Http\Controllers\Api\Admin\Docs\PostController as AdminPostController; +use App\Http\Controllers\Api\Admin\Media\MediaController as AdminMediaController; use App\Http\Controllers\Api\NotificationController; use App\Http\Controllers\Api\Admin\InfraManagement\InfraManagementController; use App\Http\Services\Media; @@ -41,6 +45,7 @@ use App\Models\ModuleVmware; use Illuminate\Http\Request; use Illuminate\Support\Facades\Route; +use Barryvdh\DomPDF\Facade\Pdf; /* |-------------------------------------------------------------------------- @@ -62,7 +67,6 @@ Route::prefix('v1') Route::post('{invoiceNumber}/send-email', 'sendInvoiceEmail'); Route::get('{invoiceNumber}/preview-pdf', 'previewInvoicePdf'); }); - }) /* Api for Admin */ ->group(function () { @@ -116,19 +120,24 @@ Route::prefix('v1') Route::delete('{uuid}/destroy', 'destroy')->middleware('perm:customer.destroy'); Route::post('{uuid}/reset-password', 'resetPassword')->middleware('perm:customer.edit'); Route::post('{uuid}/send-password', 'sendPasswordEmail')->middleware('perm:customer.edit'); + Route::post('{uuid}/send-email', 'sendEmail')->middleware('perm:customer.edit'); Route::post('{uuid}/login-as', 'loginAs')->middleware('perm:customer.edit'); Route::post('{uuid}/add-credit', 'addCredit')->middleware('perm:customer.edit'); Route::post('{uuid}/add-total', 'addTotal')->middleware('perm:customer.edit'); Route::get('{uuid}/services', 'getServices')->middleware('perm:customer.view'); Route::post('{uuid}/services', 'createService')->middleware('perm:customer.edit'); - Route::post('{uuid}/services/bulk-delete', 'bulkDeleteServices')->middleware('perm:customer.edit'); + Route::post('{uuid}/services/bulk-delete', 'bulkDeleteServices')->middleware( + 'perm:customer.edit', + ); Route::put('{uuid}/services/{serviceId}', 'updateService')->middleware('perm:customer.edit'); Route::get('{uuid}/orders', 'getOrders')->middleware('perm:customer.view'); Route::get('{uuid}/invoices', 'getInvoices')->middleware('perm:customer.view'); Route::get('{uuid}/transactions', 'getTransactions')->middleware('perm:customer.view'); Route::get('{uuid}/domains', 'getDomains')->middleware('perm:customer.view'); Route::get('{uuid}/dns-domains', 'getDnsDomains')->middleware('perm:customer.view'); - Route::get('{uuid}/dns-domains/{domainId}/records', 'getDnsDomainRecords')->middleware('perm:customer.view'); + Route::get('{uuid}/dns-domains/{domainId}/records', 'getDnsDomainRecords')->middleware( + 'perm:customer.view', + ); Route::post('{uuid}/restore', 'restore')->middleware('perm:customer.edit'); Route::delete('{uuid}/force', 'forceDelete')->middleware('perm:customer.destroy'); }); @@ -380,6 +389,39 @@ Route::prefix('v1') Route::delete('{id}', 'destroy')->middleware('perm:module-vcenter.manager'); }); + // Domain Orders (module: order) + Route::prefix('domain-orders') + ->controller(AdminDomainOrderController::class) + ->group(function () { + Route::get('pending-count', 'pendingCount')->middleware('perm:order.view'); + Route::get('/', 'index')->middleware('perm:order.view'); + Route::get('{id}', 'show')->middleware('perm:order.view'); + Route::post('{id}/confirm-registration', 'confirmRegistration')->middleware('perm:order.edit'); + Route::patch('{id}/kyc', 'updateKyc')->middleware('perm:order.edit'); + Route::patch('{id}/activate', 'activate')->middleware('perm:order.edit'); + Route::post('{id}/reject-and-refund', 'rejectAndRefund')->middleware('perm:order.edit'); + }); + + // Domain Pricing (module: settings) + Route::prefix('domain-pricing') + ->controller(AdminDomainPricingController::class) + ->group(function () { + Route::get('/', 'index'); + Route::put('/', 'update'); + Route::post('/reset', 'reset'); + }); + + // Domain Tools — proxy tới iNET API (module: domain.manager) + Route::prefix('domain-tools') + ->controller(InetDomainController::class) + ->group(function () { + Route::get('whois/{domain}', 'whois')->middleware('perm:domain.manager'); + Route::get('check-available', 'checkAvailable')->middleware('perm:domain.manager'); + Route::post('bulk-check-available', 'bulkCheckAvailable')->middleware('perm:domain.manager'); + Route::post('ai-suggest', 'aiSuggest')->middleware('perm:domain.manager'); + Route::get('suffixes', 'suffixes')->middleware('perm:domain.manager'); + }); + // Settings (module: administrator) Route::prefix('settings') ->controller(AdminSettingController::class) @@ -388,25 +430,42 @@ Route::prefix('v1') Route::put('/', 'update'); }); + Route::prefix('media') + ->controller(AdminMediaController::class) + ->group(function () { + Route::get('/', 'index'); + }); + Route::post('media/upload', [AdminSettingController::class, 'uploadMedia']); // Provider (active vCenter chain + provider settings — module: module-vcenter) - Route::prefix('provider') - ->group(function () { - Route::get('active-chain', [\App\Http\Controllers\Api\Admin\Provider\ActiveInfraController::class, 'chain']) - ->middleware('perm:module-vcenter.view'); - Route::post('{type}/{id}/activate', [\App\Http\Controllers\Api\Admin\Provider\ActiveInfraController::class, 'activate']) - ->whereIn('type', ['vcenter', 'datacenter', 'host', 'datastore', 'network']) - ->middleware('perm:module-vcenter.edit'); - Route::post('{type}/{id}/deactivate', [\App\Http\Controllers\Api\Admin\Provider\ActiveInfraController::class, 'deactivate']) - ->whereIn('type', ['vcenter', 'datacenter', 'host', 'datastore', 'network']) - ->middleware('perm:module-vcenter.edit'); + Route::prefix('provider')->group(function () { + Route::get('active-chain', [ + \App\Http\Controllers\Api\Admin\Provider\ActiveInfraController::class, + 'chain', + ])->middleware('perm:module-vcenter.view'); + Route::post('{type}/{id}/activate', [ + \App\Http\Controllers\Api\Admin\Provider\ActiveInfraController::class, + 'activate', + ]) + ->whereIn('type', ['vcenter', 'datacenter', 'host', 'datastore', 'network']) + ->middleware('perm:module-vcenter.edit'); + Route::post('{type}/{id}/deactivate', [ + \App\Http\Controllers\Api\Admin\Provider\ActiveInfraController::class, + 'deactivate', + ]) + ->whereIn('type', ['vcenter', 'datacenter', 'host', 'datastore', 'network']) + ->middleware('perm:module-vcenter.edit'); - Route::get('settings', [\App\Http\Controllers\Api\Admin\Provider\ProviderSettingController::class, 'index']) - ->middleware('perm:module-vcenter.view'); - Route::put('settings', [\App\Http\Controllers\Api\Admin\Provider\ProviderSettingController::class, 'update']) - ->middleware('perm:module-vcenter.edit'); - }); + Route::get('settings', [ + \App\Http\Controllers\Api\Admin\Provider\ProviderSettingController::class, + 'index', + ])->middleware('perm:module-vcenter.view'); + Route::put('settings', [ + \App\Http\Controllers\Api\Admin\Provider\ProviderSettingController::class, + 'update', + ])->middleware('perm:module-vcenter.edit'); + }); // Dashboard Stats Route::prefix('dashboard') @@ -456,27 +515,27 @@ Route::prefix('v1') Route::prefix('email-templates') ->controller(EmailTemplateController::class) ->group(function () { - Route::get('/', 'index'); - Route::post('/', 'store'); - Route::get('email-settings', 'getEmailSettings'); - Route::put('email-settings', 'updateEmailSettings'); - Route::get('{id}', 'show'); - Route::put('{id}', 'update'); - Route::delete('{id}', 'destroy'); - Route::get('{id}/preview-invoice','previewInvoiceJson'); + Route::get('/', 'index'); + Route::post('/', 'store'); + Route::get('email-settings', 'getEmailSettings'); + Route::put('email-settings', 'updateEmailSettings'); + Route::get('{id}', 'show'); + Route::put('{id}', 'update'); + Route::delete('{id}', 'destroy'); + Route::get('{id}/preview-invoice', 'previewInvoiceJson'); }); // Notification Email Templates — block-based editor Route::prefix('notification-templates') ->controller(EmailNotificationTemplateController::class) ->group(function () { - Route::get('/', 'index'); - Route::post('/', 'store'); - Route::get('field-definitions','fieldDefinitions'); - Route::get('{id}', 'show'); - Route::put('{id}', 'update'); - Route::delete('{id}', 'destroy'); - Route::get('{id}/preview', 'preview'); + Route::get('/', 'index'); + Route::post('/', 'store'); + Route::get('field-definitions', 'fieldDefinitions'); + Route::get('{id}', 'show'); + Route::put('{id}', 'update'); + Route::delete('{id}', 'destroy'); + Route::get('{id}/preview', 'preview'); }); // Transactions — manual deposit review (module: transaction) @@ -527,9 +586,15 @@ Route::prefix('v1') Route::prefix('notifications') ->controller(NotificationController::class) ->group(function () { - Route::get('/', fn () => app(NotificationController::class)->index(request(), 'admin')); - Route::patch('{id}/read', fn ($id) => app(NotificationController::class)->markRead(request(), 'admin', (int) $id)); - Route::patch('read-all', fn () => app(NotificationController::class)->markAllRead(request(), 'admin')); + Route::get('/', fn() => app(NotificationController::class)->index(request(), 'admin')); + Route::patch( + '{id}/read', + fn($id) => app(NotificationController::class)->markRead(request(), 'admin', (int) $id), + ); + Route::patch( + 'read-all', + fn() => app(NotificationController::class)->markAllRead(request(), 'admin'), + ); }); // Activity Logs @@ -633,8 +698,8 @@ Route::prefix('v1') })->name('api.GetConsoleTicket'); }); - Route::prefix('hello')->group(function () { - return 'heloo world'; + Route::get('pdf-invoice', function () { + // return Pdf::loadView('pdf.invoice-pdf')->setPaper('a4', 'portrait')->stream('hoa-don-mau.pdf'); }); }); diff --git a/routes/client.php b/routes/client.php index 225577a..bc87645 100644 --- a/routes/client.php +++ b/routes/client.php @@ -2,6 +2,8 @@ use App\Http\Controllers\Api\Setting\SettingController; use App\Http\Controllers\Api\Client\ClientController; +use App\Http\Controllers\Api\Client\Domain\DomainOrderController as ClientDomainOrderController; +use App\Http\Controllers\Api\Client\Domain\DomainPricingController; use App\Http\Controllers\Api\Client\Service\DomainController as ClientDomainController; use App\Http\Controllers\Api\Client\Service\ServiceController as ClientServiceController; use App\Http\Controllers\Api\Service\OrderControllerRefactor; @@ -10,6 +12,7 @@ use App\Http\Controllers\Api\Service\ServiceController; use App\Http\Controllers\Api\Service\SnapshotController; use App\Http\Controllers\Api\Service\VmController; use App\Http\Controllers\Api\Service\VmRebuildController; +use App\Http\Controllers\Api\Admin\Domain\InetDomainController; use App\Http\Controllers\Api\Client\BankController as ClientBankController; use App\Http\Controllers\Api\Client\Docs\PostCategoryController as ClientPostCategoryController; use App\Http\Controllers\Api\Client\Docs\PostController as ClientPostController; @@ -29,6 +32,8 @@ Route::prefix('v1') Route::controller(ClientController::class)->group(function () { Route::post('login', 'login')->name('login'); Route::post('register', 'register')->name('register'); + Route::post('verify-email', 'verifyEmail')->name('verify-email'); + Route::post('resend-verify', 'resendVerifyCode')->name('resend-verify'); Route::post('refresh', 'refresh')->name('refresh'); Route::post('forgot-password', 'forgotPassword')->name('forgot-password'); Route::post('impersonate/exchange', 'impersonateExchange')->name('impersonate.exchange'); @@ -132,6 +137,15 @@ Route::prefix('v1') Route::post('{ticket_num}/reply', 'reply')->middleware('throttle:10,1')->name('reply'); Route::patch('{ticket_num}/close', 'close')->name('close'); }); + + // Domain Orders (auth required) + Route::middleware(['api.auth']) + ->prefix('domain-orders') + ->controller(ClientDomainOrderController::class) + ->as('domain-orders.') + ->group(function () { + Route::post('/', 'store')->name('store'); + }); }); // Services (Products) - Public & Protected Routes @@ -184,6 +198,26 @@ Route::prefix('v1') Route::get('{slug}', 'show')->name('show'); }); + // Domain Pricing - Public Routes (no auth required) + Route::prefix('domain-pricing') + ->as('domain-pricing.') + ->controller(DomainPricingController::class) + ->group(function () { + Route::get('/', 'index')->name('index'); + }); + + // iNET Domain Tools - Public Routes (no auth required) + Route::prefix('inet-domain') + ->as('inet-domain.') + ->controller(InetDomainController::class) + ->group(function () { + Route::get('whois/{domain}', 'whois')->name('whois'); + Route::get('check-available', 'checkAvailable')->name('check-available'); + Route::post('bulk-check-available', 'bulkCheckAvailable')->name('bulk-check-available'); + Route::post('ai-suggest', 'aiSuggest')->name('ai-suggest'); + Route::get('suffixes', 'suffixes')->name('suffixes'); + }); + // Product Categories - Public Routes (no auth required) Route::prefix('group') ->as('product-categories.') diff --git a/routes/web.php b/routes/web.php index bd613f1..ab4dd33 100644 --- a/routes/web.php +++ b/routes/web.php @@ -18,8 +18,11 @@ // See routes/api.php for admin API endpoints Route::any('{any}', function () { - return response()->json([ - 'error' => true, - 'message' => 'Route not found' - ], 404); + return response()->json( + [ + 'error' => true, + 'message' => 'Route not found', + ], + 404, + ); })->where('any', '.*'); diff --git a/storage/app/app-header.tsx b/storage/app/app-header.tsx new file mode 100644 index 0000000..6554823 --- /dev/null +++ b/storage/app/app-header.tsx @@ -0,0 +1,514 @@ +import { Fragment, useState } from 'react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useNavigate, useRouterState } from '@tanstack/react-router' +import dayjs from 'dayjs' +import relativeTime from 'dayjs/plugin/relativeTime' +import 'dayjs/locale/vi' +import { + BellOutlined, + CheckOutlined, + ClockCircleOutlined, + GlobalOutlined, + InboxOutlined, + LogoutOutlined, + MoonOutlined, + QuestionCircleOutlined, + SearchOutlined, + SettingOutlined, + ShoppingCartOutlined, + TeamOutlined, + UserOutlined, +} from '@ant-design/icons' +import { + Avatar, + Badge, + Breadcrumb, + Button, + ConfigProvider, + Divider, + Dropdown, + Flex, + Input, + List, + Popover, + Select, + Space, + Switch, + Tag, + Tooltip, + Typography, +} from 'antd' +import { useAuthStore } from '@/stores/auth-store' +import { SidebarTrigger } from '@/components/ui/sidebar' +import { + fetchNotifications, + markAllNotificationsRead, + markNotificationRead, + type NotificationItem, +} from '@/features/notifications/api' + +dayjs.extend(relativeTime) +dayjs.locale('vi') + +const { Text } = Typography + +// ---------------------------------------------------------------------------- +// Breadcrumb labels (mirrors src/components/layout/app-breadcrumb.tsx) +// ---------------------------------------------------------------------------- + +const segmentLabels: Record = { + '': 'Tổng quan', + dashboard: 'Tổng quan', + create: 'Thêm mới', + groups: 'Nhóm', + tasks: 'Công việc', + customers: 'Khách hàng', + 'product-service': 'Sản phẩm / Dịch vụ', + products: 'Sản phẩm', + 'product-groups': 'Nhóm sản phẩm', + 'vcenter-manager': 'vCenter Manager', + 'infra-management': 'Hạ tầng', + 'order-invoice': 'Đơn hàng / Hoá đơn', + apps: 'Ứng dụng', + chats: 'Tin nhắn', + users: 'Người dùng', + settings: 'Cài đặt', + account: 'Tài khoản', + appearance: 'Giao diện', + notifications: 'Thông báo', + display: 'Hiển thị', + 'help-center': 'Trung tâm trợ giúp', + tickets: 'Ticket hỗ trợ', + roles: 'Vai trò', + 'activity-logs': 'Nhật ký hoạt động', + 'server-manager': 'Server Manager', + 'domain-manager': 'Domain Manager', + 'domain-fluctuations': 'Khai báo biến động tên miền', + 'ip-pool': 'IP Pool', + 'os-groups': 'OS Groups', + addons: 'Addons', + transactions: 'Giao dịch', + posts: 'Bài viết', + 'post-categories': 'Danh mục bài viết', + resolve: 'Phân giải', +} + +function labelFor(segment: string) { + if (/^\d+$/.test(segment)) return 'Chi tiết' + return segmentLabels[segment] ?? segment +} + +// ---------------------------------------------------------------------------- +// Notifications +// ---------------------------------------------------------------------------- + +const notifIconMap: Record< + NotificationItem['type'], + { icon: typeof BellOutlined; color: string } +> = { + ticket_reply: { icon: TeamOutlined, color: '#722ed1' }, + deposit_success: { icon: ShoppingCartOutlined, color: '#f16723' }, +} + +// ---------------------------------------------------------------------------- +// AppHeader +// ---------------------------------------------------------------------------- + +export function AppHeader() { + const pathname = useRouterState({ select: (s) => s.location.pathname }) + const { auth } = useAuthStore() + + const [notifOpen, setNotifOpen] = useState(false) + const [settingsOpen, setSettingsOpen] = useState(false) + const [darkMode, setDarkMode] = useState(false) + const [compact, setCompact] = useState(false) + const [language, setLanguage] = useState<'vi' | 'en'>('vi') + + const qc = useQueryClient() + const { data: notifData } = useQuery({ + queryKey: ['notifications'], + queryFn: fetchNotifications, + refetchInterval: 60_000, + }) + const notifs = notifData?.data.items ?? [] + const unreadCount = notifData?.data.unread_count ?? 0 + + const markOneRead = useMutation({ + mutationFn: markNotificationRead, + onSuccess: () => qc.invalidateQueries({ queryKey: ['notifications'] }), + }) + const markAllRead = useMutation({ + mutationFn: markAllNotificationsRead, + onSuccess: () => qc.invalidateQueries({ queryKey: ['notifications'] }), + }) + + const segments = pathname.split('/').filter(Boolean) + const crumbs = + segments.length === 0 + ? [{ title: Tổng quan }] + : segments.map((seg, idx) => { + const isLast = idx === segments.length - 1 + const label = labelFor(seg) + return { + title: isLast ? {label} : {label}, + } + }) + + const fullName = auth.user + ? `${auth.user.firstname ?? ''} ${auth.user.lastname ?? ''}`.trim() || + auth.user.username + : 'Khách' + const userRole = auth.user?.roles?.[0] ?? 'Người dùng' + const initials = + fullName + .split(' ') + .filter(Boolean) + .slice(-2) + .map((s) => s[0]?.toUpperCase()) + .join('') || 'U' + + const navigate = useNavigate() + + const userMenuItems = [ + { + key: 'profile', + icon: , + label: 'Hồ sơ', + onClick: () => navigate({ to: '/settings' }), + }, + { + key: 'logout', + icon: , + label: 'Đăng xuất', + danger: true, + onClick: () => auth.reset(), + }, + ] + + return ( + +
+ + +
+ +
+
+ } + placeholder='Tìm kiếm...' + className='!hidden lg:!inline-flex' + style={{ width: 280 }} + /> + {/* Mobile/Tablet: search as icon button only */} + + + + + +
+ {notifs.length === 0 ? ( +
+ +
+ Chưa có thông báo +
+
+ ) : ( + { + const meta = notifIconMap[n.type] ?? { icon: BellOutlined, color: '#8c8c8c' } + const Icon = meta.icon + const unread = !n.is_read + return ( + { if (unread) markOneRead.mutate(n.id) }} + style={{ + padding: '10px 8px', + borderRadius: 6, + cursor: 'pointer', + background: unread ? '#f0f7ff' : 'transparent', + marginBottom: 4, + }} + > + +
+ +
+
+ + + {n.title} + + {unread && ( + + )} + + {n.body && ( + + {n.body} + + )} + + {dayjs(n.created_at).fromNow()} + +
+
+
+ ) + }} + /> + )} +
+ + +
+ } + > + + +