diff --git a/.phpunit.cache/test-results b/.phpunit.cache/test-results index c06a223..641a271 100644 --- a/.phpunit.cache/test-results +++ b/.phpunit.cache/test-results @@ -1 +1 @@ -{"version":2,"defects":{"Tests\\Feature\\Api\\Client\\ClientAuthSecurityTest::test_reused_rotated_refresh_token_revokes_all_tokens_of_customer":7,"Tests\\Feature\\Console\\ReencryptSecretsTest::test_rotating_keys_re_encrypts_rsa_and_app_key_columns":7,"Tests\\Feature\\Console\\ReencryptSecretsTest::test_dry_run_changes_nothing_and_undecryptable_rows_fail_the_command":7,"Tests\\Feature\\Api\\Admin\\Customer\\CustomerMassAssignmentTest::test_store_ignores_fields_not_declared_in_validation_rules":7,"Tests\\Feature\\Api\\Admin\\Customer\\CustomerMassAssignmentTest::test_update_ignores_fields_not_declared_in_validation_rules":7,"Tests\\Feature\\Api\\Admin\\Customer\\ResetPasswordTest::test_reset_password_sends_email_and_does_not_leak_password_in_response":7,"Tests\\Feature\\Api\\Admin\\LoginThrottleTest::test_admin_login_is_throttled_after_5_attempts_per_minute":7,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"settings index\"":7,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"banks index\"":7,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"domain-pricing index\"":7,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"email-templates index\"":7,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"tickets index\"":7,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"activity-logs index\"":7,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"email-logs index\"":7,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"post-categories index\"":7,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"posts index\"":7,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_force_delete_post_requires_post_destroy_permission":7,"Tests\\Feature\\Api\\Admin\\Employee\\EmployeeRoleGuardTest::test_non_full_administrator_cannot_edit_a_full_administrator":7,"Tests\\Feature\\Api\\Admin\\Employee\\EmployeeRoleGuardTest::test_non_full_administrator_cannot_delete_a_full_administrator":7,"Tests\\Feature\\Api\\Admin\\Employee\\EmployeeRoleGuardTest::test_cannot_change_own_role":7,"Tests\\Feature\\Api\\Admin\\Employee\\EmployeeRoleGuardTest::test_non_full_administrator_cannot_assign_administrator_role":7,"Tests\\Feature\\Api\\Service\\VmChangeIpTest::test_change_ip_to_free_pool_ip_dispatches_job_with_pool_gateway":7,"Tests\\Feature\\Api\\Admin\\Employee\\TmpDbgTest::test_cannot_change_own_role":5},"times":{"Tests\\Unit\\BillingPricingTest::test_cycle_to_months_covers_every_enum_case":0.003,"Tests\\Unit\\BillingPricingTest::test_cycle_to_months_throws_on_unknown_value":0,"Tests\\Unit\\BillingPricingTest::test_cycle_to_months_throws_on_null":0,"Tests\\Unit\\BillingPricingTest::test_domain_price_formula with data set \"years=1, no setup fee\"":0.004,"Tests\\Unit\\BillingPricingTest::test_domain_price_formula with data set \"years=1, with setup fee\"":0,"Tests\\Unit\\BillingPricingTest::test_domain_price_formula with data set \"years=3, no setup fee\"":0,"Tests\\Unit\\BillingPricingTest::test_domain_price_formula with data set \"years=3, with setup fee\"":0,"Tests\\Unit\\EmailTemplateRendererTest::test_it_replaces_known_placeholders_in_subject_and_html":0.011,"Tests\\Unit\\EmailTemplateRendererTest::test_it_falls_back_to_example_value_when_data_missing":0,"Tests\\Unit\\EmailTemplateRendererTest::test_it_html_escapes_string_type_by_default":0.001,"Tests\\Unit\\EmailTemplateRendererTest::test_it_rejects_invalid_url_type":0,"Tests\\Unit\\EmailTemplateRendererTest::test_it_throws_for_undeclared_variable":0.001,"Tests\\Unit\\EmailTemplateRendererTest::test_it_never_executes_placeholder_content_as_code":0,"Tests\\Unit\\ExampleTest::test_that_true_is_true":0,"Tests\\Unit\\Models\\ReinstallLogPlainPasswordTest::test_plain_password_is_stored_encrypted_and_reads_back_transparently":0.004,"Tests\\Unit\\Models\\ReinstallLogPlainPasswordTest::test_backfill_migration_is_idempotent":0.001,"Tests\\Unit\\ServiceFillableTest::test_service_ignores_fields_not_declared_in_fillable":0.04,"Tests\\Unit\\Services\\VCenter\\PowerShellServiceSecretLoggingTest::test_change_ip_vm_linux_never_logs_the_guest_password":0.013,"Tests\\Unit\\Services\\VCenter\\PowerShellServiceSecretLoggingTest::test_change_ip_vm_windows_never_logs_the_guest_password":0.001,"Tests\\Unit\\Support\\VCenterHttpClientFactoryTest::test_verify_true_and_no_ca_bundle_uses_default_verification":0.002,"Tests\\Unit\\Support\\VCenterHttpClientFactoryTest::test_verify_true_with_ca_bundle_pins_the_bundle_path":0,"Tests\\Unit\\Support\\VCenterHttpClientFactoryTest::test_verify_false_disables_verification":0,"Tests\\Feature\\Api\\Admin\\Customer\\CustomerMassAssignmentTest::test_store_ignores_fields_not_declared_in_validation_rules":0.042,"Tests\\Feature\\Api\\Admin\\Customer\\CustomerMassAssignmentTest::test_update_ignores_fields_not_declared_in_validation_rules":0.01,"Tests\\Feature\\Api\\Admin\\Customer\\ResetPasswordTest::test_reset_password_sends_email_and_does_not_leak_password_in_response":0.019,"Tests\\Feature\\Api\\Admin\\Employee\\EmployeeRoleGuardTest::test_non_full_administrator_cannot_edit_a_full_administrator":0.012,"Tests\\Feature\\Api\\Admin\\Employee\\EmployeeRoleGuardTest::test_non_full_administrator_cannot_delete_a_full_administrator":0.01,"Tests\\Feature\\Api\\Admin\\Employee\\EmployeeRoleGuardTest::test_cannot_change_own_role":0.008,"Tests\\Feature\\Api\\Admin\\Employee\\EmployeeRoleGuardTest::test_non_full_administrator_cannot_assign_administrator_role":0.011,"Tests\\Feature\\Api\\Admin\\Employee\\EmployeeRoleGuardTest::test_full_administrator_can_assign_administrator_role":0.014,"Tests\\Feature\\Api\\Admin\\InvoiceTestRouteRemovedTest::test_unauthenticated_invoice_test_routes_no_longer_exist":0.003,"Tests\\Feature\\Api\\Admin\\LoginThrottleTest::test_admin_login_is_throttled_after_5_attempts_per_minute":0.008,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"settings index\"":0.08,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"banks index\"":0.011,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"domain-pricing index\"":0.011,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"email-templates index\"":0.014,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"tickets index\"":0.015,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"activity-logs index\"":0.014,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"email-logs index\"":0.016,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"post-categories index\"":0.017,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"posts index\"":0.016,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"media index\"":0.117,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"dashboard stats\"":0.019,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"queue jobs recent\"":0.031,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_force_delete_post_requires_post_destroy_permission":0.016,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_dashboard_requires_dashboard_view_permission":0.025,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_full_administrator_bypasses_all_permission_gates":0.051,"Tests\\Feature\\Api\\Client\\ClientAuthSecurityTest::test_access_token_older_than_skew_window_is_rejected":0.003,"Tests\\Feature\\Api\\Client\\ClientAuthSecurityTest::test_verify_email_code_is_invalidated_after_too_many_wrong_attempts":0.015,"Tests\\Feature\\Api\\Client\\ClientAuthSecurityTest::test_forgot_password_sends_link_without_changing_password_and_reset_revokes_sessions":0.448,"Tests\\Feature\\Api\\Client\\ClientAuthSecurityTest::test_cccd_images_are_private_and_served_only_via_signed_url":0.019,"Tests\\Feature\\Api\\Client\\ClientAuthSecurityTest::test_access_token_is_revoked_when_password_changes":0.02,"Tests\\Feature\\Api\\Client\\ClientAuthSecurityTest::test_throttle_uses_client_ip_from_encrypted_payload_not_next_server_ip":0.072,"Tests\\Feature\\Api\\Client\\ClientAuthSecurityTest::test_logout_revokes_refresh_token_so_it_cannot_be_used_again":0.009,"Tests\\Feature\\Api\\Client\\ClientAuthSecurityTest::test_reused_rotated_refresh_token_revokes_all_tokens_of_customer":0.01,"Tests\\Feature\\Api\\Client\\ClientAuthSecurityTest::test_legacy_cccd_path_is_not_exposed":0.007,"Tests\\Feature\\Api\\Client\\SecurityHardeningTest::test_register_rejects_short_password":0.004,"Tests\\Feature\\Api\\Client\\SecurityHardeningTest::test_register_accepts_password_with_at_least_ten_characters":0.01,"Tests\\Feature\\Api\\Client\\SecurityHardeningTest::test_cccd_upload_rejects_svg":0.011,"Tests\\Feature\\Api\\DomainOrder\\AdminDomainOrderTest::test_admin_can_list_domain_orders":0.014,"Tests\\Feature\\Api\\DomainOrder\\AdminDomainOrderTest::test_non_admin_cannot_access_domain_orders":0.002,"Tests\\Feature\\Api\\DomainOrder\\AdminDomainOrderTest::test_confirm_registration_creates_dns_domain_and_attaches_customer":0.015,"Tests\\Feature\\Api\\DomainOrder\\AdminDomainOrderTest::test_cannot_confirm_already_confirmed_order":0.012,"Tests\\Feature\\Api\\DomainOrder\\AdminDomainOrderTest::test_cannot_create_duplicate_dns_domain":0.012,"Tests\\Feature\\Api\\DomainOrder\\AdminDomainOrderTest::test_update_kyc_url_sets_pending_kyc_status":0.013,"Tests\\Feature\\Api\\DomainOrder\\AdminDomainOrderTest::test_activate_transitions_from_pending_kyc_to_active":0.018,"Tests\\Feature\\Api\\DomainOrder\\AdminDomainOrderTest::test_cannot_activate_pending_order_directly":0.012,"Tests\\Feature\\Api\\DomainOrder\\AdminDomainOrderTest::test_reject_and_refund_restores_customer_balance":0.023,"Tests\\Feature\\Api\\DomainOrder\\AdminDomainOrderTest::test_reject_and_refund_is_idempotent":0.025,"Tests\\Feature\\Api\\DomainOrder\\AdminDomainOrderTest::test_cannot_reject_non_pending_order":0.011,"Tests\\Feature\\Api\\DomainOrder\\AdminDomainOrderTest::test_refund_uses_invoice_amount_not_current_pricing":0.022,"Tests\\Feature\\Api\\DomainOrder\\AdminDomainOrderTest::test_transaction_rollback_on_error":0.009,"Tests\\Feature\\Api\\DomainOrder\\ClientDomainOrderTest::test_authenticated_customer_can_place_domain_order":0.016,"Tests\\Feature\\Api\\DomainOrder\\ClientDomainOrderTest::test_insufficient_balance_returns_422":0.006,"Tests\\Feature\\Api\\DomainOrder\\ClientDomainOrderTest::test_duplicate_pending_domain_is_rejected":0.016,"Tests\\Feature\\Api\\DomainOrder\\ClientDomainOrderTest::test_unauthenticated_cannot_place_order":0.002,"Tests\\Feature\\Api\\DomainOrder\\ClientDomainOrderTest::test_invalid_product_type_rejected":0.005,"Tests\\Feature\\Api\\DomainOrder\\ClientDomainOrderTest::test_domain_normalized_to_lowercase":0.015,"Tests\\Feature\\Api\\DomainOrder\\ClientDomainOrderTest::test_unknown_tld_returns_422":0.005,"Tests\\Feature\\Api\\DomainOrder\\ClientDomainOrderTest::test_years_defaults_to_1_when_not_provided":0.015,"Tests\\Feature\\Api\\Service\\OrderErrorMessageTest::test_pay_order_hides_unknown_exception_message":0.01,"Tests\\Feature\\Api\\Service\\OrderErrorMessageTest::test_pay_order_keeps_business_message_for_invalid_argument":0.01,"Tests\\Feature\\Api\\Service\\RenewServiceTest::test_renew_annual_service_for_exactly_one_cycle_charges_the_annual_price":0.012,"Tests\\Feature\\Api\\Service\\RenewServiceTest::test_renew_annual_service_with_months_not_matching_the_cycle_is_rejected":0.007,"Tests\\Feature\\Api\\Service\\RenewServiceTest::test_renew_charges_the_recurring_addon_price_on_top_of_the_service_price":0.018,"Tests\\Feature\\Api\\Service\\RenewServiceTest::test_renew_uses_current_catalog_price_not_the_original_order_price":0.011,"Tests\\Feature\\Api\\Service\\RenewServiceTest::test_renew_fails_when_product_pricing_is_no_longer_active":0.008,"Tests\\Feature\\Api\\Service\\RenewServiceTest::test_renew_reuses_original_order_instead_of_creating_a_new_one":0.012,"Tests\\Feature\\Api\\Service\\RenewServiceTest::test_renewal_invoice_has_a_billing_period_and_is_not_duplicated_for_the_same_period":0.016,"Tests\\Feature\\Api\\Service\\RenewServiceTest::test_renew_while_existing_invoice_is_overdue_returns_the_same_invoice":0.02,"Tests\\Feature\\Api\\Service\\ServiceOwnershipTest::test_client_cannot_fetch_another_customers_service_by_uuid":0.013,"Tests\\Feature\\Api\\Service\\ServiceOwnershipTest::test_client_can_fetch_own_service_by_uuid":0.012,"Tests\\Feature\\Api\\Service\\StoreOrderAddonTest::test_addon_belonging_to_another_product_type_is_rejected":0.007,"Tests\\Feature\\Api\\Service\\StoreOrderAddonTest::test_inactive_addon_is_rejected":0.007,"Tests\\Feature\\Api\\Service\\StoreOrderAddonTest::test_qty_above_max_is_rejected":0.006,"Tests\\Feature\\Api\\Service\\StoreOrderAddonTest::test_valid_addon_within_bounds_is_accepted":0.016,"Tests\\Feature\\Api\\Service\\VmChangeIpTest::test_change_ip_outside_pool_returns_422":0.026,"Tests\\Feature\\Api\\Service\\VmChangeIpTest::test_change_ip_already_in_use_returns_422":0.021,"Tests\\Feature\\Api\\Service\\VmChangeIpTest::test_change_ip_to_free_pool_ip_dispatches_job_with_pool_gateway":0.017,"Tests\\Feature\\Api\\Service\\VmChangeIpTest::test_power_on_for_suspended_service_returns_403":0.023,"Tests\\Feature\\Api\\Service\\VmOwnershipTest::test_customer_cannot_read_power_state_of_another_customers_vm_by_numeric_id":0.007,"Tests\\Feature\\Api\\Service\\VmUpgradeAddonsTest::test_upgrade_request_creates_unpaid_invoice_without_touching_vcenter":0.011,"Tests\\Feature\\Api\\Service\\VmUpgradeAddonsTest::test_qty_over_max_is_rejected":0.005,"Tests\\Feature\\Api\\Service\\VmUpgradeAddonsTest::test_paying_the_invoice_dispatches_upgrade_job_and_updates_addons_meta":0.017,"Tests\\Feature\\Api\\Transaction\\ManualDepositProofTest::test_confirm_rejects_proof_path_belonging_to_another_customer":0.011,"Tests\\Feature\\Api\\Transaction\\ManualDepositProofTest::test_confirm_rejects_traversal_and_reused_proof_paths":0.012,"Tests\\Feature\\Api\\Transaction\\ManualDepositProofTest::test_confirm_without_proof_is_rejected":0.006,"Tests\\Feature\\Api\\Transaction\\ManualDepositProofTest::test_upload_then_confirm_binds_proof_to_owner":0.014,"Tests\\Feature\\Console\\InvoiceReminderTest::test_no_reminder_is_sent_before_the_first_offset":0.006,"Tests\\Feature\\Console\\InvoiceReminderTest::test_reminder_step_advances_once_per_offset_reached":0.008,"Tests\\Feature\\Console\\InvoiceReminderTest::test_an_overdue_invoice_reaches_the_later_offsets":0.009,"Tests\\Feature\\Console\\InvoiceReminderTest::test_a_paid_invoice_is_never_reminded":0.006,"Tests\\Feature\\Console\\InvoiceReminderTest::test_manual_reminder_sends_even_when_the_offset_was_already_used":0.005,"Tests\\Feature\\Console\\PostWhmcsSyncSafetyTest::test_reminder_chain_does_not_reopen_for_long_overdue_legacy_invoices":0.009,"Tests\\Feature\\Console\\PostWhmcsSyncSafetyTest::test_recently_overdue_invoice_is_still_reminded":0.006,"Tests\\Feature\\Console\\PostWhmcsSyncSafetyTest::test_manual_reminder_still_works_on_an_old_invoice":0.004,"Tests\\Feature\\Console\\PostWhmcsSyncSafetyTest::test_legacy_pending_order_does_not_block_new_orders":0.003,"Tests\\Feature\\Console\\PostWhmcsSyncSafetyTest::test_local_pending_order_still_blocks_new_orders":0.005,"Tests\\Feature\\Console\\PostWhmcsSyncSafetyTest::test_expiry_does_not_duplicate_an_invoice_already_synced_on_the_order":0.011,"Tests\\Feature\\Console\\ServiceExpiryTest::test_expiry_command_creates_renewal_invoice_on_the_original_order":0.015,"Tests\\Feature\\Console\\ServiceExpiryTest::test_running_expiry_command_twice_does_not_duplicate_the_renewal_invoice":0.018,"Tests\\Feature\\Console\\ServiceExpiryTest::test_cancelled_service_does_not_get_a_renewal_invoice":0.026,"Tests\\Feature\\Console\\ServiceExpiryTest::test_expiry_command_does_not_duplicate_invoice_after_it_became_overdue":0.018,"Tests\\Feature\\Console\\ServiceExpiryTest::test_overdue_renewal_invoice_does_not_reset_an_active_order":0.017,"Tests\\Feature\\Console\\ServiceExpiryTest::test_renewal_invoice_uses_the_current_price_list":0.018,"Tests\\Feature\\Console\\ServiceExpiryTest::test_renewal_falls_back_to_the_original_price_when_no_current_price_exists":0.014,"Tests\\Feature\\EmailTemplateServiceTest::test_send_dispatches_job_to_queue":0.001,"Tests\\Feature\\EmailTemplateServiceTest::test_job_renders_and_sends_mail_with_template_sender_config":0.002,"Tests\\Feature\\EmailTemplateServiceTest::test_job_falls_back_to_system_default_sender_when_template_has_no_mail_config":0.001,"Tests\\Feature\\EmailTemplateServiceTest::test_preview_throws_for_undeclared_variable":0.001,"Tests\\Feature\\EmailTemplateServiceTest::test_load_template_is_cached_by_scenario":0.002,"Tests\\Feature\\Payment\\AdminTransactionApproveTest::test_approving_the_same_manual_deposit_twice_credits_the_customer_only_once":0.011,"Tests\\Feature\\Payment\\AdminTransactionApproveTest::test_approving_credits_the_received_amount_not_the_declared_one":0.012,"Tests\\Feature\\Payment\\AdminTransactionApproveTest::test_approving_without_received_amount_credits_the_declared_amount":0.011,"Tests\\Feature\\Payment\\InvoicePaymentServiceTest::test_paying_an_already_paid_invoice_does_not_double_deduct_balance":0.019,"Tests\\Feature\\Payment\\InvoicePaymentServiceTest::test_a_cancelled_invoice_cannot_be_paid_and_is_never_counted_as_paid":0.007,"Tests\\Feature\\Payment\\InvoicePaymentServiceTest::test_admin_cannot_mark_a_cancelled_invoice_as_paid":0.008,"Tests\\Feature\\Payment\\InvoicePaymentServiceTest::test_partial_payment_records_amount_paid_and_keeps_invoice_unpaid":0.014,"Tests\\Feature\\Payment\\InvoicePaymentServiceTest::test_paying_the_remainder_settles_the_invoice":0.023,"Tests\\Feature\\Payment\\InvoicePaymentServiceTest::test_overpaying_only_charges_the_outstanding_amount":0.014,"Tests\\Feature\\Payment\\InvoicePaymentServiceTest::test_refund_returns_balance_and_records_a_negative_transaction":0.019,"Tests\\Feature\\Api\\Client\\ClientAuthSecurityTest::test_concurrent_refresh_within_grace_window_does_not_revoke_sessions":0.013,"Tests\\Unit\\Support\\TlsHttpClientFactoryTest::test_verify_true_and_no_ca_bundle_uses_default_verification":0.002,"Tests\\Unit\\Support\\TlsHttpClientFactoryTest::test_verify_true_with_ca_bundle_pins_the_bundle_path":0,"Tests\\Unit\\Support\\TlsHttpClientFactoryTest::test_verify_false_disables_verification":0,"Tests\\Unit\\Support\\TlsHttpClientFactoryTest::test_plesk_reads_its_own_config":0,"Tests\\Unit\\Support\\TlsHttpClientFactoryTest::test_curl_options_follow_vcenter_config":0,"Tests\\Feature\\Console\\ReencryptSecretsTest::test_rotating_keys_re_encrypts_rsa_and_app_key_columns":0.123,"Tests\\Feature\\Console\\ReencryptSecretsTest::test_dry_run_changes_nothing_and_undecryptable_rows_fail_the_command":0.059,"Tests\\Unit\\Support\\SshHostKeyVerifierTest::test_missing_known_hosts_is_rejected_while_tls_verification_is_on":0.009,"Tests\\Unit\\Support\\SshHostKeyVerifierTest::test_missing_known_hosts_is_allowed_when_tls_verification_is_off":0,"Tests\\Unit\\Support\\SshHostKeyVerifierTest::test_host_key_must_match_known_hosts_entry":0.003,"Tests\\Feature\\ZzInvoiceBladeCompareTest::test_split_invoice_template_renders_same_html":0.04,"Tests\\Feature\\Api\\Admin\\Employee\\TmpDbgTest::test_cannot_change_own_role":0.133,"Tests\\Unit\\MailPlaceholdersTest::test_extracts_both_placeholder_styles_without_layout_slot":0,"Tests\\Unit\\MailPlaceholdersTest::test_fill_escapes_html_and_keeps_layout_slot":0,"Tests\\Unit\\MailPlaceholdersTest::test_recipient_values_fall_back_to_email_for_external_recipient":0,"Tests\\Unit\\EmailTemplateRendererTest::test_dollar_placeholders_are_declared_on_sync_and_rendered_with_default":0,"Tests\\Unit\\EmailScenarioRegistryTest::test_registered_scenario_is_locked_and_custom_template_is_marketing":0,"Tests\\Unit\\EmailScenarioRegistryTest::test_missing_variables_accepts_both_syntaxes_and_layout_slot":0}} \ No newline at end of file +{"version":2,"defects":{"Tests\\Feature\\Api\\Client\\ClientAuthSecurityTest::test_reused_rotated_refresh_token_revokes_all_tokens_of_customer":7,"Tests\\Feature\\Console\\ReencryptSecretsTest::test_rotating_keys_re_encrypts_rsa_and_app_key_columns":7,"Tests\\Feature\\Console\\ReencryptSecretsTest::test_dry_run_changes_nothing_and_undecryptable_rows_fail_the_command":7,"Tests\\Feature\\Api\\Admin\\Customer\\CustomerMassAssignmentTest::test_store_ignores_fields_not_declared_in_validation_rules":7,"Tests\\Feature\\Api\\Admin\\Customer\\CustomerMassAssignmentTest::test_update_ignores_fields_not_declared_in_validation_rules":7,"Tests\\Feature\\Api\\Admin\\Customer\\ResetPasswordTest::test_reset_password_sends_email_and_does_not_leak_password_in_response":7,"Tests\\Feature\\Api\\Admin\\LoginThrottleTest::test_admin_login_is_throttled_after_5_attempts_per_minute":7,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"settings index\"":7,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"banks index\"":7,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"domain-pricing index\"":7,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"email-templates index\"":7,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"tickets index\"":7,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"activity-logs index\"":7,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"email-logs index\"":7,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"post-categories index\"":7,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"posts index\"":7,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_force_delete_post_requires_post_destroy_permission":7,"Tests\\Feature\\Api\\Admin\\Employee\\EmployeeRoleGuardTest::test_non_full_administrator_cannot_edit_a_full_administrator":7,"Tests\\Feature\\Api\\Admin\\Employee\\EmployeeRoleGuardTest::test_non_full_administrator_cannot_delete_a_full_administrator":7,"Tests\\Feature\\Api\\Admin\\Employee\\EmployeeRoleGuardTest::test_cannot_change_own_role":7,"Tests\\Feature\\Api\\Admin\\Employee\\EmployeeRoleGuardTest::test_non_full_administrator_cannot_assign_administrator_role":7,"Tests\\Feature\\Api\\Service\\VmChangeIpTest::test_change_ip_to_free_pool_ip_dispatches_job_with_pool_gateway":7,"Tests\\Feature\\Api\\Admin\\Employee\\TmpDbgTest::test_cannot_change_own_role":5,"Tests\\Feature\\Api\\Admin\\Customer\\AssignServiceVmTest::test_assigns_and_reassigns_vm_and_rejects_vm_of_another_service":8},"times":{"Tests\\Unit\\BillingPricingTest::test_cycle_to_months_covers_every_enum_case":0.003,"Tests\\Unit\\BillingPricingTest::test_cycle_to_months_throws_on_unknown_value":0,"Tests\\Unit\\BillingPricingTest::test_cycle_to_months_throws_on_null":0,"Tests\\Unit\\BillingPricingTest::test_domain_price_formula with data set \"years=1, no setup fee\"":0.004,"Tests\\Unit\\BillingPricingTest::test_domain_price_formula with data set \"years=1, with setup fee\"":0,"Tests\\Unit\\BillingPricingTest::test_domain_price_formula with data set \"years=3, no setup fee\"":0,"Tests\\Unit\\BillingPricingTest::test_domain_price_formula with data set \"years=3, with setup fee\"":0,"Tests\\Unit\\EmailTemplateRendererTest::test_it_replaces_known_placeholders_in_subject_and_html":0.019,"Tests\\Unit\\EmailTemplateRendererTest::test_it_falls_back_to_example_value_when_data_missing":0,"Tests\\Unit\\EmailTemplateRendererTest::test_it_html_escapes_string_type_by_default":0.001,"Tests\\Unit\\EmailTemplateRendererTest::test_it_rejects_invalid_url_type":0,"Tests\\Unit\\EmailTemplateRendererTest::test_it_throws_for_undeclared_variable":0.001,"Tests\\Unit\\EmailTemplateRendererTest::test_it_never_executes_placeholder_content_as_code":0,"Tests\\Unit\\ExampleTest::test_that_true_is_true":0,"Tests\\Unit\\Models\\ReinstallLogPlainPasswordTest::test_plain_password_is_stored_encrypted_and_reads_back_transparently":0.003,"Tests\\Unit\\Models\\ReinstallLogPlainPasswordTest::test_backfill_migration_is_idempotent":0.001,"Tests\\Unit\\ServiceFillableTest::test_service_ignores_fields_not_declared_in_fillable":0.042,"Tests\\Unit\\Services\\VCenter\\PowerShellServiceSecretLoggingTest::test_change_ip_vm_linux_never_logs_the_guest_password":0.003,"Tests\\Unit\\Services\\VCenter\\PowerShellServiceSecretLoggingTest::test_change_ip_vm_windows_never_logs_the_guest_password":0.001,"Tests\\Unit\\Support\\VCenterHttpClientFactoryTest::test_verify_true_and_no_ca_bundle_uses_default_verification":0.002,"Tests\\Unit\\Support\\VCenterHttpClientFactoryTest::test_verify_true_with_ca_bundle_pins_the_bundle_path":0,"Tests\\Unit\\Support\\VCenterHttpClientFactoryTest::test_verify_false_disables_verification":0,"Tests\\Feature\\Api\\Admin\\Customer\\CustomerMassAssignmentTest::test_store_ignores_fields_not_declared_in_validation_rules":0.01,"Tests\\Feature\\Api\\Admin\\Customer\\CustomerMassAssignmentTest::test_update_ignores_fields_not_declared_in_validation_rules":0.007,"Tests\\Feature\\Api\\Admin\\Customer\\ResetPasswordTest::test_reset_password_sends_email_and_does_not_leak_password_in_response":0.02,"Tests\\Feature\\Api\\Admin\\Employee\\EmployeeRoleGuardTest::test_non_full_administrator_cannot_edit_a_full_administrator":0.011,"Tests\\Feature\\Api\\Admin\\Employee\\EmployeeRoleGuardTest::test_non_full_administrator_cannot_delete_a_full_administrator":0.009,"Tests\\Feature\\Api\\Admin\\Employee\\EmployeeRoleGuardTest::test_cannot_change_own_role":0.008,"Tests\\Feature\\Api\\Admin\\Employee\\EmployeeRoleGuardTest::test_non_full_administrator_cannot_assign_administrator_role":0.011,"Tests\\Feature\\Api\\Admin\\Employee\\EmployeeRoleGuardTest::test_full_administrator_can_assign_administrator_role":0.012,"Tests\\Feature\\Api\\Admin\\InvoiceTestRouteRemovedTest::test_unauthenticated_invoice_test_routes_no_longer_exist":0.003,"Tests\\Feature\\Api\\Admin\\LoginThrottleTest::test_admin_login_is_throttled_after_5_attempts_per_minute":0.008,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"settings index\"":0.015,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"banks index\"":0.015,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"domain-pricing index\"":0.016,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"email-templates index\"":0.018,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"tickets index\"":0.018,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"activity-logs index\"":0.017,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"email-logs index\"":0.014,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"post-categories index\"":0.016,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"posts index\"":0.015,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"media index\"":0.108,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"dashboard stats\"":0.016,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"queue jobs recent\"":0.02,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_force_delete_post_requires_post_destroy_permission":0.014,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_dashboard_requires_dashboard_view_permission":0.017,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_full_administrator_bypasses_all_permission_gates":0.042,"Tests\\Feature\\Api\\Client\\ClientAuthSecurityTest::test_access_token_older_than_skew_window_is_rejected":0.002,"Tests\\Feature\\Api\\Client\\ClientAuthSecurityTest::test_verify_email_code_is_invalidated_after_too_many_wrong_attempts":0.014,"Tests\\Feature\\Api\\Client\\ClientAuthSecurityTest::test_forgot_password_sends_link_without_changing_password_and_reset_revokes_sessions":0.456,"Tests\\Feature\\Api\\Client\\ClientAuthSecurityTest::test_cccd_images_are_private_and_served_only_via_signed_url":0.019,"Tests\\Feature\\Api\\Client\\ClientAuthSecurityTest::test_access_token_is_revoked_when_password_changes":0.019,"Tests\\Feature\\Api\\Client\\ClientAuthSecurityTest::test_throttle_uses_client_ip_from_encrypted_payload_not_next_server_ip":0.07,"Tests\\Feature\\Api\\Client\\ClientAuthSecurityTest::test_logout_revokes_refresh_token_so_it_cannot_be_used_again":0.008,"Tests\\Feature\\Api\\Client\\ClientAuthSecurityTest::test_reused_rotated_refresh_token_revokes_all_tokens_of_customer":0.009,"Tests\\Feature\\Api\\Client\\ClientAuthSecurityTest::test_legacy_cccd_path_is_not_exposed":0.005,"Tests\\Feature\\Api\\Client\\SecurityHardeningTest::test_register_rejects_short_password":0.003,"Tests\\Feature\\Api\\Client\\SecurityHardeningTest::test_register_accepts_password_with_at_least_ten_characters":0.008,"Tests\\Feature\\Api\\Client\\SecurityHardeningTest::test_cccd_upload_rejects_svg":0.011,"Tests\\Feature\\Api\\DomainOrder\\AdminDomainOrderTest::test_admin_can_list_domain_orders":0.01,"Tests\\Feature\\Api\\DomainOrder\\AdminDomainOrderTest::test_non_admin_cannot_access_domain_orders":0.001,"Tests\\Feature\\Api\\DomainOrder\\AdminDomainOrderTest::test_confirm_registration_creates_dns_domain_and_attaches_customer":0.011,"Tests\\Feature\\Api\\DomainOrder\\AdminDomainOrderTest::test_cannot_confirm_already_confirmed_order":0.009,"Tests\\Feature\\Api\\DomainOrder\\AdminDomainOrderTest::test_cannot_create_duplicate_dns_domain":0.009,"Tests\\Feature\\Api\\DomainOrder\\AdminDomainOrderTest::test_update_kyc_url_sets_pending_kyc_status":0.009,"Tests\\Feature\\Api\\DomainOrder\\AdminDomainOrderTest::test_activate_transitions_from_pending_kyc_to_active":0.013,"Tests\\Feature\\Api\\DomainOrder\\AdminDomainOrderTest::test_cannot_activate_pending_order_directly":0.007,"Tests\\Feature\\Api\\DomainOrder\\AdminDomainOrderTest::test_reject_and_refund_restores_customer_balance":0.017,"Tests\\Feature\\Api\\DomainOrder\\AdminDomainOrderTest::test_reject_and_refund_is_idempotent":0.017,"Tests\\Feature\\Api\\DomainOrder\\AdminDomainOrderTest::test_cannot_reject_non_pending_order":0.008,"Tests\\Feature\\Api\\DomainOrder\\AdminDomainOrderTest::test_refund_uses_invoice_amount_not_current_pricing":0.017,"Tests\\Feature\\Api\\DomainOrder\\AdminDomainOrderTest::test_transaction_rollback_on_error":0.006,"Tests\\Feature\\Api\\DomainOrder\\ClientDomainOrderTest::test_authenticated_customer_can_place_domain_order":0.011,"Tests\\Feature\\Api\\DomainOrder\\ClientDomainOrderTest::test_insufficient_balance_returns_422":0.004,"Tests\\Feature\\Api\\DomainOrder\\ClientDomainOrderTest::test_duplicate_pending_domain_is_rejected":0.012,"Tests\\Feature\\Api\\DomainOrder\\ClientDomainOrderTest::test_unauthenticated_cannot_place_order":0.002,"Tests\\Feature\\Api\\DomainOrder\\ClientDomainOrderTest::test_invalid_product_type_rejected":0.003,"Tests\\Feature\\Api\\DomainOrder\\ClientDomainOrderTest::test_domain_normalized_to_lowercase":0.01,"Tests\\Feature\\Api\\DomainOrder\\ClientDomainOrderTest::test_unknown_tld_returns_422":0.002,"Tests\\Feature\\Api\\DomainOrder\\ClientDomainOrderTest::test_years_defaults_to_1_when_not_provided":0.01,"Tests\\Feature\\Api\\Service\\OrderErrorMessageTest::test_pay_order_hides_unknown_exception_message":0.013,"Tests\\Feature\\Api\\Service\\OrderErrorMessageTest::test_pay_order_keeps_business_message_for_invalid_argument":0.006,"Tests\\Feature\\Api\\Service\\RenewServiceTest::test_renew_annual_service_for_exactly_one_cycle_charges_the_annual_price":0.015,"Tests\\Feature\\Api\\Service\\RenewServiceTest::test_renew_annual_service_with_months_not_matching_the_cycle_is_rejected":0.006,"Tests\\Feature\\Api\\Service\\RenewServiceTest::test_renew_charges_the_recurring_addon_price_on_top_of_the_service_price":0.015,"Tests\\Feature\\Api\\Service\\RenewServiceTest::test_renew_uses_current_catalog_price_not_the_original_order_price":0.011,"Tests\\Feature\\Api\\Service\\RenewServiceTest::test_renew_fails_when_product_pricing_is_no_longer_active":0.007,"Tests\\Feature\\Api\\Service\\RenewServiceTest::test_renew_reuses_original_order_instead_of_creating_a_new_one":0.011,"Tests\\Feature\\Api\\Service\\RenewServiceTest::test_renewal_invoice_has_a_billing_period_and_is_not_duplicated_for_the_same_period":0.014,"Tests\\Feature\\Api\\Service\\RenewServiceTest::test_renew_while_existing_invoice_is_overdue_returns_the_same_invoice":0.019,"Tests\\Feature\\Api\\Service\\ServiceOwnershipTest::test_client_cannot_fetch_another_customers_service_by_uuid":0.012,"Tests\\Feature\\Api\\Service\\ServiceOwnershipTest::test_client_can_fetch_own_service_by_uuid":0.014,"Tests\\Feature\\Api\\Service\\StoreOrderAddonTest::test_addon_belonging_to_another_product_type_is_rejected":0.008,"Tests\\Feature\\Api\\Service\\StoreOrderAddonTest::test_inactive_addon_is_rejected":0.008,"Tests\\Feature\\Api\\Service\\StoreOrderAddonTest::test_qty_above_max_is_rejected":0.005,"Tests\\Feature\\Api\\Service\\StoreOrderAddonTest::test_valid_addon_within_bounds_is_accepted":0.016,"Tests\\Feature\\Api\\Service\\VmChangeIpTest::test_change_ip_outside_pool_returns_422":0.022,"Tests\\Feature\\Api\\Service\\VmChangeIpTest::test_change_ip_already_in_use_returns_422":0.018,"Tests\\Feature\\Api\\Service\\VmChangeIpTest::test_change_ip_to_free_pool_ip_dispatches_job_with_pool_gateway":0.016,"Tests\\Feature\\Api\\Service\\VmChangeIpTest::test_power_on_for_suspended_service_returns_403":0.022,"Tests\\Feature\\Api\\Service\\VmOwnershipTest::test_customer_cannot_read_power_state_of_another_customers_vm_by_numeric_id":0.007,"Tests\\Feature\\Api\\Service\\VmUpgradeAddonsTest::test_upgrade_request_creates_unpaid_invoice_without_touching_vcenter":0.009,"Tests\\Feature\\Api\\Service\\VmUpgradeAddonsTest::test_qty_over_max_is_rejected":0.005,"Tests\\Feature\\Api\\Service\\VmUpgradeAddonsTest::test_paying_the_invoice_dispatches_upgrade_job_and_updates_addons_meta":0.016,"Tests\\Feature\\Api\\Transaction\\ManualDepositProofTest::test_confirm_rejects_proof_path_belonging_to_another_customer":0.007,"Tests\\Feature\\Api\\Transaction\\ManualDepositProofTest::test_confirm_rejects_traversal_and_reused_proof_paths":0.011,"Tests\\Feature\\Api\\Transaction\\ManualDepositProofTest::test_confirm_without_proof_is_rejected":0.006,"Tests\\Feature\\Api\\Transaction\\ManualDepositProofTest::test_upload_then_confirm_binds_proof_to_owner":0.011,"Tests\\Feature\\Console\\InvoiceReminderTest::test_no_reminder_is_sent_before_the_first_offset":0.005,"Tests\\Feature\\Console\\InvoiceReminderTest::test_reminder_step_advances_once_per_offset_reached":0.008,"Tests\\Feature\\Console\\InvoiceReminderTest::test_an_overdue_invoice_reaches_the_later_offsets":0.007,"Tests\\Feature\\Console\\InvoiceReminderTest::test_a_paid_invoice_is_never_reminded":0.006,"Tests\\Feature\\Console\\InvoiceReminderTest::test_manual_reminder_sends_even_when_the_offset_was_already_used":0.006,"Tests\\Feature\\Console\\PostWhmcsSyncSafetyTest::test_reminder_chain_does_not_reopen_for_long_overdue_legacy_invoices":0.006,"Tests\\Feature\\Console\\PostWhmcsSyncSafetyTest::test_recently_overdue_invoice_is_still_reminded":0.007,"Tests\\Feature\\Console\\PostWhmcsSyncSafetyTest::test_manual_reminder_still_works_on_an_old_invoice":0.003,"Tests\\Feature\\Console\\PostWhmcsSyncSafetyTest::test_legacy_pending_order_does_not_block_new_orders":0.003,"Tests\\Feature\\Console\\PostWhmcsSyncSafetyTest::test_local_pending_order_still_blocks_new_orders":0.003,"Tests\\Feature\\Console\\PostWhmcsSyncSafetyTest::test_expiry_does_not_duplicate_an_invoice_already_synced_on_the_order":0.011,"Tests\\Feature\\Console\\ServiceExpiryTest::test_expiry_command_creates_renewal_invoice_on_the_original_order":0.015,"Tests\\Feature\\Console\\ServiceExpiryTest::test_running_expiry_command_twice_does_not_duplicate_the_renewal_invoice":0.018,"Tests\\Feature\\Console\\ServiceExpiryTest::test_cancelled_service_does_not_get_a_renewal_invoice":0.011,"Tests\\Feature\\Console\\ServiceExpiryTest::test_expiry_command_does_not_duplicate_invoice_after_it_became_overdue":0.021,"Tests\\Feature\\Console\\ServiceExpiryTest::test_overdue_renewal_invoice_does_not_reset_an_active_order":0.019,"Tests\\Feature\\Console\\ServiceExpiryTest::test_renewal_invoice_uses_the_current_price_list":0.018,"Tests\\Feature\\Console\\ServiceExpiryTest::test_renewal_falls_back_to_the_original_price_when_no_current_price_exists":0.017,"Tests\\Feature\\EmailTemplateServiceTest::test_send_dispatches_job_to_queue":0.003,"Tests\\Feature\\EmailTemplateServiceTest::test_job_renders_and_sends_mail_with_template_sender_config":0.002,"Tests\\Feature\\EmailTemplateServiceTest::test_job_falls_back_to_system_default_sender_when_template_has_no_mail_config":0.001,"Tests\\Feature\\EmailTemplateServiceTest::test_preview_throws_for_undeclared_variable":0.001,"Tests\\Feature\\EmailTemplateServiceTest::test_load_template_is_cached_by_scenario":0.002,"Tests\\Feature\\Payment\\AdminTransactionApproveTest::test_approving_the_same_manual_deposit_twice_credits_the_customer_only_once":0.011,"Tests\\Feature\\Payment\\AdminTransactionApproveTest::test_approving_credits_the_received_amount_not_the_declared_one":0.01,"Tests\\Feature\\Payment\\AdminTransactionApproveTest::test_approving_without_received_amount_credits_the_declared_amount":0.009,"Tests\\Feature\\Payment\\InvoicePaymentServiceTest::test_paying_an_already_paid_invoice_does_not_double_deduct_balance":0.029,"Tests\\Feature\\Payment\\InvoicePaymentServiceTest::test_a_cancelled_invoice_cannot_be_paid_and_is_never_counted_as_paid":0.008,"Tests\\Feature\\Payment\\InvoicePaymentServiceTest::test_admin_cannot_mark_a_cancelled_invoice_as_paid":0.005,"Tests\\Feature\\Payment\\InvoicePaymentServiceTest::test_partial_payment_records_amount_paid_and_keeps_invoice_unpaid":0.013,"Tests\\Feature\\Payment\\InvoicePaymentServiceTest::test_paying_the_remainder_settles_the_invoice":0.023,"Tests\\Feature\\Payment\\InvoicePaymentServiceTest::test_overpaying_only_charges_the_outstanding_amount":0.015,"Tests\\Feature\\Payment\\InvoicePaymentServiceTest::test_refund_returns_balance_and_records_a_negative_transaction":0.022,"Tests\\Feature\\Api\\Client\\ClientAuthSecurityTest::test_concurrent_refresh_within_grace_window_does_not_revoke_sessions":0.014,"Tests\\Unit\\Support\\TlsHttpClientFactoryTest::test_verify_true_and_no_ca_bundle_uses_default_verification":0.001,"Tests\\Unit\\Support\\TlsHttpClientFactoryTest::test_verify_true_with_ca_bundle_pins_the_bundle_path":0,"Tests\\Unit\\Support\\TlsHttpClientFactoryTest::test_verify_false_disables_verification":0,"Tests\\Unit\\Support\\TlsHttpClientFactoryTest::test_plesk_reads_its_own_config":0,"Tests\\Unit\\Support\\TlsHttpClientFactoryTest::test_curl_options_follow_vcenter_config":0,"Tests\\Feature\\Console\\ReencryptSecretsTest::test_rotating_keys_re_encrypts_rsa_and_app_key_columns":0.06,"Tests\\Feature\\Console\\ReencryptSecretsTest::test_dry_run_changes_nothing_and_undecryptable_rows_fail_the_command":0.045,"Tests\\Unit\\Support\\SshHostKeyVerifierTest::test_missing_known_hosts_is_rejected_while_tls_verification_is_on":0.009,"Tests\\Unit\\Support\\SshHostKeyVerifierTest::test_missing_known_hosts_is_allowed_when_tls_verification_is_off":0,"Tests\\Unit\\Support\\SshHostKeyVerifierTest::test_host_key_must_match_known_hosts_entry":0.002,"Tests\\Feature\\ZzInvoiceBladeCompareTest::test_split_invoice_template_renders_same_html":0.04,"Tests\\Feature\\Api\\Admin\\Employee\\TmpDbgTest::test_cannot_change_own_role":0.133,"Tests\\Unit\\MailPlaceholdersTest::test_extracts_both_placeholder_styles_without_layout_slot":0,"Tests\\Unit\\MailPlaceholdersTest::test_fill_escapes_html_and_keeps_layout_slot":0,"Tests\\Unit\\MailPlaceholdersTest::test_recipient_values_fall_back_to_email_for_external_recipient":0,"Tests\\Unit\\EmailTemplateRendererTest::test_dollar_placeholders_are_declared_on_sync_and_rendered_with_default":0.001,"Tests\\Unit\\EmailScenarioRegistryTest::test_registered_scenario_is_locked_and_custom_template_is_marketing":0.001,"Tests\\Unit\\EmailScenarioRegistryTest::test_missing_variables_accepts_both_syntaxes_and_layout_slot":0,"Tests\\Feature\\TemplateSeederTest::test_seeder_covers_every_registered_scenario_with_required_variables":0.001,"Tests\\Feature\\TemplateSeederTest::test_every_placeholder_has_a_variable_definition":0,"Tests\\Feature\\TemplateSeederTest::test_seeded_templates_render_and_rerun_updates_in_place":0.056,"Tests\\Feature\\Console\\InvoiceReminderTest::test_disabled_automatic_reminder_is_not_sent_and_step_stays":0.007,"Tests\\Feature\\Console\\InvoiceReminderTest::test_manual_reminder_is_sent_while_automatic_is_disabled":0.005,"Tests\\Feature\\MailEnabledToggleTest::test_mail_is_blocked_when_disabled":0.002,"Tests\\Feature\\MailEnabledToggleTest::test_mail_is_sent_when_enabled":0.002,"Tests\\Feature\\MailEnabledToggleTest::test_disabled_scenario_is_blocked_but_others_are_sent":0.003,"Tests\\Feature\\MailEnabledToggleTest::test_manual_send_bypasses_disabled_scenario":0.002,"Tests\\Feature\\MailEnabledToggleTest::test_template_mailable_exposes_its_scenario":0,"Tests\\Feature\\Api\\Admin\\Customer\\AssignServiceVmTest::test_assigns_and_reassigns_vm_and_rejects_vm_of_another_service":0.03,"Tests\\Feature\\Api\\Service\\ConsoleTicketResolvesVmTest::test_console_ticket_uses_service_vm_when_order_is_soft_deleted":0.093}} \ No newline at end of file diff --git a/app/Http/Controllers/Api/Admin/Customer/Concerns/ManagesCustomerServices.php b/app/Http/Controllers/Api/Admin/Customer/Concerns/ManagesCustomerServices.php index d2333ef..be1afb6 100644 --- a/app/Http/Controllers/Api/Admin/Customer/Concerns/ManagesCustomerServices.php +++ b/app/Http/Controllers/Api/Admin/Customer/Concerns/ManagesCustomerServices.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Api\Admin\Customer\Concerns; use App\Models\Service; +use App\Support\AutoEmailGate; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -34,6 +35,7 @@ trait ManagesCustomerServices 'requirements' => 'nullable|string', 'feedback' => 'nullable|string', 'price' => 'nullable|numeric|min:0', + 'virtual_machine_id' => 'nullable|integer|exists:virtual_machines,id', 'os_id' => 'nullable|integer|exists:os,id', 'vm_cpu' => 'nullable|integer|min:1', 'vm_ram_mb' => 'nullable|integer|min:1', @@ -74,6 +76,7 @@ trait ManagesCustomerServices public function notifyServiceRenewal(string $uuid, int $serviceId): JsonResponse { + AutoEmailGate::markManual(); $this->serviceProvisioning->notifyRenewal($uuid, $serviceId); return response()->json(['error' => false, 'message' => 'Đã gửi thông báo gia hạn đến khách hàng.']); diff --git a/app/Http/Controllers/Api/Admin/EmailTemplate/EmailTemplateController.php b/app/Http/Controllers/Api/Admin/EmailTemplate/EmailTemplateController.php index c12ac9a..6a2a1cb 100644 --- a/app/Http/Controllers/Api/Admin/EmailTemplate/EmailTemplateController.php +++ b/app/Http/Controllers/Api/Admin/EmailTemplate/EmailTemplateController.php @@ -238,6 +238,8 @@ class EmailTemplateController extends Controller 'news_template_id' => 'nullable|integer|exists:template,id', 'invoice_template_id' => 'nullable|integer|exists:template,id', 'mail_enabled' => 'sometimes|boolean', + 'auto_disabled' => 'sometimes|array', + 'auto_disabled.*' => ['string', Rule::in(array_keys(EmailScenarioRegistry::SCENARIOS))], ]); $setting = Setting::firstOrNew(['key' => 'email_settings']); diff --git a/app/Http/Controllers/Api/Admin/Invoice/Concerns/SendsInvoiceNotifications.php b/app/Http/Controllers/Api/Admin/Invoice/Concerns/SendsInvoiceNotifications.php index 981e147..3445ceb 100644 --- a/app/Http/Controllers/Api/Admin/Invoice/Concerns/SendsInvoiceNotifications.php +++ b/app/Http/Controllers/Api/Admin/Invoice/Concerns/SendsInvoiceNotifications.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers\Api\Admin\Invoice\Concerns; use App\Mail\InvoiceNotificationMail; use App\Models\Customer; use App\Services\InvoiceReminderService; +use App\Support\AutoEmailGate; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Log; @@ -36,6 +37,7 @@ trait SendsInvoiceNotifications } try { + AutoEmailGate::markManual(); $reminders->remind($invoice->load('customer'), force: true); } catch (\Throwable $e) { return response()->json([ @@ -79,6 +81,7 @@ trait SendsInvoiceNotifications ); $paymentUrl = url('/client/invoices/' . $invoice->invoice_number . '/payment'); try { + AutoEmailGate::markManual(); $invoicePdf = $this->invoicePdfService->generate($invoice); $invoicePdfFileName = $this->invoicePdfService->generateFileName($invoice); diff --git a/app/Http/Controllers/Api/Admin/Service/ServiceController.php b/app/Http/Controllers/Api/Admin/Service/ServiceController.php index 4ccaf2c..2931211 100644 --- a/app/Http/Controllers/Api/Admin/Service/ServiceController.php +++ b/app/Http/Controllers/Api/Admin/Service/ServiceController.php @@ -44,6 +44,30 @@ class ServiceController extends Controller return response()->json(['error' => false, 'data' => $service]); } + public function bulkAction(Request $request): JsonResponse + { + $data = $request->validate([ + 'ids' => 'required|array|min:1', + 'ids.*' => 'integer|distinct|exists:services,id', + 'action' => 'required|in:activate,suspend,cancel,detach-customer', + ]); + + $updates = match ($data['action']) { + 'activate' => ['status' => 'active', 'suspended_at' => null, 'cancelled_at' => null], + 'suspend' => ['status' => 'suspended', 'suspended_at' => now()], + 'cancel' => ['status' => 'cancelled', 'cancelled_at' => now()], + 'detach-customer' => ['customer_id' => null], + }; + + $updated = Service::whereIn('id', $data['ids'])->update($updates); + + return response()->json([ + 'error' => false, + 'message' => "Đã cập nhật {$updated} dịch vụ.", + 'data' => ['updated' => $updated], + ]); + } + public function detachCustomer(int $id): JsonResponse { $service = Service::findOrFail($id); diff --git a/app/Http/Controllers/Api/Client/Service/ServiceController.php b/app/Http/Controllers/Api/Client/Service/ServiceController.php index ed0ba16..aea7e8d 100644 --- a/app/Http/Controllers/Api/Client/Service/ServiceController.php +++ b/app/Http/Controllers/Api/Client/Service/ServiceController.php @@ -129,7 +129,7 @@ class ServiceController extends Controller { $customer = $request->user(); - $service = Service::with(['orderItem.order.vmWare.vCenter']) + $service = Service::with(['vmware.vCenter', 'orderItem.order.vmWare.vCenter']) ->where('uuid', $uuid) ->where('customer_id', $customer->id) ->first(); @@ -138,7 +138,7 @@ class ServiceController extends Controller return response()->json(['error' => true, 'message' => 'Không tìm thấy dịch vụ'], 404); } - $vm = $service->orderItem?->order?->vmWare?->first(); + $vm = $service->resolveVmware(); if (!$vm) { return response()->json(['error' => true, 'message' => 'Dịch vụ chưa được cấp phát VM'], 404); diff --git a/app/Http/Controllers/Api/Service/Concerns/ManagesVmNetwork.php b/app/Http/Controllers/Api/Service/Concerns/ManagesVmNetwork.php index ef4608a..f93ef5d 100644 --- a/app/Http/Controllers/Api/Service/Concerns/ManagesVmNetwork.php +++ b/app/Http/Controllers/Api/Service/Concerns/ManagesVmNetwork.php @@ -31,7 +31,7 @@ trait ManagesVmNetwork return $service; } - $vm = $service->orderItem?->order?->vmWare?->first(); + $vm = $service->resolveVmware(); if (!$vm) { return response()->json(['success' => false, 'message' => 'Không tìm thấy VM cho dịch vụ này.'], 404); } @@ -77,7 +77,7 @@ trait ManagesVmNetwork return $service; } - $vm = $service->orderItem?->order?->vmWare?->first(); + $vm = $service->resolveVmware(); if (!$vm) { return response()->json(['success' => false, 'message' => 'Không tìm thấy VM cho dịch vụ này.'], 404); @@ -219,8 +219,9 @@ trait ManagesVmNetwork { $customer = $request->user(); - // VM chain: Service → orderItem → order → vmWare (same as ServiceClientDetailResource) $service = Service::with([ + 'vmware.vCenter', + 'vmware.ipAddress.host.datacenter.vCenter', 'orderItem.order.vmWare.vCenter', 'orderItem.order.vmWare.ipAddress.host.datacenter.vCenter', 'orderItem.os.osGroup', diff --git a/app/Jobs/Mail/ExpiryMail.php b/app/Jobs/Mail/ExpiryMail.php index a2b5603..f469164 100644 --- a/app/Jobs/Mail/ExpiryMail.php +++ b/app/Jobs/Mail/ExpiryMail.php @@ -65,7 +65,9 @@ class ExpiryMail implements ShouldQueue try { $email = new ExpiryService($this->template_id,$data); - Mail::to($product->Customer->email)->send($email); + if (Mail::to($product->Customer->email)->send($email) === null) { + return; + } \App\Models\Mail::create([ 'template_id' => $this->template_id, 'subject' => $email->envelope()->subject, diff --git a/app/Jobs/Mail/SendMailCampaignRecipient.php b/app/Jobs/Mail/SendMailCampaignRecipient.php index fa1b222..a5aecd4 100644 --- a/app/Jobs/Mail/SendMailCampaignRecipient.php +++ b/app/Jobs/Mail/SendMailCampaignRecipient.php @@ -49,11 +49,11 @@ class SendMailCampaignRecipient implements ShouldQueue if ($bcc = EmailRecipients::parse($campaign->bcc)) { $mailer->bcc($bcc); } - $mailer->send(new GenericTemplateMail( + $failed = $mailer->send(new GenericTemplateMail( $rendered['subject'], $rendered['html'], Arr::only($campaign->template?->mailConfig() ?? [], ['from_email', 'from_name', 'reply_to', 'reply_to_name']), - )); + )) === null; } catch (\Throwable $e) { $failed = true; Log::warning('Gửi email chiến dịch thất bại', [ diff --git a/app/Jobs/Mail/SendTemplateEmailJob.php b/app/Jobs/Mail/SendTemplateEmailJob.php index 5fc13ef..9f54205 100644 --- a/app/Jobs/Mail/SendTemplateEmailJob.php +++ b/app/Jobs/Mail/SendTemplateEmailJob.php @@ -37,6 +37,7 @@ class SendTemplateEmailJob implements ShouldQueue $rendered['subject'], $rendered['html'], $template->mailConfig(), + $this->scenario, )); } } diff --git a/app/Listeners/Mail/CheckMailEnabled.php b/app/Listeners/Mail/CheckMailEnabled.php index 35bdab2..42c2d96 100644 --- a/app/Listeners/Mail/CheckMailEnabled.php +++ b/app/Listeners/Mail/CheckMailEnabled.php @@ -3,6 +3,7 @@ namespace App\Listeners\Mail; use App\Models\Setting; +use App\Support\AutoEmailGate; use Illuminate\Mail\Events\MessageSending; use Illuminate\Support\Facades\Log; @@ -26,6 +27,15 @@ class CheckMailEnabled return false; } + $scenario = $event->data[AutoEmailGate::SCENARIO_DATA_KEY] ?? null; + if (AutoEmailGate::blocks($scenario)) { + Log::info('Mail sending blocked: automatic sending is off for this scenario.', [ + 'scenario' => $scenario, + 'subject' => $event->message->getSubject(), + ]); + return false; + } + return true; } } diff --git a/app/Mail/Admin/AdminOrderNotification.php b/app/Mail/Admin/AdminOrderNotification.php index 5f10222..6221712 100644 --- a/app/Mail/Admin/AdminOrderNotification.php +++ b/app/Mail/Admin/AdminOrderNotification.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Mail\Admin; use App\Mail\TemplateMailable; +use App\Support\EmailTemplateShell; use Illuminate\Bus\Queueable; use Illuminate\Queue\SerializesModels; @@ -26,11 +27,11 @@ class AdminOrderNotification extends TemplateMailable protected function variables(): array { - $vmFields = ['ip' => '🌐 IP Address', 'username' => '👤 Username', 'password' => '🔑 Password']; + $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]) . '
'; + $vmRows .= EmailTemplateShell::row($label, e($this->data[$field])); } } @@ -40,14 +41,14 @@ class AdminOrderNotification extends TemplateMailable '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']) . '' + ? EmailTemplateShell::row('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 - ? '
Thông tin dịch vụ Cloud VPS
' . $vmRows . '
' + ? EmailTemplateShell::heading('Thông tin dịch vụ Cloud VPS') . EmailTemplateShell::infoTable([], $vmRows) : '', 'company_name' => $this->data['company_name'] ?? 'Portal Admin', ]; diff --git a/app/Mail/Admin/HostingAccountCreatedAdminNotification.php b/app/Mail/Admin/HostingAccountCreatedAdminNotification.php index 12bb528..32376d9 100644 --- a/app/Mail/Admin/HostingAccountCreatedAdminNotification.php +++ b/app/Mail/Admin/HostingAccountCreatedAdminNotification.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Mail\Admin; use App\Mail\TemplateMailable; +use App\Support\EmailTemplateShell; use Illuminate\Bus\Queueable; use Illuminate\Queue\SerializesModels; @@ -27,7 +28,7 @@ class HostingAccountCreatedAdminNotification extends TemplateMailable protected function variables(): array { $row = fn (string $label, ?string $value) => $value - ? '
' . $label . '' . e($value) . '
' + ? EmailTemplateShell::row($label, e($value)) : ''; return [ @@ -41,10 +42,10 @@ class HostingAccountCreatedAdminNotification extends TemplateMailable '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), + '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), + 'plesk_password_row' => $row('Plesk 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 359f49d..2adac31 100644 --- a/app/Mail/Admin/ManualDepositPendingNotification.php +++ b/app/Mail/Admin/ManualDepositPendingNotification.php @@ -3,6 +3,7 @@ namespace App\Mail\Admin; use App\Mail\TemplateMailable; +use App\Support\EmailTemplateShell; use Illuminate\Bus\Queueable; use Illuminate\Queue\SerializesModels; @@ -30,14 +31,14 @@ class ManualDepositPendingNotification extends TemplateMailable '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']) . '' + ? EmailTemplateShell::row('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']) . '' + ? EmailTemplateShell::row('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' + ? EmailTemplateShell::row('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/TrialRegistrationRequest.php b/app/Mail/Admin/TrialRegistrationRequest.php index 6c6c628..712a410 100644 --- a/app/Mail/Admin/TrialRegistrationRequest.php +++ b/app/Mail/Admin/TrialRegistrationRequest.php @@ -3,6 +3,7 @@ namespace App\Mail\Admin; use App\Mail\TemplateMailable; +use App\Support\EmailTemplateShell; use Illuminate\Bus\Queueable; use Illuminate\Queue\SerializesModels; @@ -33,7 +34,7 @@ class TrialRegistrationRequest extends TemplateMailable 'ssd' => $this->data['ssd'] ?? '-', 'os' => $this->data['os'] ?? '-', 'other_row' => !empty($this->data['other']) - ? 'Yêu cầu khác' . e($this->data['other']) . '' + ? EmailTemplateShell::row('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/Customer/HostingAccountCreated.php b/app/Mail/Customer/HostingAccountCreated.php index 4276025..bd3c32f 100644 --- a/app/Mail/Customer/HostingAccountCreated.php +++ b/app/Mail/Customer/HostingAccountCreated.php @@ -3,6 +3,7 @@ namespace App\Mail\Customer; use App\Mail\TemplateMailable; +use App\Support\EmailTemplateShell; use App\Support\MailGlobals; use Illuminate\Bus\Queueable; use Illuminate\Queue\SerializesModels; @@ -28,17 +29,17 @@ class HostingAccountCreated extends TemplateMailable $data = [...MailGlobals::values(), ...$this->data]; $row = fn (string $label, ?string $value) => $value - ? "{$label}{$value}" + ? EmailTemplateShell::row($label, e($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'] . '
'; + $pleskBlock = EmailTemplateShell::paragraph('TÀI KHOẢN ĐĂNG NHẬP PLESK PANEL') + . EmailTemplateShell::infoTable(array_filter([ + 'Địa chỉ Panel' => '' . e($data['plesk_panel_url'] ?? '') . '', + 'Tên đăng nhập' => e($data['plesk_login']), + 'Mật khẩu' => !empty($data['plesk_password']) ? e($data['plesk_password']) : null, + ])); } return [ @@ -46,9 +47,9 @@ class HostingAccountCreated extends TemplateMailable '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), + '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.

' : '', diff --git a/app/Mail/Customer/ManualDepositStatus.php b/app/Mail/Customer/ManualDepositStatus.php index f0f29e6..91c44e3 100644 --- a/app/Mail/Customer/ManualDepositStatus.php +++ b/app/Mail/Customer/ManualDepositStatus.php @@ -3,6 +3,7 @@ namespace App\Mail\Customer; use App\Mail\TemplateMailable; +use App\Support\EmailTemplateShell; use App\Support\MailGlobals; use Illuminate\Bus\Queueable; use Illuminate\Queue\SerializesModels; @@ -38,7 +39,7 @@ class ManualDepositStatus extends TemplateMailable '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'] . '' + ? EmailTemplateShell::row('Thời gian xử lý', e($data['processed_date'])) : '', 'portal_url' => $data['portal_url'] ?? MailGlobals::frontendUrl() . '/nap-tien', ]; diff --git a/app/Mail/Customer/VmCredentials.php b/app/Mail/Customer/VmCredentials.php index ce2cef4..cdbb7c7 100644 --- a/app/Mail/Customer/VmCredentials.php +++ b/app/Mail/Customer/VmCredentials.php @@ -3,6 +3,7 @@ namespace App\Mail\Customer; use App\Mail\TemplateMailable; +use App\Support\EmailTemplateShell; use App\Support\MailGlobals; use Illuminate\Bus\Queueable; use Illuminate\Mail\Mailables\Attachment; @@ -44,21 +45,21 @@ class VmCredentials extends TemplateMailable $data = [...MailGlobals::values(), ...$this->data]; $row = fn (string $label, ?string $value) => $value - ? "{$label}{$value}" + ? EmailTemplateShell::row($label, e($value)) : ''; return [ 'app_name' => $data['company_name'] ?? config('app.name', 'The Gioi So'), 'full_name' => $data['client_name'] ?? 'Quý khách', 'product_name' => $data['product_name'] ?? 'Cloud VPS', - 'product_billing_row' => $row('Chu kỳ thanh toán:', $data['product_billing'] ?? null), - 'product_price_row' => $row('Giá dịch vụ:', !empty($data['product_price']) ? $data['product_price'] . ' VNĐ' : null), - 'os_name_row' => $row('Hệ điều hành:', $data['os_name'] ?? null), + 'product_billing_row' => $row('Chu kỳ thanh toán', $data['product_billing'] ?? null), + 'product_price_row' => $row('Giá dịch vụ', !empty($data['product_price']) ? $data['product_price'] . ' VNĐ' : null), + 'os_name_row' => $row('Hệ điều hành', $data['os_name'] ?? null), 'service_host' => $data['service_host'] ?? '', 'service_username' => $data['service_username'] ?? '', 'service_password' => $data['service_password'] ?? '', - 'service_port_row' => $row('Cổng kết nối:', $data['service_port'] ?? null), - 'service_due_exp_row' => $row('Hạn sử dụng:', $data['service_due_exp'] ?? null), + 'service_port_row' => $row('Cổng kết nối', $data['service_port'] ?? null), + 'service_due_exp_row' => $row('Hạn sử dụng', $data['service_due_exp'] ?? null), ]; } } diff --git a/app/Mail/Customer/VmPasswordChanged.php b/app/Mail/Customer/VmPasswordChanged.php index e3eeb3c..ef78bfe 100644 --- a/app/Mail/Customer/VmPasswordChanged.php +++ b/app/Mail/Customer/VmPasswordChanged.php @@ -3,6 +3,7 @@ namespace App\Mail\Customer; use App\Mail\TemplateMailable; +use App\Support\EmailTemplateShell; use App\Support\MailGlobals; use Illuminate\Bus\Queueable; use Illuminate\Queue\SerializesModels; @@ -33,7 +34,7 @@ class VmPasswordChanged extends TemplateMailable 'full_name' => $data['fullname'] ?? 'Quý khách', 'vm_name' => $data['vm_name'] ?? '', 'new_password_row' => $newPassword - ? '
Mật khẩu mới: ' . $newPassword . '
' + ? EmailTemplateShell::row('Mật khẩu mới', '' . e($newPassword) . '') : '', ]; } diff --git a/app/Mail/GenericTemplateMail.php b/app/Mail/GenericTemplateMail.php index d459874..0e43e00 100644 --- a/app/Mail/GenericTemplateMail.php +++ b/app/Mail/GenericTemplateMail.php @@ -3,6 +3,7 @@ namespace App\Mail; use App\Mail\Concerns\BuildsTemplateEnvelope; +use App\Support\AutoEmailGate; use Illuminate\Bus\Queueable; use Illuminate\Mail\Mailable; use Illuminate\Mail\Mailables\Content; @@ -22,6 +23,7 @@ class GenericTemplateMail extends Mailable public readonly string $renderedSubject, public readonly string $renderedHtml, public readonly array $mailConfig = [], + public readonly ?string $scenario = null, ) {} public function envelope(): Envelope @@ -33,4 +35,9 @@ class GenericTemplateMail extends Mailable { return new Content(htmlString: $this->renderedHtml); } + + public function buildViewData() + { + return [...parent::buildViewData(), AutoEmailGate::SCENARIO_DATA_KEY => $this->scenario]; + } } diff --git a/app/Mail/InvoiceNotificationMail.php b/app/Mail/InvoiceNotificationMail.php index 6a63738..41963d4 100644 --- a/app/Mail/InvoiceNotificationMail.php +++ b/app/Mail/InvoiceNotificationMail.php @@ -5,6 +5,7 @@ namespace App\Mail; use App\Enums\Invoice\InvoiceStatus; use App\Models\Invoice; use App\Services\InvoicePaymentService; +use App\Support\EmailTemplateShell; use Carbon\Carbon; use Illuminate\Database\Eloquent\Collection; use Illuminate\Mail\Mailables\Attachment; @@ -113,10 +114,8 @@ class InvoiceNotificationMail extends TemplateMailable return ''; } - return 'Đã thanh toán:' - . number_format($paid, 0, ',', '.') . ' VNĐ' - . 'Còn thiếu:' - . number_format($outstanding, 0, ',', '.') . ' VNĐ'; + return EmailTemplateShell::row('Đã thanh toán', '' . number_format($paid, 0, ',', '.') . ' VNĐ') + . EmailTemplateShell::row('Còn thiếu', '' . number_format($outstanding, 0, ',', '.') . ' VNĐ'); } private function payButton(): string diff --git a/app/Mail/ServiceRenewalReminderMail.php b/app/Mail/ServiceRenewalReminderMail.php index 2554187..1bba9a8 100644 --- a/app/Mail/ServiceRenewalReminderMail.php +++ b/app/Mail/ServiceRenewalReminderMail.php @@ -4,6 +4,7 @@ namespace App\Mail; use App\Models\Customer; use App\Models\Service; +use App\Support\EmailTemplateShell; use Illuminate\Bus\Queueable; use Illuminate\Queue\SerializesModels; @@ -36,7 +37,7 @@ class ServiceRenewalReminderMail extends TemplateMailable ? \Carbon\Carbon::parse($this->service->expires_at)->format('d/m/Y') : 'N/A', 'price_row' => $price - ? 'Số tiền gia hạn:' . $price . ' VNĐ' + ? EmailTemplateShell::row('Số tiền gia hạn', $price . ' VNĐ') : '', 'login_url' => rtrim(config('app.frontend_url', ''), '/') . '/dang-nhap', 'support_email' => 'support@tgs.com.vn', diff --git a/app/Mail/TemplateMailable.php b/app/Mail/TemplateMailable.php index 74d7e0e..7638e3b 100644 --- a/app/Mail/TemplateMailable.php +++ b/app/Mail/TemplateMailable.php @@ -6,6 +6,7 @@ use App\Mail\Concerns\BuildsTemplateEnvelope; use App\Models\Template; use App\Services\Email\EmailTemplateRenderer; use App\Services\Email\EmailTemplateService; +use App\Support\AutoEmailGate; use App\Support\MailGlobals; use Illuminate\Mail\Mailable; use Illuminate\Mail\Mailables\Content; @@ -34,6 +35,11 @@ abstract class TemplateMailable extends Mailable return new Content(htmlString: $this->rendered()['html']); } + public function buildViewData() + { + return [...parent::buildViewData(), AutoEmailGate::SCENARIO_DATA_KEY => $this->scenario()]; + } + /** * @return array{subject: string, html: string} */ diff --git a/app/Mail/TicketCreated.php b/app/Mail/TicketCreated.php index b687092..7e3f282 100644 --- a/app/Mail/TicketCreated.php +++ b/app/Mail/TicketCreated.php @@ -2,6 +2,7 @@ namespace App\Mail; +use App\Support\EmailTemplateShell; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Queue\SerializesModels; @@ -34,7 +35,7 @@ class TicketCreated extends TemplateMailable implements ShouldQueue 'ticket_num' => $this->ticket->ticket_num, 'subject' => $this->ticket->subject, 'room_row' => $room - ? 'Phòng ban:' . $room . '' + ? EmailTemplateShell::row('Phòng ban', e($room)) : '', 'ticket_url' => rtrim(config('app.frontend_url', ''), '/') . '/tickets/' . $this->ticket->ticket_num, 'app_name' => config('app.name', 'Thế Giới Số'), diff --git a/app/Models/Service.php b/app/Models/Service.php index d0af7bd..2c285f9 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -109,6 +109,11 @@ class Service extends Model return $this->belongsTo(ModuleVmware::class, 'vm_id'); } + public function resolveVmware(): ?ModuleVmware + { + return $this->vmware ?? $this->orderItem?->order?->vmWare?->first(); + } + /** The Plesk hosting account associated with this service, if type = hosting. */ public function hostingAccount() { diff --git a/app/Services/Customer/CustomerServiceProvisioningService.php b/app/Services/Customer/CustomerServiceProvisioningService.php index cdab27e..344e4b5 100644 --- a/app/Services/Customer/CustomerServiceProvisioningService.php +++ b/app/Services/Customer/CustomerServiceProvisioningService.php @@ -79,12 +79,16 @@ class CustomerServiceProvisioningService $service = Service::where('customer_id', $customer->id)->where('id', $serviceId)->first(); abort_if(!$service, 404, 'Không tìm thấy dịch vụ'); - return DB::transaction(function () use ($service, $data) { - $vmFields = ['os_id', 'vm_cpu', 'vm_ram_mb', 'vm_disk_gb', 'vm_ip', 'vm_dns', 'vm_user', 'vm_password', 'vm_port']; + return DB::transaction(function () use ($customer, $service, $data) { + $vmFields = ['virtual_machine_id', 'os_id', 'vm_cpu', 'vm_ram_mb', 'vm_disk_gb', 'vm_ip', 'vm_dns', 'vm_user', 'vm_password', 'vm_port']; $service->fill(collect($data)->except(['price', ...$vmFields])->all()); $service->save(); + if (!empty($data['virtual_machine_id'])) { + $this->assignVirtualMachine($service, $customer, (int) $data['virtual_machine_id']); + } + if (array_key_exists('price', $data) && $data['price'] !== null && $service->orderItem) { $service->orderItem->update(['price' => $data['price']]); } @@ -117,6 +121,49 @@ class CustomerServiceProvisioningService }); } + private function assignVirtualMachine(Service $service, Customer $customer, int $virtualMachineId): void + { + $vm = VirtualMachines::with('datacenter')->findOrFail($virtualMachineId); + $current = $service->vmware; + + if ($current && ($current->virtual_machine_id === $vm->id || $current->vmware_id === $vm->vm_id)) { + return; + } + + $takenBy = DB::table('module_vmware as mv') + ->join('services as s', 's.vm_id', '=', 'mv.id') + ->where(fn($q) => $q->where('mv.virtual_machine_id', $vm->id)->orWhere('mv.vmware_id', $vm->vm_id)) + ->where('s.id', '!=', $service->id) + ->value('s.id'); + abort_if($takenBy, 422, "VM {$vm->name} đang gán cho dịch vụ #{$takenBy}."); + + if ($current) { + $current->update([ + 'virtual_machine_id' => $vm->id, + 'vmware_id' => $vm->vm_id, + 'name' => $vm->name, + 'vcenter_id' => $vm->datacenter->vcenter_id ?? null, + 'cpu' => $vm->cpu, + 'ram_mb' => $vm->ram_mb, + 'ip' => $vm->ip, + ]); + + return; + } + + $price = (int) ($service->orderItem->price ?? 0); + $vmId = $this->createModuleVmwareIfNeeded( + ['virtual_machine_id' => $vm->id, 'server_name' => $service->server_name], + $customer, + $service->orderItem?->order, + $price, + $service->expires_at ?? now(), + ); + + ModuleVmware::where('id', $vmId)->update(['service_id' => $service->id]); + $service->update(['vm_id' => $vmId]); + } + /** * Gửi email nhắc gia hạn dịch vụ cho khách hàng. */ @@ -285,7 +332,7 @@ class CustomerServiceProvisioningService }); } - private function createModuleVmwareIfNeeded(array $data, Customer $customer, Order $order, int $basePrice, string|Carbon $expiresAt): ?int + private function createModuleVmwareIfNeeded(array $data, Customer $customer, ?Order $order, int $basePrice, string|Carbon $expiresAt): ?int { if (empty($data['virtual_machine_id'])) { return null; @@ -298,7 +345,7 @@ class CustomerServiceProvisioningService ['virtual_machine_id' => $vm->id], [ 'customer_id' => $customer->id, - 'order_id' => $order->id, + 'order_id' => $order?->id, 'name' => $vm->name, 'vmware_id' => $vm->vm_id, 'vcenter_id' => $vcenterId, diff --git a/app/Services/InvoiceReminderService.php b/app/Services/InvoiceReminderService.php index b555ac6..6dd8cec 100644 --- a/app/Services/InvoiceReminderService.php +++ b/app/Services/InvoiceReminderService.php @@ -5,6 +5,8 @@ namespace App\Services; use App\Models\Customer; use App\Models\Invoice; use App\Services\Email\EmailTemplateService; +use App\Support\AutoEmailGate; +use App\Support\EmailTemplateShell; use Carbon\Carbon; /** @@ -79,6 +81,10 @@ class InvoiceReminderService return false; } + if (! $force && AutoEmailGate::blocks(self::SCENARIO)) { + return false; + } + $customer = $invoice->customer; if (! $customer?->email) { return false; @@ -108,8 +114,7 @@ class InvoiceReminderService ? 'Hoá đơn đã quá hạn ' . $daysLate . ' ngày.' : ($daysLate === 0 ? 'Hôm nay là hạn thanh toán của hoá đơn này.' : 'Hoá đơn sẽ đến hạn sau ' . abs($daysLate) . ' ngày.'), 'paid_row' => (int) ($invoice->amount_paid ?? 0) > 0 - ? 'Đã thanh toán:' - . number_format((int) $invoice->amount_paid, 0, ',', '.') . ' VNĐ' + ? EmailTemplateShell::row('Đã thanh toán', number_format((int) $invoice->amount_paid, 0, ',', '.') . ' VNĐ') : '', 'login_url' => rtrim((string) config('app.frontend_url', ''), '/') . '/dang-nhap', 'support_email' => 'support@tgs.com.vn', diff --git a/app/Support/AutoEmailGate.php b/app/Support/AutoEmailGate.php new file mode 100644 index 0000000..151be2a --- /dev/null +++ b/app/Support/AutoEmailGate.php @@ -0,0 +1,37 @@ + + */ + public static function disabledScenarios(): array + { + $value = Setting::where('key', 'email_settings')->value('value'); + + return (array) (json_decode($value ?? '', true)['auto_disabled'] ?? []); + } +} diff --git a/app/Support/EmailTemplateShell.php b/app/Support/EmailTemplateShell.php index f6cc20a..ec4c87f 100644 --- a/app/Support/EmailTemplateShell.php +++ b/app/Support/EmailTemplateShell.php @@ -2,75 +2,158 @@ namespace App\Support; -/** - * Khung 700px dùng chung cho template email trong tbl.template: header (banner) + footer - * (menu dịch vụ + thông tin công ty). Mỗi scenario chỉ cần viết phần . - * - * Thông tin công ty hardcode ở footer vì EmailTemplateRenderer chỉ thay placeholder dạng - * {{key}} đã khai báo trong metadata.variables — mọi scenario đều phải khai lại nếu muốn - * footer động, không đáng. - */ class EmailTemplateShell { - /** Bọc phần nội dung vào khung chuẩn. */ + private const BRAND = '#f16723'; + + private const SERVICE_LINKS = [ + 'Premium Hosting' => 'https://tgs.com.vn/premium-hosting/', + 'Cloud Server' => 'https://tgs.com.vn/cloud-server/', + 'Thuê Server' => 'https://tgs.com.vn/may-chu-rieng', + 'Thiết kế website' => 'https://tgs.com.vn/thiet-ke-website/', + ]; + public static function wrap(string $bodyHtml): string { return self::header() . $bodyHtml . self::footer(); } - /** - * Mở khối NoiDungKM với style chữ chuẩn, để scenario chỉ viết nội dung bên trong. - */ + public static function page(string $innerHtml): string + { + return self::wrap(self::body($innerHtml)); + } + public static function body(string $innerHtml): string { - return '' + return '' . $innerHtml . ''; } - /** Khối "Lưu ý: email tự động" dùng ở cuối hầu hết scenario. */ + public static function title(string $html): string + { + return '

' . $html . '

'; + } + + public static function subtitle(string $html): string + { + return '

' . $html . '

'; + } + + public static function highlight(string $html): string + { + return '' + . '
' + . '' . $html . '' + . '
'; + } + + public static function heading(string $html): string + { + return '

' . $html . '

'; + } + + public static function greeting(string $nameHtml): string + { + return '

Kính chào ' . $nameHtml . ',

'; + } + + public static function paragraph(string $html): string + { + return '

' . $html . '

'; + } + + public static function button(string $url, string $label): string + { + return '' + . '
' + . '' . $label . '' + . '
'; + } + + public static function note(string $html, string $label = 'Lưu ý:'): string + { + return '' + . '
' + . '' . $label . ' ' . $html + . '
'; + } + public static function autoNotice(): string { - return <<<'HTML' - - -
Lưu ý: Đây là email được gửi tự động. Vui lòng không trả lời email này.
-HTML; + return self::note('Đây là email được gửi tự động. Vui lòng không trả lời email này.'); + } + + public static function supportLine(): string + { + return self::paragraph('Mọi thắc mắc xin liên hệ support@tgs.com.vn hoặc (028) 7309. 7379.'); + } + + /** + * @param array $rows label => value HTML (đã escape) + */ + public static function infoTable(array $rows, string $extraRowsHtml = ''): string + { + $html = ''; + foreach ($rows as $label => $value) { + $html .= self::row($label, $value); + } + + return '' + . $html . $extraRowsHtml + . '
'; + } + + public static function row(string $label, string $valueHtml): string + { + return '' . $label . '' + . '' . $valueHtml . ''; } public static function header(): string { return <<<'HTML' -' + . '
- +
Thư gửi từ CÔNG TY CỔ PHẦN GIẢI PHÁP CÔNG NGHỆ THẾ GIỚI SỐ
+ HTML; } public static function footer(): string { - return <<<'HTML' - -
Thư gửi từ CÔNG TY CỔ PHẦN GIẢI PHÁP CÔNG NGHỆ THẾ GIỚI SỐ
-HTML; + return '
'; + } + + private static function serviceBanner(): string + { + $cells = ''; + foreach (self::SERVICE_LINKS as $label => $url) { + $cells .= '' + . '' . $label . ''; + } + + return '' + . '' + . $cells + . '
'; + } + + private static function companyInfo(): string + { + $line = fn (string $html, string $padding = '6px 30px 0 30px') => '' . $html . ''; + $text = 'box-sizing: border-box; font-size: 12px; line-height: 18px; color: #ffffff;'; + + return $line('CÔNG TY CỔ PHẦN GIẢI PHÁP CÔNG NGHỆ THẾ GIỚI SỐ') + . $line('Địa chỉ: 128/38 Tân Hương, Phường Phú Thọ Hòa, Thành Phố Hồ Chí Minh') + . $line('Tel: (028) 7309. 7379  -  MST: 0318443116') + . $line('Email: support@tgs.com.vn') + . $line('🥇 Thế Giới Số 🥇 - Premium HOSTING • CLOUD • SERVER & WEBSITE | 15+ năm Phụng Sự🚀', '6px 30px 16px 30px'); } - /** - * Bọc một đoạn HTML thành email hoàn chỉnh (doctype + head). Thay cho blade - * emails.customer.template-envelope — để không còn mail nào đi qua Blade. - */ public static function document(?string $title, string $bodyHtml): string { $safeTitle = htmlspecialchars((string) $title, ENT_QUOTES, 'UTF-8'); diff --git a/config/cors.php b/config/cors.php index c9516c3..15a067a 100644 --- a/config/cors.php +++ b/config/cors.php @@ -21,7 +21,6 @@ return [ 'allowed_origins' => [ 'https://portal.tgs.com.vn', 'https://admin.portal.tgs.com.vn', - // Origin dev chỉ bật ở môi trường local (supports_credentials = true nên không để lọt lên prod). ...(env('APP_ENV') === 'local' ? [ 'http://localhost:3000', 'http://localhost:5173', diff --git a/database/migrations/2026_10_08_000001_disable_automatic_billing_emails.php b/database/migrations/2026_10_08_000001_disable_automatic_billing_emails.php new file mode 100644 index 0000000..c893d63 --- /dev/null +++ b/database/migrations/2026_10_08_000001_disable_automatic_billing_emails.php @@ -0,0 +1,36 @@ +update(fn (array $disabled) => array_values(array_unique([...$disabled, ...self::SCENARIOS]))); + } + + public function down(): void + { + $this->update(fn (array $disabled) => array_values(array_diff($disabled, self::SCENARIOS))); + } + + private function update(callable $change): void + { + $row = DB::table('setting')->where('key', 'email_settings')->first(); + $value = $row ? (json_decode($row->value, true) ?: []) : []; + $value['auto_disabled'] = $change((array) ($value['auto_disabled'] ?? [])); + + DB::table('setting')->updateOrInsert( + ['key' => 'email_settings'], + ['value' => json_encode($value), 'updated_at' => now()], + ); + } +}; diff --git a/database/seeders/EmailTemplates/AccountTemplates.php b/database/seeders/EmailTemplates/AccountTemplates.php new file mode 100644 index 0000000..a29cbcd --- /dev/null +++ b/database/seeders/EmailTemplates/AccountTemplates.php @@ -0,0 +1,79 @@ + + */ + public static function all(): array + { + return [ + [ + 'scenario' => 'verify_account_welcome', + 'name' => 'Mã xác thực tài khoản', + 'subject' => '[{{app_name}}] Mã xác thực tài khoản của bạn', + 'body' => T::title('Xác thực tài khoản') + . T::greeting('{{full_name}}') + . T::paragraph('Cảm ơn Quý khách đã đăng ký tài khoản tại {{app_name}}. Vui lòng nhập mã xác thực dưới đây để hoàn tất đăng ký:') + . T::highlight('{{verify_code}}') + . T::paragraph('Mã xác thực có hiệu lực trong 5 phút. Nếu Quý khách không thực hiện yêu cầu này, vui lòng bỏ qua email.') + . T::note('Không chia sẻ mã này cho bất kỳ ai, kể cả nhân viên Thế Giới Số.'), + ], + [ + 'scenario' => 'customer_password_reset_link', + 'name' => 'Link đặt lại mật khẩu khách hàng', + 'subject' => 'Yêu cầu đặt lại mật khẩu', + 'body' => T::title('Đặt lại mật khẩu') + . T::greeting('{{full_name}}') + . T::paragraph('Chúng tôi nhận được yêu cầu đặt lại mật khẩu cho tài khoản của Quý khách. Vui lòng bấm nút bên dưới để tạo mật khẩu mới. Link có hiệu lực trong {{expire_minutes}} phút và chỉ dùng được một lần.') + . T::button('{{reset_url}}', 'ĐẶT LẠI MẬT KHẨU') + . T::paragraph('Nếu Quý khách không yêu cầu, vui lòng bỏ qua email này — mật khẩu hiện tại vẫn giữ nguyên. Mọi thắc mắc xin liên hệ support@tgs.com.vn hoặc (028) 7309. 7379.') + . T::note('Không chia sẻ link này cho bất kỳ ai, kể cả nhân viên Thế Giới Số.'), + ], + [ + 'scenario' => 'password_reset_admin', + 'name' => 'Admin cấp lại mật khẩu', + 'subject' => 'Mật khẩu của bạn đã được cập nhật — {{app_name}}', + 'body' => T::title('Mật khẩu đã được cập nhật') + . T::greeting('{{full_name}}') + . T::paragraph('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 Quý khách. Vui lòng dùng thông tin bên dưới để đăng nhập:') + . T::infoTable([ + 'Tài khoản' => '{{email}}', + 'Mật khẩu mới' => '{{password}}', + ]) + . T::button('{{login_url}}', 'ĐĂNG NHẬP NGAY') + . T::note('Vui lòng đổi mật khẩu ngay sau khi đăng nhập. Nếu Quý khách không yêu cầu thao tác này, hãy liên hệ ngay support@tgs.com.vn.'), + ], + [ + 'scenario' => 'customer_welcome', + 'name' => 'Chào mừng khách hàng mới', + 'subject' => 'Chào mừng Quý khách đến với {{app_name}}', + 'body' => T::title('Chào mừng đến với {{app_name}}') + . T::greeting('{{full_name}}') + . T::paragraph('Cảm ơn Quý khách đã tin tưởng lựa chọn {{app_name}}. Tài khoản của Quý khách đã được kích hoạt và sẵn sàng sử dụng.') + . T::paragraph('Tại cổng quản lý, Quý khách có thể đặt mua dịch vụ Hosting, Cloud Server, tên miền, theo dõi hoá đơn và gửi yêu cầu hỗ trợ bất cứ lúc nào.') + . T::button('{{login_url}}', 'ĐĂNG NHẬP NGAY') + . T::supportLine(), + ], + [ + 'scenario' => 'ticket_created', + 'name' => 'Ticket hỗ trợ mới', + 'subject' => '[#{{ticket_num}}] Yêu cầu hỗ trợ của bạn đã được tiếp nhận', + 'body' => T::title('Yêu cầu hỗ trợ đã được tiếp nhận') + . T::greeting('{{full_name}}') + . T::paragraph('Chúng tôi đã nhận được yêu cầu hỗ trợ của Quý khách và sẽ phản hồi trong thời gian sớm nhất. Thông tin ticket:') + . T::infoTable([ + 'Mã ticket' => '#{{ticket_num}}', + 'Tiêu đề' => '{{subject}}', + ], '{{room_row}}' . T::row('Trạng thái', 'Đang mở')) + . T::button('{{ticket_url}}', 'XEM CHI TIẾT TICKET') + . T::paragraph('Độ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.') + . T::autoNotice(), + ], + ]; + } +} diff --git a/database/seeders/EmailTemplates/AdminTemplates.php b/database/seeders/EmailTemplates/AdminTemplates.php new file mode 100644 index 0000000..61d9095 --- /dev/null +++ b/database/seeders/EmailTemplates/AdminTemplates.php @@ -0,0 +1,135 @@ + + */ + public static function all(): array + { + return [ + [ + 'scenario' => 'admin_order_notification', + 'name' => '[Admin] Đơn hàng mới', + 'subject' => '[ADMIN] Đơn hàng mới #{{order_num}} — {{customer_email}}', + 'body' => T::title('Đơn hàng mới') + . T::paragraph('Có đơn hàng mới vừa được đặt trên hệ thống {{company_name}}.') + . T::heading('Thông tin khách hàng') + . T::infoTable([ + 'Mã đơn hàng' => '#{{order_num}}', + 'Thời gian' => '{{created_at}}', + 'Khách hàng' => '{{customer_name}}', + 'Email' => '{{customer_email}}', + ], '{{client_ip_row}}') + . T::heading('Thông tin sản phẩm') + . T::infoTable([ + 'Sản phẩm' => '{{product_name}}', + 'Loại' => '{{product_type}}', + 'Chu kỳ thanh toán' => '{{billing_cycle}}', + 'Tổng tiền' => '{{total_formatted}}', + ]) + . '{{vm_block}}', + ], + [ + 'scenario' => 'admin_domain_order_new', + 'name' => '[Admin] Đơn đăng ký tên miền mới', + 'subject' => '[ADMIN] Đơn đăng ký tên miền mới - {{domain_name}}', + 'body' => T::title('Đơn đăng ký tên miền mới') + . T::paragraph('Có đơn đăng ký tên miền mới trên hệ thống {{company_name}}, cần kiểm tra và đăng ký thủ công.') + . T::infoTable([ + '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}}', + ]) + . T::button('{{admin_url}}', 'XEM ĐƠN HÀNG'), + ], + [ + 'scenario' => 'admin_hosting_account_created', + 'name' => '[Admin] Hosting account mới', + 'subject' => '[ADMIN] Hosting account mới — {{domain_name}}', + 'body' => T::title('Hosting account mới') + . T::paragraph('Hệ thống {{company_name}} vừa khởi tạo hosting cho khách hàng.') + . T::heading('Thông tin dịch vụ') + . T::infoTable([ + 'Tên miền' => '{{domain_name}}', + 'Gói hosting' => '{{hosting_plan}}', + 'Khách hàng' => '{{customer_name}}', + 'Email' => '{{customer_email}}', + 'Thời gian' => '{{created_at}}', + ]) + . T::heading('Máy chủ Plesk') + . T::infoTable([ + 'Máy chủ' => '{{server_name}}', + 'IP' => '{{server_ip}}', + 'Client ID' => '{{plesk_client_id}}', + 'Subscription ID' => '{{plesk_subscription_id}}', + ]) + . T::heading('Thông tin truy cập') + . T::infoTable( + ['FTP Username' => '{{ftp_username}}'], + '{{ftp_password_row}}' + . T::row('Plesk Panel', '{{plesk_panel_url}}') + . T::row('Plesk Login', '{{plesk_login}}') + . '{{plesk_password_row}}', + ), + ], + [ + 'scenario' => 'admin_trial_registration_request', + 'name' => '[Admin] Yêu cầu dùng thử VPS', + 'subject' => '[ADMIN] Yêu cầu dùng thử Cloud VPS - {{customer_name}}', + 'body' => T::title('Yêu cầu dùng thử Cloud VPS') + . T::paragraph('Có khách hàng vừa gửi yêu cầu dùng thử Cloud VPS trên hệ thống {{company_name}}.') + . T::heading('Thông tin khách hàng') + . T::infoTable([ + 'Khách hàng' => '{{customer_name}}', + 'Email' => '{{customer_email}}', + 'Số điện thoại' => '{{customer_phone}}', + 'Thời gian' => '{{created_at}}', + ]) + . T::heading('Cấu hình yêu cầu') + . T::infoTable([ + 'CPU' => '{{cpu}}', + 'RAM' => '{{ram}}', + 'SSD' => '{{ssd}}', + 'Hệ điều hành' => '{{os}}', + ], '{{other_row}}'), + ], + [ + 'scenario' => 'admin_manual_deposit_pending', + 'name' => '[Admin] Yêu cầu nạp tiền chờ duyệt', + 'subject' => '[ADMIN] Yêu cầu nạp tiền chờ duyệt - {{transaction_code}}', + 'body' => T::title('Yêu cầu nạp tiền chờ duyệt') + . T::paragraph('Có yêu cầu nạp tiền thủ công mới trên hệ thống {{company_name}}, vui lòng kiểm tra chứng từ và duyệt trong trang quản trị.') + . T::infoTable([ + 'Mã giao dịch' => '{{transaction_code}}', + 'Số tiền' => '{{amount_formatted}}', + 'Thời gian' => '{{created_at}}', + 'Khách hàng' => '{{customer_name}}', + 'Email' => '{{customer_email}}', + ], '{{customer_phone_row}}{{client_ip_row}}{{proof_row}}'), + ], + [ + 'scenario' => 'admin_ticket_notification', + 'name' => '[Admin] Ticket hỗ trợ mới', + 'subject' => '[ADMIN] Ticket mới #{{ticket_num}} — {{customer_email}}', + 'body' => T::title('Ticket hỗ trợ mới') + . T::infoTable([ + 'Mã ticket' => '#{{ticket_num}}', + 'Tiêu đề' => '{{subject_text}}', + 'Phòng ban' => '{{room}}', + 'Khách hàng' => '{{customer_name}}', + 'Email' => '{{customer_email}}', + ]) + . T::note('{{description}}', 'Nội dung:') + . T::button('{{ticket_url}}', 'XEM TICKET'), + ], + ]; + } +} diff --git a/database/seeders/EmailTemplates/BillingTemplates.php b/database/seeders/EmailTemplates/BillingTemplates.php new file mode 100644 index 0000000..d9cee90 --- /dev/null +++ b/database/seeders/EmailTemplates/BillingTemplates.php @@ -0,0 +1,150 @@ +}> + */ + public static function all(): array + { + return [ + [ + 'scenario' => 'invoice_notification', + 'name' => 'Thông báo hoá đơn / gia hạn cước', + 'subject' => '{{title}} #{{invoice_number}} - {{company_name}}', + 'body' => T::title('{{title}}') + . T::greeting('{{customer_name}}') + . T::paragraph('{{body_message}}') + . T::infoTable([ + 'Số hoá đơn' => '{{invoice_number}}', + 'Ngày lập' => '{{created_date}}', + 'Hạn thanh toán' => '{{due_date}}', + 'Tổng tiền' => '{{total}} VNĐ', + 'Trạng thái' => '{{status_label}}', + ], '{{outstanding_row}}') + . T::paragraph('{{intro_note}}') + . '{{pay_button}}{{attachments_block}}{{note_block}}' + . T::supportLine(), + ], + [ + 'scenario' => 'invoice_payment_reminder', + 'name' => 'Nhắc thanh toán hoá đơn', + 'subject' => 'Nhắc thanh toán hoá đơn {{invoice_number}}', + 'body' => T::title('Nhắc thanh toán hoá đơn') + . T::greeting('{{client_name}}') + . T::paragraph('{{status_line}} Vui lòng thanh toán để dịch vụ không bị gián đoạn.') + . T::infoTable([ + 'Số hoá đơn' => '{{invoice_number}}', + 'Hạn thanh toán' => '{{due_date}}', + 'Tổng tiền' => '{{total}} VNĐ', + ], '{{paid_row}}' . T::row('Còn phải thanh toán', '{{outstanding}} VNĐ')) + . T::button('{{login_url}}', 'THANH TOÁN NGAY') + . T::paragraph('Nếu Quý khách đã thanh toán, vui lòng bỏ qua email này.') + . T::supportLine(), + ], + [ + 'scenario' => 'customer_deposit_success', + 'name' => 'Nạp tiền thành công', + 'subject' => 'Thông báo nạp tiền thành công - {{amount_formatted}} VNĐ', + 'body' => T::title('Nạp tiền thành công') + . T::greeting('{{customer_name}}') + . T::paragraph('Tài khoản của Quý khách đã được nạp tiền thành công. Chi tiết giao dịch:') + . T::infoTable([ + 'Mã giao dịch' => '{{transaction_code}}', + 'Số tiền nạp' => '{{amount_formatted}} VNĐ', + 'Tiền khuyến mãi' => '{{credit_formatted}} VNĐ', + 'Thời gian' => '{{deposit_date}}', + 'Số dư hiện tại' => '{{current_total_formatted}} VNĐ', + 'Số dư khuyến mãi' => '{{current_credit_formatted}} VNĐ', + ]) + . T::button('{{login_url}}', 'XEM TÀI KHOẢN') + . T::supportLine(), + ], + [ + 'scenario' => 'manual_deposit_status', + 'name' => 'Trạng thái nạp tiền thủ công', + 'subject' => '{{company_name}} - Trạng thái nạp tiền', + 'types' => ['status_label' => 'string'], + 'body' => T::title('Cập nhật yêu cầu nạp tiền') + . T::greeting('{{customer_name}}') + . T::paragraph('{{status_message}}') + . T::infoTable([ + '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}}', + ], '{{processed_date_row}}') + . T::button('{{portal_url}}', 'XEM YÊU CẦU NẠP TIỀN') + . T::supportLine(), + ], + [ + 'scenario' => 'order_created', + 'name' => 'Xác nhận đơn hàng', + 'subject' => 'Xác nhận đơn hàng #{{order_num}}', + 'body' => T::title('Xác nhận đơn hàng') + . T::greeting('{{client_name}}') + . T::paragraph('Cảm ơn Quý khách đã đặt hàng tại {{company_name}}! Đơn hàng đã được tiếp nhận thành công.') + . T::infoTable([ + 'Mã đơn hàng' => '{{order_num}}', + 'Mã hoá đơn' => '{{invoice_num}}', + 'Tổng tiền' => '{{order_total}} VNĐ', + ]) + . T::paragraph('Chúng tôi sẽ xử lý đơn hàng trong thời gian sớm nhất và thông báo cho Quý khách khi dịch vụ được kích hoạt.') + . T::supportLine() + . T::autoNotice(), + ], + [ + 'scenario' => 'domain_order_received', + 'name' => 'Đơn đăng ký tên miền đã ghi nhận', + 'subject' => 'Đơn đăng ký tên miền {{domain_name}} đã được ghi nhận', + 'body' => T::title('Đã ghi nhận đơn đăng ký tên miền') + . T::greeting('{{customer_name}}') + . T::paragraph('Cảm ơn Quý khách đã đặt mua tên miền tại {{company_name}}. Đơn hàng đang chờ đội ngũ kỹ thuật kiểm tra và đăng ký.') + . T::infoTable([ + 'Tên miền' => '{{domain_name}}', + 'Mã đơn hàng' => '{{order_num}}', + 'Tổng tiền' => '{{order_total}}', + 'Thời gian' => '{{created_at}}', + ]) + . T::note('Việc đặt mua chưa đồ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ố tiền đã thanh toán sẽ được hoàn lại vào tài khoản.') + . T::button('{{order_url}}', 'XEM ĐƠN HÀNG') + . T::supportLine(), + ], + [ + 'scenario' => 'domain_order_approved', + 'name' => 'Tên miền được duyệt', + 'subject' => 'Tên miền {{domain_name}} đã được duyệt đăng ký', + 'body' => T::title('Tên miền đã được duyệt') + . T::greeting('{{client_name}}') + . T::paragraph('Đơn đăng ký tên miền của Quý khách đã được duyệt và kích hoạt thành công.') + . T::infoTable([ + 'Tên miền' => '{{domain_name}}', + 'Mã đơn hàng' => '{{order_num}}', + ]) + . T::paragraph('Tên miền hiện đã hoạt động.') + . T::supportLine() + . T::autoNotice(), + ], + [ + 'scenario' => 'domain_order_rejected', + 'name' => 'Tên miền bị từ chối', + 'subject' => 'Tên miền {{domain_name}} đã bị từ chối đăng ký', + 'body' => T::title('Tên miền bị từ chối đăng ký') + . T::greeting('{{client_name}}') + . T::paragraph('Rất tiếc, đơn đăng ký tên miền của Quý khách đã bị từ chối.') + . T::infoTable([ + 'Tên miền' => '{{domain_name}}', + 'Mã đơn hàng' => '{{order_num}}', + 'Lý do' => '{{reason}}', + ]) + . T::paragraph('Số tiền đã thanh toán sẽ được hoàn lại vào tài khoản của Quý khách.') + . T::supportLine() + . T::autoNotice(), + ], + ]; + } +} diff --git a/database/seeders/EmailTemplates/ServiceTemplates.php b/database/seeders/EmailTemplates/ServiceTemplates.php new file mode 100644 index 0000000..fc60385 --- /dev/null +++ b/database/seeders/EmailTemplates/ServiceTemplates.php @@ -0,0 +1,190 @@ +Đang hoạt động'; + + /** + * @return array + */ + public static function all(): array + { + return [ + [ + 'scenario' => 'vm_credentials', + 'name' => 'Khởi tạo VPS (thông tin truy cập)', + 'subject' => 'VPS của bạn đã sẵn sàng — {{product_name}}', + 'body' => T::title('Cloud VPS đã sẵn sàng') + . T::greeting('{{full_name}}') + . T::paragraph('Cảm ơn Quý khách đã tin tưởng sử dụng dịch vụ của {{app_name}}. Cloud VPS {{product_name}} đã được khởi tạo thành công.') + . T::heading('Thông tin dịch vụ') + . T::infoTable(['Dịch vụ' => '{{product_name}}'], '{{product_billing_row}}{{product_price_row}}{{os_name_row}}{{service_due_exp_row}}') + . T::heading('Thông tin truy cập') + . T::infoTable([ + 'IP / Host' => '{{service_host}}', + 'Tài khoản' => '{{service_username}}', + 'Mật khẩu' => '{{service_password}}', + ], '{{service_port_row}}') + . T::button('{{login_url}}', 'QUẢN LÝ DỊCH VỤ') + . T::note('Vui lòng đổi mật khẩu ngay sau lần đăng nhập đầu tiên và không chia sẻ thông tin truy cập cho người khác.') + . T::supportLine(), + ], + [ + 'scenario' => 'hosting_information', + 'name' => 'Thông tin truy cập Hosting', + 'subject' => 'Thông tin truy cập {{product_name}}', + 'body' => T::title('Thông tin truy cập Hosting') + . T::greeting('{{client_name}}') + . T::paragraph('Cảm ơn Quý khách đã tin tưởng 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.') + . T::heading('Thông tin gói dịch vụ') + . T::infoTable([ + 'Gói dịch vụ' => '{{product_name}}', + 'Chu kỳ thanh toán' => '{{product_billing}}', + 'Giá' => '{{product_price}} VNĐ', + 'Ngày hết hạn' => '{{service_due_exp}}', + ]) + . T::heading('Thông tin truy cập Hosting') + . T::infoTable([ + 'IP / Host' => '{{service_host}}', + 'Tài khoản' => '{{service_username}}', + 'Mật khẩu' => '{{service_password}}', + ]) + . T::heading('Thông tin truy cập FTP') + . T::infoTable([ + 'FTP Host' => '{{service_host}}', + 'Tài khoản FTP' => '{{ftp_account}}', + 'Mật khẩu FTP' => '{{ftp_password}}', + ]) + . T::button('{{login_url}}', 'QUẢN LÝ DỊCH VỤ') + . T::note('Vui lòng lưu thông tin truy cập ở nơi an toàn và không chia sẻ cho người khác.') + . T::supportLine(), + ], + [ + 'scenario' => 'hosting_account_created', + 'name' => 'Hosting đã khởi tạo', + 'subject' => 'Hosting của bạn đã sẵn sàng — {{domain_name}}', + 'body' => T::title('Hosting đã sẵn sàng') + . T::greeting('{{full_name}}') + . T::paragraph('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:') + . T::infoTable([ + 'Tên miền' => '{{domain_name}}', + 'Gói hosting' => '{{hosting_plan}}', + ], '{{server_host_row}}{{ftp_username_row}}{{ftp_password_row}}' . T::row('Trạng thái', self::ACTIVE)) + . '{{ftp_password_notice}}' + . '{{plesk_block}}' + . T::button('{{login_url}}', 'ĐĂNG NHẬP NGAY') + . T::supportLine(), + ], + [ + 'scenario' => 'vm_suspended', + 'name' => 'VPS bị tạm ngưng', + 'subject' => '[Thế Giới Số] Dịch vụ Cloud VPS của bạn đã bị tạm ngưng', + 'body' => T::title('Dịch vụ Cloud VPS đã bị tạm ngưng') + . T::greeting('{{customer_name}}') + . T::paragraph('Dịch vụ Cloud VPS của Quý khách hiện đã bị tạm ngưng (Suspend) do chưa thanh toán gia hạn. Vui lòng thanh toán sớm để dịch vụ được kích hoạt lại.') + . T::infoTable([ + 'Tên dịch vụ' => '{{vm_name}}', + 'VM ID' => '{{vmware_id}}', + 'Thời gian tạm ngưng' => '{{suspended_at}}', + ]) + . T::note('Sau 30 ngày kể từ ngày tạm ngưng, nếu không thanh toán, dịch vụ sẽ bị xoá vĩnh viễn và dữ liệu không thể khôi phục.', 'Quan trọng:') + . T::button('{{login_url}}', 'THANH TOÁN NGAY') + . T::supportLine(), + ], + [ + 'scenario' => 'service_expiry', + 'name' => 'Dịch vụ sắp hết hạn', + 'subject' => 'Thông báo dịch vụ {{product_name}} sắp hết hạn', + 'body' => T::title('Dịch vụ sắp hết hạn') + . T::greeting('Quý khách') + . T::paragraph('{{company_name}} xin thông báo dịch vụ của Quý khách sắp hết hạn sử dụng:') + . T::infoTable([ + 'Dịch vụ' => '{{product_name}}', + 'Ngày hết hạn' => '{{service_due_exp}}', + 'Thời hạn' => 'Còn {{service_remaining}} ngày', + ]) + . T::paragraph('Vui lòng gia hạn trước ngày hết hạn để dịch vụ không bị gián đoạn.') + . T::button('{{login_url}}', 'GIA HẠN NGAY') + . T::supportLine(), + ], + [ + 'scenario' => 'service_renewal_reminder', + 'name' => 'Nhắc gia hạn dịch vụ', + 'subject' => 'Nhắc gia hạn dịch vụ {{product_name}}', + 'body' => T::title('Nhắc gia hạn dịch vụ') + . T::greeting('{{client_name}}') + . T::paragraph('Dịch vụ {{product_name}}{{server_name_suffix}} của Quý khách 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ụ.') + . T::infoTable([ + 'Dịch vụ' => '{{product_name}}', + 'Ngày hết hạn' => '{{expires_at}}', + ], '{{price_row}}') + . T::button('{{login_url}}', 'GIA HẠN NGAY') + . T::supportLine(), + ], + [ + 'scenario' => 'vm_creation_failed', + 'name' => 'Khởi tạo VPS thất bại', + 'subject' => 'Thông báo: Không thể khởi tạo máy chủ ảo - Đơn hàng #{{order_num}}', + 'body' => T::title('Thông báo về đơn hàng #{{order_num}}') + . T::subtitle('Đội ngũ kỹ thuật đang xử lý yêu cầu của Quý khách') + . T::greeting('{{full_name}}') + . T::paragraph('Hệ thống chưa khởi tạo được dịch vụ cho đơn hàng của Quý khách. Đội ngũ kỹ thuật của {{app_name}} đã được thông báo và đang hỗ trợ nhanh nhất có thể.') + . T::infoTable([ + 'Mã đơn hàng' => '#{{order_num}}', + 'Sản phẩm' => '{{product_name}}', + ]) + . T::paragraph('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 dịch vụ được kích hoạt thành công.') + . T::button('{{invoice_url}}', 'XEM ĐƠN HÀNG') + . T::supportLine(), + ], + [ + 'scenario' => 'vm_rebuild_failed', + 'name' => 'Rebuild VPS thất bại', + 'subject' => 'Thông báo: Cài lại hệ điều hành thất bại - {{vm_name}}', + 'body' => T::title('Cài lại hệ điều hành chưa thành công') + . T::subtitle('Đội ngũ kỹ thuật đang xử lý yêu cầu của Quý khách') + . T::greeting('{{full_name}}') + . T::paragraph('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 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ý.') + . T::infoTable([ + 'Tên VPS' => '{{vm_name}}', + 'IP' => '{{ip}}', + ]) + . T::paragraph('VPS của Quý khách vẫn hoạt động bình thường trên cấu hình cũ, không có dữ liệu nào bị ảnh hưởng.') + . T::button('{{service_url}}', 'XEM DỊCH VỤ') + . T::supportLine(), + ], + [ + 'scenario' => 'vm_password_changed', + 'name' => 'Đổi mật khẩu VPS', + 'subject' => 'Mật khẩu VPS của bạn đã được thay đổi — {{app_name}}', + 'body' => T::title('Mật khẩu VPS đã được thay đổi') + . T::greeting('{{full_name}}') + . T::paragraph('Mật khẩu đăng nhập máy chủ ảo của Quý khách tại {{app_name}} đã được thay đổi thành công.') + . T::infoTable(['Máy chủ' => '{{vm_name}}'], '{{new_password_row}}' . T::row('Trạng thái', 'Đổi mật khẩu thành công')) + . T::button('{{login_url}}', 'QUẢN LÝ DỊCH VỤ') + . T::note('Nếu Quý khách không thực hiện thao tác này, vui lòng liên hệ ngay support@tgs.com.vn hoặc (028) 7309. 7379.'), + ], + [ + 'scenario' => 'snapshot_action', + 'name' => 'Thao tác Snapshot VPS', + 'subject' => '{{action_title}} — {{app_name}}', + 'body' => T::title('{{action_icon}} {{action_title}}') + . T::subtitle('{{action_desc}}') + . T::greeting('{{full_name}}') + . T::paragraph('Thao tác snapshot máy chủ ảo của Quý khách tại {{app_name}} đã hoàn tất.') + . T::infoTable([ + 'Máy chủ' => '{{vm_name}}', + 'Snapshot' => '{{snapshot_name}}', + 'Thời gian' => '{{action_at}}', + 'Trạng thái' => '{{action_status}}', + ]) + . T::button('{{login_url}}', 'QUẢN LÝ DỊCH VỤ') + . T::supportLine(), + ], + ]; + } +} diff --git a/database/seeders/EmailTemplates/TemplateVariables.php b/database/seeders/EmailTemplates/TemplateVariables.php new file mode 100644 index 0000000..f50536c --- /dev/null +++ b/database/seeders/EmailTemplates/TemplateVariables.php @@ -0,0 +1,131 @@ + key => [label, type, example] + */ + public const DEFINITIONS = [ + 'app_name' => ['Tên công ty', 'string', 'Thế Giới Số'], + 'company_name' => ['Tên công ty', 'string', 'Thế Giới Số'], + 'login_url' => ['Link đăng nhập', 'url', 'https://tgs.com.vn/dang-nhap'], + + 'full_name' => ['Tên khách hàng', 'string', 'Nguyễn Văn A'], + 'client_name' => ['Tên khách hàng', 'string', 'Nguyễn Văn A'], + 'customer_name' => ['Tên khách hàng', 'string', 'Nguyễn Văn A'], + 'customer_email' => ['Email khách hàng', 'string', 'khachhang@example.com'], + 'customer_phone' => ['Số điện thoại khách hàng', 'string', '0901 234 567'], + 'email' => ['Email đăng nhập', 'string', 'khachhang@example.com'], + + 'verify_code' => ['Mã xác thực', 'string', '482913'], + 'reset_url' => ['Link đặt lại mật khẩu', 'url', 'https://tgs.com.vn/dat-lai-mat-khau?token=abc'], + 'expire_minutes' => ['Số phút hiệu lực', 'string', '60'], + 'password' => ['Mật khẩu mới', 'string', 'Xk9#pL2m'], + + 'product_name' => ['Tên dịch vụ', 'string', 'Cloud VPS Pro'], + 'product_billing' => ['Chu kỳ thanh toán', 'string', '12 tháng'], + 'product_price' => ['Giá dịch vụ', 'string', '1.200.000'], + 'product_billing_row' => ['[Tự động] Dòng chu kỳ thanh toán', 'html', ''], + 'product_price_row' => ['[Tự động] Dòng giá dịch vụ', 'html', ''], + 'os_name_row' => ['[Tự động] Dòng hệ điều hành', 'html', ''], + 'service_host' => ['Địa chỉ IP / Host', 'string', '103.0.0.10'], + 'service_username' => ['Tài khoản đăng nhập', 'string', 'root'], + 'service_password' => ['Mật khẩu dịch vụ', 'string', 'Xk9#pL2m'], + 'service_port_row' => ['[Tự động] Dòng cổng kết nối', 'html', ''], + 'service_due_exp_row' => ['[Tự động] Dòng hạn sử dụng', 'html', ''], + 'service_due_exp' => ['Ngày hết hạn', 'string', '31/12/2026'], + 'service_remaining' => ['Số ngày còn lại', 'string', '7'], + 'ftp_account' => ['Tài khoản FTP', 'string', 'ftp_example'], + 'ftp_password' => ['Mật khẩu FTP', 'string', 'Ftp#2026'], + 'vm_name' => ['Tên VPS', 'string', 'VPS-Pro-01'], + 'vmware_id' => ['VM ID', 'string', 'vm-1024'], + 'suspended_at' => ['Thời gian tạm ngưng', 'string', '08/10/2026 09:00'], + 'server_name_suffix' => ['[Tự động] Tên máy chủ kèm theo', 'html', ''], + 'expires_at' => ['Ngày hết hạn', 'string', '31/12/2026'], + 'price_row' => ['[Tự động] Dòng số tiền gia hạn', 'html', ''], + 'invoice_url' => ['Link đơn hàng', 'url', 'https://tgs.com.vn/don-hang'], + 'ip' => ['Địa chỉ IP', 'string', '103.0.0.10'], + 'service_url' => ['Link dịch vụ', 'url', 'https://tgs.com.vn/dich-vu'], + 'new_password_row' => ['[Tự động] Dòng mật khẩu mới', 'html', ''], + 'snapshot_name' => ['Tên snapshot', 'string', 'backup-2026-10-08'], + 'action_at' => ['Thời gian thực hiện', 'string', '08/10/2026 09:00:00'], + 'action_icon' => ['Biểu tượng thao tác', 'string', '📸'], + 'action_title' => ['Tiêu đề thao tác', 'string', 'Tạo snapshot thành công'], + 'action_desc' => ['Mô tả thao tác', 'string', 'Snapshot máy chủ ảo đã được tạo'], + 'action_status' => ['Trạng thái thao tác', 'string', 'Thành công'], + + 'domain_name' => ['Tên miền', 'string', 'example.vn'], + 'hosting_plan' => ['Gói hosting', 'string', 'Premium Hosting 1'], + 'server_host_row' => ['[Tự động] Dòng máy chủ / FTP host', 'html', ''], + 'ftp_username_row' => ['[Tự động] Dòng tài khoản FTP', 'html', ''], + 'ftp_password_row' => ['[Tự động] Dòng mật khẩu FTP', 'html', ''], + 'ftp_password_notice' => ['[Tự động] Lưu ý mật khẩu FTP', 'html', ''], + 'plesk_block' => ['[Tự động] Khối tài khoản Plesk', 'html', ''], + 'server_name' => ['Tên máy chủ', 'string', 'plesk-01'], + 'server_ip' => ['IP máy chủ', 'string', '103.0.0.20'], + 'plesk_client_id' => ['Plesk Client ID', 'string', '15'], + 'plesk_subscription_id' => ['Plesk Subscription ID', 'string', '42'], + 'ftp_username' => ['Tài khoản FTP', 'string', 'ftp_example'], + 'plesk_panel_url' => ['Địa chỉ Plesk Panel', 'string', 'https://103.0.0.20:8443'], + 'plesk_login' => ['Tài khoản Plesk', 'string', 'example_vn'], + 'plesk_password_row' => ['[Tự động] Dòng mật khẩu Plesk', 'html', ''], + 'created_at' => ['Thời gian tạo', 'string', '08/10/2026 09:00:00'], + + 'title' => ['Tiêu đề thông báo', 'string', 'Thông báo hóa đơn'], + 'body_message' => ['Lời nhắn', 'string', 'Vui lòng xem thông tin hóa đơn của bạn.'], + 'invoice_number' => ['Số hoá đơn', 'string', 'INV-2026-0001'], + 'created_date' => ['Ngày lập', 'string', '01/10/2026'], + 'due_date' => ['Hạn thanh toán', 'string', '15/10/2026'], + 'total' => ['Tổng tiền', 'string', '1.200.000'], + 'status_label' => ['[Tự động] Trạng thái hoá đơn', 'html', ''], + 'outstanding_row' => ['[Tự động] Dòng đã trả / còn thiếu', 'html', ''], + 'intro_note' => ['Ghi chú đính kèm', 'string', 'File PDF hóa đơn đã được đính kèm trong email này.'], + 'pay_button' => ['[Tự động] Nút thanh toán', 'html', ''], + 'attachments_block' => ['[Tự động] Danh sách file đính kèm', 'html', ''], + 'note_block' => ['[Tự động] Ghi chú hoá đơn', 'html', ''], + 'outstanding' => ['Số tiền còn phải trả', 'string', '1.200.000'], + 'status_line' => ['Tình trạng hạn thanh toán', 'string', 'Hoá đơn sẽ đến hạn sau 3 ngày.'], + 'paid_row' => ['[Tự động] Dòng đã thanh toán', 'html', ''], + 'transaction_code' => ['Mã giao dịch', 'string', 'NAP20261008001'], + 'amount_formatted' => ['Số tiền', 'string', '500,000'], + 'credit_formatted' => ['Tiền khuyến mãi', 'string', '0'], + 'current_total_formatted' => ['Số dư hiện tại', 'string', '1,500,000'], + 'current_credit_formatted' => ['Số dư khuyến mãi hiện tại', 'string', '0'], + 'deposit_date' => ['Thời gian nạp', 'string', '08/10/2026 09:00:00'], + 'status_color' => ['Màu chữ trạng thái', 'string', '#16a34a'], + 'status_bg' => ['Màu nền trạng thái', 'string', '#dcfce7'], + 'status_message' => ['Nội dung trạng thái', 'string', 'Yêu cầu nạp tiền của bạn đã được duyệt.'], + 'processed_date_row' => ['[Tự động] Dòng thời gian xử lý', 'html', ''], + 'portal_url' => ['Link trang nạp tiền', 'url', 'https://tgs.com.vn/nap-tien'], + + 'order_num' => ['Mã đơn hàng', 'string', 'DH20261008001'], + 'invoice_num' => ['Mã hoá đơn', 'string', 'INV-2026-0001'], + 'order_total' => ['Tổng tiền đơn hàng', 'string', '1.200.000'], + 'order_url' => ['Link đơn hàng', 'url', 'https://tgs.com.vn/don-hang'], + 'reason' => ['Lý do', 'string', 'Tên miền đã có người đăng ký'], + 'admin_url' => ['Link quản trị', 'url', 'https://admin.tgs.com.vn'], + 'client_ip_row' => ['[Tự động] Dòng IP khách', 'html', ''], + 'product_type' => ['Loại sản phẩm', 'string', 'Cloud VPS'], + 'billing_cycle' => ['Chu kỳ thanh toán', 'string', '12 tháng'], + 'total_formatted' => ['Tổng tiền', 'string', '1.200.000 VNĐ'], + 'vm_block' => ['[Tự động] Khối thông tin VPS', 'html', ''], + + 'ticket_num' => ['Mã ticket', 'string', 'TK1024'], + 'subject' => ['Tiêu đề ticket', 'string', 'Không truy cập được VPS'], + 'room_row' => ['[Tự động] Dòng phòng ban', 'html', ''], + 'ticket_url' => ['Link ticket', 'url', 'https://tgs.com.vn/tickets/TK1024'], + 'subject_text' => ['Tiêu đề ticket', 'string', 'Không truy cập được VPS'], + 'room' => ['Phòng ban', 'string', 'Kỹ thuật'], + 'description' => ['Nội dung ticket', 'string', 'VPS không SSH được từ sáng nay.'], + + 'customer_phone_row' => ['[Tự động] Dòng số điện thoại', 'html', ''], + 'proof_row' => ['[Tự động] Dòng chứng từ', 'html', ''], + 'cpu' => ['CPU', 'string', '2 vCPU'], + 'ram' => ['RAM', 'string', '4 GB'], + 'ssd' => ['SSD', 'string', '60 GB'], + 'os' => ['Hệ điều hành', 'string', 'Ubuntu 24.04'], + 'other_row' => ['[Tự động] Dòng yêu cầu khác', 'html', ''], + ]; +} diff --git a/database/seeders/LegacyProductCatalogSeeder.php b/database/seeders/LegacyProductCatalogSeeder.php new file mode 100644 index 0000000..485261c --- /dev/null +++ b/database/seeders/LegacyProductCatalogSeeder.php @@ -0,0 +1,97 @@ + [ + 'category' => ['name' => 'VPS', 'slug' => 'vps', 'type' => 'vps', 'sort_order' => 10], + 'product_type' => 'vps', + 'ids' => [1, 2, 3, 4, 22, 23, 24, 25, 26, 28, 34, 35, 36, 37, 38, 71, 82, 84, 85, 86, 87, 88, 89, 90], + ], + 'hosting' => [ + 'category' => ['name' => 'Hosting', 'slug' => 'hosting', 'type' => 'hosting', 'sort_order' => 20], + 'product_type' => 'hosting', + 'ids' => [5, 6, 7, 17, 18, 19, 20, 46, 47, 72, 73, 74, 75, 77, 83], + ], + 'cloud-storage' => [ + 'category' => ['name' => 'Cloud Storage', 'slug' => 'cloud-storage', 'type' => 'cloud', 'sort_order' => 30], + 'product_type' => 'cloud', + 'ids' => [39, 40, 41, 42, 43, 57, 58, 59, 78], + ], + 'email' => [ + 'category' => ['name' => 'Email', 'slug' => 'email', 'type' => 'email', 'sort_order' => 40], + 'product_type' => 'email', + 'ids' => [16, 52, 53, 54, 55, 56, 67, 68, 69, 70, 94, 95, 96, 97], + ], + 'hardware' => [ + 'category' => ['name' => 'Hardware', 'slug' => 'hardware', 'type' => 'hardware', 'sort_order' => 50], + 'product_type' => 'hardware', + 'ids' => [62, 63, 64, 76, 79, 81], + ], + 'web-design' => [ + 'category' => ['name' => 'Thiết kế website', 'slug' => 'thiet-ke-website', 'type' => 'web_design', 'sort_order' => 60], + 'product_type' => 'web_design', + 'ids' => [14], + ], + 'it-support' => [ + 'category' => ['name' => 'IT Support', 'slug' => 'it-support', 'type' => 'it_support', 'sort_order' => 70], + 'product_type' => 'it_support', + 'ids' => [66], + ], + 'other' => [ + 'category' => ['name' => 'Other', 'slug' => 'other', 'type' => null, 'sort_order' => 80], + 'product_type' => 'other', + 'ids' => [8, 9, 10, 11, 12, 13, 15, 21, 27, 29, 30, 31, 32, 33, 44, 45, 48, 49, 50, 51, 60, 61, 65, 80, 91, 92, 93, 98, 99, 100, 101, 102], + ], + ]; + + $allIds = collect($catalog)->pluck('ids')->flatten()->map(fn ($id) => (int) $id); + + if ($allIds->count() !== 102 || $allIds->unique()->count() !== 102) { + throw new RuntimeException('Product catalog mapping must contain exactly 102 unique product IDs.'); + } + + $missing = $allIds->diff(DB::table('products')->whereIn('id', $allIds)->pluck('id')); + if ($missing->isNotEmpty()) { + throw new RuntimeException('Missing products: ' . $missing->implode(', ')); + } + + DB::transaction(function () use ($catalog): void { + foreach ($catalog as $definition) { + $category = $definition['category']; + DB::table('product_categories')->updateOrInsert( + ['slug' => $category['slug']], + $category + [ + 'parent_id' => null, + 'is_active' => true, + 'deleted_at' => null, + 'updated_at' => now(), + 'created_at' => now(), + ], + ); + + $categoryId = DB::table('product_categories') + ->where('slug', $category['slug']) + ->value('id'); + + DB::table('products') + ->whereIn('id', $definition['ids']) + ->update([ + 'product_categories_id' => $categoryId, + 'product_type' => $definition['product_type'], + 'updated_at' => now(), + ]); + } + }); + + $this->command?->info('Categorized all 102 legacy products.'); + } +} diff --git a/database/seeders/TemplateSeeder.php b/database/seeders/TemplateSeeder.php index 8a5b1c1..bcf8744 100644 --- a/database/seeders/TemplateSeeder.php +++ b/database/seeders/TemplateSeeder.php @@ -3,275 +3,96 @@ namespace Database\Seeders; use App\Models\Template; +use App\Support\EmailTemplateShell; +use App\Support\MailPlaceholders; +use Database\Seeders\EmailTemplates\AccountTemplates; +use Database\Seeders\EmailTemplates\AdminTemplates; +use Database\Seeders\EmailTemplates\BillingTemplates; +use Database\Seeders\EmailTemplates\ServiceTemplates; +use Database\Seeders\EmailTemplates\TemplateVariables; use Illuminate\Database\Seeder; class TemplateSeeder extends Seeder { - /** - * Run the database seeds. - * - * @return void - */ - public function run() + public function run(): void { - $Templates = [ - [ - 'name' => 'Client Signup Verify Email', - 'subject' => 'Thông tin kích hoạt tài khoản khách hàng', - '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, - ], - [ - 'name' => 'Server information', - 'subject' => 'Thông tin truy cập VPS {$os_name}', - '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, - ], - [ - 'name' => 'Hosting Information', - 'subject' => 'Thông tin truy cập {$product_name}', - '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, - ], - [ - 'name' => 'Customer Password Reset', - 'subject' => 'Khôi phục mật khẩu | Password Recovery', - '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, - ], - [ - 'name' => 'Order Created', - 'subject' => 'Xác nhận đơn hàng #{$order_num}', - '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, - ], - [ - 'name' => 'Domain Order New', - 'subject' => '[ADMIN] Đơn đăng ký tên miền mới - {$domain_name}', - '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, - ], - [ - '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!

', - 'type_id' => 0, - '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!

', - 'type_id' => 0, - '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.

 
', - 'type_id' => 0, - 'status' => 1, - ], - ]; - - // 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' -
-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, - ], - ]; - - foreach ($newTemplates as $item) { - if (!Template::where('scenario', $item['scenario'])->exists()) { - Template::create($item); - } - } - - foreach ($Templates as $item) { - Template::create($item); + foreach (self::templates() as $item) { + $this->upsert($item); } } + + /** + * @return array}> + */ + public static function templates(): array + { + $layout = [ + 'scenario' => 'layout', + 'name' => 'Bố cục Header/Footer (mặc định)', + 'subject' => '', + 'message' => EmailTemplateShell::page('{{$content}}'), + ]; + + $scenarios = array_map( + fn (array $item) => [...$item, 'message' => EmailTemplateShell::page($item['body'])], + [ + ...AccountTemplates::all(), + ...ServiceTemplates::all(), + ...BillingTemplates::all(), + ...AdminTemplates::all(), + ], + ); + + return [$layout, ...$scenarios]; + } + + private function upsert(array $item): void + { + $scenario = $item['scenario']; + + $template = Template::query() + ->where(fn ($q) => $q->where('key', $scenario)->orWhere('scenario', $scenario)) + ->orderByRaw('`key` = ? desc', [$scenario]) + ->orderBy('id') + ->first() ?? new Template(['type_id' => 0, 'status' => 1]); + + $template->fill([ + 'key' => $scenario, + 'scenario' => $scenario, + 'name' => $item['name'], + 'subject' => $item['subject'], + 'message' => $item['message'], + 'format' => 'html', + ]); + + $template->metadata = [ + ...($template->metadata ?? []), + 'variables' => $this->variables($template, $item['types'] ?? []), + ]; + + $template->save(); + } + + /** + * @param array $typeOverrides + * @return array + */ + private function variables(Template $template, array $typeOverrides): array + { + $existing = $template->variables(); + + return collect(MailPlaceholders::extract($template->subject, $template->message)) + ->map(function (string $key) use ($existing, $typeOverrides) { + [$label, $type, $example] = TemplateVariables::DEFINITIONS[$key] ?? [$key, 'string', '']; + + return [ + 'key' => $key, + 'label' => $existing[$key]['label'] ?? $label, + 'type' => $typeOverrides[$key] ?? $type, + 'example' => $existing[$key]['example'] ?? $example, + ]; + }) + ->values() + ->all(); + } } diff --git a/routes/api/admin/customers.php b/routes/api/admin/customers.php index 5de6dae..d08fdc5 100644 --- a/routes/api/admin/customers.php +++ b/routes/api/admin/customers.php @@ -66,6 +66,7 @@ Route::prefix('customers') // Services (module: customer) Route::prefix('services')->controller(ServiceController::class)->group(function () { Route::get('/', 'index')->middleware('perm:customer.view'); + Route::post('bulk-action', 'bulkAction')->middleware('perm:customer.edit'); Route::get('{id}', 'show')->middleware('perm:customer.view'); Route::patch('{id}/detach-customer', 'detachCustomer')->middleware('perm:customer.edit'); Route::post('{id}/resend-credentials', 'resendCredentials')->middleware('perm:customer.edit'); diff --git a/tests/Feature/Api/Admin/Customer/AssignServiceVmTest.php b/tests/Feature/Api/Admin/Customer/AssignServiceVmTest.php new file mode 100644 index 0000000..b9c72d2 --- /dev/null +++ b/tests/Feature/Api/Admin/Customer/AssignServiceVmTest.php @@ -0,0 +1,80 @@ + 'Full Administrator', 'guard_name' => 'admin']); + $this->admin = User::factory()->create(); + $this->admin->assignRole('Full Administrator'); + } + + private function vm(string $vmId): VirtualMachines + { + Schema::disableForeignKeyConstraints(); + + return VirtualMachines::create([ + 'uuid' => (string) Str::uuid(), + 'vm_id' => $vmId, + 'name' => "name-{$vmId}", + 'cpu' => 2, + 'ram_mb' => 2048, + 'is_template' => 0, + 'is_system' => 0, + 'datacenter_id' => 1, + 'host_id' => 1, + ]); + } + + private function assign(Customer $customer, Service $service, VirtualMachines $vm) + { + return $this->actingAs($this->admin, 'admin')->putJson( + "/api/v1/administrator/customers/{$customer->uuid}/services/{$service->id}", + ['virtual_machine_id' => $vm->id], + ); + } + + public function test_assigns_and_reassigns_vm_and_rejects_vm_of_another_service(): void + { + $customer = Customer::factory()->create(); + $service = Service::create(['customer_id' => $customer->id, 'status' => 'active']); + $first = $this->vm('vm-1'); + $second = $this->vm('vm-2'); + + $this->assign($customer, $service, $first)->assertOk(); + $service->refresh(); + $moduleId = $service->vm_id; + $this->assertNotNull($moduleId); + $this->assertSame($service->id, (int) ModuleVmware::find($moduleId)->service_id); + + $this->assign($customer, $service, $second)->assertOk(); + $service->refresh(); + $this->assertSame($moduleId, $service->vm_id); + $this->assertSame('vm-2', ModuleVmware::find($moduleId)->vmware_id); + + $other = Service::create(['customer_id' => $customer->id, 'status' => 'active']); + $this->assign($customer, $other, $second)->assertStatus(422); + $this->assertNull($other->refresh()->vm_id); + } +} diff --git a/tests/Feature/Api/Service/ConsoleTicketResolvesVmTest.php b/tests/Feature/Api/Service/ConsoleTicketResolvesVmTest.php new file mode 100644 index 0000000..1dae4dc --- /dev/null +++ b/tests/Feature/Api/Service/ConsoleTicketResolvesVmTest.php @@ -0,0 +1,52 @@ +create(); + $order = Order::create(['order_num' => 'ORD-' . Str::random(10), 'customer_id' => $customer->id]); + $orderItem = OrderItem::create(['order_id' => $order->id]); + $vcenter = vCenter::create(['host' => 'vc.test', 'user' => 'u', 'password' => 'p', 'status' => 1]); + $vm = ModuleVmware::create([ + 'name' => 'vm', + 'customer_id' => $customer->id, + 'order_id' => $order->id, + 'vmware_id' => 'vm-1', + 'vcenter_id' => $vcenter->id, + 'status' => 1, + ]); + $service = Service::create([ + 'customer_id' => $customer->id, + 'order_item_id' => $orderItem->id, + 'vm_id' => $vm->id, + 'status' => 'active', + ]); + $order->delete(); + + $this->mock(VCenterService::class)->shouldReceive('loginServer')->once()->andReturnFalse(); + + $this->actingAs($customer, 'client') + ->withHeaders(['x-access-token' => $this->buildClientAccessToken()]) + ->getJson("/v1/client/client-service/{$service->uuid}/console-ticket") + ->assertOk() + ->assertJsonPath('message', 'Không thể đăng nhập vào vCenter'); + } +} diff --git a/tests/Feature/Console/InvoiceReminderTest.php b/tests/Feature/Console/InvoiceReminderTest.php index 6f17725..c58f9ef 100644 --- a/tests/Feature/Console/InvoiceReminderTest.php +++ b/tests/Feature/Console/InvoiceReminderTest.php @@ -9,8 +9,11 @@ use App\Enums\Order\OrderStatus; use App\Models\Customer; use App\Models\Invoice; use App\Models\Order; +use App\Jobs\Mail\SendTemplateEmailJob; use App\Services\InvoiceReminderService; +use App\Support\AutoEmailGate; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Queue; use Tests\TestCase; @@ -25,6 +28,32 @@ class InvoiceReminderTest extends TestCase // không phụ thuộc template có trong DB hay không. Queue::fake(); config(['billing.reminder_offsets' => [-7, -3, 0, 3]]); + $this->setAutoDisabled([]); + } + + private function setAutoDisabled(array $scenarios): void + { + DB::table('setting')->updateOrInsert(['key' => 'email_settings'], ['value' => json_encode(['auto_disabled' => $scenarios])]); + } + + public function test_disabled_automatic_reminder_is_not_sent_and_step_stays(): void + { + $this->setAutoDisabled([InvoiceReminderService::SCENARIO]); + $invoice = $this->makeInvoice(now()->addDays(5)); + + $this->artisan('invoices:send-reminders')->assertExitCode(0); + + $this->assertSame(0, (int) $invoice->fresh()->reminder_step); + Queue::assertNotPushed(SendTemplateEmailJob::class); + } + + public function test_manual_reminder_is_sent_while_automatic_is_disabled(): void + { + $this->setAutoDisabled([InvoiceReminderService::SCENARIO]); + $invoice = $this->makeInvoice(now()->addDays(5)); + AutoEmailGate::markManual(); + + $this->assertTrue(app(InvoiceReminderService::class)->remind($invoice, force: true)); } public function test_no_reminder_is_sent_before_the_first_offset(): void diff --git a/tests/Feature/Console/PostWhmcsSyncSafetyTest.php b/tests/Feature/Console/PostWhmcsSyncSafetyTest.php index e604e8b..f2f8386 100644 --- a/tests/Feature/Console/PostWhmcsSyncSafetyTest.php +++ b/tests/Feature/Console/PostWhmcsSyncSafetyTest.php @@ -15,6 +15,7 @@ use App\Models\Service; use App\Repositories\Api\Order\OrderRepository; use App\Services\InvoiceReminderService; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Queue; use Tests\TestCase; @@ -31,6 +32,7 @@ class PostWhmcsSyncSafetyTest extends TestCase parent::setUp(); Queue::fake(); config(['billing.reminder_offsets' => [-7, -3, 0, 3, 7]]); + DB::table('setting')->updateOrInsert(['key' => 'email_settings'], ['value' => json_encode(['auto_disabled' => []])]); } /** Hoá đơn quá hạn nhiều năm KHÔNG được mở lại chuỗi nhắc nợ. */ diff --git a/tests/Feature/MailEnabledToggleTest.php b/tests/Feature/MailEnabledToggleTest.php new file mode 100644 index 0000000..6a87a61 --- /dev/null +++ b/tests/Feature/MailEnabledToggleTest.php @@ -0,0 +1,75 @@ + 'email_settings']); + $current = $setting->value ? json_decode($setting->value, true) : []; + $setting->value = json_encode(array_merge($current, ['mail_enabled' => $enabled])); + $setting->save(); + } + + private function disableAuto(array $scenarios): void + { + $setting = Setting::firstOrNew(['key' => 'email_settings']); + $current = $setting->value ? json_decode($setting->value, true) : []; + $setting->value = json_encode(array_merge($current, ['mail_enabled' => true, 'auto_disabled' => $scenarios])); + $setting->save(); + } + + private function send(?string $scenario = null): mixed + { + return Mail::to('user@example.com')->send(new GenericTemplateMail('Subject', '

