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
-
-
-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ụ
-
-
-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 <<
-
-
-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
-
-
-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 <<
-
-
-
- | Sản phẩm |
- Server |
- Chu kỳ |
- Giá |
-
-
- {$rows}
-
-
-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 @@
|