diff --git a/.DS_Store b/.DS_Store index 9151e7d..a6ef1b5 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/.phpunit.cache/test-results b/.phpunit.cache/test-results index e9f9305..fa8cdc2 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},"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.012,"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.002,"Tests\\Unit\\Models\\ReinstallLogPlainPasswordTest::test_backfill_migration_is_idempotent":0.001,"Tests\\Unit\\ServiceFillableTest::test_service_ignores_fields_not_declared_in_fillable":0.039,"Tests\\Unit\\Services\\VCenter\\PowerShellServiceSecretLoggingTest::test_change_ip_vm_linux_never_logs_the_guest_password":0.004,"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.032,"Tests\\Feature\\Api\\Admin\\Customer\\CustomerMassAssignmentTest::test_update_ignores_fields_not_declared_in_validation_rules":0.009,"Tests\\Feature\\Api\\Admin\\Customer\\ResetPasswordTest::test_reset_password_sends_email_and_does_not_leak_password_in_response":0.018,"Tests\\Feature\\Api\\Admin\\Employee\\EmployeeRoleGuardTest::test_non_full_administrator_cannot_edit_a_full_administrator":0.01,"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.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.007,"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.015,"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.019,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"activity-logs index\"":0.018,"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.016,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"posts index\"":0.014,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"media index\"":0.086,"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.017,"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.018,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_full_administrator_bypasses_all_permission_gates":0.043,"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.449,"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.068,"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.012,"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.009,"Tests\\Feature\\Api\\Client\\SecurityHardeningTest::test_cccd_upload_rejects_svg":0.01,"Tests\\Feature\\Api\\DomainOrder\\AdminDomainOrderTest::test_admin_can_list_domain_orders":0.012,"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.014,"Tests\\Feature\\Api\\DomainOrder\\AdminDomainOrderTest::test_cannot_confirm_already_confirmed_order":0.011,"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.012,"Tests\\Feature\\Api\\DomainOrder\\AdminDomainOrderTest::test_activate_transitions_from_pending_kyc_to_active":0.019,"Tests\\Feature\\Api\\DomainOrder\\AdminDomainOrderTest::test_cannot_activate_pending_order_directly":0.011,"Tests\\Feature\\Api\\DomainOrder\\AdminDomainOrderTest::test_reject_and_refund_restores_customer_balance":0.024,"Tests\\Feature\\Api\\DomainOrder\\AdminDomainOrderTest::test_reject_and_refund_is_idempotent":0.03,"Tests\\Feature\\Api\\DomainOrder\\AdminDomainOrderTest::test_cannot_reject_non_pending_order":0.012,"Tests\\Feature\\Api\\DomainOrder\\AdminDomainOrderTest::test_refund_uses_invoice_amount_not_current_pricing":0.021,"Tests\\Feature\\Api\\DomainOrder\\AdminDomainOrderTest::test_transaction_rollback_on_error":0.01,"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.017,"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.009,"Tests\\Feature\\Api\\Service\\OrderErrorMessageTest::test_pay_order_keeps_business_message_for_invalid_argument":0.009,"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.007,"Tests\\Feature\\Api\\Service\\RenewServiceTest::test_renew_charges_the_recurring_addon_price_on_top_of_the_service_price":0.017,"Tests\\Feature\\Api\\Service\\RenewServiceTest::test_renew_uses_current_catalog_price_not_the_original_order_price":0.009,"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.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.018,"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.01,"Tests\\Feature\\Api\\Service\\StoreOrderAddonTest::test_inactive_addon_is_rejected":0.005,"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.014,"Tests\\Feature\\Api\\Service\\VmChangeIpTest::test_change_ip_outside_pool_returns_422":0.037,"Tests\\Feature\\Api\\Service\\VmChangeIpTest::test_change_ip_already_in_use_returns_422":0.019,"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.018,"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.013,"Tests\\Feature\\Api\\Service\\VmUpgradeAddonsTest::test_qty_over_max_is_rejected":0.008,"Tests\\Feature\\Api\\Service\\VmUpgradeAddonsTest::test_paying_the_invoice_dispatches_upgrade_job_and_updates_addons_meta":0.025,"Tests\\Feature\\Api\\Transaction\\ManualDepositProofTest::test_confirm_rejects_proof_path_belonging_to_another_customer":0.01,"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.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.009,"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.008,"Tests\\Feature\\Console\\PostWhmcsSyncSafetyTest::test_manual_reminder_still_works_on_an_old_invoice":0.005,"Tests\\Feature\\Console\\PostWhmcsSyncSafetyTest::test_legacy_pending_order_does_not_block_new_orders":0.004,"Tests\\Feature\\Console\\PostWhmcsSyncSafetyTest::test_local_pending_order_still_blocks_new_orders":0.007,"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.016,"Tests\\Feature\\Console\\ServiceExpiryTest::test_cancelled_service_does_not_get_a_renewal_invoice":0.015,"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.018,"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.015,"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.004,"Tests\\Feature\\EmailTemplateServiceTest::test_job_falls_back_to_system_default_sender_when_template_has_no_mail_config":0.002,"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.01,"Tests\\Feature\\Payment\\AdminTransactionApproveTest::test_approving_credits_the_received_amount_not_the_declared_one":0.013,"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.017,"Tests\\Feature\\Payment\\InvoicePaymentServiceTest::test_a_cancelled_invoice_cannot_be_paid_and_is_never_counted_as_paid":0.006,"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.015,"Tests\\Feature\\Payment\\InvoicePaymentServiceTest::test_paying_the_remainder_settles_the_invoice":0.021,"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.02,"Tests\\Feature\\Api\\Client\\ClientAuthSecurityTest::test_concurrent_refresh_within_grace_window_does_not_revoke_sessions":0.016,"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.107,"Tests\\Feature\\Console\\ReencryptSecretsTest::test_dry_run_changes_nothing_and_undecryptable_rows_fail_the_command":0.069,"Tests\\Unit\\Support\\SshHostKeyVerifierTest::test_missing_known_hosts_is_rejected_while_tls_verification_is_on":0.01,"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}} \ 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},"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.005,"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.013,"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.044,"Tests\\Unit\\Services\\VCenter\\PowerShellServiceSecretLoggingTest::test_change_ip_vm_linux_never_logs_the_guest_password":0.004,"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.01,"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}} \ No newline at end of file diff --git a/app/Http/Controllers/Api/Admin/Dashboard/DashboardController.php b/app/Http/Controllers/Api/Admin/Dashboard/DashboardController.php index c4b14bf..194502c 100644 --- a/app/Http/Controllers/Api/Admin/Dashboard/DashboardController.php +++ b/app/Http/Controllers/Api/Admin/Dashboard/DashboardController.php @@ -53,28 +53,44 @@ class DashboardController extends Controller return round((($current - $previous) / $previous) * 100, 2); } - public function stats(): JsonResponse + /** + * @return array{0: Carbon, 1: Carbon, 2: Carbon, 3: Carbon} + */ + private function periodFromRequest(Request $request): array + { + $request->validate([ + 'from' => ['nullable', 'date_format:Y-m-d', 'required_with:to'], + 'to' => ['nullable', 'date_format:Y-m-d', 'required_with:from', 'after_or_equal:from'], + ]); + + $start = $request->date('from', 'Y-m-d')?->startOfDay() ?? now()->startOfMonth(); + $end = $request->date('to', 'Y-m-d')?->endOfDay() ?? now(); + + $days = (int) $start->diffInDays($end->copy()->startOfDay()) + 1; + + return [$start, $end, $start->copy()->subDays($days), $start->copy()->subSecond()]; + } + + public function stats(Request $request): JsonResponse { $now = now(); - $startOfMonth = $now->copy()->startOfMonth(); - $startOfPrevMonth = $now->copy()->subMonthNoOverflow()->startOfMonth(); - $endOfPrevMonth = $now->copy()->subMonthNoOverflow()->endOfMonth(); + [$start, $end, $prevStart, $prevEnd] = $this->periodFromRequest($request); // Customers $totalCustomers = Customer::count(); - $newThisMonth = Customer::where('created_at', '>=', $startOfMonth)->count(); - $newPrevMonth = Customer::whereBetween('created_at', [$startOfPrevMonth, $endOfPrevMonth])->count(); + $newInPeriod = Customer::whereBetween('created_at', [$start, $end])->count(); + $newPrevPeriod = Customer::whereBetween('created_at', [$prevStart, $prevEnd])->count(); // Deposits (transactions completed, type ∈ DEPOSIT_TYPES) $depositBase = Transaction::whereIn('type', self::DEPOSIT_TYPES) ->where('status', TransactionStatus::COMPLETED->value); $totalDeposits = (float) (clone $depositBase)->sum('amount'); - $depositsThisMonth = (float) (clone $depositBase) - ->where('created_at', '>=', $startOfMonth) + $depositsInPeriod = (float) (clone $depositBase) + ->whereBetween('created_at', [$start, $end]) ->sum('amount'); - $depositsPrevMonth = (float) (clone $depositBase) - ->whereBetween('created_at', [$startOfPrevMonth, $endOfPrevMonth]) + $depositsPrevPeriod = (float) (clone $depositBase) + ->whereBetween('created_at', [$prevStart, $prevEnd]) ->sum('amount'); // Số dư ví khách hàng (cột customers.total) @@ -88,29 +104,29 @@ class DashboardController extends Controller ->count(); // Orders - $totalOrdersThisMonth = Order::where('created_at', '>=', $startOfMonth)->count(); - $totalOrdersPrevMonth = Order::whereBetween('created_at', [$startOfPrevMonth, $endOfPrevMonth])->count(); + $ordersInPeriod = Order::whereBetween('created_at', [$start, $end])->count(); + $ordersPrevPeriod = Order::whereBetween('created_at', [$prevStart, $prevEnd])->count(); $pendingOrders = Order::where('status', OrderStatus::PENDING->value)->count(); return response()->json([ 'error' => false, 'data' => [ 'total_customers' => $totalCustomers, - 'new_customers_this_month' => $newThisMonth, - 'new_customers_delta_pct' => $this->deltaPct($newThisMonth, $newPrevMonth), + 'new_customers_in_period' => $newInPeriod, + 'new_customers_delta_pct' => $this->deltaPct($newInPeriod, $newPrevPeriod), 'total_deposits' => $totalDeposits, - 'deposits_this_month' => $depositsThisMonth, - 'deposits_delta_pct' => $this->deltaPct($depositsThisMonth, $depositsPrevMonth), + 'deposits_in_period' => $depositsInPeriod, + 'deposits_delta_pct' => $this->deltaPct($depositsInPeriod, $depositsPrevPeriod), 'total_balance' => $totalBalance, 'total_active_services' => $totalActiveServices, 'expiring_services_7d' => $expiring7d, - 'total_orders_this_month' => $totalOrdersThisMonth, + 'orders_in_period' => $ordersInPeriod, 'pending_orders' => $pendingOrders, - 'orders_delta_pct' => $this->deltaPct($totalOrdersThisMonth, $totalOrdersPrevMonth), + 'orders_delta_pct' => $this->deltaPct($ordersInPeriod, $ordersPrevPeriod), ], ]); } @@ -192,7 +208,7 @@ class DashboardController extends Controller $limit = max(1, min(50, $limit)); $orders = Order::with([ - 'customer:id,fullname,email', + 'customer:id,uuid,fullname,email', 'orderItems:id,order_id,product_id', 'orderItems.product:id,name', 'invoice:id,order_id,status', @@ -203,16 +219,18 @@ class DashboardController extends Controller $items = $orders->map(function (Order $o) { $firstItem = $o->orderItems->first(); - $serviceName = $firstItem?->product?->name ?? '—'; + $product = $firstItem?->product; $invoiceStatus = $o->invoice?->status ?? null; return [ 'id' => $o->id, 'code' => $o->order_num, 'customer_id' => $o->customer_id, + 'customer_uuid' => $o->customer?->uuid, 'customer_name' => $o->customer->fullname ?? '—', 'customer_email' => $o->customer->email ?? '', - 'service_name' => $serviceName, + 'product_id' => $product?->id, + 'service_name' => $product?->name ?? '—', 'amount' => (float) $o->total, 'payment_status' => $this->paymentStatusFromInvoice($invoiceStatus), 'status' => $this->orderStatusLabel((int) $o->status), @@ -271,9 +289,7 @@ class DashboardController extends Controller $limit = (int) $request->integer('limit', 5); $limit = max(1, min(20, $limit)); - $startOfMonth = now()->startOfMonth(); - $startOfPrevMonth = now()->copy()->subMonthNoOverflow()->startOfMonth(); - $endOfPrevMonth = now()->copy()->subMonthNoOverflow()->endOfMonth(); + [$start, $end, $prevStart, $prevEnd] = $this->periodFromRequest($request); // Top theo số service đang active của từng product $rows = Service::query() @@ -297,7 +313,7 @@ class DashboardController extends Controller $soldCurrent = DB::table('order_items') ->join('orders', 'orders.id', '=', 'order_items.order_id') ->whereIn('order_items.product_id', $productIds) - ->where('orders.created_at', '>=', $startOfMonth) + ->whereBetween('orders.created_at', [$start, $end]) ->selectRaw('order_items.product_id, COUNT(*) as sold, SUM(order_items.price * order_items.quantity) as revenue') ->groupBy('order_items.product_id') ->get() @@ -306,7 +322,7 @@ class DashboardController extends Controller $soldPrev = DB::table('order_items') ->join('orders', 'orders.id', '=', 'order_items.order_id') ->whereIn('order_items.product_id', $productIds) - ->whereBetween('orders.created_at', [$startOfPrevMonth, $endOfPrevMonth]) + ->whereBetween('orders.created_at', [$prevStart, $prevEnd]) ->selectRaw('order_items.product_id, SUM(order_items.price * order_items.quantity) as revenue') ->groupBy('order_items.product_id') ->get() diff --git a/app/Http/Controllers/Api/Admin/Staff/StaffOnlineController.php b/app/Http/Controllers/Api/Admin/Staff/StaffOnlineController.php index fded7aa..0f44fd2 100644 --- a/app/Http/Controllers/Api/Admin/Staff/StaffOnlineController.php +++ b/app/Http/Controllers/Api/Admin/Staff/StaffOnlineController.php @@ -22,7 +22,11 @@ class StaffOnlineController extends Controller */ public function index(): JsonResponse { - $users = User::whereHas('staffOnline')->with('staffOnline')->get(); + $onlineSince = fn ($q) => $q->where('last_visited', '>=', time() - self::ONLINE_THRESHOLD_SECONDS); + + $users = User::whereHas('staffOnline', $onlineSince) + ->with(['staffOnline' => $onlineSince]) + ->get(); $online = $users->map(fn ($user) => [ 'user_id' => $user->id, diff --git a/app/Services/Customer/CustomerBillingService.php b/app/Services/Customer/CustomerBillingService.php index 21ff31e..638b22d 100644 --- a/app/Services/Customer/CustomerBillingService.php +++ b/app/Services/Customer/CustomerBillingService.php @@ -5,6 +5,7 @@ namespace App\Services\Customer; use App\Models\Invoice; use App\Models\Order; use App\Models\Transaction; +use App\Repositories\Customer\CustomerRepositoryInterface; use App\Repositories\Invoice\InvoiceRepositoryInterface; use App\Repositories\Order\OrderRepositoryInterface; use App\Repositories\Transaction\TransactionRepositoryInterface; @@ -16,6 +17,7 @@ class CustomerBillingService use ResolvesCustomer; public function __construct( + private readonly CustomerRepositoryInterface $customerRepository, private readonly InvoiceRepositoryInterface $invoiceRepository, private readonly TransactionRepositoryInterface $transactionRepository, private readonly OrderRepositoryInterface $orderRepository, diff --git a/app/Services/Customer/CustomerImpersonationService.php b/app/Services/Customer/CustomerImpersonationService.php index 4b8ab51..9f88f5e 100644 --- a/app/Services/Customer/CustomerImpersonationService.php +++ b/app/Services/Customer/CustomerImpersonationService.php @@ -5,6 +5,7 @@ namespace App\Services\Customer; use App\Http\Resources\Api\Client\ClientResource; use App\Models\Log; use App\Models\User; +use App\Repositories\Customer\CustomerRepositoryInterface; use App\Services\Api\Client\ClientAuthService; use App\Services\Customer\Concerns\ResolvesCustomer; use Illuminate\Http\Request; @@ -19,6 +20,7 @@ class CustomerImpersonationService private const TTL_SECONDS = 120; public function __construct( + private readonly CustomerRepositoryInterface $customerRepository, private readonly ClientAuthService $clientAuthService, ) {} diff --git a/app/Services/Customer/CustomerNoteService.php b/app/Services/Customer/CustomerNoteService.php index a4dbdce..eb5a4c5 100644 --- a/app/Services/Customer/CustomerNoteService.php +++ b/app/Services/Customer/CustomerNoteService.php @@ -3,6 +3,7 @@ namespace App\Services\Customer; use App\Models\Note; +use App\Repositories\Customer\CustomerRepositoryInterface; use App\Services\Customer\Concerns\ResolvesCustomer; use Illuminate\Support\Collection; @@ -10,6 +11,10 @@ class CustomerNoteService { use ResolvesCustomer; + public function __construct( + private readonly CustomerRepositoryInterface $customerRepository, + ) {} + /** * Ghi chú của khách hàng (không gắn với dịch vụ cụ thể). */ diff --git a/app/Services/Customer/CustomerServiceProvisioningService.php b/app/Services/Customer/CustomerServiceProvisioningService.php index 7f60ff4..cdab27e 100644 --- a/app/Services/Customer/CustomerServiceProvisioningService.php +++ b/app/Services/Customer/CustomerServiceProvisioningService.php @@ -16,6 +16,7 @@ use App\Models\Product; use App\Models\Service; use App\Models\Transaction; use App\Models\VirtualMachines; +use App\Repositories\Customer\CustomerRepositoryInterface; use App\Repositories\Module\Hosting\HostingRepositoryInterface as ModuleHostingRepositoryInterface; use App\Repositories\Module\Vmware\VmwareRepositoryInterface; use App\Repositories\Service\ServiceRepositoryInterface; @@ -32,6 +33,7 @@ class CustomerServiceProvisioningService use ResolvesCustomer; public function __construct( + private readonly CustomerRepositoryInterface $customerRepository, private readonly ModuleHostingRepositoryInterface $moduleHostingRepository, private readonly VmwareRepositoryInterface $vmwareRepository, private readonly ServiceRepositoryInterface $serviceRepository, diff --git a/database/seeders/CustomerBillingDemoSeeder.php b/database/seeders/CustomerBillingDemoSeeder.php new file mode 100644 index 0000000..4dcf795 --- /dev/null +++ b/database/seeders/CustomerBillingDemoSeeder.php @@ -0,0 +1,205 @@ + 'email-doanh-nghiep-goi-10-tai-khoan', 'cycle' => 'monthly', 'status' => InvoiceStatus::PAID, 'paid' => 1.0, 'created' => -25, 'due' => -18], + ['product' => 'switch-cisco-catalyst-24-port', 'cycle' => 'onetime', 'status' => InvoiceStatus::PAID, 'paid' => 1.0, 'created' => -60, 'due' => -53], + ['product' => 'thue-may-chu-vat-ly-dell-r740', 'cycle' => 'monthly', 'status' => InvoiceStatus::UNPAID, 'paid' => 0.0, 'created' => -1, 'due' => 6], + ['product' => 'it-support-goi-doanh-nghiep', 'cycle' => 'monthly', 'status' => InvoiceStatus::UNPAID, 'paid' => 0.5, 'created' => -3, 'due' => 4], + ['product' => 'website-landing-page-co-ban', 'cycle' => 'onetime', 'status' => InvoiceStatus::OVERDUE, 'paid' => 0.0, 'created' => -17, 'due' => -10], + ['product' => 'email-doanh-nghiep-goi-10-tai-khoan', 'cycle' => 'monthly', 'status' => InvoiceStatus::OVERDUE, 'paid' => 0.3, 'created' => -10, 'due' => -3], + ['product' => 'it-support-goi-doanh-nghiep', 'cycle' => 'monthly', 'status' => InvoiceStatus::CANCELLED, 'paid' => 0.0, 'created' => -40, 'due' => -33], + ['product' => 'thue-may-chu-vat-ly-dell-r740', 'cycle' => 'monthly', 'status' => InvoiceStatus::REFUNDED, 'paid' => 1.0, 'created' => -45, 'due' => -38], + ]; + + public function run(): void + { + $this->call([TestAccountSeeder::class, ProductCategorySeeder::class, ProductTypeFieldSeeder::class, ProductMockupSeeder::class]); + + $customer = Customer::where('email', 'pbao.dev@gmail.com')->firstOrFail(); + + DB::transaction(function () use ($customer) { + $this->purgePreviousRun($customer); + + foreach (self::SCENARIOS as $scenario) { + $this->seedScenario($customer, $scenario); + } + }); + + $this->command?->info('Đã tạo ' . count(self::SCENARIOS) . ' đơn hàng/hoá đơn mẫu cho ' . $customer->email . '.'); + } + + private function purgePreviousRun(Customer $customer): void + { + $orderIds = Order::withTrashed()->where('customer_id', $customer->id)->where('note', self::MARKER)->pluck('id'); + $invoices = Invoice::withTrashed()->whereIn('order_id', $orderIds)->get(['id', 'invoice_number']); + + Transaction::withTrashed() + ->where('customer_id', $customer->id) + ->where(function ($query) use ($invoices) { + foreach ($invoices->pluck('invoice_number') as $number) { + $query->orWhere('trans', 'like', $number . '%'); + } + }) + ->when($invoices->isNotEmpty(), fn ($query) => $query->forceDelete()); + + $orderItemIds = OrderItem::whereIn('order_id', $orderIds)->pluck('id'); + Service::withTrashed()->whereIn('order_item_id', $orderItemIds)->forceDelete(); + InvoiceItem::whereIn('invoice_id', $invoices->pluck('id'))->delete(); + Invoice::withTrashed()->whereIn('id', $invoices->pluck('id'))->forceDelete(); + OrderItem::whereIn('id', $orderItemIds)->delete(); + Order::withTrashed()->whereIn('id', $orderIds)->forceDelete(); + } + + /** + * @param array{product: string, cycle: string, status: InvoiceStatus, paid: float, created: int, due: int} $scenario + */ + private function seedScenario(Customer $customer, array $scenario): void + { + $product = Product::where('slug', $scenario['product'])->firstOrFail(); + $price = (int) $product->pricings()->where('billing_cycle', $scenario['cycle'])->value('price'); + $months = OrderHelper::cycleToMonths($scenario['cycle']); + $status = $scenario['status']; + $createdAt = Carbon::now()->addDays($scenario['created']); + $amountPaid = (int) round($price * $scenario['paid'], -3); + $isSettled = in_array($status, [InvoiceStatus::PAID, InvoiceStatus::REFUNDED], true); + $paidAt = $isSettled ? $createdAt->copy()->addHour() : null; + + $order = Order::query()->forceCreate([ + 'order_num' => OrderHelper::generateOrderNumber(), + 'date' => $createdAt->toDateString(), + 'customer_id' => $customer->id, + 'sub_total' => $price, + 'discount_amount' => 0, + 'total' => $price, + 'status' => match ($status) { + InvoiceStatus::PAID => OrderStatus::ACTIVE->value, + InvoiceStatus::CANCELLED, InvoiceStatus::REFUNDED => OrderStatus::CANCELLED->value, + default => OrderStatus::PENDING->value, + }, + 'payment_method' => $amountPaid > 0 ? 'balance' : null, + 'paid_at' => $paidAt, + 'note' => self::MARKER, + 'created_at' => $createdAt, + 'updated_at' => $createdAt, + ]); + + $orderItem = OrderItem::query()->forceCreate([ + 'order_id' => $order->id, + 'product_id' => $product->id, + 'product_pricing_id' => $product->pricings()->where('billing_cycle', $scenario['cycle'])->value('id'), + 'billing_cycle' => $scenario['cycle'], + 'months' => $months, + 'quantity' => 1, + 'price' => $price, + 'created_at' => $createdAt, + 'updated_at' => $createdAt, + ]); + + $invoice = Invoice::query()->forceCreate([ + 'invoice_number' => OrderHelper::generateOrderNumber('INV'), + 'date' => $createdAt->toDateString(), + 'customer_id' => $customer->id, + 'order_id' => $order->id, + 'invoice_type' => 1, + 'sub_total' => $price, + 'discount_amount' => 0, + 'tax' => 0, + 'total' => $price, + 'amount_paid' => $amountPaid, + 'status' => $status->value, + 'payment_method' => $amountPaid > 0 ? 'balance' : null, + 'due_date' => Carbon::now()->addDays($scenario['due'])->toDateString(), + 'billing_period_start' => $createdAt->toDateString(), + 'billing_period_end' => $createdAt->copy()->addMonths($months)->toDateString(), + 'paid_at' => $paidAt, + 'created_at' => $createdAt, + 'updated_at' => $createdAt, + ]); + + InvoiceItem::query()->forceCreate([ + 'invoice_id' => $invoice->id, + 'product_id' => $product->id, + 'type' => 'service', + 'description' => $product->name, + 'months' => $months, + 'quantity' => 1, + 'unit_price' => $price, + 'total_price' => $price, + 'created_at' => $createdAt, + 'updated_at' => $createdAt, + ]); + + if ($amountPaid > 0) { + $this->recordPayment($invoice, $amountPaid, $isSettled, $paidAt ?? $createdAt->copy()->addHour()); + } + + if ($isSettled) { + $this->seedService($order, $orderItem, $invoice, $status, $paidAt); + } + } + + private function recordPayment(Invoice $invoice, int $amount, bool $isSettled, Carbon $paidAt): void + { + Transaction::query()->forceCreate([ + 'uuid' => (string) Str::uuid(), + 'trans' => $invoice->invoice_number . ($isSettled ? '' : '-P' . $amount), + 'public_code' => 'INV-PAY-' . $paidAt->format('Ymd') . '-' . strtoupper(Str::random(6)), + 'amount' => $amount, + 'received_amount' => $amount, + 'customer_id' => $invoice->customer_id, + 'status' => TransactionStatus::COMPLETED->value, + 'type' => 'invoice', + 'note' => 'Khách hàng thanh toán hóa đơn #' . $invoice->invoice_number . ' (balance)', + 'created_at' => $paidAt, + 'updated_at' => $paidAt, + ]); + } + + private function seedService(Order $order, OrderItem $orderItem, Invoice $invoice, InvoiceStatus $status, Carbon $startedAt): void + { + $isRefunded = $status === InvoiceStatus::REFUNDED; + $refundedAt = $startedAt->copy()->addDays(2); + + Service::query()->forceCreate([ + 'uuid' => (string) Str::uuid(), + 'customer_id' => $order->customer_id, + 'product_id' => $orderItem->product_id, + 'order_item_id' => $orderItem->id, + 'invoice_id' => $invoice->id, + 'status' => $isRefunded ? 'cancelled' : 'active', + 'auto_renew' => false, + 'started_at' => $startedAt, + 'expires_at' => $startedAt->copy()->addMonths($orderItem->months), + 'cancelled_at' => $isRefunded ? $refundedAt : null, + 'refunded_at' => $isRefunded ? $refundedAt : null, + 'created_at' => $startedAt, + 'updated_at' => $isRefunded ? $refundedAt : $startedAt, + ]); + } +} diff --git a/database/seeders/TestAccountSeeder.php b/database/seeders/TestAccountSeeder.php index c63d75f..cb497d0 100644 --- a/database/seeders/TestAccountSeeder.php +++ b/database/seeders/TestAccountSeeder.php @@ -17,7 +17,7 @@ use Illuminate\Support\Str; class TestAccountSeeder extends Seeder { private const CUSTOMER_EMAIL = 'pbao.dev@gmail.com'; - private const CUSTOMER_PASSWORD = 'password123'; + private const CUSTOMER_PASSWORD = '!admin@123!@#'; private const ADMIN_USERNAME = 'admin'; private const ADMIN_PASSWORD = '!admin@123!@#';