Body

', [], $scenario)); + } + + public function test_mail_is_blocked_when_disabled(): void + { + $this->setMailEnabled(false); + + $this->assertNull($this->send()); + $this->assertCount(0, Mail::mailer()->getSymfonyTransport()->messages()); + } + + public function test_mail_is_sent_when_enabled(): void + { + $this->setMailEnabled(true); + + $this->assertNotNull($this->send()); + $this->assertCount(1, Mail::mailer()->getSymfonyTransport()->messages()); + } + + public function test_disabled_scenario_is_blocked_but_others_are_sent(): void + { + $this->disableAuto(['invoice_payment_reminder']); + + $this->assertNull($this->send('invoice_payment_reminder')); + $this->assertNotNull($this->send('order_created')); + $this->assertNotNull($this->send()); + } + + public function test_manual_send_bypasses_disabled_scenario(): void + { + $this->disableAuto(['invoice_payment_reminder']); + AutoEmailGate::markManual(); + + $this->assertNotNull($this->send('invoice_payment_reminder')); + } + + public function test_template_mailable_exposes_its_scenario(): void + { + $this->assertSame('service_expiry', (new ExpiryService(0, []))->buildViewData()[AutoEmailGate::SCENARIO_DATA_KEY]); + } +} diff --git a/tests/Feature/TemplateSeederTest.php b/tests/Feature/TemplateSeederTest.php new file mode 100644 index 0000000..80d8435 --- /dev/null +++ b/tests/Feature/TemplateSeederTest.php @@ -0,0 +1,64 @@ +keyBy('scenario'); + + $this->assertEqualsCanonicalizing(array_keys(EmailScenarioRegistry::SCENARIOS), $templates->keys()->all()); + + foreach (EmailScenarioRegistry::SCENARIOS as $scenario => $definition) { + $item = $templates[$scenario]; + $this->assertSame([], EmailScenarioRegistry::missingVariables($definition['required_variables'], $item['subject'], $item['message']), $scenario); + $this->assertStringContainsString('class="NoiDungKM"', $item['message'], $scenario); + $this->assertStringContainsString('class="Footer"', $item['message'], $scenario); + } + } + + public function test_every_placeholder_has_a_variable_definition(): void + { + foreach (TemplateSeeder::templates() as $item) { + $unknown = array_diff(MailPlaceholders::extract($item['subject'], $item['message']), array_keys(TemplateVariables::DEFINITIONS)); + $this->assertSame([], array_values($unknown), $item['scenario']); + } + } + + public function test_seeded_templates_render_and_rerun_updates_in_place(): void + { + $this->seed(TemplateSeeder::class); + $countAfterFirstRun = Template::count(); + $this->seed(TemplateSeeder::class); + $this->assertSame($countAfterFirstRun, Template::count()); + + $service = app(EmailTemplateService::class); + $renderer = app(EmailTemplateRenderer::class); + + foreach (array_keys(EmailScenarioRegistry::SCENARIOS) as $scenario) { + if ($scenario === 'layout') { + continue; + } + $template = $service->loadTemplate($scenario); + $rendered = $renderer->render($template, []); + + $this->assertDoesNotMatchRegularExpression(MailPlaceholders::PATTERN, $rendered['subject'] . $rendered['html'], $scenario); + } + + $this->assertSame('html', $service->loadTemplate('invoice_notification')->variables()['pay_button']['type']); + $this->assertSame('string', $service->loadTemplate('manual_deposit_status')->variables()['status_label']['type']); + } +}
-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}.