diff --git a/app/Http/Resources/Api/Client/Service/ServiceClientDetailResource.php b/app/Http/Resources/Api/Client/Service/ServiceClientDetailResource.php index 9d39a98..feb8804 100644 --- a/app/Http/Resources/Api/Client/Service/ServiceClientDetailResource.php +++ b/app/Http/Resources/Api/Client/Service/ServiceClientDetailResource.php @@ -12,6 +12,25 @@ class ServiceClientDetailResource extends JsonResource * * @return array */ + private function resolveSpecs($product): ?array + { + $addons = $this->addons ?? collect(); + if ($addons->isNotEmpty()) { + return [ + 'cpu' => (int) $addons->where('addon_type', 'cpu')->sum('addon_value'), + 'ram' => (int) $addons->where('addon_type', 'ram')->sum('addon_value'), + 'disk' => (int) $addons->where('addon_type', 'disk')->sum('addon_value'), + 'bandwidth' => (int) $addons->where('addon_type', 'bandwidth')->sum('addon_value'), + ]; + } + return $product ? [ + 'cpu' => (int) ($product->cpu ?? 0), + 'ram' => (int) ($product->ram ?? 0), + 'disk' => (int) ($product->disk ?? 0), + 'bandwidth' => (int) ($product->bandwidth ?? 0), + ] : null; + } + public function toArray(Request $request): array { if (!$this->resource) { @@ -35,12 +54,7 @@ class ServiceClientDetailResource extends JsonResource 'version' => $orderItem?->os?->version, 'group' => $orderItem?->os?->osGroup?->name, ], - 'specs' => $product ? [ - 'cpu' => (int) ($product->cpu ?? 0), - 'ram' => (int) ($product->ram ?? 0), - 'disk' => (int) ($product->disk ?? 0), - 'bandwidth' => (int) ($product->bandwidth ?? 0), - ] : null, + 'specs' => $this->resolveSpecs($product), 'order' => $order ? [ 'total' => (float) $order->total, 'months' => (int) $orderItem->months, diff --git a/app/Http/Resources/Api/Service/VpsServiceResource.php b/app/Http/Resources/Api/Service/VpsServiceResource.php index 2fa16ba..ab95447 100644 --- a/app/Http/Resources/Api/Service/VpsServiceResource.php +++ b/app/Http/Resources/Api/Service/VpsServiceResource.php @@ -12,6 +12,25 @@ class VpsServiceResource extends JsonResource * * @return array */ + private function resolveSpecs($product): ?array + { + $addons = $this->addons ?? collect(); + if ($addons->isNotEmpty()) { + return [ + 'cpu' => (int) $addons->where('addon_type', 'cpu')->sum('addon_value'), + 'ram' => (int) $addons->where('addon_type', 'ram')->sum('addon_value'), + 'disk' => (int) $addons->where('addon_type', 'disk')->sum('addon_value'), + 'bandwidth' => (int) $addons->where('addon_type', 'bandwidth')->sum('addon_value'), + ]; + } + return $product ? [ + 'cpu' => (int) ($product->cpu ?? 0), + 'ram' => (int) ($product->ram ?? 0), + 'disk' => (int) ($product->disk ?? 0), + 'bandwidth' => (int) ($product->bandwidth ?? 0), + ] : null; + } + public function toArray(Request $request): array { $orderItem = $this->orderItem; @@ -43,13 +62,7 @@ class VpsServiceResource extends JsonResource 'product_type' => $product->product_type, 'page_type' => 'service_vps', ] : null, - 'specs' => $product ? [ - 'cpu' => (int) ($product->cpu ?? 0), - 'ram' => (int) ($product->ram ?? 0), - 'disk' => (int) ($product->disk ?? 0), - 'bandwidth' => (int) ($product->bandwidth ?? 0), - 'gpu' => $product->gpu, - ] : null, + 'specs' => $this->resolveSpecs($product), ]; } } diff --git a/app/Jobs/VirtualMachine/ProcessOrderPaymentJob.php b/app/Jobs/VirtualMachine/ProcessOrderPaymentJob.php index 639b15e..2dd937e 100644 --- a/app/Jobs/VirtualMachine/ProcessOrderPaymentJob.php +++ b/app/Jobs/VirtualMachine/ProcessOrderPaymentJob.php @@ -265,21 +265,51 @@ class ProcessOrderPaymentJob implements ShouldQueue { try { $customer = $this->order->customer; - - // Gửi email thông báo thành công + \App\Jobs\Mail\Customer\OrderPaymentSuccessJob::dispatch([ 'customer_email' => $customer->email, - 'customer_name' => trim($customer->firstname . ' ' . $customer->lastname), - 'order_id' => $this->order->id, - 'order_num' => $this->order->order_num, - 'amount' => $this->order->amount, - 'vm_results' => $vmResults + 'customer_name' => trim($customer->firstname . ' ' . $customer->lastname), + 'order_id' => $this->order->id, + 'order_num' => $this->order->order_num, + 'amount' => $this->order->amount, + 'vm_results' => $vmResults, + 'addons_summary' => $this->resolveAddonsSummary(), ]); } catch (\Throwable $e) { Log::error('Không thể gửi email thông báo thành công: ' . $e->getMessage()); } } + /** + * Resolve addon display rows from all order items' addons_meta. + * Returns array of [{name, qty, subtotal}] for email rendering. + * + * @return array + */ + private function resolveAddonsSummary(): array + { + $rows = []; + $this->order->load('orderItems'); + + foreach ($this->order->orderItems as $item) { + $meta = is_array($item->addons_meta) ? $item->addons_meta : []; + foreach ($meta as $entry) { + $addon = \App\Models\Addon::find($entry['addon_id'] ?? null); + if (!$addon) { + continue; + } + $qty = (int) ($entry['qty'] ?? 0); + $rows[] = [ + 'name' => $addon->name, + 'qty' => $qty, + 'subtotal' => (int) ($entry['subtotal'] ?? $addon->price * $qty), + ]; + } + } + + return $rows; + } + /** * Check if the error is a timeout error */ diff --git a/app/Jobs/VirtualMachine/Provision/AssignIpToGuestJob.php b/app/Jobs/VirtualMachine/Provision/AssignIpToGuestJob.php index 2656979..b8d5e62 100644 --- a/app/Jobs/VirtualMachine/Provision/AssignIpToGuestJob.php +++ b/app/Jobs/VirtualMachine/Provision/AssignIpToGuestJob.php @@ -31,7 +31,7 @@ use Illuminate\Support\Facades\Log; * * Assigns a real static IP to the VM guest OS and verifies it is active. * Only persists the IP to DB (module_vmware + virtual_machines) after - * successful guest-side verification, then dispatches ChangePasswordViaVsphereJob. + * successful guest-side verification, then dispatches UpdateVmConfigJob → ChangePasswordViaVsphereJob. * * Flow: * [1] Resolve network config from order item → product → ipAddress template @@ -41,7 +41,7 @@ use Illuminate\Support\Facades\Log; * [5] On FAIL → release IP, try next * [6] Call verifyIPLinux.ps1 / verifyIPWindows.ps1 via SSH * [7] On FAIL → release IP, try next - * [8] SUCCESS → persistIp() → dispatch ChangePasswordViaVsphereJob + * [8] SUCCESS → persistIp() → dispatch UpdateVmConfigJob → ChangePasswordViaVsphereJob * [9] All attempts exhausted → mark VM inactive (status=0) */ class AssignIpToGuestJob implements ShouldQueue @@ -184,7 +184,7 @@ class AssignIpToGuestJob implements ShouldQueue 'attempt' => $attempt, ]); - ChangePasswordViaVsphereJob::dispatch( + UpdateVmConfigJob::dispatch( $this->moduleVmwareId, $this->vmId, $this->osFamily, @@ -226,8 +226,8 @@ class AssignIpToGuestJob implements ShouldQueue 'attempt' => $attempt, ]); - // Dispatch password change now that IP is confirmed - ChangePasswordViaVsphereJob::dispatch( + // Dispatch addon config update → then password change + UpdateVmConfigJob::dispatch( $this->moduleVmwareId, $this->vmId, $this->osFamily, diff --git a/app/Jobs/VirtualMachine/Provision/UpdateVmConfigJob.php b/app/Jobs/VirtualMachine/Provision/UpdateVmConfigJob.php new file mode 100644 index 0000000..989b421 --- /dev/null +++ b/app/Jobs/VirtualMachine/Provision/UpdateVmConfigJob.php @@ -0,0 +1,251 @@ +for('UpdateVmConfigJob'); + + $logger->step('UpdateVmConfigJob started', [ + 'module_vmware_id' => $this->moduleVmwareId, + 'vm_id' => $this->vmId, + ]); + + $moduleVmware = ModuleVmware::with(['order.orderItems.product', 'vcenter'])->find($this->moduleVmwareId); + + if (!$moduleVmware) { + Log::error('UpdateVmConfigJob: ModuleVmware not found', [ + 'module_vmware_id' => $this->moduleVmwareId, + 'vm_id' => $this->vmId, + ]); + $this->nextStep(); + return; + } + + $orderItem = $moduleVmware->order?->orderItems?->first(); + $product = $orderItem?->product; + $addonsMeta = is_array($orderItem?->addons_meta) ? $orderItem->addons_meta : []; + + [$addonCpu, $addonRamGb] = $this->resolveAddonCpuRam($addonsMeta); + + if ($addonCpu === 0 && $addonRamGb === 0) { + $logger->step('no cpu/ram addon — skipping UpdateVmConfigJob', [ + 'module_vmware_id' => $this->moduleVmwareId, + 'vm_id' => $this->vmId, + ]); + $this->nextStep(); + return; + } + + // Load vCenter credentials — same pattern as ChangePasswordViaVsphereJob + $vcenter = $moduleVmware->vcenter ?? vCenter::first(); + + if (!$vcenter) { + $msg = "UpdateVmConfigJob: no vCenter record found for server [{$moduleVmware->name}]"; + Log::error($msg, ['module_vmware_id' => $this->moduleVmwareId, 'server_name' => $moduleVmware->name]); + $telegram->SendMessage("🚨 UpdateVmConfigJob: Lỗi\n⚠️ Không tìm thấy vCenter\n🖥️ Server: {$moduleVmware->name}\n🕒 " . Carbon::now()->format('d/m/Y H:i:s')); + throw new \RuntimeException($msg); + } + + $vcenterHost = $vcenter->host; + $vcenterUser = $vcenter->user; + $vcenterPass = SignRSA::DecryptFunc($vcenter->password); + + $serverName = (string) $moduleVmware->name; + $totalCpu = ((int) ($product?->cpu ?? 0)) + $addonCpu; + $totalRam = ((int) ($product?->ram ?? 0)) + $addonRamGb; + + $logger->step('calling updateVMConfig.ps1', [ + 'module_vmware_id' => $this->moduleVmwareId, + 'server_name' => $serverName, + 'vcenter_host' => $vcenterHost, + 'addon_cpu' => $addonCpu, + 'addon_ram_gb' => $addonRamGb, + 'total_cpu' => $totalCpu, + 'total_ram_gb' => $totalRam, + ]); + + $output = $ssh->updateVmConfig( + $serverName, + $totalCpu, + $totalRam, + $vcenterHost, + $vcenterUser, + $vcenterPass, + ); + + Log::info('UpdateVmConfigJob: PS1 output', [ + 'module_vmware_id' => $this->moduleVmwareId, + 'server_name' => $serverName, + 'output' => trim($output), + ]); + + if (!str_contains(strtoupper(trim($output)), 'SUCCESS')) { + $msg = "updateVMConfig.ps1 failed for server [{$serverName}]. Output: " . trim($output); + Log::error('UpdateVmConfigJob: script returned FAIL', [ + 'module_vmware_id' => $this->moduleVmwareId, + 'server_name' => $serverName, + 'total_cpu' => $totalCpu, + 'total_ram_gb' => $totalRam, + 'output' => trim($output), + 'attempt' => $this->attempts(), + 'max_tries' => $this->tries, + ]); + + $telegram->SendMessage( + "⚠️ UpdateVmConfigJob thất bại\n" . + "🖥️ Server: {$serverName}\n" . + "🔁 Lần thử: {$this->attempts()}/{$this->tries}\n" . + "💻 CPU: {$totalCpu} | RAM: {$totalRam} GB\n" . + "📋 Output: " . htmlspecialchars(trim($output) ?: '(trống)') . "\n" . + "🕒 " . Carbon::now()->format('d/m/Y H:i:s') + ); + + throw new \RuntimeException($msg); + } + + $logger->success('VM config updated via addon script', [ + 'module_vmware_id' => $this->moduleVmwareId, + 'vm_id' => $this->vmId, + 'total_cpu' => $totalCpu, + 'total_ram_gb' => $totalRam, + 'output' => trim($output), + ]); + + $this->nextStep(); + } + + public function failed(\Throwable $e): void + { + Log::error('UpdateVmConfigJob: all retries exhausted', [ + 'module_vmware_id' => $this->moduleVmwareId, + 'vm_id' => $this->vmId, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + ModuleVmware::where('id', $this->moduleVmwareId)->update(['status' => 0]); + Service::where('vm_id', $this->moduleVmwareId)->update(['status' => 'failed']); + + try { + $telegram = app(Telegram::class); + $vm = ModuleVmware::with(['order.customer'])->find($this->moduleVmwareId); + $customer = $vm?->order?->customer; + + $telegram->SendMessage( + "🚨 UpdateVmConfigJob: Thất bại hoàn toàn\n" . + "━━━━━━━━━━━━━━━━━━━━\n" . + "🖥️ VM ID: {$this->vmId}\n" . + "📦 Server: " . ($vm?->name ?? 'N/A') . "\n" . + "👤 Khách hàng: " . ($customer ? trim($customer->firstname . ' ' . $customer->lastname) : 'N/A') . "\n" . + "📧 Email: " . ($customer?->email ?? 'N/A') . "\n" . + "❌ Lỗi: " . htmlspecialchars($e->getMessage()) . "\n" . + "⚡ Trạng thái: VM đã bị đánh dấu inactive\n" . + "🕒 " . Carbon::now()->format('d/m/Y H:i:s') . "\n" . + "━━━━━━━━━━━━━━━━━━━━\n" . + "⚠️ Vui lòng kiểm tra và xử lý thủ công!" + ); + } catch (\Throwable $te) { + Log::warning('UpdateVmConfigJob: could not send Telegram failure notification', [ + 'error' => $te->getMessage(), + ]); + } + } + + // ───────────────────────────────────────────────────────────────────────── + + /** + * Resolve cpu/ram qty from addons_meta. + * Uses 'code' field saved by OrderService::resolveAddons() — no extra DB query needed. + * Falls back to Addon::find() if 'code' is missing (legacy meta without code). + * + * @param array $addonsMeta + * @return array{int, int} [cpu_count, ram_gb] + */ + private function resolveAddonCpuRam(array $addonsMeta): array + { + $cpu = 0; + $ram = 0; + + foreach ($addonsMeta as $meta) { + $qty = (int) ($meta['qty'] ?? 0); + $code = strtolower((string) ($meta['code'] ?? '')); + + // Fallback: look up code from DB if not in meta (old orders) + if ($code === '' && isset($meta['addon_id'])) { + $addon = Addon::find($meta['addon_id']); + $code = strtolower((string) ($addon?->code ?? '')); + } + + if ($code === 'cpu') { + $cpu += $qty; + } elseif ($code === 'ram') { + $ram += $qty; + } + } + + return [$cpu, $ram]; + } + + private function nextStep(): void + { + ChangePasswordViaVsphereJob::dispatch( + $this->moduleVmwareId, + $this->vmId, + $this->osFamily, + ); + } +} diff --git a/app/Mail/Customer/OrderPaymentSuccess.php b/app/Mail/Customer/OrderPaymentSuccess.php index a31e00b..0163f41 100644 --- a/app/Mail/Customer/OrderPaymentSuccess.php +++ b/app/Mail/Customer/OrderPaymentSuccess.php @@ -41,13 +41,14 @@ class OrderPaymentSuccess extends Mailable return new Content( view: 'emails.customer.order-payment-success', with: [ - 'customerName' => $this->data['customer_name'] ?? 'Khách hàng', - 'orderId' => $this->data['order_id'] ?? 'N/A', - 'orderNum' => $this->data['order_num'] ?? 'N/A', - 'amount' => $this->data['amount'] ?? 0, - 'vmResults' => $this->data['vm_results'] ?? [], - 'supportEmail' => config('mail.support_email', 'support@example.com'), - 'supportPhone' => config('mail.support_phone', '1900-xxxx'), + 'customerName' => $this->data['customer_name'] ?? 'Khách hàng', + 'orderId' => $this->data['order_id'] ?? 'N/A', + 'orderNum' => $this->data['order_num'] ?? 'N/A', + 'amount' => $this->data['amount'] ?? 0, + 'vmResults' => $this->data['vm_results'] ?? [], + 'addonsSummary' => $this->data['addons_summary'] ?? [], + 'supportEmail' => config('mail.support_email', 'support@example.com'), + 'supportPhone' => config('mail.support_phone', '1900-xxxx'), ], ); } diff --git a/app/Repositories/Api/Invoice/InvoiceRepository.php b/app/Repositories/Api/Invoice/InvoiceRepository.php index 43a2512..0e5feba 100644 --- a/app/Repositories/Api/Invoice/InvoiceRepository.php +++ b/app/Repositories/Api/Invoice/InvoiceRepository.php @@ -50,7 +50,7 @@ class InvoiceRepository extends BaseApiRepository implements InvoiceRepositoryIn 'due_date' => now()->addDays(7)->toDateString(), ]); - // Create one invoice_item per order_item line + // Create one invoice_item per order_item line + addon sub-lines foreach ($order->orderItems as $item) { InvoiceItem::create([ 'invoice_id' => $invoice->id, @@ -62,6 +62,27 @@ class InvoiceRepository extends BaseApiRepository implements InvoiceRepositoryIn 'unit_price' => $item->price, 'total_price' => $item->price * ($item->quantity ?? 1), ]); + + $addonsMeta = is_array($item->addons_meta) ? $item->addons_meta : []; + foreach ($addonsMeta as $meta) { + $qty = (int) ($meta['qty'] ?? 1); + $price = (int) ($meta['price'] ?? 0); + $subtotal = (int) ($meta['subtotal'] ?? $price * $qty); + $name = $meta['name'] ?? ('Addon #' . ($meta['addon_id'] ?? '?')); + $code = strtoupper($meta['code'] ?? ''); + $label = $code ? "[{$code}] {$name}" : $name; + + InvoiceItem::create([ + 'invoice_id' => $invoice->id, + 'product_id' => $item->product_id, + 'type' => 'addon', + 'description' => $label, + 'months' => $this->cycleToMonths($item->billing_cycle), + 'quantity' => $qty, + 'unit_price' => $price, + 'total_price' => $subtotal, + ]); + } } return $invoice; diff --git a/app/Repositories/Api/ServiceClient/ServiceClientRepository.php b/app/Repositories/Api/ServiceClient/ServiceClientRepository.php index 0b77e48..58f0e95 100644 --- a/app/Repositories/Api/ServiceClient/ServiceClientRepository.php +++ b/app/Repositories/Api/ServiceClient/ServiceClientRepository.php @@ -20,6 +20,7 @@ class ServiceClientRepository extends BaseApiRepository implements ServiceClient $query = $this->model ->with([ 'product', + 'addons', 'invoice.order', 'orderItem' => fn($q) => $q->with(['product', 'os.osGroup', 'order']), ]) @@ -60,6 +61,7 @@ class ServiceClientRepository extends BaseApiRepository implements ServiceClient { return $this->model ->with([ + 'addons', 'orderItem.os.osGroup', 'orderItem.product', 'orderItem.order', diff --git a/app/Services/Api/Order/OrderService.php b/app/Services/Api/Order/OrderService.php index b480566..421b51f 100644 --- a/app/Services/Api/Order/OrderService.php +++ b/app/Services/Api/Order/OrderService.php @@ -308,6 +308,8 @@ class OrderService $qty = max(1, (int) ($entry['qty'] ?? 1)); $resolved[] = [ 'addon_id' => $addon->id, + 'code' => strtolower((string) $addon->code), + 'name' => $addon->name, 'qty' => $qty, 'price' => $addon->price, 'subtotal' => $addon->price * $qty, diff --git a/app/Services/InvoicePaymentService.php b/app/Services/InvoicePaymentService.php index 3179765..2247c5b 100644 --- a/app/Services/InvoicePaymentService.php +++ b/app/Services/InvoicePaymentService.php @@ -7,6 +7,7 @@ use App\Enums\Order\OrderStatus; use App\Enums\TransactionStatus; use App\Jobs\VirtualMachine\Action\VMJobs; use App\Jobs\VirtualMachine\Create\CreateVirtualMachineJob; +use App\Models\Addon; use App\Models\Customer; use App\Models\Invoice; use App\Models\Log as CustomerActivityLog; @@ -210,6 +211,23 @@ class InvoicePaymentService } } } + + $addonsMeta = is_array($item->addons_meta) + ? $item->addons_meta + : json_decode($item->addons_meta ?? '[]', true); + + foreach ((array) $addonsMeta as $meta) { + $addon = Addon::find($meta['addon_id'] ?? null); + if (!$addon) { + continue; + } + ServiceAddon::create([ + 'service_id' => $service->id, + 'addon_type' => strtolower((string) $addon->code), + 'addon_value' => (string) ($meta['qty'] ?? 1), + 'price' => (int) ($meta['price'] ?? $addon->price), + ]); + } } private function cycleToMonths(?string $cycle): int diff --git a/app/Services/VsphereHostSshService.php b/app/Services/VsphereHostSshService.php index a6550ab..089764e 100644 --- a/app/Services/VsphereHostSshService.php +++ b/app/Services/VsphereHostSshService.php @@ -36,6 +36,9 @@ final class VsphereHostSshService private const SCRIPT_CHANGE_PWD_LINUX = 'changePwdLinux.ps1'; private const SCRIPT_CHANGE_PWD_WINDOWS = 'changePwdWindows.ps1'; + private const ADDON_DIR = 'C:\\CLISave\\Addon'; + private const SCRIPT_UPDATE_VM_CONFIG = 'updateVMConfig.ps1'; + private const CONNECT_TIMEOUT = 20; // ───────────────────────────────────────────────────────────────────────── @@ -257,6 +260,49 @@ final class VsphereHostSshService ]; } + /** + * Update VM CPU and memory via updateVMConfig.ps1. + * + * Script path: C:\CLISave\Addon\updateVMConfig.ps1 + * Params: $server_name $cpu $memoryGB $vcenter_host $vcenter_user $vcenter_password + * Output: "SUCCESS" or "FAIL" + * + * PS1 must call Connect-VIServer before Get-VM — see updateVMConfig.ps1 on vSphere host. + */ + public function updateVmConfig( + string $serverName, + int $cpu, + int $memoryGB, + string $vcenterHost, + string $vcenterUser, + string $vcenterPassword, + ): string { + $remoteCmd = $this->windowsPowerShellCommand(self::ADDON_DIR, self::SCRIPT_UPDATE_VM_CONFIG, [ + $serverName, + (string) $cpu, + (string) $memoryGB, + $vcenterHost, + $vcenterUser, + $vcenterPassword, + ]); + + Log::info('VsphereHostSshService: executing updateVmConfig', [ + 'server_name' => $serverName, + 'cpu' => $cpu, + 'memoryGB' => $memoryGB, + 'vcenter_host' => $vcenterHost, + ]); + + $output = $this->sshExec($remoteCmd); + + Log::info('VsphereHostSshService: updateVmConfig output', [ + 'server_name' => $serverName, + 'output' => trim($output), + ]); + + return $output; + } + /** * Customer-initiated VM password change. * diff --git a/resources/views/emails/customer/order-payment-success.blade.php b/resources/views/emails/customer/order-payment-success.blade.php index 6cb6495..b668611 100644 --- a/resources/views/emails/customer/order-payment-success.blade.php +++ b/resources/views/emails/customer/order-payment-success.blade.php @@ -26,6 +26,11 @@ .vm-box .vm-row:last-child { border-bottom: none; } .vm-box .vm-label { color: #15803d; font-size: 13px; width: 120px; flex-shrink: 0; } .vm-box .vm-value { font-family: 'Courier New', monospace; font-size: 14px; font-weight: 700; color: #111827; word-break: break-all; } + .addon-table { width: 100%; border-collapse: collapse; margin-top: 12px; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; overflow: hidden; } + .addon-table th { padding: 8px 14px; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: #64748b; background: #f1f5f9; text-align: left; border-bottom: 1px solid #e2e8f0; } + .addon-table td { padding: 9px 14px; font-size: 13px; color: #374151; border-bottom: 1px solid #f1f5f9; } + .addon-table tr:last-child td { border-bottom: none; } + .addon-table .td-num { font-weight: 600; color: #111827; text-align: right; } .amount-box { background: #fffbeb; border: 1.5px solid #fbbf24; border-radius: 8px; padding: 14px 20px; display: flex; justify-content: space-between; align-items: center; margin-top: 20px; } .amount-box span { font-size: 13px; color: #92400e; } .amount-box strong { font-size: 20px; font-weight: 800; color: #b45309; } @@ -86,6 +91,28 @@ @endforeach @endif + @if(!empty($addonsSummary)) + + + + + + + + + + + @foreach($addonsSummary as $addon) + + + + + + @endforeach + +
Dịch vụ addonSố lượngThành tiền
{{ $addon['name'] }}{{ $addon['qty'] }}{{ number_format($addon['subtotal'], 0, ',', '.') }} VNĐ
+ @endif +
⚠️ Vui lòng đổi mật khẩu ngay sau khi đăng nhập lần đầu để bảo mật tài khoản.
Nếu cần hỗ trợ, liên hệ: {{ $supportEmail ?? '' }} hoặc {{ $supportPhone ?? '' }}