diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 1e770b2..fe4f96e 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -45,7 +45,9 @@ "Bash(pnpm exec *)", "Bash(echo \"exit: $?\")", "Bash(node -e \"console.log\\(require.resolve\\('ag-grid-react/package.json'\\)\\)\")", - "Bash(awk 'NR==1,NR==900' routes/api.php)" + "Bash(awk 'NR==1,NR==900' routes/api.php)", + "Bash(sed -i '' -e 's/#c93131/#F16723/g' -e 's/#FFF6F6/#FFF1E6/g' -e 's/#ea4544/#F16723/g' -e 's/#EA4544/#F16723/g' -e 's/#e0cccc/#e6e6e6/g' portal/index.html)", + "Bash(sed -i '' -e 's/#c93131/#F16723/g' -e 's/#FFF6F6/#FFF1E6/g' -e 's/#ea4544/#F16723/g' -e 's/#EA4544/#F16723/g' -e 's/#e0cccc/#e6e6e6/g' index.html)" ] } } diff --git a/app/Jobs/Hosting/ProvisionHostingAccountJob.php b/app/Jobs/Hosting/ProvisionHostingAccountJob.php new file mode 100644 index 0000000..9686441 --- /dev/null +++ b/app/Jobs/Hosting/ProvisionHostingAccountJob.php @@ -0,0 +1,84 @@ +item->service()->first(); + $domainName = $this->item->server_name; + + if (! $service || ! $domainName) { + Log::channel('Custom')->warning('ProvisionHostingAccountJob skipped: missing service or domain', [ + 'order_item_id' => $this->item->id, + ]); + + return; + } + + $server = DnsServer::query() + ->where('status', 'activated') + ->whereIn('type', [ServerType::HOSTING->value, ServerType::BOTH->value]) + ->first(); + + if (! $server) { + Log::channel('Custom')->error('ProvisionHostingAccountJob failed: no active hosting server available', [ + 'order_item_id' => $this->item->id, + ]); + + return; + } + + $result = $plesk->provision([ + 'customer_id' => $this->item->order->customer_id, + 'service_id' => $service->id, + 'dns_manager_server_id' => $server->id, + 'domain_name' => $domainName, + 'hosting_plan' => $this->item->product?->name ?? 'Default', + ]); + + if ($result['error']) { + Log::channel('Custom')->error('ProvisionHostingAccountJob failed', [ + 'order_item_id' => $this->item->id, + 'message' => $result['message'] ?? 'Unknown error', + ]); + + return; + } + + $service->update(['status' => 'active']); + } +} diff --git a/app/Services/EmailNotificationTemplateRenderer.php b/app/Services/EmailNotificationTemplateRenderer.php index 398a62c..380d8da 100644 --- a/app/Services/EmailNotificationTemplateRenderer.php +++ b/app/Services/EmailNotificationTemplateRenderer.php @@ -4,9 +4,14 @@ namespace App\Services; use App\Models\EmailNotificationTemplate; use App\Models\Invoice; +use Illuminate\Support\Facades\View; class EmailNotificationTemplateRenderer { + private const BANNERS = [ + 'vm_credentials' => 'https://res.cloudinary.com/izpast9i/image/upload/v1786503590/email_banner_cloud_vps.png', + ]; + public function render(EmailNotificationTemplate $tpl, Invoice $invoice, string $companyName = ''): string { $blocks = collect($tpl->blocks) @@ -19,10 +24,9 @@ class EmailNotificationTemplateRenderer $bodyHtml .= $this->renderBlock($block, $invoice, $companyName); } - $color = htmlspecialchars($tpl->header_color ?? '#1a56db'); - $title = htmlspecialchars($tpl->header_title ?? 'Thông báo hóa đơn'); + $title = $tpl->header_title ?? 'Thông báo hóa đơn'; - return $this->wrapLayout($title, $color, $bodyHtml, $companyName); + return $this->wrapLayout($title, $bodyHtml, $companyName, self::BANNERS[$tpl->type] ?? null); } /** @@ -42,22 +46,21 @@ class EmailNotificationTemplateRenderer foreach ($blocks as $block) { $bodyHtml .= match ($block['type'] ?? '') { - 'greeting' => $this->renderGreetingFromData($emailData), - 'vm_credentials' => $this->renderVmBlock($emailData, $block['config'] ?? []), - 'product_info' => $this->renderProductInfoBlock($emailData, $block['config'] ?? []), - 'invoice_info' => $invoice ? $this->renderInvoiceInfo($invoice, $block['config'] ?? []) : '', + 'greeting' => $this->renderView('emails.partials.greeting', ['name' => $emailData['client_name'] ?? 'Quý khách']), + 'vm_credentials' => $this->renderView('emails.partials.vm-credentials', ['fields' => $block['config']['fields'] ?? null, 'data' => $emailData]), + 'product_info' => $this->renderView('emails.partials.product-info', ['fields' => $block['config']['fields'] ?? null, 'data' => $emailData]), + 'invoice_info' => $invoice ? $this->renderView('emails.partials.invoice-info', ['fields' => $block['config']['fields'] ?? null, 'invoice' => $invoice]) : '', 'order_items' => $invoice ? $this->renderOrderItems($invoice) : '', 'attachments' => $invoice ? $this->renderAttachments($invoice) : '', - 'custom_text' => $this->renderCustomText($block['config'] ?? []), - 'footer' => $this->renderFooterBlock($companyName), + 'custom_text' => $this->renderView('emails.partials.custom-text', ['content' => $block['config']['content'] ?? '']), + 'footer' => $this->renderView('emails.partials.signature', ['companyName' => $companyName]), default => '', }; } - $color = htmlspecialchars($tpl->header_color ?? '#1a56db'); - $title = htmlspecialchars($tpl->header_title ?? 'Thông tin truy cập VPS'); + $title = $tpl->header_title ?? 'Thông tin truy cập VPS'; - return $this->wrapLayout($title, $color, $bodyHtml, $companyName); + return $this->wrapLayout($title, $bodyHtml, $companyName, self::BANNERS[$tpl->type] ?? null); } public function renderPreview(EmailNotificationTemplate $tpl): string @@ -75,277 +78,49 @@ class EmailNotificationTemplateRenderer private function renderBlock(array $block, Invoice $invoice, string $companyName): string { return match ($block['type'] ?? '') { - 'greeting' => $this->renderGreeting($invoice), - 'invoice_info' => $this->renderInvoiceInfo($invoice, $block['config'] ?? []), - 'customer_info'=> $this->renderCustomerInfo($invoice, $block['config'] ?? []), - 'order_items' => $this->renderOrderItems($invoice), - 'attachments' => $this->renderAttachments($invoice), - 'custom_text' => $this->renderCustomText($block['config'] ?? []), - 'footer' => $this->renderFooterBlock($companyName), - default => '', + 'greeting' => $this->renderView('emails.partials.greeting', ['name' => $invoice->customer?->fullname ?? 'Quý khách']), + 'invoice_info' => $this->renderView('emails.partials.invoice-info', ['fields' => $block['config']['fields'] ?? null, 'invoice' => $invoice]), + 'customer_info' => $this->renderView('emails.partials.customer-info', ['fields' => $block['config']['fields'] ?? null, 'customer' => $invoice->customer]), + 'order_items' => $this->renderOrderItems($invoice), + 'attachments' => $this->renderAttachments($invoice), + 'custom_text' => $this->renderView('emails.partials.custom-text', ['content' => $block['config']['content'] ?? '']), + 'footer' => $this->renderView('emails.partials.signature', ['companyName' => $companyName]), + default => '', }; } - private function renderGreeting(Invoice $invoice): string - { - $name = htmlspecialchars($invoice->customer?->fullname ?? 'Quý khách'); - return "

Xin chào {$name},

\n"; - } - - private function renderGreetingFromData(array $data): string - { - $name = htmlspecialchars($data['client_name'] ?? 'Quý khách'); - return "

Xin chào {$name},

\n"; - } - - private function renderVmBlock(array $data, array $config): string - { - $fields = $config['fields'] ?? ['service_host', 'service_username', 'service_port', 'service_password', 'os_name']; - - $fieldMap = [ - 'service_host' => ['Địa chỉ IP', $data['service_host'] ?? '—'], - 'service_username' => ['Username', $data['service_username'] ?? '—'], - 'service_port' => ['Port', (string)($data['service_port'] ?? '—')], - 'service_password' => ['Mật khẩu', $data['service_password'] ?? '—'], - 'os_name' => ['Hệ điều hành', $data['os_name'] ?? '—'], - ]; - - $rows = ''; - foreach ($fields as $key) { - if (!isset($fieldMap[$key])) continue; - [$label, $raw] = $fieldMap[$key]; - $value = htmlspecialchars($raw); - // Highlight password with monospace - $display = $key === 'service_password' - ? "{$value}" - : $value; - $rows .= "{$label}" - . "{$display}\n"; - } - - return << -

🖥️ Thông tin truy cập VPS

- {$rows}
- -HTML; - } - - private function renderProductInfoBlock(array $data, array $config): string - { - $fields = $config['fields'] ?? ['product_name', 'product_billing', 'product_price', 'service_due_exp']; - - $fieldMap = [ - 'product_name' => ['Tên dịch vụ', $data['product_name'] ?? '—'], - 'product_billing' => ['Chu kỳ thanh toán', $data['product_billing'] ?? '—'], - 'product_price' => ['Giá dịch vụ', ($data['product_price'] ?? '0') . ' VNĐ'], - 'service_due_exp' => ['Ngày hết hạn', $data['service_due_exp'] ?? '—'], - ]; - - $rows = ''; - foreach ($fields as $key) { - if (!isset($fieldMap[$key])) continue; - [$label, $value] = $fieldMap[$key]; - $value = htmlspecialchars($value); - $rows .= "{$label}" - . "{$value}\n"; - } - - return << -

Thông tin dịch vụ

- {$rows}
- -HTML; - } - - private function renderInvoiceInfo(Invoice $invoice, array $config): string - { - $fields = $config['fields'] ?? ['invoice_number', 'created_at', 'due_date', 'total', 'status']; - - $rows = ''; - foreach ($fields as $field) { - $rows .= $this->invoiceRow($field, $invoice); - } - - return << - - {$rows} -
- -HTML; - } - - private function invoiceRow(string $field, Invoice $invoice): string - { - [$label, $value] = match ($field) { - 'invoice_number' => ['Số hóa đơn', '#' . ($invoice->invoice_number ?? '—')], - 'created_at' => ['Ngày tạo', $invoice->created_at ? $invoice->created_at->format('d/m/Y') : '—'], - 'due_date' => ['Hạn thanh toán', $invoice->due_date ? date('d/m/Y', strtotime($invoice->due_date)) : '—'], - 'total' => ['Tổng tiền', number_format((float)$invoice->total) . ' VNĐ'], - 'sub_total' => ['Tiền dịch vụ', number_format((float)$invoice->sub_total) . ' VNĐ'], - 'tax' => ['Thuế', number_format((float)$invoice->tax) . ' VNĐ'], - 'status' => ['Trạng thái', $this->statusBadge((int)$invoice->status)], - 'payment_method' => ['Phương thức TT', htmlspecialchars($invoice->payment_method ?? '—')], - default => [$field, '—'], - }; - - return "{$label}" - . "{$value}\n"; - } - - private function statusBadge(int $status): string - { - return match ($status) { - 2 => 'Đã thanh toán', - 1 => 'Chưa thanh toán', - default => 'Đã hủy', - }; - } - - private function renderCustomerInfo(Invoice $invoice, array $config): string - { - $fields = $config['fields'] ?? ['name', 'email']; - $customer = $invoice->customer; - if (!$customer) return ''; - - $rows = ''; - foreach ($fields as $field) { - [$label, $value] = match ($field) { - 'name' => ['Tên khách hàng', htmlspecialchars($customer->fullname ?? '—')], - 'email' => ['Email', htmlspecialchars($customer->email ?? '—')], - 'address' => ['Địa chỉ', htmlspecialchars($customer->address ?? '—')], - default => [$field, '—'], - }; - $rows .= "{$label}" - . "{$value}\n"; - } - - return << -

Thông tin khách hàng

- {$rows}
- -HTML; - } - private function renderOrderItems(Invoice $invoice): string { $order = $invoice->order; if (!$order) return ''; $order->loadMissing('orderItems.product'); - $items = $order->orderItems; - if ($items->isEmpty()) return ''; - $cycleMap = [ - 'monthly' => 'Hàng tháng', 'quarterly' => 'Hàng quý', - 'semi_annually' => '6 tháng', 'annually' => 'Hàng năm', 'biennially' => '2 năm', - ]; - - $rows = ''; - foreach ($items as $item) { - $name = htmlspecialchars($item->product?->name ?? '—'); - $server = htmlspecialchars($item->server_name ?? '—'); - $cycle = htmlspecialchars($cycleMap[$item->billing_cycle] ?? ($item->billing_cycle ?? '—')); - $price = number_format((float) $item->price) . ' VNĐ'; - - $rows .= "" - . "{$name}" - . "{$server}" - . "{$cycle}" - . "{$price}" - . "\n"; - } - - return << - - - - - - - - - - {$rows} -
Sản phẩmServerChu kỳGiá
- -HTML; + return $this->renderView('emails.partials.order-items', ['items' => $order->orderItems]); } private function renderAttachments(Invoice $invoice): string { $invoice->loadMissing('attachments'); - if ($invoice->attachments->isEmpty()) return ''; - $items = ''; - foreach ($invoice->attachments as $file) { - $name = htmlspecialchars($file->filename); - $url = htmlspecialchars($file->url ?? '#'); - $items .= "
  • {$name}
  • \n"; - } - - return << -

    File đính kèm

    - - -HTML; + return $this->renderView('emails.partials.attachments', ['attachments' => $invoice->attachments]); } - private function renderCustomText(array $config): string + private function renderView(string $view, array $data): string { - $content = $config['content'] ?? ''; - if (!$content) return ''; - - // If plain text (no HTML tags), wrap in

    - $html = strip_tags($content) === $content - ? '

    ' . htmlspecialchars($content) . '

    ' - : $content; - - return "
    {$html}
    \n"; - } - - private function renderFooterBlock(string $companyName): string - { - $company = htmlspecialchars($companyName ?: config('app.name', 'Công ty')); - $year = date('Y'); - return "

    Trân trọng,
    {$company}

    \n"; + return View::make($view, $data)->render(); } // ─── Layout wrapper ─────────────────────────────────────────────────────── - private function wrapLayout(string $title, string $color, string $body, string $companyName): string + private function wrapLayout(string $title, string $body, string $companyName, ?string $bannerUrl = null): string { - $company = htmlspecialchars($companyName ?: config('app.name', 'Công ty')); - $year = date('Y'); - - return << - - - - -{$title} - - -
    -
    -

    {$title}

    -
    -
    - {$body} -
    -
    - © {$year} {$company}. Đây là email tự động, vui lòng không trả lời. -
    -
    - - -HTML; + return View::make('emails.layouts.notification', [ + 'title' => $title, + 'body' => $body, + 'companyName' => $companyName ?: config('app.name', 'Thế Giới Số'), + 'bannerUrl' => $bannerUrl, + ])->render(); } // ─── Sample data for preview ────────────────────────────────────────────── diff --git a/app/Services/InvoicePaymentService.php b/app/Services/InvoicePaymentService.php index 2247c5b..8795c0d 100644 --- a/app/Services/InvoicePaymentService.php +++ b/app/Services/InvoicePaymentService.php @@ -5,6 +5,7 @@ namespace App\Services; use App\Enums\Invoice\InvoiceStatus; use App\Enums\Order\OrderStatus; use App\Enums\TransactionStatus; +use App\Jobs\Hosting\ProvisionHostingAccountJob; use App\Jobs\VirtualMachine\Action\VMJobs; use App\Jobs\VirtualMachine\Create\CreateVirtualMachineJob; use App\Models\Addon; @@ -135,6 +136,10 @@ class InvoicePaymentService } foreach ($order->orderItems as $item) { + $isHostingProvision = ! $item->os_id + && $item->server_name + && $item->product?->product_type === 'hosting'; + $service = Service::firstOrCreate( ['order_item_id' => $item->id], [ @@ -142,7 +147,7 @@ class InvoicePaymentService 'product_id' => $item->product_id, 'invoice_id' => $invoice->id, 'server_name' => $item->server_name, - 'status' => $item->os_id ? 'pending' : 'active', + 'status' => ($item->os_id || $isHostingProvision) ? 'pending' : 'active', 'auto_renew' => false, 'started_at' => now(), 'expires_at' => now()->addMonths($item->months ?: $this->cycleToMonths($item->billing_cycle)), @@ -151,8 +156,12 @@ class InvoicePaymentService $this->attachServiceAddons($service, $item); - if ($service->wasRecentlyCreated && $item->product_id && $item->os_id) { - dispatch(new CreateVirtualMachineJob($item)); + if ($service->wasRecentlyCreated && $item->product_id) { + if ($item->os_id) { + dispatch(new CreateVirtualMachineJob($item)); + } elseif ($isHostingProvision) { + dispatch(new ProvisionHostingAccountJob($item)); + } } } } diff --git a/app/Services/Plesk/PleskClientService.php b/app/Services/Plesk/PleskClientService.php index 9157b6c..87bb181 100644 --- a/app/Services/Plesk/PleskClientService.php +++ b/app/Services/Plesk/PleskClientService.php @@ -34,14 +34,27 @@ class PleskClientService ->first(); if ($existing) { - return [ - 'error' => false, - 'data' => [ - 'id' => $existing->plesk_client_id, - 'login' => $existing->login, - 'password' => Crypt::decryptString($existing->password), - ], - ]; + if ($this->clientExists($server, (int) $existing->plesk_client_id)) { + return [ + 'error' => false, + 'data' => [ + 'id' => $existing->plesk_client_id, + 'login' => $existing->login, + 'password' => Crypt::decryptString($existing->password), + ], + ]; + } + + // Cached client was deleted directly on Plesk (out-of-band) — the id is + // dead, so any call reusing it (e.g. owner_client on domain creation) + // fails with "Owner does not exist". Drop the stale row and re-provision. + Log::warning('Cached Plesk client no longer exists on server — re-creating', [ + 'plesk_client_id' => $existing->plesk_client_id, + 'customer_id' => $customer->id, + 'server_id' => $server->id, + ]); + + $existing->delete(); } $login = $this->generateUsername($customer); @@ -130,6 +143,20 @@ class PleskClientService } } + private function clientExists(DnsServer $server, int $pleskClientId): bool + { + try { + $response = $this->plesk->client($server, 15) + ->get($this->plesk->url($server, 'clients', "/{$pleskClientId}")); + + return $response->successful(); + } catch (RequestException $e) { + Log::error('Plesk client existence check failed', ['error' => $e->getMessage(), 'plesk_client_id' => $pleskClientId]); + + return false; + } + } + private function findByLogin(DnsServer $server, string $login): ?array { try { diff --git a/public/images/emails/admin.png b/public/images/emails/admin.png new file mode 100644 index 0000000..51a35b2 Binary files /dev/null and b/public/images/emails/admin.png differ diff --git a/public/images/emails/buy-date.png b/public/images/emails/buy-date.png new file mode 100644 index 0000000..8d70747 Binary files /dev/null and b/public/images/emails/buy-date.png differ diff --git a/public/images/emails/due-date.png b/public/images/emails/due-date.png new file mode 100644 index 0000000..de77b6d Binary files /dev/null and b/public/images/emails/due-date.png differ diff --git a/public/images/emails/internet.png b/public/images/emails/internet.png new file mode 100644 index 0000000..c4a208a Binary files /dev/null and b/public/images/emails/internet.png differ diff --git a/public/images/emails/password.png b/public/images/emails/password.png new file mode 100644 index 0000000..4d1d37b Binary files /dev/null and b/public/images/emails/password.png differ diff --git a/resources/views/emails/customer/hosting-account-created.blade.php b/resources/views/emails/customer/hosting-account-created.blade.php index 5b075d1..f039ff6 100644 --- a/resources/views/emails/customer/hosting-account-created.blade.php +++ b/resources/views/emails/customer/hosting-account-created.blade.php @@ -20,204 +20,194 @@ - + - -
    - +
    - - - -
    -
    🎉
    -

    - Hosting của bạn đã sẵn sàng -

    -
    - Gói hosting cho tên miền {{ $domainName }} đã được khởi tạo thành công -
    -
    - - - - - - -
    -
     
    -
    Xin chào {{ $fullName }},
    -
     
    -
    - Dịch vụ hosting của bạn tại {{ $appName }} đã được khởi tạo thành công. Dưới đây là thông tin truy cập: -
    -
     
    - - - - - - -
    -
    Tên miền: {{ $domainName }}
    -
    Gói hosting: {{ $hostingPlan }}
    - @if($serverHost) -
    Máy chủ / FTP Host: {{ $serverHost }}
    - @endif - @if($ftpUsername) -
    Tài khoản FTP: {{ $ftpUsername }}
    - @endif - @if($ftpPassword) -
    Mật khẩu FTP: {{ $ftpPassword }}
    - @endif -
    Trạng thái: Đang hoạt động ✓
    -
    -
     
    - @if($ftpPassword) -
    - ⚠️ Vì lý do bảo mật, mật khẩu FTP chỉ được gửi một lần duy nhất qua email và không được lưu trữ trên hệ thống. Vui lòng lưu lại ở nơi an toàn. -
    -
     
    - @endif - @if($pleskLogin) -
    Tài khoản đăng nhập Plesk Panel (để tự quản lý domain, DNS, database, mail...):
    -
     
    - - - - - - -
    -
    Địa chỉ Panel: {{ $pleskPanelUrl }}
    -
    Tên đăng nhập: {{ $pleskLogin }}
    - @if($pleskPassword) -
    Mật khẩu: {{ $pleskPassword }}
    - @endif -
    -
     
    - @endif -
    - Bạn có thể đăng nhập vào cổng quản lý để theo dõi và quản lý dịch vụ của mình. -
    -
     
    - -
     
    -
    - Nếu bạn không yêu cầu dịch vụ này, vui lòng liên hệ ngay với chúng tôi qua {{ $supportEmail }} hoặc {{ $supportPhone }}. -
    -
     
    -
    - Email này được gửi tự động. Vui lòng không trả lời email này. -
    -
     
    -
    +
    + + Thư gửi từ Công Ty Cổ Phần Giải Pháp Công Nghệ Thế Giới Số +
    - + + - -
    +

    + Hosting của bạn đã sẵn sàng +

    + +

    + Hosting của bạn đã sẵn sàng +

    + +

    + Kính chào {{ $fullName }}, +

    + +

    + Gói hosting cho tên miền {{ $domainName }} tại {{ $appName }} đã được khởi tạo thành công. Dưới đây là thông tin truy cập: +

    + + - + + - - + + + + @if($serverHost) - + + - - + @endif + @if($ftpUsername) + + + + @endif + @if($ftpPassword) - + + + @endif + + + + + +
    - - - - - - -
     
    -
    Tên miền:{{ $domainName }}
     
    Gói hosting:{{ $hostingPlan }}
    - - - - - - - - - - - - - - -
      - Premium HOSTING -   - Cloud SERVER -   - EMAIL DOANH NGHIỆP -   - Thiết Kế WEBSITE -  
    -
    Máy chủ / FTP Host:{{ $serverHost }}
     
    Tài khoản FTP:{{ $ftpUsername }}
    - - - - - - -
     
    +
    Mật khẩu FTP:{{ $ftpPassword }}
    Trạng thái:Đang hoạt động ✓
    + + @if($ftpPassword) +

    + Lưu ý: Vì lý do bảo mật, mật khẩu FTP chỉ được gửi một lần duy nhất qua email và không được lưu trữ trên hệ thống. Vui lòng lưu lại ở nơi an toàn. +

    + @endif + + @if($pleskLogin) +

    TÀI KHOẢN ĐĂNG NHẬP PLESK PANEL

    + + + + + + + + + + + + @if($pleskPassword) + + + + + @endif + +
    Địa chỉ Panel:{{ $pleskPanelUrl }}
    Tên đăng nhập:{{ $pleskLogin }}
    Mật khẩu:{{ $pleskPassword }}
    + @endif + +

    + Bạn có thể đăng nhập vào cổng quản lý để theo dõi và quản lý dịch vụ của mình. +

    + + + + +
    + Đăng nhập ngay
    + +

    + Nếu bạn không yêu cầu dịch vụ này, vui lòng liên hệ ngay với chúng tôi qua {{ $supportEmail }} hoặc {{ $supportPhone }}. +

    + +

    + Email này được gửi tự động. Vui lòng không trả lời email này. +

    + +

    ---
    + Hotline: {{ $supportPhone }}
    + Email: {{ $supportEmail }} +

    - + +