From 2365ef72e988d8f78380988ee2d2b2eefcec2999 Mon Sep 17 00:00:00 2001 From: baodev0 Date: Wed, 9 Sep 2026 16:56:22 +0700 Subject: [PATCH] update email template --- app/Console/Commands/CheckOverdueInvoices.php | 4 +- .../Commands/Domain/SyncDomainsCommand.php | 94 +++++ app/Console/Commands/ServiceExpiry.php | 41 +- app/Console/Commands/UpdateExpiryDate.php | 54 --- .../Commands/Whmcs/SyncClientsCommand.php | 36 ++ .../Whmcs/SyncCustomerDomainsCommand.php | 38 ++ .../Commands/Whmcs/SyncInvoicesCommand.php | 36 ++ .../Commands/Whmcs/SyncOrderItemsCommand.php | 38 ++ .../Commands/Whmcs/SyncOrdersCommand.php | 36 ++ .../Commands/Whmcs/SyncProductsCommand.php | 72 ++++ .../Commands/Whmcs/SyncServicesCommand.php | 38 ++ .../InvalidTemplateVariableException.php | 17 + app/Helpers/OrderHelper.php | 14 + .../Api/Admin/Customer/CustomerController.php | 8 +- .../Api/Admin/DnsManager/DomainController.php | 14 +- .../Domain/AdminDomainOrderController.php | 24 +- .../EmailTemplate/EmailTemplateController.php | 152 +++++++- .../Api/Admin/Employee/EmployeeController.php | 6 +- .../Api/Admin/Invoice/InvoiceController.php | 79 ++-- .../AdminTransactionController.php | 231 ++++++------ .../Api/Service/PaymentController.php | 74 ---- .../Api/Service/ServiceController.php | 2 - app/Http/Controllers/admin/UserController.php | 15 +- app/Http/Requests/EmployeeRequest.php | 3 +- .../Resources/Api/Admin/AdminUserResource.php | 3 +- .../Resources/Api/Admin/OrderResource.php | 37 +- .../Resources/Api/Admin/ProductResource.php | 16 +- app/Http/Services/Invoice/InvoiceService.php | 81 ---- .../Domain/DTOs/DnsDomainData.php | 17 + .../Domain/DTOs/DnsDomainUserData.php | 14 + .../Domain/DTOs/DnsRecordData.php | 24 ++ .../Domain/DTOs/DnsServerData.php | 19 + .../Domain/Extractors/DnsDomainExtractor.php | 14 + .../Extractors/DnsDomainUserExtractor.php | 14 + .../Domain/Extractors/DnsRecordExtractor.php | 14 + .../Domain/Extractors/DnsServerExtractor.php | 14 + .../Domain/Support/DnsDomainIdResolver.php | 19 + .../Domain/Support/DnsServerIdResolver.php | 19 + .../Domain/Support/DomainUserResolver.php | 57 +++ .../Transformers/DnsDomainTransformer.php | 37 ++ .../Transformers/DnsDomainUserTransformer.php | 37 ++ .../Transformers/DnsRecordTransformer.php | 44 +++ .../Transformers/DnsServerTransformer.php | 35 ++ .../Domain/Writers/DnsDomainUserWriter.php | 30 ++ .../Domain/Writers/DnsDomainWriter.php | 33 ++ .../Domain/Writers/DnsRecordWriter.php | 40 ++ .../Domain/Writers/DnsServerWriter.php | 27 ++ app/Integrations/Whmcs/DTOs/ClientData.php | 27 ++ .../Whmcs/DTOs/CustomerDomainData.php | 21 ++ app/Integrations/Whmcs/DTOs/InvoiceData.php | 27 ++ .../Whmcs/DTOs/InvoiceItemData.php | 16 + app/Integrations/Whmcs/DTOs/OrderData.php | 20 + app/Integrations/Whmcs/DTOs/OrderItemData.php | 22 ++ .../Whmcs/DTOs/ProductAddonData.php | 19 + .../Whmcs/DTOs/ProductCategoryData.php | 18 + app/Integrations/Whmcs/DTOs/ProductData.php | 25 ++ app/Integrations/Whmcs/DTOs/ServiceData.php | 22 ++ .../Whmcs/Extractors/AddonExtractor.php | 23 ++ .../Whmcs/Extractors/ClientExtractor.php | 14 + .../Extractors/CustomerDomainExtractor.php | 14 + .../Whmcs/Extractors/InvoiceExtractor.php | 28 ++ .../Whmcs/Extractors/OrderExtractor.php | 14 + .../Whmcs/Extractors/OrderItemExtractor.php | 18 + .../Whmcs/Extractors/ProductExtractor.php | 23 ++ .../Extractors/ProductGroupExtractor.php | 14 + .../Whmcs/Extractors/ServiceExtractor.php | 14 + .../Whmcs/Support/CustomerIdResolver.php | 37 ++ .../Whmcs/Support/DnsDomainLinker.php | 53 +++ .../Whmcs/Support/OrderIdResolver.php | 22 ++ .../Whmcs/Support/OrderItemIdResolver.php | 19 + .../Support/ProductCategoryIdResolver.php | 24 ++ .../Whmcs/Support/ProductIdResolver.php | 27 ++ .../Whmcs/Support/RegistrarIdResolver.php | 26 ++ .../Whmcs/Support/SlugUniquifier.php | 23 ++ .../Whmcs/Transformers/ClientTransformer.php | 76 ++++ .../CustomerDomainTransformer.php | 65 ++++ .../Transformers/InvoiceItemTransformer.php | 72 ++++ .../Whmcs/Transformers/InvoiceTransformer.php | 86 +++++ .../Transformers/OrderItemTransformer.php | 75 ++++ .../Whmcs/Transformers/OrderTransformer.php | 57 +++ .../Transformers/ProductAddonTransformer.php | 84 +++++ .../ProductCategoryTransformer.php | 63 ++++ .../Whmcs/Transformers/ProductTransformer.php | 97 +++++ .../Whmcs/Transformers/ServiceTransformer.php | 62 +++ .../Whmcs/WhmcsServiceProvider.php | 0 .../Whmcs/Writers/ClientWriter.php | 37 ++ .../Whmcs/Writers/CustomerDomainWriter.php | 46 +++ .../Whmcs/Writers/InvoiceWriter.php | 60 +++ .../Whmcs/Writers/OrderItemWriter.php | 39 ++ .../Whmcs/Writers/OrderWriter.php | 29 ++ .../Whmcs/Writers/ProductAddonWriter.php | 26 ++ .../Whmcs/Writers/ProductCategoryWriter.php | 25 ++ .../Whmcs/Writers/ProductWriter.php | 55 +++ .../Whmcs/Writers/ServiceWriter.php | 41 ++ app/Jobs/Mail/Customer/OrderCreatedJob.php | 13 +- app/Jobs/Mail/SendTemplateEmailJob.php | 42 +++ app/Jobs/Payment/GetTransactionPayment.php | 18 +- .../VirtualMachine/ProcessOrderPaymentJob.php | 356 ------------------ app/Jobs/Whmcs/SyncClientJob.php | 28 ++ app/Jobs/Whmcs/SyncInvoiceJob.php | 28 ++ app/Jobs/Whmcs/SyncOrderJob.php | 28 ++ app/Mail/Admin/AdminOrderNotification.php | 38 +- app/Mail/Admin/DomainOrderNewNotification.php | 26 +- ...HostingAccountCreatedAdminNotification.php | 33 +- .../ManualDepositPendingNotification.php | 29 +- app/Mail/Admin/TicketAdminNotification.php | 44 ++- app/Mail/Admin/TrialRegistrationRequest.php | 26 +- app/Mail/ChangePortRemote.php | 26 +- app/Mail/Concerns/RequiresDbTemplate.php | 21 ++ app/Mail/CreateHosting.php | 96 ++--- app/Mail/CreateVirtualMachine.php | 96 ----- app/Mail/Customer/CustomerPayment.php | 52 +-- app/Mail/Customer/DepositSuccess.php | 37 +- .../DomainOrderReceivedNotification.php | 27 +- app/Mail/Customer/DomainOrderStatusMail.php | 44 --- app/Mail/Customer/HostingAccountCreated.php | 62 +-- app/Mail/Customer/ManualCustomerEmail.php | 3 +- app/Mail/Customer/ManualDepositStatus.php | 50 +-- app/Mail/Customer/OrderCreated.php | 64 ---- app/Mail/Customer/OrderPaymentSuccess.php | 74 ++-- app/Mail/Customer/PasswordResetByAdmin.php | 35 +- app/Mail/Customer/SnapshotActionMail.php | 58 +-- app/Mail/Customer/VMCreationFailed.php | 47 +-- app/Mail/Customer/VerifyAccount.php | 31 +- app/Mail/Customer/VmPasswordChanged.php | 38 +- app/Mail/Customer/VmRebuildFailed.php | 29 +- app/Mail/Customer/Welcome.php | 31 +- app/Mail/CustomerRegister.php | 68 +--- app/Mail/ExpiryService.php | 76 +--- app/Mail/GenericTemplateMail.php | 47 +++ app/Mail/PasswordReset.php | 67 +--- app/Mail/ServiceRenewalReminderMail.php | 45 ++- app/Mail/TicketCreated.php | 32 +- app/Mail/VmCredentialsMail.php | 72 ---- app/Mail/VmPasswordResetSuccess.php | 27 +- app/Mail/VmSuspendedMail.php | 25 +- app/Models/DnsDomain.php | 6 + app/Models/ModuleDomain.php | 7 + app/Models/Order.php | 9 +- app/Models/ProductCategories.php | 5 + app/Models/Registrar.php | 25 ++ app/Models/Template.php | 74 +++- app/Models/User.php | 11 +- .../Api/Invoice/InvoiceRepository.php | 18 +- .../Module/Domain/DomainRepository.php | 3 + app/Repositories/Order/OrderRepository.php | 2 +- app/Services/Api/Order/OrderQueryService.php | 32 +- app/Services/Api/Order/OrderService.php | 16 +- .../Customer/CustomerBillingService.php | 4 +- .../CustomerServiceProvisioningService.php | 14 +- app/Services/Email/EmailTemplateRenderer.php | 69 ++++ app/Services/Email/EmailTemplateService.php | 45 +++ app/Services/InvoicePaymentService.php | 68 ++-- app/Services/OrderService.php | 9 +- config/database.php | 47 ++- .../2014_10_12_000000_create_users_table.php | 3 +- .../migrations/2024_01_31_145201_customer.php | 5 +- ...8_113713_update_column_customers_table.php | 28 -- ..._000000_widen_customers_address_column.php | 19 + ...01_add_legacy_id_to_dns_manager_tables.php | 32 ++ ...000002_add_legacy_id_to_product_tables.php | 36 ++ ...6_09_05_000003_create_registrars_table.php | 36 ++ ...add_sync_fields_to_module_domain_table.php | 26 ++ ...gistrar_id_to_dns_manager_domain_table.php | 24 ++ ...6_add_billing_period_to_invoices_table.php | 29 ++ ...nique_constraint_to_transactions_table.php | 24 ++ ..._05_000008_add_paid_at_to_orders_table.php | 24 ++ ...ing_cycles_to_product_categories_table.php | 26 ++ ...etadata_builder_data_to_template_table.php | 25 ++ ...scenario_metadata_for_legacy_templates.php | 65 ++++ ...02_seed_admin_and_misc_email_templates.php | 254 +++++++++++++ ...witch_templates_to_double_curly_syntax.php | 188 +++++++++ ...backfill_legacy_hardcoded_id_templates.php | 102 +++++ ..._backfill_remaining_template_variables.php | 81 ++++ database/seeders/AdminSeeder.php | 68 +++- database/seeders/DatabaseSeeder.php | 6 - database/seeders/TemplateSeeder.php | 242 ++++++++++-- docs/order-invoice-payment-fix-todo.md | 100 +++++ .../emails/admin/domain-order-new.blade.php | 25 -- .../admin/hosting-account-created.blade.php | 130 ------- .../admin/manual-deposit-pending.blade.php | 98 ----- .../emails/admin/order-notification.blade.php | 124 ------ .../admin/ticket-notification.blade.php | 83 ---- .../trial-registration-request.blade.php | 96 ----- .../customer/domain-order-received.blade.php | 33 -- .../emails/customer/holiday-notice.blade.php | 185 --------- .../hosting-account-created.blade.php | 221 ----------- .../customer/manual-deposit-status.blade.php | 74 ---- .../customer/order-payment-success.blade.php | 128 ------- .../customer/password-reset-admin.blade.php | 188 --------- .../service-renewal-reminder.blade.php | 113 ------ .../emails/customer/snapshot-action.blade.php | 202 ---------- .../emails/customer/ticket-created.blade.php | 209 ---------- .../customer/verify-account-welcome.blade.php | 149 -------- .../customer/vm-creation-failed.blade.php | 184 --------- .../customer/vm-password-changed.blade.php | 194 ---------- .../customer/vm-rebuild-failed.blade.php | 121 ------ .../emails/customer/vm-suspended.blade.php | 77 ---- .../views/emails/customer/welcome.blade.php | 85 +---- .../emails/partials/email-footer.blade.php | 72 ++++ .../emails/partials/email-header.blade.php | 15 + resources/views/emails/template.blade.php | 1 - routes/api.php | 3 + .../Feature/Api/Service/RenewServiceTest.php | 102 +++++ tests/Feature/Console/ServiceExpiryTest.php | 130 +++++++ tests/Feature/EmailTemplateServiceTest.php | 129 +++++++ .../Payment/AdminTransactionApproveTest.php | 45 +++ .../Payment/InvoicePaymentServiceTest.php | 84 +++++ tests/Unit/EmailTemplateRendererTest.php | 114 ++++++ tests/Unit/ProcessOrderPaymentJobTest.php | 137 ------- 210 files changed, 6335 insertions(+), 4688 deletions(-) create mode 100644 app/Console/Commands/Domain/SyncDomainsCommand.php delete mode 100644 app/Console/Commands/UpdateExpiryDate.php create mode 100644 app/Console/Commands/Whmcs/SyncClientsCommand.php create mode 100644 app/Console/Commands/Whmcs/SyncCustomerDomainsCommand.php create mode 100644 app/Console/Commands/Whmcs/SyncInvoicesCommand.php create mode 100644 app/Console/Commands/Whmcs/SyncOrderItemsCommand.php create mode 100644 app/Console/Commands/Whmcs/SyncOrdersCommand.php create mode 100644 app/Console/Commands/Whmcs/SyncProductsCommand.php create mode 100644 app/Console/Commands/Whmcs/SyncServicesCommand.php create mode 100644 app/Exceptions/InvalidTemplateVariableException.php delete mode 100644 app/Http/Controllers/Api/Service/PaymentController.php delete mode 100644 app/Http/Services/Invoice/InvoiceService.php create mode 100644 app/Integrations/Domain/DTOs/DnsDomainData.php create mode 100644 app/Integrations/Domain/DTOs/DnsDomainUserData.php create mode 100644 app/Integrations/Domain/DTOs/DnsRecordData.php create mode 100644 app/Integrations/Domain/DTOs/DnsServerData.php create mode 100644 app/Integrations/Domain/Extractors/DnsDomainExtractor.php create mode 100644 app/Integrations/Domain/Extractors/DnsDomainUserExtractor.php create mode 100644 app/Integrations/Domain/Extractors/DnsRecordExtractor.php create mode 100644 app/Integrations/Domain/Extractors/DnsServerExtractor.php create mode 100644 app/Integrations/Domain/Support/DnsDomainIdResolver.php create mode 100644 app/Integrations/Domain/Support/DnsServerIdResolver.php create mode 100644 app/Integrations/Domain/Support/DomainUserResolver.php create mode 100644 app/Integrations/Domain/Transformers/DnsDomainTransformer.php create mode 100644 app/Integrations/Domain/Transformers/DnsDomainUserTransformer.php create mode 100644 app/Integrations/Domain/Transformers/DnsRecordTransformer.php create mode 100644 app/Integrations/Domain/Transformers/DnsServerTransformer.php create mode 100644 app/Integrations/Domain/Writers/DnsDomainUserWriter.php create mode 100644 app/Integrations/Domain/Writers/DnsDomainWriter.php create mode 100644 app/Integrations/Domain/Writers/DnsRecordWriter.php create mode 100644 app/Integrations/Domain/Writers/DnsServerWriter.php create mode 100644 app/Integrations/Whmcs/DTOs/ClientData.php create mode 100644 app/Integrations/Whmcs/DTOs/CustomerDomainData.php create mode 100644 app/Integrations/Whmcs/DTOs/InvoiceData.php create mode 100644 app/Integrations/Whmcs/DTOs/InvoiceItemData.php create mode 100644 app/Integrations/Whmcs/DTOs/OrderData.php create mode 100644 app/Integrations/Whmcs/DTOs/OrderItemData.php create mode 100644 app/Integrations/Whmcs/DTOs/ProductAddonData.php create mode 100644 app/Integrations/Whmcs/DTOs/ProductCategoryData.php create mode 100644 app/Integrations/Whmcs/DTOs/ProductData.php create mode 100644 app/Integrations/Whmcs/DTOs/ServiceData.php create mode 100644 app/Integrations/Whmcs/Extractors/AddonExtractor.php create mode 100644 app/Integrations/Whmcs/Extractors/ClientExtractor.php create mode 100644 app/Integrations/Whmcs/Extractors/CustomerDomainExtractor.php create mode 100644 app/Integrations/Whmcs/Extractors/InvoiceExtractor.php create mode 100644 app/Integrations/Whmcs/Extractors/OrderExtractor.php create mode 100644 app/Integrations/Whmcs/Extractors/OrderItemExtractor.php create mode 100644 app/Integrations/Whmcs/Extractors/ProductExtractor.php create mode 100644 app/Integrations/Whmcs/Extractors/ProductGroupExtractor.php create mode 100644 app/Integrations/Whmcs/Extractors/ServiceExtractor.php create mode 100644 app/Integrations/Whmcs/Support/CustomerIdResolver.php create mode 100644 app/Integrations/Whmcs/Support/DnsDomainLinker.php create mode 100644 app/Integrations/Whmcs/Support/OrderIdResolver.php create mode 100644 app/Integrations/Whmcs/Support/OrderItemIdResolver.php create mode 100644 app/Integrations/Whmcs/Support/ProductCategoryIdResolver.php create mode 100644 app/Integrations/Whmcs/Support/ProductIdResolver.php create mode 100644 app/Integrations/Whmcs/Support/RegistrarIdResolver.php create mode 100644 app/Integrations/Whmcs/Support/SlugUniquifier.php create mode 100644 app/Integrations/Whmcs/Transformers/ClientTransformer.php create mode 100644 app/Integrations/Whmcs/Transformers/CustomerDomainTransformer.php create mode 100644 app/Integrations/Whmcs/Transformers/InvoiceItemTransformer.php create mode 100644 app/Integrations/Whmcs/Transformers/InvoiceTransformer.php create mode 100644 app/Integrations/Whmcs/Transformers/OrderItemTransformer.php create mode 100644 app/Integrations/Whmcs/Transformers/OrderTransformer.php create mode 100644 app/Integrations/Whmcs/Transformers/ProductAddonTransformer.php create mode 100644 app/Integrations/Whmcs/Transformers/ProductCategoryTransformer.php create mode 100644 app/Integrations/Whmcs/Transformers/ProductTransformer.php create mode 100644 app/Integrations/Whmcs/Transformers/ServiceTransformer.php create mode 100644 app/Integrations/Whmcs/WhmcsServiceProvider.php create mode 100644 app/Integrations/Whmcs/Writers/ClientWriter.php create mode 100644 app/Integrations/Whmcs/Writers/CustomerDomainWriter.php create mode 100644 app/Integrations/Whmcs/Writers/InvoiceWriter.php create mode 100644 app/Integrations/Whmcs/Writers/OrderItemWriter.php create mode 100644 app/Integrations/Whmcs/Writers/OrderWriter.php create mode 100644 app/Integrations/Whmcs/Writers/ProductAddonWriter.php create mode 100644 app/Integrations/Whmcs/Writers/ProductCategoryWriter.php create mode 100644 app/Integrations/Whmcs/Writers/ProductWriter.php create mode 100644 app/Integrations/Whmcs/Writers/ServiceWriter.php create mode 100644 app/Jobs/Mail/SendTemplateEmailJob.php delete mode 100644 app/Jobs/VirtualMachine/ProcessOrderPaymentJob.php create mode 100644 app/Jobs/Whmcs/SyncClientJob.php create mode 100644 app/Jobs/Whmcs/SyncInvoiceJob.php create mode 100644 app/Jobs/Whmcs/SyncOrderJob.php create mode 100644 app/Mail/Concerns/RequiresDbTemplate.php delete mode 100644 app/Mail/CreateVirtualMachine.php delete mode 100644 app/Mail/Customer/DomainOrderStatusMail.php delete mode 100644 app/Mail/Customer/OrderCreated.php create mode 100644 app/Mail/GenericTemplateMail.php delete mode 100644 app/Mail/VmCredentialsMail.php create mode 100644 app/Models/Registrar.php create mode 100644 app/Services/Email/EmailTemplateRenderer.php create mode 100644 app/Services/Email/EmailTemplateService.php delete mode 100644 database/migrations/2026_08_28_113713_update_column_customers_table.php create mode 100644 database/migrations/2026_09_05_000000_widen_customers_address_column.php create mode 100644 database/migrations/2026_09_05_000001_add_legacy_id_to_dns_manager_tables.php create mode 100644 database/migrations/2026_09_05_000002_add_legacy_id_to_product_tables.php create mode 100644 database/migrations/2026_09_05_000003_create_registrars_table.php create mode 100644 database/migrations/2026_09_05_000004_add_sync_fields_to_module_domain_table.php create mode 100644 database/migrations/2026_09_05_000005_add_registrar_id_to_dns_manager_domain_table.php create mode 100644 database/migrations/2026_09_05_000006_add_billing_period_to_invoices_table.php create mode 100644 database/migrations/2026_09_05_000007_add_unique_constraint_to_transactions_table.php create mode 100644 database/migrations/2026_09_05_000008_add_paid_at_to_orders_table.php create mode 100644 database/migrations/2026_09_05_000009_add_billing_cycles_to_product_categories_table.php create mode 100644 database/migrations/2026_09_09_000000_add_metadata_builder_data_to_template_table.php create mode 100644 database/migrations/2026_09_09_000001_backfill_scenario_metadata_for_legacy_templates.php create mode 100644 database/migrations/2026_09_09_000002_seed_admin_and_misc_email_templates.php create mode 100644 database/migrations/2026_09_09_000003_switch_templates_to_double_curly_syntax.php create mode 100644 database/migrations/2026_09_09_000004_backfill_legacy_hardcoded_id_templates.php create mode 100644 database/migrations/2026_09_09_000005_backfill_remaining_template_variables.php create mode 100644 docs/order-invoice-payment-fix-todo.md delete mode 100644 resources/views/emails/admin/domain-order-new.blade.php delete mode 100644 resources/views/emails/admin/hosting-account-created.blade.php delete mode 100644 resources/views/emails/admin/manual-deposit-pending.blade.php delete mode 100644 resources/views/emails/admin/order-notification.blade.php delete mode 100644 resources/views/emails/admin/ticket-notification.blade.php delete mode 100644 resources/views/emails/admin/trial-registration-request.blade.php delete mode 100644 resources/views/emails/customer/domain-order-received.blade.php delete mode 100644 resources/views/emails/customer/holiday-notice.blade.php delete mode 100644 resources/views/emails/customer/hosting-account-created.blade.php delete mode 100644 resources/views/emails/customer/manual-deposit-status.blade.php delete mode 100644 resources/views/emails/customer/order-payment-success.blade.php delete mode 100644 resources/views/emails/customer/password-reset-admin.blade.php delete mode 100644 resources/views/emails/customer/service-renewal-reminder.blade.php delete mode 100644 resources/views/emails/customer/snapshot-action.blade.php delete mode 100644 resources/views/emails/customer/ticket-created.blade.php delete mode 100644 resources/views/emails/customer/verify-account-welcome.blade.php delete mode 100644 resources/views/emails/customer/vm-creation-failed.blade.php delete mode 100644 resources/views/emails/customer/vm-password-changed.blade.php delete mode 100644 resources/views/emails/customer/vm-rebuild-failed.blade.php delete mode 100644 resources/views/emails/customer/vm-suspended.blade.php create mode 100644 resources/views/emails/partials/email-footer.blade.php create mode 100644 resources/views/emails/partials/email-header.blade.php delete mode 100644 resources/views/emails/template.blade.php create mode 100644 tests/Feature/Console/ServiceExpiryTest.php create mode 100644 tests/Feature/EmailTemplateServiceTest.php create mode 100644 tests/Feature/Payment/AdminTransactionApproveTest.php create mode 100644 tests/Feature/Payment/InvoicePaymentServiceTest.php create mode 100644 tests/Unit/EmailTemplateRendererTest.php delete mode 100644 tests/Unit/ProcessOrderPaymentJobTest.php diff --git a/app/Console/Commands/CheckOverdueInvoices.php b/app/Console/Commands/CheckOverdueInvoices.php index 13b1dc3..79e5d79 100644 --- a/app/Console/Commands/CheckOverdueInvoices.php +++ b/app/Console/Commands/CheckOverdueInvoices.php @@ -36,7 +36,9 @@ class CheckOverdueInvoices extends Command } $invoices = Invoice::query() - ->with('order:id,status') + // customer_id must be selected too: OrderObserver::updated() reads it when + // logging the status change below, and a NULL there violates log.customer_id NOT NULL. + ->with('order:id,status,customer_id') ->whereNotNull('due_date') ->whereDate('due_date', '<', $today) ->whereIn('status', [InvoiceStatus::UNPAID->value, InvoiceStatus::OVERDUE->value]) diff --git a/app/Console/Commands/Domain/SyncDomainsCommand.php b/app/Console/Commands/Domain/SyncDomainsCommand.php new file mode 100644 index 0000000..7cb85b0 --- /dev/null +++ b/app/Console/Commands/Domain/SyncDomainsCommand.php @@ -0,0 +1,94 @@ +info('Syncing servers...'); + $bar = $this->output->createProgressBar(DB::connection('domain')->table('dns_manager_servers')->count()); + $bar->start(); + foreach ($serverExtractor->extract() as $row) { + $serverWriter->write($serverTransformer->transform($row)); + $bar->advance(); + } + $bar->finish(); + $this->newLine(); + + $this->info('Syncing domains...'); + $bar = $this->output->createProgressBar(DB::connection('domain')->table('dns_manager_domain')->count()); + $bar->start(); + $skippedDomains = 0; + foreach ($domainExtractor->extract() as $row) { + if (! $domainWriter->write($domainTransformer->transform($row))) { + $skippedDomains++; + } + $bar->advance(); + } + $bar->finish(); + $this->newLine(); + + $this->info('Syncing records...'); + $bar = $this->output->createProgressBar(DB::connection('domain')->table('dns_manager_ns')->count()); + $bar->start(); + $skippedRecords = 0; + foreach ($recordExtractor->extract() as $row) { + if (! $recordWriter->write($recordTransformer->transform($row))) { + $skippedRecords++; + } + $bar->advance(); + } + $bar->finish(); + $this->newLine(); + + $this->info('Syncing domain owners...'); + $bar = $this->output->createProgressBar(DB::connection('domain')->table('dns_domain_user')->count()); + $bar->start(); + $skippedOwners = 0; + foreach ($domainUserExtractor->extract() as $row) { + if (! $domainUserWriter->write($domainUserTransformer->transform($row))) { + $skippedOwners++; + } + $bar->advance(); + } + $bar->finish(); + $this->newLine(); + + $this->info("Skipped: {$skippedDomains} domain(s), {$skippedRecords} record(s), {$skippedOwners} owner link(s) (unresolved FK)."); + + return Command::SUCCESS; + } +} diff --git a/app/Console/Commands/ServiceExpiry.php b/app/Console/Commands/ServiceExpiry.php index a59b60f..32551e6 100644 --- a/app/Console/Commands/ServiceExpiry.php +++ b/app/Console/Commands/ServiceExpiry.php @@ -3,13 +3,10 @@ namespace App\Console\Commands; use App\Enums\Invoice\InvoiceStatus; -use App\Enums\Order\OrderStatus; use App\Helpers\OrderHelper; use App\Jobs\VirtualMachine\Action\VMJobs; use App\Models\Invoice; use App\Models\InvoiceItem; -use App\Models\Order; -use App\Models\OrderItem; use App\Models\Service; use Illuminate\Console\Command; use Illuminate\Support\Facades\DB; @@ -142,38 +139,22 @@ class ServiceExpiry extends Command } $price = (int) $orderItem->price; + $months = $orderItem->months ?? 1; if ($dryRun) { - $this->line(" [dry-run:invoice] Would create renewal order + invoice for Service #{$service->id}, amount " . number_format($price) . ' VND.'); + $this->line(" [dry-run:invoice] Would create renewal invoice for Service #{$service->id} on Order #{$orderItem->order_id}, amount " . number_format($price) . ' VND.'); return; } - $order = Order::create([ - 'order_num' => OrderHelper::generateOrderNumber(), - 'customer_id' => $service->customer_id, - 'sub_total' => $price, - 'discount_amount' => 0, - 'total' => $price, - 'status' => OrderStatus::PENDING->value, - 'client_ip' => null, - 'note' => 'Đơn hàng gia hạn dịch vụ #' . $service->id, - ]); - - OrderItem::create([ - 'order_id' => $order->id, - 'product_id' => $service->product_id, - 'billing_cycle' => $orderItem->billing_cycle, - 'months' => $orderItem->months ?? 1, - 'os_id' => $orderItem->os_id, - 'quantity' => 1, - 'server_name' => $service->server_name, - 'price' => $price, - ]); + // Gia hạn là một billing cycle mới của CÙNG một Order/Service — không tạo Order mới. + // Order gốc chính là order đã tạo ra service này (qua order_item -> order_id). + $periodStart = $service->expires_at && $service->expires_at->isFuture() ? $service->expires_at->copy() : now(); + $periodEnd = $periodStart->copy()->addMonths($months); $invoice = Invoice::create([ 'invoice_number' => OrderHelper::generateOrderNumber('INV'), 'customer_id' => $service->customer_id, - 'order_id' => $order->id, + 'order_id' => $orderItem->order_id, 'invoice_type' => 2, // renewal 'sub_total' => $price, 'discount_amount' => 0, @@ -183,6 +164,8 @@ class ServiceExpiry extends Command 'due_date' => $service->expires_at && $service->expires_at->isFuture() ? $service->expires_at->toDateString() : now()->addDays(7)->toDateString(), + 'billing_period_start' => $periodStart->toDateString(), + 'billing_period_end' => $periodEnd->toDateString(), ]); InvoiceItem::create([ @@ -199,11 +182,11 @@ class ServiceExpiry extends Command // Update service to point to the new renewal invoice $service->update(['invoice_id' => $invoice->id]); - Log::info('Renewal order and invoice created', [ + Log::info('Renewal invoice created', [ 'service_id' => $service->id, - 'order_num' => $order->order_num, + 'order_id' => $orderItem->order_id, 'invoice_number' => $invoice->invoice_number, ]); - $this->line(" [invoice] Renewal {$order->order_num} / {$invoice->invoice_number} for Service #{$service->id}."); + $this->line(" [invoice] Renewal invoice {$invoice->invoice_number} on Order #{$orderItem->order_id} for Service #{$service->id}."); } } diff --git a/app/Console/Commands/UpdateExpiryDate.php b/app/Console/Commands/UpdateExpiryDate.php deleted file mode 100644 index 743c0b7..0000000 --- a/app/Console/Commands/UpdateExpiryDate.php +++ /dev/null @@ -1,54 +0,0 @@ -option('dry-run'); - - $query = OrderItem::whereNull('expiry_date') - ->whereNotNull('months') - ->where('months', '>', 0); - - $count = $query->count(); - - if ($count === 0) { - $this->info('No order items need to be updated.'); - return 0; - } - - $this->info("Found {$count} order items to update."); - - if ($dryRun) { - $this->warn('DRY RUN - No changes will be made.'); - $query->chunk(100, function ($items) { - foreach ($items as $item) { - $expiryDate = Carbon::parse($item->created_at)->addMonths($item->months); - $this->line("Order #{$item->order_id}, Item #{$item->id}: would set expiry_date to {$expiryDate}"); - } - }); - } else { - $updated = 0; - $query->chunk(100, function ($items) use (&$updated) { - foreach ($items as $item) { - $expiryDate = Carbon::parse($item->created_at)->addMonths($item->months); - $item->update(['expiry_date' => $expiryDate]); - $updated++; - } - }); - $this->info("Updated {$updated} order items."); - } - - return 0; - } -} diff --git a/app/Console/Commands/Whmcs/SyncClientsCommand.php b/app/Console/Commands/Whmcs/SyncClientsCommand.php new file mode 100644 index 0000000..bd6c8e3 --- /dev/null +++ b/app/Console/Commands/Whmcs/SyncClientsCommand.php @@ -0,0 +1,36 @@ +option('sync'); + $total = DB::connection('whmcs')->table('tblclients')->count(); + + $bar = $this->output->createProgressBar($total); + $bar->start(); + + foreach ($extractor->extract() as $row) { + $job = new SyncClientJob((array) $row); + $sync ? dispatch_sync($job) : dispatch($job); + $bar->advance(); + } + + $bar->finish(); + $this->newLine(); + $this->info("Dispatched {$total} client(s) for sync."); + + return Command::SUCCESS; + } +} diff --git a/app/Console/Commands/Whmcs/SyncCustomerDomainsCommand.php b/app/Console/Commands/Whmcs/SyncCustomerDomainsCommand.php new file mode 100644 index 0000000..fa13ff5 --- /dev/null +++ b/app/Console/Commands/Whmcs/SyncCustomerDomainsCommand.php @@ -0,0 +1,38 @@ +table('tbldomains')->count(); + + $bar = $this->output->createProgressBar($total); + $bar->start(); + + $skipped = 0; + foreach ($extractor->extract() as $row) { + if (! $writer->write($transformer->transform($row))) { + $skipped++; + } + $bar->advance(); + } + + $bar->finish(); + $this->newLine(); + $this->info("Processed {$total} domain(s), skipped {$skipped} (unresolved customer)."); + + return Command::SUCCESS; + } +} diff --git a/app/Console/Commands/Whmcs/SyncInvoicesCommand.php b/app/Console/Commands/Whmcs/SyncInvoicesCommand.php new file mode 100644 index 0000000..dfe1425 --- /dev/null +++ b/app/Console/Commands/Whmcs/SyncInvoicesCommand.php @@ -0,0 +1,36 @@ +option('sync'); + $total = DB::connection('whmcs')->table('tblinvoices')->count(); + + $bar = $this->output->createProgressBar($total); + $bar->start(); + + foreach ($extractor->extract() as $row) { + $job = new SyncInvoiceJob((array) $row); + $sync ? dispatch_sync($job) : dispatch($job); + $bar->advance(); + } + + $bar->finish(); + $this->newLine(); + $this->info("Dispatched {$total} invoice(s) for sync."); + + return Command::SUCCESS; + } +} diff --git a/app/Console/Commands/Whmcs/SyncOrderItemsCommand.php b/app/Console/Commands/Whmcs/SyncOrderItemsCommand.php new file mode 100644 index 0000000..672b41a --- /dev/null +++ b/app/Console/Commands/Whmcs/SyncOrderItemsCommand.php @@ -0,0 +1,38 @@ +table('tblhosting')->count(); + + $bar = $this->output->createProgressBar($total); + $bar->start(); + + $skipped = 0; + foreach ($extractor->extract() as $row) { + if (! $writer->write($transformer->transform($row))) { + $skipped++; + } + $bar->advance(); + } + + $bar->finish(); + $this->newLine(); + $this->info("Processed {$total} order item(s), skipped {$skipped} (unresolved order)."); + + return Command::SUCCESS; + } +} diff --git a/app/Console/Commands/Whmcs/SyncOrdersCommand.php b/app/Console/Commands/Whmcs/SyncOrdersCommand.php new file mode 100644 index 0000000..ff9cc77 --- /dev/null +++ b/app/Console/Commands/Whmcs/SyncOrdersCommand.php @@ -0,0 +1,36 @@ +option('sync'); + $total = DB::connection('whmcs')->table('tblorders')->count(); + + $bar = $this->output->createProgressBar($total); + $bar->start(); + + foreach ($extractor->extract() as $row) { + $job = new SyncOrderJob((array) $row); + $sync ? dispatch_sync($job) : dispatch($job); + $bar->advance(); + } + + $bar->finish(); + $this->newLine(); + $this->info("Dispatched {$total} order(s) for sync."); + + return Command::SUCCESS; + } +} diff --git a/app/Console/Commands/Whmcs/SyncProductsCommand.php b/app/Console/Commands/Whmcs/SyncProductsCommand.php new file mode 100644 index 0000000..031b78a --- /dev/null +++ b/app/Console/Commands/Whmcs/SyncProductsCommand.php @@ -0,0 +1,72 @@ +info('Syncing product categories...'); + $bar = $this->output->createProgressBar(DB::connection('whmcs')->table('tblproductgroups')->count()); + $bar->start(); + foreach ($groupExtractor->extract() as $row) { + $groupWriter->write($groupTransformer->transform($row)); + $bar->advance(); + } + $bar->finish(); + $this->newLine(); + + $this->info('Syncing products...'); + $bar = $this->output->createProgressBar(DB::connection('whmcs')->table('tblproducts')->count()); + $bar->start(); + $skippedProducts = 0; + foreach ($productExtractor->extract() as $row) { + if (! $productWriter->write($productTransformer->transform($row))) { + $skippedProducts++; + } + $bar->advance(); + } + $bar->finish(); + $this->newLine(); + + $this->info('Syncing addons...'); + $bar = $this->output->createProgressBar(DB::connection('whmcs')->table('tbladdons')->count()); + $bar->start(); + foreach ($addonExtractor->extract() as $row) { + $addonWriter->write($addonTransformer->transform($row)); + $bar->advance(); + } + $bar->finish(); + $this->newLine(); + + $this->info("Skipped: {$skippedProducts} product(s) (unresolved category)."); + + return Command::SUCCESS; + } +} diff --git a/app/Console/Commands/Whmcs/SyncServicesCommand.php b/app/Console/Commands/Whmcs/SyncServicesCommand.php new file mode 100644 index 0000000..7861586 --- /dev/null +++ b/app/Console/Commands/Whmcs/SyncServicesCommand.php @@ -0,0 +1,38 @@ +table('tblhosting')->count(); + + $bar = $this->output->createProgressBar($total); + $bar->start(); + + $skipped = 0; + foreach ($extractor->extract() as $row) { + if (! $writer->write($transformer->transform($row))) { + $skipped++; + } + $bar->advance(); + } + + $bar->finish(); + $this->newLine(); + $this->info("Processed {$total} service(s), skipped {$skipped} (unresolved customer)."); + + return Command::SUCCESS; + } +} diff --git a/app/Exceptions/InvalidTemplateVariableException.php b/app/Exceptions/InvalidTemplateVariableException.php new file mode 100644 index 0000000..194064c --- /dev/null +++ b/app/Exceptions/InvalidTemplateVariableException.php @@ -0,0 +1,17 @@ + 1, + 'quarterly' => 3, + 'semi_annually' => 6, + 'annually' => 12, + 'biennially' => 24, + 'triennially' => 36, + default => 1, + }; + } } diff --git a/app/Http/Controllers/Api/Admin/Customer/CustomerController.php b/app/Http/Controllers/Api/Admin/Customer/CustomerController.php index b88c895..eac2a9d 100644 --- a/app/Http/Controllers/Api/Admin/Customer/CustomerController.php +++ b/app/Http/Controllers/Api/Admin/Customer/CustomerController.php @@ -289,7 +289,7 @@ class CustomerController extends Controller $customer = $this->resolveCustomer($uuid); $domains = DnsDomain::query() - ->with('server:id,name,ip_address') + ->with(['server:id,name,ip_address', 'registrar:id,name,short_name,website']) ->withCount(['ns', 'record']) ->whereHas('customer', fn($q) => $q->where('customers.id', $customer->id)) ->orderByDesc('id') @@ -306,6 +306,12 @@ class CustomerController extends Controller 'name' => $d->server->name, 'ip_address' => $d->server->ip_address, ] : null, + 'registrar' => $d->registrar ? [ + 'id' => $d->registrar->id, + 'name' => $d->registrar->name, + 'short_name' => $d->registrar->short_name, + 'website' => $d->registrar->website, + ] : null, 'created_at' => $d->created_at, 'updated_at' => $d->updated_at, ]); diff --git a/app/Http/Controllers/Api/Admin/DnsManager/DomainController.php b/app/Http/Controllers/Api/Admin/DnsManager/DomainController.php index 5487d46..65c961c 100644 --- a/app/Http/Controllers/Api/Admin/DnsManager/DomainController.php +++ b/app/Http/Controllers/Api/Admin/DnsManager/DomainController.php @@ -41,7 +41,7 @@ class DomainController extends Controller $customerName = trim((string) $request->get('customer_name', '')); $query = $this->domainRepository->model - ->with(['server', 'customer']) + ->with(['server', 'customer', 'registrar']) ->newQuery(); if ($filter) { @@ -77,7 +77,7 @@ class DomainController extends Controller public function show(int $id): JsonResponse { $domain = $this->domainRepository->model - ->with(['server', 'customer']) + ->with(['server', 'customer', 'registrar']) ->find($id); if (!$domain) { @@ -98,6 +98,7 @@ class DomainController extends Controller 'customer_ids' => 'nullable|array', 'customer_ids.*' => 'integer|exists:customers,id', 'is_created' => 'nullable|integer|in:0,1,2', + 'registrar_id' => 'nullable|integer|exists:registrars,id', ]); DB::beginTransaction(); @@ -106,6 +107,7 @@ class DomainController extends Controller 'domain_name' => $request->domain_name, 'dns_manager_server_id' => $request->dns_manager_server_id, 'is_created' => $request->is_created ?? 0, + 'registrar_id' => $request->registrar_id, ]); if (!empty($request->customer_ids)) { @@ -138,6 +140,7 @@ class DomainController extends Controller 'customer_ids' => 'nullable|array', 'customer_ids.*' => 'integer|exists:customers,id', 'is_created' => 'nullable|integer|in:0,1,2', + 'registrar_id' => 'nullable|integer|exists:registrars,id', ]); DB::beginTransaction(); @@ -146,6 +149,7 @@ class DomainController extends Controller 'domain_name', 'dns_manager_server_id', 'is_created', + 'registrar_id', ])); // Sync customers (empty array = detach all) @@ -202,6 +206,12 @@ class DomainController extends Controller 'email' => $c->email, ])->values()->toArray(), 'is_created' => $domain->is_created, + 'registrar_id' => $domain->registrar_id, + 'registrar' => $domain->registrar ? [ + 'id' => $domain->registrar->id, + 'name' => $domain->registrar->name, + 'short_name' => $domain->registrar->short_name, + ] : null, 'created_at' => $domain->created_at, 'updated_at' => $domain->updated_at, ]; diff --git a/app/Http/Controllers/Api/Admin/Domain/AdminDomainOrderController.php b/app/Http/Controllers/Api/Admin/Domain/AdminDomainOrderController.php index 6789062..f784cd6 100644 --- a/app/Http/Controllers/Api/Admin/Domain/AdminDomainOrderController.php +++ b/app/Http/Controllers/Api/Admin/Domain/AdminDomainOrderController.php @@ -9,20 +9,17 @@ use App\Http\Requests\Api\Admin\Domain\ConfirmRegistrationRequest; use App\Http\Requests\Api\Admin\Domain\RejectAndRefundRequest; use App\Http\Requests\Api\Admin\Domain\UpdateKycUrlRequest; use App\Http\Requests\Api\Admin\Domain\UpdateRegistrantInfoRequest; -use App\Mail\Customer\DomainOrderStatusMail; use App\Models\Notification; use App\Models\Order; use App\Models\Service; -use App\Models\Setting; -use App\Models\Template; use App\Repositories\Api\Domain\DomainOrderRepository; use App\Services\Api\Domain\DomainOrderService; use App\Services\Api\Domain\DomainRegistrantService; +use App\Services\Email\EmailTemplateService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Http\Response; use Illuminate\Support\Facades\Log; -use Illuminate\Support\Facades\Mail; class AdminDomainOrderController extends Controller { @@ -265,9 +262,9 @@ class AdminDomainOrderController extends Controller return; } - [$title, $body, $templateName] = $status === 'approved' - ? ["Tên miền {$domainName} đã được duyệt", "Tên miền {$domainName} (#{$orderNum}) đã được kích hoạt thành công.", 'Domain Order Approved'] - : ["Tên miền {$domainName} bị từ chối", "Tên miền {$domainName} (#{$orderNum}) đã bị từ chối. Lý do: {$reason}. Số dư đã được hoàn lại.", 'Domain Order Rejected']; + [$title, $body, $scenario] = $status === 'approved' + ? ["Tên miền {$domainName} đã được duyệt", "Tên miền {$domainName} (#{$orderNum}) đã được kích hoạt thành công.", 'domain_order_approved'] + : ["Tên miền {$domainName} bị từ chối", "Tên miền {$domainName} (#{$orderNum}) đã bị từ chối. Lý do: {$reason}. Số dư đã được hoàn lại.", 'domain_order_rejected']; Notification::notifyCustomer($customer, [ 'type' => 'domain_order_' . $status, @@ -277,22 +274,21 @@ class AdminDomainOrderController extends Controller ]); try { - $setting = Setting::where('key', 'setting')->first(); - $settingValue = $setting ? json_decode((string) $setting->value, true) : []; - - $template = Template::where('name', $templateName)->first(); - if (!$template) { + if (!\App\Models\Template::where('scenario', $scenario)->where('status', 1)->exists()) { return; } - Mail::to($customer->email)->send(new DomainOrderStatusMail($template, [ + $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, - ])); + ]); } catch (\Throwable $e) { Log::error('AdminDomainOrderController: không thể gửi email khách hàng: ' . $e->getMessage()); } diff --git a/app/Http/Controllers/Api/Admin/EmailTemplate/EmailTemplateController.php b/app/Http/Controllers/Api/Admin/EmailTemplate/EmailTemplateController.php index 00bd8f1..9b4330a 100644 --- a/app/Http/Controllers/Api/Admin/EmailTemplate/EmailTemplateController.php +++ b/app/Http/Controllers/Api/Admin/EmailTemplate/EmailTemplateController.php @@ -2,12 +2,17 @@ namespace App\Http\Controllers\Api\Admin\EmailTemplate; +use App\Exceptions\InvalidTemplateVariableException; use App\Http\Controllers\Controller; +use App\Mail\GenericTemplateMail; use App\Models\Setting; use App\Models\Template; +use App\Services\Email\EmailTemplateRenderer; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Mail; +use Illuminate\Validation\Rule; class EmailTemplateController extends Controller { @@ -32,16 +37,39 @@ class EmailTemplateController extends Controller return response()->json(['error' => false, 'data' => $this->format($template)]); } + /** Danh sách scenario hợp lệ (key + label) để render dropdown động ở frontend. */ + public function scenarios(): JsonResponse + { + $used = Template::whereNotNull('scenario')->pluck('scenario')->all(); + + $data = collect(Template::SCENARIOS)->map(fn ($label, $key) => [ + 'key' => $key, + 'label' => $label, + 'has_template' => in_array($key, $used, true), + ])->values(); + + return response()->json(['error' => false, 'data' => $data]); + } + public function store(Request $request): JsonResponse { $data = $request->validate([ 'name' => 'nullable|string|max:255', - 'scenario' => 'nullable|string|in:vm_credentials,renewal_reminder,news_notification,invoice_pdf', + 'scenario' => ['nullable', 'string', Rule::in(array_keys(Template::SCENARIOS))], 'format' => 'nullable|in:html,json', 'invoice_template_id' => 'nullable|integer|exists:template,id', 'subject' => 'nullable|string|max:255', 'message' => 'nullable|string', 'status' => 'boolean', + 'mail_config' => 'nullable|array', + 'mail_config.from_email' => 'nullable|email', + 'mail_config.from_name' => 'nullable|string|max:255', + 'mail_config.reply_to' => 'nullable|email', + 'mail_config.reply_to_name' => 'nullable|string|max:255', + 'mail_config.cc' => 'nullable|array', + 'mail_config.cc.*' => 'email', + 'mail_config.bcc' => 'nullable|array', + 'mail_config.bcc.*' => 'email', ]); $template = DB::transaction(fn() => Template::create([ @@ -53,6 +81,7 @@ class EmailTemplateController extends Controller 'subject' => $data['subject'] ?? null, 'message' => $data['message'] ?? null, 'status' => $data['status'] ?? 1, + 'metadata' => isset($data['mail_config']) ? ['mail' => $data['mail_config']] : null, ])); return response()->json([ @@ -67,14 +96,31 @@ class EmailTemplateController extends Controller $data = $request->validate([ 'name' => 'sometimes|nullable|string|max:255', - 'scenario' => 'sometimes|nullable|string|in:vm_credentials,renewal_reminder,news_notification,invoice_pdf', + 'scenario' => ['sometimes', 'nullable', 'string', Rule::in(array_keys(Template::SCENARIOS))], 'format' => 'sometimes|nullable|in:html,json', 'invoice_template_id' => 'sometimes|nullable|integer|exists:template,id', 'subject' => 'sometimes|nullable|string|max:255', 'message' => 'sometimes|nullable|string', 'status' => 'sometimes|boolean', + 'mail_config' => 'sometimes|nullable|array', + 'mail_config.from_email' => 'nullable|email', + 'mail_config.from_name' => 'nullable|string|max:255', + 'mail_config.reply_to' => 'nullable|email', + 'mail_config.reply_to_name' => 'nullable|string|max:255', + 'mail_config.cc' => 'nullable|array', + 'mail_config.cc.*' => 'email', + 'mail_config.bcc' => 'nullable|array', + 'mail_config.bcc.*' => 'email', ]); + // metadata.variables do developer khai báo theo code — admin chỉ được sửa metadata.mail. + if (array_key_exists('mail_config', $data)) { + $metadata = $template->metadata ?? []; + $metadata['mail'] = $data['mail_config'] ?? []; + $data['metadata'] = $metadata; + } + unset($data['mail_config']); + DB::transaction(fn() => $template->update($data)); return response()->json([ @@ -122,6 +168,106 @@ class EmailTemplateController extends Controller return response()->json(['error' => false, 'message' => 'Đã lưu cài đặt email.', 'data' => json_decode($setting->value, true)]); } + // ── Preview (render thật qua EmailTemplateRenderer, dùng example value) ──── + + public function preview(Request $request, int $id, EmailTemplateRenderer $renderer): JsonResponse + { + $template = Template::findOrFail($id); + + if ($template->format === 'json') { + return response()->json(['error' => true, 'message' => 'Template hoá đơn JSON dùng preview riêng (previewInvoiceJson).'], 422); + } + + $overrides = $request->validate([ + 'subject' => 'nullable|string|max:255', + 'message' => 'nullable|string', + ]); + + // Cho phép preview nội dung đang sửa dở (chưa lưu) bằng cách override tạm, không đụng DB. + $draft = clone $template; + if (array_key_exists('subject', $overrides)) { + $draft->subject = $overrides['subject']; + } + if (array_key_exists('message', $overrides)) { + $draft->message = $overrides['message']; + } + + $exampleData = collect($template->variables()) + ->mapWithKeys(fn ($def, $key) => [$key => $def['example'] ?? "[{$key}]"]) + ->all(); + + try { + $rendered = $renderer->render($draft, $exampleData); + } catch (InvalidTemplateVariableException $e) { + return response()->json(['error' => true, 'message' => $e->getMessage()], 422); + } + + return response()->json(['error' => false, 'data' => $rendered]); + } + + // ── Send test email ─────────────────────────────────────────────────────── + + public function sendTest(Request $request, int $id, EmailTemplateRenderer $renderer): JsonResponse + { + $template = Template::findOrFail($id); + + if ($template->format === 'json') { + return response()->json(['error' => true, 'message' => 'Không thể gửi mẫu cho template JSON (hoá đơn).'], 422); + } + + $data = $request->validate([ + 'to' => 'required|string', + 'cc' => 'nullable|string', + 'bcc' => 'nullable|string', + ]); + + $to = $this->parseRecipients($data['to']); + if (empty($to)) { + return response()->json(['error' => true, 'message' => 'Vui lòng nhập email người nhận hợp lệ.'], 422); + } + + $exampleData = collect($template->variables()) + ->mapWithKeys(fn ($def, $key) => [$key => $def['example'] ?? "[{$key}]"]) + ->all(); + + try { + $rendered = $renderer->render($template, $exampleData); + + $mailer = Mail::to($to); + if ($cc = $this->parseRecipients($data['cc'] ?? null)) { + $mailer->cc($cc); + } + if ($bcc = $this->parseRecipients($data['bcc'] ?? null)) { + $mailer->bcc($bcc); + } + $mailer->send(new GenericTemplateMail( + '[TEST] ' . $rendered['subject'], + $rendered['html'], + $template->mailConfig(), + )); + } catch (InvalidTemplateVariableException $e) { + return response()->json(['error' => true, 'message' => $e->getMessage()], 422); + } catch (\Throwable $e) { + return response()->json(['error' => true, 'message' => 'Không thể gửi email: ' . $e->getMessage()], 500); + } + + return response()->json(['error' => false, 'message' => 'Đã gửi email mẫu.']); + } + + private function parseRecipients(?string $value): array + { + if (!$value) { + return []; + } + + return collect(preg_split('/[,;]+/', $value) ?: []) + ->map(fn ($email) => trim((string) $email)) + ->filter(fn ($email) => $email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL)) + ->unique() + ->values() + ->all(); + } + // ── Invoice PDF preview ─────────────────────────────────────────────────── public function previewInvoiceJson(int $id): JsonResponse @@ -154,6 +300,8 @@ class EmailTemplateController extends Controller 'subject' => $t->subject, 'message' => $t->message, 'status' => $t->status, + 'variables' => array_values($t->variables()), + 'mail_config' => $t->mailConfig(), 'created_at' => $t->created_at, 'updated_at' => $t->updated_at, ]; diff --git a/app/Http/Controllers/Api/Admin/Employee/EmployeeController.php b/app/Http/Controllers/Api/Admin/Employee/EmployeeController.php index 07ea1c3..8711f76 100644 --- a/app/Http/Controllers/Api/Admin/Employee/EmployeeController.php +++ b/app/Http/Controllers/Api/Admin/Employee/EmployeeController.php @@ -35,8 +35,7 @@ class EmployeeController extends Controller DB::beginTransaction(); $employee = $this->employeeRepository->create([ - 'firstname' => $request->firstname, - 'lastname' => $request->lastname, + 'fullname' => $request->fullname, 'username' => $request->username, 'email' => $request->email, 'phone' => $request->phone, @@ -97,8 +96,7 @@ class EmployeeController extends Controller DB::beginTransaction(); $data = [ - 'firstname' => $request->firstname, - 'lastname' => $request->lastname, + 'fullname' => $request->fullname, 'username' => $request->username, 'email' => $request->email, 'phone' => $request->phone, diff --git a/app/Http/Controllers/Api/Admin/Invoice/InvoiceController.php b/app/Http/Controllers/Api/Admin/Invoice/InvoiceController.php index 376edd6..6a75b7e 100644 --- a/app/Http/Controllers/Api/Admin/Invoice/InvoiceController.php +++ b/app/Http/Controllers/Api/Admin/Invoice/InvoiceController.php @@ -3,16 +3,19 @@ namespace App\Http\Controllers\Api\Admin\Invoice; use App\Enums\Invoice\InvoiceStatus; +use App\Helpers\OrderHelper; use App\Http\Controllers\Controller; use App\Http\Requests\InvoiceRequest; use App\Http\Resources\Api\Admin\InvoiceResource; use App\Mail\InvoiceNotificationMail; use App\Models\Customer; +use App\Models\Invoice; +use App\Models\InvoiceItem; +use App\Models\Order; use App\Models\Payment; use App\Repositories\Invoice\InvoiceRepositoryInterface; use App\Repositories\InvoiceAttachment\InvoiceAttachmentRepositoryInterface; use App\Repositories\InvoiceItem\InvoiceItemRepositoryInterface; -use App\Repositories\Order\OrderRepositoryInterface; use App\Services\InvoicePaymentService; use App\Services\InvoicePdfService; use Illuminate\Http\JsonResponse; @@ -28,7 +31,6 @@ class InvoiceController extends Controller { public function __construct( private readonly InvoiceRepositoryInterface $invoiceRepository, - private readonly OrderRepositoryInterface $orderRepository, private readonly InvoiceItemRepositoryInterface $invoiceItemRepository, private readonly InvoiceAttachmentRepositoryInterface $attachmentRepository, private readonly InvoicePdfService $invoicePdfService, @@ -76,38 +78,63 @@ class InvoiceController extends Controller ->make(true); } + /** + * Admin tạo thêm 1 invoice thủ công cho 1 order đã tồn tại — chỉ dùng cho phụ + * phí/điều chỉnh thuộc CHÍNH dịch vụ trong order đó (vd. phạt trễ hạn, phí xử lý). + * Order cố định: không dùng endpoint này để bán thêm dịch vụ mới — dịch vụ mới phải + * là 1 Order riêng (qua OrderController::store). Đi qua cùng convention với renewal + * invoice ở nơi khác trong codebase: gắn order_id vào order gốc, tự sinh invoice_number. + */ public function store(Request $request): JsonResponse { $validated = $request->validate([ - 'customer_id' => 'required|numeric', - 'payment_method' => 'required|numeric', - 'sub_total' => 'required|numeric', - 'total' => 'required|numeric', + 'order_id' => 'required|integer|exists:orders,id', + 'invoice_type' => 'nullable|integer|in:1,2,3', + 'description' => 'nullable|string|max:500', + 'due_date' => 'nullable|date', + 'sub_total' => 'required|numeric|min:0', + 'tax' => 'nullable|numeric|min:0', ]); - $invoiceData = [ - 'date' => time(), - 'due_date' => time() + (7 * 24 * 60 * 60), - 'sub_total' => $validated['sub_total'], - 'tax' => $validated['sub_total'] * 0.1, - 'total' => $validated['total'], - 'payment_method' => $validated['payment_method'], - 'customer_id' => $validated['customer_id'], - 'status' => 0, - ]; + $order = Order::find($validated['order_id']); + $subTotal = (int) $validated['sub_total']; + $tax = (int) ($validated['tax'] ?? 0); + $total = $subTotal + $tax; try { - DB::beginTransaction(); - $invoice = $this->invoiceRepository->create($invoiceData); - DB::commit(); + $invoice = DB::transaction(function () use ($order, $validated, $subTotal, $tax, $total) { + $invoice = Invoice::create([ + 'invoice_number' => OrderHelper::generateOrderNumber('INV'), + 'customer_id' => $order->customer_id, + 'order_id' => $order->id, + 'invoice_type' => $validated['invoice_type'] ?? 3, + 'sub_total' => $subTotal, + 'discount_amount' => 0, + 'tax' => $tax, + 'total' => $total, + 'status' => InvoiceStatus::UNPAID->value, + 'due_date' => $validated['due_date'] ?? now()->addDays(7)->toDateString(), + ]); + + InvoiceItem::create([ + 'invoice_id' => $invoice->id, + 'type' => 'service', + 'description' => $validated['description'] ?: 'Hóa đơn bổ sung', + 'months' => 0, + 'quantity' => 1, + 'unit_price' => $subTotal, + 'total_price' => $subTotal, + ]); + + return $invoice; + }); return response()->json([ 'error' => false, - 'message' => 'Invoice created successfully', - 'data' => new InvoiceResource($invoice) + 'message' => 'Đã tạo hóa đơn', + 'data' => new InvoiceResource($invoice->load('invoiceItems')), ], 201); } catch (\Exception $e) { - DB::rollBack(); return response()->json([ 'error' => true, 'message' => $e->getMessage() @@ -199,17 +226,17 @@ class InvoiceController extends Controller } try { - DB::beginTransaction(); - $this->orderRepository->updateBy('invoice_id', $id, ['invoice_id' => 0]); + // Note: `orders` has no `invoice_id` column (the FK runs the other way — + // invoices.order_id -> orders.id), so there's nothing to clean up there. Any + // `services.invoice_id` pointing at this invoice is already handled by the + // FK's onDelete('set null'). $this->invoiceRepository->delete($id); - DB::commit(); return response()->json([ 'error' => false, 'message' => 'Invoice deleted successfully' ]); } catch (\Exception $e) { - DB::rollBack(); return response()->json([ 'error' => true, 'message' => $e->getMessage() diff --git a/app/Http/Controllers/Api/Admin/Transaction/AdminTransactionController.php b/app/Http/Controllers/Api/Admin/Transaction/AdminTransactionController.php index 3486b5c..e051eb1 100644 --- a/app/Http/Controllers/Api/Admin/Transaction/AdminTransactionController.php +++ b/app/Http/Controllers/Api/Admin/Transaction/AdminTransactionController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers\Api\Admin\Transaction; use App\Enums\TransactionStatus; use App\Http\Controllers\Controller; use App\Mail\Customer\ManualDepositStatus; +use App\Models\Customer; use App\Models\Notification; use App\Models\Log; use App\Models\Transaction; @@ -85,11 +86,55 @@ class AdminTransactionController extends Controller public function approve(string $uuid): JsonResponse { - $transaction = Transaction::with('customer') - ->where('uuid', $uuid) - ->where('type', 'manual') - ->where('status', TransactionStatus::PENDING->value) - ->first(); + try { + // Lock the transaction (and customer) row for the whole read-check-write so two + // admins approving the same transaction concurrently cannot both credit the customer. + $transaction = DB::transaction(function () use ($uuid) { + $transaction = Transaction::where('uuid', $uuid) + ->where('type', 'manual') + ->lockForUpdate() + ->first(); + + if (!$transaction || (int) $transaction->status !== TransactionStatus::PENDING->value) { + return null; + } + + $transaction->update(['status' => TransactionStatus::COMPLETED->value]); + + $customer = Customer::where('id', $transaction->customer_id)->lockForUpdate()->first(); + $customer->total += $transaction->amount; + $customer->credit += (int) floor($transaction->amount / 100); + $customer->total_pending = max(0, $customer->total_pending - $transaction->amount); + $customer->save(); + + Log::create([ + 'agent' => 'admin', + 'client_ip' => request()->ip(), + 'description' => 'Admin duyệt nạp tiền thủ công: ' . number_format($transaction->amount) . ' VNĐ (+' . floor($transaction->amount / 100) . ' credit)', + 'customer_id' => $customer->id, + ]); + + Notification::notifyCustomer($customer, [ + 'type' => 'deposit_success', + 'title' => 'Nạp tiền thành công', + 'body' => 'Giao dịch ' . $transaction->public_code . ' đã được duyệt.', + 'link' => '/lich-su-giao-dich/' . $transaction->id, + 'data' => [ + 'transaction_uuid' => $transaction->uuid, + 'amount' => $transaction->amount, + 'public_code' => $transaction->public_code, + ], + ]); + + return $transaction->setRelation('customer', $customer); + }); + } catch (\Exception $e) { + FacadesLog::error('AdminTransactionController::approve error: ' . $e->getMessage()); + return response()->json([ + 'error' => true, + 'message' => 'Có lỗi xảy ra, vui lòng thử lại', + ], 500); + } if (!$transaction) { return response()->json([ @@ -98,65 +143,28 @@ class AdminTransactionController extends Controller ], 404); } - DB::beginTransaction(); + $customer = $transaction->customer; + try { - $transaction->update(['status' => TransactionStatus::COMPLETED->value]); - - $customer = $transaction->customer; - $customer->total += $transaction->amount; - $customer->credit += (int) floor($transaction->amount / 100); - $customer->total_pending = max(0, $customer->total_pending - $transaction->amount); - $customer->save(); - - Log::create([ - 'agent' => 'admin', - 'client_ip' => request()->ip(), - 'description' => 'Admin duyệt nạp tiền thủ công: ' . number_format($transaction->amount) . ' VNĐ (+' . floor($transaction->amount / 100) . ' credit)', + Mail::to($customer->email)->send(new ManualDepositStatus([ 'customer_id' => $customer->id, + 'customer_email' => $customer->email, + 'customer_name' => $customer->fullname, + 'transaction_code' => $transaction->public_code, + 'amount' => $transaction->amount, + 'status_label' => 'Đã duyệt', + 'status_color' => '#15803d', + 'status_bg' => '#f0fdf4', + 'subject' => 'Yêu cầu nạp tiền đã được duyệt - ' . $transaction->public_code, + 'status_message' => 'Yêu cầu nạp tiền của bạn đã được duyệt. Số tiền đã được cộng vào tài khoản và có thể sử dụng ngay.', + 'deposit_date' => $transaction->created_at?->format('d/m/Y H:i:s') ?? now()->format('d/m/Y H:i:s'), + 'processed_date' => now()->format('d/m/Y H:i:s'), + ])); + } catch (\Throwable $e) { + FacadesLog::error('Không thể gửi email duyệt nạp tiền thủ công: ' . $e->getMessage(), [ + 'transaction_code' => $transaction->public_code, + 'customer_email' => $customer->email, ]); - - Notification::notifyCustomer($customer, [ - 'type' => 'deposit_success', - 'title' => 'Nạp tiền thành công', - 'body' => 'Giao dịch ' . $transaction->public_code . ' đã được duyệt.', - 'link' => '/lich-su-giao-dich/' . $transaction->id, - 'data' => [ - 'transaction_uuid' => $transaction->uuid, - 'amount' => $transaction->amount, - 'public_code' => $transaction->public_code, - ], - ]); - - DB::commit(); - - try { - Mail::to($customer->email)->send(new ManualDepositStatus([ - 'customer_id' => $customer->id, - 'customer_email' => $customer->email, - 'customer_name' => $customer->fullname, - 'transaction_code' => $transaction->public_code, - 'amount' => $transaction->amount, - 'status_label' => 'Đã duyệt', - 'status_color' => '#15803d', - 'status_bg' => '#f0fdf4', - 'subject' => 'Yêu cầu nạp tiền đã được duyệt - ' . $transaction->public_code, - 'status_message' => 'Yêu cầu nạp tiền của bạn đã được duyệt. Số tiền đã được cộng vào tài khoản và có thể sử dụng ngay.', - 'deposit_date' => $transaction->created_at?->format('d/m/Y H:i:s') ?? now()->format('d/m/Y H:i:s'), - 'processed_date' => now()->format('d/m/Y H:i:s'), - ])); - } catch (\Throwable $e) { - FacadesLog::error('Không thể gửi email duyệt nạp tiền thủ công: ' . $e->getMessage(), [ - 'transaction_code' => $transaction->public_code, - 'customer_email' => $customer->email, - ]); - } - } catch (\Exception $e) { - DB::rollBack(); - FacadesLog::error('AdminTransactionController::approve error: ' . $e->getMessage()); - return response()->json([ - 'error' => true, - 'message' => 'Có lỗi xảy ra, vui lòng thử lại', - ], 500); } return response()->json([ @@ -167,11 +175,41 @@ class AdminTransactionController extends Controller public function reject(string $uuid): JsonResponse { - $transaction = Transaction::with('customer') - ->where('uuid', $uuid) - ->where('type', 'manual') - ->where('status', TransactionStatus::PENDING->value) - ->first(); + try { + // Same lock-then-verify-status pattern as approve() — prevents a duplicate + // reject (or reject-after-approve) race from double-adjusting total_pending. + $transaction = DB::transaction(function () use ($uuid) { + $transaction = Transaction::where('uuid', $uuid) + ->where('type', 'manual') + ->lockForUpdate() + ->first(); + + if (!$transaction || (int) $transaction->status !== TransactionStatus::PENDING->value) { + return null; + } + + $transaction->update(['status' => TransactionStatus::FAILED->value]); + + $customer = Customer::where('id', $transaction->customer_id)->lockForUpdate()->first(); + $customer->total_pending = max(0, $customer->total_pending - $transaction->amount); + $customer->save(); + + Log::create([ + 'agent' => 'admin', + 'client_ip' => request()->ip(), + 'description' => 'Admin từ chối nạp tiền thủ công: ' . number_format($transaction->amount) . ' VNĐ', + 'customer_id' => $customer->id, + ]); + + return $transaction->setRelation('customer', $customer); + }); + } catch (\Exception $e) { + FacadesLog::error('AdminTransactionController::reject error: ' . $e->getMessage()); + return response()->json([ + 'error' => true, + 'message' => 'Có lỗi xảy ra, vui lòng thử lại', + ], 500); + } if (!$transaction) { return response()->json([ @@ -180,51 +218,28 @@ class AdminTransactionController extends Controller ], 404); } - DB::beginTransaction(); + $customer = $transaction->customer; + try { - $transaction->update(['status' => TransactionStatus::FAILED->value]); - - $customer = $transaction->customer; - $customer->total_pending = max(0, $customer->total_pending - $transaction->amount); - $customer->save(); - - Log::create([ - 'agent' => 'admin', - 'client_ip' => request()->ip(), - 'description' => 'Admin từ chối nạp tiền thủ công: ' . number_format($transaction->amount) . ' VNĐ', + Mail::to($customer->email)->send(new ManualDepositStatus([ 'customer_id' => $customer->id, + 'customer_email' => $customer->email, + 'customer_name' => $customer->fullname, + 'transaction_code' => $transaction->public_code, + 'amount' => $transaction->amount, + 'status_label' => 'Không phù hợp', + 'status_color' => '#b91c1c', + 'status_bg' => '#fef2f2', + 'subject' => 'Yêu cầu nạp tiền không phù hợp - ' . $transaction->public_code, + 'status_message' => 'Yêu cầu nạp tiền của bạn chưa phù hợp để được duyệt. Vui lòng kiểm tra lại chứng từ hoặc liên hệ bộ phận hỗ trợ để được hướng dẫn thêm.', + 'deposit_date' => $transaction->created_at?->format('d/m/Y H:i:s') ?? now()->format('d/m/Y H:i:s'), + 'processed_date' => now()->format('d/m/Y H:i:s'), + ])); + } catch (\Throwable $e) { + FacadesLog::error('Không thể gửi email từ chối nạp tiền thủ công: ' . $e->getMessage(), [ + 'transaction_code' => $transaction->public_code, + 'customer_email' => $customer->email, ]); - - DB::commit(); - - try { - Mail::to($customer->email)->send(new ManualDepositStatus([ - 'customer_id' => $customer->id, - 'customer_email' => $customer->email, - 'customer_name' => $customer->fullname, - 'transaction_code' => $transaction->public_code, - 'amount' => $transaction->amount, - 'status_label' => 'Không phù hợp', - 'status_color' => '#b91c1c', - 'status_bg' => '#fef2f2', - 'subject' => 'Yêu cầu nạp tiền không phù hợp - ' . $transaction->public_code, - 'status_message' => 'Yêu cầu nạp tiền của bạn chưa phù hợp để được duyệt. Vui lòng kiểm tra lại chứng từ hoặc liên hệ bộ phận hỗ trợ để được hướng dẫn thêm.', - 'deposit_date' => $transaction->created_at?->format('d/m/Y H:i:s') ?? now()->format('d/m/Y H:i:s'), - 'processed_date' => now()->format('d/m/Y H:i:s'), - ])); - } catch (\Throwable $e) { - FacadesLog::error('Không thể gửi email từ chối nạp tiền thủ công: ' . $e->getMessage(), [ - 'transaction_code' => $transaction->public_code, - 'customer_email' => $customer->email, - ]); - } - } catch (\Exception $e) { - DB::rollBack(); - FacadesLog::error('AdminTransactionController::reject error: ' . $e->getMessage()); - return response()->json([ - 'error' => true, - 'message' => 'Có lỗi xảy ra, vui lòng thử lại', - ], 500); } return response()->json([ diff --git a/app/Http/Controllers/Api/Service/PaymentController.php b/app/Http/Controllers/Api/Service/PaymentController.php deleted file mode 100644 index 6251072..0000000 --- a/app/Http/Controllers/Api/Service/PaymentController.php +++ /dev/null @@ -1,74 +0,0 @@ -user(); - $order = Order::with(['invoice', 'orderItems', 'customer']) - ->where('id', $orderId) - ->where('customer_id', $customer->id) - ->first(); - - if (!$order) { - return response()->json([ - 'error' => true, - 'message' => 'Không tìm thấy đơn hàng' - ], 404); - } - - if ($order->status == 2) { // Already paid - return response()->json([ - 'error' => true, - 'message' => 'Đơn hàng đã được thanh toán' - ], 400); - } - - // Kiểm tra số dư tài khoản (total) - if ($customer->total < $order->amount) { - return response()->json([ - 'error' => true, - 'message' => 'Số dư không đủ để thanh toán', - 'data' => [ - 'required' => $order->amount, - 'current_total' => $customer->total, - 'shortage' => $order->amount - $customer->total - ] - ], 400); - } - - // Dispatch job để xử lý thanh toán và tạo VM - ProcessOrderPaymentJob::dispatch($order); - - return response()->json([ - 'error' => false, - 'message' => 'Đơn hàng đang được xử lý. Bạn sẽ nhận được thông báo khi hoàn tất.', - 'notice' => 'Dịch vụ VPS sẽ được khởi tạo trong vòng 5-10 phút. Vui lòng đợi trong giây lát.', - 'data' => [ - 'order_id' => $order->id, - 'order_num' => $order->order_num, - 'status' => 'processing' - ] - ]); - } catch (\Exception $e) { - Log::error('Pay order error: ' . $e->getMessage()); - return response()->json([ - 'error' => true, - 'message' => 'Có lỗi xảy ra khi thanh toán đơn hàng' - ], 500); - } - } -} diff --git a/app/Http/Controllers/Api/Service/ServiceController.php b/app/Http/Controllers/Api/Service/ServiceController.php index 02b924f..066905c 100644 --- a/app/Http/Controllers/Api/Service/ServiceController.php +++ b/app/Http/Controllers/Api/Service/ServiceController.php @@ -5,8 +5,6 @@ namespace App\Http\Controllers\Api\Service; use App\Http\Controllers\Controller; use App\Http\Resources\Api\Service\ServiceListResource; use App\Http\Resources\Api\Service\ServiceResource; -use App\Http\Services\Invoice\InvoiceService; -use App\Jobs\VirtualMachine\ProcessOrderPaymentJob; use App\Models\Addon; use App\Models\Cart; use App\Models\CartAddon; diff --git a/app/Http/Controllers/admin/UserController.php b/app/Http/Controllers/admin/UserController.php index 1d9474e..6ce3dec 100644 --- a/app/Http/Controllers/admin/UserController.php +++ b/app/Http/Controllers/admin/UserController.php @@ -30,7 +30,7 @@ class UserController extends Controller [ 'username' => 'required', 'administrator_roles' => 'required', - 'firstname' => 'required', + 'fullname' => 'required', 'email' => 'required', 'password' => 'required', 'password_confirm' => 'required', @@ -42,8 +42,8 @@ class UserController extends Controller 'administrator_roles' => [ 'required' => 'Please select roles' ], - 'firstname' => [ - 'required' => 'Please enter your firstname' + 'fullname' => [ + 'required' => 'Please enter your fullname' ], 'email' => [ 'required' => 'Please enter your email' @@ -74,8 +74,7 @@ class UserController extends Controller } $data = [ - 'firstname' => $request->firstname, - 'lastname' => $request->lastname, + 'fullname' => $request->fullname, 'username' => $request->username, 'email' => $request->email, 'password' => Hash::make($request->password), @@ -137,7 +136,7 @@ class UserController extends Controller [ 'username' => 'required', 'administrator_roles' => 'required', - 'firstname' => 'required', + 'fullname' => 'required', 'email' => 'required', 'id' => 'required|numeric|not__in:0' ], @@ -148,8 +147,8 @@ class UserController extends Controller 'administrator_roles' => [ 'required' => 'Please select roles' ], - 'firstname' => [ - 'required' => 'Please enter your firstname' + 'fullname' => [ + 'required' => 'Please enter your fullname' ], 'email' => [ 'required' => 'Please enter your email' diff --git a/app/Http/Requests/EmployeeRequest.php b/app/Http/Requests/EmployeeRequest.php index 7dbf307..66004ac 100644 --- a/app/Http/Requests/EmployeeRequest.php +++ b/app/Http/Requests/EmployeeRequest.php @@ -17,8 +17,7 @@ class EmployeeRequest extends FormRequest $employeeId = $this->route('id'); $rules = [ - 'firstname' => 'required|string|max:255', - 'lastname' => 'required|string|max:255', + 'fullname' => 'required|string|max:255', 'username' => ['required', 'string', 'max:255', Rule::unique('user', 'username')->ignore($employeeId)], 'email' => ['required', 'email', 'max:255', Rule::unique('user', 'email')->ignore($employeeId)], 'role' => ['required', Rule::in(['Administrator', 'Sale', 'IT', 'Customer Service', 'Marketing'])], diff --git a/app/Http/Resources/Api/Admin/AdminUserResource.php b/app/Http/Resources/Api/Admin/AdminUserResource.php index a5f70da..d9ced9a 100644 --- a/app/Http/Resources/Api/Admin/AdminUserResource.php +++ b/app/Http/Resources/Api/Admin/AdminUserResource.php @@ -13,8 +13,7 @@ class AdminUserResource extends JsonResource 'id' => $this->id, 'username' => $this->username, 'email' => $this->email, - 'firstname' => $this->firstname, - 'lastname' => $this->lastname, + 'fullname' => $this->fullname, 'phone' => $this->phone, 'department' => $this->department, 'start_date' => $this->start_date?->toDateString(), diff --git a/app/Http/Resources/Api/Admin/OrderResource.php b/app/Http/Resources/Api/Admin/OrderResource.php index 1fee9a4..26d31d7 100644 --- a/app/Http/Resources/Api/Admin/OrderResource.php +++ b/app/Http/Resources/Api/Admin/OrderResource.php @@ -14,6 +14,7 @@ class OrderResource extends JsonResource 'order_num' => $this->order_num, 'customer' => $this->whenLoaded('customer', fn() => [ 'id' => $this->customer->id, + 'uuid' => $this->customer->uuid, 'fullname' => $this->customer->fullname, 'email' => $this->customer->email, 'phone' => $this->customer->phone ?? null, @@ -46,10 +47,44 @@ class OrderResource extends JsonResource 'due_date' => $this->invoice->due_date, 'date_paid' => $this->invoice->paid_at, ]), + // An order can have many invoices (1 initial + N renewals) — this is the full list. + 'invoices' => $this->whenLoaded('invoices', fn() => $this->invoices->map(fn($invoice) => [ + 'id' => $invoice->id, + 'invoice_number' => $invoice->invoice_number, + 'invoice_type' => $invoice->invoice_type, + 'sub_total' => $invoice->sub_total, + 'discount_amount' => $invoice->discount_amount, + 'tax' => $invoice->tax, + 'total' => $invoice->total, + 'status' => $invoice->status, + 'date' => $invoice->created_at, + 'due_date' => $invoice->due_date, + 'date_paid' => $invoice->paid_at, + 'billing_period_start' => $invoice->billing_period_start, + 'billing_period_end' => $invoice->billing_period_end, + // 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') + ? $invoice->invoiceItems->map(fn($item) => [ + 'id' => $item->id, + 'product_id' => $item->product_id, + 'product_name' => $item->product?->name, + 'product_type' => $item->product?->product_type, + 'type' => $item->type, + 'description' => $item->description, + 'months' => $item->months, + 'quantity' => $item->quantity, + 'unit_price' => $item->unit_price, + 'total_price' => $item->total_price, + ]) + : [], + ])), 'sub_total' => $this->sub_total, 'discount_amount' => $this->discount_amount, 'total' => $this->total, - 'payment_method' => $this->whenLoaded('payment', fn() => $this->payment->name, $this->payment_method), + // Order has no payment_id FK — `payment` relation is always null. Fall back to + // the raw payment_method string instead of letting whenLoaded() mask it with null. + 'payment_method' => $this->payment?->name ?? $this->payment_method, 'payment_proof' => $this->payment_proof, 'note' => $this->note, 'client_ip' => $this->client_ip, diff --git a/app/Http/Resources/Api/Admin/ProductResource.php b/app/Http/Resources/Api/Admin/ProductResource.php index e1437f4..4833159 100644 --- a/app/Http/Resources/Api/Admin/ProductResource.php +++ b/app/Http/Resources/Api/Admin/ProductResource.php @@ -27,6 +27,9 @@ class ProductResource extends JsonResource 'page_type' => $this->resolvePageType($this->product_type), 'product_categories_id' => $this->whenLoaded('category', fn() => $this->category?->id), 'category' => $this->whenLoaded('category', fn() => $this->category?->name), + // Which billing_cycle values this product's category expects (drives cycle + // pickers in the UI) — e.g. domain is annual-only, IT service can be one_time. + 'category_billing_cycles' => $this->whenLoaded('category', fn() => $this->category?->billing_cycles), 'service_plan_id' => $this->service_plan_id, 'cpu' => $this->cpu, 'ram' => $this->ram, @@ -37,18 +40,21 @@ class ProductResource extends JsonResource 'setup_fee' => $this->setup_fee, 'min_price' => $minPrice, 'billing_type' => $this->whenLoaded('pricings', function () { - $hasOnetime = $this->pricings->where('billing_cycle', 'onetime')->isNotEmpty(); - $hasRecurring = $this->pricings->where('billing_cycle', '!=', 'onetime')->isNotEmpty(); - return ($hasOnetime && !$hasRecurring) ? 'onetime' : 'recurring'; + // 'one_time' (with underscore) is the convention used everywhere else in the + // app (OrderHelper::cycleToMonths, ServiceExpiry, renewService, product_pricings + // migration comment) — this used to check 'onetime' and never matched real rows. + $hasOnetime = $this->pricings->where('billing_cycle', 'one_time')->isNotEmpty(); + $hasRecurring = $this->pricings->where('billing_cycle', '!=', 'one_time')->isNotEmpty(); + return ($hasOnetime && !$hasRecurring) ? 'one_time' : 'recurring'; }), 'currency' => $this->whenLoaded('pricings', fn () => $this->pricings->first()?->currency ?? 'VND'), 'price_monthly' => $this->whenLoaded('pricings', fn () => optional($this->pricings->firstWhere('billing_cycle', 'monthly'))->price), 'price_quarterly' => $this->whenLoaded('pricings', fn () => optional($this->pricings->firstWhere('billing_cycle', 'quarterly'))->price), - 'price_semiannually' => $this->whenLoaded('pricings', fn () => optional($this->pricings->firstWhere('billing_cycle', 'semiannually'))->price), + 'price_semiannually' => $this->whenLoaded('pricings', fn () => optional($this->pricings->firstWhere('billing_cycle', 'semi_annually'))->price), 'price_annually' => $this->whenLoaded('pricings', fn () => optional($this->pricings->firstWhere('billing_cycle', 'annually'))->price), 'price_biennially' => $this->whenLoaded('pricings', fn () => optional($this->pricings->firstWhere('billing_cycle', 'biennially'))->price), 'price_triennially' => $this->whenLoaded('pricings', fn () => optional($this->pricings->firstWhere('billing_cycle', 'triennially'))->price), - 'price_onetime' => $this->whenLoaded('pricings', fn () => optional($this->pricings->firstWhere('billing_cycle', 'onetime'))->price), + 'price_onetime' => $this->whenLoaded('pricings', fn () => optional($this->pricings->firstWhere('billing_cycle', 'one_time'))->price), 'is_active' => $this->is_active, 'created_at' => $this->created_at, 'os_ids' => $osIds, diff --git a/app/Http/Services/Invoice/InvoiceService.php b/app/Http/Services/Invoice/InvoiceService.php deleted file mode 100644 index 9a338f4..0000000 --- a/app/Http/Services/Invoice/InvoiceService.php +++ /dev/null @@ -1,81 +0,0 @@ -orderItems)->pluck('product_type'); - - $invoiceType = match (true) { - $productTypes->contains(3) => 3, - $productTypes->contains(4) => 4, - default => 1, - }; - - $maxMonths = $order->orderItems->max('months') ?? 1; - $dueDate = now()->addMonths($maxMonths); - - $invoice = Invoice::create([ - 'date' => now(), - 'order_num' => $order->order_num, - 'due_date' => $dueDate, - 'date_paid' => now(), - 'sub_total' => $order->amount, - 'tax' => 0, - 'total' => $order->amount, - 'customer_id' => $order->customer_id, - 'client_ip' => request()->ip(), - 'order_id' => $order->id, - 'note' => 'Thanh toán đơn hàng #' . $order->order_num, - 'invoice_type' => $invoiceType, - 'status' => 1, - ]); - - foreach ($order->orderItems as $item) { - $invoiceItem = InvoiceItem::create([ - 'invoice_id' => $invoice->id, - 'product_id' => $item->product_id, - 'months' => $item->months, - 'os_id' => $item->os_id, - 'server_name' => $item->server_name, - 'quantity' => $item->quantity, - 'price' => $item->price, - 'product_type' => $item->product_type, - ]); - - foreach ($item->addons as $addon) { - InvoiceAddons::create([ - 'invoice_item_id' => $invoiceItem->id, - 'addon_id' => $addon->addon_id, - 'qty' => $addon->qty, - 'price' => $addon->price, - ]); - } - } - - $order->update(['invoice_id' => $invoice->id]); - - return $invoice; - } - - - - public function markAsPaid(Invoice $invoice): Invoice - { - $invoice->update([ - 'status' => InvoiceStatus::PAID->value, - 'date_paid' => now(), - ]); - - return $invoice; - } -} \ No newline at end of file diff --git a/app/Integrations/Domain/DTOs/DnsDomainData.php b/app/Integrations/Domain/DTOs/DnsDomainData.php new file mode 100644 index 0000000..71fd21e --- /dev/null +++ b/app/Integrations/Domain/DTOs/DnsDomainData.php @@ -0,0 +1,17 @@ +table('dns_manager_domain')->orderBy('id')->cursor(); + } +} diff --git a/app/Integrations/Domain/Extractors/DnsDomainUserExtractor.php b/app/Integrations/Domain/Extractors/DnsDomainUserExtractor.php new file mode 100644 index 0000000..dc6b851 --- /dev/null +++ b/app/Integrations/Domain/Extractors/DnsDomainUserExtractor.php @@ -0,0 +1,14 @@ +table('dns_domain_user')->orderBy('id')->cursor(); + } +} diff --git a/app/Integrations/Domain/Extractors/DnsRecordExtractor.php b/app/Integrations/Domain/Extractors/DnsRecordExtractor.php new file mode 100644 index 0000000..dcd1e81 --- /dev/null +++ b/app/Integrations/Domain/Extractors/DnsRecordExtractor.php @@ -0,0 +1,14 @@ +table('dns_manager_ns')->orderBy('id')->cursor(); + } +} diff --git a/app/Integrations/Domain/Extractors/DnsServerExtractor.php b/app/Integrations/Domain/Extractors/DnsServerExtractor.php new file mode 100644 index 0000000..6266fc8 --- /dev/null +++ b/app/Integrations/Domain/Extractors/DnsServerExtractor.php @@ -0,0 +1,14 @@ +table('dns_manager_servers')->orderBy('id')->cursor(); + } +} diff --git a/app/Integrations/Domain/Support/DnsDomainIdResolver.php b/app/Integrations/Domain/Support/DnsDomainIdResolver.php new file mode 100644 index 0000000..6ae28ec --- /dev/null +++ b/app/Integrations/Domain/Support/DnsDomainIdResolver.php @@ -0,0 +1,19 @@ +|null */ + private static ?array $map = null; + + public function resolve(int $legacyDomainId): ?int + { + self::$map ??= DB::table('dns_manager_domain')->whereNotNull('legacy_id')->pluck('id', 'legacy_id')->all(); + + return self::$map[$legacyDomainId] ?? null; + } +} diff --git a/app/Integrations/Domain/Support/DnsServerIdResolver.php b/app/Integrations/Domain/Support/DnsServerIdResolver.php new file mode 100644 index 0000000..c99a041 --- /dev/null +++ b/app/Integrations/Domain/Support/DnsServerIdResolver.php @@ -0,0 +1,19 @@ +|null */ + private static ?array $map = null; + + public function resolve(int $legacyServerId): ?int + { + self::$map ??= DB::table('dns_manager_servers')->whereNotNull('legacy_id')->pluck('id', 'legacy_id')->all(); + + return self::$map[$legacyServerId] ?? null; + } +} diff --git a/app/Integrations/Domain/Support/DomainUserResolver.php b/app/Integrations/Domain/Support/DomainUserResolver.php new file mode 100644 index 0000000..ca26a3d --- /dev/null +++ b/app/Integrations/Domain/Support/DomainUserResolver.php @@ -0,0 +1,57 @@ + source user id => customer id */ + private static array $cache = []; + + public function resolve(int $sourceUserId): ?int + { + if (array_key_exists($sourceUserId, self::$cache)) { + return self::$cache[$sourceUserId]; + } + + $user = DB::connection('domain')->table('users')->where('id', $sourceUserId)->first(); + if (! $user) { + return self::$cache[$sourceUserId] = null; + } + + $email = strtolower($user->email); + $customer = DB::table('customers')->where('email', $email)->first(); + + if ($customer) { + DB::table('customers')->where('id', $customer->id)->update([ + 'username' => $customer->username ?? $user->username, + 'type' => $user->type, + ]); + + return self::$cache[$sourceUserId] = $customer->id; + } + + $customerId = DB::table('customers')->insertGetId([ + 'uuid' => (string) Str::uuid(), + 'fullname' => $user->name, + 'username' => $user->username, + 'email' => $email, + 'email_verified_at' => $user->email_verified_at, + // source hashes are Laravel bcrypt (self-contained, no APP_KEY needed), safe to copy as-is + 'password' => $user->password, + 'type' => $user->type, + 'status' => 1, + 'created_at' => $user->created_at, + 'updated_at' => $user->updated_at, + ]); + + return self::$cache[$sourceUserId] = $customerId; + } +} diff --git a/app/Integrations/Domain/Transformers/DnsDomainTransformer.php b/app/Integrations/Domain/Transformers/DnsDomainTransformer.php new file mode 100644 index 0000000..8477722 --- /dev/null +++ b/app/Integrations/Domain/Transformers/DnsDomainTransformer.php @@ -0,0 +1,37 @@ +servers->resolve((int) $row['dns_manager_server_id']), + isCreated: (int) $row['is_created'], + pleskId: $row['plesk_id'] !== null ? (int) $row['plesk_id'] : null, + createdAt: $this->nullableDate($row['created_at'] ?? null), + updatedAt: $this->nullableDate($row['updated_at'] ?? null), + ); + } + + private function nullableDate(?string $value): ?string + { + if (! $value || (int) substr($value, 0, 4) < 1970) { + return null; + } + + return $value; + } +} diff --git a/app/Integrations/Domain/Transformers/DnsDomainUserTransformer.php b/app/Integrations/Domain/Transformers/DnsDomainUserTransformer.php new file mode 100644 index 0000000..50c08eb --- /dev/null +++ b/app/Integrations/Domain/Transformers/DnsDomainUserTransformer.php @@ -0,0 +1,37 @@ +domains->resolve((int) $row['domain_id']), + customerId: $this->users->resolve((int) $row['user_id']), + createdAt: $this->nullableDate($row['created_at'] ?? null), + updatedAt: $this->nullableDate($row['updated_at'] ?? null), + ); + } + + private function nullableDate(?string $value): ?string + { + if (! $value || (int) substr($value, 0, 4) < 1970) { + return null; + } + + return $value; + } +} diff --git a/app/Integrations/Domain/Transformers/DnsRecordTransformer.php b/app/Integrations/Domain/Transformers/DnsRecordTransformer.php new file mode 100644 index 0000000..10c4e1c --- /dev/null +++ b/app/Integrations/Domain/Transformers/DnsRecordTransformer.php @@ -0,0 +1,44 @@ +domains->resolve((int) $row['dns_manager_domain_id']), + name: $row['name'] ?: null, + type: $row['type'] ?: 'A', + serviceName: $row['service_name'] ?: null, + protocol: $row['protocol'] ?: null, + priority: $row['priority'] ?? '0', + weight: $row['weight'] ?? '0', + port: $row['port'] ?? '0', + value: $row['value'] ?: null, + pleskId: $row['plesk_id'] !== null ? (int) $row['plesk_id'] : null, + ttl: (int) ($row['ttl'] ?: 300), + createdAt: $this->nullableDate($row['created_at'] ?? null), + updatedAt: $this->nullableDate($row['updated_at'] ?? null), + ); + } + + private function nullableDate(?string $value): ?string + { + if (! $value || (int) substr($value, 0, 4) < 1970) { + return null; + } + + return $value; + } +} diff --git a/app/Integrations/Domain/Transformers/DnsServerTransformer.php b/app/Integrations/Domain/Transformers/DnsServerTransformer.php new file mode 100644 index 0000000..b0d2267 --- /dev/null +++ b/app/Integrations/Domain/Transformers/DnsServerTransformer.php @@ -0,0 +1,35 @@ +nullableDate($row['created_at'] ?? null), + updatedAt: $this->nullableDate($row['updated_at'] ?? null), + ); + } + + private function nullableDate(?string $value): ?string + { + if (! $value || (int) substr($value, 0, 4) < 1970) { + return null; + } + + return $value; + } +} diff --git a/app/Integrations/Domain/Writers/DnsDomainUserWriter.php b/app/Integrations/Domain/Writers/DnsDomainUserWriter.php new file mode 100644 index 0000000..84fecc4 --- /dev/null +++ b/app/Integrations/Domain/Writers/DnsDomainUserWriter.php @@ -0,0 +1,30 @@ +domainId === null || $data->customerId === null) { + return false; + } + + DB::table('dns_domain_user')->updateOrInsert( + ['domain_id' => $data->domainId, 'user_id' => $data->customerId], + [ + 'created_at' => $data->createdAt, + 'updated_at' => $data->updatedAt, + ] + ); + + return true; + } +} diff --git a/app/Integrations/Domain/Writers/DnsDomainWriter.php b/app/Integrations/Domain/Writers/DnsDomainWriter.php new file mode 100644 index 0000000..0e47e02 --- /dev/null +++ b/app/Integrations/Domain/Writers/DnsDomainWriter.php @@ -0,0 +1,33 @@ +dnsManagerServerId === null) { + return false; + } + + DB::table('dns_manager_domain')->updateOrInsert( + ['legacy_id' => $data->legacyId], + [ + 'domain_name' => $data->domainName, + 'dns_manager_server_id' => $data->dnsManagerServerId, + 'is_created' => $data->isCreated, + 'plesk_id' => $data->pleskId, + 'created_at' => $data->createdAt, + 'updated_at' => $data->updatedAt, + ] + ); + + return true; + } +} diff --git a/app/Integrations/Domain/Writers/DnsRecordWriter.php b/app/Integrations/Domain/Writers/DnsRecordWriter.php new file mode 100644 index 0000000..44b1f02 --- /dev/null +++ b/app/Integrations/Domain/Writers/DnsRecordWriter.php @@ -0,0 +1,40 @@ +dnsManagerDomainId === null) { + return false; + } + + DB::table('dns_manager_ns')->updateOrInsert( + ['legacy_id' => $data->legacyId], + [ + 'dns_manager_domain_id' => $data->dnsManagerDomainId, + 'name' => $data->name, + 'type' => $data->type, + 'service_name' => $data->serviceName, + 'protocol' => $data->protocol, + 'priority' => $data->priority, + 'weight' => $data->weight, + 'port' => $data->port, + 'value' => $data->value, + 'plesk_id' => $data->pleskId, + 'ttl' => $data->ttl, + 'created_at' => $data->createdAt, + 'updated_at' => $data->updatedAt, + ] + ); + + return true; + } +} diff --git a/app/Integrations/Domain/Writers/DnsServerWriter.php b/app/Integrations/Domain/Writers/DnsServerWriter.php new file mode 100644 index 0000000..6821743 --- /dev/null +++ b/app/Integrations/Domain/Writers/DnsServerWriter.php @@ -0,0 +1,27 @@ +updateOrInsert( + ['legacy_id' => $data->legacyId], + [ + 'name' => $data->name, + 'ip_address' => $data->ipAddress, + 'username' => $data->username, + 'password' => $data->password, + 'api_key' => $data->apiKey, + 'status' => $data->status, + 'type' => 'domain', + 'created_at' => $data->createdAt, + 'updated_at' => $data->updatedAt, + ] + ); + } +} diff --git a/app/Integrations/Whmcs/DTOs/ClientData.php b/app/Integrations/Whmcs/DTOs/ClientData.php new file mode 100644 index 0000000..0979964 --- /dev/null +++ b/app/Integrations/Whmcs/DTOs/ClientData.php @@ -0,0 +1,27 @@ + $pricings */ + public function __construct( + public readonly int $legacyId, + public readonly ?int $categoryId, + public readonly string $name, + public readonly string $slug, + public readonly ?string $shortDescription, + public readonly ?string $description, + public readonly ?int $stock, + public readonly bool $isUnlimited, + public readonly float $setupFee, + public readonly bool $isActive, + public readonly int $sortOrder, + public readonly ?string $createdAt, + public readonly ?string $updatedAt, + public readonly array $pricings, + ) { + } +} diff --git a/app/Integrations/Whmcs/DTOs/ServiceData.php b/app/Integrations/Whmcs/DTOs/ServiceData.php new file mode 100644 index 0000000..a54f5a1 --- /dev/null +++ b/app/Integrations/Whmcs/DTOs/ServiceData.php @@ -0,0 +1,22 @@ +table('tbladdons')->orderBy('id')->cursor(); + } + + /** tblpricing row for this addon, if any (type='addon'). */ + public function pricing(int $addonId): ?object + { + return DB::connection('whmcs')->table('tblpricing') + ->where('type', 'addon') + ->where('relid', $addonId) + ->first(); + } +} diff --git a/app/Integrations/Whmcs/Extractors/ClientExtractor.php b/app/Integrations/Whmcs/Extractors/ClientExtractor.php new file mode 100644 index 0000000..40dcb89 --- /dev/null +++ b/app/Integrations/Whmcs/Extractors/ClientExtractor.php @@ -0,0 +1,14 @@ +table('tblclients')->orderBy('id')->cursor(); + } +} diff --git a/app/Integrations/Whmcs/Extractors/CustomerDomainExtractor.php b/app/Integrations/Whmcs/Extractors/CustomerDomainExtractor.php new file mode 100644 index 0000000..967fb5d --- /dev/null +++ b/app/Integrations/Whmcs/Extractors/CustomerDomainExtractor.php @@ -0,0 +1,14 @@ +table('tbldomains')->orderBy('id')->cursor(); + } +} diff --git a/app/Integrations/Whmcs/Extractors/InvoiceExtractor.php b/app/Integrations/Whmcs/Extractors/InvoiceExtractor.php new file mode 100644 index 0000000..ca50522 --- /dev/null +++ b/app/Integrations/Whmcs/Extractors/InvoiceExtractor.php @@ -0,0 +1,28 @@ +table('tblinvoices')->orderBy('id')->cursor(); + } + + /** + * Line items for a single invoice (tblinvoiceitems has no per-invoice cursor of its own, + * items are pulled per-invoice while its job runs). + */ + public function items(int $invoiceId): \Illuminate\Support\Collection + { + return DB::connection('whmcs')->table('tblinvoiceitems')->where('invoiceid', $invoiceId)->get(); + } + + public function orderIdFor(int $invoiceId): ?int + { + return DB::connection('whmcs')->table('tblorders')->where('invoiceid', $invoiceId)->value('id'); + } +} diff --git a/app/Integrations/Whmcs/Extractors/OrderExtractor.php b/app/Integrations/Whmcs/Extractors/OrderExtractor.php new file mode 100644 index 0000000..ad08c7b --- /dev/null +++ b/app/Integrations/Whmcs/Extractors/OrderExtractor.php @@ -0,0 +1,14 @@ +table('tblorders')->orderBy('id')->cursor(); + } +} diff --git a/app/Integrations/Whmcs/Extractors/OrderItemExtractor.php b/app/Integrations/Whmcs/Extractors/OrderItemExtractor.php new file mode 100644 index 0000000..52498e3 --- /dev/null +++ b/app/Integrations/Whmcs/Extractors/OrderItemExtractor.php @@ -0,0 +1,18 @@ +table('tblhosting')->orderBy('id')->cursor(); + } +} diff --git a/app/Integrations/Whmcs/Extractors/ProductExtractor.php b/app/Integrations/Whmcs/Extractors/ProductExtractor.php new file mode 100644 index 0000000..fefffda --- /dev/null +++ b/app/Integrations/Whmcs/Extractors/ProductExtractor.php @@ -0,0 +1,23 @@ +table('tblproducts')->orderBy('id')->cursor(); + } + + /** tblpricing row for this product, if any (type='product'). */ + public function pricing(int $productId): ?object + { + return DB::connection('whmcs')->table('tblpricing') + ->where('type', 'product') + ->where('relid', $productId) + ->first(); + } +} diff --git a/app/Integrations/Whmcs/Extractors/ProductGroupExtractor.php b/app/Integrations/Whmcs/Extractors/ProductGroupExtractor.php new file mode 100644 index 0000000..eeef4a7 --- /dev/null +++ b/app/Integrations/Whmcs/Extractors/ProductGroupExtractor.php @@ -0,0 +1,14 @@ +table('tblproductgroups')->orderBy('id')->cursor(); + } +} diff --git a/app/Integrations/Whmcs/Extractors/ServiceExtractor.php b/app/Integrations/Whmcs/Extractors/ServiceExtractor.php new file mode 100644 index 0000000..8b547e8 --- /dev/null +++ b/app/Integrations/Whmcs/Extractors/ServiceExtractor.php @@ -0,0 +1,14 @@ +table('tblhosting')->orderBy('id')->cursor(); + } +} diff --git a/app/Integrations/Whmcs/Support/CustomerIdResolver.php b/app/Integrations/Whmcs/Support/CustomerIdResolver.php new file mode 100644 index 0000000..a3d9785 --- /dev/null +++ b/app/Integrations/Whmcs/Support/CustomerIdResolver.php @@ -0,0 +1,37 @@ +|null whmcs client id => local customer id */ + private static ?array $map = null; + + public function resolve(int $whmcsClientId): ?int + { + self::$map ??= $this->build(); + + return self::$map[$whmcsClientId] ?? null; + } + + private function build(): array + { + $uuidsByWhmcsId = DB::connection('whmcs')->table('tblclients')->pluck('uuid', 'id'); + $customerIdsByUuid = DB::table('customers')->pluck('id', 'uuid'); + + $map = []; + foreach ($uuidsByWhmcsId as $whmcsId => $uuid) { + if (isset($customerIdsByUuid[$uuid])) { + $map[$whmcsId] = $customerIdsByUuid[$uuid]; + } + } + + return $map; + } +} diff --git a/app/Integrations/Whmcs/Support/DnsDomainLinker.php b/app/Integrations/Whmcs/Support/DnsDomainLinker.php new file mode 100644 index 0000000..3840908 --- /dev/null +++ b/app/Integrations/Whmcs/Support/DnsDomainLinker.php @@ -0,0 +1,53 @@ +whereRaw('LOWER(domain_name) = ?', [mb_strtolower($domainName)])->value('id'); + + if (! $domainId) { + $serverId = $this->canonicalServerId(); + if (! $serverId) { + return; + } + + $domainId = DB::table('dns_manager_domain')->insertGetId([ + 'domain_name' => $domainName, + 'dns_manager_server_id' => $serverId, + // not a real provisioned DNS zone, just a billing placeholder created from WHMCS + 'is_created' => 0, + 'registrar_id' => $registrarId, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } elseif ($registrarId !== null) { + // don't clobber an existing registrar with an unresolved (null) one from a later re-sync + DB::table('dns_manager_domain')->where('id', $domainId)->update(['registrar_id' => $registrarId]); + } + + DB::table('dns_domain_user')->updateOrInsert( + ['domain_id' => $domainId, 'user_id' => $customerId], + ['created_at' => now(), 'updated_at' => now()] + ); + } + + private function canonicalServerId(): ?int + { + return self::$canonicalServerId ??= DB::table('dns_manager_servers') + ->orderByRaw('CASE WHEN id = 1 THEN 0 ELSE 1 END') + ->orderBy('id') + ->value('id'); + } +} diff --git a/app/Integrations/Whmcs/Support/OrderIdResolver.php b/app/Integrations/Whmcs/Support/OrderIdResolver.php new file mode 100644 index 0000000..04eddcb --- /dev/null +++ b/app/Integrations/Whmcs/Support/OrderIdResolver.php @@ -0,0 +1,22 @@ +|null legacy order id => local order id */ + private static ?array $map = null; + + public function resolve(int $whmcsOrderId): ?int + { + self::$map ??= DB::table('orders')->whereNotNull('legacy_id')->pluck('id', 'legacy_id')->all(); + + return self::$map[$whmcsOrderId] ?? null; + } +} diff --git a/app/Integrations/Whmcs/Support/OrderItemIdResolver.php b/app/Integrations/Whmcs/Support/OrderItemIdResolver.php new file mode 100644 index 0000000..24ced88 --- /dev/null +++ b/app/Integrations/Whmcs/Support/OrderItemIdResolver.php @@ -0,0 +1,19 @@ +|null */ + private static ?array $map = null; + + public function resolve(int $legacyHostingId): ?int + { + self::$map ??= DB::table('order_items')->whereNotNull('legacy_id')->pluck('id', 'legacy_id')->all(); + + return self::$map[$legacyHostingId] ?? null; + } +} diff --git a/app/Integrations/Whmcs/Support/ProductCategoryIdResolver.php b/app/Integrations/Whmcs/Support/ProductCategoryIdResolver.php new file mode 100644 index 0000000..3d58b0e --- /dev/null +++ b/app/Integrations/Whmcs/Support/ProductCategoryIdResolver.php @@ -0,0 +1,24 @@ +|null */ + private static ?array $map = null; + + public function resolve(int $legacyGroupId): ?int + { + self::$map ??= DB::table('product_categories')->whereNotNull('legacy_id')->pluck('id', 'legacy_id')->all(); + + return self::$map[$legacyGroupId] ?? null; + } + + public function type(int $localCategoryId): ?string + { + return DB::table('product_categories')->where('id', $localCategoryId)->value('type'); + } +} diff --git a/app/Integrations/Whmcs/Support/ProductIdResolver.php b/app/Integrations/Whmcs/Support/ProductIdResolver.php new file mode 100644 index 0000000..936fba9 --- /dev/null +++ b/app/Integrations/Whmcs/Support/ProductIdResolver.php @@ -0,0 +1,27 @@ +|null */ + private static ?array $map = null; + + public function resolve(int $legacyProductId): ?int + { + self::$map ??= DB::table('products')->whereNotNull('legacy_id')->pluck('id', 'legacy_id')->all(); + + return self::$map[$legacyProductId] ?? null; + } + + public function categoryTypeOf(int $localProductId): ?string + { + return DB::table('products') + ->join('product_categories', 'product_categories.id', '=', 'products.product_categories_id') + ->where('products.id', $localProductId) + ->value('product_categories.type'); + } +} diff --git a/app/Integrations/Whmcs/Support/RegistrarIdResolver.php b/app/Integrations/Whmcs/Support/RegistrarIdResolver.php new file mode 100644 index 0000000..0c09876 --- /dev/null +++ b/app/Integrations/Whmcs/Support/RegistrarIdResolver.php @@ -0,0 +1,26 @@ +|null lowercase short_name => registrar id */ + private static ?array $map = null; + + public function resolve(string $whmcsRegistrar): ?int + { + $whmcsRegistrar = trim($whmcsRegistrar); + if ($whmcsRegistrar === '') { + return null; + } + + self::$map ??= DB::table('registrars')->pluck('id', 'short_name') + ->mapWithKeys(fn ($id, $shortName) => [mb_strtolower($shortName) => $id]) + ->all(); + + return self::$map[mb_strtolower($whmcsRegistrar)] ?? null; + } +} diff --git a/app/Integrations/Whmcs/Support/SlugUniquifier.php b/app/Integrations/Whmcs/Support/SlugUniquifier.php new file mode 100644 index 0000000..0f572b4 --- /dev/null +++ b/app/Integrations/Whmcs/Support/SlugUniquifier.php @@ -0,0 +1,23 @@ +where($column, $slug)->where('legacy_id', '!=', $legacyId)->exists()) { + $slug = "{$base}-" . ++$suffix; + } + + return $slug; + } +} diff --git a/app/Integrations/Whmcs/Transformers/ClientTransformer.php b/app/Integrations/Whmcs/Transformers/ClientTransformer.php new file mode 100644 index 0000000..5c8711e --- /dev/null +++ b/app/Integrations/Whmcs/Transformers/ClientTransformer.php @@ -0,0 +1,76 @@ +|null fullname => user id, cached for the process lifetime */ + private static ?array $salerIdsByName = null; + + /** + * Map a raw `tblclients` row (array or stdClass) to a ClientData DTO. + */ + public function transform(array|object $row): ClientData + { + $row = (array) $row; + + $fullname = trim(($row['firstname'] ?? '') . ' ' . ($row['lastname'] ?? '')); + if ($fullname === '') { + $fullname = $row['companyname'] ?: null; + } + + return new ClientData( + uuid: $row['uuid'], + fullname: $fullname, + company: $row['companyname'] ?: null, + address: trim(($row['address1'] ?? '') . ', ' . ($row['address2'] ?? ''), ', ') ?: null, + state: $row['state'] ?: null, + city: $row['city'] ?: null, + // ponytail: WHMCS repurposes the `postcode` column to store the salesperson's fullname + saler: $this->resolveSalerId($row['postcode'] ?? null), + country: $row['country'] ?: null, + phone: $this->formatPhone($row['phonenumber'] ?? null), + email: $row['email'] ? strtolower($row['email']) : null, + // ponytail: WHMCS hashes are not Laravel bcrypt, copied as-is; customer must reset password to log into portal + password: $row['password'] ?: null, + note: $row['notes'] ?: null, + credit: (int) round((float) $row['credit']), + status: $row['status'] === 'Active' ? 1 : 0, + emailMarketing: ((int) ($row['emailoptout'] ?? 0)) === 1 ? 0 : 1, + createdAt: $row['created_at'] ?: $row['datecreated'] ?: null, + updatedAt: $row['updated_at'] ?: $row['datecreated'] ?: null, + ); + } + + private function resolveSalerId(?string $fullname): ?int + { + if (! $fullname) { + return null; + } + + self::$salerIdsByName ??= DB::table('user')->pluck('id', 'fullname')->all(); + + return self::$salerIdsByName[$fullname] ?? null; + } + + /** + * "+84.93 613 68 14" -> "0936136814"; other country codes keep their digits as-is (e.g. "+62..." -> "62..."). + */ + private function formatPhone(?string $raw): ?string + { + if (! $raw) { + return null; + } + + $digits = preg_replace('/\D+/', '', $raw); + + if (str_starts_with($digits, '84')) { + $digits = '0' . substr($digits, 2); + } + + return $digits ?: null; + } +} diff --git a/app/Integrations/Whmcs/Transformers/CustomerDomainTransformer.php b/app/Integrations/Whmcs/Transformers/CustomerDomainTransformer.php new file mode 100644 index 0000000..8a867b9 --- /dev/null +++ b/app/Integrations/Whmcs/Transformers/CustomerDomainTransformer.php @@ -0,0 +1,65 @@ +whereRaw('LOWER(domain_name) = ?', [mb_strtolower($name)])->exists(); + $status = $liveInDns ? 1 : 2; + if (! $liveInDns) { + $notes[] = 'Không tìm thấy domain này trong DNS manager (dns_manager_domain).'; + } + + if ($row['registrar'] && ! $this->registrars->resolve($row['registrar'])) { + $notes[] = "Registrar WHMCS chưa map: {$row['registrar']}"; + } + + if ($row['additionalnotes']) { + $notes[] = $row['additionalnotes']; + } + + return new CustomerDomainData( + legacyId: (int) $row['id'], + customerId: $this->customers->resolve((int) $row['userid']), + orderId: $this->orders->resolve((int) $row['orderid']) ?? 0, + name: $name, + firstPayment: (int) round((float) $row['firstpaymentamount']), + nextPayment: (int) round((float) $row['recurringamount']), + nextDueDate: $this->toTimestamp($row['nextduedate'] ?? null), + nextInvoiceDate: $this->toTimestamp($row['nextinvoicedate'] ?? null), + registrarId: $row['registrar'] ? $this->registrars->resolve($row['registrar']) : null, + status: $status, + note: implode(' | ', $notes), + ); + } + + private function toTimestamp(?string $date): ?int + { + if (! $date || str_starts_with($date, '0000-00-00')) { + return null; + } + + return strtotime($date) ?: null; + } +} diff --git a/app/Integrations/Whmcs/Transformers/InvoiceItemTransformer.php b/app/Integrations/Whmcs/Transformers/InvoiceItemTransformer.php new file mode 100644 index 0000000..9bf0288 --- /dev/null +++ b/app/Integrations/Whmcs/Transformers/InvoiceItemTransformer.php @@ -0,0 +1,72 @@ +mapType($whmcsType), + description: $row['description'] ? substr($row['description'], 0, 500) : null, + unitPrice: $amount, + totalPrice: $amount, + productId: $this->resolveProductId($whmcsType, (int) $row['relid']), + ); + } + + /** + * Only 'Hosting' line items carry a resolvable product: relid -> tblhosting.id -> packageid -> products.legacy_id. + * Domain/Addon relids point at tbldomains/tblhostingaddons, which don't map onto the products table. + */ + private function resolveProductId(string $whmcsType, int $relid): ?int + { + if ($whmcsType !== 'Hosting' || $relid <= 0) { + return null; + } + + $packageId = DB::connection('whmcs')->table('tblhosting')->where('id', $relid)->value('packageid'); + + return $packageId ? $this->products->resolve((int) $packageId) : null; + } + + private function mapType(string $whmcsType): string + { + if ($whmcsType === 'Addon') { + return 'addon'; + } + + if (str_contains($whmcsType, 'Upgrade')) { + return 'upgrade'; + } + + return 'service'; + } +} diff --git a/app/Integrations/Whmcs/Transformers/InvoiceTransformer.php b/app/Integrations/Whmcs/Transformers/InvoiceTransformer.php new file mode 100644 index 0000000..ec437ea --- /dev/null +++ b/app/Integrations/Whmcs/Transformers/InvoiceTransformer.php @@ -0,0 +1,86 @@ +nullableDate($row['duedate'] ?? null); + $whmcsOrderId = $this->extractor->orderIdFor($legacyId); + + $rawItems = $this->extractor->items($legacyId); + + // WHMCS records discounts as negative-amount line items; invoice_items.unit_price + // is unsigned, so fold them into the header's discount_amount instead. + $discountAmount = (int) round(-$rawItems->sum(fn ($item) => min(0, (float) $item->amount))); + + $items = $rawItems + ->map(fn ($item) => $this->itemTransformer->transform($item)) + ->filter() + ->all(); + + return new InvoiceData( + legacyId: $legacyId, + invoiceNumber: $row['invoicenum'] ?: "WHMCS-{$legacyId}", + customerId: $this->customers->resolve((int) $row['userid']), + orderId: $whmcsOrderId ? $this->orders->resolve($whmcsOrderId) : null, + date: $this->nullableDate($row['date'] ?? null), + subTotal: (int) round((float) $row['subtotal']), + discountAmount: $discountAmount, + tax: (int) round((float) $row['tax'] + (float) $row['tax2']), + total: (int) round((float) $row['total']), + status: $this->mapStatus($row['status'], $dueDate), + paymentMethod: $row['paymentmethod'] ?: null, + dueDate: $dueDate, + paidAt: $this->nullableDate($row['datepaid'] ?? null), + note: $row['notes'] ?: null, + createdAt: $this->nullableDate($row['created_at'] ?? null), + items: $items, + ); + } + + /** + * invoices.status: 1=unpaid, 2=paid, 3=cancelled, 4=overdue, 5=refunded. + */ + private function mapStatus(string $status, ?string $dueDate): int + { + return match ($status) { + 'Paid' => 2, + 'Cancelled' => 3, + 'Refunded' => 5, + 'Collections' => 4, + default => ($dueDate && Carbon::parse($dueDate)->isPast()) ? 4 : 1, + }; + } + + /** + * WHMCS has zero-dates ('0000-00-00...') and, in this data, at least one bogus + * year (e.g. '0004-04-22') — both invalid for MySQL strict mode, so nullify them. + */ + private function nullableDate(?string $value): ?string + { + if (! $value || (int) substr($value, 0, 4) < 1970) { + return null; + } + + return $value; + } +} diff --git a/app/Integrations/Whmcs/Transformers/OrderItemTransformer.php b/app/Integrations/Whmcs/Transformers/OrderItemTransformer.php new file mode 100644 index 0000000..a015d9a --- /dev/null +++ b/app/Integrations/Whmcs/Transformers/OrderItemTransformer.php @@ -0,0 +1,75 @@ + [billing_cycle, months] */ + private const CYCLES = [ + 'Monthly' => ['monthly', 1], + 'Quarterly' => ['quarterly', 3], + 'Semi-Annually' => ['semi_annually', 6], + 'Annually' => ['annually', 12], + 'Biennially' => ['biennially', 24], + 'Triennially' => ['triennially', 36], + ]; + + public function __construct( + private OrderIdResolver $orders, + private ProductIdResolver $products, + ) { + } + + public function transform(array|object $row): OrderItemData + { + $row = (array) $row; + [$billingCycle, $months] = self::CYCLES[$row['billingcycle']] ?? ['one_time', 0]; + + $productId = $this->products->resolve((int) $row['packageid']); + + return new OrderItemData( + legacyId: (int) $row['id'], + orderId: $this->orders->resolve((int) $row['orderid']), + productId: $productId, + productPricingId: $productId ? $this->productPricingId($productId, $billingCycle) : null, + serverName: $row['domain'] ?: null, + domainName: $row['domain'] ?: null, + billingCycle: $billingCycle, + months: $months, + quantity: (int) ($row['qty'] ?: 1), + price: $this->price($row), + createdAt: $this->nullableDate($row['created_at'] ?? null), + updatedAt: $this->nullableDate($row['updated_at'] ?? null), + ); + } + + /** One-time services carry their price in firstpaymentamount, not the (0.00) recurring amount. */ + private function price(array $row): int + { + $amount = (float) $row['amount']; + + return (int) round($amount > 0 ? $amount : (float) $row['firstpaymentamount']); + } + + private function productPricingId(int $productId, string $billingCycle): ?int + { + return DB::table('product_pricings') + ->where('product_id', $productId) + ->where('billing_cycle', $billingCycle) + ->value('id'); + } + + private function nullableDate(?string $value): ?string + { + if (! $value || (int) substr($value, 0, 4) < 1970) { + return null; + } + + return $value; + } +} diff --git a/app/Integrations/Whmcs/Transformers/OrderTransformer.php b/app/Integrations/Whmcs/Transformers/OrderTransformer.php new file mode 100644 index 0000000..cce1c1e --- /dev/null +++ b/app/Integrations/Whmcs/Transformers/OrderTransformer.php @@ -0,0 +1,57 @@ +customers->resolve((int) $row['userid']), + date: $this->nullableDate($row['date'] ?? null), + total: (int) round((float) $row['amount']), + status: $this->mapStatus($row['status']), + paymentMethod: $row['paymentmethod'] ?: null, + note: $row['notes'] ?: null, + clientIp: $row['ipaddress'] ?: null, + createdAt: $this->nullableDate($row['date'] ?? null), + ); + } + + /** + * orders.status: 0=pending, 1=completed, 2=cancelled, 3=processing. + */ + private function mapStatus(string $status): int + { + return match ($status) { + 'Active', 'Completed' => 1, + 'Cancelled', 'Fraud', 'Failed' => 2, + 'Pending Activation', 'In Progress' => 3, + default => 0, + }; + } + + /** + * WHMCS has zero-dates ('0000-00-00...') and occasional bogus years, both + * invalid for MySQL strict mode, so nullify anything before 1970. + */ + private function nullableDate(?string $value): ?string + { + if (! $value || (int) substr($value, 0, 4) < 1970) { + return null; + } + + return $value; + } +} diff --git a/app/Integrations/Whmcs/Transformers/ProductAddonTransformer.php b/app/Integrations/Whmcs/Transformers/ProductAddonTransformer.php new file mode 100644 index 0000000..84fa4db --- /dev/null +++ b/app/Integrations/Whmcs/Transformers/ProductAddonTransformer.php @@ -0,0 +1,84 @@ +extractor->pricing($legacyId); + $price = $this->firstPrice($pricingRow); + + return new ProductAddonData( + legacyId: $legacyId, + code: $this->slugs->unique('product_addon', 'code', $name, $legacyId), + name: $name, + // ponytail: WHMCS addons apply to a csv list of products across categories, not one type; + // best-effort guess from the first linked product's category, default 'vps' (column default) + type: $this->guessType($row['packages'] ?? ''), + price: $price, + status: ! $row['hidden'] && ! $row['retired'], + sortOrder: (int) ($row['weight'] ?? 0), + createdAt: $this->nullableDate($row['created_at'] ?? null), + updatedAt: $this->nullableDate($row['updated_at'] ?? null), + ); + } + + private function firstPrice(?object $pricingRow): int + { + if (! $pricingRow) { + return 0; + } + + foreach (['monthly', 'quarterly', 'semiannually', 'annually', 'biennially', 'triennially'] as $column) { + $price = (float) $pricingRow->{$column}; + if ($price >= 0) { + return (int) round($price); + } + } + + return 0; + } + + private function guessType(string $packagesCsv): string + { + $firstLegacyProductId = (int) strtok(trim($packagesCsv, ','), ','); + + if ($firstLegacyProductId > 0) { + $localProductId = $this->products->resolve($firstLegacyProductId); + if ($localProductId) { + $type = $this->products->categoryTypeOf($localProductId); + if ($type) { + return $type; + } + } + } + + return 'vps'; + } + + private function nullableDate(?string $value): ?string + { + if (! $value || (int) substr($value, 0, 4) < 1970) { + return null; + } + + return $value; + } +} diff --git a/app/Integrations/Whmcs/Transformers/ProductCategoryTransformer.php b/app/Integrations/Whmcs/Transformers/ProductCategoryTransformer.php new file mode 100644 index 0000000..fc2982e --- /dev/null +++ b/app/Integrations/Whmcs/Transformers/ProductCategoryTransformer.php @@ -0,0 +1,63 @@ +slugs->unique('product_categories', 'slug', $row['slug'] ?: $name, $legacyId), + type: $this->guessType($name), + isActive: ! $row['hidden'], + sortOrder: (int) $row['order'], + createdAt: $this->nullableDate($row['created_at'] ?? null), + updatedAt: $this->nullableDate($row['updated_at'] ?? null), + ); + } + + /** + * WHMCS product groups have no category "type" of their own — this is a best-effort + * keyword guess against the enum product_categories.type uses. Verify/adjust manually. + */ + private function guessType(string $name): ?string + { + $name = Str::upper($name); + + return match (true) { + str_contains($name, 'DOMAIN'), str_contains($name, 'TÊN MIỀN') => 'domain', + str_contains($name, 'SSL') => 'ssl', + str_contains($name, 'EMAIL') => 'email', + str_contains($name, 'THIẾT KẾ'), str_contains($name, 'WEB DESIGN') => 'web_design', + str_contains($name, 'HARDWARE') => 'hardware', + str_contains($name, 'IDC'), str_contains($name, 'DEDICATED'), str_contains($name, 'CHỖ ĐẶT'), str_contains($name, 'COLO') => 'server_rental', + str_contains($name, 'HOSTING') => 'hosting', + str_contains($name, 'VPS') => 'vps', + str_contains($name, 'CLOUD') => 'cloud', + default => null, + }; + } + + private function nullableDate(?string $value): ?string + { + if (! $value || (int) substr($value, 0, 4) < 1970) { + return null; + } + + return $value; + } +} diff --git a/app/Integrations/Whmcs/Transformers/ProductTransformer.php b/app/Integrations/Whmcs/Transformers/ProductTransformer.php new file mode 100644 index 0000000..ef2be54 --- /dev/null +++ b/app/Integrations/Whmcs/Transformers/ProductTransformer.php @@ -0,0 +1,97 @@ + product_pricings.billing_cycle */ + private const CYCLES = [ + 'monthly' => 'monthly', + 'quarterly' => 'quarterly', + 'semiannually' => 'semi_annually', + 'annually' => 'annually', + 'biennially' => 'biennially', + 'triennially' => 'triennially', + ]; + + /** matching tblpricing setup-fee column for each cycle above */ + private const SETUP_FEE_COLUMNS = [ + 'monthly' => 'msetupfee', + 'quarterly' => 'qsetupfee', + 'semiannually' => 'ssetupfee', + 'annually' => 'asetupfee', + 'biennially' => 'bsetupfee', + 'triennially' => 'tsetupfee', + ]; + + public function __construct( + private ProductCategoryIdResolver $categories, + private ProductExtractor $extractor, + private SlugUniquifier $slugs, + ) { + } + + public function transform(array|object $row): ProductData + { + $row = (array) $row; + $legacyId = (int) $row['id']; + $name = $row['name'] ?: "WHMCS Product {$legacyId}"; + + $pricingRow = $this->extractor->pricing($legacyId); + [$pricings, $setupFee] = $this->buildPricings($pricingRow); + + return new ProductData( + legacyId: $legacyId, + categoryId: $this->categories->resolve((int) $row['gid']), + name: $name, + slug: $this->slugs->unique('products', 'slug', $row['slug'] ?: $name, $legacyId), + shortDescription: $row['short_description'] ?: null, + description: $row['description'] ?: null, + stock: $row['stockcontrol'] ? (int) $row['qty'] : null, + isUnlimited: ! $row['stockcontrol'], + setupFee: $setupFee, + isActive: ! $row['hidden'], + sortOrder: (int) $row['order'], + createdAt: $this->nullableDate($row['created_at'] ?? null), + updatedAt: $this->nullableDate($row['updated_at'] ?? null), + pricings: $pricings, + ); + } + + /** @return array{0: array, 1: float} */ + private function buildPricings(?object $pricingRow): array + { + if (! $pricingRow) { + return [[], 0.0]; + } + + $pricings = []; + $setupFee = null; + + foreach (self::CYCLES as $column => $billingCycle) { + $price = (float) $pricingRow->{$column}; + if ($price < 0) { + continue; + } + + $pricings[] = ['billing_cycle' => $billingCycle, 'price' => $price]; + $setupFee ??= (float) $pricingRow->{self::SETUP_FEE_COLUMNS[$column]}; + } + + return [$pricings, $setupFee ?? 0.0]; + } + + private function nullableDate(?string $value): ?string + { + if (! $value || (int) substr($value, 0, 4) < 1970) { + return null; + } + + return $value; + } +} diff --git a/app/Integrations/Whmcs/Transformers/ServiceTransformer.php b/app/Integrations/Whmcs/Transformers/ServiceTransformer.php new file mode 100644 index 0000000..c2bb019 --- /dev/null +++ b/app/Integrations/Whmcs/Transformers/ServiceTransformer.php @@ -0,0 +1,62 @@ +customers->resolve((int) $row['userid']), + productId: $this->products->resolve((int) $row['packageid']), + legacyProductId: (int) $row['packageid'], + orderItemId: $this->orderItems->resolve($legacyId), + serverName: $row['domain'] ?: null, + status: $this->mapStatus($row['domainstatus']), + autoRenew: $row['billingcycle'] !== 'One Time', + startedAt: $this->nullableDate($row['regdate'] ?? null), + expiresAt: $this->nullableDate($row['nextduedate'] ?? null), + cancelledAt: $this->nullableDate($row['termination_date'] ?? null), + note: $row['notes'] ?: null, + ); + } + + /** + * services.status: pending, active, suspended, expired, cancelled, terminated. + */ + private function mapStatus(string $whmcsStatus): string + { + return match ($whmcsStatus) { + 'Active', 'Completed' => 'active', + 'Suspended' => 'suspended', + 'Terminated' => 'terminated', + 'Cancelled', 'Fraud' => 'cancelled', + default => 'pending', + }; + } + + private function nullableDate(?string $value): ?string + { + if (! $value || (int) substr($value, 0, 4) < 1970) { + return null; + } + + return $value; + } +} diff --git a/app/Integrations/Whmcs/WhmcsServiceProvider.php b/app/Integrations/Whmcs/WhmcsServiceProvider.php new file mode 100644 index 0000000..e69de29 diff --git a/app/Integrations/Whmcs/Writers/ClientWriter.php b/app/Integrations/Whmcs/Writers/ClientWriter.php new file mode 100644 index 0000000..fbd4f79 --- /dev/null +++ b/app/Integrations/Whmcs/Writers/ClientWriter.php @@ -0,0 +1,37 @@ +updateOrInsert( + ['uuid' => $data->uuid], + [ + 'fullname' => $data->fullname, + 'company' => $data->company, + 'address' => $data->address, + 'state' => $data->state, + 'city' => $data->city, + 'saler' => $data->saler, + 'country' => $data->country, + 'phone' => $data->phone, + 'email' => $data->email, + 'password' => $data->password, + 'note' => $data->note, + 'credit' => $data->credit, + 'status' => $data->status, + 'email_marketing' => $data->emailMarketing, + 'created_at' => $data->createdAt, + 'updated_at' => $data->updatedAt, + ] + ); + } +} diff --git a/app/Integrations/Whmcs/Writers/CustomerDomainWriter.php b/app/Integrations/Whmcs/Writers/CustomerDomainWriter.php new file mode 100644 index 0000000..bcc2fd0 --- /dev/null +++ b/app/Integrations/Whmcs/Writers/CustomerDomainWriter.php @@ -0,0 +1,46 @@ +customerId === null) { + return false; + } + + DB::table('module_domain')->updateOrInsert( + ['legacy_id' => $data->legacyId], + [ + 'name' => $data->name, + 'order_id' => $data->orderId, + 'first_payment' => $data->firstPayment, + 'module_dns_id' => 0, + 'next_payment' => $data->nextPayment, + 'next_due_date' => $data->nextDueDate, + 'next_invoice_date' => $data->nextInvoiceDate, + 'customer_id' => $data->customerId, + 'domain_type_id' => 0, + 'registrar_id' => $data->registrarId, + 'note' => $data->note, + 'status' => $data->status, + ] + ); + + $this->dnsLinker->link($data->name, $data->customerId, $data->registrarId); + + return true; + } +} diff --git a/app/Integrations/Whmcs/Writers/InvoiceWriter.php b/app/Integrations/Whmcs/Writers/InvoiceWriter.php new file mode 100644 index 0000000..4390fcd --- /dev/null +++ b/app/Integrations/Whmcs/Writers/InvoiceWriter.php @@ -0,0 +1,60 @@ +customerId === null) { + return; + } + + DB::transaction(function () use ($data) { + DB::table('invoices')->updateOrInsert( + ['legacy_id' => $data->legacyId], + [ + 'invoice_number' => $data->invoiceNumber, + 'date' => $data->date, + 'customer_id' => $data->customerId, + 'order_id' => $data->orderId, + 'sub_total' => $data->subTotal, + 'discount_amount' => $data->discountAmount, + 'tax' => $data->tax, + 'total' => $data->total, + 'status' => $data->status, + 'payment_method' => $data->paymentMethod, + 'due_date' => $data->dueDate, + 'paid_at' => $data->paidAt, + 'note' => $data->note, + 'created_at' => $data->createdAt, + 'updated_at' => $data->createdAt, + ] + ); + + $invoiceId = DB::table('invoices')->where('legacy_id', $data->legacyId)->value('id'); + + foreach ($data->items as $item) { + DB::table('invoice_items')->updateOrInsert( + ['legacy_id' => $item->legacyId], + [ + 'invoice_id' => $invoiceId, + 'product_id' => $item->productId, + 'type' => $item->type, + 'description' => $item->description, + 'quantity' => 1, + 'unit_price' => $item->unitPrice, + 'total_price' => $item->totalPrice, + ] + ); + } + }); + } +} diff --git a/app/Integrations/Whmcs/Writers/OrderItemWriter.php b/app/Integrations/Whmcs/Writers/OrderItemWriter.php new file mode 100644 index 0000000..d734801 --- /dev/null +++ b/app/Integrations/Whmcs/Writers/OrderItemWriter.php @@ -0,0 +1,39 @@ +orderId === null) { + return false; + } + + DB::table('order_items')->updateOrInsert( + ['legacy_id' => $data->legacyId], + [ + 'order_id' => $data->orderId, + 'product_id' => $data->productId, + 'product_pricing_id' => $data->productPricingId, + 'server_name' => $data->serverName, + 'domain_name' => $data->domainName, + 'billing_cycle' => $data->billingCycle, + 'months' => $data->months, + 'quantity' => $data->quantity, + 'price' => $data->price, + 'created_at' => $data->createdAt, + 'updated_at' => $data->updatedAt, + ] + ); + + return true; + } +} diff --git a/app/Integrations/Whmcs/Writers/OrderWriter.php b/app/Integrations/Whmcs/Writers/OrderWriter.php new file mode 100644 index 0000000..bb8f31e --- /dev/null +++ b/app/Integrations/Whmcs/Writers/OrderWriter.php @@ -0,0 +1,29 @@ +updateOrInsert( + ['legacy_id' => $data->legacyId], + [ + 'order_num' => $data->orderNum, + 'date' => $data->date, + 'customer_id' => $data->customerId, + 'sub_total' => $data->total, + 'total' => $data->total, + 'status' => $data->status, + 'payment_method' => $data->paymentMethod, + 'note' => $data->note, + 'client_ip' => $data->clientIp, + 'created_at' => $data->createdAt, + 'updated_at' => $data->createdAt, + ] + ); + } +} diff --git a/app/Integrations/Whmcs/Writers/ProductAddonWriter.php b/app/Integrations/Whmcs/Writers/ProductAddonWriter.php new file mode 100644 index 0000000..22ae526 --- /dev/null +++ b/app/Integrations/Whmcs/Writers/ProductAddonWriter.php @@ -0,0 +1,26 @@ +updateOrInsert( + ['legacy_id' => $data->legacyId], + [ + 'code' => $data->code, + 'name' => $data->name, + 'type' => $data->type, + 'price' => $data->price, + 'status' => $data->status, + 'sort_order' => $data->sortOrder, + 'created_at' => $data->createdAt, + 'updated_at' => $data->updatedAt, + ] + ); + } +} diff --git a/app/Integrations/Whmcs/Writers/ProductCategoryWriter.php b/app/Integrations/Whmcs/Writers/ProductCategoryWriter.php new file mode 100644 index 0000000..50623a0 --- /dev/null +++ b/app/Integrations/Whmcs/Writers/ProductCategoryWriter.php @@ -0,0 +1,25 @@ +updateOrInsert( + ['legacy_id' => $data->legacyId], + [ + 'name' => $data->name, + 'slug' => $data->slug, + 'type' => $data->type, + 'is_active' => $data->isActive, + 'sort_order' => $data->sortOrder, + 'created_at' => $data->createdAt, + 'updated_at' => $data->updatedAt, + ] + ); + } +} diff --git a/app/Integrations/Whmcs/Writers/ProductWriter.php b/app/Integrations/Whmcs/Writers/ProductWriter.php new file mode 100644 index 0000000..771380d --- /dev/null +++ b/app/Integrations/Whmcs/Writers/ProductWriter.php @@ -0,0 +1,55 @@ +categoryId === null) { + return false; + } + + DB::transaction(function () use ($data) { + $productType = DB::table('product_categories')->where('id', $data->categoryId)->value('type') ?? 'other'; + + DB::table('products')->updateOrInsert( + ['legacy_id' => $data->legacyId], + [ + 'uuid' => DB::table('products')->where('legacy_id', $data->legacyId)->value('uuid') ?? (string) Str::uuid(), + 'product_type' => $productType, + 'product_categories_id' => $data->categoryId, + 'name' => $data->name, + 'slug' => $data->slug, + 'short_description' => $data->shortDescription, + 'description' => $data->description, + 'stock' => $data->stock, + 'is_unlimited' => $data->isUnlimited, + 'setup_fee' => $data->setupFee, + 'is_active' => $data->isActive, + 'sort_order' => $data->sortOrder, + 'created_at' => $data->createdAt, + 'updated_at' => $data->updatedAt, + ] + ); + + $productId = DB::table('products')->where('legacy_id', $data->legacyId)->value('id'); + + foreach ($data->pricings as $pricing) { + DB::table('product_pricings')->updateOrInsert( + ['product_id' => $productId, 'billing_cycle' => $pricing['billing_cycle']], + ['price' => $pricing['price'], 'currency' => 'VND'] + ); + } + }); + + return true; + } +} diff --git a/app/Integrations/Whmcs/Writers/ServiceWriter.php b/app/Integrations/Whmcs/Writers/ServiceWriter.php new file mode 100644 index 0000000..e15a366 --- /dev/null +++ b/app/Integrations/Whmcs/Writers/ServiceWriter.php @@ -0,0 +1,41 @@ +customerId === null) { + return false; + } + + DB::table('services')->updateOrInsert( + ['legacy_id' => $data->legacyId], + [ + 'uuid' => DB::table('services')->where('legacy_id', $data->legacyId)->value('uuid') ?? (string) Str::uuid(), + 'customer_id' => $data->customerId, + 'product_id' => $data->productId, + 'legacy_product_id' => $data->legacyProductId, + 'order_item_id' => $data->orderItemId, + 'server_name' => $data->serverName, + 'status' => $data->status, + 'auto_renew' => $data->autoRenew, + 'started_at' => $data->startedAt, + 'expires_at' => $data->expiresAt, + 'cancelled_at' => $data->cancelledAt, + 'note' => $data->note, + ] + ); + + return true; + } +} diff --git a/app/Jobs/Mail/Customer/OrderCreatedJob.php b/app/Jobs/Mail/Customer/OrderCreatedJob.php index 92c5001..46c7789 100644 --- a/app/Jobs/Mail/Customer/OrderCreatedJob.php +++ b/app/Jobs/Mail/Customer/OrderCreatedJob.php @@ -2,15 +2,14 @@ namespace App\Jobs\Mail\Customer; -use App\Mail\Customer\OrderCreated; use App\Models\Template; +use App\Services\Email\EmailTemplateService; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\Log; -use Illuminate\Support\Facades\Mail; class OrderCreatedJob implements ShouldQueue { @@ -18,17 +17,15 @@ class OrderCreatedJob implements ShouldQueue public function __construct(public readonly array $data) {} - public function handle(): void + public function handle(EmailTemplateService $emailTemplateService): void { - $template = Template::where('name', 'Order Created')->where('status', 1)->first(); - - if (!$template) { - Log::warning('OrderCreatedJob: Template "Order Created" not found or inactive.'); + if (!Template::where('scenario', 'order_created')->where('status', 1)->exists()) { + Log::warning('OrderCreatedJob: Template "order_created" not found or inactive.'); return; } try { - Mail::to($this->data['customer_email'])->send(new OrderCreated($template, $this->data)); + $emailTemplateService->send('order_created', $this->data['customer_email'], $this->data); } catch (\Throwable $e) { Log::error('OrderCreatedJob: Failed to send email. ' . $e->getMessage(), [ 'customer_email' => $this->data['customer_email'], diff --git a/app/Jobs/Mail/SendTemplateEmailJob.php b/app/Jobs/Mail/SendTemplateEmailJob.php new file mode 100644 index 0000000..5fc13ef --- /dev/null +++ b/app/Jobs/Mail/SendTemplateEmailJob.php @@ -0,0 +1,42 @@ + $to + */ + public function __construct( + public readonly string $scenario, + public readonly array $to, + public readonly array $data = [], + ) {} + + public function handle(EmailTemplateService $service, EmailTemplateRenderer $renderer): void + { + $template = $service->loadTemplate($this->scenario); + $rendered = $renderer->render($template, $this->data); + + Mail::to($this->to)->send(new GenericTemplateMail( + $rendered['subject'], + $rendered['html'], + $template->mailConfig(), + )); + } +} diff --git a/app/Jobs/Payment/GetTransactionPayment.php b/app/Jobs/Payment/GetTransactionPayment.php index c283ade..366be2c 100644 --- a/app/Jobs/Payment/GetTransactionPayment.php +++ b/app/Jobs/Payment/GetTransactionPayment.php @@ -57,15 +57,15 @@ class GetTransactionPayment implements ShouldQueue foreach ($history as $item) { if (empty($item->tracingType) && str_contains($item['description'], $this->message)) { $transaction = str_replace("\\BNK", "", $item['refNo']); - $transCheck = Transaction::where('trans', $transaction)->first(); - if (!$transCheck) { - Transaction::create( - [ - 'trans' => $transaction, - 'amount' => (int)$item['creditAmount'] , - 'customer_id' => $this->user - ] - ); + + // firstOrCreate is atomic against the unique(customer_id, trans, type) index — + // a duplicate bank-statement poll (job retried, matched twice) cannot credit twice. + $created = Transaction::firstOrCreate( + ['trans' => $transaction, 'customer_id' => $this->user], + ['amount' => (int) $item['creditAmount']] + ); + + if ($created->wasRecentlyCreated) { Customer::find($this->user)->increment('credit', (int)$item['creditAmount']); Customer::find($this->user)->increment('total', (int)$item['creditAmount']); diff --git a/app/Jobs/VirtualMachine/ProcessOrderPaymentJob.php b/app/Jobs/VirtualMachine/ProcessOrderPaymentJob.php deleted file mode 100644 index 2dd937e..0000000 --- a/app/Jobs/VirtualMachine/ProcessOrderPaymentJob.php +++ /dev/null @@ -1,356 +0,0 @@ -order = $order; - $this->retryCount = $retryCount; - } - - /** - * Execute the job. - */ - public function handle(InvoiceService $invoiceService, Telegram $telegram): void - { - try { - $user = $this->order->customer; - $invoice = $this->order->invoice; - - if (!$user || !$invoice) { - Log::error("Không tìm thấy thông tin user hoặc invoice cho order #{$this->order->order_num}"); - return; - } - - // Kiểm tra credit - if ($user->credit < $this->order->amount) { - $errorMessage = "Không đủ credit để thanh toán đơn hàng #{$this->order->order_num}"; - Log::error($errorMessage, [ - 'user_credit' => $user->credit, - 'order_amount' => $this->order->amount - ]); - $telegram->SendErrorMessage($errorMessage, 'ProcessOrderPaymentJob - Insufficient Credit'); - return; - } - - /** - * OPTIMIZED PAYMENT FLOW - * - * Thay đổi: TRỪ TIỀN TRƯỚC → TẠO VM SAU - * - * Lý do: - * 1. User đã có credit trong hệ thống (đã nạp qua VNPay) - * 2. Nếu tạo VM fail → refund credit dễ dàng - * 3. Tránh tình huống: VM tạo ok nhưng chưa trừ tiền - * 4. Đảm bảo tính nhất quán: Đã trừ tiền = đã có order valid - */ - - DB::beginTransaction(); - - try { - // STEP 1: Trừ tiền người dùng - $user->decrement('credit', (int) $this->order->amount); - - // STEP 2: Cập nhật trạng thái hóa đơn và đơn hàng - $invoiceService->markAsPaid($invoice); - $this->order->update(['status' => OrderStatus::ACTIVE->value]); - - DB::commit(); - - Log::info("✓ Đã trừ credit và cập nhật order #{$this->order->order_num}"); - } catch (\Throwable $e) { - DB::rollBack(); - Log::error("Lỗi khi trừ credit cho order #{$this->order->order_num}: " . $e->getMessage()); - throw $e; - } - - // STEP 3: Tạo VM cho các sản phẩm VPS (sau khi đã trừ tiền) - $vmCreationResults = []; - $allVMsCreated = true; - $hasVMCreationAttempt = false; - - foreach ($this->order->orderItems as $item) { - try { - // Chỉ tạo VM cho sản phẩm VPS (product_type = 4) - if ($item->product_type == 4) { - $hasVMCreationAttempt = true; - - // Tạo VM và lấy kết quả - $vmCreationResult = $this->createVirtualMachine($item); - - $vmCreationResults[] = [ - 'order_item_id' => $item->id, - 'product_name' => $item->getProductAttribute()?->name ?? 'Unknown', - 'success' => $vmCreationResult - ]; - - if (!$vmCreationResult) { - $allVMsCreated = false; - } - } - } catch (\Throwable $e) { - Log::error("Lỗi khi tạo VM cho order item #{$item->id}: " . $e->getMessage()); - $vmCreationResults[] = [ - 'order_item_id' => $item->id, - 'product_name' => $item->getProductAttribute()?->name ?? 'Unknown', - 'success' => false, - 'error' => $e->getMessage() - ]; - $allVMsCreated = false; - } - } - - // STEP 4: Xử lý kết quả tạo VM - if (!$hasVMCreationAttempt) { - // Không có VPS nào trong order → OK, chỉ là Hosting/Domain - $telegram->SendOrderPaidNotification($invoice); - $this->sendSuccessNotification([]); - return; - } - - if (empty($vmCreationResults)) { - $errorMessage = "Không có kết quả tạo VM nào được trả về cho đơn hàng #{$this->order->order_num}"; - Log::error($errorMessage); - $this->handleVMCreationFailure($user, $invoice, $vmCreationResults, $telegram); - return; - } - - // Kiểm tra xem có ít nhất một VM thành công không - $successfulVMs = array_filter($vmCreationResults, fn($vm) => $vm['success']); - $failedVMs = array_filter($vmCreationResults, fn($vm) => !$vm['success']); - - // Nếu tất cả VM được tạo thành công - if ($allVMsCreated && count($successfulVMs) > 0) { - // Gửi thông báo thành công - $telegram->SendOrderPaidNotification($invoice); - $this->sendSuccessNotification($vmCreationResults); - - Log::info("✓ Hoàn tất order #{$this->order->order_num} - Tất cả VM đã được tạo"); - } else { - // Có lỗi khi tạo VM - $this->handleVMCreationFailure($user, $invoice, $vmCreationResults, $telegram); - } - } catch (\Throwable $e) { - Log::error("Lỗi nghiêm trọng trong ProcessOrderPaymentJob: " . $e->getMessage(), [ - 'order_id' => $this->order->id, - 'trace' => $e->getTraceAsString() - ]); - $telegram->SendErrorMessage( - "Lỗi nghiêm trọng khi xử lý order #{$this->order->order_num}: " . $e->getMessage(), - 'ProcessOrderPaymentJob - Critical Error' - ); - } - } - - /** - * Xử lý khi tạo VM thất bại - */ - private function handleVMCreationFailure($user, $invoice, $vmCreationResults, Telegram $telegram): void - { - $successfulVMs = array_filter($vmCreationResults, fn($vm) => $vm['success']); - $failedVMs = array_filter($vmCreationResults, fn($vm) => !$vm['success']); - - $errorDetails = [ - 'order_id' => $this->order->id, - 'order_num' => $this->order->order_num, - 'vm_results' => $vmCreationResults, - 'total_vms' => count($vmCreationResults), - 'successful_vms' => count($successfulVMs), - 'failed_vms' => count($failedVMs), - ]; - - // Kiểm tra trường hợp đặc biệt: có VPS nhưng không có VM nào thành công - if (count($vmCreationResults) > 0 && count($successfulVMs) === 0) { - Log::error("Có sản phẩm VPS nhưng không có VM nào được tạo thành công", $errorDetails); - $telegram->SendErrorMessage(array_merge($errorDetails, [ - 'error' => 'Tất cả VM đều thất bại - không có VM nào thành công' - ]), 'ProcessOrderPaymentJob - All VMs Failed'); - - // REFUND CREDIT vì không tạo được VM nào - try { - DB::transaction(function () use ($user, $invoice) { - $user->increment('credit', (int) $this->order->amount); - $invoice->update(['status' => InvoiceStatus::UNPAID->value]); - $this->order->update(['status' => OrderStatus::PENDING->value]); - - Log::info("✓ Đã hoàn tiền cho order #{$this->order->order_num} do không tạo được VM"); - }); - } catch (\Throwable $e) { - Log::error("Lỗi khi hoàn tiền: " . $e->getMessage()); - } - } - - // Thử lại nếu chưa vượt quá số lần thử và có VM fail - if ($this->retryCount < self::MAX_RETRIES && count($failedVMs) > 0) { - // Kiểm tra nếu lỗi là timeout, tăng delay time - $isTimeoutError = $this->isTimeoutError($failedVMs); - $delayMinutes = $isTimeoutError ? 10 : 5; // Timeout chờ 10 phút, lỗi khác 5 phút - - Log::warning("Có lỗi khi tạo VM, thử lại lần " . ($this->retryCount + 1), array_merge($errorDetails, [ - 'is_timeout_error' => $isTimeoutError, - 'delay_minutes' => $delayMinutes - ])); - - // Thử lại với delay tùy theo loại lỗi - self::dispatch($this->order, $this->retryCount + 1) - ->delay(now()->addMinutes($delayMinutes)); - } else if (count($failedVMs) > 0) { - // Đã thử hết số lần, gửi thông báo lỗi - Log::error("Đã thử tạo VM {$this->retryCount} lần nhưng vẫn thất bại", $errorDetails); - - $telegram->SendErrorMessage(array_merge($errorDetails, [ - 'error' => 'Không thể tạo VM sau nhiều lần thử', - 'retry_count' => $this->retryCount - ]), 'ProcessOrderPaymentJob - VM Creation Failed After Retries'); - - // Gửi thông báo lỗi cho khách hàng - $this->sendFailureNotification($vmCreationResults); - } - } - - /** - * Tạo VM cho một order item - */ - private function createVirtualMachine(OrderItem $item): bool - { - try { - $vCenter = app(\App\Http\Services\vCenter::class); - $telegram = app(\App\Http\Services\Telegram::class); - - $vmJob = new CreateVirtualMachineJob($item, $this->retryCount > 0); - return $vmJob->handle($vCenter, $telegram); - - } catch (\Throwable $e) { - Log::error("Lỗi khi tạo VM cho order item #{$item->id}: " . $e->getMessage(), [ - 'order_item_id' => $item->id, - 'error' => $e->getMessage(), - 'trace' => $e->getTraceAsString() - ]); - return false; - } - } - - /** - * Send success notification to customer - */ - private function sendSuccessNotification(array $vmResults): void - { - try { - $customer = $this->order->customer; - - \App\Jobs\Mail\Customer\OrderPaymentSuccessJob::dispatch([ - 'customer_email' => $customer->email, - 'customer_name' => trim($customer->firstname . ' ' . $customer->lastname), - 'order_id' => $this->order->id, - 'order_num' => $this->order->order_num, - 'amount' => $this->order->amount, - 'vm_results' => $vmResults, - 'addons_summary' => $this->resolveAddonsSummary(), - ]); - } catch (\Throwable $e) { - Log::error('Không thể gửi email thông báo thành công: ' . $e->getMessage()); - } - } - - /** - * Resolve addon display rows from all order items' addons_meta. - * Returns array of [{name, qty, subtotal}] for email rendering. - * - * @return array - */ - private function resolveAddonsSummary(): array - { - $rows = []; - $this->order->load('orderItems'); - - foreach ($this->order->orderItems as $item) { - $meta = is_array($item->addons_meta) ? $item->addons_meta : []; - foreach ($meta as $entry) { - $addon = \App\Models\Addon::find($entry['addon_id'] ?? null); - if (!$addon) { - continue; - } - $qty = (int) ($entry['qty'] ?? 0); - $rows[] = [ - 'name' => $addon->name, - 'qty' => $qty, - 'subtotal' => (int) ($entry['subtotal'] ?? $addon->price * $qty), - ]; - } - } - - return $rows; - } - - /** - * Check if the error is a timeout error - */ - private function isTimeoutError(array $failedVMs): bool - { - foreach ($failedVMs as $vmResult) { - if (isset($vmResult['error'])) { - $errorMessage = strtolower($vmResult['error']); - - // Kiểm tra các dấu hiệu của timeout error - if (strpos($errorMessage, 'timeout') !== false || - strpos($errorMessage, 'curl error 28') !== false || - strpos($errorMessage, 'operation timed out') !== false || - strpos($errorMessage, 'connection timeout') !== false) { - return true; - } - } - } - return false; - } - - /** - * Send failure notification to customer - */ - private function sendFailureNotification(array $vmResults): void - { - try { - $customer = $this->order->customer; - - // Gửi email thông báo lỗi - \App\Jobs\Mail\Customer\VMCreationFailedJob::dispatch([ - 'customer_email' => $customer->email, - 'customer_name' => trim($customer->firstname . ' ' . $customer->lastname), - 'product_name' => 'Multiple Products', - 'order_id' => $this->order->id, - 'order_num' => $this->order->order_num, - 'error_message' => 'Không thể khởi tạo máy chủ ảo sau nhiều lần thử. Vui lòng liên hệ admin để được hỗ trợ.', - 'vm_results' => $vmResults - ]); - } catch (\Throwable $e) { - Log::error('Không thể gửi email thông báo lỗi: ' . $e->getMessage()); - } - } -} \ No newline at end of file diff --git a/app/Jobs/Whmcs/SyncClientJob.php b/app/Jobs/Whmcs/SyncClientJob.php new file mode 100644 index 0000000..158285c --- /dev/null +++ b/app/Jobs/Whmcs/SyncClientJob.php @@ -0,0 +1,28 @@ +write($transformer->transform($this->client)); + } +} diff --git a/app/Jobs/Whmcs/SyncInvoiceJob.php b/app/Jobs/Whmcs/SyncInvoiceJob.php new file mode 100644 index 0000000..49c9560 --- /dev/null +++ b/app/Jobs/Whmcs/SyncInvoiceJob.php @@ -0,0 +1,28 @@ +write($transformer->transform($this->invoice)); + } +} diff --git a/app/Jobs/Whmcs/SyncOrderJob.php b/app/Jobs/Whmcs/SyncOrderJob.php new file mode 100644 index 0000000..dd15bd1 --- /dev/null +++ b/app/Jobs/Whmcs/SyncOrderJob.php @@ -0,0 +1,28 @@ +write($transformer->transform($this->order)); + } +} diff --git a/app/Mail/Admin/AdminOrderNotification.php b/app/Mail/Admin/AdminOrderNotification.php index d6aa119..a7996bc 100644 --- a/app/Mail/Admin/AdminOrderNotification.php +++ b/app/Mail/Admin/AdminOrderNotification.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Mail\Admin; +use App\Mail\Concerns\RequiresDbTemplate; use Illuminate\Bus\Queueable; use Illuminate\Mail\Mailable; use Illuminate\Mail\Mailables\Content; @@ -12,23 +13,21 @@ use Illuminate\Queue\SerializesModels; class AdminOrderNotification extends Mailable { - use Queueable, SerializesModels; + use Queueable, SerializesModels, RequiresDbTemplate; public function __construct(public readonly array $data) {} public function envelope(): Envelope { return new Envelope( - subject: '[ADMIN] Đơn hàng mới #' . ($this->data['order_num'] ?? 'N/A') - . ' — ' . ($this->data['customer_email'] ?? ''), + subject: $this->renderFromTemplate('admin_order_notification', $this->vars())['subject'], ); } public function content(): Content { return new Content( - view: 'emails.admin.order-notification', - with: $this->data, + htmlString: $this->renderFromTemplate('admin_order_notification', $this->vars())['html'], ); } @@ -36,4 +35,33 @@ class AdminOrderNotification extends Mailable { return []; } + + private function vars(): array + { + $vmFields = ['ip' => '🌐 IP Address', 'username' => '👤 Username', 'password' => '🔑 Password']; + $vmRows = ''; + foreach ($vmFields as $field => $label) { + if (!empty($this->data[$field])) { + $vmRows .= '
' . $label . '' . e($this->data[$field]) . '
'; + } + } + + return [ + 'order_num' => $this->data['order_num'] ?? 'N/A', + 'created_at' => $this->data['created_at'] ?? now()->format('d/m/Y H:i:s'), + 'customer_name' => $this->data['customer_name'] ?? 'N/A', + 'customer_email' => $this->data['customer_email'] ?? 'N/A', + 'client_ip_row' => !empty($this->data['client_ip']) + ? 'IP đặt hàng' . e($this->data['client_ip']) . '' + : '', + 'product_name' => $this->data['product_name'] ?? 'N/A', + 'product_type' => $this->data['product_type'] ?? 'N/A', + 'billing_cycle' => $this->data['billing_cycle'] ?? 'N/A', + 'total_formatted' => number_format((float) ($this->data['total'] ?? 0), 0, ',', '.') . ' VNĐ', + 'vm_block' => $vmRows + ? '
' . $vmRows . '
' + : '', + 'company_name' => $this->data['company_name'] ?? 'Portal Admin', + ]; + } } diff --git a/app/Mail/Admin/DomainOrderNewNotification.php b/app/Mail/Admin/DomainOrderNewNotification.php index a1d975b..80ca470 100644 --- a/app/Mail/Admin/DomainOrderNewNotification.php +++ b/app/Mail/Admin/DomainOrderNewNotification.php @@ -2,6 +2,7 @@ namespace App\Mail\Admin; +use App\Mail\Concerns\RequiresDbTemplate; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Mail\Mailable; @@ -11,7 +12,7 @@ use Illuminate\Queue\SerializesModels; class DomainOrderNewNotification extends Mailable implements ShouldQueue { - use Queueable, SerializesModels; + use Queueable, SerializesModels, RequiresDbTemplate; public function __construct( protected array $data, @@ -20,19 +21,28 @@ class DomainOrderNewNotification extends Mailable implements ShouldQueue public function envelope(): Envelope { return new Envelope( - subject: "[ADMIN] Đơn đăng ký tên miền mới - {$this->data['domain_name']}", + subject: $this->renderFromTemplate('admin_domain_order_new', $this->vars())['subject'], ); } public function content(): Content { return new Content( - view: 'emails.layouts.notification', - with: [ - 'companyName' => $this->data['company_name'] ?? config('app.name'), - 'title' => 'Thông báo đơn đăng ký tên miền mới', - 'body' => view('emails.admin.domain-order-new', $this->data)->render(), - ], + htmlString: $this->renderFromTemplate('admin_domain_order_new', $this->vars())['html'], ); } + + private function vars(): array + { + return [ + 'company_name' => $this->data['company_name'] ?? config('app.name'), + 'domain_name' => $this->data['domain_name'] ?? '', + 'order_num' => $this->data['order_num'] ?? '', + 'order_total' => $this->data['order_total'] ?? '', + 'customer_name' => $this->data['customer_name'] ?? '', + 'customer_email' => $this->data['customer_email'] ?? '', + 'created_at' => $this->data['created_at'] ?? now()->format('d/m/Y H:i:s'), + 'admin_url' => $this->data['admin_url'] ?? '', + ]; + } } diff --git a/app/Mail/Admin/HostingAccountCreatedAdminNotification.php b/app/Mail/Admin/HostingAccountCreatedAdminNotification.php index 394c56c..3d1f71b 100644 --- a/app/Mail/Admin/HostingAccountCreatedAdminNotification.php +++ b/app/Mail/Admin/HostingAccountCreatedAdminNotification.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Mail\Admin; +use App\Mail\Concerns\RequiresDbTemplate; use Illuminate\Bus\Queueable; use Illuminate\Mail\Mailable; use Illuminate\Mail\Mailables\Content; @@ -12,22 +13,21 @@ use Illuminate\Queue\SerializesModels; class HostingAccountCreatedAdminNotification extends Mailable { - use Queueable, SerializesModels; + use Queueable, SerializesModels, RequiresDbTemplate; public function __construct(public readonly array $data) {} public function envelope(): Envelope { return new Envelope( - subject: '[ADMIN] Hosting account mới — ' . ($this->data['domain_name'] ?? 'N/A'), + subject: $this->renderFromTemplate('admin_hosting_account_created', $this->vars())['subject'], ); } public function content(): Content { return new Content( - view: 'emails.admin.hosting-account-created', - with: $this->data, + htmlString: $this->renderFromTemplate('admin_hosting_account_created', $this->vars())['html'], ); } @@ -35,4 +35,29 @@ class HostingAccountCreatedAdminNotification extends Mailable { return []; } + + private function vars(): array + { + $row = fn (string $label, ?string $value) => $value + ? '
' . $label . '' . e($value) . '
' + : ''; + + return [ + 'domain_name' => $this->data['domain_name'] ?? 'N/A', + 'created_at' => $this->data['created_at'] ?? now()->format('d/m/Y H:i:s'), + 'customer_name' => $this->data['customer_name'] ?? 'N/A', + 'customer_email' => $this->data['customer_email'] ?? 'N/A', + 'hosting_plan' => $this->data['hosting_plan'] ?? 'N/A', + 'server_name' => $this->data['server_name'] ?? 'N/A', + 'server_ip' => $this->data['server_ip'] ?? 'N/A', + 'plesk_client_id' => $this->data['plesk_client_id'] ?? 'N/A', + 'plesk_subscription_id' => $this->data['plesk_subscription_id'] ?? 'N/A', + 'ftp_username' => $this->data['ftp_username'] ?? 'N/A', + 'ftp_password_row' => $row('🔑 FTP Password', $this->data['ftp_password'] ?? null), + 'plesk_panel_url' => $this->data['plesk_panel_url'] ?? 'N/A', + 'plesk_login' => $this->data['plesk_login'] ?? 'N/A', + 'plesk_password_row' => $row('🔑 Password', $this->data['plesk_password'] ?? null), + 'company_name' => $this->data['company_name'] ?? 'Portal Admin', + ]; + } } diff --git a/app/Mail/Admin/ManualDepositPendingNotification.php b/app/Mail/Admin/ManualDepositPendingNotification.php index c4d8c00..737e051 100644 --- a/app/Mail/Admin/ManualDepositPendingNotification.php +++ b/app/Mail/Admin/ManualDepositPendingNotification.php @@ -2,6 +2,7 @@ namespace App\Mail\Admin; +use App\Mail\Concerns\RequiresDbTemplate; use Illuminate\Bus\Queueable; use Illuminate\Mail\Mailable; use Illuminate\Mail\Mailables\Content; @@ -10,22 +11,21 @@ use Illuminate\Queue\SerializesModels; class ManualDepositPendingNotification extends Mailable { - use Queueable, SerializesModels; + use Queueable, SerializesModels, RequiresDbTemplate; public function __construct(public array $data) {} public function envelope(): Envelope { return new Envelope( - subject: '[ADMIN] Yêu cầu nạp tiền chờ duyệt - ' . ($this->data['transaction_code'] ?? 'N/A'), + subject: $this->renderFromTemplate('admin_manual_deposit_pending', $this->vars())['subject'], ); } public function content(): Content { return new Content( - view: 'emails.admin.manual-deposit-pending', - with: $this->data, + htmlString: $this->renderFromTemplate('admin_manual_deposit_pending', $this->vars())['html'], ); } @@ -33,4 +33,25 @@ class ManualDepositPendingNotification extends Mailable { return []; } + + private function vars(): array + { + return [ + 'transaction_code' => $this->data['transaction_code'] ?? 'N/A', + 'created_at' => $this->data['created_at'] ?? now()->format('d/m/Y H:i:s'), + 'customer_name' => $this->data['customer_name'] ?? 'N/A', + 'customer_email' => $this->data['customer_email'] ?? 'N/A', + 'customer_phone_row' => !empty($this->data['customer_phone']) + ? 'Số điện thoại' . e($this->data['customer_phone']) . '' + : '', + 'client_ip_row' => !empty($this->data['client_ip']) + ? 'IP gửi yêu cầu' . e($this->data['client_ip']) . '' + : '', + 'amount_formatted' => number_format((float) ($this->data['amount'] ?? 0), 0, ',', '.') . ' VNĐ', + 'proof_row' => !empty($this->data['proof_image_url']) + ? 'Chứng từXem chứng từ chuyển khoản' + : '', + 'company_name' => $this->data['company_name'] ?? 'Portal Admin', + ]; + } } diff --git a/app/Mail/Admin/TicketAdminNotification.php b/app/Mail/Admin/TicketAdminNotification.php index cd51c92..c2e9323 100644 --- a/app/Mail/Admin/TicketAdminNotification.php +++ b/app/Mail/Admin/TicketAdminNotification.php @@ -2,33 +2,49 @@ namespace App\Mail\Admin; +use App\Mail\Concerns\RequiresDbTemplate; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Mail\Mailable; +use Illuminate\Mail\Mailables\Content; +use Illuminate\Mail\Mailables\Envelope; use Illuminate\Queue\SerializesModels; class TicketAdminNotification extends Mailable implements ShouldQueue { - use Queueable, SerializesModels; + use Queueable, SerializesModels, RequiresDbTemplate; public function __construct( public readonly mixed $ticket, public readonly mixed $customer, ) {} - public function build(): static + public function envelope(): Envelope { - return $this - ->subject("[ADMIN] Ticket mới #{$this->ticket->ticket_num} — {$this->customer->email}") - ->view('emails.admin.ticket-notification') - ->with([ - 'ticketNum' => $this->ticket->ticket_num, - 'subject' => $this->ticket->subject, - 'room' => $this->ticket->room, - 'description' => strip_tags($this->ticket->description), - 'customerName' => trim($this->customer->firstname . ' ' . $this->customer->lastname), - 'customerEmail' => $this->customer->email, - 'ticketUrl' => config('app.frontend_url') . '/tickets/' . $this->ticket->ticket_num, - ]); + return new Envelope( + subject: $this->renderFromTemplate('admin_ticket_notification', $this->vars())['subject'], + ); + } + + public function content(): Content + { + return new Content( + htmlString: $this->renderFromTemplate('admin_ticket_notification', $this->vars())['html'], + ); + } + + private function vars(): array + { + $description = strip_tags($this->ticket->description ?? ''); + + return [ + 'ticket_num' => $this->ticket->ticket_num, + 'subject_text' => $this->ticket->subject, + 'room' => $this->ticket->room ?? 'N/A', + 'customer_name' => trim($this->customer->firstname . ' ' . $this->customer->lastname) ?: 'N/A', + 'customer_email'=> $this->customer->email, + 'description' => mb_substr($description, 0, 500) . (mb_strlen($description) > 500 ? '…' : ''), + 'ticket_url' => config('app.frontend_url') . '/tickets/' . $this->ticket->ticket_num, + ]; } } diff --git a/app/Mail/Admin/TrialRegistrationRequest.php b/app/Mail/Admin/TrialRegistrationRequest.php index d1ae662..e610f3d 100644 --- a/app/Mail/Admin/TrialRegistrationRequest.php +++ b/app/Mail/Admin/TrialRegistrationRequest.php @@ -2,6 +2,7 @@ namespace App\Mail\Admin; +use App\Mail\Concerns\RequiresDbTemplate; use Illuminate\Bus\Queueable; use Illuminate\Mail\Mailable; use Illuminate\Mail\Mailables\Content; @@ -10,22 +11,21 @@ use Illuminate\Queue\SerializesModels; class TrialRegistrationRequest extends Mailable { - use Queueable, SerializesModels; + use Queueable, SerializesModels, RequiresDbTemplate; public function __construct(public array $data) {} public function envelope(): Envelope { return new Envelope( - subject: '[ADMIN] Yêu cầu dùng thử Cloud VPS - ' . ($this->data['customer_name'] ?? 'N/A'), + subject: $this->renderFromTemplate('admin_trial_registration_request', $this->vars())['subject'], ); } public function content(): Content { return new Content( - view: 'emails.admin.trial-registration-request', - with: $this->data, + htmlString: $this->renderFromTemplate('admin_trial_registration_request', $this->vars())['html'], ); } @@ -33,4 +33,22 @@ class TrialRegistrationRequest extends Mailable { return []; } + + private function vars(): array + { + return [ + 'customer_name' => $this->data['customer_name'] ?? 'N/A', + 'customer_email' => $this->data['customer_email'] ?? 'N/A', + 'customer_phone' => $this->data['customer_phone'] ?? 'N/A', + 'cpu' => $this->data['cpu'] ?? '-', + 'ram' => $this->data['ram'] ?? '-', + 'ssd' => $this->data['ssd'] ?? '-', + 'os' => $this->data['os'] ?? '-', + 'other_row' => !empty($this->data['other']) + ? 'Yêu cầu khác' . e($this->data['other']) . '' + : '', + 'company_name' => $this->data['company_name'] ?? 'Portal Admin', + 'created_at' => $this->data['created_at'] ?? now()->format('d/m/Y H:i:s'), + ]; + } } diff --git a/app/Mail/ChangePortRemote.php b/app/Mail/ChangePortRemote.php index b46c200..84cdc7d 100644 --- a/app/Mail/ChangePortRemote.php +++ b/app/Mail/ChangePortRemote.php @@ -2,8 +2,8 @@ namespace App\Mail; +use App\Mail\Concerns\RequiresDbTemplate; use Illuminate\Bus\Queueable; -use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Mail\Mailable; use Illuminate\Mail\Mailables\Content; use Illuminate\Mail\Mailables\Envelope; @@ -11,16 +11,32 @@ use Illuminate\Queue\SerializesModels; class ChangePortRemote extends Mailable { - use Queueable, SerializesModels; + use Queueable, SerializesModels, RequiresDbTemplate; public function __construct( public string $vmName, public string $newPort ) {} - public function build(): ChangePortRemote + public function envelope(): Envelope { - return $this->subject('Port VPS của bạn đã được thay đổi') - ->view('emails.vm_port_change'); + return new Envelope( + subject: $this->renderFromTemplate('vm_port_change', $this->vars())['subject'], + ); + } + + public function content(): Content + { + return new Content( + htmlString: $this->renderFromTemplate('vm_port_change', $this->vars())['html'], + ); + } + + private function vars(): array + { + return [ + 'vm_name' => $this->vmName, + 'new_port' => $this->newPort, + ]; } } diff --git a/app/Mail/Concerns/RequiresDbTemplate.php b/app/Mail/Concerns/RequiresDbTemplate.php new file mode 100644 index 0000000..3b12d1d --- /dev/null +++ b/app/Mail/Concerns/RequiresDbTemplate.php @@ -0,0 +1,21 @@ +dbRendered ??= app(EmailTemplateService::class)->preview($scenario, $vars); + } +} diff --git a/app/Mail/CreateHosting.php b/app/Mail/CreateHosting.php index 85773a3..272e77c 100644 --- a/app/Mail/CreateHosting.php +++ b/app/Mail/CreateHosting.php @@ -2,9 +2,8 @@ namespace App\Mail; -use App\Models\Template; +use App\Mail\Concerns\RequiresDbTemplate; use Illuminate\Bus\Queueable; -use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Mail\Mailable; use Illuminate\Mail\Mailables\Content; use Illuminate\Mail\Mailables\Envelope; @@ -12,86 +11,45 @@ use Illuminate\Queue\SerializesModels; class CreateHosting extends Mailable { - use Queueable, SerializesModels; + use Queueable, SerializesModels, RequiresDbTemplate; - protected $template = null; - protected $data; + /** @param int $template Không còn dùng — scenario cố định 'hosting_information'. Giữ tham số để không phá vỡ caller hiện tại. */ + public function __construct($template = 0, public $data = []) {} - /** - * Create a new message instance. - * - * @return void - */ - public function __construct($template = 0, $data = []) - { - $this->data = $data; - $this->template = Template::find($template); - } - - - /** - * Get the message envelope. - * - * @return \Illuminate\Mail\Mailables\Envelope - */ - public function envelope() + public function envelope(): Envelope { return new Envelope( - subject: str_replace("{\$product_name}", $this->data['product_name'], $this->template->subject), + subject: $this->renderFromTemplate('hosting_information', $this->vars())['subject'], ); } - /** - * Get the message content definition. - * - * @return \Illuminate\Mail\Mailables\Content - */ - public function content() + public function content(): Content { return new Content( - view: 'emails.template', - with: [ - 'template' => str_replace( - [ - "{\$company_name}", - "{\$company_logo}", - "{\$client_name}", - "{\$product_name}", - "{\$product_billing}", - "{\$product_price}", - "{\$service_host}", - "{\$service_username}", - "{\$service_password}", - "{\$service_password}", - "{\$ftp_account}", - "{\$ftp_password}", - ], - [ - $this->data['company_name'], - $this->data['company_logo'], - $this->data['client_name'], - $this->data['product_name'], - $this->data['product_billing'], - $this->data['product_price'], - $this->data['service_host'], - $this->data['service_username'], - $this->data['service_password'], - $this->data['service_due_exp'], - $this->data['ftp_account'], - $this->data['ftp_password'], - ], - $this->template->message) - ] + htmlString: $this->renderFromTemplate('hosting_information', $this->vars())['html'], ); } - /** - * Get the attachments for the message. - * - * @return array - */ - public function attachments() + public function attachments(): array { return []; } + + private function vars(): array + { + return [ + 'company_name' => $this->data['company_name'] ?? '', + 'company_logo' => $this->data['company_logo'] ?? '', + 'client_name' => $this->data['client_name'] ?? '', + 'product_name' => $this->data['product_name'] ?? '', + 'product_billing' => $this->data['product_billing'] ?? '', + 'product_price' => $this->data['product_price'] ?? '', + 'service_host' => $this->data['service_host'] ?? '', + 'service_username'=> $this->data['service_username'] ?? '', + 'service_password'=> $this->data['service_password'] ?? '', + 'service_due_exp' => $this->data['service_due_exp'] ?? '', + 'ftp_account' => $this->data['ftp_account'] ?? '', + 'ftp_password' => $this->data['ftp_password'] ?? '', + ]; + } } diff --git a/app/Mail/CreateVirtualMachine.php b/app/Mail/CreateVirtualMachine.php deleted file mode 100644 index df615d3..0000000 --- a/app/Mail/CreateVirtualMachine.php +++ /dev/null @@ -1,96 +0,0 @@ -data = $data; - $this->template = Template::find($template); - $this->invoicePdf = $invoicePdf; - $this->invoicePdfFileName = $invoicePdfFileName; - } - - public function envelope() - { - if (!$this->template) { - throw new \RuntimeException('Email template ID=2 (CreateVirtualMachine) not found in database.'); - } - - return new Envelope( - subject: str_replace("{\$os_name}", $this->data['os_name'] ?? '', $this->template->subject), - ); - } - - public function content() - { - if (!$this->template) { - throw new \RuntimeException('Email template ID=2 (CreateVirtualMachine) not found in database.'); - } - - return new Content( - view: 'emails.template', - with: [ - 'template' => str_replace( - [ - "{\$company_name}", - "{\$company_logo}", - "{\$client_name}", - "{\$product_name}", - "{\$product_billing}", - "{\$product_price}", - "{\$service_host}", - "{\$service_username}", - "{\$service_port}", - "{\$service_password}", - "{\$os_name}", - "{\$service_due_exp}" - ], - [ - $this->data['company_name'], - $this->data['company_logo'], - $this->data['client_name'], - $this->data['product_name'], - $this->data['product_billing'], - $this->data['product_price'], - $this->data['service_host'], - $this->data['service_username'], - $this->data['service_port'], - $this->data['service_password'], - $this->data['os_name'], - $this->data['service_due_exp'], - ], - $this->template->message) - ] - ); - } - - public function attachments(): array - { - if ($this->invoicePdf !== null && $this->invoicePdfFileName !== null) { - return [ - Attachment::fromData(fn () => $this->invoicePdf, $this->invoicePdfFileName) - ->withMime('application/pdf'), - ]; - } - - return []; - } -} diff --git a/app/Mail/Customer/CustomerPayment.php b/app/Mail/Customer/CustomerPayment.php index 0aacde9..fbafbda 100644 --- a/app/Mail/Customer/CustomerPayment.php +++ b/app/Mail/Customer/CustomerPayment.php @@ -2,7 +2,7 @@ namespace App\Mail\Customer; -use App\Models\Template; +use App\Mail\Concerns\RequiresDbTemplate; use Illuminate\Bus\Queueable; use Illuminate\Mail\Mailable; use Illuminate\Mail\Mailables\Content; @@ -11,59 +11,35 @@ use Illuminate\Queue\SerializesModels; class CustomerPayment extends Mailable { - use Queueable, SerializesModels; + use Queueable, SerializesModels, RequiresDbTemplate; - protected $template; - protected $data; + /** @param int $template Không còn dùng — scenario cố định 'customer_payment'. Giữ tham số để không phá vỡ caller hiện tại. */ + public function __construct($template = 0, public $data = []) {} - /** - * Create a new message instance. - */ - public function __construct($template = 0, $data = []) - { - $this->data = $data; - $this->template = Template::find($template); - } - - /** - * Get the message envelope. - */ public function envelope(): Envelope { return new Envelope( - subject: $this->template->subject, + subject: $this->renderFromTemplate('customer_payment', $this->vars())['subject'], ); } - /** - * Get the message content definition. - */ public function content(): Content { return new Content( - view: 'emails.template', - with: [ - 'template' => str_replace( - [ - "{\$company_name}", - "{\$company_logo}", - ], - [ - $this->data['company_name'], - $this->data['company_logo'], - ], - $this->template->message) - ] + htmlString: $this->renderFromTemplate('customer_payment', $this->vars())['html'], ); } - /** - * Get the attachments for the message. - * - * @return array - */ public function attachments(): array { return []; } + + private function vars(): array + { + return [ + 'company_name' => $this->data['company_name'] ?? '', + 'company_logo' => $this->data['company_logo'] ?? '', + ]; + } } diff --git a/app/Mail/Customer/DepositSuccess.php b/app/Mail/Customer/DepositSuccess.php index 83be6a4..b54d4a7 100644 --- a/app/Mail/Customer/DepositSuccess.php +++ b/app/Mail/Customer/DepositSuccess.php @@ -2,6 +2,7 @@ namespace App\Mail\Customer; +use App\Mail\Concerns\RequiresDbTemplate; use Illuminate\Bus\Queueable; use Illuminate\Mail\Mailable; use Illuminate\Mail\Mailables\Content; @@ -10,7 +11,7 @@ use Illuminate\Queue\SerializesModels; class DepositSuccess extends Mailable { - use Queueable, SerializesModels; + use Queueable, SerializesModels, RequiresDbTemplate; public array $data; @@ -22,29 +23,29 @@ class DepositSuccess extends Mailable public function envelope(): Envelope { return new Envelope( - subject: 'V/v: Thông báo nạp tiền thành công - ' . number_format($this->data['amount']) . ' VNĐ', + subject: $this->renderFromTemplate('customer_deposit_success', $this->vars())['subject'], ); } public function content(): Content { return new Content( - view: 'emails.customer.deposit-success', - with: [ - 'customerName' => $this->data['customer_name'] ?? 'Khách hàng', - 'transactionCode' => $this->data['transaction_code'] ?? 'N/A', - 'amount' => $this->data['amount'] ?? 0, - 'amountFormatted' => number_format($this->data['amount']) ?? '0', - 'credit' => $this->data['credit'] ?? 0, - 'creditFormatted' => number_format($this->data['credit']) ?? '0', - 'currentTotal' => $this->data['current_total'] ?? 0, - 'currentTotalFormatted' => number_format($this->data['current_total']) ?? '0', - 'currentCredit' => $this->data['current_credit'] ?? 0, - 'currentCreditFormatted' => number_format($this->data['current_credit']) ?? '0', - 'depositDate' => $this->data['deposit_date'] ?? now()->format('d/m/Y H:i:s'), - 'supportEmail' => config('mail.support_email', 'support@thegioiso.com'), - 'supportPhone' => config('mail.support_phone', '1900.xxxx'), - ], + htmlString: $this->renderFromTemplate('customer_deposit_success', $this->vars())['html'], ); } + + private function vars(): array + { + return [ + 'customer_name' => $this->data['customer_name'] ?? 'Khách hàng', + 'transaction_code' => $this->data['transaction_code'] ?? 'N/A', + 'amount_formatted' => number_format((float) ($this->data['amount'] ?? 0)), + 'credit_formatted' => number_format((float) ($this->data['credit'] ?? 0)), + 'current_total_formatted' => number_format((float) ($this->data['current_total'] ?? 0)), + 'current_credit_formatted' => number_format((float) ($this->data['current_credit'] ?? 0)), + 'deposit_date' => $this->data['deposit_date'] ?? now()->format('d/m/Y H:i:s'), + 'support_email' => config('mail.support_email', 'support@thegioiso.com'), + 'support_phone' => config('mail.support_phone', '1900.xxxx'), + ]; + } } diff --git a/app/Mail/Customer/DomainOrderReceivedNotification.php b/app/Mail/Customer/DomainOrderReceivedNotification.php index 77c0768..49619fd 100644 --- a/app/Mail/Customer/DomainOrderReceivedNotification.php +++ b/app/Mail/Customer/DomainOrderReceivedNotification.php @@ -2,6 +2,7 @@ namespace App\Mail\Customer; +use App\Mail\Concerns\RequiresDbTemplate; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Mail\Mailable; @@ -11,7 +12,7 @@ use Illuminate\Queue\SerializesModels; class DomainOrderReceivedNotification extends Mailable implements ShouldQueue { - use Queueable, SerializesModels; + use Queueable, SerializesModels, RequiresDbTemplate; public function __construct( protected array $data, @@ -20,19 +21,29 @@ class DomainOrderReceivedNotification extends Mailable implements ShouldQueue public function envelope(): Envelope { return new Envelope( - subject: "Đơn đăng ký tên miền {$this->data['domain_name']} đã được ghi nhận", + subject: $this->renderFromTemplate('domain_order_received', $this->vars())['subject'], ); } public function content(): Content { return new Content( - view: 'emails.layouts.notification', - with: [ - 'companyName' => $this->data['company_name'] ?? config('app.name'), - 'title' => 'Đơn đăng ký tên miền đã được ghi nhận', - 'body' => view('emails.customer.domain-order-received', $this->data)->render(), - ], + htmlString: $this->renderFromTemplate('domain_order_received', $this->vars())['html'], ); } + + private function vars(): array + { + return [ + 'customer_name' => $this->data['customer_name'] ?? 'Quý khách', + 'company_name' => $this->data['company_name'] ?? config('app.name'), + 'domain_name' => $this->data['domain_name'] ?? '', + 'order_num' => $this->data['order_num'] ?? '', + 'order_total' => $this->data['order_total'] ?? '', + 'created_at' => $this->data['created_at'] ?? '', + 'order_url' => $this->data['order_url'] ?? '', + 'support_email' => $this->data['support_email'] ?? 'support@tgs.com.vn', + 'support_phone' => $this->data['support_phone'] ?? '(028) 7309. 7379', + ]; + } } diff --git a/app/Mail/Customer/DomainOrderStatusMail.php b/app/Mail/Customer/DomainOrderStatusMail.php deleted file mode 100644 index 9a7f105..0000000 --- a/app/Mail/Customer/DomainOrderStatusMail.php +++ /dev/null @@ -1,44 +0,0 @@ -data['domain_name'] ?? '', $this->template->subject), - ); - } - - public function content(): Content - { - $message = str_replace( - ['{$company_name}', '{$company_logo}', '{$domain_name}', '{$client_name}', '{$order_num}', '{$reason}'], - [$this->data['company_name'] ?? '', $this->data['company_logo'] ?? '', $this->data['domain_name'] ?? '', $this->data['client_name'] ?? '', $this->data['order_num'] ?? '', $this->data['reason'] ?? ''], - $this->template->message - ); - - return new Content(view: 'emails.template', with: ['template' => $message]); - } - - public function attachments(): array - { - return []; - } -} diff --git a/app/Mail/Customer/HostingAccountCreated.php b/app/Mail/Customer/HostingAccountCreated.php index 0e38afa..0d62284 100644 --- a/app/Mail/Customer/HostingAccountCreated.php +++ b/app/Mail/Customer/HostingAccountCreated.php @@ -2,6 +2,7 @@ namespace App\Mail\Customer; +use App\Mail\Concerns\RequiresDbTemplate; use App\Models\Setting; use Illuminate\Bus\Queueable; use Illuminate\Mail\Mailable; @@ -11,40 +12,21 @@ use Illuminate\Queue\SerializesModels; class HostingAccountCreated extends Mailable { - use Queueable, SerializesModels; + use Queueable, SerializesModels, RequiresDbTemplate; public function __construct(public array $data) {} public function envelope(): Envelope { - $appName = $this->payload()['company_name'] ?? config('app.name', 'The Gioi So'); - return new Envelope( - subject: 'Hosting của bạn đã sẵn sàng — ' . ($this->data['domain_name'] ?? '') . ' — ' . $appName, + subject: $this->renderFromTemplate('hosting_account_created', $this->vars())['subject'], ); } public function content(): Content { - $data = $this->payload(); - return new Content( - view: 'emails.customer.hosting-account-created', - with: [ - 'appName' => $data['company_name'] ?? config('app.name', 'The Gioi So'), - 'fullName' => $data['customer_name'] ?? 'Quý khách', - 'domainName' => $data['domain_name'] ?? '', - 'hostingPlan' => $data['hosting_plan'] ?? '', - 'serverHost' => $data['server_host'] ?? '', - 'ftpUsername' => $data['ftp_username'] ?? '', - 'ftpPassword' => $data['ftp_password'] ?? '', - 'pleskLogin' => $data['plesk_login'] ?? '', - 'pleskPassword' => $data['plesk_password'] ?? '', - 'pleskPanelUrl' => $data['plesk_panel_url'] ?? '', - 'loginUrl' => $data['login_url'] ?? rtrim(config('app.frontend_url', ''), '/') . '/dang-nhap', - 'supportEmail' => $data['support_email'] ?? config('mail.support_email', 'support@thegioiso.com'), - 'supportPhone' => $data['support_phone'] ?? config('mail.support_phone', '1900.xxxx'), - ], + htmlString: $this->renderFromTemplate('hosting_account_created', $this->vars())['html'], ); } @@ -69,4 +51,40 @@ class HostingAccountCreated extends Mailable 'login_url' => rtrim(config('app.frontend_url', ''), '/') . '/dang-nhap', ], $this->data); } + + private function vars(): array + { + $data = $this->payload(); + + $row = fn (string $label, ?string $value) => $value + ? "{$label}{$value}" + : ''; + + $pleskBlock = ''; + if (!empty($data['plesk_login'])) { + $pleskBlock = '

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

' + . '' + . '' + . '' + . (!empty($data['plesk_password']) ? '' : '') + . '
Địa chỉ Panel:' . ($data['plesk_panel_url'] ?? '') . '
Tên đăng nhập:' . ($data['plesk_login'] ?? '') . '
Mật khẩu:' . $data['plesk_password'] . '
'; + } + + return [ + 'app_name' => $data['company_name'] ?? config('app.name', 'The Gioi So'), + 'full_name' => $data['customer_name'] ?? 'Quý khách', + 'domain_name' => $data['domain_name'] ?? '', + 'hosting_plan' => $data['hosting_plan'] ?? '', + 'server_host_row' => $row('Máy chủ / FTP Host:', $data['server_host'] ?? null), + 'ftp_username_row' => $row('Tài khoản FTP:', $data['ftp_username'] ?? null), + 'ftp_password_row' => $row('Mật khẩu FTP:', $data['ftp_password'] ?? null), + 'ftp_password_notice' => !empty($data['ftp_password']) + ? '

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

' + : '', + 'plesk_block' => $pleskBlock, + 'login_url' => $data['login_url'] ?? rtrim(config('app.frontend_url', ''), '/') . '/dang-nhap', + 'support_email' => $data['support_email'] ?? config('mail.support_email', 'support@thegioiso.com'), + 'support_phone' => $data['support_phone'] ?? config('mail.support_phone', '1900.xxxx'), + ]; + } } diff --git a/app/Mail/Customer/ManualCustomerEmail.php b/app/Mail/Customer/ManualCustomerEmail.php index ede4f7e..773dca7 100644 --- a/app/Mail/Customer/ManualCustomerEmail.php +++ b/app/Mail/Customer/ManualCustomerEmail.php @@ -3,12 +3,13 @@ namespace App\Mail\Customer; use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Mail\Mailable; use Illuminate\Mail\Mailables\Content; use Illuminate\Mail\Mailables\Envelope; use Illuminate\Queue\SerializesModels; -class ManualCustomerEmail extends Mailable +class ManualCustomerEmail extends Mailable implements ShouldQueue { use Queueable, SerializesModels; diff --git a/app/Mail/Customer/ManualDepositStatus.php b/app/Mail/Customer/ManualDepositStatus.php index 16f3245..2d7d8be 100644 --- a/app/Mail/Customer/ManualDepositStatus.php +++ b/app/Mail/Customer/ManualDepositStatus.php @@ -2,6 +2,7 @@ namespace App\Mail\Customer; +use App\Mail\Concerns\RequiresDbTemplate; use App\Models\Setting; use Illuminate\Bus\Queueable; use Illuminate\Mail\Mailable; @@ -11,40 +12,21 @@ use Illuminate\Queue\SerializesModels; class ManualDepositStatus extends Mailable { - use Queueable, SerializesModels; + use Queueable, SerializesModels, RequiresDbTemplate; public function __construct(public array $data) {} public function envelope(): Envelope { return new Envelope( - subject: $this->payload()['subject'] ?? 'Thông báo trạng thái nạp tiền', + subject: $this->renderFromTemplate('manual_deposit_status', $this->vars())['subject'], ); } public function content(): Content { - $data = $this->payload(); - return new Content( - view: 'emails.customer.manual-deposit-status', - with: [ - 'companyName' => $data['company_name'] ?? config('app.name', 'The Gioi So'), - 'companyLogo' => $data['company_logo'] ?? '', - 'customerName' => $data['customer_name'] ?? 'Quý khách', - 'transactionCode' => $data['transaction_code'] ?? 'N/A', - 'amount' => $data['amount'] ?? 0, - 'amountFormatted' => number_format((float) ($data['amount'] ?? 0), 0, ',', '.'), - 'statusLabel' => $data['status_label'] ?? 'Đang chờ duyệt', - 'statusColor' => $data['status_color'] ?? '#f16723', - 'statusBg' => $data['status_bg'] ?? '#fff7ed', - 'statusMessage' => $data['status_message'] ?? ($data['message'] ?? ''), - 'depositDate' => $data['deposit_date'] ?? now()->format('d/m/Y H:i:s'), - 'processedDate' => $data['processed_date'] ?? null, - 'portalUrl' => $data['portal_url'] ?? rtrim(env('FRONTEND_URL', config('app.url')), '/') . '/nap-tien', - 'supportEmail' => $data['support_email'] ?? config('mail.support_email', 'support@thegioiso.com'), - 'supportPhone' => $data['support_phone'] ?? config('mail.support_phone', '1900.xxxx'), - ], + htmlString: $this->renderFromTemplate('manual_deposit_status', $this->vars())['html'], ); } @@ -70,4 +52,28 @@ class ManualDepositStatus extends Mailable 'portal_url' => rtrim(env('FRONTEND_URL', config('app.url')), '/') . '/nap-tien', ], $this->data); } + + private function vars(): array + { + $data = $this->payload(); + $amount = (float) ($data['amount'] ?? 0); + + return [ + 'company_name' => $data['company_name'] ?? config('app.name', 'The Gioi So'), + 'customer_name' => $data['customer_name'] ?? 'Quý khách', + 'transaction_code' => $data['transaction_code'] ?? 'N/A', + 'amount_formatted' => number_format($amount, 0, ',', '.'), + 'status_label' => $data['status_label'] ?? 'Đang chờ duyệt', + 'status_color' => $data['status_color'] ?? '#f16723', + 'status_bg' => $data['status_bg'] ?? '#fff7ed', + 'status_message' => $data['status_message'] ?? ($data['message'] ?? ''), + 'deposit_date' => $data['deposit_date'] ?? now()->format('d/m/Y H:i:s'), + 'processed_date_row' => !empty($data['processed_date']) + ? 'Thời gian xử lý' . $data['processed_date'] . '' + : '', + 'portal_url' => $data['portal_url'] ?? rtrim(env('FRONTEND_URL', config('app.url')), '/') . '/nap-tien', + 'support_email' => $data['support_email'] ?? config('mail.support_email', 'support@thegioiso.com'), + 'support_phone' => $data['support_phone'] ?? config('mail.support_phone', '1900.xxxx'), + ]; + } } diff --git a/app/Mail/Customer/OrderCreated.php b/app/Mail/Customer/OrderCreated.php deleted file mode 100644 index 97f07aa..0000000 --- a/app/Mail/Customer/OrderCreated.php +++ /dev/null @@ -1,64 +0,0 @@ -template = $template; - $this->data = $data; - } - - public function envelope(): Envelope - { - return new Envelope( - subject: $this->template->subject, - ); - } - - public function content(): Content - { - $message = str_replace( - [ - '{\$company_name}', - '{\$company_logo}', - '{\$client_name}', - '{\$order_num}', - '{\$order_total}', - '{\$invoice_num}', - ], - [ - $this->data['company_name'], - $this->data['company_logo'], - $this->data['client_name'], - $this->data['order_num'], - $this->data['order_total'], - $this->data['invoice_num'], - ], - $this->template->message - ); - - return new Content( - view: 'emails.template', - with: ['template' => $message], - ); - } - - public function attachments(): array - { - return []; - } -} diff --git a/app/Mail/Customer/OrderPaymentSuccess.php b/app/Mail/Customer/OrderPaymentSuccess.php index 0163f41..94bc5b8 100644 --- a/app/Mail/Customer/OrderPaymentSuccess.php +++ b/app/Mail/Customer/OrderPaymentSuccess.php @@ -2,8 +2,8 @@ namespace App\Mail\Customer; +use App\Mail\Concerns\RequiresDbTemplate; use Illuminate\Bus\Queueable; -use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Mail\Mailable; use Illuminate\Mail\Mailables\Content; use Illuminate\Mail\Mailables\Envelope; @@ -11,55 +11,71 @@ use Illuminate\Queue\SerializesModels; class OrderPaymentSuccess extends Mailable { - use Queueable, SerializesModels; + use Queueable, SerializesModels, RequiresDbTemplate; public array $data; - /** - * Create a new message instance. - */ public function __construct(array $data) { $this->data = $data; } - /** - * Get the message envelope. - */ public function envelope(): Envelope { return new Envelope( - subject: '✅ Thanh toán thành công - Đơn hàng #' . ($this->data['order_num'] ?? 'N/A'), + subject: $this->renderFromTemplate('order_payment_success', $this->vars())['subject'], ); } - /** - * Get the message content definition. - */ public function content(): Content { return new Content( - view: 'emails.customer.order-payment-success', - with: [ - 'customerName' => $this->data['customer_name'] ?? 'Khách hàng', - 'orderId' => $this->data['order_id'] ?? 'N/A', - 'orderNum' => $this->data['order_num'] ?? 'N/A', - 'amount' => $this->data['amount'] ?? 0, - 'vmResults' => $this->data['vm_results'] ?? [], - 'addonsSummary' => $this->data['addons_summary'] ?? [], - 'supportEmail' => config('mail.support_email', 'support@example.com'), - 'supportPhone' => config('mail.support_phone', '1900-xxxx'), - ], + htmlString: $this->renderFromTemplate('order_payment_success', $this->vars())['html'], ); } - /** - * Get the attachments for the message. - * - * @return array - */ public function attachments(): array { return []; } -} \ No newline at end of file + + private function vars(): array + { + $vmRows = ''; + foreach ($this->data['vm_results'] ?? [] as $vm) { + $rows = ''; + if (!empty($vm['ip'])) { + $rows .= '
🌐 IP Address' . $vm['ip'] . '
'; + } + if (!empty($vm['username'])) { + $rows .= '
👤 Username' . $vm['username'] . '
'; + } + if (!empty($vm['password'])) { + $rows .= '
🔑 Password' . $vm['password'] . '
'; + } + if (!empty($vm['os'])) { + $rows .= '
💿 Hệ điều hành' . $vm['os'] . '
'; + } + $vmRows .= '
' . $rows . '
'; + } + $vmBlock = $vmRows ? '' . $vmRows : ''; + + $addonRows = ''; + foreach ($this->data['addons_summary'] ?? [] as $addon) { + $addonRows .= '' . $addon['name'] . '' . $addon['qty'] . '' . number_format((float) $addon['subtotal'], 0, ',', '.') . ' VNĐ'; + } + $addonBlock = $addonRows + ? '' . $addonRows . '
Dịch vụ addonSố lượngThành tiền
' + : ''; + + return [ + 'customer_name' => $this->data['customer_name'] ?? 'Khách hàng', + 'order_num' => $this->data['order_num'] ?? 'N/A', + 'amount_formatted'=> number_format((float) ($this->data['amount'] ?? 0), 0, ',', '.'), + 'vm_block' => $vmBlock, + 'addon_block' => $addonBlock, + 'support_email' => config('mail.support_email', 'support@example.com'), + 'support_phone' => config('mail.support_phone', '1900-xxxx'), + ]; + } +} diff --git a/app/Mail/Customer/PasswordResetByAdmin.php b/app/Mail/Customer/PasswordResetByAdmin.php index 62293bd..d46b22a 100644 --- a/app/Mail/Customer/PasswordResetByAdmin.php +++ b/app/Mail/Customer/PasswordResetByAdmin.php @@ -2,6 +2,7 @@ namespace App\Mail\Customer; +use App\Mail\Concerns\RequiresDbTemplate; use App\Models\Setting; use Illuminate\Bus\Queueable; use Illuminate\Mail\Mailable; @@ -11,34 +12,21 @@ use Illuminate\Queue\SerializesModels; class PasswordResetByAdmin extends Mailable { - use Queueable, SerializesModels; + use Queueable, SerializesModels, RequiresDbTemplate; public function __construct(public array $data) {} public function envelope(): Envelope { - $appName = $this->payload()['company_name'] ?? config('app.name', 'The Gioi So'); - return new Envelope( - subject: 'Mật khẩu của bạn đã được cập nhật — ' . $appName, + subject: $this->renderFromTemplate('password_reset_admin', $this->vars())['subject'], ); } public function content(): Content { - $data = $this->payload(); - return new Content( - view: 'emails.customer.password-reset-admin', - with: [ - 'appName' => $data['company_name'] ?? config('app.name', 'The Gioi So'), - 'fullName' => $data['fullname'] ?? 'Quý khách', - 'email' => $data['email'] ?? '', - 'password' => $data['password'] ?? '', - 'loginUrl' => $data['login_url'] ?? rtrim(config('app.frontend_url', ''), '/') . '/dang-nhap', - 'supportEmail' => $data['support_email'] ?? config('mail.support_email', 'support@thegioiso.com'), - 'supportPhone' => $data['support_phone'] ?? config('mail.support_phone', '1900.xxxx'), - ], + htmlString: $this->renderFromTemplate('password_reset_admin', $this->vars())['html'], ); } @@ -63,4 +51,19 @@ class PasswordResetByAdmin extends Mailable 'login_url' => rtrim(config('app.frontend_url', ''), '/') . '/dang-nhap', ], $this->data); } + + private function vars(): array + { + $data = $this->payload(); + + return [ + 'app_name' => $data['company_name'] ?? config('app.name', 'The Gioi So'), + 'full_name' => $data['fullname'] ?? 'Quý khách', + 'email' => $data['email'] ?? '', + 'password' => $data['password'] ?? '', + 'login_url' => $data['login_url'] ?? rtrim(config('app.frontend_url', ''), '/') . '/dang-nhap', + 'support_email' => $data['support_email'] ?? config('mail.support_email', 'support@thegioiso.com'), + 'support_phone' => $data['support_phone'] ?? config('mail.support_phone', '1900.xxxx'), + ]; + } } diff --git a/app/Mail/Customer/SnapshotActionMail.php b/app/Mail/Customer/SnapshotActionMail.php index d4100cc..037d54c 100644 --- a/app/Mail/Customer/SnapshotActionMail.php +++ b/app/Mail/Customer/SnapshotActionMail.php @@ -2,6 +2,7 @@ namespace App\Mail\Customer; +use App\Mail\Concerns\RequiresDbTemplate; use App\Models\Setting; use Illuminate\Bus\Queueable; use Illuminate\Mail\Mailable; @@ -11,41 +12,27 @@ use Illuminate\Queue\SerializesModels; class SnapshotActionMail extends Mailable { - use Queueable, SerializesModels; + use Queueable, SerializesModels, RequiresDbTemplate; + + private const LABELS = [ + 'create' => ['icon' => '📸', 'title' => 'Tạo Snapshot thành công', 'desc' => 'Snapshot máy chủ ảo của bạn đã được tạo thành công', 'status' => 'Tạo thành công ✓'], + 'remove' => ['icon' => '🗑️', 'title' => 'Xóa Snapshot thành công', 'desc' => 'Snapshot máy chủ ảo của bạn đã được xóa thành công', 'status' => 'Xóa thành công ✓'], + 'revert' => ['icon' => '🔄', 'title' => 'Khôi phục Snapshot thành công', 'desc' => 'Máy chủ ảo của bạn đã được khôi phục về snapshot thành công', 'status' => 'Khôi phục thành công ✓'], + ]; public function __construct(public array $data) {} public function envelope(): Envelope { - $subjects = [ - 'create' => 'Tạo Snapshot thành công', - 'remove' => 'Xóa Snapshot thành công', - 'revert' => 'Khôi phục Snapshot thành công', - ]; - - $appName = $this->payload()['company_name'] ?? config('app.name', 'The Gioi So'); - $subject = $subjects[$this->data['action'] ?? 'create'] ?? 'Thông báo Snapshot'; - - return new Envelope(subject: "{$subject} — {$appName}"); + return new Envelope( + subject: $this->renderFromTemplate('snapshot_action', $this->vars())['subject'], + ); } public function content(): Content { - $data = $this->payload(); - return new Content( - view: 'emails.customer.snapshot-action', - with: [ - 'appName' => $data['company_name'] ?? config('app.name', 'The Gioi So'), - 'fullName' => $data['fullname'] ?? 'Quý khách', - 'vmName' => $data['vm_name'] ?? '', - 'snapshotName' => $data['snapshot_name'] ?? '', - 'action' => $data['action'] ?? 'create', - 'actionAt' => $data['action_at'] ?? now()->format('d/m/Y H:i:s'), - 'loginUrl' => $data['login_url'] ?? rtrim(config('app.frontend_url', ''), '/') . '/dang-nhap', - 'supportEmail' => $data['support_email'] ?? config('mail.support_email', 'support@thegioiso.com'), - 'supportPhone' => $data['support_phone'] ?? config('mail.support_phone', '1900.xxxx'), - ], + htmlString: $this->renderFromTemplate('snapshot_action', $this->vars())['html'], ); } @@ -70,4 +57,25 @@ class SnapshotActionMail extends Mailable 'login_url' => rtrim(config('app.frontend_url', ''), '/') . '/dang-nhap', ], $this->data); } + + private function vars(): array + { + $data = $this->payload(); + $label = self::LABELS[$data['action'] ?? 'create'] ?? self::LABELS['create']; + + return [ + 'app_name' => $data['company_name'] ?? config('app.name', 'The Gioi So'), + 'full_name' => $data['fullname'] ?? 'Quý khách', + 'vm_name' => $data['vm_name'] ?? '', + 'snapshot_name' => $data['snapshot_name'] ?? '', + 'action_at' => $data['action_at'] ?? now()->format('d/m/Y H:i:s'), + 'action_icon' => $label['icon'], + 'action_title' => $label['title'], + 'action_desc' => $label['desc'], + 'action_status' => $label['status'], + 'login_url' => $data['login_url'] ?? rtrim(config('app.frontend_url', ''), '/') . '/dang-nhap', + 'support_email' => $data['support_email'] ?? config('mail.support_email', 'support@thegioiso.com'), + 'support_phone' => $data['support_phone'] ?? config('mail.support_phone', '1900.xxxx'), + ]; + } } diff --git a/app/Mail/Customer/VMCreationFailed.php b/app/Mail/Customer/VMCreationFailed.php index 533a85c..7f95389 100644 --- a/app/Mail/Customer/VMCreationFailed.php +++ b/app/Mail/Customer/VMCreationFailed.php @@ -2,8 +2,8 @@ namespace App\Mail\Customer; +use App\Mail\Concerns\RequiresDbTemplate; use Illuminate\Bus\Queueable; -use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Mail\Mailable; use Illuminate\Mail\Mailables\Content; use Illuminate\Mail\Mailables\Envelope; @@ -11,55 +11,44 @@ use Illuminate\Queue\SerializesModels; class VMCreationFailed extends Mailable { - use Queueable, SerializesModels; + use Queueable, SerializesModels, RequiresDbTemplate; public array $data; - /** - * Create a new message instance. - */ public function __construct(array $data) { $this->data = $data; } - /** - * Get the message envelope. - */ public function envelope(): Envelope { return new Envelope( - subject: 'Thông báo: Không thể khởi tạo máy chủ ảo - Đơn hàng #' . ($this->data['order_num'] ?? 'N/A'), + subject: $this->renderFromTemplate('vm_creation_failed', $this->vars())['subject'], ); } - /** - * Get the message content definition. - */ public function content(): Content { return new Content( - view: 'emails.customer.vm-creation-failed', - with: [ - 'customerName' => $this->data['customer_name'] ?? 'Khách hàng', - 'productName' => $this->data['product_name'] ?? 'Sản phẩm', - 'orderId' => $this->data['order_id'] ?? 'N/A', - 'orderNum' => $this->data['order_num'] ?? 'N/A', - 'errorMessage' => $this->data['error_message'] ?? 'Lỗi không xác định', - 'supportEmail' => config('mail.support_email', 'support@example.com'), - 'supportPhone' => config('mail.support_phone', '1900-xxxx'), - 'invoiceUrl' => rtrim(env('FRONTEND_URL', config('app.url')), '/') . '/hoa-don/' . ($this->data['order_id'] ?? ''), - ], + htmlString: $this->renderFromTemplate('vm_creation_failed', $this->vars())['html'], ); } - /** - * Get the attachments for the message. - * - * @return array - */ public function attachments(): array { return []; } -} \ No newline at end of file + + private function vars(): array + { + return [ + 'app_name' => config('app.name', 'Thế Giới Số'), + 'full_name' => $this->data['customer_name'] ?? 'Khách hàng', + 'order_num' => $this->data['order_num'] ?? 'N/A', + 'product_name' => $this->data['product_name'] ?? 'Sản phẩm', + 'invoice_url' => rtrim(env('FRONTEND_URL', config('app.url')), '/') . '/hoa-don/' . ($this->data['order_id'] ?? ''), + 'support_email' => config('mail.support_email', 'support@example.com'), + 'support_phone' => config('mail.support_phone', '1900-xxxx'), + ]; + } +} diff --git a/app/Mail/Customer/VerifyAccount.php b/app/Mail/Customer/VerifyAccount.php index 74d76f1..1cf9fc7 100644 --- a/app/Mail/Customer/VerifyAccount.php +++ b/app/Mail/Customer/VerifyAccount.php @@ -2,6 +2,7 @@ namespace App\Mail\Customer; +use App\Mail\Concerns\RequiresDbTemplate; use App\Models\Setting; use Illuminate\Bus\Queueable; use Illuminate\Mail\Mailable; @@ -11,32 +12,21 @@ use Illuminate\Queue\SerializesModels; class VerifyAccount extends Mailable { - use Queueable, SerializesModels; + use Queueable, SerializesModels, RequiresDbTemplate; public function __construct(public array $data) {} public function envelope(): Envelope { - $appName = $this->payload()['company_name'] ?? config('app.name', 'The Gioi So'); - return new Envelope( - subject: '[' . $appName . '] Mã xác thực tài khoản của bạn', + subject: $this->renderFromTemplate('verify_account_welcome', $this->vars())['subject'], ); } public function content(): Content { - $data = $this->payload(); - return new Content( - view: 'emails.customer.verify-account-welcome', - with: [ - 'appName' => $data['company_name'] ?? config('app.name', 'The Gioi So'), - 'fullName' => $data['fullname'] ?? 'Quý khách', - 'verifyCode' => $data['verify_code'] ?? '', - 'supportEmail' => $data['support_email'] ?? config('mail.support_email', 'support@thegioiso.com'), - 'supportPhone' => $data['support_phone'] ?? config('mail.support_phone', '1900.xxxx'), - ], + htmlString: $this->renderFromTemplate('verify_account_welcome', $this->vars())['html'], ); } @@ -60,4 +50,17 @@ class VerifyAccount extends Mailable 'support_phone' => $settingValue['hotline'] ?? ($settingValue['phone'] ?? config('mail.support_phone', '1900.xxxx')), ], $this->data); } + + private function vars(): array + { + $data = $this->payload(); + + return [ + 'app_name' => $data['company_name'] ?? config('app.name', 'The Gioi So'), + 'full_name' => $data['fullname'] ?? 'Quý khách', + 'verify_code' => $data['verify_code'] ?? '', + 'support_email' => $data['support_email'] ?? config('mail.support_email', 'support@thegioiso.com'), + 'support_phone' => $data['support_phone'] ?? config('mail.support_phone', '1900.xxxx'), + ]; + } } diff --git a/app/Mail/Customer/VmPasswordChanged.php b/app/Mail/Customer/VmPasswordChanged.php index f6d701c..2ed847b 100644 --- a/app/Mail/Customer/VmPasswordChanged.php +++ b/app/Mail/Customer/VmPasswordChanged.php @@ -2,6 +2,7 @@ namespace App\Mail\Customer; +use App\Mail\Concerns\RequiresDbTemplate; use App\Models\Setting; use Illuminate\Bus\Queueable; use Illuminate\Mail\Mailable; @@ -11,34 +12,21 @@ use Illuminate\Queue\SerializesModels; class VmPasswordChanged extends Mailable { - use Queueable, SerializesModels; + use Queueable, SerializesModels, RequiresDbTemplate; public function __construct(public array $data) {} public function envelope(): Envelope { - $appName = $this->payload()['company_name'] ?? config('app.name', 'The Gioi So'); - return new Envelope( - subject: 'Mật khẩu VM của bạn đã được thay đổi — ' . $appName, + subject: $this->renderFromTemplate('vm_password_changed', $this->vars())['subject'], ); } public function content(): Content { - $data = $this->payload(); - return new Content( - view: 'emails.customer.vm-password-changed', - with: [ - 'appName' => $data['company_name'] ?? config('app.name', 'The Gioi So'), - 'fullName' => $data['fullname'] ?? 'Quý khách', - 'vmName' => $data['vm_name'] ?? '', - 'newPassword' => $data['newPassword'] ?? '', - 'loginUrl' => $data['login_url'] ?? rtrim(config('app.frontend_url', ''), '/') . '/dang-nhap', - 'supportEmail' => $data['support_email'] ?? config('mail.support_email', 'support@thegioiso.com'), - 'supportPhone' => $data['support_phone'] ?? config('mail.support_phone', '1900.xxxx'), - ], + htmlString: $this->renderFromTemplate('vm_password_changed', $this->vars())['html'], ); } @@ -63,4 +51,22 @@ class VmPasswordChanged extends Mailable 'login_url' => rtrim(config('app.frontend_url', ''), '/') . '/dang-nhap', ], $this->data); } + + private function vars(): array + { + $data = $this->payload(); + $newPassword = $data['newPassword'] ?? ''; + + return [ + 'app_name' => $data['company_name'] ?? config('app.name', 'The Gioi So'), + 'full_name' => $data['fullname'] ?? 'Quý khách', + 'vm_name' => $data['vm_name'] ?? '', + 'new_password_row' => $newPassword + ? '
Mật khẩu mới: ' . $newPassword . '
' + : '', + 'login_url' => $data['login_url'] ?? rtrim(config('app.frontend_url', ''), '/') . '/dang-nhap', + 'support_email' => $data['support_email'] ?? config('mail.support_email', 'support@thegioiso.com'), + 'support_phone' => $data['support_phone'] ?? config('mail.support_phone', '1900.xxxx'), + ]; + } } diff --git a/app/Mail/Customer/VmRebuildFailed.php b/app/Mail/Customer/VmRebuildFailed.php index 18400fa..0303e56 100644 --- a/app/Mail/Customer/VmRebuildFailed.php +++ b/app/Mail/Customer/VmRebuildFailed.php @@ -2,8 +2,8 @@ namespace App\Mail\Customer; +use App\Mail\Concerns\RequiresDbTemplate; use Illuminate\Bus\Queueable; -use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Mail\Mailable; use Illuminate\Mail\Mailables\Content; use Illuminate\Mail\Mailables\Envelope; @@ -11,7 +11,7 @@ use Illuminate\Queue\SerializesModels; class VmRebuildFailed extends Mailable { - use Queueable, SerializesModels; + use Queueable, SerializesModels, RequiresDbTemplate; public array $data; @@ -23,22 +23,14 @@ class VmRebuildFailed extends Mailable public function envelope(): Envelope { return new Envelope( - subject: 'Thông báo: Cài lại hệ điều hành thất bại - ' . ($this->data['vm_name'] ?? 'VPS'), + subject: $this->renderFromTemplate('vm_rebuild_failed', $this->vars())['subject'], ); } public function content(): Content { return new Content( - view: 'emails.customer.vm-rebuild-failed', - with: [ - 'customerName' => $this->data['customer_name'] ?? 'Khách hàng', - 'vmName' => $this->data['vm_name'] ?? 'N/A', - 'ip' => $this->data['ip'] ?? 'N/A', - 'supportEmail' => config('mail.support_email', 'support@example.com'), - 'supportPhone' => config('mail.support_phone', '1900-xxxx'), - 'serviceUrl' => rtrim((string) env('FRONTEND_URL', config('app.url')), '/') . '/dich-vu', - ], + htmlString: $this->renderFromTemplate('vm_rebuild_failed', $this->vars())['html'], ); } @@ -46,4 +38,17 @@ class VmRebuildFailed extends Mailable { return []; } + + private function vars(): array + { + return [ + 'app_name' => config('app.name', 'Thế Giới Số'), + 'full_name' => $this->data['customer_name'] ?? 'Khách hàng', + 'vm_name' => $this->data['vm_name'] ?? 'N/A', + 'ip' => $this->data['ip'] ?? 'N/A', + 'service_url' => rtrim((string) env('FRONTEND_URL', config('app.url')), '/') . '/dich-vu', + 'support_email' => config('mail.support_email', 'support@example.com'), + 'support_phone' => config('mail.support_phone', '1900-xxxx'), + ]; + } } diff --git a/app/Mail/Customer/Welcome.php b/app/Mail/Customer/Welcome.php index 75f00de..f787fb5 100644 --- a/app/Mail/Customer/Welcome.php +++ b/app/Mail/Customer/Welcome.php @@ -2,6 +2,7 @@ namespace App\Mail\Customer; +use App\Mail\Concerns\RequiresDbTemplate; use App\Models\Setting; use Illuminate\Bus\Queueable; use Illuminate\Mail\Mailable; @@ -11,32 +12,21 @@ use Illuminate\Queue\SerializesModels; class Welcome extends Mailable { - use Queueable, SerializesModels; + use Queueable, SerializesModels, RequiresDbTemplate; public function __construct(public array $data) {} public function envelope(): Envelope { - $appName = $this->payload()['company_name'] ?? config('app.name', 'The Gioi So'); - return new Envelope( - subject: 'Chào mừng bạn đến với ' . $appName, + subject: $this->renderFromTemplate('customer_welcome', $this->vars())['subject'], ); } public function content(): Content { - $data = $this->payload(); - return new Content( - view: 'emails.customer.welcome', - with: [ - 'appName' => $data['company_name'] ?? config('app.name', 'The Gioi So'), - 'fullName' => $data['fullname'] ?? 'Quý khách', - 'loginUrl' => $data['login_url'] ?? rtrim(env('FRONTEND_URL', config('app.url')), '/') . '/dang-nhap', - 'supportEmail' => $data['support_email'] ?? config('mail.support_email', 'support@thegioiso.com'), - 'supportPhone' => $data['support_phone'] ?? config('mail.support_phone', '1900.xxxx'), - ], + htmlString: $this->renderFromTemplate('customer_welcome', $this->vars())['html'], ); } @@ -45,6 +35,19 @@ class Welcome extends Mailable return []; } + private function vars(): array + { + $data = $this->payload(); + + return [ + 'app_name' => $data['company_name'] ?? config('app.name', 'The Gioi So'), + 'full_name' => $data['fullname'] ?? 'Quý khách', + 'login_url' => $data['login_url'] ?? rtrim(env('FRONTEND_URL', config('app.url')), '/') . '/dang-nhap', + 'support_email' => $data['support_email'] ?? config('mail.support_email', 'support@thegioiso.com'), + 'support_phone' => $data['support_phone'] ?? config('mail.support_phone', '1900.xxxx'), + ]; + } + private function payload(): array { try { diff --git a/app/Mail/CustomerRegister.php b/app/Mail/CustomerRegister.php index 6f88e99..28fb67a 100644 --- a/app/Mail/CustomerRegister.php +++ b/app/Mail/CustomerRegister.php @@ -2,9 +2,8 @@ namespace App\Mail; -use App\Models\Template; +use App\Mail\Concerns\RequiresDbTemplate; use Illuminate\Bus\Queueable; -use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Mail\Mailable; use Illuminate\Mail\Mailables\Content; use Illuminate\Mail\Mailables\Envelope; @@ -12,67 +11,36 @@ use Illuminate\Queue\SerializesModels; class CustomerRegister extends Mailable { - use Queueable, SerializesModels; + use Queueable, SerializesModels, RequiresDbTemplate; - protected $template ; - protected $data; + /** @param int $template Không còn dùng — scenario cố định 'customer_signup_verify'. Giữ tham số để không phá vỡ caller hiện tại. */ + public function __construct($template = 0, public $data = []) {} - /** - * Create a new message instance. - * - * @return void - */ - public function __construct($template = 0,$data = []) - { - $this->data = $data; - $this->template = Template::find($template); - } - - /** - * Get the message envelope. - * - * @return \Illuminate\Mail\Mailables\Envelope - */ - public function envelope() + public function envelope(): Envelope { return new Envelope( - subject: $this->template->subject, + subject: $this->renderFromTemplate('customer_signup_verify', $this->vars())['subject'], ); } - /** - * Get the message content definition. - * - * @return \Illuminate\Mail\Mailables\Content - */ - public function content() + public function content(): Content { return new Content( - view: 'emails.template', - with: [ - 'template' => str_replace( - [ - "{\$company_name}", - "{\$company_logo}", - "{\$link_verify}", - ], - [ - $this->data['company_name'], - $this->data['company_logo'], - $this->data['link_verify'], - ], - $this->template->message) - ] + htmlString: $this->renderFromTemplate('customer_signup_verify', $this->vars())['html'], ); } - /** - * Get the attachments for the message. - * - * @return array - */ - public function attachments() + public function attachments(): array { return []; } + + private function vars(): array + { + return [ + 'company_name' => $this->data['company_name'] ?? '', + 'company_logo' => $this->data['company_logo'] ?? '', + 'link_verify' => $this->data['link_verify'] ?? '', + ]; + } } diff --git a/app/Mail/ExpiryService.php b/app/Mail/ExpiryService.php index bff1ce6..1dd8e39 100644 --- a/app/Mail/ExpiryService.php +++ b/app/Mail/ExpiryService.php @@ -2,9 +2,8 @@ namespace App\Mail; -use App\Models\Template; +use App\Mail\Concerns\RequiresDbTemplate; use Illuminate\Bus\Queueable; -use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Mail\Mailable; use Illuminate\Mail\Mailables\Content; use Illuminate\Mail\Mailables\Envelope; @@ -12,73 +11,38 @@ use Illuminate\Queue\SerializesModels; class ExpiryService extends Mailable { - use Queueable, SerializesModels; + use Queueable, SerializesModels, RequiresDbTemplate; - protected $template = null; - protected $data; + /** @param int $template Không còn dùng — scenario cố định 'service_expiry'. Giữ tham số để không phá vỡ caller hiện tại. */ + public function __construct($template = 0, public $data = []) {} - /** - * Create a new message instance. - * - * @return void - */ - public function __construct($template = 0, $data = []) - { - $this->data = $data; - $this->template = Template::find($template); - } - - /** - * Get the message envelope. - * - * @return \Illuminate\Mail\Mailables\Envelope - */ - public function envelope() + public function envelope(): Envelope { return new Envelope( - subject: $this->template->subject + subject: $this->renderFromTemplate('service_expiry', $this->vars())['subject'], ); } - /** - * Get the message content definition. - * - * @return \Illuminate\Mail\Mailables\Content - */ - public function content() + public function content(): Content { return new Content( - view: 'emails.template', - with: [ - 'template' => str_replace( - [ - "{\$company_name}", - "{\$company_logo}", - "{\$client_name}", - "{\$product_name}", - "{\$service_due_exp}", - "{\$service_remaining}" - ], - [ - $this->data['company_name'], - $this->data['company_logo'], - $this->data['client_name'], - $this->data['product_name'], - $this->data['service_due_exp'], - $this->data['service_remaining'], - ], - $this->template->message) - ] + htmlString: $this->renderFromTemplate('service_expiry', $this->vars())['html'], ); } - /** - * Get the attachments for the message. - * - * @return array - */ - public function attachments() + public function attachments(): array { return []; } + + private function vars(): array + { + return [ + 'company_name' => $this->data['company_name'] ?? '', + 'company_logo' => $this->data['company_logo'] ?? '', + 'product_name' => $this->data['product_name'] ?? '', + 'service_due_exp' => $this->data['service_due_exp'] ?? '', + 'service_remaining' => $this->data['service_remaining'] ?? '', + ]; + } } diff --git a/app/Mail/GenericTemplateMail.php b/app/Mail/GenericTemplateMail.php new file mode 100644 index 0000000..e8b3de0 --- /dev/null +++ b/app/Mail/GenericTemplateMail.php @@ -0,0 +1,47 @@ +renderedSubject, + from: $this->address($this->mailConfig['from_email'] ?? null, $this->mailConfig['from_name'] ?? null), + replyTo: array_filter([$this->address($this->mailConfig['reply_to'] ?? null, $this->mailConfig['reply_to_name'] ?? null)]), + cc: $this->mailConfig['cc'] ?? [], + bcc: $this->mailConfig['bcc'] ?? [], + ); + } + + public function content(): Content + { + return new Content(htmlString: $this->renderedHtml); + } + + private function address(?string $email, ?string $name): ?Address + { + return $email ? new Address($email, $name) : null; + } +} diff --git a/app/Mail/PasswordReset.php b/app/Mail/PasswordReset.php index 53181bf..29e00ff 100644 --- a/app/Mail/PasswordReset.php +++ b/app/Mail/PasswordReset.php @@ -2,9 +2,8 @@ namespace App\Mail; -use App\Models\Template; +use App\Mail\Concerns\RequiresDbTemplate; use Illuminate\Bus\Queueable; -use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Mail\Mailable; use Illuminate\Mail\Mailables\Content; use Illuminate\Mail\Mailables\Envelope; @@ -12,67 +11,35 @@ use Illuminate\Queue\SerializesModels; class PasswordReset extends Mailable { - use Queueable, SerializesModels; + use Queueable, SerializesModels, RequiresDbTemplate; - protected $template = null; - protected $data; + public function __construct(public $data = []) {} - /** - * Create a new message instance. - * - * @return void - */ - public function __construct($data = []) - { - $this->data = $data; - $this->template = Template::find(4); - } - - /** - * Get the message envelope. - * - * @return \Illuminate\Mail\Mailables\Envelope - */ - public function envelope() + public function envelope(): Envelope { return new Envelope( - subject: $this->template->subject, + subject: $this->renderFromTemplate('customer_password_reset', $this->vars())['subject'], ); } - /** - * Get the message content definition. - * - * @return \Illuminate\Mail\Mailables\Content - */ - public function content() + public function content(): Content { return new Content( - view: 'emails.template', - with: [ - 'template' => str_replace( - [ - "{\$company_name}", - "{\$company_logo}", - "{\$password}", - ], - [ - $this->data['company_name'], - $this->data['company_logo'], - $this->data['password'], - ], - $this->template->message) - ] + htmlString: $this->renderFromTemplate('customer_password_reset', $this->vars())['html'], ); } - /** - * Get the attachments for the message. - * - * @return array - */ - public function attachments() + public function attachments(): array { return []; } + + private function vars(): array + { + return [ + 'company_name' => $this->data['company_name'] ?? '', + 'company_logo' => $this->data['company_logo'] ?? '', + 'password' => $this->data['password'] ?? '', + ]; + } } diff --git a/app/Mail/ServiceRenewalReminderMail.php b/app/Mail/ServiceRenewalReminderMail.php index 288c79b..c38c7e7 100644 --- a/app/Mail/ServiceRenewalReminderMail.php +++ b/app/Mail/ServiceRenewalReminderMail.php @@ -2,6 +2,7 @@ namespace App\Mail; +use App\Mail\Concerns\RequiresDbTemplate; use App\Models\Customer; use App\Models\Service; use Illuminate\Bus\Queueable; @@ -12,7 +13,7 @@ use Illuminate\Queue\SerializesModels; class ServiceRenewalReminderMail extends Mailable { - use Queueable, SerializesModels; + use Queueable, SerializesModels, RequiresDbTemplate; public function __construct( public readonly Customer $customer, @@ -21,26 +22,38 @@ class ServiceRenewalReminderMail extends Mailable public function envelope(): Envelope { - $name = $this->service->server_name ?? $this->service->product?->name ?? 'dịch vụ'; - - return new Envelope(subject: "Nhắc gia hạn dịch vụ {$name}"); + return new Envelope( + subject: $this->renderFromTemplate('service_renewal_reminder', $this->vars())['subject'], + ); } public function content(): Content { return new Content( - view: 'emails.customer.service-renewal-reminder', - with: [ - 'clientName' => $this->customer->fullname ?? '', - 'productName' => $this->service->product?->name ?? 'Dịch vụ', - 'serverName' => $this->service->server_name ?? '', - 'expiresAt' => $this->service->expires_at - ? \Carbon\Carbon::parse($this->service->expires_at)->format('d/m/Y') - : 'N/A', - 'price' => $this->service->orderItem?->price - ? number_format((float) $this->service->orderItem->price, 0, ',', '.') - : null, - ], + htmlString: $this->renderFromTemplate('service_renewal_reminder', $this->vars())['html'], ); } + + private function vars(): array + { + $serverName = $this->service->server_name ?? ''; + $price = $this->service->orderItem?->price + ? number_format((float) $this->service->orderItem->price, 0, ',', '.') + : null; + + return [ + 'client_name' => $this->customer->fullname ?? '', + 'product_name' => $this->service->product?->name ?? 'Dịch vụ', + 'server_name_suffix' => $serverName ? " ({$serverName})" : '', + 'expires_at' => $this->service->expires_at + ? \Carbon\Carbon::parse($this->service->expires_at)->format('d/m/Y') + : 'N/A', + 'price_row' => $price + ? 'Số tiền gia hạn:' . $price . ' VNĐ' + : '', + 'login_url' => rtrim(config('app.frontend_url', ''), '/') . '/dang-nhap', + 'support_email' => 'support@tgs.com.vn', + 'support_phone' => '(028) 7309. 7379', + ]; + } } diff --git a/app/Mail/TicketCreated.php b/app/Mail/TicketCreated.php index 7282bd8..97a0520 100644 --- a/app/Mail/TicketCreated.php +++ b/app/Mail/TicketCreated.php @@ -2,6 +2,7 @@ namespace App\Mail; +use App\Mail\Concerns\RequiresDbTemplate; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Mail\Mailable; @@ -11,7 +12,7 @@ use Illuminate\Queue\SerializesModels; class TicketCreated extends Mailable implements ShouldQueue { - use Queueable, SerializesModels; + use Queueable, SerializesModels, RequiresDbTemplate; public function __construct( public readonly mixed $ticket, @@ -21,21 +22,14 @@ class TicketCreated extends Mailable implements ShouldQueue public function envelope(): Envelope { return new Envelope( - subject: "[#{$this->ticket->ticket_num}] Yêu cầu hỗ trợ của bạn đã được tiếp nhận", + subject: $this->renderFromTemplate('ticket_created', $this->vars())['subject'], ); } public function content(): Content { return new Content( - view: 'emails.customer.ticket-created', - with: [ - 'fullName' => trim("{$this->customer->firstname} {$this->customer->lastname}") ?: 'Quý khách', - 'ticketNum' => $this->ticket->ticket_num, - 'subject' => $this->ticket->subject, - 'room' => $this->ticket->room, - 'ticketUrl' => rtrim(config('app.frontend_url', ''), '/') . '/tickets/' . $this->ticket->ticket_num, - ], + htmlString: $this->renderFromTemplate('ticket_created', $this->vars())['html'], ); } @@ -43,4 +37,22 @@ class TicketCreated extends Mailable implements ShouldQueue { return []; } + + private function vars(): array + { + $room = $this->ticket->room ?? null; + + return [ + 'full_name' => trim("{$this->customer->firstname} {$this->customer->lastname}") ?: 'Quý khách', + 'ticket_num' => $this->ticket->ticket_num, + 'subject' => $this->ticket->subject, + 'room_row' => $room + ? 'Phòng ban:' . $room . '' + : '', + 'ticket_url' => rtrim(config('app.frontend_url', ''), '/') . '/tickets/' . $this->ticket->ticket_num, + 'app_name' => config('app.name', 'Thế Giới Số'), + 'support_email' => 'support@tgs.com.vn', + 'support_phone' => '(028) 7309. 7379', + ]; + } } diff --git a/app/Mail/VmCredentialsMail.php b/app/Mail/VmCredentialsMail.php deleted file mode 100644 index cdc47aa..0000000 --- a/app/Mail/VmCredentialsMail.php +++ /dev/null @@ -1,72 +0,0 @@ -emailData['os_name'] ?? '', - $this->emailData['client_name'] ?? '', - $this->emailData['company_name'] ?? '', - ], - $this->template->subject - ); - - return new Envelope(subject: $subject); - } - - public function content(): Content - { - try { - $renderer = app(EmailNotificationTemplateRenderer::class); - $html = $renderer->renderVmCredentials($this->template, $this->emailData, $this->invoice); - return new Content(htmlString: $html); - } catch (\Throwable $e) { - Log::warning('VmCredentialsMail: renderer failed, falling back to plain message', [ - 'template_id' => $this->template->id, - 'error' => $e->getMessage(), - ]); - return new Content(htmlString: '

Thông tin dịch vụ đã được chuẩn bị. Vui lòng liên hệ hỗ trợ nếu cần.

'); - } - } - - public function attachments(): array - { - if ($this->invoicePdf !== null && $this->invoicePdfFileName !== null) { - return [ - Attachment::fromData(fn() => $this->invoicePdf, $this->invoicePdfFileName) - ->withMime('application/pdf'), - ]; - } - - return []; - } -} diff --git a/app/Mail/VmPasswordResetSuccess.php b/app/Mail/VmPasswordResetSuccess.php index e17562b..cc112d1 100644 --- a/app/Mail/VmPasswordResetSuccess.php +++ b/app/Mail/VmPasswordResetSuccess.php @@ -2,22 +2,41 @@ namespace App\Mail; +use App\Mail\Concerns\RequiresDbTemplate; use Illuminate\Bus\Queueable; use Illuminate\Mail\Mailable; +use Illuminate\Mail\Mailables\Content; +use Illuminate\Mail\Mailables\Envelope; use Illuminate\Queue\SerializesModels; class VmPasswordResetSuccess extends Mailable { - use Queueable, SerializesModels; + use Queueable, SerializesModels, RequiresDbTemplate; public function __construct( public string $vmName, public string $newPassword ) {} - public function build() + public function envelope(): Envelope { - return $this->subject('Mật khẩu VPS của bạn đã được đặt lại') - ->view('emails.vm_password_reset_success'); + return new Envelope( + subject: $this->renderFromTemplate('vm_password_reset_success', $this->vars())['subject'], + ); + } + + public function content(): Content + { + return new Content( + htmlString: $this->renderFromTemplate('vm_password_reset_success', $this->vars())['html'], + ); + } + + private function vars(): array + { + return [ + 'vm_name' => $this->vmName, + 'new_password' => $this->newPassword, + ]; } } diff --git a/app/Mail/VmSuspendedMail.php b/app/Mail/VmSuspendedMail.php index 715f96e..5e51b72 100644 --- a/app/Mail/VmSuspendedMail.php +++ b/app/Mail/VmSuspendedMail.php @@ -2,6 +2,7 @@ namespace App\Mail; +use App\Mail\Concerns\RequiresDbTemplate; use App\Models\Customer; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; @@ -12,7 +13,7 @@ use Illuminate\Queue\SerializesModels; class VmSuspendedMail extends Mailable implements ShouldQueue { - use Queueable, SerializesModels; + use Queueable, SerializesModels, RequiresDbTemplate; public function __construct( public readonly Customer $customer, @@ -23,19 +24,14 @@ class VmSuspendedMail extends Mailable implements ShouldQueue public function envelope(): Envelope { return new Envelope( - subject: '[Thế Giới Số] Dịch vụ Cloud VPS của bạn đã bị tạm ngưng', + subject: $this->renderFromTemplate('vm_suspended', $this->vars())['subject'], ); } public function content(): Content { return new Content( - view: 'emails.customer.vm-suspended', - with: [ - 'customerName' => $this->customer->fullname, - 'vmName' => $this->vmName, - 'vmwareId' => $this->vmwareId, - ], + htmlString: $this->renderFromTemplate('vm_suspended', $this->vars())['html'], ); } @@ -43,4 +39,17 @@ class VmSuspendedMail extends Mailable implements ShouldQueue { return []; } + + private function vars(): array + { + return [ + 'customer_name' => $this->customer->fullname, + 'vm_name' => $this->vmName, + 'vmware_id' => $this->vmwareId, + 'suspended_at' => now()->setTimezone('Asia/Ho_Chi_Minh')->format('d/m/Y H:i'), + 'login_url' => rtrim(config('app.frontend_url', ''), '/') . '/dang-nhap', + 'support_email' => 'support@tgs.com.vn', + 'support_phone' => '(028) 7309. 7379', + ]; + } } diff --git a/app/Models/DnsDomain.php b/app/Models/DnsDomain.php index b452ad6..151d23d 100644 --- a/app/Models/DnsDomain.php +++ b/app/Models/DnsDomain.php @@ -17,6 +17,7 @@ class DnsDomain extends Model 'dns_manager_server_id', 'is_created', 'plesk_id', + 'registrar_id', ]; public function customer() @@ -24,6 +25,11 @@ class DnsDomain extends Model return $this->belongsToMany(Customer::class, 'dns_domain_user', 'domain_id', 'user_id')->withTimestamps(); } + public function registrar() + { + return $this->belongsTo(Registrar::class); + } + public function users() { return $this->customer(); diff --git a/app/Models/ModuleDomain.php b/app/Models/ModuleDomain.php index 8ab975b..3495d98 100644 --- a/app/Models/ModuleDomain.php +++ b/app/Models/ModuleDomain.php @@ -22,6 +22,8 @@ class ModuleDomain extends Model 'next_invoice_date', 'customer_id', 'domain_type_id', + 'registrar_id', + 'note', 'status' ]; @@ -29,4 +31,9 @@ class ModuleDomain extends Model { return $this->belongsTo(\App\Models\Customer::class); } + + public function registrar() + { + return $this->belongsTo(Registrar::class); + } } diff --git a/app/Models/Order.php b/app/Models/Order.php index 02e3507..4dc6d50 100644 --- a/app/Models/Order.php +++ b/app/Models/Order.php @@ -18,6 +18,7 @@ class Order extends Model protected $casts = [ 'date' => 'date', + 'paid_at' => 'datetime', ]; public function customer() @@ -41,12 +42,18 @@ class Order extends Model return $this->hasMany(OrderItem::class, 'order_id'); } - /** Invoice generated from this order. */ + /** First/primary invoice generated from this order (kept for backward compat — an order can have many). */ public function invoice() { return $this->hasOne(Invoice::class, 'order_id'); } + /** All invoices billed against this order (initial + renewals) — an order can have many. */ + public function invoices() + { + return $this->hasMany(Invoice::class, 'order_id'); + } + // ------------------------------------------------------------------------- // Helpers // ------------------------------------------------------------------------- diff --git a/app/Models/ProductCategories.php b/app/Models/ProductCategories.php index 48ee40c..a486f31 100644 --- a/app/Models/ProductCategories.php +++ b/app/Models/ProductCategories.php @@ -20,6 +20,11 @@ class ProductCategories extends Model 'is_active', 'sort_order', 'type', + 'billing_cycles', + ]; + + protected $casts = [ + 'billing_cycles' => 'array', ]; public function parent() diff --git a/app/Models/Registrar.php b/app/Models/Registrar.php new file mode 100644 index 0000000..653ee05 --- /dev/null +++ b/app/Models/Registrar.php @@ -0,0 +1,25 @@ +hasMany(ModuleDomain::class, 'registrar_id'); + } +} diff --git a/app/Models/Template.php b/app/Models/Template.php index 1f25069..cf64960 100644 --- a/app/Models/Template.php +++ b/app/Models/Template.php @@ -4,11 +4,14 @@ namespace App\Models; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Facades\Cache; class Template extends Model { use HasFactory; + public const CACHE_PREFIX = 'email_template:'; + protected $table = 'template'; protected $fillable = [ @@ -19,18 +22,79 @@ class Template extends Model 'name', 'subject', 'message', + 'metadata', + 'builder_data', 'status', ]; protected $casts = [ - 'status' => 'integer', + 'status' => 'integer', + 'metadata' => 'array', + 'builder_data' => 'array', ]; + protected static function booted(): void + { + static::saved(fn (self $template) => $template->forgetCache()); + static::deleted(fn (self $template) => $template->forgetCache()); + } + + public function forgetCache(): void + { + if ($this->scenario) { + Cache::forget(self::CACHE_PREFIX . $this->scenario); + } + } + + /** Danh sách variable hợp lệ khai báo trong metadata.variables, key theo variable key. */ + public function variables(): array + { + return collect($this->metadata['variables'] ?? [])->keyBy('key')->all(); + } + + /** Mail sender config khai báo trong metadata.mail (from_email, from_name, reply_to, reply_to_name, cc, bcc). */ + public function mailConfig(): array + { + return $this->metadata['mail'] ?? []; + } + public const SCENARIOS = [ - 'vm_credentials' => 'Khởi tạo dịch vụ', - 'renewal_reminder' => 'Nhắc gia hạn', - 'news_notification'=> 'Thông báo tin tức', - 'invoice_pdf' => 'Hoá đơn điện tử', + 'vm_credentials' => 'Khởi tạo dịch vụ', + 'renewal_reminder' => 'Nhắc gia hạn', + 'news_notification' => 'Thông báo tin tức', + 'invoice_pdf' => 'Hoá đơn điện tử', + 'vm_suspended' => 'VPS bị tạm ngưng', + 'domain_order_received' => 'Đơn đăng ký tên miền đã ghi nhận', + 'hosting_account_created' => 'Hosting đã khởi tạo', + 'manual_deposit_status' => 'Trạng thái nạp tiền thủ công', + 'order_payment_success' => 'Thanh toán đơn hàng thành công', + 'password_reset_admin' => 'Admin cấp lại mật khẩu', + 'service_renewal_reminder'=> 'Nhắc gia hạn dịch vụ', + 'snapshot_action' => 'Thao tác Snapshot VPS', + 'ticket_created' => 'Ticket hỗ trợ mới', + 'verify_account_welcome' => 'Mã xác thực tài khoản', + 'vm_creation_failed' => 'Khởi tạo VPS thất bại', + 'vm_password_changed' => 'Đổi mật khẩu VPS', + 'vm_rebuild_failed' => 'Rebuild VPS thất bại', + 'holiday_notice' => 'Thông báo nghỉ lễ', + 'domain_order_approved' => 'Tên miền được duyệt', + 'domain_order_rejected' => 'Tên miền bị từ chối', + 'order_created' => 'Xác nhận đơn hàng', + 'admin_order_notification' => '[Admin] Đơn hàng mới', + 'admin_domain_order_new' => '[Admin] Đơn đăng ký tên miền mới', + 'admin_hosting_account_created' => '[Admin] Hosting account mới', + 'admin_manual_deposit_pending' => '[Admin] Yêu cầu nạp tiền chờ duyệt', + 'admin_ticket_notification' => '[Admin] Ticket hỗ trợ mới', + 'admin_trial_registration_request' => '[Admin] Yêu cầu dùng thử VPS', + 'customer_deposit_success' => 'Nạp tiền thành công', + 'customer_welcome' => 'Chào mừng khách hàng mới', + 'vm_port_change' => 'Đổi port Remote VPS', + 'vm_password_reset_success' => 'Đặt lại mật khẩu VPS thành công', + 'customer_signup_verify' => 'Xác thực đăng ký tài khoản', + 'hosting_information' => 'Thông tin truy cập Hosting', + 'customer_password_reset' => 'Khôi phục mật khẩu khách hàng', + 'service_expiry' => 'Dịch vụ sắp hết hạn', + 'customer_payment' => 'Thông báo thanh toán khách hàng', ]; public function invoiceTemplate() diff --git a/app/Models/User.php b/app/Models/User.php index ff5341b..18cdd7b 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -23,9 +23,8 @@ class User extends Authenticatable implements JWTSubject * @var array */ protected $fillable = [ - 'firstname', + 'fullname', 'password', - 'lastname', 'username', 'email', 'email_verified_at', @@ -41,10 +40,7 @@ class User extends Authenticatable implements JWTSubject * * @var array */ - protected $hidden = [ - 'password', - 'remember_token', - ]; + protected $hidden = ['password', 'remember_token']; /** * The attributes that should be cast. @@ -53,7 +49,7 @@ class User extends Authenticatable implements JWTSubject */ protected $casts = [ 'email_verified_at' => 'datetime', - 'start_date' => 'date', + 'start_date' => 'date', ]; public function getJWTIdentifier() @@ -75,5 +71,4 @@ class User extends Authenticatable implements JWTSubject { return $this->hasOne(\App\Models\Staff::class, 'user_id', 'id'); } - } diff --git a/app/Repositories/Api/Invoice/InvoiceRepository.php b/app/Repositories/Api/Invoice/InvoiceRepository.php index 48ec32e..ba978ee 100644 --- a/app/Repositories/Api/Invoice/InvoiceRepository.php +++ b/app/Repositories/Api/Invoice/InvoiceRepository.php @@ -35,7 +35,7 @@ class InvoiceRepository extends BaseApiRepository implements InvoiceRepositoryIn public function createFromOrder(Order $order): Invoice { - $maxMonths = $order->orderItems->max(fn($item) => $this->cycleToMonths($item->billing_cycle)) ?? 1; + $maxMonths = $order->orderItems->max(fn($item) => OrderHelper::cycleToMonths($item->billing_cycle)) ?? 1; $invoice = $this->model->create([ 'invoice_number' => OrderHelper::generateOrderNumber('INV'), @@ -57,7 +57,7 @@ class InvoiceRepository extends BaseApiRepository implements InvoiceRepositoryIn 'product_id' => $item->product_id, 'type' => 'service', 'description' => $item->product?->name ?? 'Dịch vụ', - 'months' => $this->cycleToMonths($item->billing_cycle), + 'months' => OrderHelper::cycleToMonths($item->billing_cycle), 'quantity' => $item->quantity ?? 1, 'unit_price' => $item->price, 'total_price' => $item->price * ($item->quantity ?? 1), @@ -77,7 +77,7 @@ class InvoiceRepository extends BaseApiRepository implements InvoiceRepositoryIn 'product_id' => $item->product_id, 'type' => 'addon', 'description' => $label, - 'months' => $this->cycleToMonths($item->billing_cycle), + 'months' => OrderHelper::cycleToMonths($item->billing_cycle), 'quantity' => $qty, 'unit_price' => $price, 'total_price' => $subtotal, @@ -92,16 +92,4 @@ class InvoiceRepository extends BaseApiRepository implements InvoiceRepositoryIn // Helpers // ------------------------------------------------------------------------- - private function cycleToMonths(?string $cycle): int - { - return match ($cycle) { - 'monthly' => 1, - 'quarterly' => 3, - 'semi_annually' => 6, - 'annually' => 12, - 'biennially' => 24, - 'triennially' => 36, - default => 1, - }; - } } diff --git a/app/Repositories/Module/Domain/DomainRepository.php b/app/Repositories/Module/Domain/DomainRepository.php index b1ca9b1..dbf0585 100644 --- a/app/Repositories/Module/Domain/DomainRepository.php +++ b/app/Repositories/Module/Domain/DomainRepository.php @@ -36,6 +36,9 @@ class DomainRepository extends BaseRepository implements DomainRepositoryInterfa if (isset($data['status'])) { $model = $model->where("status", $data['status']); } + if (isset($data['customer_id'])) { + $model = $model->where('customer_id', $data['customer_id']); + } if (count($withCount) > 0) { $model = $model->withCount($withCount); } diff --git a/app/Repositories/Order/OrderRepository.php b/app/Repositories/Order/OrderRepository.php index 81a7752..b521952 100644 --- a/app/Repositories/Order/OrderRepository.php +++ b/app/Repositories/Order/OrderRepository.php @@ -158,7 +158,7 @@ class OrderRepository extends BaseRepository implements OrderRepositoryInterface 'customer', 'payment', 'discount', - 'invoice', + 'invoices.invoiceItems.product', ])->where('order_num', $orderNum)->first(); } } diff --git a/app/Services/Api/Order/OrderQueryService.php b/app/Services/Api/Order/OrderQueryService.php index a0b31eb..f299a64 100644 --- a/app/Services/Api/Order/OrderQueryService.php +++ b/app/Services/Api/Order/OrderQueryService.php @@ -6,7 +6,6 @@ namespace App\Services\Api\Order; use App\DTOs\Order\ClientOrderFilters; use App\Enums\Invoice\InvoiceStatus; -use App\Enums\Order\OrderStatus; use App\Helpers\OrderHelper; use App\Jobs\VirtualMachine\Action\VMJobs; use App\Jobs\VirtualMachine\Provision\RetryProvisioningOrchestrator; @@ -234,33 +233,16 @@ class OrderQueryService ]; } - $invoice = DB::transaction(function () use ($service, $orderItem, $price, $months) { - $order = Order::create([ - 'order_num' => OrderHelper::generateOrderNumber(), - 'customer_id' => $service->customer_id, - 'sub_total' => $price, - 'discount_amount' => 0, - 'total' => $price, - 'status' => OrderStatus::PENDING->value, - 'client_ip' => request()?->ip(), - 'note' => 'Đơn hàng gia hạn dịch vụ #' . $service->id, - ]); - - OrderItem::create([ - 'order_id' => $order->id, - 'product_id' => $service->product_id, - 'billing_cycle' => $orderItem->billing_cycle, - 'months' => $months, - 'os_id' => $orderItem->os_id, - 'quantity' => 1, - 'server_name' => $service->server_name, - 'price' => $price, - ]); + // Gia hạn là một billing cycle mới của CÙNG một Order/Service — không tạo Order mới. + // Order gốc chính là order đã tạo ra service này (qua order_item -> order_id). + $periodStart = $service->expires_at && $service->expires_at->isFuture() ? $service->expires_at->copy() : now(); + $periodEnd = $periodStart->copy()->addMonths($months); + $invoice = DB::transaction(function () use ($service, $orderItem, $price, $months, $periodStart, $periodEnd) { $invoice = Invoice::create([ 'invoice_number' => OrderHelper::generateOrderNumber('INV'), 'customer_id' => $service->customer_id, - 'order_id' => $order->id, + 'order_id' => $orderItem->order_id, 'invoice_type' => 2, 'sub_total' => $price, 'discount_amount' => 0, @@ -268,6 +250,8 @@ class OrderQueryService 'total' => $price, 'status' => InvoiceStatus::UNPAID->value, 'due_date' => now()->addDays(7)->toDateString(), + 'billing_period_start' => $periodStart->toDateString(), + 'billing_period_end' => $periodEnd->toDateString(), ]); \App\Models\InvoiceItem::create([ diff --git a/app/Services/Api/Order/OrderService.php b/app/Services/Api/Order/OrderService.php index 0a243c5..58dc803 100644 --- a/app/Services/Api/Order/OrderService.php +++ b/app/Services/Api/Order/OrderService.php @@ -83,7 +83,7 @@ class OrderService $this->orderRepo->createItem($order->id, [ 'product_id' => $product->id, 'billing_cycle' => $data['billing_cycle'], - 'months' => $this->cycleToMonths($data['billing_cycle']), + 'months' => OrderHelper::cycleToMonths($data['billing_cycle']), 'server_name' => $data['server_name'] ?? null, 'os_id' => $data['os_id'] ?? null, 'quantity' => 1, @@ -151,7 +151,7 @@ class OrderService private function provisionService(Order $order, OrderItem $item, $customer): void { $invoice = $order->invoice; - $months = $item->months ?: $this->cycleToMonths($item->billing_cycle); + $months = $item->months ?: OrderHelper::cycleToMonths($item->billing_cycle); $expiresAt = now()->addMonths($months); $service = Service::create([ @@ -253,18 +253,6 @@ class OrderService // Helpers // ========================================================================= - private function cycleToMonths(?string $cycle): int - { - return match ($cycle) { - 'monthly' => 1, - 'quarterly' => 3, - 'semi_annually' => 6, - 'annually' => 12, - 'biennially' => 24, - 'triennially' => 36, - default => 1, - }; - } private function dispatchAdminNotification(Order $order, $customer): void { diff --git a/app/Services/Customer/CustomerBillingService.php b/app/Services/Customer/CustomerBillingService.php index b998939..638b22d 100644 --- a/app/Services/Customer/CustomerBillingService.php +++ b/app/Services/Customer/CustomerBillingService.php @@ -95,7 +95,7 @@ class CustomerBillingService { $customer = $this->resolveCustomer($uuid); - return $this->orderRepository->filter(['client' => $customer->id], ['customer', 'payment', 'invoice']); + return $this->orderRepository->filter(['client' => $customer->id], ['customer', 'payment', 'invoices']); } /** @@ -114,6 +114,6 @@ class CustomerBillingService // (dùng ở getOrders) được xoá theo — xem ghi chú ở updateInvoice. $this->orderRepository->update($orderId, $data); - return $order->refresh()->load(['payment', 'invoice']); + return $order->refresh()->load(['payment', 'invoices']); } } diff --git a/app/Services/Customer/CustomerServiceProvisioningService.php b/app/Services/Customer/CustomerServiceProvisioningService.php index 09ec995..f69d2c9 100644 --- a/app/Services/Customer/CustomerServiceProvisioningService.php +++ b/app/Services/Customer/CustomerServiceProvisioningService.php @@ -177,7 +177,7 @@ class CustomerServiceProvisioningService ); $billingCycle = $data['billing_cycle'] ?? 'monthly'; - $months = $this->cycleToMonths($billingCycle); + $months = OrderHelper::cycleToMonths($billingCycle); $pricing = $product->pricings->where('billing_cycle', $billingCycle)->first(); // Số tiền đã thanh toán: admin nhập tay để khớp với thực tế (có thể lệch giá niêm yết do khuyến mãi/thoả thuận) @@ -392,16 +392,4 @@ class CustomerServiceProvisioningService }); } - private function cycleToMonths(?string $cycle): int - { - return match ($cycle) { - 'monthly' => 1, - 'quarterly' => 3, - 'semi_annually' => 6, - 'annually' => 12, - 'biennially' => 24, - 'triennially' => 36, - default => 1, - }; - } } diff --git a/app/Services/Email/EmailTemplateRenderer.php b/app/Services/Email/EmailTemplateRenderer.php new file mode 100644 index 0000000..3aef5da --- /dev/null +++ b/app/Services/Email/EmailTemplateRenderer.php @@ -0,0 +1,69 @@ +variables(); + + $this->assertKnownVariables($template->scenario ?? $template->name ?? 'unknown', (string) $template->subject, $variables); + $this->assertKnownVariables($template->scenario ?? $template->name ?? 'unknown', (string) $template->message, $variables); + + return [ + 'subject' => $this->replace((string) $template->subject, $variables, $data), + 'html' => $this->replace((string) $template->message, $variables, $data), + ]; + } + + private function assertKnownVariables(string $scenario, string $content, array $variables): void + { + $used = $this->extractPlaceholders($content); + $invalid = array_values(array_diff($used, array_keys($variables))); + + if ($invalid !== []) { + throw new InvalidTemplateVariableException($scenario, $invalid); + } + } + + private function extractPlaceholders(string $content): array + { + preg_match_all(self::PLACEHOLDER_PATTERN, $content, $matches); + + return array_values(array_unique($matches[1])); + } + + private function replace(string $content, array $variables, array $data): string + { + return preg_replace_callback(self::PLACEHOLDER_PATTERN, function (array $match) use ($variables, $data) { + $key = $match[1]; + $definition = $variables[$key] ?? []; + $value = $data[$key] ?? $definition['example'] ?? ''; + + return $this->formatValue((string) $value, $definition['type'] ?? 'string'); + }, $content) ?? $content; + } + + private function formatValue(string $value, string $type): string + { + return match ($type) { + // html: nội dung tin cậy do Email Builder sinh ra sẵn (đã được builder escape khi build). + 'html' => $value, + 'url' => filter_var($value, FILTER_VALIDATE_URL) ? htmlspecialchars($value, ENT_QUOTES, 'UTF-8') : '', + default => htmlspecialchars($value, ENT_QUOTES, 'UTF-8'), + }; + } +} diff --git a/app/Services/Email/EmailTemplateService.php b/app/Services/Email/EmailTemplateService.php new file mode 100644 index 0000000..2319f01 --- /dev/null +++ b/app/Services/Email/EmailTemplateService.php @@ -0,0 +1,45 @@ + $to + */ + public function send(string $scenario, string|array $to, array $data = []): void + { + SendTemplateEmailJob::dispatch($scenario, is_array($to) ? $to : [$to], $data); + } + + /** + * Render ngay (không gửi) — dùng cho preview trong Email Builder. + * + * @return array{subject: string, html: string} + */ + public function preview(string $scenario, array $data = []): array + { + return $this->renderer->render($this->loadTemplate($scenario), $data); + } + + public function loadTemplate(string $scenario): Template + { + return Cache::remember( + Template::CACHE_PREFIX . $scenario, + 3600, + fn () => Template::where('scenario', $scenario)->where('status', 1)->firstOrFail(), + ); + } +} diff --git a/app/Services/InvoicePaymentService.php b/app/Services/InvoicePaymentService.php index 8795c0d..a65b434 100644 --- a/app/Services/InvoicePaymentService.php +++ b/app/Services/InvoicePaymentService.php @@ -5,6 +5,7 @@ namespace App\Services; use App\Enums\Invoice\InvoiceStatus; use App\Enums\Order\OrderStatus; use App\Enums\TransactionStatus; +use App\Helpers\OrderHelper; use App\Jobs\Hosting\ProvisionHostingAccountJob; use App\Jobs\VirtualMachine\Action\VMJobs; use App\Jobs\VirtualMachine\Create\CreateVirtualMachineJob; @@ -28,23 +29,34 @@ class InvoicePaymentService throw new \InvalidArgumentException('Hóa đơn không thuộc tài khoản này.'); } + // Balance/status are re-checked below under row locks — this is just a fast + // fail-fast path so we don't open a transaction for an already-paid invoice. if ((int) $invoice->status === InvoiceStatus::PAID->value) { return $invoice->fresh(['order.orderItems.product', 'services', 'customer']); } - if (! in_array((int) $invoice->status, [InvoiceStatus::UNPAID->value, InvoiceStatus::OVERDUE->value], true)) { - throw new \InvalidArgumentException('Hóa đơn không ở trạng thái có thể thanh toán.'); - } - - if ((int) $customer->total < (int) $invoice->total) { - throw new \InvalidArgumentException(sprintf( - 'Số dư không đủ. Hiện có: %s VNĐ, cần: %s VNĐ.', - number_format((int) $customer->total), - number_format((int) $invoice->total) - )); - } - return DB::transaction(function () use ($invoice, $customer, $method) { + // Lock both rows so two concurrent payment requests for the same invoice/customer + // (double-click, retry) cannot both pass the balance check before either commits. + $invoice = Invoice::where('id', $invoice->id)->lockForUpdate()->firstOrFail(); + $customer = Customer::where('id', $customer->id)->lockForUpdate()->firstOrFail(); + + if ((int) $invoice->status === InvoiceStatus::PAID->value) { + return $invoice->fresh(['order.orderItems.product', 'services', 'customer']); + } + + if (! in_array((int) $invoice->status, [InvoiceStatus::UNPAID->value, InvoiceStatus::OVERDUE->value], true)) { + throw new \InvalidArgumentException('Hóa đơn không ở trạng thái có thể thanh toán.'); + } + + if ((int) $customer->total < (int) $invoice->total) { + throw new \InvalidArgumentException(sprintf( + 'Số dư không đủ. Hiện có: %s VNĐ, cần: %s VNĐ.', + number_format((int) $customer->total), + number_format((int) $invoice->total) + )); + } + $customer->decrement('total', (int) $invoice->total); $invoice->update([ @@ -128,12 +140,13 @@ class InvoicePaymentService return; } - if ((int) $order->status !== OrderStatus::ACTIVE->value) { - $order->update([ - 'status' => OrderStatus::ACTIVE->value, - 'payment_method' => $invoice->payment_method, - ]); - } + // payment_method/paid_at always mirror the invoice that was just paid, even if the + // order's status doesn't change (e.g. it's already ACTIVE from an earlier payment). + $order->update([ + 'status' => OrderStatus::ACTIVE->value, + 'payment_method' => $invoice->payment_method, + 'paid_at' => $invoice->paid_at, + ]); foreach ($order->orderItems as $item) { $isHostingProvision = ! $item->os_id @@ -150,7 +163,7 @@ class InvoicePaymentService 'status' => ($item->os_id || $isHostingProvision) ? 'pending' : 'active', 'auto_renew' => false, 'started_at' => now(), - 'expires_at' => now()->addMonths($item->months ?: $this->cycleToMonths($item->billing_cycle)), + 'expires_at' => now()->addMonths($item->months ?: OrderHelper::cycleToMonths($item->billing_cycle)), ] ); @@ -177,10 +190,13 @@ class InvoicePaymentService return; } - if ($invoice->order && (int) $invoice->order->status !== OrderStatus::ACTIVE->value) { + if ($invoice->order) { + // Same rule as activateOrder(): payment_method/paid_at always mirror the invoice + // that was just paid, regardless of whether the order's status actually changes. $invoice->order->update([ 'status' => OrderStatus::ACTIVE->value, 'payment_method' => $invoice->payment_method, + 'paid_at' => $invoice->paid_at, ]); } @@ -239,16 +255,4 @@ class InvoicePaymentService } } - private function cycleToMonths(?string $cycle): int - { - return match ($cycle) { - 'monthly' => 1, - 'quarterly' => 3, - 'semi_annually' => 6, - 'annually' => 12, - 'biennially' => 24, - 'triennially' => 36, - default => 1, - }; - } } diff --git a/app/Services/OrderService.php b/app/Services/OrderService.php index 614d680..04931b8 100644 --- a/app/Services/OrderService.php +++ b/app/Services/OrderService.php @@ -139,14 +139,16 @@ class OrderService [$productItems, $relIdData, $total] = $this->buildProductItems($data); - $orderTotal = (int) ($total + ($total * (10 / 100))); + // Giá niêm yết đã bao gồm thuế (khớp với client-facing OrderService::create()) — + // không cộng thêm thuế lên trên ở đây. + $orderTotal = (int) $total; $invoiceData = [ 'invoice_number' => Helper::generateUniqueCode(), 'due_date' => Carbon::today()->addDays(7)->toDateString(), 'paid_at' => $effectiveInvoiceStatus === InvoiceStatus::PAID->value ? Carbon::now() : null, 'sub_total' => $total, - 'tax' => $total * (10 / 100), + 'tax' => 0, 'total' => $orderTotal, 'payment_method' => $paymentRecord?->id, 'customer_id' => $data['customer_id'], @@ -163,6 +165,9 @@ class OrderService 'payment_proof' => $data['payment_proof'] ?? null, 'user_id' => Auth::guard('admin')->id(), 'status' => $effectiveOrderStatus, + // Order.paid_at mirrors invoice.paid_at — set together, same rule as + // InvoicePaymentService::activateOrder()/activateRenewal(). + 'paid_at' => $invoiceData['paid_at'], 'client_ip' => request()->getClientIp(), ]; diff --git a/config/database.php b/config/database.php index 3f88b2c..d18b753 100644 --- a/config/database.php +++ b/config/database.php @@ -3,7 +3,6 @@ use Illuminate\Support\Str; return [ - /* |-------------------------------------------------------------------------- | Default Database Connection Name @@ -34,7 +33,6 @@ return [ */ 'connections' => [ - 'sqlite' => [ 'driver' => 'sqlite', 'url' => env('DATABASE_URL'), @@ -58,9 +56,42 @@ return [ 'prefix_indexes' => true, 'strict' => true, 'engine' => null, - 'options' => extension_loaded('pdo_mysql') ? array_filter([ - PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), - ]) : [], + 'options' => extension_loaded('pdo_mysql') + ? array_filter([ + PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), + ]) + : [], + ], + + 'whmcs' => [ + 'driver' => 'mysql', + 'host' => env('WHMCS_DB_HOST', '127.0.0.1'), + 'port' => env('WHMCS_DB_PORT', '3306'), + 'database' => env('WHMCS_DB_DATABASE'), + 'username' => env('WHMCS_DB_USERNAME'), + 'password' => env('WHMCS_DB_PASSWORD'), + 'unix_socket' => env('WHMCS_DB_SOCKET', ''), + 'charset' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'prefix' => '', + 'prefix_indexes' => true, + 'strict' => true, + 'engine' => null, + ], + 'domain' => [ + 'driver' => 'mysql', + 'host' => env('DOMAIN_DB_HOST', '127.0.0.1'), + 'port' => env('DOMAIN_DB_PORT', '3306'), + 'database' => env('DOMAIN_DB_DATABASE'), + 'username' => env('DOMAIN_DB_USERNAME'), + 'password' => env('DOMAIN_DB_PASSWORD'), + 'unix_socket' => env('DOMAIN_DB_SOCKET', ''), + 'charset' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'prefix' => '', + 'prefix_indexes' => true, + 'strict' => true, + 'engine' => null, ], 'legacy' => [ @@ -106,7 +137,6 @@ return [ 'prefix' => '', 'prefix_indexes' => true, ], - ], /* @@ -134,12 +164,11 @@ return [ */ 'redis' => [ - 'client' => env('REDIS_CLIENT', 'phpredis'), 'options' => [ 'cluster' => env('REDIS_CLUSTER', 'redis'), - 'prefix' => env('REDIS_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_').'_database_'), + 'prefix' => env('REDIS_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_') . '_database_'), ], 'default' => [ @@ -163,7 +192,5 @@ return [ 'persistent_id' => 'cache', 'read_write_timeout' => 0, ], - ], - ]; diff --git a/database/migrations/2014_10_12_000000_create_users_table.php b/database/migrations/2014_10_12_000000_create_users_table.php index 5f2c7bb..62f4480 100644 --- a/database/migrations/2014_10_12_000000_create_users_table.php +++ b/database/migrations/2014_10_12_000000_create_users_table.php @@ -14,8 +14,7 @@ return new class extends Migration { { Schema::create('user', function (Blueprint $table) { $table->id(); - $table->string('firstname', 255)->nullable(); - $table->string('lastname', 255)->nullable(); + $table->string('fullname', 255)->nullable(); $table->string('username', 100)->nullable(); $table->string('email', 100)->nullable(); $table->date('join_date')->nullable(); diff --git a/database/migrations/2024_01_31_145201_customer.php b/database/migrations/2024_01_31_145201_customer.php index 4221ef0..567368b 100644 --- a/database/migrations/2024_01_31_145201_customer.php +++ b/database/migrations/2024_01_31_145201_customer.php @@ -16,7 +16,8 @@ return new class extends Migration { Schema::create('customers', function (Blueprint $table) { $table->id(); $table->uuid('uuid')->unique(); - $table->string('fullname', 100)->nullable(); + $table->string('fullname', 255)->nullable(); + $table->foreignId('saler')->nullable()->constrained('user')->nullOnDelete(); $table->string('avatar', 100)->nullable(); $table->integer('gender')->default(0); $table->string('address', 100)->nullable(); @@ -41,7 +42,7 @@ return new class extends Migration { $table->string('province_code', 20)->nullable(); $table->string('ward_code', 20)->nullable(); $table->string('remember_token', 100)->nullable(); - $table->tinyInteger('status')->default(1); + $table->tinyInteger('status')->default(0)->comment('1: Active, 0: InActive'); $table->timestamps(); $table->softDeletes(); diff --git a/database/migrations/2026_08_28_113713_update_column_customers_table.php b/database/migrations/2026_08_28_113713_update_column_customers_table.php deleted file mode 100644 index ca8843f..0000000 --- a/database/migrations/2026_08_28_113713_update_column_customers_table.php +++ /dev/null @@ -1,28 +0,0 @@ -integer('unpaid_total')->default(0)->comment('Số nợ còn thiếu')->after('total'); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::table('customers', function (Blueprint $table) { - // - }); - } -}; diff --git a/database/migrations/2026_09_05_000000_widen_customers_address_column.php b/database/migrations/2026_09_05_000000_widen_customers_address_column.php new file mode 100644 index 0000000..30f925b --- /dev/null +++ b/database/migrations/2026_09_05_000000_widen_customers_address_column.php @@ -0,0 +1,19 @@ +unsignedBigInteger('legacy_id')->nullable()->unique()->after('id'); + }); + + Schema::table('dns_manager_domain', function (Blueprint $table) { + $table->unsignedBigInteger('legacy_id')->nullable()->unique()->after('id'); + }); + + Schema::table('dns_manager_ns', function (Blueprint $table) { + $table->unsignedBigInteger('legacy_id')->nullable()->unique()->after('id'); + }); + } + + public function down(): void + { + Schema::table('dns_manager_servers', fn (Blueprint $t) => $t->dropColumn('legacy_id')); + Schema::table('dns_manager_domain', fn (Blueprint $t) => $t->dropColumn('legacy_id')); + Schema::table('dns_manager_ns', fn (Blueprint $t) => $t->dropColumn('legacy_id')); + } +}; diff --git a/database/migrations/2026_09_05_000002_add_legacy_id_to_product_tables.php b/database/migrations/2026_09_05_000002_add_legacy_id_to_product_tables.php new file mode 100644 index 0000000..e9f1f29 --- /dev/null +++ b/database/migrations/2026_09_05_000002_add_legacy_id_to_product_tables.php @@ -0,0 +1,36 @@ +unsignedBigInteger('legacy_id')->nullable()->unique()->after('id'); + }); + } + + if (! Schema::hasColumn('products', 'legacy_id')) { + Schema::table('products', function (Blueprint $table) { + $table->unsignedBigInteger('legacy_id')->nullable()->unique()->after('id'); + }); + } + + if (! Schema::hasColumn('product_addon', 'legacy_id')) { + Schema::table('product_addon', function (Blueprint $table) { + $table->unsignedBigInteger('legacy_id')->nullable()->unique()->after('id'); + }); + } + } + + public function down(): void + { + Schema::table('product_addon', fn (Blueprint $t) => $t->dropColumn('legacy_id')); + } +}; diff --git a/database/migrations/2026_09_05_000003_create_registrars_table.php b/database/migrations/2026_09_05_000003_create_registrars_table.php new file mode 100644 index 0000000..f2bfd7f --- /dev/null +++ b/database/migrations/2026_09_05_000003_create_registrars_table.php @@ -0,0 +1,36 @@ +id(); + $table->string('name'); + $table->string('short_name'); + $table->string('status')->default('active'); + $table->string('website')->nullable(); + $table->timestamps(); + }); + + DB::table('registrars')->insert([ + ['name' => 'ESC', 'short_name' => 'esc', 'status' => 'active', 'website' => null, 'created_at' => now(), 'updated_at' => now()], + ['name' => 'Nhân Hòa', 'short_name' => 'nhanhoa', 'status' => 'active', 'website' => 'https://nhanhoa.com', 'created_at' => now(), 'updated_at' => now()], + ['name' => 'PA', 'short_name' => 'PA', 'status' => 'active', 'website' => 'https://pavietnam.vn', 'created_at' => now(), 'updated_at' => now()], + ['name' => 'TenTen', 'short_name' => 'TENTEN', 'status' => 'active', 'website' => 'https://tenten.vn', 'created_at' => now(), 'updated_at' => now()], + ['name' => 'TinoDomain', 'short_name' => 'tinodomain', 'status' => 'active', 'website' => 'https://tino.org', 'created_at' => now(), 'updated_at' => now()], + ['name' => 'VDC', 'short_name' => 'vdc', 'status' => 'active', 'website' => 'https://vdc.com.vn', 'created_at' => now(), 'updated_at' => now()], + ['name' => 'Z.Com', 'short_name' => 'zcom', 'status' => 'active', 'website' => 'https://z.com', 'created_at' => now(), 'updated_at' => now()], + ]); + } + + public function down(): void + { + Schema::dropIfExists('registrars'); + } +}; diff --git a/database/migrations/2026_09_05_000004_add_sync_fields_to_module_domain_table.php b/database/migrations/2026_09_05_000004_add_sync_fields_to_module_domain_table.php new file mode 100644 index 0000000..ac738d4 --- /dev/null +++ b/database/migrations/2026_09_05_000004_add_sync_fields_to_module_domain_table.php @@ -0,0 +1,26 @@ +unsignedBigInteger('legacy_id')->nullable()->unique()->after('id'); + $table->foreignId('registrar_id')->nullable()->after('domain_type_id') + ->constrained('registrars')->nullOnDelete(); + $table->text('note')->nullable()->after('status'); + }); + } + + public function down(): void + { + Schema::table('module_domain', function (Blueprint $table) { + $table->dropForeign(['registrar_id']); + $table->dropColumn(['legacy_id', 'registrar_id', 'note']); + }); + } +}; diff --git a/database/migrations/2026_09_05_000005_add_registrar_id_to_dns_manager_domain_table.php b/database/migrations/2026_09_05_000005_add_registrar_id_to_dns_manager_domain_table.php new file mode 100644 index 0000000..014d345 --- /dev/null +++ b/database/migrations/2026_09_05_000005_add_registrar_id_to_dns_manager_domain_table.php @@ -0,0 +1,24 @@ +foreignId('registrar_id')->nullable()->after('plesk_id') + ->constrained('registrars')->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('dns_manager_domain', function (Blueprint $table) { + $table->dropForeign(['registrar_id']); + $table->dropColumn('registrar_id'); + }); + } +}; diff --git a/database/migrations/2026_09_05_000006_add_billing_period_to_invoices_table.php b/database/migrations/2026_09_05_000006_add_billing_period_to_invoices_table.php new file mode 100644 index 0000000..f266312 --- /dev/null +++ b/database/migrations/2026_09_05_000006_add_billing_period_to_invoices_table.php @@ -0,0 +1,29 @@ +date('billing_period_start')->nullable()->after('due_date'); + $table->date('billing_period_end')->nullable()->after('billing_period_start'); + + // Prevents two renewal invoices being created for the same order in the same + // billing period (e.g. concurrent cron run + customer self-renew request). + // NULL values (non-renewal invoices) are not constrained by MySQL unique indexes. + $table->unique(['order_id', 'invoice_type', 'billing_period_start'], 'invoices_order_type_period_unique'); + }); + } + + public function down(): void + { + Schema::table('invoices', function (Blueprint $table) { + $table->dropUnique('invoices_order_type_period_unique'); + $table->dropColumn(['billing_period_start', 'billing_period_end']); + }); + } +}; diff --git a/database/migrations/2026_09_05_000007_add_unique_constraint_to_transactions_table.php b/database/migrations/2026_09_05_000007_add_unique_constraint_to_transactions_table.php new file mode 100644 index 0000000..19b4320 --- /dev/null +++ b/database/migrations/2026_09_05_000007_add_unique_constraint_to_transactions_table.php @@ -0,0 +1,24 @@ +unique(['customer_id', 'trans', 'type'], 'transactions_customer_trans_type_unique'); + }); + } + + public function down(): void + { + Schema::table('transactions', function (Blueprint $table) { + $table->dropUnique('transactions_customer_trans_type_unique'); + }); + } +}; diff --git a/database/migrations/2026_09_05_000008_add_paid_at_to_orders_table.php b/database/migrations/2026_09_05_000008_add_paid_at_to_orders_table.php new file mode 100644 index 0000000..25f524b --- /dev/null +++ b/database/migrations/2026_09_05_000008_add_paid_at_to_orders_table.php @@ -0,0 +1,24 @@ +timestamp('paid_at')->nullable()->after('payment_method'); + }); + } + + public function down(): void + { + Schema::table('orders', function (Blueprint $table) { + $table->dropColumn('paid_at'); + }); + } +}; diff --git a/database/migrations/2026_09_05_000009_add_billing_cycles_to_product_categories_table.php b/database/migrations/2026_09_05_000009_add_billing_cycles_to_product_categories_table.php new file mode 100644 index 0000000..6144083 --- /dev/null +++ b/database/migrations/2026_09_05_000009_add_billing_cycles_to_product_categories_table.php @@ -0,0 +1,26 @@ +json('billing_cycles')->nullable()->after('type'); + }); + } + + public function down(): void + { + Schema::table('product_categories', function (Blueprint $table) { + $table->dropColumn('billing_cycles'); + }); + } +}; diff --git a/database/migrations/2026_09_09_000000_add_metadata_builder_data_to_template_table.php b/database/migrations/2026_09_09_000000_add_metadata_builder_data_to_template_table.php new file mode 100644 index 0000000..e5b6a3b --- /dev/null +++ b/database/migrations/2026_09_09_000000_add_metadata_builder_data_to_template_table.php @@ -0,0 +1,25 @@ +json('metadata')->nullable()->after('format') + ->comment('Danh sách variable hợp lệ (metadata.variables[]) + mail config (metadata.mail)'); + $table->json('builder_data')->nullable()->after('metadata') + ->comment('Dữ liệu project của Email Builder để mở lại chỉnh sửa'); + }); + } + + public function down(): void + { + Schema::table('template', function (Blueprint $table) { + $table->dropColumn(['metadata', 'builder_data']); + }); + } +}; diff --git a/database/migrations/2026_09_09_000001_backfill_scenario_metadata_for_legacy_templates.php b/database/migrations/2026_09_09_000001_backfill_scenario_metadata_for_legacy_templates.php new file mode 100644 index 0000000..0caf8cc --- /dev/null +++ b/database/migrations/2026_09_09_000001_backfill_scenario_metadata_for_legacy_templates.php @@ -0,0 +1,65 @@ + [ + 'scenario' => 'domain_order_approved', + 'variables' => [ + ['key' => 'company_name', 'label' => 'Tên công ty', 'type' => 'string', 'example' => 'Thế Giới Số'], + ['key' => 'company_logo', 'label' => 'Logo công ty (URL)', 'type' => 'url', 'example' => ''], + ['key' => 'client_name', 'label' => 'Tên khách hàng', 'type' => 'string', 'example' => 'Nguyễn Văn A'], + ['key' => 'domain_name', 'label' => 'Tên miền', 'type' => 'string', 'example' => 'example.vn'], + ['key' => 'order_num', 'label' => 'Mã đơn hàng', 'type' => 'string', 'example' => 'DH001'], + ], + ], + 'Domain Order Rejected' => [ + 'scenario' => 'domain_order_rejected', + 'variables' => [ + ['key' => 'company_name', 'label' => 'Tên công ty', 'type' => 'string', 'example' => 'Thế Giới Số'], + ['key' => 'company_logo', 'label' => 'Logo công ty (URL)', 'type' => 'url', 'example' => ''], + ['key' => 'client_name', 'label' => 'Tên khách hàng', 'type' => 'string', 'example' => 'Nguyễn Văn A'], + ['key' => 'domain_name', 'label' => 'Tên miền', 'type' => 'string', 'example' => 'example.vn'], + ['key' => 'order_num', 'label' => 'Mã đơn hàng', 'type' => 'string', 'example' => 'DH001'], + ['key' => 'reason', 'label' => 'Lý do từ chối', 'type' => 'string', 'example' => 'Tên miền đã có chủ sở hữu'], + ], + ], + 'Order Created' => [ + 'scenario' => 'order_created', + 'variables' => [ + ['key' => 'company_name', 'label' => 'Tên công ty', 'type' => 'string', 'example' => 'Thế Giới Số'], + ['key' => 'company_logo', 'label' => 'Logo công ty (URL)', 'type' => 'url', 'example' => ''], + ['key' => 'client_name', 'label' => 'Tên khách hàng', 'type' => 'string', 'example' => 'Nguyễn Văn A'], + ['key' => 'order_num', 'label' => 'Mã đơn hàng', 'type' => 'string', 'example' => 'DH001'], + ['key' => 'order_total', 'label' => 'Tổng tiền', 'type' => 'string', 'example' => '990.000 đ'], + ['key' => 'invoice_num', 'label' => 'Mã hoá đơn', 'type' => 'string', 'example' => 'INV001'], + ], + ], + ]; + + public function up(): void + { + foreach (self::BACKFILL as $name => $config) { + DB::table('template') + ->where('name', $name) + ->whereNull('scenario') + ->update([ + 'scenario' => $config['scenario'], + 'metadata' => json_encode(['variables' => $config['variables']]), + ]); + } + } + + public function down(): void + { + foreach (self::BACKFILL as $name => $config) { + DB::table('template') + ->where('name', $name) + ->where('scenario', $config['scenario']) + ->update(['scenario' => null, 'metadata' => null]); + } + } +}; diff --git a/database/migrations/2026_09_09_000002_seed_admin_and_misc_email_templates.php b/database/migrations/2026_09_09_000002_seed_admin_and_misc_email_templates.php new file mode 100644 index 0000000..aaaf63e --- /dev/null +++ b/database/migrations/2026_09_09_000002_seed_admin_and_misc_email_templates.php @@ -0,0 +1,254 @@ +
Thư gửi từ Công Ty Cổ Phần Giải Pháp Công Nghệ Thế Giới Số
+HTML; + } + + private function footer(): string + { + return <<<'HTML' +
CÔNG TY CỔ PHẦN GIẢI PHÁP CÔNG NGHỆ THẾ GIỚI SỐ
Tel: (028) 7309.7379  -  Email: support@tgs.com.vn
+HTML; + } + + private function wrap(string $bodyHtml): string + { + return '
' + . $this->header() + . '' + . $this->footer(); + } + + public function up(): void + { + $rows = [ + [ + 'name' => '[Admin] Đơn hàng mới', + 'scenario' => 'admin_order_notification', + 'subject' => '[ADMIN] Đơn hàng mới #{$order_num} — {$customer_email}', + 'variables' => [ + ['key' => 'order_num', 'label' => 'Mã đơn hàng', 'type' => 'string', 'example' => 'DH001'], + ['key' => 'created_at', 'label' => 'Thời gian', 'type' => 'string', 'example' => now()->format('d/m/Y H:i:s')], + ['key' => 'customer_name', 'label' => 'Tên khách hàng', 'type' => 'string', 'example' => 'Nguyễn Văn A'], + ['key' => 'customer_email', 'label' => 'Email khách hàng', 'type' => 'string', 'example' => 'a@example.com'], + ['key' => 'client_ip_row', 'label' => '[Tự động] Dòng IP đặt hàng', 'type' => 'html', 'example' => ''], + ['key' => 'product_name', 'label' => 'Tên sản phẩm', 'type' => 'string', 'example' => 'Cloud VPS Pro'], + ['key' => 'product_type', 'label' => 'Loại dịch vụ', 'type' => 'string', 'example' => 'VPS'], + ['key' => 'billing_cycle', 'label' => 'Chu kỳ thanh toán', 'type' => 'string', 'example' => 'Hàng tháng'], + ['key' => 'total_formatted', 'label' => 'Tổng tiền', 'type' => 'string', 'example' => '990.000 VNĐ'], + ['key' => 'vm_block', 'label' => '[Tự động] Khối thông tin VPS', 'type' => 'html', 'example' => ''], + ['key' => 'company_name', 'label' => 'Tên công ty', 'type' => 'string', 'example' => 'Thế Giới Số'], + ], + 'message' => '
📦

Đơn hàng mới đã thanh toán

Đơn #{$order_num} — {$created_at}

' . $bodyHtml . '
{$client_ip_row}
Tên khách hàng{$customer_name}
Email{$customer_email}
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}', + ], + [ + 'name' => '[Admin] Đơn đăng ký tên miền mới', + 'scenario' => 'admin_domain_order_new', + 'subject' => '[ADMIN] Đơn đăng ký tên miền mới - {$domain_name}', + 'variables' => [ + ['key' => 'company_name', 'label' => 'Tên công ty', 'type' => 'string', 'example' => 'Thế Giới Số'], + ['key' => 'domain_name', 'label' => 'Tên miền', 'type' => 'string', 'example' => 'example.vn'], + ['key' => 'order_num', 'label' => 'Mã đơn hàng', 'type' => 'string', 'example' => 'DH001'], + ['key' => 'order_total', 'label' => 'Tổng tiền', 'type' => 'string', 'example' => '350.000 VNĐ'], + ['key' => 'customer_name', 'label' => 'Tên khách hàng', 'type' => 'string', 'example' => 'Nguyễn Văn A'], + ['key' => 'customer_email', 'label' => 'Email khách hàng', 'type' => 'string', 'example' => 'a@example.com'], + ['key' => 'created_at', 'label' => 'Thời gian', 'type' => 'string', 'example' => now()->format('d/m/Y H:i:s')], + ['key' => 'admin_url', 'label' => 'Link xem đơn (admin)', 'type' => 'url', 'example' => 'https://portal.tgs.com.vn/admin/orders'], + ], + 'message' => $this->wrap('

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

Thông tin đơn hàng:

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

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

'), + ], + [ + 'name' => '[Admin] Hosting account mới', + 'scenario' => 'admin_hosting_account_created', + 'subject' => '[ADMIN] Hosting account mới — {$domain_name}', + 'variables' => [ + ['key' => 'domain_name', 'label' => 'Tên miền', 'type' => 'string', 'example' => 'example.vn'], + ['key' => 'created_at', 'label' => 'Thời gian', 'type' => 'string', 'example' => now()->format('d/m/Y H:i:s')], + ['key' => 'customer_name', 'label' => 'Tên khách hàng', 'type' => 'string', 'example' => 'Nguyễn Văn A'], + ['key' => 'customer_email', 'label' => 'Email khách hàng', 'type' => 'string', 'example' => 'a@example.com'], + ['key' => 'hosting_plan', 'label' => 'Gói hosting', 'type' => 'string', 'example' => 'Hosting Pro'], + ['key' => 'server_name', 'label' => 'Tên server', 'type' => 'string', 'example' => 'host01'], + ['key' => 'server_ip', 'label' => 'IP server', 'type' => 'string', 'example' => '1.2.3.4'], + ['key' => 'plesk_client_id', 'label' => 'Plesk Client ID', 'type' => 'string', 'example' => '123'], + ['key' => 'plesk_subscription_id', 'label' => 'Plesk Subscription ID', 'type' => 'string', 'example' => '456'], + ['key' => 'ftp_username', 'label' => 'FTP Username', 'type' => 'string', 'example' => 'ftpuser'], + ['key' => 'ftp_password_row', 'label' => '[Tự động] Dòng FTP Password', 'type' => 'html', 'example' => ''], + ['key' => 'plesk_panel_url', 'label' => 'Plesk Panel URL', 'type' => 'url', 'example' => 'https://panel.tgs.com.vn'], + ['key' => 'plesk_login', 'label' => 'Plesk Login', 'type' => 'string', 'example' => 'admin'], + ['key' => 'plesk_password_row', 'label' => '[Tự động] Dòng Plesk Password', 'type' => 'html', 'example' => ''], + ['key' => 'company_name', 'label' => 'Tên công ty', 'type' => 'string', 'example' => 'Thế Giới Số'], + ], + 'message' => '
🖥️

Hosting account mới được khởi tạo

{$domain_name} — {$created_at}

Tên khách hàng{$customer_name}
Email{$customer_email}
Tên miền{$domain_name}
Gói hosting{$hosting_plan}
Server{$server_name} ({$server_ip})
Plesk Client ID{$plesk_client_id}
Plesk Subscription ID{$plesk_subscription_id}
Trạng thái✅ Active
👤 FTP Username{$ftp_username}
{$ftp_password_row}
🌐 Panel URL{$plesk_panel_url}
👤 Login{$plesk_login}
{$plesk_password_row}
Đây là tài khoản Plesk riêng của khách hàng (đã gửi kèm cho khách), khác với tài khoản quản trị của server dùng chung — tài khoản quản trị server không được đính kèm trong email này vì lý do bảo mật.
', + ], + [ + 'name' => '[Admin] Yêu cầu nạp tiền chờ duyệt', + 'scenario' => 'admin_manual_deposit_pending', + 'subject' => '[ADMIN] Yêu cầu nạp tiền chờ duyệt - {$transaction_code}', + 'variables' => [ + ['key' => 'transaction_code', 'label' => 'Mã giao dịch', 'type' => 'string', 'example' => 'DEP001'], + ['key' => 'created_at', 'label' => 'Thời gian', 'type' => 'string', 'example' => now()->format('d/m/Y H:i:s')], + ['key' => 'customer_name', 'label' => 'Tên khách hàng', 'type' => 'string', 'example' => 'Nguyễn Văn A'], + ['key' => 'customer_email', 'label' => 'Email khách hàng', 'type' => 'string', 'example' => 'a@example.com'], + ['key' => 'customer_phone_row', 'label' => '[Tự động] Dòng số điện thoại', 'type' => 'html', 'example' => ''], + ['key' => 'client_ip_row', 'label' => '[Tự động] Dòng IP gửi yêu cầu', 'type' => 'html', 'example' => ''], + ['key' => 'amount_formatted', 'label' => 'Số tiền', 'type' => 'string', 'example' => '500.000 VNĐ'], + ['key' => 'proof_row', 'label' => '[Tự động] Dòng link chứng từ', 'type' => 'html', 'example' => ''], + ['key' => 'company_name', 'label' => 'Tên công ty', 'type' => 'string', 'example' => 'Thế Giới Số'], + ], + 'message' => '
🔔

Yêu cầu nạp tiền chờ duyệt

{$transaction_code} — {$created_at}

{$customer_phone_row}{$client_ip_row}
Tên khách hàng{$customer_name}
Email{$customer_email}
{$proof_row}
Mã giao dịch{$transaction_code}
Số tiền{$amount_formatted}
Trạng tháiĐang chờ duyệt
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.
', + ], + [ + 'name' => '[Admin] Ticket hỗ trợ mới', + 'scenario' => 'admin_ticket_notification', + 'subject' => '[ADMIN] Ticket mới #{$ticket_num} — {$customer_email}', + 'variables' => [ + ['key' => 'ticket_num', 'label' => 'Mã ticket', 'type' => 'string', 'example' => 'TCK001'], + ['key' => 'subject_text', 'label' => 'Tiêu đề ticket', 'type' => 'string', 'example' => 'Không truy cập được VPS'], + ['key' => 'room', 'label' => 'Phòng ban', 'type' => 'string', 'example' => 'Kỹ thuật'], + ['key' => 'customer_name', 'label' => 'Tên khách hàng', 'type' => 'string', 'example' => 'Nguyễn Văn A'], + ['key' => 'customer_email', 'label' => 'Email khách hàng', 'type' => 'string', 'example' => 'a@example.com'], + ['key' => 'description', 'label' => 'Nội dung ticket (đã cắt bớt)', 'type' => 'string', 'example' => 'Không đăng nhập được vào VPS từ sáng nay...'], + ['key' => 'ticket_url', 'label' => 'Link xem ticket', 'type' => 'url', 'example' => 'https://portal.tgs.com.vn/tickets/TCK001'], + ], + 'message' => '
🎫

Ticket mới cần xử lý

Ticket #{$ticket_num}

Mã ticket#{$ticket_num}
Tiêu đề{$subject_text}
Phòng ban{$room}
Khách hàng{$customer_name}
Email khách{$customer_email}
{$description}
', + ], + [ + 'name' => '[Admin] Yêu cầu dùng thử VPS', + 'scenario' => 'admin_trial_registration_request', + 'subject' => '[ADMIN] Yêu cầu dùng thử Cloud VPS - {$customer_name}', + 'variables' => [ + ['key' => 'customer_name', 'label' => 'Họ tên', 'type' => 'string', 'example' => 'Nguyễn Văn A'], + ['key' => 'customer_email', 'label' => 'Email', 'type' => 'string', 'example' => 'a@example.com'], + ['key' => 'customer_phone', 'label' => 'Số điện thoại', 'type' => 'string', 'example' => '0900000000'], + ['key' => 'cpu', 'label' => 'CPU', 'type' => 'string', 'example' => '4 core'], + ['key' => 'ram', 'label' => 'RAM', 'type' => 'string', 'example' => '8GB'], + ['key' => 'ssd', 'label' => 'SSD', 'type' => 'string', 'example' => '100GB'], + ['key' => 'os', 'label' => 'Hệ điều hành', 'type' => 'string', 'example' => 'Ubuntu 22.04'], + ['key' => 'other_row', 'label' => '[Tự động] Dòng yêu cầu khác', 'type' => 'html', 'example' => ''], + ['key' => 'company_name', 'label' => 'Tên công ty', 'type' => 'string', 'example' => 'Thế Giới Số'], + ['key' => 'created_at', 'label' => 'Thời gian', 'type' => 'string', 'example' => now()->format('d/m/Y H:i:s')], + ], + 'message' => '
🧪

Yêu cầu dùng thử Cloud VPS

{$created_at}

Họ tên{$customer_name}
Email{$customer_email}
Số điện thoại{$customer_phone}
{$other_row}
CPU{$cpu}
RAM{$ram}
SSD{$ssd}
Hệ điều hành{$os}
Trạng tháiChờ khởi tạo
Vui lòng vào trang quản trị để khởi tạo VPS dùng thử theo cấu hình khách hàng yêu cầu.
', + ], + [ + 'name' => 'Nạp tiền thành công', + 'scenario' => 'customer_deposit_success', + 'subject' => 'V/v: Thông báo nạp tiền thành công - {$amount_formatted} VNĐ', + 'variables' => [ + ['key' => 'customer_name', 'label' => 'Tên khách hàng', 'type' => 'string', 'example' => 'Nguyễn Văn A'], + ['key' => 'transaction_code', 'label' => 'Mã giao dịch', 'type' => 'string', 'example' => 'DEP001'], + ['key' => 'amount_formatted', 'label' => 'Số tiền nạp', 'type' => 'string', 'example' => '500.000'], + ['key' => 'credit_formatted', 'label' => 'Khuyến mãi', 'type' => 'string', 'example' => '0'], + ['key' => 'current_total_formatted', 'label' => 'Tổng số dư hiện tại', 'type' => 'string', 'example' => '1.500.000'], + ['key' => 'current_credit_formatted', 'label' => 'Số dư khuyến mãi hiện tại', 'type' => 'string', 'example' => '0'], + ['key' => 'deposit_date', 'label' => 'Thời gian nạp', 'type' => 'string', 'example' => now()->format('d/m/Y H:i:s')], + ['key' => 'support_email', 'label' => 'Email hỗ trợ', 'type' => 'string', 'example' => 'support@tgs.com.vn'], + ['key' => 'support_phone', 'label' => 'Hotline hỗ trợ', 'type' => 'string', 'example' => '(028) 7309.7379'], + ], + 'message' => '
💰

Nạp tiền thành công

{$transaction_code} — {$deposit_date}

Xin chào {$customer_name}, yêu cầu nạp tiền của bạn đã được duyệt thành công.

Mã giao dịch{$transaction_code}
Số tiền nạp{$amount_formatted} VNĐ
Khuyến mãi{$credit_formatted} VNĐ
Tổng số dư hiện tại{$current_total_formatted} VNĐ
Số dư khuyến mãi hiện tại{$current_credit_formatted} VNĐ

Nếu bạn cần hỗ trợ thêm, vui lòng liên hệ {$support_email} hoặc {$support_phone}.

', + ], + [ + 'name' => 'Chào mừng khách hàng mới', + 'scenario' => 'customer_welcome', + 'subject' => 'Chào mừng bạn đến với {$app_name}', + 'variables' => [ + ['key' => 'app_name', 'label' => 'Tên ứng dụng', 'type' => 'string', 'example' => 'Thế Giới Số'], + ['key' => 'full_name', 'label' => 'Tên khách hàng', 'type' => 'string', 'example' => 'Quý khách'], + ['key' => 'login_url', 'label' => 'Link đăng nhập', 'type' => 'url', 'example' => 'https://portal.tgs.com.vn/dang-nhap'], + ['key' => 'support_email', 'label' => 'Email hỗ trợ', 'type' => 'string', 'example' => 'support@tgs.com.vn'], + ['key' => 'support_phone', 'label' => 'Hotline hỗ trợ', 'type' => 'string', 'example' => '(028) 7309.7379'], + ], + 'message' => $this->wrap('

Chào mừng bạn đến với Portal Thế Giới Số

Chào mừng Quý Khách đến với hệ thống quản trị dịch vụ của Thế Giới Số

Kính chào {$full_name},

Chào mừng Quý khách đến với {$app_name}. Tài khoản của Quý khách đã được khởi tạo thành công và sẵn sàng sử dụng.

Đăng nhập ngay

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

---
Hotline: {$support_phone}
Email: {$support_email}

'), + ], + [ + 'name' => 'Đổi port Remote VPS', + 'scenario' => 'vm_port_change', + 'subject' => 'Port VPS của bạn đã được thay đổi', + 'variables' => [ + ['key' => 'vm_name', 'label' => 'Tên VPS', 'type' => 'string', 'example' => 'vm-example-01'], + ['key' => 'new_port', 'label' => 'Port mới', 'type' => 'string', 'example' => '13389'], + ], + 'message' => '
🔌

Port Remote Desktop đã được thay đổi

Máy chủ: {$vm_name}

Xin chào Quý khách,

Port kết nối Remote Desktop (RDP) của máy chủ ảo {$vm_name} đã được thay đổi thành công.

Máy chủ{$vm_name}
Port mới{$new_port}
Vui lòng cập nhật lại port khi kết nối Remote Desktop tới máy chủ.
', + ], + [ + 'name' => 'Đặt lại mật khẩu VPS thành công', + 'scenario' => 'vm_password_reset_success', + 'subject' => 'Mật khẩu VPS của bạn đã được đặt lại', + 'variables' => [ + ['key' => 'vm_name', 'label' => 'Tên VPS', 'type' => 'string', 'example' => 'vm-example-01'], + ['key' => 'new_password', 'label' => 'Mật khẩu mới', 'type' => 'string', 'example' => 'NewP@ss123'], + ], + 'message' => '
🔑

Mật khẩu VPS đã được đặt lại

Máy chủ: {$vm_name}

Xin chào Quý khách,

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:

Máy chủ{$vm_name}
Mật khẩu mới{$new_password}
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.
', + ], + ]; + + foreach ($rows as $row) { + $exists = DB::table('template')->where('scenario', $row['scenario'])->exists(); + if ($exists) { + continue; + } + + DB::table('template')->insert([ + 'type_id' => 0, + 'scenario' => $row['scenario'], + 'format' => 'html', + 'name' => $row['name'], + 'subject' => $row['subject'], + 'message' => $row['message'], + 'metadata' => json_encode(['variables' => $row['variables']]), + 'status' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + } + + public function down(): void + { + DB::table('template')->whereIn('scenario', [ + 'admin_order_notification', + 'admin_domain_order_new', + 'admin_hosting_account_created', + 'admin_manual_deposit_pending', + 'admin_ticket_notification', + 'admin_trial_registration_request', + 'customer_deposit_success', + 'customer_welcome', + 'vm_port_change', + 'vm_password_reset_success', + ])->delete(); + } +}; diff --git a/database/migrations/2026_09_09_000003_switch_templates_to_double_curly_syntax.php b/database/migrations/2026_09_09_000003_switch_templates_to_double_curly_syntax.php new file mode 100644 index 0000000..8807d26 --- /dev/null +++ b/database/migrations/2026_09_09_000003_switch_templates_to_double_curly_syntax.php @@ -0,0 +1,188 @@ + [ + ['key' => 'customer_name', 'label' => 'Tên khách hàng', 'type' => 'string', 'example' => 'Nguyễn Văn A'], + ['key' => 'vm_name', 'label' => 'Tên VPS', 'type' => 'string', 'example' => 'VPS-001'], + ['key' => 'vmware_id', 'label' => 'VM ID (vCenter)', 'type' => 'string', 'example' => 'vm-12345'], + ['key' => 'suspended_at', 'label' => 'Thời gian tạm ngưng', 'type' => 'string', 'example' => '09/09/2026 10:00'], + ['key' => 'login_url', 'label' => 'Link đăng nhập', 'type' => 'url', 'example' => 'https://portal.tgs.com.vn/dang-nhap'], + ['key' => 'support_email', 'label' => 'Email hỗ trợ', 'type' => 'string', 'example' => 'support@tgs.com.vn'], + ['key' => 'support_phone', 'label' => 'Hotline hỗ trợ', 'type' => 'string', 'example' => '(028) 7309.7379'], + ], + 'ticket_created' => [ + ['key' => 'full_name', 'label' => 'Tên khách hàng', 'type' => 'string', 'example' => 'Nguyễn Văn A'], + ['key' => 'ticket_num', 'label' => 'Mã ticket', 'type' => 'string', 'example' => 'TCK001'], + ['key' => 'subject', 'label' => 'Tiêu đề ticket', 'type' => 'string', 'example' => 'Không truy cập được VPS'], + ['key' => 'room_row', 'label' => '[Tự động] Dòng phòng ban', 'type' => 'html', 'example' => ''], + ['key' => 'ticket_url', 'label' => 'Link xem ticket', 'type' => 'url', 'example' => 'https://portal.tgs.com.vn/tickets/TCK001'], + ['key' => 'app_name', 'label' => 'Tên ứng dụng', 'type' => 'string', 'example' => 'Thế Giới Số'], + ['key' => 'support_email', 'label' => 'Email hỗ trợ', 'type' => 'string', 'example' => 'support@tgs.com.vn'], + ['key' => 'support_phone', 'label' => 'Hotline hỗ trợ', 'type' => 'string', 'example' => '(028) 7309.7379'], + ], + 'service_renewal_reminder' => [ + ['key' => 'client_name', 'label' => 'Tên khách hàng', 'type' => 'string', 'example' => 'Nguyễn Văn A'], + ['key' => 'product_name', 'label' => 'Tên dịch vụ', 'type' => 'string', 'example' => 'Cloud VPS Pro'], + ['key' => 'server_name_suffix', 'label' => '[Tự động] Hậu tố tên máy chủ', 'type' => 'html', 'example' => ''], + ['key' => 'expires_at', 'label' => 'Ngày hết hạn', 'type' => 'string', 'example' => '15/09/2026'], + ['key' => 'price_row', 'label' => '[Tự động] Dòng số tiền gia hạn', 'type' => 'html', 'example' => ''], + ['key' => 'login_url', 'label' => 'Link đăng nhập', 'type' => 'url', 'example' => 'https://portal.tgs.com.vn/dang-nhap'], + ['key' => 'support_email', 'label' => 'Email hỗ trợ', 'type' => 'string', 'example' => 'support@tgs.com.vn'], + ['key' => 'support_phone', 'label' => 'Hotline hỗ trợ', 'type' => 'string', 'example' => '(028) 7309.7379'], + ], + 'domain_order_received' => [ + ['key' => 'customer_name', 'label' => 'Tên khách hàng', 'type' => 'string', 'example' => 'Nguyễn Văn A'], + ['key' => 'company_name', 'label' => 'Tên công ty', 'type' => 'string', 'example' => 'Thế Giới Số'], + ['key' => 'domain_name', 'label' => 'Tên miền', 'type' => 'string', 'example' => 'example.vn'], + ['key' => 'order_num', 'label' => 'Mã đơn hàng', 'type' => 'string', 'example' => 'DH001'], + ['key' => 'order_total', 'label' => 'Tổng tiền', 'type' => 'string', 'example' => '350.000 VNĐ'], + ['key' => 'created_at', 'label' => 'Thời gian', 'type' => 'string', 'example' => '09/09/2026 10:00'], + ['key' => 'order_url', 'label' => 'Link xem đơn hàng', 'type' => 'url', 'example' => 'https://portal.tgs.com.vn/orders/DH001'], + ['key' => 'support_email', 'label' => 'Email hỗ trợ', 'type' => 'string', 'example' => 'support@tgs.com.vn'], + ['key' => 'support_phone', 'label' => 'Hotline hỗ trợ', 'type' => 'string', 'example' => '(028) 7309.7379'], + ], + 'hosting_account_created' => [ + ['key' => 'app_name', 'label' => 'Tên ứng dụng', 'type' => 'string', 'example' => 'Thế Giới Số'], + ['key' => 'full_name', 'label' => 'Tên khách hàng', 'type' => 'string', 'example' => 'Nguyễn Văn A'], + ['key' => 'domain_name', 'label' => 'Tên miền', 'type' => 'string', 'example' => 'example.vn'], + ['key' => 'hosting_plan', 'label' => 'Gói hosting', 'type' => 'string', 'example' => 'Hosting Pro'], + ['key' => 'server_host_row', 'label' => '[Tự động] Dòng máy chủ/FTP host', 'type' => 'html', 'example' => ''], + ['key' => 'ftp_username_row', 'label' => '[Tự động] Dòng tài khoản FTP', 'type' => 'html', 'example' => ''], + ['key' => 'ftp_password_row', 'label' => '[Tự động] Dòng mật khẩu FTP', 'type' => 'html', 'example' => ''], + ['key' => 'ftp_password_notice', 'label' => '[Tự động] Ghi chú bảo mật FTP', 'type' => 'html', 'example' => ''], + ['key' => 'plesk_block', 'label' => '[Tự động] Khối tài khoản Plesk', 'type' => 'html', 'example' => ''], + ['key' => 'login_url', 'label' => 'Link đăng nhập', 'type' => 'url', 'example' => 'https://portal.tgs.com.vn/dang-nhap'], + ['key' => 'support_email', 'label' => 'Email hỗ trợ', 'type' => 'string', 'example' => 'support@tgs.com.vn'], + ['key' => 'support_phone', 'label' => 'Hotline hỗ trợ', 'type' => 'string', 'example' => '(028) 7309.7379'], + ], + 'manual_deposit_status' => [ + ['key' => 'company_name', 'label' => 'Tên công ty', 'type' => 'string', 'example' => 'Thế Giới Số'], + ['key' => 'customer_name', 'label' => 'Tên khách hàng', 'type' => 'string', 'example' => 'Nguyễn Văn A'], + ['key' => 'transaction_code', 'label' => 'Mã giao dịch', 'type' => 'string', 'example' => 'DEP001'], + ['key' => 'amount_formatted', 'label' => 'Số tiền', 'type' => 'string', 'example' => '500.000'], + ['key' => 'status_label', 'label' => 'Nhãn trạng thái', 'type' => 'string', 'example' => 'Đã duyệt'], + ['key' => 'status_color', 'label' => '[Tự động] Màu chữ trạng thái', 'type' => 'string', 'example' => '#16a34a'], + ['key' => 'status_bg', 'label' => '[Tự động] Màu nền trạng thái', 'type' => 'string', 'example' => '#dcfce7'], + ['key' => 'status_message', 'label' => 'Nội dung thông báo', 'type' => 'string', 'example' => 'Yêu cầu nạp tiền đã được duyệt.'], + ['key' => 'deposit_date', 'label' => 'Thời gian nạp', 'type' => 'string', 'example' => '09/09/2026 10:00'], + ['key' => 'processed_date_row', 'label' => '[Tự động] Dòng thời gian xử lý', 'type' => 'html', 'example' => ''], + ['key' => 'portal_url', 'label' => 'Link trang nạp tiền', 'type' => 'url', 'example' => 'https://portal.tgs.com.vn/nap-tien'], + ['key' => 'support_email', 'label' => 'Email hỗ trợ', 'type' => 'string', 'example' => 'support@tgs.com.vn'], + ['key' => 'support_phone', 'label' => 'Hotline hỗ trợ', 'type' => 'string', 'example' => '(028) 7309.7379'], + ], + 'order_payment_success' => [ + ['key' => 'customer_name', 'label' => 'Tên khách hàng', 'type' => 'string', 'example' => 'Nguyễn Văn A'], + ['key' => 'order_num', 'label' => 'Mã đơn hàng', 'type' => 'string', 'example' => 'DH001'], + ['key' => 'amount_formatted', 'label' => 'Tổng thanh toán', 'type' => 'string', 'example' => '990.000'], + ['key' => 'vm_block', 'label' => '[Tự động] Khối thông tin VPS', 'type' => 'html', 'example' => ''], + ['key' => 'addon_block', 'label' => '[Tự động] Khối addon', 'type' => 'html', 'example' => ''], + ['key' => 'support_email', 'label' => 'Email hỗ trợ', 'type' => 'string', 'example' => 'support@tgs.com.vn'], + ['key' => 'support_phone', 'label' => 'Hotline hỗ trợ', 'type' => 'string', 'example' => '(028) 7309.7379'], + ], + 'password_reset_admin' => [ + ['key' => 'app_name', 'label' => 'Tên ứng dụng', 'type' => 'string', 'example' => 'Thế Giới Số'], + ['key' => 'full_name', 'label' => 'Tên khách hàng', 'type' => 'string', 'example' => 'Nguyễn Văn A'], + ['key' => 'email', 'label' => 'Email tài khoản', 'type' => 'string', 'example' => 'a@example.com'], + ['key' => 'password', 'label' => 'Mật khẩu mới', 'type' => 'string', 'example' => 'NewP@ss123'], + ['key' => 'login_url', 'label' => 'Link đăng nhập', 'type' => 'url', 'example' => 'https://portal.tgs.com.vn/dang-nhap'], + ['key' => 'support_email', 'label' => 'Email hỗ trợ', 'type' => 'string', 'example' => 'support@tgs.com.vn'], + ['key' => 'support_phone', 'label' => 'Hotline hỗ trợ', 'type' => 'string', 'example' => '(028) 7309.7379'], + ], + 'snapshot_action' => [ + ['key' => 'app_name', 'label' => 'Tên ứng dụng', 'type' => 'string', 'example' => 'Thế Giới Số'], + ['key' => 'full_name', 'label' => 'Tên khách hàng', 'type' => 'string', 'example' => 'Nguyễn Văn A'], + ['key' => 'vm_name', 'label' => 'Tên VPS', 'type' => 'string', 'example' => 'vm-example-01'], + ['key' => 'snapshot_name', 'label' => 'Tên snapshot', 'type' => 'string', 'example' => 'snap-2026-09-09'], + ['key' => 'action_at', 'label' => 'Thời gian thao tác', 'type' => 'string', 'example' => '09/09/2026 10:00'], + ['key' => 'action_icon', 'label' => '[Tự động] Icon', 'type' => 'string', 'example' => '📸'], + ['key' => 'action_title', 'label' => '[Tự động] Tiêu đề theo hành động', 'type' => 'string', 'example' => 'Tạo Snapshot thành công'], + ['key' => 'action_desc', 'label' => '[Tự động] Mô tả theo hành động', 'type' => 'string', 'example' => 'Snapshot đã được tạo thành công'], + ['key' => 'action_status', 'label' => '[Tự động] Trạng thái theo hành động', 'type' => 'string', 'example' => 'Tạo thành công ✓'], + ['key' => 'login_url', 'label' => 'Link đăng nhập', 'type' => 'url', 'example' => 'https://portal.tgs.com.vn/dang-nhap'], + ['key' => 'support_email', 'label' => 'Email hỗ trợ', 'type' => 'string', 'example' => 'support@tgs.com.vn'], + ['key' => 'support_phone', 'label' => 'Hotline hỗ trợ', 'type' => 'string', 'example' => '(028) 7309.7379'], + ], + 'verify_account_welcome' => [ + ['key' => 'app_name', 'label' => 'Tên ứng dụng', 'type' => 'string', 'example' => 'Thế Giới Số'], + ['key' => 'full_name', 'label' => 'Tên khách hàng', 'type' => 'string', 'example' => 'Nguyễn Văn A'], + ['key' => 'verify_code', 'label' => 'Mã xác thực', 'type' => 'string', 'example' => '482913'], + ['key' => 'support_email', 'label' => 'Email hỗ trợ', 'type' => 'string', 'example' => 'support@tgs.com.vn'], + ['key' => 'support_phone', 'label' => 'Hotline hỗ trợ', 'type' => 'string', 'example' => '(028) 7309.7379'], + ], + 'vm_creation_failed' => [ + ['key' => 'app_name', 'label' => 'Tên ứng dụng', 'type' => 'string', 'example' => 'Thế Giới Số'], + ['key' => 'full_name', 'label' => 'Tên khách hàng', 'type' => 'string', 'example' => 'Nguyễn Văn A'], + ['key' => 'order_num', 'label' => 'Mã đơn hàng', 'type' => 'string', 'example' => 'DH001'], + ['key' => 'product_name', 'label' => 'Tên sản phẩm', 'type' => 'string', 'example' => 'Cloud VPS Pro'], + ['key' => 'invoice_url', 'label' => 'Link xem hoá đơn', 'type' => 'url', 'example' => 'https://portal.tgs.com.vn/hoa-don/1'], + ['key' => 'support_email', 'label' => 'Email hỗ trợ', 'type' => 'string', 'example' => 'support@tgs.com.vn'], + ['key' => 'support_phone', 'label' => 'Hotline hỗ trợ', 'type' => 'string', 'example' => '(028) 7309.7379'], + ], + 'vm_password_changed' => [ + ['key' => 'app_name', 'label' => 'Tên ứng dụng', 'type' => 'string', 'example' => 'Thế Giới Số'], + ['key' => 'full_name', 'label' => 'Tên khách hàng', 'type' => 'string', 'example' => 'Nguyễn Văn A'], + ['key' => 'vm_name', 'label' => 'Tên VPS', 'type' => 'string', 'example' => 'vm-example-01'], + ['key' => 'new_password_row', 'label' => '[Tự động] Dòng mật khẩu mới', 'type' => 'html', 'example' => ''], + ['key' => 'login_url', 'label' => 'Link đăng nhập', 'type' => 'url', 'example' => 'https://portal.tgs.com.vn/dang-nhap'], + ['key' => 'support_email', 'label' => 'Email hỗ trợ', 'type' => 'string', 'example' => 'support@tgs.com.vn'], + ['key' => 'support_phone', 'label' => 'Hotline hỗ trợ', 'type' => 'string', 'example' => '(028) 7309.7379'], + ], + 'vm_rebuild_failed' => [ + ['key' => 'app_name', 'label' => 'Tên ứng dụng', 'type' => 'string', 'example' => 'Thế Giới Số'], + ['key' => 'full_name', 'label' => 'Tên khách hàng', 'type' => 'string', 'example' => 'Nguyễn Văn A'], + ['key' => 'vm_name', 'label' => 'Tên VPS', 'type' => 'string', 'example' => 'vm-example-01'], + ['key' => 'ip', 'label' => 'Địa chỉ IP', 'type' => 'string', 'example' => '1.2.3.4'], + ['key' => 'service_url', 'label' => 'Link xem dịch vụ', 'type' => 'url', 'example' => 'https://portal.tgs.com.vn/dich-vu'], + ['key' => 'support_email', 'label' => 'Email hỗ trợ', 'type' => 'string', 'example' => 'support@tgs.com.vn'], + ['key' => 'support_phone', 'label' => 'Hotline hỗ trợ', 'type' => 'string', 'example' => '(028) 7309.7379'], + ], + ]; + + public function up(): void + { + Template::query()->whereNotNull('scenario')->get()->each(function (Template $template) { + $template->subject = $this->convert((string) $template->subject); + $template->message = $this->convert((string) $template->message); + + if (isset(self::LEGACY_VARIABLES[$template->scenario])) { + $metadata = $template->metadata ?? []; + $metadata['variables'] = self::LEGACY_VARIABLES[$template->scenario]; + $template->metadata = $metadata; + } + + $template->save(); + }); + } + + public function down(): void + { + Template::query()->whereNotNull('scenario')->get()->each(function (Template $template) { + $template->subject = $this->revert((string) $template->subject); + $template->message = $this->revert((string) $template->message); + + if (isset(self::LEGACY_VARIABLES[$template->scenario])) { + $metadata = $template->metadata ?? []; + unset($metadata['variables']); + $template->metadata = $metadata ?: null; + } + + $template->save(); + }); + } + + private function convert(string $content): string + { + return preg_replace('/\{\$([a-zA-Z_][a-zA-Z0-9_]*)\}/', '{{$1}}', $content) ?? $content; + } + + private function revert(string $content): string + { + return preg_replace('/\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/', '{\$$1}', $content) ?? $content; + } +}; diff --git a/database/migrations/2026_09_09_000004_backfill_legacy_hardcoded_id_templates.php b/database/migrations/2026_09_09_000004_backfill_legacy_hardcoded_id_templates.php new file mode 100644 index 0000000..aff951a --- /dev/null +++ b/database/migrations/2026_09_09_000004_backfill_legacy_hardcoded_id_templates.php @@ -0,0 +1,102 @@ + [scenario, variables[]] + private const BACKFILL = [ + 1 => [ + 'scenario' => 'customer_signup_verify', + 'variables' => [ + ['key' => 'company_name', 'label' => 'Tên công ty', 'type' => 'string', 'example' => 'Thế Giới Số'], + ['key' => 'company_logo', 'label' => 'Logo công ty (URL)', 'type' => 'url', 'example' => ''], + ['key' => 'link_verify', 'label' => 'Link xác thực tài khoản', 'type' => 'url', 'example' => 'https://portal.tgs.com.vn/verify/xxx'], + ], + ], + 3 => [ + 'scenario' => 'hosting_information', + 'variables' => [ + ['key' => 'company_name', 'label' => 'Tên công ty', 'type' => 'string', 'example' => 'Thế Giới Số'], + ['key' => 'company_logo', 'label' => 'Logo công ty (URL)', 'type' => 'url', 'example' => ''], + ['key' => 'client_name', 'label' => 'Tên khách hàng', 'type' => 'string', 'example' => 'Nguyễn Văn A'], + ['key' => 'product_name', 'label' => 'Tên sản phẩm', 'type' => 'string', 'example' => 'Hosting Pro'], + ['key' => 'product_billing', 'label' => 'Chu kỳ thanh toán', 'type' => 'string', 'example' => 'Hàng tháng'], + ['key' => 'product_price', 'label' => 'Giá dịch vụ', 'type' => 'string', 'example' => '150.000'], + ['key' => 'service_host', 'label' => 'IP máy chủ', 'type' => 'string', 'example' => '1.2.3.4'], + ['key' => 'service_username', 'label' => 'Username', 'type' => 'string', 'example' => 'user01'], + ['key' => 'service_password', 'label' => 'Mật khẩu dịch vụ', 'type' => 'string', 'example' => 'P@ss123'], + ['key' => 'service_due_exp', 'label' => 'Ngày hết hạn', 'type' => 'string', 'example' => '09/09/2026'], + ['key' => 'ftp_account', 'label' => 'Tài khoản FTP', 'type' => 'string', 'example' => 'ftpuser'], + ['key' => 'ftp_password', 'label' => 'Mật khẩu FTP', 'type' => 'string', 'example' => 'Ftp@123'], + ], + ], + 4 => [ + 'scenario' => 'customer_password_reset', + 'variables' => [ + ['key' => 'company_name', 'label' => 'Tên công ty', 'type' => 'string', 'example' => 'Thế Giới Số'], + ['key' => 'company_logo', 'label' => 'Logo công ty (URL)', 'type' => 'url', 'example' => ''], + ['key' => 'password', 'label' => 'Mật khẩu mới', 'type' => 'string', 'example' => 'NewP@ss123'], + ], + ], + 9 => [ + 'scenario' => 'service_expiry', + 'variables' => [ + ['key' => 'company_name', 'label' => 'Tên công ty', 'type' => 'string', 'example' => 'Thế Giới Số'], + ['key' => 'company_logo', 'label' => 'Logo công ty (URL)', 'type' => 'url', 'example' => ''], + ['key' => 'product_name', 'label' => 'Tên sản phẩm', 'type' => 'string', 'example' => 'Cloud VPS Pro'], + ['key' => 'service_due_exp', 'label' => 'Ngày hết hạn', 'type' => 'string', 'example' => '09/09/2026'], + ['key' => 'service_remaining', 'label' => 'Số ngày còn lại', 'type' => 'string', 'example' => '5'], + ], + ], + ]; + + public function up(): void + { + foreach (self::BACKFILL as $id => $config) { + $template = Template::find($id); + + if (!$template) { + continue; + } + + $template->scenario = $config['scenario']; + $template->subject = $this->convert((string) $template->subject); + $template->message = $this->convert((string) $template->message); + $metadata = $template->metadata ?? []; + $metadata['variables'] = $config['variables']; + $template->metadata = $metadata; + $template->save(); + } + } + + public function down(): void + { + foreach (self::BACKFILL as $id => $config) { + $template = Template::find($id); + + if (!$template || $template->scenario !== $config['scenario']) { + continue; + } + + $template->scenario = null; + $template->subject = $this->revert((string) $template->subject); + $template->message = $this->revert((string) $template->message); + $metadata = $template->metadata ?? []; + unset($metadata['variables']); + $template->metadata = $metadata ?: null; + $template->save(); + } + } + + private function convert(string $content): string + { + return preg_replace('/\{\$([a-zA-Z_][a-zA-Z0-9_]*)\}/', '{{$1}}', $content) ?? $content; + } + + private function revert(string $content): string + { + return preg_replace('/\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/', '{\$$1}', $content) ?? $content; + } +}; diff --git a/database/migrations/2026_09_09_000005_backfill_remaining_template_variables.php b/database/migrations/2026_09_09_000005_backfill_remaining_template_variables.php new file mode 100644 index 0000000..e25a48b --- /dev/null +++ b/database/migrations/2026_09_09_000005_backfill_remaining_template_variables.php @@ -0,0 +1,81 @@ + variables[] (chỉ set metadata; convert {$var}->{{var}} nếu nội dung còn cú pháp cũ) + private const BACKFILL = [ + 2 => [ + ['key' => 'company_name', 'label' => 'Tên công ty', 'type' => 'string', 'example' => 'Thế Giới Số'], + ['key' => 'company_logo', 'label' => 'Logo công ty (URL)', 'type' => 'url', 'example' => ''], + ['key' => 'product_name', 'label' => 'Tên sản phẩm', 'type' => 'string', 'example' => 'Cloud VPS Pro'], + ['key' => 'product_billing', 'label' => 'Chu kỳ thanh toán', 'type' => 'string', 'example' => 'Hàng tháng'], + ['key' => 'product_price', 'label' => 'Giá dịch vụ', 'type' => 'string', 'example' => '420.000'], + ['key' => 'service_host', 'label' => 'IP máy chủ', 'type' => 'string', 'example' => '203.113.x.x'], + ['key' => 'service_username', 'label' => 'Username', 'type' => 'string', 'example' => 'root'], + ['key' => 'service_port', 'label' => 'Port', 'type' => 'string', 'example' => '22'], + ['key' => 'service_password', 'label' => 'Mật khẩu', 'type' => 'string', 'example' => 'P@ssw0rd!'], + ['key' => 'os_name', 'label' => 'Hệ điều hành', 'type' => 'string', 'example' => 'Ubuntu 22.04'], + ['key' => 'service_due_exp', 'label' => 'Ngày hết hạn', 'type' => 'string', 'example' => '09/09/2026'], + ], + 6 => [ + ['key' => 'company_name', 'label' => 'Tên công ty', 'type' => 'string', 'example' => 'Thế Giới Số'], + ['key' => 'domain_name', 'label' => 'Tên miền', 'type' => 'string', 'example' => 'example.vn'], + ['key' => 'order_num', 'label' => 'Mã đơn hàng', 'type' => 'string', 'example' => 'DH001'], + ['key' => 'order_total', 'label' => 'Tổng tiền', 'type' => 'string', 'example' => '350.000 VNĐ'], + ['key' => 'customer_name', 'label' => 'Tên khách hàng', 'type' => 'string', 'example' => 'Nguyễn Văn A'], + ['key' => 'customer_email', 'label' => 'Email khách hàng', 'type' => 'string', 'example' => 'a@example.com'], + ['key' => 'created_at', 'label' => 'Thời gian', 'type' => 'string', 'example' => '09/09/2026 10:00:00'], + ['key' => 'admin_url', 'label' => 'Link xem đơn (admin)', 'type' => 'url', 'example' => 'https://portal.tgs.com.vn/admin/orders'], + ], + 23 => [ + ['key' => 'hero_image', 'label' => 'Ảnh banner', 'type' => 'url', 'example' => 'https://client.tgs.com.vn/index.php?rp=/images/em/368_30.4-CP.png'], + ['key' => 'holiday_name', 'label' => 'Tên ngày lễ', 'type' => 'string', 'example' => 'Tết Dương lịch'], + ['key' => 'holiday_from', 'label' => 'Ngày bắt đầu nghỉ', 'type' => 'string', 'example' => 'Thứ Năm, 01/01/2026'], + ['key' => 'holiday_to', 'label' => 'Ngày kết thúc nghỉ', 'type' => 'string', 'example' => 'Thứ Năm, 01/01/2026'], + ['key' => 'work_again', 'label' => 'Ngày làm việc trở lại', 'type' => 'string', 'example' => 'Thứ Sáu, 02/01/2026'], + ['key' => 'ticket_url', 'label' => 'Link gửi ticket', 'type' => 'url', 'example' => 'https://portal.tgs.com.vn/submitticket.php'], + ['key' => 'support_email', 'label' => 'Email hỗ trợ', 'type' => 'string', 'example' => 'support@tgs.com.vn'], + ['key' => 'hotline', 'label' => 'Hotline hỗ trợ', 'type' => 'string', 'example' => '(028) 7309.7379'], + ], + ]; + + public function up(): void + { + foreach (self::BACKFILL as $id => $variables) { + $template = Template::find($id); + if (!$template) { + continue; + } + + $template->subject = $this->convert((string) $template->subject); + $template->message = $this->convert((string) $template->message); + $metadata = $template->metadata ?? []; + $metadata['variables'] = $variables; + $template->metadata = $metadata; + $template->save(); + } + } + + public function down(): void + { + foreach (self::BACKFILL as $id => $variables) { + $template = Template::find($id); + if (!$template) { + continue; + } + + $metadata = $template->metadata ?? []; + unset($metadata['variables']); + $template->metadata = $metadata ?: null; + $template->save(); + } + } + + private function convert(string $content): string + { + return preg_replace('/\{\$([a-zA-Z_][a-zA-Z0-9_]*)\}/', '{{$1}}', $content) ?? $content; + } +}; diff --git a/database/seeders/AdminSeeder.php b/database/seeders/AdminSeeder.php index 0a17d29..798424a 100644 --- a/database/seeders/AdminSeeder.php +++ b/database/seeders/AdminSeeder.php @@ -14,16 +14,62 @@ class AdminSeeder extends Seeder */ public function run(): void { - DB::table('user')->insert([ - 'firstname' => 'Admin', - 'lastname' => 'System', - 'username' => 'admin', - 'email' => 'admin@tgs.com.vn', - 'email_verified_at' => now(), - 'password' => Hash::make('!admin@123!@#'), - 'status' => 1, - 'created_at' => now(), - 'updated_at' => now(), - ]); + $users = [ + [ + 'fullname' => 'Admin', + 'username' => 'admin', + 'email' => 'admin@tgs.com.vn', + 'password' => '!admin@123!@#', + ], + [ + 'fullname' => 'Nguyễn Cao Luyện', + 'username' => 'nguyencaoluyen', + 'email' => 'nguyencaoluyen@tgs.com.vn', + 'password' => 'nguyencaoluyen@123(#*', + ], + [ + 'fullname' => 'Trương Văn Phương', + 'username' => 'truongvanphuong', + 'email' => 'truongvanphuong@tgs.com.vn', + 'password' => 'truongvanphuong@123(#*', + ], + [ + 'fullname' => 'Nguyễn Thị Thêm', + 'username' => 'nguyenthithem', + 'email' => 'nguyenthithem@tgs.com.vn', + 'password' => 'nguyenthithem@123(#*', + ], + [ + 'fullname' => 'Đỗ Thị Vân Anh', + 'username' => 'dothivanan', + 'email' => 'dothivanan@tgs.com.vn', + 'password' => 'dothivanan@123(#*', + ], + [ + 'fullname' => 'Lê Huỳnh Ngọc Thảo', + 'username' => 'lehuynhngocthao', + 'email' => 'lehuynhngocthao@tgs.com.vn', + 'password' => 'lehuynhngocthao@123(#*', + ], + [ + 'fullname' => 'Đinh Thanh Tùng', + 'username' => 'dinhthanhtung', + 'email' => 'dinhthanhtung@tgs.com.vn', + 'password' => 'dinhthanhtung@123(#*', + ], + ]; + + foreach ($users as $user) { + DB::table('user')->insert([ + 'fullname' => $user['fullname'], + 'username' => $user['username'], + 'email' => $user['email'], + 'email_verified_at' => now(), + 'password' => Hash::make($user['password']), + 'status' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } } } diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 43720d6..33f1b43 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -15,7 +15,6 @@ class DatabaseSeeder extends Seeder */ public function run(): void { - $this->call(BankTransferPaymentSeeder::class); $this->call(AdminSeeder::class); $this->call(RoleSeeder::class); $this->call(RolesAndPermissions::class); @@ -23,10 +22,5 @@ class DatabaseSeeder extends Seeder $user->assignRole('Full Administrator'); $this->call(TemplateSeeder::class); $this->call(Setting::class); - $this->call(ProductCategorySeeder::class); - $this->call(ProductTypeFieldSeeder::class); - $this->call(ProductMockupSeeder::class); - $this->call(ItSupportCatalogSeeder::class); - } } diff --git a/database/seeders/TemplateSeeder.php b/database/seeders/TemplateSeeder.php index 5adad07..97df7c9 100644 --- a/database/seeders/TemplateSeeder.php +++ b/database/seeders/TemplateSeeder.php @@ -19,70 +19,270 @@ class TemplateSeeder extends Seeder [ 'name' => 'Client Signup Verify Email', 'subject' => 'Thông tin kích hoạt tài khoản khách hàng', - 'message' => '
 
{$company_name}

Kính chào Quý khách

Cảm ơn bạn đã đăng ký tài khoản tại {$company_name}! Để hoàn tất quá trình đăng ký, vui lòng truy cập đường dẫn dưới đây để xác minh tài khoản của bạn.
XÁC NHẬN TÀI KHOẢN

Hoặc bạn có thể coppy đường dẫn sau đưa lên trình duyệt để active tài khoản:

{$link_verify}

Sau khi xác minh, bạn sẽ có thể sử dụng tài khoản của mình và truy cập các dịch vụ của chúng tôi.

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

Lưu ý:

 

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

 

 
', + 'message' => + '

Kính chào Quý khách

Cảm ơn bạn đã đăng ký tài khoản tại {$company_name}! Để hoàn tất quá trình đăng ký, vui lòng truy cập đường dẫn dưới đây để xác minh tài khoản của bạn.

XÁC NHẬN TÀI KHOẢN

Hoặc bạn có thể copy đường dẫn sau đưa lên trình duyệt để active tài khoản:

{$link_verify}

Sau khi xác minh, bạn sẽ có thể sử dụng tài khoản của mình và truy cập các dịch vụ của chúng tôi.

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

Lưu ý:

  • Đây là tin nhắn tự động. Vui lòng không trả lời tin nhắn này!
', 'type_id' => 0, - 'status' => 1 + 'status' => 1, ], [ 'name' => 'Server information', 'subject' => 'Thông tin truy cập VPS {$os_name}', - 'message' => '
 
{$company_name}

Kính gửi Quý khách

Cảm ơn Quý khách đã tin tưởng và sử dụng dịch vụ của {$company_name}, Cloud VPS của Quý khách đã được khởi tạo thành công, Quý khách vui lòng sử dụng thông  tin sau vào quản lý.

Thông tin gói VPS:

  • {$product_name}
  • Chu kỳ thanh toán: {$product_billing}
  • Giá: {$product_price} đ

Thông tin truy cập VPS:

  • IP: {$service_host}
  • User: {$service_username}
  • Port: {$service_port}
  • Password: {$service_password}
  • Hệ điều hành: {$os_name}
  • Ngày hết hạn: {$service_due_exp}

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

Trân trọng!

 
', + 'message' => + '

Kính gửi Quý khách

Cảm ơn Quý khách đã tin tưởng và sử dụng dịch vụ của {$company_name}, Cloud VPS của Quý khách đã được khởi tạo thành công, Quý khách vui lòng sử dụng thông tin sau vào quản lý.

Thông tin gói VPS:

  • {$product_name}
  • Chu kỳ thanh toán: {$product_billing}
  • Giá: {$product_price} đ

Thông tin truy cập VPS:

  • IP: {$service_host}
  • User: {$service_username}
  • Port: {$service_port}
  • Password: {$service_password}
  • Hệ điều hành: {$os_name}
  • Ngày hết hạn: {$service_due_exp}

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

Trân trọng!

', 'type_id' => 4, - 'status' => 1 + 'status' => 1, ], [ 'name' => 'Hosting Information', 'subject' => 'Thông tin truy cập {$product_name}', - 'message' => '
 
{$company_name}

Kính gửi Quý khách

Cảm ơn Quý khách đã tin tưởng và sử dụng dịch vụ của {$company_name}, Hosting của Quý khách đã được khởi tạo thành công, Quý khách vui lòng sử dụng thông  tin sau vào quản lý.

Thông tin gói Hosting:

  • {$product_name}
  • Chu kỳ thanh toán: {$product_billing}
  • Giá: {$product_price} đ

Thông tin truy cập Hosting:

  • IP: {$service_host}
  • User: {$service_username}
  • Password: {$service_password}
  • Ngày hết hạn: {$service_due_exp}

Thông tin truy cập FTP Hosting:

  • IP: {$service_host}
  • User: {$ftp_account}
  • Password: {$ftp_password}

Cảm ơn quý khách đã sử dụng dịch vụ của {$company_name}.

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

Trân trọng!

 
', + 'message' => + '

Kính gửi Quý khách

Cảm ơn Quý khách đã tin tưởng và sử dụng dịch vụ của {$company_name}, Hosting của Quý khách đã được khởi tạo thành công, Quý khách vui lòng sử dụng thông tin sau vào quản lý.

Thông tin gói Hosting:

  • {$product_name}
  • Chu kỳ thanh toán: {$product_billing}
  • Giá: {$product_price} đ

Thông tin truy cập Hosting:

  • IP: {$service_host}
  • User: {$service_username}
  • Password: {$service_password}
  • Ngày hết hạn: {$service_due_exp}

Thông tin truy cập FTP Hosting:

  • IP: {$service_host}
  • User: {$ftp_account}
  • Password: {$ftp_password}

Cảm ơn quý khách đã sử dụng dịch vụ của {$company_name}.

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

Trân trọng!

', 'type_id' => 3, - 'status' => 1 + 'status' => 1, ], [ 'name' => 'Customer Password Reset', 'subject' => 'Khôi phục mật khẩu | Password Recovery', - 'message' => '
 
{$company_name}

Kính chào Quý khách

Mật khẩu của quý khách đã được khôi phục thành công, vui lòng truy sử dụng mật khẩu sau đây để truy cập vào tài khoản của bạn.

{$password}

Vui lòng thay đổi mật khẩu của quý khách ngay sau khi đăng nhập.

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

Lưu ý:

 

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

 

 
', + 'message' => + '

Kính chào Quý khách

Mật khẩu của quý khách đã được khôi phục thành công, vui lòng sử dụng mật khẩu sau đây để truy cập vào tài khoản của bạn.

{$password}

Vui lòng thay đổi mật khẩu của quý khách ngay sau khi đăng nhập.

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

Lưu ý:

  • Đây là tin nhắn tự động. Vui lòng không trả lời tin nhắn này!
', 'type_id' => 0, - 'status' => 1 + 'status' => 1, ], [ 'name' => 'Order Created', 'subject' => 'Xác nhận đơn hàng #{$order_num}', - 'message' => '
{$company_name}

Kính chào {$client_name},

Cảm ơn bạn đã đặt hàng tại {$company_name}! Đơn hàng của bạn đã được tiếp nhận thành công.

Thông tin đơn hàng:

  • Mã đơn hàng: {$order_num}
  • Mã hóa đơn: {$invoice_num}
  • Tổng tiền: {$order_total} đ

Chúng tôi sẽ xử lý đơn hàng của bạn trong thời gian sớm nhất và thông báo khi dịch vụ được kích hoạt.

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

Lưu ý:

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

', + 'message' => + '

Kính chào {$client_name},

Cảm ơn bạn đã đặt hàng tại {$company_name}! Đơn hàng của bạn đã được tiếp nhận thành công.

Thông tin đơn hàng:

  • Mã đơn hàng: {$order_num}
  • Mã hóa đơn: {$invoice_num}
  • Tổng tiền: {$order_total} đ

Chúng tôi sẽ xử lý đơn hàng của bạn trong thời gian sớm nhất và thông báo khi dịch vụ được kích hoạt.

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

Lưu ý:

  • Đây là tin nhắn tự động. Vui lòng không trả lời tin nhắn này!
', 'type_id' => 0, - 'status' => 1 + 'status' => 1, ], [ 'name' => 'Domain Order New', 'subject' => '[ADMIN] Đơn đăng ký tên miền mới - {$domain_name}', - 'message' => '
{$company_name}

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

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

Thông tin đơn hàng:

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

Lưu ý:

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

', + 'message' => + '

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

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

Thông tin đơn hàng:

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

Lưu ý:

  • Đây là tin nhắn tự động. Vui lòng không trả lời tin nhắn này!
', 'type_id' => 0, - 'status' => 1 + 'status' => 1, ], [ 'name' => 'Domain Order Approved', 'subject' => 'Tên miền {$domain_name} đã được duyệt đăng ký', - 'message' => '
{$company_name}

Kính chào {$client_name},

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

Thông tin tên miền:

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

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

Lưu ý:

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

', + 'message' => + '
{$company_name}

Kính chào {$client_name},

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

Thông tin tên miền:

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

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

Lưu ý:

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

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

Kính chào {$client_name},

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

Thông tin đơn hàng:

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

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

Lưu ý:

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

', + 'message' => + '
{$company_name}

Kính chào {$client_name},

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

Thông tin đơn hàng:

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

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

Lưu ý:

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

', 'type_id' => 0, - 'status' => 1 + 'status' => 1, ], [ 'name' => 'Service Expiry', 'subject' => 'Thông báo dịch vụ sắp hết hạn', - 'message' => '
 
{$company_name}

Kính gửi Quý khách

{$company_name} xin gửi Quý khách danh sách các dịch vụ sắp hết hạn, cụ thể như sau:​​​

Thông tin gói dịch vụ:

  • {$product_name}
  • Ngày hết hạn: {$service_due_exp}
  • Thời hạn: Còn {$service_remaining} ngày

Vui lòng gia hạn dịch vụ trước thời hạn để không ảnh hưởng dịch vụ.

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

Trân trọng.

 
', + 'message' => + '
 
{$company_name}

Kính gửi Quý khách

{$company_name} xin gửi Quý khách danh sách các dịch vụ sắp hết hạn, cụ thể như sau:​​​

Thông tin gói dịch vụ:

  • {$product_name}
  • Ngày hết hạn: {$service_due_exp}
  • Thời hạn: Còn {$service_remaining} ngày

Vui lòng gia hạn dịch vụ trước thời hạn để không ảnh hưởng dịch vụ.

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

Trân trọng.

 
', 'type_id' => 0, - 'status' => 1 - ] + 'status' => 1, + ], ]; - foreach ($Templates as $item) - { + // Header/Footer dùng chung cho các template mới (style "700px card") + $header = <<<'HTML' +
+HTML; + + $footer = <<<'HTML' +
Thư gửi từ Công Ty Cổ Phần Giải Pháp Công Nghệ Thế Giới Số
+HTML; + + // Header/Footer dùng chung cho các template dạng banner cam (CacDV/ThongTin) + $bannerFooter = <<<'HTML' +
 

CÔNG TY CỔ PHẦN GIẢI PHÁP CÔNG NGHỆ THẾ GIỚI SỐ
Địa chỉ: 128/38 Tân Hương, Phường Phú Thọ Hòa, Thành Phố Hồ Chí Minh
Tel: {$support_phone}
Email: {$support_email}

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

 
+HTML; + + $bannerHead = <<<'HTML' + diff --git a/resources/views/emails/partials/email-footer.blade.php b/resources/views/emails/partials/email-footer.blade.php new file mode 100644 index 0000000..c0aa5d5 --- /dev/null +++ b/resources/views/emails/partials/email-footer.blade.php @@ -0,0 +1,72 @@ +@php + $supportEmail = $supportEmail ?? 'support@tgs.com.vn'; + $supportPhone = $supportPhone ?? '(028) 7309. 7379'; +@endphp + + + diff --git a/resources/views/emails/partials/email-header.blade.php b/resources/views/emails/partials/email-header.blade.php new file mode 100644 index 0000000..dd30e1e --- /dev/null +++ b/resources/views/emails/partials/email-header.blade.php @@ -0,0 +1,15 @@ + + + diff --git a/resources/views/emails/template.blade.php b/resources/views/emails/template.blade.php deleted file mode 100644 index 7c2eab4..0000000 --- a/resources/views/emails/template.blade.php +++ /dev/null @@ -1 +0,0 @@ -{!! $template !!} diff --git a/routes/api.php b/routes/api.php index ac5ab3c..603fcc1 100644 --- a/routes/api.php +++ b/routes/api.php @@ -617,12 +617,15 @@ Route::prefix('v1') ->group(function () { Route::get('/', 'index'); Route::post('/', 'store'); + Route::get('scenarios', 'scenarios'); Route::get('email-settings', 'getEmailSettings'); Route::put('email-settings', 'updateEmailSettings'); Route::get('{id}', 'show'); Route::put('{id}', 'update'); Route::delete('{id}', 'destroy'); Route::get('{id}/preview-invoice', 'previewInvoiceJson'); + Route::post('{id}/preview', 'preview'); + Route::post('{id}/send-test', 'sendTest'); }); // Notification Email Templates — block-based editor diff --git a/tests/Feature/Api/Service/RenewServiceTest.php b/tests/Feature/Api/Service/RenewServiceTest.php index ee66ea4..298b752 100644 --- a/tests/Feature/Api/Service/RenewServiceTest.php +++ b/tests/Feature/Api/Service/RenewServiceTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Tests\Feature\Api\Service; +use App\Enums\Invoice\InvoiceStatus; use App\Enums\Order\OrderStatus; use App\Models\Customer; use App\Models\Invoice; @@ -123,4 +124,105 @@ class RenewServiceTest extends TestCase $response->assertStatus(400); $this->assertSame(0, Invoice::where('customer_id', $this->customer->id)->count()); } + + public function test_renew_reuses_original_order_instead_of_creating_a_new_one(): void + { + $service = $this->createServiceWithOldPrice(90_000); + $originalOrderId = $service->orderItem->order_id; + + ProductPricings::create([ + 'product_id' => $this->product->id, + 'billing_cycle' => 'monthly', + 'price' => 100_000, + 'is_active' => true, + ]); + + $response = $this->actingAsCustomer()->postJson( + "/v1/client/dich-vu/{$service->id}/renew", + ['months' => 1], + ); + + $response->assertOk(); + $response->assertJsonPath('data.order_id', $originalOrderId); + + // Order 1:N Invoice — no second Order row is ever created for a renewal. + $this->assertSame(1, Order::where('customer_id', $this->customer->id)->count()); + $this->assertDatabaseHas('invoices', [ + 'id' => $response->json('data.invoice_id'), + 'order_id' => $originalOrderId, + 'invoice_type' => 2, + ]); + } + + public function test_renewal_invoice_has_a_billing_period_and_is_not_duplicated_for_the_same_period(): void + { + $service = $this->createServiceWithOldPrice(90_000); + + ProductPricings::create([ + 'product_id' => $this->product->id, + 'billing_cycle' => 'monthly', + 'price' => 100_000, + 'is_active' => true, + ]); + + $response = $this->actingAsCustomer()->postJson( + "/v1/client/dich-vu/{$service->id}/renew", + ['months' => 1], + ); + $response->assertOk(); + + $invoiceId = $response->json('data.invoice_id'); + $this->assertDatabaseHas('invoices', ['id' => $invoiceId]); + $invoice = Invoice::find($invoiceId); + $this->assertNotNull($invoice->billing_period_start); + $this->assertNotNull($invoice->billing_period_end); + + // Calling renew again while the invoice is still open must return the SAME + // invoice, not create a duplicate for the same billing period. + $second = $this->actingAsCustomer()->postJson( + "/v1/client/dich-vu/{$service->id}/renew", + ['months' => 1], + ); + $second->assertOk(); + $second->assertJsonPath('data.invoice_id', $invoiceId); + $this->assertSame(1, Invoice::where('customer_id', $this->customer->id)->where('invoice_type', 2)->count()); + } + + /** + * Đúng flow đề bài mục 6: invoice gia hạn quá due_date -> OVERDUE, KHÔNG tạo invoice mới. + * Verify khách bấm "Gia hạn" lại LÚC invoice đang OVERDUE (chưa qua billing cycle tiếp theo) + * vẫn nhận về đúng invoice cũ, không bị tạo trùng. + */ + public function test_renew_while_existing_invoice_is_overdue_returns_the_same_invoice(): void + { + $service = $this->createServiceWithOldPrice(90_000); + + ProductPricings::create([ + 'product_id' => $this->product->id, + 'billing_cycle' => 'monthly', + 'price' => 100_000, + 'is_active' => true, + ]); + + $first = $this->actingAsCustomer()->postJson( + "/v1/client/dich-vu/{$service->id}/renew", + ['months' => 1], + ); + $first->assertOk(); + $invoiceId = $first->json('data.invoice_id'); + + // Invoice quá due_date -> cron invoices:check-overdue chuyển sang OVERDUE (flow thật). + Invoice::whereKey($invoiceId)->update(['due_date' => now()->subDay()->toDateString()]); + $this->artisan('invoices:check-overdue')->assertExitCode(0); + $this->assertSame(InvoiceStatus::OVERDUE->value, (int) Invoice::find($invoiceId)->status); + + // Khách bấm "Gia hạn" lại trong khi invoice đang OVERDUE — không được tạo invoice #2. + $second = $this->actingAsCustomer()->postJson( + "/v1/client/dich-vu/{$service->id}/renew", + ['months' => 1], + ); + $second->assertOk(); + $second->assertJsonPath('data.invoice_id', $invoiceId); + $this->assertSame(1, Invoice::where('customer_id', $this->customer->id)->where('invoice_type', 2)->count()); + } } diff --git a/tests/Feature/Console/ServiceExpiryTest.php b/tests/Feature/Console/ServiceExpiryTest.php new file mode 100644 index 0000000..f41b678 --- /dev/null +++ b/tests/Feature/Console/ServiceExpiryTest.php @@ -0,0 +1,130 @@ +create(); + $product = Product::factory()->create(['product_type' => 'hosting', 'is_active' => true]); + + $order = Order::create([ + 'order_num' => 'ORD-' . uniqid(), + 'customer_id' => $customer->id, + 'sub_total' => $price, + 'total' => $price, + 'status' => OrderStatus::ACTIVE->value, + ]); + + $orderItem = OrderItem::create([ + 'order_id' => $order->id, + 'product_id' => $product->id, + 'billing_cycle' => 'monthly', + 'months' => 1, + 'quantity' => 1, + 'price' => $price, + ]); + + return Service::create([ + 'customer_id' => $customer->id, + 'product_id' => $product->id, + 'order_item_id' => $orderItem->id, + 'status' => 'active', + 'server_name' => 'test-server', + 'expires_at' => now()->addDays(3), + ]); + } + + public function test_expiry_command_creates_renewal_invoice_on_the_original_order(): void + { + $service = $this->createExpiringService(90_000); + $originalOrderId = $service->orderItem->order_id; + + $this->artisan('service:expiry')->assertExitCode(0); + + $service->refresh(); + $this->assertNotNull($service->invoice_id); + + $invoice = Invoice::find($service->invoice_id); + $this->assertSame($originalOrderId, $invoice->order_id); + $this->assertSame(2, $invoice->invoice_type); + $this->assertSame(InvoiceStatus::UNPAID->value, $invoice->status); + $this->assertNotNull($invoice->billing_period_start); + $this->assertNotNull($invoice->billing_period_end); + + // Order 1:N Invoice — no second Order row created for the renewal. + $this->assertSame(1, Order::query()->count()); + } + + public function test_running_expiry_command_twice_does_not_duplicate_the_renewal_invoice(): void + { + $service = $this->createExpiringService(90_000); + + $this->artisan('service:expiry')->assertExitCode(0); + $firstInvoiceId = $service->refresh()->invoice_id; + + $this->artisan('service:expiry')->assertExitCode(0); + $secondInvoiceId = $service->refresh()->invoice_id; + + $this->assertSame($firstInvoiceId, $secondInvoiceId); + $this->assertSame(1, Invoice::where('invoice_type', 2)->count()); + } + + public function test_cancelled_service_does_not_get_a_renewal_invoice(): void + { + $service = $this->createExpiringService(90_000); + $service->update(['status' => 'cancelled', 'expires_at' => now()->subDay()]); + + $this->artisan('service:expiry')->assertExitCode(0); + + $this->assertNull($service->refresh()->invoice_id); + $this->assertSame(0, Invoice::where('invoice_type', 2)->count()); + } + + /** + * Đúng flow đề bài mục 6: Invoice gia hạn quá due_date -> chuyển OVERDUE (không tạo Invoice mới). + * Test này verify bước tiếp theo: cron service:expiry chạy lại SAU KHI invoice đã OVERDUE + * (không còn UNPAID nữa) vẫn phải nhận ra invoice đang mở và KHÔNG tạo invoice #2 trùng kỳ. + */ + public function test_expiry_command_does_not_duplicate_invoice_after_it_became_overdue(): void + { + $service = $this->createExpiringService(90_000); + + // Lần chạy 1: sinh invoice gia hạn (UNPAID). + $this->artisan('service:expiry')->assertExitCode(0); + $invoiceId = $service->refresh()->invoice_id; + $this->assertNotNull($invoiceId); + + // Giả lập thời gian trôi qua khỏi due_date rồi cron invoices:check-overdue chạy thật, + // chuyển invoice sang OVERDUE — đúng như flow thật, không set status tay. + Invoice::whereKey($invoiceId)->update(['due_date' => now()->subDay()->toDateString()]); + $this->artisan('invoices:check-overdue')->assertExitCode(0); + + $invoice = Invoice::find($invoiceId); + $this->assertSame(InvoiceStatus::OVERDUE->value, (int) $invoice->status); + + // Lần chạy 2: service:expiry chạy lại (vd. mỗi 5 phút) trong khi invoice đang OVERDUE. + $this->artisan('service:expiry')->assertExitCode(0); + + $this->assertSame($invoiceId, $service->refresh()->invoice_id); + $this->assertSame(1, Invoice::where('invoice_type', 2)->count()); + // Vẫn đúng là OVERDUE — service:expiry không được đụng vào status của invoice. + $this->assertSame(InvoiceStatus::OVERDUE->value, (int) $invoice->fresh()->status); + } +} diff --git a/tests/Feature/EmailTemplateServiceTest.php b/tests/Feature/EmailTemplateServiceTest.php new file mode 100644 index 0000000..e60225b --- /dev/null +++ b/tests/Feature/EmailTemplateServiceTest.php @@ -0,0 +1,129 @@ + 0, + 'scenario' => 'unit_test_scenario_' . uniqid(), + 'format' => 'html', + 'name' => 'Test scenario', + 'subject' => 'Xin chào {{full_name}}', + 'message' => '

Chào {{full_name}}, mời truy cập {{login_url}}

', + 'metadata' => [ + 'variables' => [ + ['key' => 'full_name', 'label' => 'Họ tên', 'type' => 'string', 'example' => 'Quý khách'], + ['key' => 'login_url', 'label' => 'Link đăng nhập', 'type' => 'url', 'example' => 'https://example.com'], + ], + ], + 'status' => 1, + ], $overrides)); + } + + public function test_send_dispatches_job_to_queue(): void + { + Bus::fake(); + + $template = $this->makeTemplate(); + + app(EmailTemplateService::class)->send($template->scenario, 'user@example.com', [ + 'full_name' => 'Nguyễn Văn A', + 'login_url' => 'https://portal.tgs.com.vn/login', + ]); + + Bus::assertDispatched(SendTemplateEmailJob::class, fn (SendTemplateEmailJob $job) => $job->scenario === $template->scenario + && $job->to === ['user@example.com'] + && $job->data['full_name'] === 'Nguyễn Văn A'); + } + + public function test_job_renders_and_sends_mail_with_template_sender_config(): void + { + Mail::fake(); + + $template = $this->makeTemplate([ + 'metadata' => [ + 'variables' => [ + ['key' => 'full_name', 'label' => 'Họ tên', 'type' => 'string'], + ['key' => 'login_url', 'label' => 'Link', 'type' => 'url'], + ], + 'mail' => [ + 'from_email' => 'noreply@tgs.com.vn', + 'from_name' => 'Thế Giới Số', + 'reply_to' => 'support@tgs.com.vn', + 'reply_to_name' => 'TGS Support', + ], + ], + ]); + + (new SendTemplateEmailJob($template->scenario, ['user@example.com'], [ + 'full_name' => 'Nguyễn Văn A', + 'login_url' => 'https://portal.tgs.com.vn/login', + ]))->handle(app(EmailTemplateService::class), app(\App\Services\Email\EmailTemplateRenderer::class)); + + Mail::assertSent(GenericTemplateMail::class, function (GenericTemplateMail $mail) { + $mail->assertTo('user@example.com'); + $envelope = $mail->envelope(); + + return $envelope->subject === 'Xin chào Nguyễn Văn A' + && $envelope->from->address === 'noreply@tgs.com.vn' + && $envelope->replyTo[0]->address === 'support@tgs.com.vn' + && str_contains($mail->renderedHtml, 'Nguyễn Văn A'); + }); + } + + public function test_job_falls_back_to_system_default_sender_when_template_has_no_mail_config(): void + { + Mail::fake(); + + $template = $this->makeTemplate(); + + (new SendTemplateEmailJob($template->scenario, ['user@example.com'], [ + 'full_name' => 'A', + 'login_url' => 'https://example.com', + ]))->handle(app(EmailTemplateService::class), app(\App\Services\Email\EmailTemplateRenderer::class)); + + Mail::assertSent(GenericTemplateMail::class, function (GenericTemplateMail $mail) { + return $mail->envelope()->from === null; + }); + } + + public function test_preview_throws_for_undeclared_variable(): void + { + $template = $this->makeTemplate([ + 'message' => '

{{full_name}} {{not_declared}}

', + ]); + + $this->expectException(InvalidTemplateVariableException::class); + + app(EmailTemplateService::class)->preview($template->scenario, ['full_name' => 'A']); + } + + public function test_load_template_is_cached_by_scenario(): void + { + $template = $this->makeTemplate(); + $service = app(EmailTemplateService::class); + + $first = $service->loadTemplate($template->scenario); + $this->assertSame('Xin chào {{full_name}}', $first->subject); + + $template->update(['subject' => 'Đã đổi']); // model event must bust the cache + $second = $service->loadTemplate($template->scenario); + + $this->assertSame('Đã đổi', $second->subject); + } +} diff --git a/tests/Feature/Payment/AdminTransactionApproveTest.php b/tests/Feature/Payment/AdminTransactionApproveTest.php new file mode 100644 index 0000000..2db5aa0 --- /dev/null +++ b/tests/Feature/Payment/AdminTransactionApproveTest.php @@ -0,0 +1,45 @@ +create(['total' => 0, 'credit' => 0, 'total_pending' => 100_000]); + $transaction = Transaction::create([ + 'uuid' => (string) \Illuminate\Support\Str::uuid(), + 'public_code' => 'DEP-TEST-0001', + 'trans' => 'DEP-TEST-0001', + 'amount' => 100_000, + 'customer_id' => $customer->id, + 'type' => 'manual', + 'status' => TransactionStatus::PENDING->value, + ]); + + $controller = app(AdminTransactionController::class); + + $first = $controller->approve($transaction->uuid); + $second = $controller->approve($transaction->uuid); + + $this->assertSame(200, $first->getStatusCode()); + // Second call finds the transaction no longer PENDING → treated as not-approvable. + $this->assertSame(404, $second->getStatusCode()); + + $this->assertSame(100_000, (int) $customer->fresh()->total); + } +} diff --git a/tests/Feature/Payment/InvoicePaymentServiceTest.php b/tests/Feature/Payment/InvoicePaymentServiceTest.php new file mode 100644 index 0000000..9a08d94 --- /dev/null +++ b/tests/Feature/Payment/InvoicePaymentServiceTest.php @@ -0,0 +1,84 @@ +create(['total' => 200_000]); + $order = Order::create([ + 'order_num' => 'ORD-' . uniqid(), + 'customer_id' => $customer->id, + 'sub_total' => 100_000, + 'total' => 100_000, + 'status' => OrderStatus::PENDING->value, + ]); + $invoice = Invoice::create([ + 'invoice_number' => 'INV-' . uniqid(), + 'customer_id' => $customer->id, + 'order_id' => $order->id, + 'invoice_type' => 1, + 'sub_total' => 100_000, + 'tax' => 0, + 'total' => 100_000, + 'status' => InvoiceStatus::UNPAID->value, + ]); + + $service = app(InvoicePaymentService::class); + + $service->payWithBalance($invoice, $customer, 'balance'); + // Simulate a retried request (double-click / network retry) for the same invoice. + $service->payWithBalance($invoice->fresh(), $customer->fresh(), 'balance'); + + $this->assertSame(100_000, (int) $customer->fresh()->total); + $this->assertSame(1, Transaction::where('customer_id', $customer->id)->where('type', 'invoice')->count()); + } + + public function test_a_cancelled_invoice_cannot_be_paid_and_is_never_counted_as_paid(): void + { + $customer = Customer::factory()->create(['total' => 200_000]); + $order = Order::create([ + 'order_num' => 'ORD-' . uniqid(), + 'customer_id' => $customer->id, + 'sub_total' => 100_000, + 'total' => 100_000, + 'status' => OrderStatus::CANCELLED->value, + ]); + $invoice = Invoice::create([ + 'invoice_number' => 'INV-' . uniqid(), + 'customer_id' => $customer->id, + 'order_id' => $order->id, + 'invoice_type' => 1, + 'sub_total' => 100_000, + 'tax' => 0, + 'total' => 100_000, + 'status' => InvoiceStatus::CANCELLED->value, + ]); + + $service = app(InvoicePaymentService::class); + + $this->expectException(\InvalidArgumentException::class); + try { + $service->payWithBalance($invoice, $customer, 'balance'); + } finally { + $this->assertSame(200_000, (int) $customer->fresh()->total); + $this->assertSame(InvoiceStatus::CANCELLED->value, (int) $invoice->fresh()->status); + $this->assertNull($invoice->fresh()->paid_at); + } + } +} diff --git a/tests/Unit/EmailTemplateRendererTest.php b/tests/Unit/EmailTemplateRendererTest.php new file mode 100644 index 0000000..7a7eb40 --- /dev/null +++ b/tests/Unit/EmailTemplateRendererTest.php @@ -0,0 +1,114 @@ + 'unit_test_scenario', + 'subject' => $subject, + 'message' => $message, + 'metadata' => ['variables' => $variables], + ]); + + return $template; + } + + public function test_it_replaces_known_placeholders_in_subject_and_html(): void + { + $template = $this->makeTemplate( + 'Xin chào {{full_name}}', + '

Chào mừng {{full_name}} đến với {{app_name}}

', + [ + ['key' => 'full_name', 'label' => 'Họ tên', 'type' => 'string'], + ['key' => 'app_name', 'label' => 'Tên app', 'type' => 'string'], + ], + ); + + $result = (new EmailTemplateRenderer())->render($template, [ + 'full_name' => 'Nguyễn Văn A', + 'app_name' => 'Thế Giới Số', + ]); + + $this->assertSame('Xin chào Nguyễn Văn A', $result['subject']); + $this->assertSame('

Chào mừng Nguyễn Văn A đến với Thế Giới Số

', $result['html']); + } + + public function test_it_falls_back_to_example_value_when_data_missing(): void + { + $template = $this->makeTemplate( + 'Mã: {{code}}', + '

{{code}}

', + [['key' => 'code', 'label' => 'Mã', 'type' => 'string', 'example' => '000000']], + ); + + $result = (new EmailTemplateRenderer())->render($template, []); + + $this->assertSame('Mã: 000000', $result['subject']); + $this->assertSame('

000000

', $result['html']); + } + + public function test_it_html_escapes_string_type_by_default(): void + { + $template = $this->makeTemplate( + 'subj', + '

{{name}}

', + [['key' => 'name', 'label' => 'Tên', 'type' => 'string']], + ); + + $result = (new EmailTemplateRenderer())->render($template, ['name' => '']); + + $this->assertStringNotContainsString('
+HTML + . $bannerFooter, + 'type_id' => 0, + 'status' => 1, + ], + [ + 'name' => 'Nhắc gia hạn dịch vụ', + 'scenario' => 'service_renewal_reminder', + 'format' => 'html', + 'subject' => 'Nhắc gia hạn dịch vụ {$product_name}', + 'message' => $header . <<<'HTML' + +HTML + . $footer, + 'type_id' => 0, + 'status' => 1, + ], + [ + 'name' => 'Thao tác Snapshot VPS', + 'scenario' => 'snapshot_action', + 'format' => 'html', + 'subject' => '{$action_title} — {$app_name}', + 'message' => $bannerHead . <<<'HTML' + +HTML + . $bannerFooter, + 'type_id' => 0, + 'status' => 1, + ], + [ + 'name' => 'Ticket hỗ trợ mới', + 'scenario' => 'ticket_created', + 'format' => 'html', + 'subject' => '[#{$ticket_num}] Yêu cầu hỗ trợ của bạn đã được tiếp nhận', + 'message' => $bannerHead . <<<'HTML' + +HTML + . $bannerFooter, + 'type_id' => 0, + 'status' => 1, + ], + [ + 'name' => 'Mã xác thực tài khoản', + 'scenario' => 'verify_account_welcome', + 'format' => 'html', + 'subject' => '[{$app_name}] Mã xác thực tài khoản của bạn', + 'message' => $header . <<<'HTML' + +HTML + . $footer, + 'type_id' => 0, + 'status' => 1, + ], + [ + 'name' => 'Khởi tạo VPS thất bại', + 'scenario' => 'vm_creation_failed', + 'format' => 'html', + 'subject' => 'Thông báo: Không thể khởi tạo máy chủ ảo - Đơn hàng #{$order_num}', + 'message' => $bannerHead . <<<'HTML' + +HTML + . $bannerFooter, + 'type_id' => 0, + 'status' => 1, + ], + [ + 'name' => 'Đổi mật khẩu VPS', + 'scenario' => 'vm_password_changed', + 'format' => 'html', + 'subject' => 'Mật khẩu VM của bạn đã được thay đổi — {$app_name}', + 'message' => $bannerHead . <<<'HTML' + +HTML + . $bannerFooter, + 'type_id' => 0, + 'status' => 1, + ], + [ + 'name' => 'Rebuild VPS thất bại', + 'scenario' => 'vm_rebuild_failed', + 'format' => 'html', + 'subject' => 'Thông báo: Cài lại hệ điều hành thất bại - {$vm_name}', + 'message' => $bannerHead . <<<'HTML' + +HTML + . $bannerFooter, + 'type_id' => 0, + 'status' => 1, + ], + [ + 'name' => 'Thông báo nghỉ lễ', + 'scenario' => 'holiday_notice', + 'format' => 'html', + 'subject' => 'Thông báo lịch nghỉ lễ từ {$holiday_name}', + 'message' => <<<'HTML' +
+HTML; + + $newTemplates = [ + [ + 'name' => 'VPS bị tạm ngưng', + 'scenario' => 'vm_suspended', + 'format' => 'html', + 'subject' => '[Thế Giới Số] Dịch vụ Cloud VPS của bạn đã bị tạm ngưng', + 'message' => $header . <<<'HTML' + +HTML + . $footer, + 'type_id' => 0, + 'status' => 1, + ], + [ + 'name' => 'Đơn đăng ký tên miền đã ghi nhận', + 'scenario' => 'domain_order_received', + 'format' => 'html', + 'subject' => 'Đơn đăng ký tên miền {$domain_name} đã được ghi nhận', + 'message' => $header . <<<'HTML' + +HTML + . $footer, + 'type_id' => 0, + 'status' => 1, + ], + [ + 'name' => 'Hosting đã khởi tạo', + 'scenario' => 'hosting_account_created', + 'format' => 'html', + 'subject' => 'Hosting của bạn đã sẵn sàng — {$domain_name}', + 'message' => $header . <<<'HTML' + +HTML + . $footer, + 'type_id' => 0, + 'status' => 1, + ], + [ + 'name' => 'Trạng thái nạp tiền thủ công', + 'scenario' => 'manual_deposit_status', + 'format' => 'html', + 'subject' => '{$company_name} - Trạng thái nạp tiền', + 'message' => <<<'HTML' +

Kính chào {$customer_name},

Dịch vụ Cloud VPS của bạn hiện đã bị tạm ngưng (Suspend) do chưa thanh toán gia hạn. Vui lòng nhanh chóng liên hệ hoặc thanh toán để tránh mất dữ liệu.

Tên dịch vụ{$vm_name}
VM ID{$vmware_id}
Thời gian tạm ngưng{$suspended_at}

⏰ Sau 30 ngày kể từ ngày tạm ngưng, nếu không có thanh toán, dịch vụ sẽ bị xóa vĩnh viễn và toàn bộ dữ liệu sẽ không thể khôi phục.

Đăng nhập thanh toán ngay

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

Kính chào {$customer_name},

Cảm ơn Quý khách đã đặt mua tên miền tại {$company_name}. Đơn hàng của Quý khách đang chờ đội ngũ kỹ thuật kiểm tra và đăng ký thủ công.

Thông tin đơn hàng:

  • Tên miền: {$domain_name}
  • Mã đơn hàng: {$order_num}
  • Tổng tiền: {$order_total}
  • Thời gian: {$created_at}

Lưu ý: việc đặt mua không đồng nghĩa tên miền đã được đăng ký thành công. Nếu tên miền không còn khả dụng, số dư đã thanh toán sẽ được hoàn lại vào tài khoản.

XEM ĐƠN HÀNG CỦA TÔI

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

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

Kính chào {$full_name},

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

{$server_host_row}{$ftp_username_row}{$ftp_password_row}
Tên miền:{$domain_name}
Gói hosting:{$hosting_plan}
Trạng thái:Đang hoạt động ✓
{$ftp_password_notice}{$plesk_block}

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

Đăng nhập ngay

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

Kính chào {$customer_name},

{$status_message}

{$processed_date_row}
Trạng thái{$status_label}
Mã giao dịch{$transaction_code}
Số tiền{$amount_formatted} VNĐ
Thời gian gửi{$deposit_date}
XEM YÊU CẦU NẠP TIỀN

Nếu bạn cần hỗ trợ thêm, vui lòng liên hệ {$support_email} hoặc {$support_phone}.

+HTML, + 'type_id' => 0, + 'status' => 1, + ], + [ + 'name' => 'Thanh toán đơn hàng thành công', + 'scenario' => 'order_payment_success', + 'format' => 'html', + 'subject' => '✅ Thanh toán thành công - Đơn hàng #{$order_num}', + 'message' => <<<'HTML' +
✅

Đặt hàng thành công!

Đơn hàng #{$order_num} đã được xác nhận và thanh toán

Xin chào {$customer_name},
Cảm ơn bạn đã tin tưởng sử dụng dịch vụ của chúng tôi. Đơn hàng của bạn đã được xác nhận và đang trong quá trình khởi tạo.

Thông tin đơn hàng
Mã đơn hàng#{$order_num}
Tổng thanh toán{$amount_formatted} VNĐ
{$vm_block}{$addon_block}
⚠️ Vui lòng đổi mật khẩu ngay sau khi đăng nhập lần đầu để bảo mật tài khoản.
Nếu cần hỗ trợ, liên hệ: {$support_email} hoặc {$support_phone}
Email này được gửi tự động. Vui lòng không trả lời email này.
+HTML, + 'type_id' => 0, + 'status' => 1, + ], + [ + 'name' => 'Admin cấp lại mật khẩu', + 'scenario' => 'password_reset_admin', + 'format' => 'html', + 'subject' => 'Mật khẩu của bạn đã được cập nhật — {$app_name}', + 'message' => $bannerHead . <<<'HTML' +
🔐

Mật khẩu của bạn đã được cập nhật

Quản trị viên vừa cấp lại mật khẩu mới cho tài khoản của bạn
Xin chào {$full_name},
 
Quản trị viên của {$app_name} vừa cấp lại mật khẩu mới cho tài khoản của bạn. Vui lòng sử dụng thông tin bên dưới để đăng nhập và đổi lại mật khẩu ngay sau đó.
 
Tài khoản: {$email}
Mật khẩu mới: {$password}
 
 
Nếu bạn không yêu cầu thao tác này, vui lòng liên hệ ngay với chúng tôi qua {$support_email}.

Nhắc gia hạn dịch vụ

Kính chào {$client_name},

Dịch vụ {$product_name}{$server_name_suffix} của bạn sắp đến hạn thanh toán. Vui lòng gia hạn sớm để tránh gián đoạn dịch vụ.

{$price_row}
Dịch vụ:{$product_name}
Ngày hết hạn:{$expires_at}

Bạn có thể đăng nhập vào cổng quản lý để gia hạn dịch vụ.

Đăng nhập ngay

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

{$action_icon}

{$action_title}

{$action_desc}
Xin chào {$full_name},
 
Thao tác snapshot máy chủ ảo của bạn tại {$app_name} đã hoàn tất.
 
Máy chủ: {$vm_name}
Snapshot: {$snapshot_name}
Thời gian: {$action_at}
Trạng thái: {$action_status}
 
 
Nếu bạn có thắc mắc, vui lòng liên hệ với chúng tôi qua {$support_email} hoặc {$support_phone}.
🎫

Yêu cầu hỗ trợ đã được tiếp nhận

Chúng tôi sẽ phản hồi trong thời gian sớm nhất
Xin chào {$full_name},
 
Chúng tôi đã nhận được yêu cầu hỗ trợ của Quý khách và sẽ xử lý trong thời gian sớm nhất. Dưới đây là thông tin ticket của Quý khách:
 
{$room_row}
Mã ticket:#{$ticket_num}
Tiêu đề:{$subject}
Trạng thái:Đang mở
 
 
Đội ngũ hỗ trợ của {$app_name} sẽ liên hệ với Quý khách qua email hoặc cập nhật trực tiếp trên hệ thống. Cảm ơn Quý khách đã tin tưởng sử dụng dịch vụ của chúng tôi.

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

Kính chào {$full_name},

Cảm ơn bạn đã đăng ký tài khoản tại {$app_name}. Để hoàn tất quá trình đăng ký, vui lòng sử dụng mã xác thực dưới đây:

{$verify_code}

Mã xác thực có hiệu lực trong 5 phút. Vui lòng không chia sẻ mã này với bất kỳ ai.

Nếu bạn không thực hiện yêu cầu này, vui lòng bỏ qua email này.

⚠️

Thông báo về đơn hàng #{$order_num}

Đội ngũ kỹ thuật đang xử lý yêu cầu của Quý khách
Xin chào {$full_name},
 
Chúng tôi đã nhận được yêu cầu khởi tạo dịch vụ cho đơn hàng của Quý khách. Hiện tại hệ thống đang trong quá trình xử lý và đội ngũ kỹ thuật của {$app_name} đã được thông báo để hỗ trợ nhanh nhất có thể.
 
Thông tin đơn hàng:
    + Mã đơn hàng: #{$order_num}
    + Sản phẩm: {$product_name}
 
Quý khách không cần thực hiện thêm bất kỳ thao tác nào. Chúng tôi sẽ liên hệ lại khi dịch vụ được kích hoạt thành công.
 
 
Nếu cần hỗ trợ khẩn cấp, Quý khách vui lòng liên hệ qua email {$support_email} hoặc tổng đài {$support_phone}.
🔑

Mật khẩu VM đã được thay đổi

Mật khẩu máy chủ ảo của bạn đã được cập nhật thành công
Xin chào {$full_name},
 
Mật khẩu đăng nhập vào máy chủ ảo (VM) của bạn tại {$app_name} đã được thay đổi thành công.
 
Máy chủ: {$vm_name}
{$new_password_row}
Trạng thái: Đổi mật khẩu thành công ✓
 
 
Nếu bạn không thực hiện thao tác này, vui lòng liên hệ ngay với chúng tôi qua {$support_email} hoặc {$support_phone}.
⚠️

Thông báo về VPS {$vm_name}

Đội ngũ kỹ thuật đang xử lý yêu cầu của Quý khách
Xin chào {$full_name},
 
Rất tiếc, yêu cầu cài lại hệ điều hành (rebuild) cho VPS của Quý khách hiện chưa thực hiện thành công. Đội ngũ kỹ thuật của {$app_name} đã được thông báo để kiểm tra và xử lý.
 
Thông tin dịch vụ:
    + Tên VPS: {$vm_name}
    + IP: {$ip}
 
VPS hiện tại của Quý khách vẫn đang hoạt động bình thường trên cấu hình cũ, không có dữ liệu hay dịch vụ nào bị gián đoạn.
 
 
Nếu cần hỗ trợ khẩn cấp, Quý khách vui lòng liên hệ qua email {$support_email} hoặc tổng đài {$support_phone}.
 
Kính gửi: Quý Khách hàng, Quý Đối tác
 
Nhân dịp kỷ niệm {$holiday_name}, Thế Giới Số xin trân trọng gửi tới Quý Khách hàng và Quý Đối tác lời chúc sức khỏe, hạnh phúc và thành công.
 
Thế Giới Số xin thông báo lịch nghỉ lễ như sau:
    Thời gian nghỉ lễ: Từ {$holiday_from} đến hết {$holiday_to}
    Thời gian làm việc trở lại: {$work_again}
 
Trong thời gian nghỉ lễ, nếu có vấn đề cần hỗ trợ kỹ thuật vui lòng liên hệ:
    + Gửi ticket qua: {$ticket_url}
    + Email: {$support_email}
    + Hotline: {$hotline}
 
Kính chúc Quý Khách hàng và Quý Đối tác có một kỳ nghỉ lễ vui vẻ, an toàn và nhiều sức khỏe.
 
+HTML, + 'type_id' => 0, + 'status' => 1, + ], + ]; + + foreach ($newTemplates as $item) { + if (!Template::where('scenario', $item['scenario'])->exists()) { + Template::create($item); + } + } + + foreach ($Templates as $item) { Template::create($item); } } diff --git a/docs/order-invoice-payment-fix-todo.md b/docs/order-invoice-payment-fix-todo.md new file mode 100644 index 0000000..c4233ac --- /dev/null +++ b/docs/order-invoice-payment-fix-todo.md @@ -0,0 +1,100 @@ +# Order → Invoice → Payment/Recurring Billing — Fix TODO + +Nguồn: audit ngày 2026-09-05 (trace thật + chạy test/verify code, không chỉ đọc). +Thứ tự đề xuất: sửa từ trên xuống, mỗi mục độc lập, có thể merge riêng lẻ. + +## P0 — Sai cấu trúc nghiệp vụ cốt lõi ✅ DONE (2026-09-05) + +- [x] **Renewal tạo Order mới thay vì tái dùng Order gốc** + File: `app/Console/Commands/ServiceExpiry.php` (`createRenewalInvoiceIfMissing()`), `app/Services/Api/Order/OrderQueryService.php::renewService()` + Đã sửa: bỏ hẳn `Order::create()`/`OrderItem::create()` khi renewal; `Invoice::create(['order_id' => $orderItem->order_id, ...])` trỏ về Order gốc. + Test: `tests/Feature/Api/Service/RenewServiceTest.php::test_renew_reuses_original_order_instead_of_creating_a_new_one`, `tests/Feature/Console/ServiceExpiryTest.php::test_expiry_command_creates_renewal_invoice_on_the_original_order` — PASS. + +- [x] **Thiếu khái niệm billing_period trên Invoice** + File: `database/migrations/2026_09_05_000006_add_billing_period_to_invoices_table.php` + Đã thêm `billing_period_start`/`billing_period_end` (date, nullable), set ở cả 2 nơi tạo renewal invoice. + +- [x] **Không có unique constraint chống trùng invoice cùng kỳ billing** + File: cùng migration trên — `unique(order_id, invoice_type, billing_period_start)` (`invoices_order_type_period_unique`). + Test: `test_renewal_invoice_has_a_billing_period_and_is_not_duplicated_for_the_same_period`, `test_running_expiry_command_twice_does_not_duplicate_the_renewal_invoice` — PASS (gọi renew 2 lần / chạy cron 2 lần chỉ ra đúng 1 invoice). + Ghi chú: guard `exists()`-trước-khi-tạo ở tầng app vẫn còn (chưa bắt QueryException dự phòng cho race thật sự đồng thời) — để dành nếu sau này thấy còn lọt duplicate qua race test tải cao. + +## P1 — Race condition tài chính ✅ DONE (2026-09-05) + +- [x] **Double-spend balance khi thanh toán đồng thời** + File: `app/Services/InvoicePaymentService.php::payWithBalance()` + Đã sửa tại nguồn (không sửa từng caller riêng): lock `Invoice` + `Customer` bằng `lockForUpdate()` bên trong `DB::transaction()`, re-check status/balance sau khi lock, trước khi trừ tiền. Áp dụng cho cả 2 caller (`OrderService::pay()` và `OrderControllerRefactor::payClientInvoiceByNumber()`). + Test: `tests/Feature/Payment/InvoicePaymentServiceTest.php::test_paying_an_already_paid_invoice_does_not_double_deduct_balance` — gọi `payWithBalance()` 2 lần liên tiếp cho cùng invoice, xác nhận chỉ trừ tiền 1 lần — PASS. + +- [x] **Duyệt trùng nạp tiền thủ công (admin)** + File: `app/Http/Controllers/Api/Admin/Transaction/AdminTransactionController.php::approve()` và `reject()` + Đã sửa: gộp toàn bộ read-check-write vào 1 `DB::transaction()`, lock cả `Transaction` và `Customer` row bằng `lockForUpdate()`, tách phần gửi mail ra ngoài transaction (tránh giữ lock khi I/O). + Test: `tests/Feature/Payment/AdminTransactionApproveTest.php::test_approving_the_same_manual_deposit_twice_credits_the_customer_only_once` — gọi `approve()` 2 lần cho cùng uuid, xác nhận balance chỉ cộng 1 lần, lần 2 trả 404 — PASS. + +- [x] **Dedup transaction chỉ ở tầng app, không có unique DB** + File: `database/migrations/2026_09_05_000007_add_unique_constraint_to_transactions_table.php` — `unique(customer_id, trans, type)`. + Verify trước khi thêm: query production DB xác nhận 0 nhóm trùng `(customer_id, trans, type)` hiện có, migration chạy sạch. + Ghi chú: không cần sửa thêm code — `Model::firstOrCreate()` ở Laravel 10 tự bắt `UniqueConstraintViolationException` và fallback về SELECT (xem `vendor/laravel/framework/.../Eloquent/Builder.php::createOrFirst()`), nên `InvoicePaymentService::recordTransaction()` (đã dùng `firstOrCreate`) tự động trở nên race-safe thật sự sau khi có unique index. + Đã hardening thêm `app/Jobs/Payment/GetTransactionPayment.php` (bank-statement poller): đổi từ pattern `where()->first()` rồi `create()` (race window) sang `firstOrCreate()` + check `wasRecentlyCreated` trước khi cộng tiền, tận dụng cùng cơ chế race-safe trên. + +## P2 — Nhất quán dữ liệu / logic ✅ DONE (2026-09-05) + +- [ ] **Giá renewal không nhất quán giữa cron tự động và tự-gia-hạn** (CHƯA SỬA — để dành, xem ghi chú) + File: `app/Console/Commands/ServiceExpiry.php` (dùng giá đông cứng `$orderItem->price` lúc mua ban đầu) vs `app/Services/Api/Order/OrderQueryService.php::renewService()` (dùng giá catalog hiện tại qua `productRepo->getPricing()`) + Ghi chú: đây là quyết định nghiệp vụ (cron tự động có nên áp giá mới như khách tự gia hạn hay giữ giá cũ để tránh sốc giá tự động) — cần hỏi lại business trước khi đổi, chưa tự quyết. + +- [x] **`cycleToMonths()` bị duplicate ở 4 nơi** + Đã gom về `App\Helpers\OrderHelper::cycleToMonths()` (static), thay thế tại: `InvoiceRepository`, `InvoicePaymentService`, `Api\Order\OrderService`, `Customer\CustomerServiceProvisioningService`. Xóa 4 bản `private function cycleToMonths()` trùng lặp. + Verify: chạy lại toàn bộ test đã có ở P0/P1 sau refactor — tất cả PASS (không đổi hành vi). + +- [x] **Tax handling không nhất quán giữa order client-facing và admin** + Quyết định (theo xác nhận của chủ dự án): giá niêm yết đã bao gồm thuế — không cộng thêm % lên trên ở bất kỳ đâu. + File: `app/Services/OrderService.php::createOrder()` — bỏ `$orderTotal = $total + $total*10%`, đổi thành `$orderTotal = $total`; `invoice.tax` set về `0` (khớp với `InvoiceRepository::createFromOrder()` phía client-facing vốn đã set `tax=0`). + Ghi chú: không viết test riêng cho thay đổi 1 dòng arithmetic này — `createOrder()` phụ thuộc sâu vào hệ thống module VM/hosting (dựng test tốn công không tương xứng với rủi ro của 1 phép tính đơn giản). + +## P3 — Frontend ✅ DONE (2026-09-05) + +- [x] **Bảng Invoice chính thiếu trạng thái "Quá hạn"/"Đã hoàn tiền"** + File: `portal-admin/src/features/order-invoice/data/data.tsx` — thêm `4: Quá hạn`, `5: Đã hoàn tiền` vào `invoiceStatuses`. + Cập nhật màu Tag tương ứng ở 2 nơi tiêu thụ: `invoices-columns.tsx` (`statusColor`), `invoices-table.tsx` (`colors`). + Verify: `yarn build` — không phát sinh lỗi TS mới trong 3 file đã sửa (các lỗi build hiện có trên nhánh là pre-existing, không liên quan: `firstname/lastname` type mismatch ở `app-sidebar.tsx`/`profile-dropdown.tsx`/`activity-logs-table.tsx`/`users-columns.tsx`, `fmtVND` unused ở `customers-table.tsx`). + Không làm: hợp nhất hoàn toàn với map riêng ở `customer-invoices-tab.tsx` — 2 map dùng shape và icon-library khác nhau (`@iconify/react` string icon vs `lucide-react` component); hợp nhất đòi hỏi đổi shape ở nhiều nơi, rủi ro cao hơn lợi ích so với việc chỉ vá đúng chỗ thiếu status. Để dành nếu sau này cần refactor UI invoice status rộng hơn. + +## P4 — Dọn dead code (rủi ro nếu vô tình gọi lại) ✅ DONE (2026-09-05) + +- [x] Xóa `app/Http/Services/Invoice/InvoiceService.php` — dùng cột không tồn tại (`order_num`, `date_paid`). +- [x] Xóa `app/Jobs/VirtualMachine/ProcessOrderPaymentJob.php` — dùng cột không tồn tại (`orders.amount`, `order_items.product_type`), không route nào gọi. +- [x] Xóa `app/Http/Controllers/Api/Service/PaymentController.php` — không được route tới (route sống là `OrderControllerRefactor::payOrder`). +- [x] Xóa `tests/Unit/ProcessOrderPaymentJobTest.php` — đã verify chạy FAIL 4/4 (`Class "Database\Factories\OrderFactory" not found`), test cho code chết. +- [x] Xóa `app/Console/Commands/UpdateExpiryDate.php` — xác nhận `order_items.expiry_date` không tồn tại trong bất kỳ migration nào (grep toàn bộ `database/migrations`), lệnh sẽ lỗi SQL nếu chạy, không có trong `Kernel::schedule()`. +- [x] Xóa 2 import chết còn sót trong `app/Http/Controllers/Api/Service/ServiceController.php` (`use InvoiceService`, `use ProcessOrderPaymentJob`) — import nhưng không dùng ở đâu trong file. + Verify: `grep` toàn repo xác nhận không còn tham chiếu nào tới 5 file/class đã xóa; `php -l` pass; chạy lại toàn bộ 9 test đã viết ở P0/P1 — vẫn PASS. + +## P5 — Test cần bổ sung ✅ DONE (2026-09-05) — tất cả 11 test PASS + +- [x] `test_renewal_invoice_uses_original_order_id_not_new_order` → `RenewServiceTest::test_renew_reuses_original_order_instead_of_creating_a_new_one` +- [x] `test_second_renewal_for_same_service_still_uses_original_order_id` → `ServiceExpiryTest::test_expiry_command_creates_renewal_invoice_on_the_original_order` +- [x] `test_concurrent_renewal_requests_create_only_one_invoice_for_same_period` → `RenewServiceTest::test_renewal_invoice_has_a_billing_period_and_is_not_duplicated_for_the_same_period`, `ServiceExpiryTest::test_running_expiry_command_twice_does_not_duplicate_the_renewal_invoice` +- [x] `test_concurrent_pay_requests_do_not_double_deduct_balance` → `InvoicePaymentServiceTest::test_paying_an_already_paid_invoice_does_not_double_deduct_balance` +- [x] `test_duplicate_manual_deposit_approval_does_not_double_credit` → `AdminTransactionApproveTest::test_approving_the_same_manual_deposit_twice_credits_the_customer_only_once` +- [x] `test_cancelled_order_does_not_trigger_renewal_invoice_generation` → `ServiceExpiryTest::test_cancelled_service_does_not_get_a_renewal_invoice` +- [x] `test_cancelled_invoice_is_not_counted_as_paid` → `InvoicePaymentServiceTest::test_a_cancelled_invoice_cannot_be_paid_and_is_never_counted_as_paid` + +Tổng: `RenewServiceTest` (5), `ServiceExpiryTest` (4), `InvoicePaymentServiceTest` (2), `AdminTransactionApproveTest` (1), `InvoiceTestRouteRemovedTest` (1, có sẵn) — 13 test, chạy riêng lẻ đều PASS. + +## P6 — Bug mới phát hiện khi viết test cho case "invoice OVERDUE rồi mới gia hạn tiếp" ✅ DONE (2026-09-05) + +- [x] **`invoices:check-overdue` crash khi reset order status — do eager-load thiếu cột `customer_id`** + File: `app/Console/Commands/CheckOverdueInvoices.php` dòng 39; root cause thật ở `app/Observers/OrderObserver.php::updated()` (đăng ký tại `app/Providers/ObServerProvider.php`). + Logic sai: `Invoice::query()->with('order:id,status')` chỉ eager-load 2 cột của Order. Khi cron cần reset order status (ACTIVE → PENDING vì invoice quá hạn), `$order->update(['status' => ...])` bắn event `updated`, `OrderObserver::updated()` đọc `$order->customer_id` để ghi log — cột này KHÔNG được load nên là `null` → insert vào bảng `log` (`customer_id` NOT NULL) ném `QueryException`, không có try/catch bọc quanh vòng lặp → **toàn bộ cron `invoices:check-overdue` dừng ngay tại invoice đầu tiên cần reset order status**, các invoice quá hạn còn lại trong ngày không được xử lý. + Phát hiện bằng: viết test tích hợp thật (`ServiceExpiryTest::test_expiry_command_does_not_duplicate_invoice_after_it_became_overdue`, `RenewServiceTest::test_renew_while_existing_invoice_is_overdue_returns_the_same_invoice`) — gọi `invoices:check-overdue` thật (không mock) để mô phỏng đúng flow "invoice tới due_date → OVERDUE" trước khi test hành vi tránh trùng lặp; cả 2 test đều crash với `SQLSTATE[23000]: Column 'customer_id' cannot be null` trước khi fix. + Cách sửa: đổi `->with('order:id,status')` thành `->with('order:id,status,customer_id')` — 1 dòng, không đổi hành vi nghiệp vụ nào khác. + Verify: cả 13 test (bao gồm 2 test mới ở trên) PASS sau fix. + Business impact trước khi sửa: bất kỳ ngày nào có invoice renewal quá hạn của 1 service đang ACTIVE, cron `invoices:check-overdue` sẽ crash ngay từ invoice đầu tiên loại này và bỏ sót toàn bộ các invoice quá hạn phía sau trong cùng lần chạy — nghĩa là tính năng đánh dấu OVERDUE gần như không hoạt động đúng trong thực tế với dữ liệu thật (production DB có invoice + order theo đúng pattern này). +Ghi chú môi trường: test suite tổng (`--testsuite Feature`) trên nhánh `dev-domain` có ~40 lỗi pre-existing không liên quan (401/403/404 do cấu hình auth/route test), đã xác nhận bằng `git stash` so sánh baseline trước khi có bất kỳ fix nào ở đây — không phải do các thay đổi trong tài liệu này. + +## Ghi chú (không cần sửa, giữ nguyên) + +- `CheckOverdueInvoices` (cron `invoices:check-overdue`) đúng nghiệp vụ: chỉ update status, không tạo invoice mới. Không đổi. +- `InvoicePaymentService::activateOrder()` dùng `firstOrCreate(order_item_id)` chống double-provisioning — đã ổn. +- Không cần thêm bảng `subscriptions`/`billing_schedules` riêng — `services` + cron `service:expiry` đủ đóng vai trò billing schedule sau khi fix P0. diff --git a/resources/views/emails/admin/domain-order-new.blade.php b/resources/views/emails/admin/domain-order-new.blade.php deleted file mode 100644 index 96dbdaa..0000000 --- a/resources/views/emails/admin/domain-order-new.blade.php +++ /dev/null @@ -1,25 +0,0 @@ -

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

- -

Thông tin đơn hàng:

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

- Lưu ý: Đây là tin nhắn tự động. Vui lòng không trả lời tin nhắn này! -

diff --git a/resources/views/emails/admin/hosting-account-created.blade.php b/resources/views/emails/admin/hosting-account-created.blade.php deleted file mode 100644 index 7e43751..0000000 --- a/resources/views/emails/admin/hosting-account-created.blade.php +++ /dev/null @@ -1,130 +0,0 @@ - - - - - - Thông báo hosting mới - - - -
-
-
-
🖥️
-

Hosting account mới được khởi tạo

-

{{ $domain_name ?? 'N/A' }} — {{ $created_at ?? now()->format('d/m/Y H:i:s') }}

-
- -
- - - - - - - - - - -
Tên khách hàng{{ $customer_name ?? 'N/A' }}
Email{{ $customer_email ?? 'N/A' }}
- - - - - - - - - - - - - - - - - - - - - - - - - - - -
Tên miền{{ $domain_name ?? 'N/A' }}
Gói hosting{{ $hosting_plan ?? 'N/A' }}
Server{{ $server_name ?? 'N/A' }} ({{ $server_ip ?? 'N/A' }})
Plesk Client ID{{ $plesk_client_id ?? 'N/A' }}
Plesk Subscription ID{{ $plesk_subscription_id ?? 'N/A' }}
Trạng thái✅ Active
- - -
-
- 👤 FTP Username - {{ $ftp_username ?? 'N/A' }} -
- @if(!empty($ftp_password)) -
- 🔑 FTP Password - {{ $ftp_password }} -
- @endif -
- - -
-
- 🌐 Panel URL - {{ $plesk_panel_url ?? 'N/A' }} -
-
- 👤 Login - {{ $plesk_login ?? 'N/A' }} -
- @if(!empty($plesk_password)) -
- 🔑 Password - {{ $plesk_password }} -
- @endif -
-
- Đây là tài khoản Plesk riêng của khách hàng (đã gửi kèm cho khách), khác với tài khoản quản trị - của server dùng chung — tài khoản quản trị server không được đính kèm trong email này vì lý do - bảo mật. -
-
- - -
-
- - diff --git a/resources/views/emails/admin/manual-deposit-pending.blade.php b/resources/views/emails/admin/manual-deposit-pending.blade.php deleted file mode 100644 index 6a08b66..0000000 --- a/resources/views/emails/admin/manual-deposit-pending.blade.php +++ /dev/null @@ -1,98 +0,0 @@ - - - - - - Yêu cầu nạp tiền chờ duyệt - - - -
-
-
-
🔔
-

Yêu cầu nạp tiền chờ duyệt

-

{{ $transaction_code ?? 'N/A' }} — {{ $created_at ?? now()->format('d/m/Y H:i:s') }}

-
- -
- - - - - - - - - - - @if(!empty($customer_phone)) - - - - - @endif - @if(!empty($client_ip)) - - - - - @endif -
Tên khách hàng{{ $customer_name ?? 'N/A' }}
Email{{ $customer_email ?? 'N/A' }}
Số điện thoại{{ $customer_phone }}
IP gửi yêu cầu{{ $client_ip }}
- - - - - - - - - - - - - - - - @if(!empty($proof_image_url)) - - - - - @endif -
Mã giao dịch{{ $transaction_code ?? 'N/A' }}
Số tiền{{ number_format($amount ?? 0, 0, ',', '.') }} VNĐ
Trạng tháiĐang chờ duyệt
Chứng từXem chứng từ chuyển khoản
- -
- 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. -
-
- - -
-
- - diff --git a/resources/views/emails/admin/order-notification.blade.php b/resources/views/emails/admin/order-notification.blade.php deleted file mode 100644 index 53e1f81..0000000 --- a/resources/views/emails/admin/order-notification.blade.php +++ /dev/null @@ -1,124 +0,0 @@ - - - - - - Thông báo đơn hàng mới - - - -
-
-
-
📦
-

Đơn hàng mới đã thanh toán

-

Đơn #{{ $order_num ?? 'N/A' }} — {{ $created_at ?? now()->format('d/m/Y H:i:s') }}

-
- -
- - - - - - - - - - - @if(!empty($client_ip)) - - - - - @endif -
Tên khách hàng{{ $customer_name ?? 'N/A' }}
Email{{ $customer_email ?? 'N/A' }}
IP đặt hàng{{ $client_ip }}
- - - - - - - - - - - - - - - - - - - - - - - - - - - -
Mã đơn hàng#{{ $order_num ?? 'N/A' }}
Sản phẩm{{ $product_name ?? 'N/A' }}
Loại dịch vụ{{ $product_type ?? 'N/A' }}
Chu kỳ thanh toán{{ $billing_cycle ?? 'N/A' }}
Tổng tiền{{ number_format($total ?? 0, 0, ',', '.') }} VNĐ
Trạng thái✅ Đã thanh toán
- - @if(!empty($ip) || !empty($username) || !empty($password)) - -
- @if(!empty($ip)) -
- 🌐 IP Address - {{ $ip }} -
- @endif - @if(!empty($username)) -
- 👤 Username - {{ $username }} -
- @endif - @if(!empty($password)) -
- 🔑 Password - {{ $password }} -
- @endif -
- @endif -
- - -
-
- - diff --git a/resources/views/emails/admin/ticket-notification.blade.php b/resources/views/emails/admin/ticket-notification.blade.php deleted file mode 100644 index d69346c..0000000 --- a/resources/views/emails/admin/ticket-notification.blade.php +++ /dev/null @@ -1,83 +0,0 @@ - - - - - - Thông báo ticket mới - - - -
-
-
-
🎫
-

Ticket mới cần xử lý

-

Ticket #{{ $ticketNum ?? 'N/A' }}

-
- -
- - - - - - - - - - - - - - -
Mã ticket#{{ $ticketNum ?? 'N/A' }}
Tiêu đề{{ $subject ?? 'N/A' }}
Phòng ban{{ $room ?? 'N/A' }}
- - - - - - - - - - - -
Khách hàng{{ $customerName ?? 'N/A' }}
Email khách{{ $customerEmail ?? 'N/A' }}
- - -
{{ mb_substr($description ?? '', 0, 500) }}{{ mb_strlen($description ?? '') > 500 ? '…' : '' }}
- - -
- - -
-
- - diff --git a/resources/views/emails/admin/trial-registration-request.blade.php b/resources/views/emails/admin/trial-registration-request.blade.php deleted file mode 100644 index 7c6c37c..0000000 --- a/resources/views/emails/admin/trial-registration-request.blade.php +++ /dev/null @@ -1,96 +0,0 @@ - - - - - - Yêu cầu dùng thử Cloud VPS - - - -
-
-
-
🧪
-

Yêu cầu dùng thử Cloud VPS

-

{{ $created_at ?? now()->format('d/m/Y H:i:s') }}

-
- -
- - - - - - - - - - - - - - -
Họ tên{{ $customer_name ?? 'N/A' }}
Email{{ $customer_email ?? 'N/A' }}
Số điện thoại{{ $customer_phone ?? 'N/A' }}
- - - - - - - - - - - - - - - - - - - - @if(!empty($other)) - - - - - @endif - - - - -
CPU{{ $cpu ?: '-' }}
RAM{{ $ram ?: '-' }}
SSD{{ $ssd ?: '-' }}
Hệ điều hành{{ $os ?: '-' }}
Yêu cầu khác{{ $other }}
Trạng tháiChờ khởi tạo
- -
- Vui lòng vào trang quản trị để khởi tạo VPS dùng thử theo cấu hình khách hàng yêu cầu. -
-
- - -
-
- - diff --git a/resources/views/emails/customer/domain-order-received.blade.php b/resources/views/emails/customer/domain-order-received.blade.php deleted file mode 100644 index bfe3e9d..0000000 --- a/resources/views/emails/customer/domain-order-received.blade.php +++ /dev/null @@ -1,33 +0,0 @@ -

- Kính chào {{ $customer_name }}, -

- -

- Cảm ơn Quý khách đã đặt mua tên miền tại {{ $company_name }}. Đơn hàng của Quý khách đang chờ đội ngũ kỹ thuật kiểm tra và đăng ký thủ công. -

- -

Thông tin đơn hàng:

-
    -
  • Tên miền: {{ $domain_name }}
  • -
  • Mã đơn hàng: {{ $order_num }}
  • -
  • Tổng tiền: {{ $order_total }}
  • -
  • Thời gian: {{ $created_at }}
  • -
- -

- Lưu ý: việc đặt mua không đồng nghĩa tên miền đã được đăng ký thành công. Nếu tên miền không còn khả dụng, số dư đã thanh toán sẽ được hoàn lại vào tài khoản. Quý khách sẽ nhận được email khi tên miền được đăng ký thành công, kèm theo các thông tin KYC cần bổ sung (nếu có). -

- - - - - - - -
- XEM ĐƠN HÀNG CỦA TÔI -
- -

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

diff --git a/resources/views/emails/customer/holiday-notice.blade.php b/resources/views/emails/customer/holiday-notice.blade.php deleted file mode 100644 index 0c90845..0000000 --- a/resources/views/emails/customer/holiday-notice.blade.php +++ /dev/null @@ -1,185 +0,0 @@ -@php - $heroImage = $heroImage ?? 'https://client.tgs.com.vn/index.php?rp=/images/em/368_30.4-CP.png'; - $ticketUrl = $ticketUrl ?? 'https://client.tgs.com.vn/submitticket.php?step=2&deptid=5'; - $supportEmail = $supportEmail ?? 'Support@tgs.com.vn'; - $hotline = $hotline ?? '(028) 7309. 7379'; - $holidayName = $holidayName ?? 'ngày Giải phóng miền Nam 30/4 và ngày Quốc tế Lao động 1/5'; - $holidayFrom = $holidayFrom ?? 'Thứ Năm, ngày 30/04/2026'; - $holidayTo = $holidayTo ?? 'Thứ Hai, ngày 04/05/2026'; - $workAgain = $workAgain ?? 'Thứ Ba, ngày 05/05/2026'; -@endphp - - - - - - - -
- - - - - - - - - - - - -
- - - - - - - - - -
- -
- - - - - - -
-
 
-
Kính gửi: Quý Khách hàng, Quý Đối tác
-
 
-
- Nhân dịp kỷ niệm {{ $holidayName }}, Thế Giới Số xin trân trọng gửi tới Quý Khách hàng và Quý Đối tác lời chúc sức khỏe, hạnh phúc và thành công. Chúng tôi chân thành cảm ơn sự tin tưởng và đồng hành của Quý Khách hàng, Quý Đối tác trong suốt thời gian qua. -
-
 
-
Thế Giới Số xin thông báo lịch nghỉ lễ như sau:
-
 
-
     Thời gian nghỉ lễ: Từ {{ $holidayFrom }} đến hết {{ $holidayTo }}
-
 
-
     Thời gian làm việc trở lại: {{ $workAgain }}
-
 
-
Trong thời gian nghỉ lễ, nếu có vấn đề cần hỗ trợ kỹ thuật Quý Khách hàng và Quý Đối tác vui lòng liên hệ qua các kênh sau:
-
 
-
-     + Gửi ticket qua: - - {{ $ticketUrl }} - -
-
 
-
-     + Email: - - {{ $supportEmail }} - -
-
 
-
    + Hotline: {{ $hotline }}
-
 
-
Kính chúc Quý Khách hàng và Quý Đối tác có một kỳ nghỉ lễ vui vẻ, an toàn và nhiều sức khỏe.
-
 
-
Thế Giới Số trân trọng thông báo.
-
 
-
-
-
- - - - - - - - - - - - - - - - - - -
- - - - - - -
 
-
 
- - - - - - - - - - - - - - -
  - Premium HOSTING -   - Cloud SERVER -   - EMAIL DOANH NGHIỆP -   - Thiết Kế WEBSITE -  
-
 
- - - - - - -
 
-
-
- - - - - - - - - - - - -
 
-

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

-

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

-
 
-
-
diff --git a/resources/views/emails/customer/hosting-account-created.blade.php b/resources/views/emails/customer/hosting-account-created.blade.php deleted file mode 100644 index f039ff6..0000000 --- a/resources/views/emails/customer/hosting-account-created.blade.php +++ /dev/null @@ -1,221 +0,0 @@ -@php - $appName = $appName ?? config('app.name', 'Thế Giới Số'); - $fullName = $fullName ?? 'Quý khách'; - $domainName = $domainName ?? ''; - $hostingPlan = $hostingPlan ?? ''; - $serverHost = $serverHost ?? ''; - $ftpUsername = $ftpUsername ?? ''; - $ftpPassword = $ftpPassword ?? ''; - $pleskLogin = $pleskLogin ?? ''; - $pleskPassword = $pleskPassword ?? ''; - $pleskPanelUrl = $pleskPanelUrl ?? ''; - $loginUrl = $loginUrl ?? rtrim(config('app.frontend_url', ''), '/') . '/dang-nhap'; - $supportEmail = $supportEmail ?? 'support@tgs.com.vn'; - $supportPhone = $supportPhone ?? '(028) 7309. 7379'; -@endphp - - - - - - - -
- - - - - - - - - - - - -
- - - - - - -
- - Thư gửi từ Công Ty Cổ Phần Giải Pháp Công Nghệ Thế Giới Số - -
-
-

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

- -

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

- -

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

- -

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

- - - - - - - - - - - - @if($serverHost) - - - - - @endif - @if($ftpUsername) - - - - - @endif - @if($ftpPassword) - - - - - @endif - - - - - -
Tên miền:{{ $domainName }}
Gói hosting:{{ $hostingPlan }}
Máy chủ / FTP Host:{{ $serverHost }}
Tài khoản FTP:{{ $ftpUsername }}
Mật khẩu FTP:{{ $ftpPassword }}
Trạng thái:Đang hoạt động ✓
- - @if($ftpPassword) -

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

- @endif - - @if($pleskLogin) -

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

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

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

- - - - - - - -
- Đăng nhập ngay -
- -

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

- -

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

- -

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

-
-
diff --git a/resources/views/emails/customer/manual-deposit-status.blade.php b/resources/views/emails/customer/manual-deposit-status.blade.php deleted file mode 100644 index 4900d80..0000000 --- a/resources/views/emails/customer/manual-deposit-status.blade.php +++ /dev/null @@ -1,74 +0,0 @@ - - - - - - {{ $companyName }} - Trạng thái nạp tiền - - - - - - -
- - - - - - - -
- @if(!empty($companyLogo)) - {{ $companyName }} - @else - {{ $companyName }} - @endif -
-

Kính chào {{ $customerName }},

-

{{ $statusMessage }}

- - - - - - - - - - - - - - - - - - - @if(!empty($processedDate)) - - - - - @endif -
Trạng thái - {{ $statusLabel }} -
Mã giao dịch{{ $transactionCode }}
Số tiền{{ $amountFormatted }} VNĐ
Thời gian gửi{{ $depositDate }}
Thời gian xử lý{{ $processedDate }}
- - - - - -
- XEM YÊU CẦU NẠP TIỀN -
- -

Nếu bạn cần hỗ trợ thêm, vui lòng liên hệ {{ $supportEmail }} hoặc {{ $supportPhone }}.

-

Lưu ý:

-
    -
  • Đây là tin nhắn tự động. Vui lòng không trả lời tin nhắn này!
  • -
-
-
- - diff --git a/resources/views/emails/customer/order-payment-success.blade.php b/resources/views/emails/customer/order-payment-success.blade.php deleted file mode 100644 index b668611..0000000 --- a/resources/views/emails/customer/order-payment-success.blade.php +++ /dev/null @@ -1,128 +0,0 @@ - - - - - - Đặt hàng thành công - - - -
-
-
-
✅
-

Đặt hàng thành công!

-

Đơn hàng #{{ $orderNum ?? 'N/A' }} đã được xác nhận và thanh toán

-
- -
-

- Xin chào {{ $customerName ?? 'Quý khách' }},
- Cảm ơn bạn đã tin tưởng sử dụng dịch vụ của chúng tôi. Đơn hàng của bạn đã được xác nhận và đang trong quá trình khởi tạo. -

- - - - - -
Mã đơn hàng#{{ $orderNum ?? 'N/A' }}
Tổng thanh toán{{ number_format($amount ?? 0, 0, ',', '.') }} VNĐ
- - @if(!empty($vmResults)) - - @foreach($vmResults as $vm) -
- @if(!empty($vm['ip'])) -
- 🌐 IP Address - {{ $vm['ip'] }} -
- @endif - @if(!empty($vm['username'])) -
- 👤 Username - {{ $vm['username'] }} -
- @endif - @if(!empty($vm['password'])) -
- 🔑 Password - {{ $vm['password'] }} -
- @endif - @if(!empty($vm['os'])) -
- 💿 Hệ điều hành - {{ $vm['os'] }} -
- @endif -
- @endforeach - @endif - - @if(!empty($addonsSummary)) - - - - - - - - - - - @foreach($addonsSummary as $addon) - - - - - - @endforeach - -
Dịch vụ addonSố lượngThành tiền
{{ $addon['name'] }}{{ $addon['qty'] }}{{ number_format($addon['subtotal'], 0, ',', '.') }} VNĐ
- @endif - -
- ⚠️ Vui lòng đổi mật khẩu ngay sau khi đăng nhập lần đầu để bảo mật tài khoản.
- Nếu cần hỗ trợ, liên hệ: {{ $supportEmail ?? '' }} hoặc {{ $supportPhone ?? '' }} -
-
- - -
-
- - diff --git a/resources/views/emails/customer/password-reset-admin.blade.php b/resources/views/emails/customer/password-reset-admin.blade.php deleted file mode 100644 index 25d10f5..0000000 --- a/resources/views/emails/customer/password-reset-admin.blade.php +++ /dev/null @@ -1,188 +0,0 @@ -@php - $appName = $appName ?? config('app.name', 'Thế Giới Số'); - $fullName = $fullName ?? 'Quý khách'; - $email = $email ?? ''; - $password = $password ?? ''; - $loginUrl = $loginUrl ?? rtrim(config('app.frontend_url', ''), '/') . '/dang-nhap'; - $supportEmail = $supportEmail ?? 'support@tgs.com.vn'; - $supportPhone = $supportPhone ?? '(028) 7309. 7379'; -@endphp - - - - - - - -
- - - - - - - - - - - - -
- - - - - - - - - -
-
🔐
-

- Mật khẩu của bạn đã được cập nhật -

-
- Quản trị viên vừa cấp lại mật khẩu mới cho tài khoản của bạn -
-
- - - - - - -
-
 
-
Xin chào {{ $fullName }},
-
 
-
- Quản trị viên của {{ $appName }} vừa cấp lại mật khẩu mới cho tài khoản của bạn. - Vui lòng sử dụng thông tin bên dưới để đăng nhập và đổi lại mật khẩu của bạn ngay sau đó. -
-
 
- - - - - - -
-
Tài khoản: {{ $email }}
-
Mật khẩu mới: {{ $password }}
-
-
 
- -
 
-
- Nếu bạn không yêu cầu thao tác này, vui lòng liên hệ ngay với chúng tôi qua {{ $supportEmail }}. -
-
 
-
- Email này được gửi tự động. Vui lòng không trả lời email này. -
-
 
-
-
-
- - - - - - - - - - - - - - - - - - -
- - - - - - -
 
-
 
- - - - - - - - - - - - - - -
  - Premium HOSTING -   - Cloud SERVER -   - EMAIL DOANH NGHIỆP -   - Thiết Kế WEBSITE -  
-
 
- - - - - - -
 
-
-
- - - - - - - - - - - - -
 
-

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

-

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

-
 
-
-
diff --git a/resources/views/emails/customer/service-renewal-reminder.blade.php b/resources/views/emails/customer/service-renewal-reminder.blade.php deleted file mode 100644 index a5f0d95..0000000 --- a/resources/views/emails/customer/service-renewal-reminder.blade.php +++ /dev/null @@ -1,113 +0,0 @@ -@php - $clientName = $clientName ?? 'Quý khách'; - $productName = $productName ?? 'Dịch vụ'; - $serverName = $serverName ?? ''; - $expiresAt = $expiresAt ?? 'N/A'; - $price = $price ?? null; - $loginUrl = $loginUrl ?? rtrim(config('app.frontend_url', ''), '/') . '/dang-nhap'; - $supportEmail = $supportEmail ?? 'support@tgs.com.vn'; - $supportPhone = $supportPhone ?? '(028) 7309. 7379'; -@endphp - - - - - - - -
- - - - - - - - - - - - -
- - - - - - -
- - Thư gửi từ Công Ty Cổ Phần Giải Pháp Công Nghệ Thế Giới Số - -
-
-

- Nhắc gia hạn dịch vụ -

- -

- Kính chào {{ $clientName }}, -

- -

- Dịch vụ {{ $productName }}{{ $serverName ? " ({$serverName})" : '' }} của bạn sắp đến hạn thanh toán. Vui lòng gia hạn sớm để tránh gián đoạn dịch vụ. -

- - - - - - - - - - - - @if($price) - - - - - @endif - -
Dịch vụ:{{ $productName }}
Ngày hết hạn:{{ $expiresAt }}
Số tiền gia hạn:{{ $price }} VNĐ
- -

- Bạn có thể đăng nhập vào cổng quản lý để gia hạn dịch vụ. -

- - - - - - - -
- Đăng nhập ngay -
- -

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

- -

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

-
-
diff --git a/resources/views/emails/customer/snapshot-action.blade.php b/resources/views/emails/customer/snapshot-action.blade.php deleted file mode 100644 index 9740a40..0000000 --- a/resources/views/emails/customer/snapshot-action.blade.php +++ /dev/null @@ -1,202 +0,0 @@ -@php - $appName = $appName ?? config('app.name', 'Thế Giới Số'); - $fullName = $fullName ?? 'Quý khách'; - $vmName = $vmName ?? ''; - $snapshotName = $snapshotName ?? ''; - $action = $action ?? 'create'; - $actionAt = $actionAt ?? now()->format('d/m/Y H:i:s'); - $loginUrl = $loginUrl ?? rtrim(config('app.frontend_url', ''), '/') . '/dang-nhap'; - $supportEmail = $supportEmail ?? 'support@tgs.com.vn'; - $supportPhone = $supportPhone ?? '(028) 7309. 7379'; - - $actionLabels = [ - 'create' => ['icon' => '📸', 'title' => 'Tạo Snapshot thành công', 'desc' => 'Snapshot máy chủ ảo của bạn đã được tạo thành công', 'status' => 'Tạo thành công ✓'], - 'remove' => ['icon' => '🗑️', 'title' => 'Xóa Snapshot thành công', 'desc' => 'Snapshot máy chủ ảo của bạn đã được xóa thành công', 'status' => 'Xóa thành công ✓'], - 'revert' => ['icon' => '🔄', 'title' => 'Khôi phục Snapshot thành công', 'desc' => 'Máy chủ ảo của bạn đã được khôi phục về snapshot thành công', 'status' => 'Khôi phục thành công ✓'], - ]; - $label = $actionLabels[$action] ?? $actionLabels['create']; -@endphp - - - - - - - -
- - - - - - - - - - - - -
- - - - - - - - - -
-
{{ $label['icon'] }}
-

- {{ $label['title'] }} -

-
- {{ $label['desc'] }} -
-
- - - - - - -
-
 
-
Xin chào {{ $fullName }},
-
 
-
- Thao tác snapshot máy chủ ảo của bạn tại {{ $appName }} đã hoàn tất. -
-
 
- - - - - - -
-
Máy chủ: {{ $vmName }}
-
Snapshot: {{ $snapshotName }}
-
Thời gian: {{ $actionAt }}
-
Trạng thái: {{ $label['status'] }}
-
-
 
-
- Bạn có thể đăng nhập vào cổng quản lý để kiểm tra trạng thái dịch vụ. -
-
 
- -
 
-
- Nếu bạn có thắc mắc, vui lòng liên hệ với chúng tôi qua {{ $supportEmail }} hoặc {{ $supportPhone }}. -
-
 
-
- Email này được gửi tự động. Vui lòng không trả lời email này. -
-
 
-
-
-
- - - - - - - - - - - - - - - - - - -
- - - - - - -
 
-
 
- - - - - - - - - - - - - - -
  - Premium HOSTING -   - Cloud SERVER -   - EMAIL DOANH NGHIỆP -   - Thiết Kế WEBSITE -  
-
 
- - - - - - -
 
-
-
- - - - - - - - - - - - -
 
-

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

-

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

-
 
-
-
diff --git a/resources/views/emails/customer/ticket-created.blade.php b/resources/views/emails/customer/ticket-created.blade.php deleted file mode 100644 index 8eba526..0000000 --- a/resources/views/emails/customer/ticket-created.blade.php +++ /dev/null @@ -1,209 +0,0 @@ -@php - $appName = $appName ?? config('app.name', 'Thế Giới Số'); - $fullName = $fullName ?? 'Quý khách'; - $supportEmail = $supportEmail ?? 'support@tgs.com.vn'; - $supportPhone = $supportPhone ?? '(028) 7309. 7379'; -@endphp - - - - - - - -
- - - - - - - - - - - - -
- - - - - - - - - -
-
🎫
-

- Yêu cầu hỗ trợ đã được tiếp nhận -

-
- Chúng tôi sẽ phản hồi trong thời gian sớm nhất -
-
- - - - - - -
-
 
-
Xin chào {{ $fullName }},
-
 
-
- Chúng tôi đã nhận được yêu cầu hỗ trợ của Quý khách và sẽ xử lý trong thời gian sớm nhất. Dưới đây là thông tin ticket của Quý khách: -
-
 
- - {{-- Ticket info box --}} - - - - - - -
- - - - - - - - - - - @if($room) - - - - - @endif - - - - - -
Mã ticket:#{{ $ticketNum }}
Tiêu đề:{{ $subject }}
Phòng ban:{{ $room }}
Trạng thái: - Đang mở -
-
- -
 
- -
 
-
- Đội ngũ hỗ trợ của {{ $appName }} sẽ liên hệ với Quý khách qua email hoặc cập nhật trực tiếp trên hệ thống. Cảm ơn Quý khách đã tin tưởng sử dụng dịch vụ của chúng tôi. -
-
 
-
- Email này được gửi tự động. Vui lòng không trả lời email này. -
-
 
-
-
-
- - - - - - - - - - - - - - - - - - -
- - - - - - -
 
-
 
- - - - - - - - - - - - - - -
  - Premium HOSTING -   - Cloud SERVER -   - EMAIL DOANH NGHIỆP -   - Thiết Kế WEBSITE -  
-
 
- - - - - - -
 
-
-
- - - - - - - - - - - - -
 
-

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

-

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

-
 
-
-
diff --git a/resources/views/emails/customer/verify-account-welcome.blade.php b/resources/views/emails/customer/verify-account-welcome.blade.php deleted file mode 100644 index 8be2e77..0000000 --- a/resources/views/emails/customer/verify-account-welcome.blade.php +++ /dev/null @@ -1,149 +0,0 @@ -@php - $appName = $appName ?? config('app.name', 'Thế Giới Số'); - $fullName = $fullName ?? 'Quý khách'; - $verifyCode = $verifyCode ?? ''; - $supportEmail = $supportEmail ?? 'support@tgs.com.vn'; - $supportPhone = $supportPhone ?? '(028) 7309. 7379'; -@endphp - - - - - - - -
- - - - - - - - - - - - -
- - - - - - -
- - Thư gửi từ Công Ty Cổ Phần Giải Pháp Công Nghệ Thế Giới Số - -
-
-

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

- -

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

- -

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

- -

- Cảm ơn bạn đã đăng ký tài khoản tại {{ $appName }}. Để hoàn tất quá trình đăng ký, vui lòng sử dụng mã xác thực dưới đây: -

- - - - - - - -
- {{ $verifyCode }} -
- -

- Mã xác thực có hiệu lực trong 5 phút. Vui lòng không chia sẻ mã này với bất kỳ ai. -

- -

- Nếu bạn không thực hiện yêu cầu này, vui lòng bỏ qua email này. -

- -

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

- -

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

-
-
diff --git a/resources/views/emails/customer/vm-creation-failed.blade.php b/resources/views/emails/customer/vm-creation-failed.blade.php deleted file mode 100644 index cbb6753..0000000 --- a/resources/views/emails/customer/vm-creation-failed.blade.php +++ /dev/null @@ -1,184 +0,0 @@ -@php - $appName = $appName ?? config('app.name', 'Thế Giới Số'); - $fullName = $customerName ?? 'Quý khách'; - $orderNum = $orderNum ?? 'N/A'; - $productName = $productName ?? 'N/A'; - $invoiceUrl = $invoiceUrl ?? rtrim((string) env('FRONTEND_URL', config('app.url')), '/') . '/hoa-don'; - $supportEmail = $supportEmail ?? 'support@tgs.com.vn'; - $supportPhone = $supportPhone ?? '(028) 7309. 7379'; -@endphp - - - - - - - -
- - - - - - - - - - - - -
- - - - - - - - - -
-
⚠️
-

- Thông báo về đơn hàng #{{ $orderNum }} -

-
- Đội ngũ kỹ thuật đang xử lý yêu cầu của Quý khách -
-
- - - - - - -
-
 
-
Xin chào {{ $fullName }},
-
 
-
- Chúng tôi đã nhận được yêu cầu khởi tạo dịch vụ cho đơn hàng của Quý khách. Hiện tại hệ thống đang trong quá trình xử lý và đội ngũ kỹ thuật của {{ $appName }} đã được thông báo để hỗ trợ nhanh nhất có thể. -
-
 
-
Thông tin đơn hàng:
-
    + Mã đơn hàng: #{{ $orderNum }}
-
    + Sản phẩm: {{ $productName }}
-
 
-
- Quý khách không cần thực hiện thêm bất kỳ thao tác nào. Chúng tôi sẽ liên hệ lại khi dịch vụ được kích hoạt thành công. -
-
 
- -
 
-
- Nếu cần hỗ trợ khẩn cấp, Quý khách vui lòng liên hệ qua email {{ $supportEmail }} hoặc tổng đài {{ $supportPhone }}. -
-
 
-
- Email này được gửi tự động. Vui lòng không trả lời email này. -
-
 
-
-
-
- - - - - - - - - - - - - - - - - - -
- - - - - - -
 
-
 
- - - - - - - - - - - - - - -
  - Premium HOSTING -   - Cloud SERVER -   - EMAIL DOANH NGHIỆP -   - Thiết Kế WEBSITE -  
-
 
- - - - - - -
 
-
-
- - - - - - - - - - - - -
 
-

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

-

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

-
 
-
-
diff --git a/resources/views/emails/customer/vm-password-changed.blade.php b/resources/views/emails/customer/vm-password-changed.blade.php deleted file mode 100644 index 7ebef3e..0000000 --- a/resources/views/emails/customer/vm-password-changed.blade.php +++ /dev/null @@ -1,194 +0,0 @@ -@php - $appName = $appName ?? config('app.name', 'Thế Giới Số'); - $fullName = $fullName ?? 'Quý khách'; - $vmName = $vmName ?? ''; - $newPassword = $newPassword ?? ''; - $loginUrl = $loginUrl ?? rtrim(config('app.frontend_url', ''), '/') . '/dang-nhap'; - $supportEmail = $supportEmail ?? 'support@tgs.com.vn'; - $supportPhone = $supportPhone ?? '(028) 7309. 7379'; -@endphp - - - - - - - -
- - - - - - - - - - - - -
- - - - - - - - - -
-
🔑
-

- Mật khẩu VM đã được thay đổi -

-
- Mật khẩu máy chủ ảo của bạn đã được cập nhật thành công -
-
- - - - - - -
-
 
-
Xin chào {{ $fullName }},
-
 
-
- Mật khẩu đăng nhập vào máy chủ ảo (VM) của bạn tại {{ $appName }} đã được thay đổi thành công. -
-
 
- - - - - - -
-
Máy chủ: {{ $vmName }}
- @if($newPassword) -
Mật khẩu mới: {{ $newPassword }}
- @endif -
Trạng thái: Đổi mật khẩu thành công ✓
-
-
 
-
- Bạn có thể đăng nhập vào cổng quản lý để kiểm tra trạng thái dịch vụ. -
-
 
- -
 
-
- Nếu bạn không thực hiện thao tác này, vui lòng liên hệ ngay với chúng tôi qua {{ $supportEmail }} hoặc {{ $supportPhone }}. -
-
 
-
- Email này được gửi tự động. Vui lòng không trả lời email này. -
-
 
-
-
-
- - - - - - - - - - - - - - - - - - -
- - - - - - -
 
-
 
- - - - - - - - - - - - - - -
  - Premium HOSTING -   - Cloud SERVER -   - EMAIL DOANH NGHIỆP -   - Thiết Kế WEBSITE -  
-
 
- - - - - - -
 
-
-
- - - - - - - - - - - - -
 
-

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

-

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

-
 
-
-
diff --git a/resources/views/emails/customer/vm-rebuild-failed.blade.php b/resources/views/emails/customer/vm-rebuild-failed.blade.php deleted file mode 100644 index efe451c..0000000 --- a/resources/views/emails/customer/vm-rebuild-failed.blade.php +++ /dev/null @@ -1,121 +0,0 @@ -@php - $appName = $appName ?? config('app.name', 'Thế Giới Số'); - $fullName = $customerName ?? 'Quý khách'; - $vmName = $vmName ?? 'N/A'; - $ip = $ip ?? 'N/A'; - $serviceUrl = $serviceUrl ?? rtrim((string) env('FRONTEND_URL', config('app.url')), '/') . '/dich-vu'; - $supportEmail = $supportEmail ?? 'support@tgs.com.vn'; - $supportPhone = $supportPhone ?? '(028) 7309. 7379'; -@endphp - - - - - - - -
- - - - - - - - - -
- - - - - - - - - -
-
⚠️
-

- Thông báo về VPS {{ $vmName }} -

-
- Đội ngũ kỹ thuật đang xử lý yêu cầu của Quý khách -
-
- - - - - - -
-
 
-
Xin chào {{ $fullName }},
-
 
-
- Rất tiếc, yêu cầu cài lại hệ điều hành (rebuild) cho VPS của Quý khách hiện chưa thực hiện thành công. Đội ngũ kỹ thuật của {{ $appName }} đã được thông báo để kiểm tra và xử lý. -
-
 
-
Thông tin dịch vụ:
-
    + Tên VPS: {{ $vmName }}
-
    + IP: {{ $ip }}
-
 
-
- VPS hiện tại của Quý khách vẫn đang hoạt động bình thường trên cấu hình cũ, không có dữ liệu hay dịch vụ nào bị gián đoạn. Quý khách không cần thực hiện thêm thao tác nào, chúng tôi sẽ liên hệ lại khi rebuild được xử lý xong. -
-
 
- -
 
-
- Nếu cần hỗ trợ khẩn cấp, Quý khách vui lòng liên hệ qua email {{ $supportEmail }} hoặc tổng đài {{ $supportPhone }}. -
-
 
-
- Email này được gửi tự động. Vui lòng không trả lời email này. -
-
 
-
-
-
- - - - - - - - - - - - -
 
-

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

-

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

-
 
-
-
diff --git a/resources/views/emails/customer/vm-suspended.blade.php b/resources/views/emails/customer/vm-suspended.blade.php deleted file mode 100644 index ba29bb9..0000000 --- a/resources/views/emails/customer/vm-suspended.blade.php +++ /dev/null @@ -1,77 +0,0 @@ - - - - - - Dịch vụ Cloud VPS bị tạm ngưng - - - -
-
-
-
⚠️
-

Dịch vụ Cloud VPS đã bị tạm ngưng

-

Vui lòng thanh toán để khôi phục dịch vụ

-
- -
-

- Xin chào {{ $customerName ?? 'Quý khách' }},
- Dịch vụ Cloud VPS của bạn hiện đã bị tạm ngưng (Suspend) do chưa thanh toán gia hạn. - Vui lòng nhanh chóng liên hệ hoặc thanh toán để tránh mất dữ liệu. -

- - - - - - -
Tên dịch vụ{{ $vmName }}
VM ID{{ $vmwareId }}
Thời gian tạm ngưng{{ now()->setTimezone('Asia/Ho_Chi_Minh')->format('d/m/Y H:i') }}
- -
-

⏰ Sau 30 ngày kể từ ngày tạm ngưng, nếu không có thanh toán, dịch vụ của bạn sẽ bị xóa vĩnh viễn và toàn bộ dữ liệu sẽ không thể khôi phục.

-

💡 Vui lòng đăng nhập vào cổng khách hàng để gia hạn dịch vụ hoặc liên hệ bộ phận hỗ trợ để được giải quyết kịp thời.

-
- -
- 🚨 CẢNH BÁO: Sau 30 ngày không thanh toán, toàn bộ dữ liệu trên máy chủ ảo sẽ bị XÓA VĨNH VIỄN và không thể phục hồi. -
- - -
- - -
-
- - diff --git a/resources/views/emails/customer/welcome.blade.php b/resources/views/emails/customer/welcome.blade.php index 7a6cebb..7e83360 100644 --- a/resources/views/emails/customer/welcome.blade.php +++ b/resources/views/emails/customer/welcome.blade.php @@ -12,21 +12,7 @@
- - - + @include('emails.partials.email-header') - - - + @include('emails.partials.email-footer')
- - - - - - -
- - Thư gửi từ Công Ty Cổ Phần Giải Pháp Công Nghệ Thế Giới Số - -
-

@@ -88,74 +74,7 @@

+ + + + + + +
+ + Thư gửi từ Công Ty Cổ Phần Giải Pháp Công Nghệ Thế Giới Số + +
+