From 30898ea46f31d946587072af55d9285619f9f990 Mon Sep 17 00:00:00 2001 From: baodev0 Date: Fri, 9 Oct 2026 11:32:05 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20email=20g=E1=BB=ADi=20tay/nh=E1=BA=AFc?= =?UTF-8?q?=20gia=20h=E1=BA=A1n,=20g=C3=B3i=20hosting,=20s=E1=BB=ADa=20d?= =?UTF-8?q?=E1=BB=AF=20li=E1=BB=87u=20s=E1=BA=A3n=20ph=E1=BA=A9m?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Gửi email theo template cho 1 khách, tab lịch sử email, nhắc gia hạn dịch vụ hàng loạt - Mail lưu from/bcc/sent_by; mẫu MARKETING và mẫu cũ trong TemplateSeeder - Gói hosting: giới hạn, trạng thái, lấy plan Plesk qua CLI gateway - Backfill payment_method và ngày đơn hàng còn thiếu - Bổ sung field CPU/RAM/Disk/Băng thông cho loại sản phẩm vps và cloud Co-Authored-By: Claude Opus 5.5 --- app/Enums/Product/ProductTypeEnums.php | 6 +- app/Exports/ProductsExport.php | 92 ++++++++++ .../Customer/Concerns/ListsCustomerEmails.php | 68 ++++++++ .../Concerns/ManagesCustomerAccount.php | 21 +++ .../Concerns/ManagesCustomerServices.php | 1 + .../Api/Admin/Customer/CustomerController.php | 2 + .../Domain/AdminDomainOrderController.php | 44 ++++- .../Admin/Hosting/ServicePlanController.php | 71 +++----- .../Concerns/HandlesInvoicePayments.php | 4 + .../MailCampaign/MailCampaignController.php | 30 ++++ .../Api/Admin/Order/OrderController.php | 9 + .../Product/ProductCategoryController.php | 12 ++ .../Api/Admin/Product/ProductController.php | 11 ++ .../ServiceRenewalReminderController.php | 55 ++++++ .../Admin/Hosting/ServicePlanRequest.php | 57 +++++++ .../Resources/Api/Admin/OrderResource.php | 11 ++ app/Jobs/Mail/SendMailCampaignRecipient.php | 25 +-- app/Listeners/Mail/CheckMailEnabled.php | 6 +- app/Mail/Customer/ManualCustomerEmail.php | 6 +- app/Models/Mail.php | 8 + app/Models/Order.php | 2 +- app/Models/ServicePlan.php | 16 ++ app/Models/Template.php | 9 +- app/Models/User.php | 2 +- app/Repositories/Order/OrderRepository.php | 2 + .../Api/Domain/DomainOrderService.php | 5 +- app/Services/Customer/CustomerMailService.php | 83 ++++++++- .../CustomerServiceProvisioningService.php | 9 +- app/Services/InvoicePaymentService.php | 13 +- app/Services/OrderService.php | 28 ++- app/Services/Plesk/PleskService.php | 1 + .../Plesk/PleskServicePlanService.php | 39 +++++ .../Product/ProductCategorySampleService.php | 91 ++++++++++ .../ServiceRenewalReminderService.php | 81 +++++++++ app/Support/AutoEmailGate.php | 12 +- app/Support/EmailScenarioRegistry.php | 2 +- ...00002_add_sender_and_bcc_to_mail_table.php | 25 +++ ...mits_and_status_to_service_plans_table.php | 35 ++++ ...ill_order_payment_method_from_invoices.php | 28 +++ ...09_120100_backfill_missing_order_dates.php | 21 +++ ...000_seed_spec_fields_for_vps_and_cloud.php | 46 +++++ database/seeders/EmailScenarioDemoSeeder.php | 137 +++++++++++++++ .../EmailTemplates/BillingTemplates.php | 29 +++- .../EmailTemplates/LegacyTemplates.php | 74 ++++++++ .../EmailTemplates/MarketingTemplates.php | 45 +++++ .../EmailTemplates/TemplateVariables.php | 5 + database/seeders/ProductTypeFieldSeeder.php | 4 + database/seeders/TemplateSeeder.php | 37 +++- routes/api/admin/billing.php | 1 + routes/api/admin/catalog.php | 4 + routes/api/admin/customers.php | 10 ++ routes/api/admin/hosting.php | 1 + routes/api/admin/system.php | 1 + .../Admin/Customer/SendTemplateEmailTest.php | 161 ++++++++++++++++++ .../UpdateServiceBillingCycleTest.php | 40 +++++ .../Api/Admin/Hosting/ServicePlanTest.php | 123 +++++++++++++ .../Api/Admin/Service/RenewalReminderTest.php | 78 +++++++++ .../Payment/InvoicePaymentServiceTest.php | 29 ++++ tests/Feature/TemplateSeederTest.php | 11 +- 59 files changed, 1771 insertions(+), 108 deletions(-) create mode 100644 app/Exports/ProductsExport.php create mode 100644 app/Http/Controllers/Api/Admin/Customer/Concerns/ListsCustomerEmails.php create mode 100644 app/Http/Controllers/Api/Admin/Service/ServiceRenewalReminderController.php create mode 100644 app/Http/Requests/Admin/Hosting/ServicePlanRequest.php create mode 100644 app/Services/Plesk/PleskServicePlanService.php create mode 100644 app/Services/Product/ProductCategorySampleService.php create mode 100644 app/Services/ServiceRenewalReminderService.php create mode 100644 database/migrations/2026_10_08_000002_add_sender_and_bcc_to_mail_table.php create mode 100644 database/migrations/2026_10_08_000003_add_limits_and_status_to_service_plans_table.php create mode 100644 database/migrations/2026_10_09_120000_backfill_order_payment_method_from_invoices.php create mode 100644 database/migrations/2026_10_09_120100_backfill_missing_order_dates.php create mode 100644 database/migrations/2026_10_09_130000_seed_spec_fields_for_vps_and_cloud.php create mode 100644 database/seeders/EmailScenarioDemoSeeder.php create mode 100644 database/seeders/EmailTemplates/LegacyTemplates.php create mode 100644 database/seeders/EmailTemplates/MarketingTemplates.php create mode 100644 tests/Feature/Api/Admin/Customer/SendTemplateEmailTest.php create mode 100644 tests/Feature/Api/Admin/Customer/UpdateServiceBillingCycleTest.php create mode 100644 tests/Feature/Api/Admin/Hosting/ServicePlanTest.php create mode 100644 tests/Feature/Api/Admin/Service/RenewalReminderTest.php diff --git a/app/Enums/Product/ProductTypeEnums.php b/app/Enums/Product/ProductTypeEnums.php index 0a97c69..e5f53af 100644 --- a/app/Enums/Product/ProductTypeEnums.php +++ b/app/Enums/Product/ProductTypeEnums.php @@ -10,7 +10,7 @@ enum ProductTypeEnums: string case EMAIL = 'email'; case SSL = 'ssl'; case DOMAIN = 'domain'; - case WEB_DESIGN = 'web design'; + case WEB_DESIGN = 'web_design'; case ADDON = 'addon'; case HARDWARE = 'hardware'; case IT_SUPPORT = 'it_support'; @@ -24,7 +24,7 @@ enum ProductTypeEnums: string self::CLOUD => 'Cloud', self::EMAIL => 'Email', self::SSL => 'SSL', - self::DOMAIN => 'Domain', + self::DOMAIN => 'Tên miền', self::WEB_DESIGN => 'Web Design', self::ADDON => 'Addon', self::HARDWARE => 'Hardware', @@ -49,4 +49,4 @@ enum ProductTypeEnums: string self::SERVER_RENTAL => 'magenta', }; } -} \ No newline at end of file +} diff --git a/app/Exports/ProductsExport.php b/app/Exports/ProductsExport.php new file mode 100644 index 0000000..98cd132 --- /dev/null +++ b/app/Exports/ProductsExport.php @@ -0,0 +1,92 @@ +with(['category', 'pricings', 'operatingSystems']) + ->orderBy('id') + ->get(); + } + + public function headings(): array + { + return [ + 'ID', 'UUID', 'Tên sản phẩm', 'Slug', 'Ảnh đại diện', 'Loại sản phẩm', + 'ID danh mục', 'Danh mục', 'ID gói dịch vụ', 'CPU', 'RAM', 'Ổ đĩa', + 'Băng thông', 'Số tên miền', 'Phí khởi tạo', 'Giá thấp nhất', 'Loại thanh toán', + 'Tiền tệ', 'Giá tháng', 'Giá quý', 'Giá 6 tháng', 'Giá năm', 'Giá 2 năm', + 'Giá 3 năm', 'Giá một lần', 'Tính năng', 'Đang hoạt động', 'ID hệ điều hành', + 'ID máy chủ module', 'ID datastore module', 'Ngày tạo', 'Ngày cập nhật', + ]; + } + + public function map($product): array + { + $prices = $product->pricings->keyBy('billing_cycle'); + $hasOneTime = $prices->has('one_time'); + $hasRecurring = $prices->keys()->contains(fn ($cycle) => $cycle !== 'one_time'); + + return [ + $product->id, + $product->uuid, + $product->name, + $product->slug, + $product->thumbnail, + $product->product_type, + $product->product_categories_id, + $product->category?->name, + $product->service_plan_id, + $product->cpu, + $product->ram, + $product->disk, + $product->bandwidth, + $product->domain_count, + $product->setup_fee, + $prices->min('price'), + $hasOneTime && !$hasRecurring ? 'Một lần' : 'Định kỳ', + $prices->first()?->currency ?? 'VND', + $prices->get('monthly')?->price, + $prices->get('quarterly')?->price, + $prices->get('semi_annually')?->price, + $prices->get('annually')?->price, + $prices->get('biennially')?->price, + $prices->get('triennially')?->price, + $prices->get('one_time')?->price, + $product->features ? json_encode($product->features, JSON_UNESCAPED_UNICODE) : null, + $product->is_active ? 'Có' : 'Không', + $product->operatingSystems->pluck('id')->implode(', '), + $product->module_host_id, + $product->module_datastore_id, + optional($product->created_at)->format('Y-m-d H:i:s'), + optional($product->updated_at)->format('Y-m-d H:i:s'), + ]; + } + + public function registerEvents(): array + { + return [ + AfterSheet::class => function (AfterSheet $event): void { + $sheet = $event->sheet->getDelegate(); + $sheet->freezePane('A2'); + $sheet->setAutoFilter($sheet->calculateWorksheetDimension()); + $sheet->getStyle('A1:AF1')->getFont()->setBold(true)->getColor()->setARGB('FFFFFFFF'); + $sheet->getStyle('A1:AF1')->getFill()->setFillType('solid')->getStartColor()->setARGB('FF1677FF'); + $sheet->getStyle('A1:AF1')->getAlignment()->setVertical('center'); + $sheet->getRowDimension(1)->setRowHeight(24); + }, + ]; + } +} diff --git a/app/Http/Controllers/Api/Admin/Customer/Concerns/ListsCustomerEmails.php b/app/Http/Controllers/Api/Admin/Customer/Concerns/ListsCustomerEmails.php new file mode 100644 index 0000000..c82ffe2 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/Customer/Concerns/ListsCustomerEmails.php @@ -0,0 +1,68 @@ +resolveCustomer($uuid); + $search = trim((string) $request->get('search', '')); + + $paginator = Mail::query() + ->with(['sender', 'template:id,name']) + ->where('customer_id', $customer->id) + ->when($search !== '', fn ($q) => $q->where(fn ($q) => $q + ->where('subject', 'like', "%{$search}%") + ->orWhere('to', 'like', "%{$search}%"))) + ->latest('id') + ->paginate(min((int) $request->get('per_page', 10), 100), ['id', 'template_id', 'subject', 'from_address', 'to', 'cc', 'bcc', 'sent_by', 'status', 'created_at']); + + return response()->json([ + 'error' => false, + 'data' => [ + 'items' => $paginator->getCollection()->map($this->mapCustomerEmail(...))->all(), + 'total' => $paginator->total(), + 'current_page' => $paginator->currentPage(), + 'per_page' => $paginator->perPage(), + ], + ]); + } + + public function emailDetail(string $uuid, int $id): JsonResponse + { + $customer = $this->resolveCustomer($uuid); + $mail = Mail::with(['sender', 'template:id,name']) + ->where('customer_id', $customer->id) + ->find($id); + + if (!$mail) { + return response()->json(['error' => true, 'message' => 'Không tìm thấy email'], 404); + } + + return response()->json([ + 'error' => false, + 'data' => [...$this->mapCustomerEmail($mail), 'html' => $mail->message], + ]); + } + + private function mapCustomerEmail(Mail $mail): array + { + return [ + 'id' => $mail->id, + 'subject' => $mail->subject, + 'from_address' => $mail->from_address, + 'sent_by' => $mail->sender ? ($mail->sender->name ?: $mail->sender->username) : null, + 'to' => $mail->to, + 'cc' => $mail->cc, + 'bcc' => $mail->bcc, + 'template' => $mail->template?->name, + 'status' => $mail->status, + 'created_at' => $mail->created_at, + ]; + } +} diff --git a/app/Http/Controllers/Api/Admin/Customer/Concerns/ManagesCustomerAccount.php b/app/Http/Controllers/Api/Admin/Customer/Concerns/ManagesCustomerAccount.php index 0495806..3c9dbdc 100644 --- a/app/Http/Controllers/Api/Admin/Customer/Concerns/ManagesCustomerAccount.php +++ b/app/Http/Controllers/Api/Admin/Customer/Concerns/ManagesCustomerAccount.php @@ -34,6 +34,26 @@ trait ManagesCustomerAccount return response()->json(['error' => false, 'message' => 'Đã gửi mật khẩu mới cho khách hàng.']); } + public function emailPreview(Request $request, string $uuid): JsonResponse + { + $data = $request->validate(['template_id' => 'required|integer']); + + return response()->json([ + 'error' => false, + 'data' => $this->mailService->previewTemplateEmail($uuid, (int) $data['template_id']), + ]); + } + + public function emailLayout(Request $request, string $uuid): JsonResponse + { + $data = $request->validate(['message' => 'nullable|string']); + + return response()->json([ + 'error' => false, + 'data' => ['html' => $this->mailService->wrapWithLayout($uuid, (string) ($data['message'] ?? ''))], + ]); + } + public function sendEmail(Request $request, string $uuid): JsonResponse { $data = $request->validate([ @@ -43,6 +63,7 @@ trait ManagesCustomerAccount 'to' => 'nullable|string|max:1000', 'cc' => 'nullable|string|max:1000', 'bcc' => 'nullable|string|max:1000', + 'with_layout' => 'nullable|boolean', ]); $this->mailService->sendCustomEmail($uuid, $data, Auth::guard('admin')->user(), $request->ip()); diff --git a/app/Http/Controllers/Api/Admin/Customer/Concerns/ManagesCustomerServices.php b/app/Http/Controllers/Api/Admin/Customer/Concerns/ManagesCustomerServices.php index be1afb6..24462ed 100644 --- a/app/Http/Controllers/Api/Admin/Customer/Concerns/ManagesCustomerServices.php +++ b/app/Http/Controllers/Api/Admin/Customer/Concerns/ManagesCustomerServices.php @@ -35,6 +35,7 @@ trait ManagesCustomerServices 'requirements' => 'nullable|string', 'feedback' => 'nullable|string', 'price' => 'nullable|numeric|min:0', + 'billing_cycle' => 'nullable|in:monthly,quarterly,semi_annually,annually,biennially,triennially', 'virtual_machine_id' => 'nullable|integer|exists:virtual_machines,id', 'os_id' => 'nullable|integer|exists:os,id', 'vm_cpu' => 'nullable|integer|min:1', diff --git a/app/Http/Controllers/Api/Admin/Customer/CustomerController.php b/app/Http/Controllers/Api/Admin/Customer/CustomerController.php index 40c0bbb..4c7d24d 100644 --- a/app/Http/Controllers/Api/Admin/Customer/CustomerController.php +++ b/app/Http/Controllers/Api/Admin/Customer/CustomerController.php @@ -24,6 +24,7 @@ use App\Http\Controllers\Api\Admin\Customer\Concerns\ManagesCustomerDomains; use App\Http\Controllers\Api\Admin\Customer\Concerns\ManagesCustomerServices; use App\Http\Controllers\Api\Admin\Customer\Concerns\ManagesCustomerBulkActions; use App\Http\Controllers\Api\Admin\Customer\Concerns\ManagesCustomerNotes; +use App\Http\Controllers\Api\Admin\Customer\Concerns\ListsCustomerEmails; class CustomerController extends Controller { @@ -34,6 +35,7 @@ class CustomerController extends Controller use ManagesCustomerServices; use ManagesCustomerBulkActions; use ManagesCustomerNotes; + use ListsCustomerEmails; public function __construct( private readonly CustomerRepositoryInterface $customerRepository, diff --git a/app/Http/Controllers/Api/Admin/Domain/AdminDomainOrderController.php b/app/Http/Controllers/Api/Admin/Domain/AdminDomainOrderController.php index f784cd6..df2363d 100644 --- a/app/Http/Controllers/Api/Admin/Domain/AdminDomainOrderController.php +++ b/app/Http/Controllers/Api/Admin/Domain/AdminDomainOrderController.php @@ -16,6 +16,8 @@ use App\Repositories\Api\Domain\DomainOrderRepository; use App\Services\Api\Domain\DomainOrderService; use App\Services\Api\Domain\DomainRegistrantService; use App\Services\Email\EmailTemplateService; +use App\Support\EmailTemplateShell; +use App\Support\MailGlobals; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Http\Response; @@ -278,22 +280,46 @@ class AdminDomainOrderController extends Controller return; } - $setting = \App\Models\Setting::where('key', 'setting')->first(); - $settingValue = $setting ? json_decode((string) $setting->value, true) : []; - app(EmailTemplateService::class)->send($scenario, $customer->email, [ - 'company_name' => $settingValue['company'] ?? config('app.name'), - 'company_logo' => $settingValue['logo'] ?? '', - 'domain_name' => $domainName, - 'client_name' => $customer->fullname, - 'order_num' => $orderNum, - 'reason' => $reason, + ...MailGlobals::values(), + ...$this->domainHandoverVariables($order), + 'domain_name' => $domainName, + 'client_name' => $customer->fullname, + 'customer_email' => $customer->email, + 'order_num' => $orderNum, + 'reason' => $reason, ]); } catch (\Throwable $e) { Log::error('AdminDomainOrderController: không thể gửi email khách hàng: ' . $e->getMessage()); } } + /** + * @return array + */ + private function domainHandoverVariables(Order $order): array + { + $service = $order->orderItems->first()?->service?->fresh(); + + if (!$service) { + return []; + } + + return [ + 'registered_at' => $service->started_at?->format('d/m/Y') ?? '', + 'expires_at' => $service->expires_at?->format('d/m/Y') ?? '', + 'kyc_block' => $service->kyc_url ? $this->kycBlock($service->kyc_url) : '', + ]; + } + + private function kycBlock(string $kycUrl): string + { + return EmailTemplateShell::heading('📋 Lưu ý quan trọng') + . EmailTemplateShell::paragraph('Căn cứ Khoản 2 Điều 9 và Khoản 6 Điều 21 Thông tư số 48/2025/TT-BKHCN ngày 25/12/2025 của Bộ Khoa học và Công nghệ về Quản lý và sử dụng tài nguyên Internet, chủ thể đăng ký tên miền là cá nhân có trách nhiệm hoàn thiện hồ sơ đăng ký thông qua hình thức xác thực điện tử (eKYC) trong vòng 03 ngày kể từ thời điểm nhận được email này.') + . EmailTemplateShell::button(htmlspecialchars($kycUrl, ENT_QUOTES, 'UTF-8'), '✅ Xác thực eKYC ngay') + . EmailTemplateShell::note('Việc không hoàn thành xác thực đúng hạn có thể ảnh hưởng đến quyền sử dụng tên miền của Quý khách.', '⏰ Lưu ý:'); + } + // ========================================================================= // Private formatters // ========================================================================= diff --git a/app/Http/Controllers/Api/Admin/Hosting/ServicePlanController.php b/app/Http/Controllers/Api/Admin/Hosting/ServicePlanController.php index 9329269..c366640 100644 --- a/app/Http/Controllers/Api/Admin/Hosting/ServicePlanController.php +++ b/app/Http/Controllers/Api/Admin/Hosting/ServicePlanController.php @@ -3,15 +3,17 @@ namespace App\Http\Controllers\Api\Admin\Hosting; use App\Http\Controllers\Controller; +use App\Http\Requests\Admin\Hosting\ServicePlanRequest; +use App\Models\DnsServer; use App\Models\ServicePlan; +use App\Services\Plesk\PleskServicePlanService; use Illuminate\Http\JsonResponse; -use Illuminate\Http\Request; class ServicePlanController extends Controller { public function index(): JsonResponse { - $plans = ServicePlan::with('plesk', 'dnsServer')->orderBy('title')->get(); + $plans = ServicePlan::with('plesk', 'dnsServer')->orderBy('sort_order')->orderBy('title')->get(); return response()->json(['error' => false, 'data' => $plans]); } @@ -27,29 +29,11 @@ class ServicePlanController extends Controller return response()->json(['error' => false, 'data' => $plan]); } - public function store(Request $request): JsonResponse + public function store(ServicePlanRequest $request): JsonResponse { - $validated = $request->validate([ - 'title' => 'required|string|max:255', - 'vcpu' => 'required|integer|min:0', - 'ram' => 'required|integer|min:0', - 'disk' => 'required|integer|min:0', - 'domain' => 'required|integer|min:0', - 'dns_manager_server_id' => 'nullable|integer|exists:dns_manager_servers,id', - 'plesk_plan_name' => 'nullable|string|max:255', - 'note' => 'nullable|string', - ]); + $plan = ServicePlan::create($request->planAttributes()); - $plan = ServicePlan::create([ - 'title' => $validated['title'], - 'vcpu' => $validated['vcpu'], - 'ram' => $validated['ram'], - 'disk' => $validated['disk'], - 'domain' => $validated['domain'], - 'dns_manager_server_id' => $validated['dns_manager_server_id'] ?? null, - ]); - - $this->savePlesk($plan, $validated); + $this->savePlesk($plan, $request->validated()); return response()->json([ 'error' => false, @@ -58,7 +42,7 @@ class ServicePlanController extends Controller ], 201); } - public function update(Request $request, int $id): JsonResponse + public function update(ServicePlanRequest $request, int $id): JsonResponse { $plan = ServicePlan::find($id); @@ -66,27 +50,9 @@ class ServicePlanController extends Controller return response()->json(['error' => true, 'message' => 'Không tìm thấy gói hosting.'], 404); } - $validated = $request->validate([ - 'title' => 'required|string|max:255', - 'vcpu' => 'required|integer|min:0', - 'ram' => 'required|integer|min:0', - 'disk' => 'required|integer|min:0', - 'domain' => 'required|integer|min:0', - 'dns_manager_server_id' => 'nullable|integer|exists:dns_manager_servers,id', - 'plesk_plan_name' => 'nullable|string|max:255', - 'note' => 'nullable|string', - ]); + $plan->update($request->planAttributes()); - $plan->update([ - 'title' => $validated['title'], - 'vcpu' => $validated['vcpu'], - 'ram' => $validated['ram'], - 'disk' => $validated['disk'], - 'domain' => $validated['domain'], - 'dns_manager_server_id' => $validated['dns_manager_server_id'] ?? null, - ]); - - $this->savePlesk($plan, $validated); + $this->savePlesk($plan, $request->validated()); return response()->json([ 'error' => false, @@ -108,6 +74,23 @@ class ServicePlanController extends Controller return response()->json(['error' => false, 'message' => 'Xoá gói hosting thành công.']); } + public function pleskPlans(int $serverId, PleskServicePlanService $plans): JsonResponse + { + $server = DnsServer::find($serverId); + + if (!$server || !$server->servesHosting()) { + return response()->json(['error' => true, 'message' => 'Server không hỗ trợ hosting.'], 404); + } + + $names = $plans->names($server); + + if ($names === null) { + return response()->json(['error' => true, 'message' => 'Không lấy được danh sách plan từ Plesk.'], 502); + } + + return response()->json(['error' => false, 'data' => $names]); + } + private function savePlesk(ServicePlan $plan, array $data): void { if (empty($data['plesk_plan_name'])) { diff --git a/app/Http/Controllers/Api/Admin/Invoice/Concerns/HandlesInvoicePayments.php b/app/Http/Controllers/Api/Admin/Invoice/Concerns/HandlesInvoicePayments.php index c2d2907..8d9cbbd 100644 --- a/app/Http/Controllers/Api/Admin/Invoice/Concerns/HandlesInvoicePayments.php +++ b/app/Http/Controllers/Api/Admin/Invoice/Concerns/HandlesInvoicePayments.php @@ -28,6 +28,8 @@ trait HandlesInvoicePayments // hoá đơn ghi nhận trả một phần và chưa kích hoạt dịch vụ. $validated = $request->validate([ 'received_amount' => 'nullable|integer|min:1', + 'recording_mode' => 'nullable|string|in:manual,automatic', + 'note' => 'nullable|string|max:1000', ]); try { @@ -37,7 +39,9 @@ trait HandlesInvoicePayments $invoice = $this->invoicePaymentService->markPaidByAdmin( $invoice, auth()->id(), + method: $validated['recording_mode'] ?? 'manual', receivedAmount: $validated['received_amount'] ?? null, + note: $validated['note'] ?? '', ); DB::commit(); diff --git a/app/Http/Controllers/Api/Admin/MailCampaign/MailCampaignController.php b/app/Http/Controllers/Api/Admin/MailCampaign/MailCampaignController.php index d4353c1..cd5ff41 100644 --- a/app/Http/Controllers/Api/Admin/MailCampaign/MailCampaignController.php +++ b/app/Http/Controllers/Api/Admin/MailCampaign/MailCampaignController.php @@ -74,6 +74,36 @@ final class MailCampaignController extends Controller ]); } + public function variableSuggestions(Request $request): JsonResponse + { + $data = $request->validate([ + 'template_id' => 'required|integer|exists:template,id', + ]); + + $suggestions = MailCampaign::query() + ->where('template_id', $data['template_id']) + ->whereNotNull('variables') + ->where('sent_count', '>', 0) + ->orderByDesc('created_at') + ->limit(20) + ->get(['uuid', 'name', 'variables', 'created_at']) + ->filter(fn (MailCampaign $campaign) => !empty($campaign->variables)) + ->unique(fn (MailCampaign $campaign) => json_encode($campaign->variables)) + ->take(8) + ->map(fn (MailCampaign $campaign) => [ + 'uuid' => $campaign->uuid, + 'name' => $campaign->name, + 'variables' => $campaign->variables, + 'created_at' => $campaign->created_at, + ]) + ->values(); + + return response()->json([ + 'error' => false, + 'data' => $suggestions, + ]); + } + public function preview(Request $request, MailCampaignRenderer $renderer): JsonResponse { $campaign = $this->draftCampaign($request->validate($this->contentRules())); diff --git a/app/Http/Controllers/Api/Admin/Order/OrderController.php b/app/Http/Controllers/Api/Admin/Order/OrderController.php index a9ddc12..964d317 100644 --- a/app/Http/Controllers/Api/Admin/Order/OrderController.php +++ b/app/Http/Controllers/Api/Admin/Order/OrderController.php @@ -7,6 +7,7 @@ use App\Http\Requests\OrderRequest; use App\Http\Requests\UpdateOrderRequest; use App\Http\Resources\Api\Admin\OrderResource; use App\Models\Order; +use App\Models\DomainPricing; use App\Repositories\Order\OrderRepositoryInterface; use App\Services\OrderService; use Illuminate\Http\JsonResponse; @@ -42,6 +43,14 @@ class OrderController extends Controller ]); } + public function domainPricing(): JsonResponse + { + return response()->json([ + 'error' => false, + 'data' => DomainPricing::toResponseArray(), + ]); + } + public function store(OrderRequest $request): JsonResponse { try { diff --git a/app/Http/Controllers/Api/Admin/Product/ProductCategoryController.php b/app/Http/Controllers/Api/Admin/Product/ProductCategoryController.php index d25dec0..6224cfe 100644 --- a/app/Http/Controllers/Api/Admin/Product/ProductCategoryController.php +++ b/app/Http/Controllers/Api/Admin/Product/ProductCategoryController.php @@ -5,12 +5,24 @@ namespace App\Http\Controllers\Api\Admin\Product; use App\Http\Controllers\Controller; use App\Http\Resources\Api\Admin\ProductCategoryResource; use App\Models\ProductCategories; +use App\Services\Product\ProductCategorySampleService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Str; class ProductCategoryController extends Controller { + public function seedSample(ProductCategorySampleService $service): JsonResponse + { + $result = $service->seed(); + + return response()->json([ + 'error' => false, + 'message' => "Đã tạo {$result['created']} và bổ sung thông tin cho {$result['updated']} nhóm dịch vụ.", + 'data' => $result, + ]); + } + public function index(Request $request): JsonResponse { $perPage = min((int) $request->get('per_page', 15), 100); diff --git a/app/Http/Controllers/Api/Admin/Product/ProductController.php b/app/Http/Controllers/Api/Admin/Product/ProductController.php index 2d3f804..b1e4682 100644 --- a/app/Http/Controllers/Api/Admin/Product/ProductController.php +++ b/app/Http/Controllers/Api/Admin/Product/ProductController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\Api\Admin\Product; +use App\Exports\ProductsExport; use App\Http\Controllers\Controller; use App\Http\Resources\Api\Admin\ProductResource; use App\Http\Services\NextjsRevalidationService; @@ -12,6 +13,9 @@ use App\Models\Os; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Str; +use Maatwebsite\Excel\Excel as ExcelWriter; +use Maatwebsite\Excel\Facades\Excel; +use Symfony\Component\HttpFoundation\BinaryFileResponse; class ProductController extends Controller { @@ -40,6 +44,13 @@ class ProductController extends Controller ]); } + public function export(): BinaryFileResponse + { + $filename = 'danh-sach-san-pham-' . now()->format('Y-m-d-His') . '.xlsx'; + + return Excel::download(new ProductsExport(), $filename, ExcelWriter::XLSX); + } + public function show(int $id): JsonResponse { $product = $this->products->findWithPricings($id); diff --git a/app/Http/Controllers/Api/Admin/Service/ServiceRenewalReminderController.php b/app/Http/Controllers/Api/Admin/Service/ServiceRenewalReminderController.php new file mode 100644 index 0000000..cc7990c --- /dev/null +++ b/app/Http/Controllers/Api/Admin/Service/ServiceRenewalReminderController.php @@ -0,0 +1,55 @@ +validate(['days' => 'required|integer|min:1|max:365'])['days']; + + $items = $this->reminders->expiring($days)->map(fn (Service $service) => [ + 'id' => $service->id, + 'server_name' => $service->server_name, + 'product_name' => $service->product?->name, + 'expires_at' => $service->expires_at, + 'days_left' => $this->reminders->daysLeft($service), + 'auto_renew' => (bool) $service->auto_renew, + 'customer' => [ + 'uuid' => $service->customer->uuid, + 'fullname' => $service->customer->fullname, + 'email' => $service->customer->email, + ], + ]); + + return response()->json([ + 'error' => false, + 'data' => $items->values(), + 'truncated' => $items->count() >= ServiceRenewalReminderService::MAX_RESULTS, + ]); + } + + public function send(Request $request): JsonResponse + { + $data = $request->validate([ + 'service_ids' => 'required|array|min:1|max:' . ServiceRenewalReminderService::MAX_RESULTS, + 'service_ids.*' => 'integer', + ]); + + $count = $this->reminders->send($data['service_ids'], Auth::guard('admin')->user()); + + return response()->json([ + 'error' => false, + 'message' => "Đã đưa {$count} email nhắc gia hạn vào hàng đợi gửi.", + ]); + } +} diff --git a/app/Http/Requests/Admin/Hosting/ServicePlanRequest.php b/app/Http/Requests/Admin/Hosting/ServicePlanRequest.php new file mode 100644 index 0000000..0ade686 --- /dev/null +++ b/app/Http/Requests/Admin/Hosting/ServicePlanRequest.php @@ -0,0 +1,57 @@ + + */ + public function rules(): array + { + return [ + 'title' => 'required|string|max:255', + 'description' => 'nullable|string|max:2000', + 'vcpu' => 'required|integer|min:0', + 'ram' => 'required|integer|min:0', + 'disk' => 'required|integer|min:0', + 'domain' => 'required|integer|min:0', + 'bandwidth' => 'nullable|integer|min:0', + 'databases' => 'nullable|integer|min:0', + 'mailboxes' => 'nullable|integer|min:0', + 'mailbox_quota' => 'nullable|integer|min:0', + 'ftp_accounts' => 'nullable|integer|min:0', + 'subdomains' => 'nullable|integer|min:0', + 'domain_aliases' => 'nullable|integer|min:0', + 'features' => 'nullable|array|max:30', + 'features.*' => 'string|distinct|max:100', + 'is_active' => 'boolean', + 'sort_order' => 'nullable|integer|min:0', + 'dns_manager_server_id' => 'nullable|integer|exists:dns_manager_servers,id', + 'plesk_plan_name' => 'nullable|string|max:255', + 'note' => 'nullable|string', + ]; + } + + /** + * @return array + */ + public function planAttributes(): array + { + $validated = $this->validated(); + + return [ + ...collect($validated)->except(['plesk_plan_name', 'note'])->all(), + 'dns_manager_server_id' => $validated['dns_manager_server_id'] ?? null, + 'sort_order' => $validated['sort_order'] ?? 0, + 'is_active' => $validated['is_active'] ?? true, + ]; + } +} diff --git a/app/Http/Resources/Api/Admin/OrderResource.php b/app/Http/Resources/Api/Admin/OrderResource.php index 78bbd04..01d1166 100644 --- a/app/Http/Resources/Api/Admin/OrderResource.php +++ b/app/Http/Resources/Api/Admin/OrderResource.php @@ -65,6 +65,17 @@ class OrderResource extends JsonResource 'reminder_step' => (int) $invoice->reminder_step, 'billing_period_start' => $invoice->billing_period_start, 'billing_period_end' => $invoice->billing_period_end, + 'payment_method' => $invoice->payment?->name ?? $invoice->payment_method, + 'note' => $invoice->note, + 'attachments' => $invoice->relationLoaded('attachments') + ? $invoice->attachments->map(fn($attachment) => [ + 'id' => $attachment->id, + 'filename' => $attachment->filename, + 'url' => $attachment->url, + 'mime_type' => $attachment->mime_type, + 'size' => $attachment->size, + ]) + : [], // Which service(s)/product(s) this invoice actually bills for — without this, // an invoice was a black box: just a total with no visible line items. 'items' => $invoice->relationLoaded('invoiceItems') diff --git a/app/Jobs/Mail/SendMailCampaignRecipient.php b/app/Jobs/Mail/SendMailCampaignRecipient.php index a5aecd4..3dc04a6 100644 --- a/app/Jobs/Mail/SendMailCampaignRecipient.php +++ b/app/Jobs/Mail/SendMailCampaignRecipient.php @@ -39,6 +39,8 @@ class SendMailCampaignRecipient implements ShouldQueue $customer = $this->customerId ? Customer::find($this->customerId) : null; $rendered = $renderer->render($campaign, $renderer->recipientValues($campaign, $customer, $this->email)); $cc = EmailRecipients::parse($campaign->cc); + $bcc = EmailRecipients::parse($campaign->bcc); + $mailConfig = Arr::only($campaign->template?->mailConfig() ?? [], ['from_email', 'from_name', 'reply_to', 'reply_to_name']); $failed = false; try { @@ -46,13 +48,13 @@ class SendMailCampaignRecipient implements ShouldQueue if ($cc) { $mailer->cc($cc); } - if ($bcc = EmailRecipients::parse($campaign->bcc)) { + if ($bcc) { $mailer->bcc($bcc); } $failed = $mailer->send(new GenericTemplateMail( $rendered['subject'], $rendered['html'], - Arr::only($campaign->template?->mailConfig() ?? [], ['from_email', 'from_name', 'reply_to', 'reply_to_name']), + $mailConfig, )) === null; } catch (\Throwable $e) { $failed = true; @@ -64,14 +66,17 @@ class SendMailCampaignRecipient implements ShouldQueue } MailLog::create([ - 'campaign_id' => $campaign->id, - 'template_id' => $campaign->template_id ?? 0, - 'subject' => $rendered['subject'], - 'to' => $this->email, - 'cc' => implode(', ', $cc), - 'customer_id' => $customer?->id, - 'message' => $rendered['html'], - 'status' => $failed ? 'failed' : 'sent', + 'campaign_id' => $campaign->id, + 'template_id' => $campaign->template_id ?? 0, + 'subject' => $rendered['subject'], + 'from_address' => $mailConfig['from_email'] ?? config('mail.from.address'), + 'to' => $this->email, + 'cc' => implode(', ', $cc), + 'bcc' => implode(', ', $bcc), + 'customer_id' => $customer?->id, + 'sent_by' => $campaign->created_by, + 'message' => $rendered['html'], + 'status' => $failed ? 'failed' : 'sent', ]); $this->markDone($campaign, $failed); diff --git a/app/Listeners/Mail/CheckMailEnabled.php b/app/Listeners/Mail/CheckMailEnabled.php index 42c2d96..4a6ce1c 100644 --- a/app/Listeners/Mail/CheckMailEnabled.php +++ b/app/Listeners/Mail/CheckMailEnabled.php @@ -2,7 +2,6 @@ namespace App\Listeners\Mail; -use App\Models\Setting; use App\Support\AutoEmailGate; use Illuminate\Mail\Events\MessageSending; use Illuminate\Support\Facades\Log; @@ -17,10 +16,7 @@ class CheckMailEnabled */ public function handle(MessageSending $event): bool { - $setting = Setting::where('key', 'email_settings')->first(); - $config = $setting ? json_decode($setting->value, true) : []; - - if (array_key_exists('mail_enabled', $config) && !$config['mail_enabled']) { + if (!AutoEmailGate::mailEnabled()) { Log::info('Mail sending blocked: mail_enabled is off.', [ 'subject' => $event->message->getSubject(), ]); diff --git a/app/Mail/Customer/ManualCustomerEmail.php b/app/Mail/Customer/ManualCustomerEmail.php index 37e0f7a..5629289 100644 --- a/app/Mail/Customer/ManualCustomerEmail.php +++ b/app/Mail/Customer/ManualCustomerEmail.php @@ -3,6 +3,7 @@ namespace App\Mail\Customer; use App\Models\Template; +use App\Support\EmailTemplateShell; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Mail\Mailable; @@ -17,6 +18,7 @@ class ManualCustomerEmail extends Mailable implements ShouldQueue public function __construct( private readonly string $emailSubject, private readonly string $htmlContent, + private readonly bool $wrapLayout = true, ) {} public function envelope(): Envelope @@ -27,7 +29,9 @@ class ManualCustomerEmail extends Mailable implements ShouldQueue public function content(): Content { return new Content( - html: Template::renderWithLayout($this->emailSubject, $this->htmlContent), + htmlString: $this->wrapLayout + ? Template::renderWithLayout($this->emailSubject, $this->htmlContent) + : EmailTemplateShell::document($this->emailSubject, $this->htmlContent), ); } diff --git a/app/Models/Mail.php b/app/Models/Mail.php index 6b90d69..77a0db1 100644 --- a/app/Models/Mail.php +++ b/app/Models/Mail.php @@ -15,9 +15,12 @@ class Mail extends Model 'campaign_id', 'template_id', 'subject', + 'from_address', 'to', 'cc', + 'bcc', 'customer_id', + 'sent_by', 'message', 'status' ]; @@ -44,6 +47,11 @@ class Mail extends Model return $this->belongsTo(Customer::class, 'customer_id'); } + public function sender() + { + return $this->belongsTo(User::class, 'sent_by'); + } + public function template() { return $this->belongsTo(Template::class, 'template_id'); diff --git a/app/Models/Order.php b/app/Models/Order.php index 7d3b2f3..5c453a1 100644 --- a/app/Models/Order.php +++ b/app/Models/Order.php @@ -69,7 +69,7 @@ class Order extends Model public function payment() { - return $this->belongsTo(Payment::class); + return $this->belongsTo(Payment::class, 'payment_method', 'id'); } public function moduleVmwares() diff --git a/app/Models/ServicePlan.php b/app/Models/ServicePlan.php index f78c578..68037fe 100644 --- a/app/Models/ServicePlan.php +++ b/app/Models/ServicePlan.php @@ -8,13 +8,29 @@ class ServicePlan extends Model { protected $fillable = [ 'title', + 'description', 'vcpu', 'ram', 'disk', 'domain', + 'bandwidth', + 'databases', + 'mailboxes', + 'mailbox_quota', + 'ftp_accounts', + 'subdomains', + 'domain_aliases', + 'features', + 'is_active', + 'sort_order', 'dns_manager_server_id', ]; + protected $casts = [ + 'features' => 'array', + 'is_active' => 'boolean', + ]; + public function plesk() { return $this->hasOne(ServicePlanPlesk::class); diff --git a/app/Models/Template.php b/app/Models/Template.php index 446ecd6..fd8a633 100644 --- a/app/Models/Template.php +++ b/app/Models/Template.php @@ -108,14 +108,17 @@ class Template extends Model * dùng khung header/footer chuẩn trong code thay vì gửi ra nội dung trần. */ public static function renderWithLayout(?string $subject, string $contentHtml): string + { + return EmailTemplateShell::document($subject, self::layoutBody($contentHtml)); + } + + public static function layoutBody(string $contentHtml): string { $layout = self::where('scenario', 'layout')->where('status', 1)->first(); - $body = $layout && $layout->message + return $layout && $layout->message ? str_replace('{{$content}}', $contentHtml, $layout->message) : EmailTemplateShell::wrap(EmailTemplateShell::body($contentHtml)); - - return EmailTemplateShell::document($subject, $body); } public function type() diff --git a/app/Models/User.php b/app/Models/User.php index 91581ac..c6cae19 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -64,7 +64,7 @@ class User extends Authenticatable implements JWTSubject public function getNameAttribute(): string { - return trim((string) $this->fullname); + return trim("{$this->firstname} {$this->lastname}") ?: trim((string) $this->fullname); } public function staffOnline() diff --git a/app/Repositories/Order/OrderRepository.php b/app/Repositories/Order/OrderRepository.php index e34dfe4..4775770 100644 --- a/app/Repositories/Order/OrderRepository.php +++ b/app/Repositories/Order/OrderRepository.php @@ -152,6 +152,8 @@ class OrderRepository extends BaseRepository implements OrderRepositoryInterface 'payment', 'discount', 'invoices.invoiceItems.product', + 'invoices.attachments', + 'invoices.payment', ])->where('order_num', $orderNum)->first(); } } diff --git a/app/Services/Api/Domain/DomainOrderService.php b/app/Services/Api/Domain/DomainOrderService.php index 6f0bdc2..d51436a 100644 --- a/app/Services/Api/Domain/DomainOrderService.php +++ b/app/Services/Api/Domain/DomainOrderService.php @@ -274,9 +274,12 @@ class DomainOrderService 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).'); } + $startedAt = $service->started_at ?? now(); + $service->update([ 'status' => DomainProvisioningStatus::ACTIVE->value, - 'started_at' => $service->started_at ?? now(), + 'started_at' => $startedAt, + 'expires_at' => $service->expires_at ?? $startedAt->copy()->addMonths((int) $orderItem->months), ]); $order->update(['status' => OrderStatus::ACTIVE->value]); diff --git a/app/Services/Customer/CustomerMailService.php b/app/Services/Customer/CustomerMailService.php index d4046ec..29dea28 100644 --- a/app/Services/Customer/CustomerMailService.php +++ b/app/Services/Customer/CustomerMailService.php @@ -2,8 +2,9 @@ namespace App\Services\Customer; -use App\Support\EmailRecipients; +use App\Exceptions\InvalidTemplateVariableException; use App\Mail\Customer\ManualCustomerEmail; +use App\Mail\GenericTemplateMail; use App\Mail\Customer\PasswordResetByAdmin; use App\Models\Customer; use App\Models\Log; @@ -12,6 +13,11 @@ use App\Models\Template; use App\Models\User; use App\Repositories\Customer\CustomerRepositoryInterface; use App\Services\Customer\Concerns\ResolvesCustomer; +use App\Services\Email\EmailTemplateRenderer; +use App\Support\AutoEmailGate; +use App\Support\EmailRecipients; +use App\Support\MailGlobals; +use App\Support\MailPlaceholders; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Mail; @@ -22,8 +28,62 @@ class CustomerMailService public function __construct( private readonly CustomerRepositoryInterface $customerRepository, + private readonly EmailTemplateRenderer $renderer, ) {} + /** + * @return array{subject: string, html: string, cc: array, bcc: array} + */ + public function previewTemplateEmail(string $uuid, int $templateId): array + { + $customer = $this->resolveCustomer($uuid); + $template = $this->sendableTemplate($templateId); + $config = $template->mailConfig(); + + return [ + ...$this->renderForCustomer($template, $customer), + 'cc' => $config['cc'] ?? [], + 'bcc' => $config['bcc'] ?? [], + ]; + } + + public function wrapWithLayout(string $uuid, string $contentHtml): string + { + $customer = $this->resolveCustomer($uuid); + + return MailPlaceholders::fill(Template::layoutBody($contentHtml), [ + ...MailGlobals::values(), + ...MailPlaceholders::recipientValues($customer, $customer->email), + ]); + } + + private function sendableTemplate(int $templateId): Template + { + $template = Template::query()->whereKey($templateId)->where('status', 1)->first(); + abort_if(!$template, 422, 'Template không tồn tại hoặc đang bị tắt.'); + abort_if($template->format === 'json', 422, 'Không thể gửi template JSON (hoá đơn).'); + + return $template; + } + + /** + * @return array{subject: string, html: string} + */ + private function renderForCustomer(Template $template, Customer $customer): array + { + $values = collect($template->variables()) + ->map(fn ($definition) => $definition['example'] ?? '') + ->merge(MailGlobals::values()) + ->merge(MailPlaceholders::recipientValues($customer, $customer->email)) + ->all(); + + try { + return $this->renderer->render($template, $values); + } catch (InvalidTemplateVariableException $e) { + abort(422, $e->getMessage()); + } + } + /** * Reset về mật khẩu ngẫu nhiên và gửi mail cho khách hàng. */ @@ -80,16 +140,16 @@ class CustomerMailService { $customer = $this->resolveCustomer($uuid); - $template = null; + abort_if(!AutoEmailGate::mailEnabled(), 422, 'Hệ thống đang tắt gửi email (Mẫu email → Cài đặt → Bật gửi email).'); + + $template = empty($data['template_id']) ? null : $this->sendableTemplate((int) $data['template_id']); $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(); - abort_if(!$template, 422, 'Template không tồn tại hoặc đang bị tắt.'); - - $subject = trim((string) $template->subject); - $message = trim((string) $template->message); + if ($template && ($subject === '' || $message === '')) { + $rendered = $this->renderForCustomer($template, $customer); + $subject = $subject ?: $rendered['subject']; + $message = $message ?: $rendered['html']; } abort_if($subject === '' || $message === '', 422, 'Subject và content không được để trống.'); @@ -107,14 +167,19 @@ class CustomerMailService if ($bcc !== []) { $mailer->bcc($bcc); } - $mailer->send(new ManualCustomerEmail($subject, $message)); + $mailer->send($template + ? new GenericTemplateMail($subject, $message, array_diff_key($template->mailConfig(), array_flip(['cc', 'bcc']))) + : new ManualCustomerEmail($subject, $message, wrapLayout: (bool) ($data['with_layout'] ?? true))); MailLog::create([ 'template_id' => $template->id ?? 0, 'subject' => $subject, + 'from_address' => $template?->mailConfig()['from_email'] ?? config('mail.from.address'), 'to' => implode(', ', $to), 'cc' => implode(', ', $cc), + 'bcc' => implode(', ', $bcc), 'customer_id' => $customer->id, + 'sent_by' => $admin->id, 'message' => $message, ]); diff --git a/app/Services/Customer/CustomerServiceProvisioningService.php b/app/Services/Customer/CustomerServiceProvisioningService.php index 344e4b5..95a88ae 100644 --- a/app/Services/Customer/CustomerServiceProvisioningService.php +++ b/app/Services/Customer/CustomerServiceProvisioningService.php @@ -82,7 +82,7 @@ class CustomerServiceProvisioningService return DB::transaction(function () use ($customer, $service, $data) { $vmFields = ['virtual_machine_id', 'os_id', 'vm_cpu', 'vm_ram_mb', 'vm_disk_gb', 'vm_ip', 'vm_dns', 'vm_user', 'vm_password', 'vm_port']; - $service->fill(collect($data)->except(['price', ...$vmFields])->all()); + $service->fill(collect($data)->except(['price', 'billing_cycle', ...$vmFields])->all()); $service->save(); if (!empty($data['virtual_machine_id'])) { @@ -93,6 +93,13 @@ class CustomerServiceProvisioningService $service->orderItem->update(['price' => $data['price']]); } + if (!empty($data['billing_cycle']) && $service->orderItem) { + $service->orderItem->update([ + 'billing_cycle' => $data['billing_cycle'], + 'months' => OrderHelper::cycleToMonths($data['billing_cycle']), + ]); + } + // Đồng bộ sang module_vmware nếu có gán VM if ($service->vm_id) { $vmData = array_filter([ diff --git a/app/Services/InvoicePaymentService.php b/app/Services/InvoicePaymentService.php index f3198d3..0e591fd 100644 --- a/app/Services/InvoicePaymentService.php +++ b/app/Services/InvoicePaymentService.php @@ -145,12 +145,13 @@ class InvoicePaymentService ?int $adminId = null, string $method = 'admin', ?int $receivedAmount = null, + string $note = '', ): Invoice { if ((int) $invoice->status === InvoiceStatus::PAID->value) { return $invoice->fresh(['order.orderItems.product', 'services', 'customer']); } - return DB::transaction(function () use ($invoice, $adminId, $method, $receivedAmount) { + return DB::transaction(function () use ($invoice, $adminId, $method, $receivedAmount, $note) { $invoice = Invoice::where('id', $invoice->id)->lockForUpdate()->firstOrFail(); if ((int) $invoice->status === InvoiceStatus::PAID->value) { @@ -174,13 +175,19 @@ class InvoicePaymentService 'status' => $settled ? InvoiceStatus::PAID->value : $invoice->status, 'paid_at' => $settled ? now() : null, 'payment_method' => $invoice->payment_method ?: $method, + 'note' => $note !== '' ? $note : $invoice->note, ]); + $transactionNote = 'Admin xác nhận thanh toán hóa đơn #' . $invoice->invoice_number + . ($settled ? '' : sprintf(' (một phần: %s/%s VNĐ)', number_format($paidSoFar), number_format((int) $invoice->total))); + if ($note !== '') { + $transactionNote .= '. Ghi chú: ' . $note; + } + $this->recordTransaction( $invoice, $method, - 'Admin xác nhận thanh toán hóa đơn #' . $invoice->invoice_number - . ($settled ? '' : sprintf(' (một phần: %s/%s VNĐ)', number_format($paidSoFar), number_format((int) $invoice->total))), + $transactionNote, $adminId, amount: $received, sequence: $settled && $received === (int) $invoice->total ? null : $paidSoFar, diff --git a/app/Services/OrderService.php b/app/Services/OrderService.php index 78050b5..8624908 100644 --- a/app/Services/OrderService.php +++ b/app/Services/OrderService.php @@ -13,6 +13,7 @@ use App\Jobs\Mail\Customer\OrderCreatedJob; use App\Jobs\VirtualMachine\Create\CreateVirtualMachineJob; use App\Jobs\VirtualMachine\Upgrade\UpgradeVirtualMachine; use App\Models\Customer; +use App\Models\DomainPricing; use App\Models\Order; use App\Models\OrderItem; use App\Models\Payment; @@ -155,11 +156,15 @@ class OrderService $orderData = [ 'order_num' => Helper::generateUniqueCode(), + 'date' => Carbon::today()->toDateString(), 'customer_id' => $data['customer_id'], 'discount_id' => $data['discount_id'] ?? null, 'sub_total' => $total, 'total' => $orderTotal, - 'payment_method' => $paymentRecord?->code ?? null, + // `payment` has no `code` column. Store its primary key in the + // legacy string column so the selected method can be resolved + // consistently on both orders and invoices. + 'payment_method' => $paymentRecord?->id, 'payment_proof' => $data['payment_proof'] ?? null, 'user_id' => Auth::guard('admin')->id(), 'status' => $effectiveOrderStatus, @@ -287,7 +292,20 @@ class OrderService }; $billingDetails = $this->calculateBillingDetails($item['billing']); - $priceSelect = $item['price_custom'] ?? $product->price_sale ?? $product->price; + $isDomain = $categoryType === ProductTypeEnums::DOMAIN; + $billingMultiplier = $isDomain ? 1 : $billingDetails['billing']; + + if ($isDomain) { + $domainName = strtolower(trim((string) ($item['server_name'] ?? $item['domain'] ?? ''))); + if ($domainName === '') { + throw new \InvalidArgumentException('Vui lòng nhập đầy đủ tên miền.'); + } + + $domainPrice = DomainPricing::findByDomain($domainName)->orderPrice(1); + $priceSelect = $item['price_custom'] ?? $domainPrice; + } else { + $priceSelect = $item['price_custom'] ?? $product->price_sale ?? $product->price; + } $billingCycle = ProductBillingCycle::fromLegacyCode((int) $item['billing']); $generalCode = Helper::generateUniqueCode(); @@ -312,8 +330,8 @@ class OrderService 'customer_id' => $data['customer_id'], 'status' => 0, 'name' => $item['server_name'] ?? $item['domain'] ?? $product->name, - 'first_payment' => $priceSelect * $billingDetails['billing'], - 'next_payment' => ($item['price_custom'] ?? $product->price) * $billingDetails['billing'], + 'first_payment' => $priceSelect * $billingMultiplier, + 'next_payment' => $priceSelect * $billingMultiplier, ...$billingDetails, ], ]; @@ -326,7 +344,7 @@ class OrderService } } - $total += $item['quantity'] * ($item['price_custom'] ?? ($product->price_sale ?? $product->price)) * $billingDetails['billing']; + $total += $item['quantity'] * $priceSelect * $billingMultiplier; } return [$productItems, $relIdData, $total]; diff --git a/app/Services/Plesk/PleskService.php b/app/Services/Plesk/PleskService.php index b65b366..7d01c96 100644 --- a/app/Services/Plesk/PleskService.php +++ b/app/Services/Plesk/PleskService.php @@ -13,6 +13,7 @@ class PleskService protected array $entryPoints = [ 'clients' => ':8443/api/v2/clients', 'domains' => ':8443/api/v2/domains', + 'cli' => ':8443/api/v2/cli', ]; public function client(DnsServer $server, int $timeout = 30): PendingRequest diff --git a/app/Services/Plesk/PleskServicePlanService.php b/app/Services/Plesk/PleskServicePlanService.php new file mode 100644 index 0000000..2388e18 --- /dev/null +++ b/app/Services/Plesk/PleskServicePlanService.php @@ -0,0 +1,39 @@ +|null + */ + public function names(DnsServer $server): ?array + { + try { + $response = $this->plesk->client($server, 15) + ->post($this->plesk->url($server, 'cli', '/service_plan/call'), ['params' => ['--list']]); + } catch (ConnectionException $e) { + Log::error('Plesk service plan list failed', ['error' => $e->getMessage(), 'server_id' => $server->id]); + + return null; + } + + if (!$response->successful() || (int) $response->json('code', 1) !== 0) { + Log::error('Plesk service plan list failed', ['status' => $response->status(), 'server_id' => $server->id]); + + return null; + } + + $lines = preg_split('/\R/', (string) $response->json('stdout', '')); + + return array_values(array_unique(array_filter(array_map('trim', $lines), 'strlen'))); + } +} diff --git a/app/Services/Product/ProductCategorySampleService.php b/app/Services/Product/ProductCategorySampleService.php new file mode 100644 index 0000000..74b491f --- /dev/null +++ b/app/Services/Product/ProductCategorySampleService.php @@ -0,0 +1,91 @@ + 'vps', 'name' => 'VPS', 'type' => 'vps', 'sort_order' => 10, 'billing_cycles' => $this->recurringCycles()], + ['slug' => 'hosting', 'name' => 'Hosting', 'type' => 'hosting', 'sort_order' => 20, 'billing_cycles' => $this->recurringCycles()], + ['slug' => 'cloud-storage', 'name' => 'Cloud Storage', 'type' => 'cloud', 'sort_order' => 30, 'billing_cycles' => $this->recurringCycles()], + ['slug' => 'email', 'name' => 'Email', 'type' => 'email', 'sort_order' => 40, 'billing_cycles' => $this->recurringCycles()], + ['slug' => 'hardware', 'name' => 'Hardware', 'type' => 'hardware', 'sort_order' => 50, 'billing_cycles' => ['one_time']], + ['slug' => 'thiet-ke-website', 'name' => 'Thiết kế website', 'type' => 'web_design', 'sort_order' => 60, 'billing_cycles' => ['one_time']], + ['slug' => 'it-support', 'name' => 'IT Support', 'type' => 'it_support', 'sort_order' => 70, 'billing_cycles' => ['monthly', 'annually', 'one_time']], + ['slug' => 'domain', 'name' => 'Tên miền Việt Nam', 'type' => 'domain', 'sort_order' => 75, 'billing_cycles' => ['annually']], + ['slug' => 'other', 'name' => 'Other', 'type' => 'addon', 'sort_order' => 80, 'billing_cycles' => ['one_time']], + ]; + + return DB::transaction(function () use ($definitions): array { + $created = 0; + $updated = 0; + + foreach ($definitions as $definition) { + $group = ProductCategories::withTrashed()->firstOrNew(['slug' => $definition['slug']]); + $isNew = ! $group->exists; + + if ($isNew) { + $group->fill([ + ...$definition, + 'parent_id' => null, + 'is_active' => true, + ]); + } else { + foreach (['type', 'billing_cycles'] as $field) { + if (blank($group->{$field})) { + $group->{$field} = $definition[$field]; + } + } + } + + if ($isNew || $group->isDirty()) { + $group->save(); + $isNew ? $created++ : $updated++; + } + + if ($group->trashed()) { + $group->restore(); + $updated++; + } + } + + ProductCategories::query() + ->whereNotNull('type') + ->whereNull('billing_cycles') + ->get() + ->each(function (ProductCategories $group) use (&$updated): void { + $group->billing_cycles = $this->cyclesForType($group->type); + $group->save(); + $updated++; + }); + + return compact('created', 'updated'); + }); + } + + private function cyclesForType(string $type): array + { + return match ($type) { + 'hardware', 'web_design', 'addon' => ['one_time'], + 'domain' => ['annually'], + 'it_support' => ['monthly', 'annually', 'one_time'], + default => $this->recurringCycles(), + }; + } + + private function recurringCycles(): array + { + return ['monthly', 'quarterly', 'semi_annually', 'annually', 'biennially', 'triennially']; + } +} diff --git a/app/Services/ServiceRenewalReminderService.php b/app/Services/ServiceRenewalReminderService.php new file mode 100644 index 0000000..1f96576 --- /dev/null +++ b/app/Services/ServiceRenewalReminderService.php @@ -0,0 +1,81 @@ + + */ + public function expiring(int $days): Collection + { + return Service::with(['customer:id,uuid,fullname,email', 'product:id,name']) + ->where('status', 'active') + ->whereHas('customer') + ->whereBetween('expires_at', [now()->startOfDay(), now()->addDays($days)->endOfDay()]) + ->orderBy('expires_at') + ->limit(self::MAX_RESULTS) + ->get(); + } + + public function daysLeft(Service $service): int + { + return (int) now()->startOfDay()->diffInDays($service->expires_at->copy()->startOfDay(), false); + } + + /** + * @param array $serviceIds + */ + public function send(array $serviceIds, User $admin): int + { + abort_if(!AutoEmailGate::mailEnabled(), 422, 'Hệ thống đang tắt gửi email (Mẫu email → Cài đặt → Bật gửi email).'); + + try { + $template = $this->templates->loadTemplate(self::SCENARIO); + } catch (ModelNotFoundException) { + abort(422, 'Chưa có template nhắc gia hạn (service_renewal_reminder) đang bật trong Mẫu email.'); + } + + AutoEmailGate::markManual(); + + $services = Service::with(['customer', 'product', 'orderItem']) + ->whereIn('id', $serviceIds) + ->whereNotNull('expires_at') + ->get() + ->filter(fn (Service $service) => filled($service->customer?->email)); + + foreach ($services as $service) { + $mail = new ServiceRenewalReminderMail($service->customer, $service); + + MailLog::create([ + 'template_id' => $template->id, + 'subject' => $mail->envelope()->subject, + 'from_address' => $template->mailConfig()['from_email'] ?? config('mail.from.address'), + 'to' => $service->customer->email, + 'customer_id' => $service->customer_id, + 'sent_by' => $admin->id, + 'message' => $mail->render(), + ]); + + Mail::to($service->customer->email)->queue($mail); + } + + return $services->count(); + } +} diff --git a/app/Support/AutoEmailGate.php b/app/Support/AutoEmailGate.php index 151be2a..6d9fbad 100644 --- a/app/Support/AutoEmailGate.php +++ b/app/Support/AutoEmailGate.php @@ -25,13 +25,23 @@ final class AutoEmailGate return in_array($scenario, self::disabledScenarios(), true); } + public static function mailEnabled(): bool + { + return (bool) (self::emailSettings()['mail_enabled'] ?? true); + } + /** * @return array */ public static function disabledScenarios(): array + { + return (array) (self::emailSettings()['auto_disabled'] ?? []); + } + + private static function emailSettings(): array { $value = Setting::where('key', 'email_settings')->value('value'); - return (array) (json_decode($value ?? '', true)['auto_disabled'] ?? []); + return (array) (json_decode($value ?? '', true) ?? []); } } diff --git a/app/Support/EmailScenarioRegistry.php b/app/Support/EmailScenarioRegistry.php index 8da0367..0c85fb9 100644 --- a/app/Support/EmailScenarioRegistry.php +++ b/app/Support/EmailScenarioRegistry.php @@ -124,7 +124,7 @@ final class EmailScenarioRegistry 'required_variables' => ['domain_name', 'order_num'], ], 'domain_order_approved' => [ - 'label' => 'Tên miền được duyệt', 'category' => self::BUSINESS, 'group' => 'ORDER', + 'label' => 'Bàn giao tên miền (đã duyệt)', 'category' => self::BUSINESS, 'group' => 'ORDER', 'required_variables' => ['domain_name'], 'permissions' => ['disable' => true], ], 'domain_order_rejected' => [ diff --git a/database/migrations/2026_10_08_000002_add_sender_and_bcc_to_mail_table.php b/database/migrations/2026_10_08_000002_add_sender_and_bcc_to_mail_table.php new file mode 100644 index 0000000..9cf9c54 --- /dev/null +++ b/database/migrations/2026_10_08_000002_add_sender_and_bcc_to_mail_table.php @@ -0,0 +1,25 @@ +string('from_address', 255)->nullable()->after('subject'); + $table->string('bcc', 1000)->nullable()->after('cc'); + $table->unsignedBigInteger('sent_by')->nullable()->index()->after('customer_id'); + }); + } + + public function down(): void + { + Schema::table('mail', function (Blueprint $table) { + $table->dropIndex(['sent_by']); + $table->dropColumn(['from_address', 'bcc', 'sent_by']); + }); + } +}; diff --git a/database/migrations/2026_10_08_000003_add_limits_and_status_to_service_plans_table.php b/database/migrations/2026_10_08_000003_add_limits_and_status_to_service_plans_table.php new file mode 100644 index 0000000..2a125fd --- /dev/null +++ b/database/migrations/2026_10_08_000003_add_limits_and_status_to_service_plans_table.php @@ -0,0 +1,35 @@ +text('description')->nullable()->after('title'); + $table->unsignedInteger('bandwidth')->nullable()->after('domain'); + $table->unsignedInteger('databases')->nullable()->after('bandwidth'); + $table->unsignedInteger('mailboxes')->nullable()->after('databases'); + $table->unsignedInteger('mailbox_quota')->nullable()->after('mailboxes'); + $table->unsignedInteger('ftp_accounts')->nullable()->after('mailbox_quota'); + $table->unsignedInteger('subdomains')->nullable()->after('ftp_accounts'); + $table->unsignedInteger('domain_aliases')->nullable()->after('subdomains'); + $table->json('features')->nullable()->after('domain_aliases'); + $table->boolean('is_active')->default(true)->after('features'); + $table->unsignedInteger('sort_order')->default(0)->after('is_active'); + }); + } + + public function down(): void + { + Schema::table('service_plans', function (Blueprint $table) { + $table->dropColumn([ + 'description', 'bandwidth', 'databases', 'mailboxes', 'mailbox_quota', + 'ftp_accounts', 'subdomains', 'domain_aliases', 'features', 'is_active', 'sort_order', + ]); + }); + } +}; diff --git a/database/migrations/2026_10_09_120000_backfill_order_payment_method_from_invoices.php b/database/migrations/2026_10_09_120000_backfill_order_payment_method_from_invoices.php new file mode 100644 index 0000000..ec04d31 --- /dev/null +++ b/database/migrations/2026_10_09_120000_backfill_order_payment_method_from_invoices.php @@ -0,0 +1,28 @@ +whereNull('payment_method') + ->whereExists(function ($query) { + $query->selectRaw('1') + ->from('invoices') + ->whereColumn('invoices.order_id', 'orders.id') + ->whereNotNull('invoices.payment_method'); + }) + ->update([ + 'payment_method' => DB::raw('(SELECT invoices.payment_method FROM invoices WHERE invoices.order_id = orders.id AND invoices.payment_method IS NOT NULL ORDER BY invoices.id ASC LIMIT 1)'), + ]); + } + + public function down(): void + { + // Preserve recovered payment data. The application remains compatible + // because orders.payment_method is still the original nullable string. + } +}; diff --git a/database/migrations/2026_10_09_120100_backfill_missing_order_dates.php b/database/migrations/2026_10_09_120100_backfill_missing_order_dates.php new file mode 100644 index 0000000..3197930 --- /dev/null +++ b/database/migrations/2026_10_09_120100_backfill_missing_order_dates.php @@ -0,0 +1,21 @@ +whereNull('date') + ->whereNotNull('created_at') + ->update(['date' => DB::raw('DATE(created_at)')]); + } + + public function down(): void + { + // Preserve recovered order dates. The date column remains nullable and + // compatible with older application versions after a code rollback. + } +}; diff --git a/database/migrations/2026_10_09_130000_seed_spec_fields_for_vps_and_cloud.php b/database/migrations/2026_10_09_130000_seed_spec_fields_for_vps_and_cloud.php new file mode 100644 index 0000000..5b2f39b --- /dev/null +++ b/database/migrations/2026_10_09_130000_seed_spec_fields_for_vps_and_cloud.php @@ -0,0 +1,46 @@ + 'cpu', 'label' => 'CPU (vCPU)', 'sort_order' => 1], + ['key' => 'ram', 'label' => 'RAM (GB)', 'sort_order' => 2], + ['key' => 'disk', 'label' => 'Disk (GB)', 'sort_order' => 3], + ['key' => 'bandwidth', 'label' => 'Băng thông (GB)', 'sort_order' => 4], + ]; + + public function up(): void + { + $now = now(); + + foreach (self::TYPES as $type) { + foreach (self::FIELDS as $field) { + $exists = DB::table('product_type_fields') + ->where('product_type', $type) + ->where('key', $field['key']) + ->exists(); + + if ($exists) { + continue; + } + + DB::table('product_type_fields')->insert($field + [ + 'product_type' => $type, + 'field_type' => 'number', + 'is_required' => false, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + } + } + + public function down(): void + { + } +}; diff --git a/database/seeders/EmailScenarioDemoSeeder.php b/database/seeders/EmailScenarioDemoSeeder.php new file mode 100644 index 0000000..e981ba7 --- /dev/null +++ b/database/seeders/EmailScenarioDemoSeeder.php @@ -0,0 +1,137 @@ + ['Phạm Bảo (dev)', '0900000001', [['vps', 2], ['email', 25], ['vps', -4]]], + 'pbao.business@gmail.com' => ['Phạm Bảo Business', '0900000002', [['email', 5], ['server', 45]]], + 'phamgiabao48756@gmail.com' => ['Phạm Gia Bảo', '0900000003', [['website', 12], ['vps', 75]]], + 'pbao.drive@gmail.com' => ['Phạm Bảo Drive', '0900000004', [['server', 0], ['support', 150], ['email', 9, 'suspended']]], + ]; + + private const PRODUCT_KEYWORDS = [ + 'vps' => 'VPS', + 'email' => 'Email', + 'server' => 'Máy Chủ', + 'website' => 'Website', + 'support' => 'IT Support', + ]; + + public function run(): void + { + $this->call([ + RoleSeeder::class, + RolesAndPermissions::class, + TestAccountSeeder::class, + TemplateSeeder::class, + Setting::class, + ProductCategorySeeder::class, + ProductTypeFieldSeeder::class, + ProductMockupSeeder::class, + VPSProductSeeder::class, + ]); + + $this->enableMail(); + $marketing = Template::whereIn('name', array_column(MarketingTemplates::all(), 'name'))->orderBy('id')->get(); + $admin = User::where('username', 'admin')->firstOrFail(); + + foreach (self::CUSTOMERS as $email => [$name, $phone, $services]) { + $customer = $this->seedCustomer($email, $name, $phone); + + foreach ($services as $service) { + $this->seedService($customer, ...$service); + } + + $this->seedMailHistory($customer, $admin, $marketing->first()); + } + + $this->command?->info('Đã tạo ' . count(self::CUSTOMERS) . ' khách hàng, ' . count($marketing) . ' mẫu marketing và dịch vụ sắp hết hạn.'); + } + + private function enableMail(): void + { + $setting = SettingModel::firstOrNew(['key' => 'email_settings']); + $value = json_decode($setting->value ?? '{}', true) ?: []; + $setting->value = json_encode([...$value, 'mail_enabled' => true], JSON_UNESCAPED_UNICODE); + $setting->save(); + } + + private function seedCustomer(string $email, string $name, string $phone): Customer + { + $customer = Customer::firstOrNew(['email' => $email]); + $customer->fill([ + 'fullname' => $name, + 'username' => Str::before($email, '@'), + 'phone' => $phone, + 'password' => Hash::make(self::CUSTOMER_PASSWORD), + 'email_verified_at' => now(), + 'email_verify' => 1, + ]); + $customer->forceFill(['status' => 1])->save(); + + Service::where('customer_id', $customer->id)->where('note', 'seed:email-demo')->delete(); + + return $customer; + } + + private function seedService(Customer $customer, string $kind, int $daysLeft, string $status = 'active'): void + { + $product = Product::where('name', 'like', '%' . self::PRODUCT_KEYWORDS[$kind] . '%')->first() ?? Product::first(); + + Service::create([ + 'uuid' => (string) Str::uuid(), + 'customer_id' => $customer->id, + 'product_id' => $product?->id, + 'server_name' => Str::upper($kind) . '-' . Str::before($customer->email, '@'), + 'status' => $status, + 'auto_renew' => $daysLeft > 30, + 'started_at' => now()->addDays($daysLeft)->subYear(), + 'expires_at' => now()->addDays($daysLeft)->setTime(17, 0), + 'note' => 'seed:email-demo', + ]); + } + + private function seedMailHistory(Customer $customer, User $admin, Template $template): void + { + Mail::where('customer_id', $customer->id)->delete(); + + Mail::create([ + 'template_id' => $template->id, + 'subject' => str_replace('{{client_name}}', $customer->fullname, $template->subject), + 'from_address' => config('mail.from.address'), + 'to' => $customer->email, + 'bcc' => 'audit@tgs.com.vn', + 'customer_id' => $customer->id, + 'sent_by' => $admin->id, + 'message' => str_replace(['{{client_name}}', '{{promo_code}}', '{{frontend_url}}'], [$customer->fullname, 'TGS30', '#'], $template->message), + ]); + + Mail::create([ + 'subject' => 'Chào mừng ' . $customer->fullname . ' đến với Thế Giới Số', + 'to' => $customer->email, + 'customer_id' => $customer->id, + 'message' => Template::renderWithLayout('Chào mừng', Shell::paragraph('Tài khoản của Quý khách đã được kích hoạt.')), + ]); + } +} diff --git a/database/seeders/EmailTemplates/BillingTemplates.php b/database/seeders/EmailTemplates/BillingTemplates.php index d9cee90..718548b 100644 --- a/database/seeders/EmailTemplates/BillingTemplates.php +++ b/database/seeders/EmailTemplates/BillingTemplates.php @@ -116,18 +116,31 @@ final class BillingTemplates ], [ 'scenario' => 'domain_order_approved', - 'name' => 'Tên miền được duyệt', - 'subject' => 'Tên miền {{domain_name}} đã được duyệt đăng ký', - 'body' => T::title('Tên miền đã được duyệt') + 'name' => 'Bàn giao tên miền', + 'subject' => 'Bàn giao tên miền {{domain_name}} - {{company_name}}', + 'body' => T::title('Bàn giao tên miền') . T::greeting('{{client_name}}') - . T::paragraph('Đơn đăng ký tên miền của Quý khách đã được duyệt và kích hoạt thành công.') + . T::paragraph('Công ty Cổ phần Giải pháp Công nghệ Thế Giới Số xin chân thành cảm ơn Quý khách đã tin tưởng lựa chọn dịch vụ đăng ký và quản lý tên miền của chúng tôi.') + . T::heading('🌐 Thông tin tên miền') . T::infoTable([ - 'Tên miền' => '{{domain_name}}', - 'Mã đơn hàng' => '{{order_num}}', + 'Tên miền' => '{{domain_name}}', + 'Mã đơn hàng' => '{{order_num}}', + 'Ngày đăng ký' => '{{registered_at}}', + 'Ngày hết hạn' => '{{expires_at}}', + 'NS1' => '{{name_server_1}}', + 'NS2' => '{{name_server_2}}', ]) - . T::paragraph('Tên miền hiện đã hoạt động.') + . T::heading('🔐 Thông tin tài khoản quản lý') + . T::infoTable([ + 'Trang đăng nhập' => '{{login_url}}', + 'Tên đăng nhập' => '{{customer_email}}', + 'Mật khẩu' => 'Mật khẩu tài khoản portal của Quý khách', + ]) + . T::note('Quý khách vui lòng bảo mật thông tin tài khoản. Nếu quên mật khẩu, sử dụng chức năng "Quên mật khẩu" tại trang đăng nhập.') + . '{{kyc_block}}' + . T::heading('📞 Hỗ trợ kỹ thuật 24/7') . T::supportLine() - . T::autoNotice(), + . T::paragraph('Một lần nữa, Thế Giới Số xin chân thành cảm ơn sự tin tưởng và đồng hành của Quý khách.'), ], [ 'scenario' => 'domain_order_rejected', diff --git a/database/seeders/EmailTemplates/LegacyTemplates.php b/database/seeders/EmailTemplates/LegacyTemplates.php new file mode 100644 index 0000000..1321197 --- /dev/null +++ b/database/seeders/EmailTemplates/LegacyTemplates.php @@ -0,0 +1,74 @@ + + */ + public static function all(): array + { + return [ + [ + 'scenario' => 'customer_signup_verify', + 'name' => '[Admin] Đơn hàng mới đã thanh toán', + 'subject' => '[ADMIN] Đơn hàng mới #{{order_num}} — {{customer_email}}', + 'body' => T::title('📦 Đơn hàng mới đã thanh toán') + . T::subtitle('Đơn #{{order_num}} — {{created_at}}') + . T::heading('Thông tin khách hàng') + . T::infoTable([ + 'Tên khách hàng' => '{{customer_name}}', + 'Email' => '{{customer_email}}', + ], '{{client_ip_row}}') + . T::heading('Thông tin đơn hàng') + . T::infoTable([ + 'Mã đơn hàng' => '#{{order_num}}', + 'Sản phẩm' => '{{product_name}}', + 'Loại dịch vụ' => '{{product_type}}', + 'Chu kỳ thanh toán' => '{{billing_cycle}}', + 'Tổng tiền' => '{{total_formatted}}', + 'Trạng thái' => '✅ Đã thanh toán', + ]) + . '{{vm_block}}' + . T::autoNotice(), + ], + [ + 'scenario' => 'customer_password_reset', + 'name' => '[Admin] Yêu cầu nạp tiền chờ duyệt (kèm chứng từ)', + 'subject' => '[ADMIN] Yêu cầu nạp tiền chờ duyệt - {{transaction_code}}', + 'body' => T::title('🔔 Yêu cầu nạp tiền chờ duyệt') + . T::subtitle('{{transaction_code}} — {{created_at}}') + . T::heading('Thông tin khách hàng') + . T::infoTable([ + 'Tên khách hàng' => '{{customer_name}}', + 'Email' => '{{customer_email}}', + ], '{{customer_phone_row}}{{client_ip_row}}') + . T::heading('Thông tin giao dịch') + . T::infoTable([ + 'Mã giao dịch' => '{{transaction_code}}', + 'Số tiền' => '{{amount_formatted}}', + 'Trạng thái' => 'Đang chờ duyệt', + ], '{{proof_row}}') + . T::note('Vui lòng vào trang quản trị để kiểm tra chứng từ và duyệt hoặc từ chối giao dịch.'), + ], + [ + 'scenario' => 'vm_password_reset_success', + 'name' => 'Mật khẩu VPS đã được đặt lại', + 'subject' => 'Mật khẩu VPS của bạn đã được đặt lại', + 'body' => T::title('🔑 Mật khẩu VPS đã được đặt lại') + . T::subtitle('Máy chủ: {{vm_name}}') + . T::paragraph('Xin chào Quý khách,') + . T::paragraph('Mật khẩu đăng nhập máy chủ ảo {{vm_name}} đã được đặt lại thành công. Vui lòng sử dụng mật khẩu mới bên dưới để đăng nhập:') + . T::infoTable([ + 'Máy chủ' => '{{vm_name}}', + 'Mật khẩu mới' => '{{new_password}}', + ]) + . T::note('Vì lý do bảo mật, vui lòng đổi lại mật khẩu ngay sau khi đăng nhập thành công.') + . T::autoNotice(), + ], + ]; + } +} diff --git a/database/seeders/EmailTemplates/MarketingTemplates.php b/database/seeders/EmailTemplates/MarketingTemplates.php new file mode 100644 index 0000000..03b4d04 --- /dev/null +++ b/database/seeders/EmailTemplates/MarketingTemplates.php @@ -0,0 +1,45 @@ +}> + */ + public static function all(): array + { + return [ + [ + 'name' => 'Khuyến mãi tháng 10', + 'subject' => 'Ưu đãi 30% Cloud VPS dành riêng cho {{client_name}}', + 'body' => T::title('ƯU ĐÃI THÁNG 10') + . T::greeting('{{client_name}}') + . T::paragraph('Thế Giới Số gửi tặng Quý khách mã giảm 30% cho mọi gói Cloud VPS đăng ký mới hoặc gia hạn trong tháng 10.') + . T::highlight('{{promo_code}}') + . T::button('{{frontend_url}}', 'Đăng ký ngay'), + 'variables' => [['key' => 'promo_code', 'label' => 'Mã khuyến mãi', 'example' => 'TGS30']], + ], + [ + 'name' => 'Thông báo bảo trì hệ thống', + 'subject' => 'V/v: Thông báo bảo trì hệ thống ngày {{maintenance_date}}', + 'body' => T::title('THÔNG BÁO BẢO TRÌ') + . T::greeting('{{client_name}}') + . T::paragraph('Hệ thống sẽ bảo trì định kỳ vào {{maintenance_date}}, dự kiến gián đoạn tối đa 30 phút.') + . T::note('Dịch vụ của Quý khách sẽ tự động hoạt động lại sau khi bảo trì.'), + 'variables' => [['key' => 'maintenance_date', 'label' => 'Ngày bảo trì', 'example' => '20/10/2026 01:00']], + ], + [ + 'name' => 'Ra mắt Premium Hosting', + 'subject' => 'Thế Giới Số ra mắt Premium Hosting thế hệ mới', + 'body' => T::title('PREMIUM HOSTING') + . T::greeting('{{client_name}}') + . T::paragraph('Hạ tầng NVMe, LiteSpeed và sao lưu hằng ngày. Chuyển đổi miễn phí từ nhà cung cấp cũ.') + . T::button('https://tgs.com.vn/premium-hosting/', 'Xem bảng giá'), + 'variables' => [], + ], + ]; + } +} diff --git a/database/seeders/EmailTemplates/TemplateVariables.php b/database/seeders/EmailTemplates/TemplateVariables.php index f50536c..4a262a8 100644 --- a/database/seeders/EmailTemplates/TemplateVariables.php +++ b/database/seeders/EmailTemplates/TemplateVariables.php @@ -40,6 +40,7 @@ final class TemplateVariables 'ftp_account' => ['Tài khoản FTP', 'string', 'ftp_example'], 'ftp_password' => ['Mật khẩu FTP', 'string', 'Ftp#2026'], 'vm_name' => ['Tên VPS', 'string', 'VPS-Pro-01'], + 'new_password' => ['Mật khẩu mới', 'string', 'Xk9#pL2m'], 'vmware_id' => ['VM ID', 'string', 'vm-1024'], 'suspended_at' => ['Thời gian tạm ngưng', 'string', '08/10/2026 09:00'], 'server_name_suffix' => ['[Tự động] Tên máy chủ kèm theo', 'html', ''], @@ -57,6 +58,10 @@ final class TemplateVariables 'action_status' => ['Trạng thái thao tác', 'string', 'Thành công'], 'domain_name' => ['Tên miền', 'string', 'example.vn'], + 'registered_at' => ['Ngày đăng ký', 'string', '08/10/2026'], + 'name_server_1' => ['Name server 1', 'string', 'ns9.tgs.com.vn'], + 'name_server_2' => ['Name server 2', 'string', 'ns10.tgs.com.vn'], + 'kyc_block' => ['[Tự động] Khối xác thực eKYC', 'html', ''], 'hosting_plan' => ['Gói hosting', 'string', 'Premium Hosting 1'], 'server_host_row' => ['[Tự động] Dòng máy chủ / FTP host', 'html', ''], 'ftp_username_row' => ['[Tự động] Dòng tài khoản FTP', 'html', ''], diff --git a/database/seeders/ProductTypeFieldSeeder.php b/database/seeders/ProductTypeFieldSeeder.php index e8434c1..2193a6b 100644 --- a/database/seeders/ProductTypeFieldSeeder.php +++ b/database/seeders/ProductTypeFieldSeeder.php @@ -15,6 +15,10 @@ class ProductTypeFieldSeeder extends Seeder ['product_type' => 'vps', 'key' => 'ram', 'label' => 'RAM (GB)', 'field_type' => 'number', 'sort_order' => 2], ['product_type' => 'vps', 'key' => 'disk', 'label' => 'Disk (GB)', 'field_type' => 'number', 'sort_order' => 3], ['product_type' => 'vps', 'key' => 'bandwidth', 'label' => 'Băng thông (GB)', 'field_type' => 'number', 'sort_order' => 4], + ['product_type' => 'cloud', 'key' => 'cpu', 'label' => 'CPU (vCPU)', 'field_type' => 'number', 'sort_order' => 1], + ['product_type' => 'cloud', 'key' => 'ram', 'label' => 'RAM (GB)', 'field_type' => 'number', 'sort_order' => 2], + ['product_type' => 'cloud', 'key' => 'disk', 'label' => 'Disk (GB)', 'field_type' => 'number', 'sort_order' => 3], + ['product_type' => 'cloud', 'key' => 'bandwidth', 'label' => 'Băng thông (GB)', 'field_type' => 'number', 'sort_order' => 4], // Hosting ['product_type' => 'hosting', 'key' => 'cpu', 'label' => 'CPU (vCPU)', 'field_type' => 'number', 'sort_order' => 1], diff --git a/database/seeders/TemplateSeeder.php b/database/seeders/TemplateSeeder.php index bcf8744..faec28d 100644 --- a/database/seeders/TemplateSeeder.php +++ b/database/seeders/TemplateSeeder.php @@ -8,6 +8,8 @@ use App\Support\MailPlaceholders; use Database\Seeders\EmailTemplates\AccountTemplates; use Database\Seeders\EmailTemplates\AdminTemplates; use Database\Seeders\EmailTemplates\BillingTemplates; +use Database\Seeders\EmailTemplates\LegacyTemplates; +use Database\Seeders\EmailTemplates\MarketingTemplates; use Database\Seeders\EmailTemplates\ServiceTemplates; use Database\Seeders\EmailTemplates\TemplateVariables; use Illuminate\Database\Seeder; @@ -16,9 +18,31 @@ class TemplateSeeder extends Seeder { public function run(): void { - foreach (self::templates() as $item) { + foreach ([...self::templates(), ...self::legacyTemplates()] as $item) { $this->upsert($item); } + + foreach (MarketingTemplates::all() as $item) { + $this->createMarketingIfMissing($item); + } + } + + private function createMarketingIfMissing(array $item): void + { + $template = Template::firstOrNew(['name' => $item['name']]); + if ($template->exists) { + return; + } + + $template->fill([ + 'type_id' => 0, + 'format' => 'html', + 'subject' => $item['subject'], + 'message' => EmailTemplateShell::page($item['body']), + 'status' => 1, + ]); + $template->syncVariables($item['variables']); + $template->save(); } /** @@ -46,6 +70,17 @@ class TemplateSeeder extends Seeder return [$layout, ...$scenarios]; } + /** + * @return array + */ + public static function legacyTemplates(): array + { + return array_map( + fn (array $item) => [...$item, 'message' => EmailTemplateShell::page($item['body'])], + LegacyTemplates::all(), + ); + } + private function upsert(array $item): void { $scenario = $item['scenario']; diff --git a/routes/api/admin/billing.php b/routes/api/admin/billing.php index 6752e56..392010a 100644 --- a/routes/api/admin/billing.php +++ b/routes/api/admin/billing.php @@ -13,6 +13,7 @@ Route::prefix('orders') ->controller(OrderController::class) ->group(function () { Route::get('/', 'index')->middleware('perm:order.view'); + Route::get('/domain-pricing', 'domainPricing')->middleware('perm:order.create'); Route::post('/upload-proof', 'uploadProof')->middleware('perm:order.create'); Route::post('/', 'store')->middleware('perm:order.create'); Route::get('{id}', 'show')->middleware('perm:order.view'); diff --git a/routes/api/admin/catalog.php b/routes/api/admin/catalog.php index 617875c..d61fc47 100644 --- a/routes/api/admin/catalog.php +++ b/routes/api/admin/catalog.php @@ -12,6 +12,7 @@ Route::prefix('products') ->controller(ProductController::class) ->group(function () { Route::get('/', 'index')->middleware('perm:product-service.view'); + Route::get('export', 'export')->middleware('perm:product-service.view'); Route::post('/', 'store')->middleware('perm:product-service.create'); Route::post('bulk-update-status', 'bulkUpdateStatus')->middleware('perm:product-service.edit'); Route::post('bulk-destroy', 'bulkDestroy')->middleware('perm:product-service.destroy'); @@ -36,6 +37,9 @@ Route::prefix('product-categories') ->group(function () { Route::get('/', 'index')->middleware('perm:product-service.view')->name('index'); Route::post('/', 'store')->middleware('perm:product-service.create')->name('store'); + Route::post('seed-sample', 'seedSample') + ->middleware('perm:product-service.create') + ->name('seed-sample'); Route::get('trashed', 'trashed')->middleware('perm:product-service.view')->name('trashed'); Route::post('bulk-update-status', 'bulkUpdateStatus') ->middleware('perm:product-service.edit') diff --git a/routes/api/admin/customers.php b/routes/api/admin/customers.php index d08fdc5..6067a50 100644 --- a/routes/api/admin/customers.php +++ b/routes/api/admin/customers.php @@ -2,6 +2,7 @@ use App\Http\Controllers\Api\Admin\Customer\CustomerController; use App\Http\Controllers\Api\Admin\Service\ServiceController; +use App\Http\Controllers\Api\Admin\Service\ServiceRenewalReminderController; use Illuminate\Support\Facades\Route; // Customers (module: customer) @@ -18,7 +19,11 @@ Route::prefix('customers') 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}/email-layout', 'emailLayout')->middleware('perm:customer.edit'); + Route::post('{uuid}/email-preview', 'emailPreview')->middleware('perm:customer.edit'); Route::post('{uuid}/send-email', 'sendEmail')->middleware('perm:customer.edit'); + Route::get('{uuid}/emails', 'emails')->middleware('perm:customer.view'); + Route::get('{uuid}/emails/{id}', 'emailDetail')->whereNumber('id')->middleware('perm:customer.view'); 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'); @@ -64,6 +69,11 @@ Route::prefix('customers') }); // Services (module: customer) +Route::prefix('services/renewal-reminders')->controller(ServiceRenewalReminderController::class)->group(function () { + Route::get('/', 'index')->middleware('perm:customer.view'); + Route::post('/', 'send')->middleware('perm:customer.edit'); +}); + Route::prefix('services')->controller(ServiceController::class)->group(function () { Route::get('/', 'index')->middleware('perm:customer.view'); Route::post('bulk-action', 'bulkAction')->middleware('perm:customer.edit'); diff --git a/routes/api/admin/hosting.php b/routes/api/admin/hosting.php index 8bde2f7..52bb64f 100644 --- a/routes/api/admin/hosting.php +++ b/routes/api/admin/hosting.php @@ -25,6 +25,7 @@ Route::prefix('service-plans') ->group(function () { Route::get('/', 'index')->middleware('perm:hosting.view')->name('index'); Route::post('/', 'store')->middleware('perm:hosting.create')->name('store'); + Route::get('plesk-plans/{serverId}', 'pleskPlans')->middleware('perm:hosting.view')->whereNumber('serverId')->name('plesk-plans'); Route::get('{id}', 'show')->middleware('perm:hosting.view')->name('show'); Route::put('{id}', 'update')->middleware('perm:hosting.edit')->name('update'); Route::delete('{id}', 'destroy')->middleware('perm:hosting.destroy')->name('destroy'); diff --git a/routes/api/admin/system.php b/routes/api/admin/system.php index 05106f5..758fb29 100644 --- a/routes/api/admin/system.php +++ b/routes/api/admin/system.php @@ -137,6 +137,7 @@ Route::prefix('mail-campaigns') ->controller(MailCampaignController::class) ->group(function () { Route::get('/', 'index')->middleware('perm:mail-campaign.view'); + Route::get('variable-suggestions', 'variableSuggestions')->middleware('perm:mail-campaign.create'); Route::post('preview', 'preview')->middleware('perm:mail-campaign.create'); Route::get('{uuid}/preview', 'previewCampaign')->middleware('perm:mail-campaign.view'); Route::get('{uuid}/logs/{id}', 'showLog')->whereNumber('id')->middleware('perm:mail-campaign.view'); diff --git a/tests/Feature/Api/Admin/Customer/SendTemplateEmailTest.php b/tests/Feature/Api/Admin/Customer/SendTemplateEmailTest.php new file mode 100644 index 0000000..ffbb243 --- /dev/null +++ b/tests/Feature/Api/Admin/Customer/SendTemplateEmailTest.php @@ -0,0 +1,161 @@ + 'Full Administrator', 'guard_name' => 'admin']); + $this->admin = User::factory()->create(); + $this->admin->assignRole('Full Administrator'); + + $this->customer = Customer::factory()->create(['fullname' => 'Nguyễn Văn A']); + + $this->template = Template::create([ + 'type_id' => 0, + 'format' => 'html', + 'name' => 'Khuyến mãi', + 'subject' => 'Chào {{client_name}}', + 'message' => '

Xin chào {{$client_name}}, mã {{promo_code}}

', + 'metadata' => [ + 'variables' => [ + ['key' => 'client_name', 'label' => 'Tên', 'type' => 'string', 'example' => 'Quý khách'], + ['key' => 'promo_code', 'label' => 'Mã', 'type' => 'string', 'example' => 'TGS10'], + ], + 'mail' => ['cc' => ['sale@example.com']], + ], + 'status' => 1, + ]); + } + + public function test_preview_renders_template_for_customer(): void + { + $this->actingAs($this->admin, 'admin') + ->postJson("/api/v1/administrator/customers/{$this->customer->uuid}/email-preview", [ + 'template_id' => $this->template->id, + ]) + ->assertOk() + ->assertJsonPath('data.subject', 'Chào Nguyễn Văn A') + ->assertJsonPath('data.html', '

Xin chào Nguyễn Văn A, mã TGS10

') + ->assertJsonPath('data.cc', ['sale@example.com']); + } + + public function test_send_uses_edited_html_without_template_cc(): void + { + Mail::fake(); + + $this->actingAs($this->admin, 'admin') + ->postJson("/api/v1/administrator/customers/{$this->customer->uuid}/send-email", [ + 'template_id' => $this->template->id, + 'subject' => 'Tiêu đề đã sửa', + 'message' => '

Nội dung đã sửa

', + 'to' => $this->customer->email, + ]) + ->assertOk(); + + Mail::assertSent(GenericTemplateMail::class, fn (GenericTemplateMail $mail) => $mail->hasTo($this->customer->email) + && $mail->renderedSubject === 'Tiêu đề đã sửa' + && $mail->renderedHtml === '

Nội dung đã sửa

' + && !array_key_exists('cc', $mail->mailConfig)); + + $this->assertDatabaseHas('mail', ['customer_id' => $this->customer->id, 'message' => '

Nội dung đã sửa

']); + } + + public function test_email_layout_wraps_content_with_header_and_footer(): void + { + $html = $this->actingAs($this->admin, 'admin') + ->postJson("/api/v1/administrator/customers/{$this->customer->uuid}/email-layout", [ + 'message' => '

Xin chào {{client_name}}

', + ]) + ->assertOk() + ->json('data.html'); + + $this->assertStringContainsString('

Xin chào Nguyễn Văn A

', $html); + $this->assertStringContainsString('class="Header"', $html); + $this->assertStringContainsString('class="Footer"', $html); + } + + public function test_manual_email_with_inserted_layout_is_not_wrapped_again(): void + { + Mail::fake(); + + $this->actingAs($this->admin, 'admin') + ->postJson("/api/v1/administrator/customers/{$this->customer->uuid}/send-email", [ + 'subject' => 'Thông báo', + 'message' => '

Nội dung

', + 'to' => $this->customer->email, + 'with_layout' => false, + ]) + ->assertOk(); + + Mail::assertQueued(ManualCustomerEmail::class, function (ManualCustomerEmail $mail) { + $html = $mail->render(); + + return substr_count($html, 'class="Header"') === 1 && str_contains($html, '

Nội dung

'); + }); + } + + public function test_sent_emails_are_listed_with_sender_and_bcc(): void + { + Mail::fake(); + $base = "/api/v1/administrator/customers/{$this->customer->uuid}"; + + $this->actingAs($this->admin, 'admin')->postJson("{$base}/send-email", [ + 'subject' => 'Thông tin VPS', + 'message' => '

Thông tin

', + 'to' => $this->customer->email, + 'bcc' => 'audit@example.com', + ])->assertOk(); + + $item = $this->getJson("{$base}/emails") + ->assertOk() + ->assertJsonPath('data.total', 1) + ->assertJsonPath('data.items.0.bcc', 'audit@example.com') + ->assertJsonPath('data.items.0.to', $this->customer->email) + ->assertJsonPath('data.items.0.sent_by', $this->admin->name ?: $this->admin->username) + ->assertJsonMissingPath('data.items.0.html') + ->json('data.items.0'); + + $this->getJson("{$base}/emails/{$item['id']}") + ->assertOk() + ->assertJsonPath('data.html', '

Thông tin

'); + + $other = Customer::factory()->create(); + $this->getJson("/api/v1/administrator/customers/{$other->uuid}/emails/{$item['id']}")->assertNotFound(); + } + + public function test_send_reports_error_when_mail_is_disabled(): void + { + \App\Models\Setting::updateOrCreate(['key' => 'email_settings'], ['value' => json_encode(['mail_enabled' => false])]); + + $this->actingAs($this->admin, 'admin') + ->postJson("/api/v1/administrator/customers/{$this->customer->uuid}/send-email", [ + 'subject' => 'Thông báo', + 'message' => '

Nội dung

', + ]) + ->assertStatus(422); + + $this->assertDatabaseMissing('mail', ['customer_id' => $this->customer->id]); + } +} diff --git a/tests/Feature/Api/Admin/Customer/UpdateServiceBillingCycleTest.php b/tests/Feature/Api/Admin/Customer/UpdateServiceBillingCycleTest.php new file mode 100644 index 0000000..2e1ba1d --- /dev/null +++ b/tests/Feature/Api/Admin/Customer/UpdateServiceBillingCycleTest.php @@ -0,0 +1,40 @@ + 'Full Administrator', 'guard_name' => 'admin']); + $admin = User::factory()->create(); + $admin->assignRole('Full Administrator'); + + Schema::disableForeignKeyConstraints(); + $customer = Customer::factory()->create(); + $item = OrderItem::create(['order_id' => 1, 'product_id' => 1, 'billing_cycle' => 'monthly', 'months' => 1, 'quantity' => 1, 'price' => 100000]); + $service = Service::create(['customer_id' => $customer->id, 'order_item_id' => $item->id, 'status' => 'active']); + + $url = "/api/v1/administrator/customers/{$customer->uuid}/services/{$service->id}"; + + $this->actingAs($admin, 'admin')->putJson($url, ['billing_cycle' => 'annually'])->assertOk(); + $item->refresh(); + $this->assertSame('annually', $item->billing_cycle); + $this->assertSame(12, (int) $item->months); + + $this->actingAs($admin, 'admin')->putJson($url, ['billing_cycle' => 'onetime'])->assertStatus(422); + } +} diff --git a/tests/Feature/Api/Admin/Hosting/ServicePlanTest.php b/tests/Feature/Api/Admin/Hosting/ServicePlanTest.php new file mode 100644 index 0000000..f9ebb45 --- /dev/null +++ b/tests/Feature/Api/Admin/Hosting/ServicePlanTest.php @@ -0,0 +1,123 @@ + 'Full Administrator', 'guard_name' => 'admin']); + $this->admin = User::factory()->create(); + $this->admin->assignRole('Full Administrator'); + } + + private function server(string $type = 'hosting'): DnsServer + { + return DnsServer::create([ + 'name' => 'plesk-01', + 'ip_address' => '10.0.0.5', + 'username' => 'admin', + 'password' => Crypt::encryptString('secret'), + 'status' => 'activated', + 'type' => $type, + ]); + } + + public function test_store_and_update_persist_limits_features_and_plesk_mapping(): void + { + $payload = [ + 'title' => 'Hosting Basic', + 'description' => 'Cho website nhỏ', + 'vcpu' => 1, + 'ram' => 1, + 'disk' => 10, + 'domain' => 1, + 'bandwidth' => null, + 'databases' => 2, + 'mailboxes' => 5, + 'mailbox_quota' => 1024, + 'features' => ['SSL miễn phí', 'Backup hàng ngày'], + 'is_active' => false, + 'sort_order' => 3, + 'plesk_plan_name' => 'basic', + ]; + + $id = $this->actingAs($this->admin, 'admin') + ->postJson('/api/v1/administrator/service-plans', $payload) + ->assertCreated() + ->assertJsonPath('data.features', ['SSL miễn phí', 'Backup hàng ngày']) + ->assertJsonPath('data.is_active', false) + ->assertJsonPath('data.plesk.plesk_plan_name', 'basic') + ->json('data.id'); + + $plan = ServicePlan::findOrFail($id); + $this->assertNull($plan->bandwidth); + $this->assertSame(5, $plan->mailboxes); + + $this->actingAs($this->admin, 'admin') + ->putJson("/api/v1/administrator/service-plans/{$id}", [...$payload, 'is_active' => true, 'plesk_plan_name' => null]) + ->assertOk() + ->assertJsonPath('data.is_active', true) + ->assertJsonPath('data.plesk', null); + } + + public function test_rejects_negative_limits_and_duplicate_features(): void + { + $this->actingAs($this->admin, 'admin') + ->postJson('/api/v1/administrator/service-plans', [ + 'title' => 'X', 'vcpu' => 1, 'ram' => 1, 'disk' => 1, 'domain' => 1, + 'databases' => -1, + 'features' => ['SSH', 'SSH'], + ]) + ->assertStatus(422) + ->assertJsonValidationErrors(['databases', 'features.0']); + } + + public function test_lists_plesk_plan_names_from_cli_gateway(): void + { + $server = $this->server(); + Http::fake([ + 'https://10.0.0.5:8443/api/v2/cli/service_plan/call' => Http::response([ + 'code' => 0, 'stdout' => "Default Domain\r\nbasic\n\nbasic\n", 'stderr' => '', + ]), + ]); + + $this->actingAs($this->admin, 'admin') + ->getJson("/api/v1/administrator/service-plans/plesk-plans/{$server->id}") + ->assertOk() + ->assertExactJson(['error' => false, 'data' => ['Default Domain', 'basic']]); + + Http::assertSent(fn ($request) => $request['params'] === ['--list']); + } + + public function test_plesk_plans_fails_for_cli_error_and_non_hosting_server(): void + { + $server = $this->server(); + Http::fake(['*' => Http::response(['code' => 1, 'stdout' => '', 'stderr' => 'denied'])]); + + $this->actingAs($this->admin, 'admin') + ->getJson("/api/v1/administrator/service-plans/plesk-plans/{$server->id}") + ->assertStatus(502); + + $this->actingAs($this->admin, 'admin') + ->getJson('/api/v1/administrator/service-plans/plesk-plans/' . $this->server('domain')->id) + ->assertNotFound(); + } +} diff --git a/tests/Feature/Api/Admin/Service/RenewalReminderTest.php b/tests/Feature/Api/Admin/Service/RenewalReminderTest.php new file mode 100644 index 0000000..987d230 --- /dev/null +++ b/tests/Feature/Api/Admin/Service/RenewalReminderTest.php @@ -0,0 +1,78 @@ +seed(\Database\Seeders\TemplateSeeder::class); + + Role::firstOrCreate(['name' => 'Full Administrator', 'guard_name' => 'admin']); + $this->admin = User::factory()->create(); + $this->admin->assignRole('Full Administrator'); + + $customer = Customer::factory()->create(); + $this->soon = Service::create(['customer_id' => $customer->id, 'status' => 'active', 'expires_at' => now()->addDays(5)]); + $this->later = Service::create(['customer_id' => $customer->id, 'status' => 'active', 'expires_at' => now()->addDays(40)]); + Service::create(['customer_id' => $customer->id, 'status' => 'suspended', 'expires_at' => now()->addDays(3)]); + } + + public function test_lists_active_services_expiring_within_days(): void + { + $this->actingAs($this->admin, 'admin') + ->getJson(self::BASE . '?days=30') + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.id', $this->soon->id) + ->assertJsonPath('data.0.days_left', 5); + } + + public function test_send_queues_reminders_bypassing_auto_disabled_scenario(): void + { + Mail::fake(); + Setting::updateOrCreate(['key' => 'email_settings'], ['value' => json_encode(['auto_disabled' => ['service_renewal_reminder']])]); + + $this->actingAs($this->admin, 'admin') + ->postJson(self::BASE, ['service_ids' => [$this->soon->id, $this->later->id]]) + ->assertOk(); + + Mail::assertQueued(ServiceRenewalReminderMail::class, 2); + $this->assertDatabaseCount('mail', 2); + $this->assertDatabaseHas('mail', ['customer_id' => $this->soon->customer_id, 'sent_by' => $this->admin->id]); + } + + public function test_send_is_rejected_when_mail_is_disabled(): void + { + Mail::fake(); + Setting::updateOrCreate(['key' => 'email_settings'], ['value' => json_encode(['mail_enabled' => false])]); + + $this->actingAs($this->admin, 'admin') + ->postJson(self::BASE, ['service_ids' => [$this->soon->id]]) + ->assertStatus(422); + + Mail::assertNothingQueued(); + } +} diff --git a/tests/Feature/Payment/InvoicePaymentServiceTest.php b/tests/Feature/Payment/InvoicePaymentServiceTest.php index bea42ac..21199f2 100644 --- a/tests/Feature/Payment/InvoicePaymentServiceTest.php +++ b/tests/Feature/Payment/InvoicePaymentServiceTest.php @@ -145,6 +145,35 @@ class InvoicePaymentServiceTest extends TestCase $this->assertSame(400_000, (int) $customer->fresh()->total); } + public function test_admin_payment_records_source_and_note(): void + { + $customer = Customer::factory()->create(['total' => 0]); + $invoice = $this->makeInvoice($customer, 100_000); + + $invoice = app(InvoicePaymentService::class)->markPaidByAdmin( + $invoice, + null, + method: 'manual', + receivedAmount: 40_000, + note: 'Khách chuyển khoản đợt 1', + ); + + $this->assertSame(40_000, (int) $invoice->amount_paid); + $this->assertSame('Khách chuyển khoản đợt 1', $invoice->note); + $this->assertDatabaseHas('transactions', [ + 'customer_id' => $customer->id, + 'received_amount' => 40_000, + ]); + $this->assertStringContainsString( + 'Khách chuyển khoản đợt 1', + (string) Transaction::where('customer_id', $customer->id)->value('note'), + ); + $this->assertStringContainsString( + '(manual)', + (string) Transaction::where('customer_id', $customer->id)->value('note'), + ); + } + /** Hoàn tiền: cộng lại số dư, ghi Transaction âm, hoá đơn sang REFUNDED. */ public function test_refund_returns_balance_and_records_a_negative_transaction(): void { diff --git a/tests/Feature/TemplateSeederTest.php b/tests/Feature/TemplateSeederTest.php index 80d8435..09aeec6 100644 --- a/tests/Feature/TemplateSeederTest.php +++ b/tests/Feature/TemplateSeederTest.php @@ -30,9 +30,18 @@ class TemplateSeederTest extends TestCase } } + public function test_legacy_templates_use_the_shared_shell(): void + { + foreach (TemplateSeeder::legacyTemplates() as $item) { + $this->assertNull(EmailScenarioRegistry::find($item['scenario']), $item['scenario']); + $this->assertStringContainsString('class="Header"', $item['message'], $item['scenario']); + $this->assertStringContainsString('class="Footer"', $item['message'], $item['scenario']); + } + } + public function test_every_placeholder_has_a_variable_definition(): void { - foreach (TemplateSeeder::templates() as $item) { + foreach ([...TemplateSeeder::templates(), ...TemplateSeeder::legacyTemplates()] as $item) { $unknown = array_diff(MailPlaceholders::extract($item['subject'], $item['message']), array_keys(TemplateVariables::DEFINITIONS)); $this->assertSame([], array_values($unknown), $item['scenario']); }