create(); $visible = $this->makeProduct([ 'name' => 'Starter Shared', 'slug' => 'starter-shared-phase3', 'category' => HostingProduct::CATEGORY_SHARED, 'type' => HostingProduct::TYPE_SINGLE_DOMAIN, ]); $hidden = $this->makeProduct([ 'name' => 'Hidden Shared', 'slug' => 'hidden-shared-phase3', 'category' => HostingProduct::CATEGORY_SHARED, 'type' => HostingProduct::TYPE_MULTI_DOMAIN, 'is_visible' => false, ]); $this->actingAs($user) ->getJson('/api/plans') ->assertOk() ->assertJsonPath('data.0.id', $visible->id) ->assertJsonMissing(['id' => $hidden->id]); } public function test_upgrade_endpoint_applies_prorated_plan_change_and_updates_quotas(): void { Carbon::setTestNow(Carbon::create(2026, 4, 6, 10, 0, 0)); $user = User::factory()->create(); $currentProduct = $this->makeProduct([ 'name' => 'Starter', 'slug' => 'starter-phase3-upgrade', 'price_monthly' => 10, 'disk_gb' => 10, 'max_domains' => 1, 'max_databases' => 2, 'max_email_accounts' => 5, ]); $targetProduct = $this->makeProduct([ 'name' => 'Growth', 'slug' => 'growth-phase3-upgrade', 'price_monthly' => 25, 'disk_gb' => 25, 'max_domains' => 5, 'max_databases' => 10, 'max_email_accounts' => 15, ]); $account = $this->makeAccount($user, $currentProduct, [ 'allocated_disk_gb' => 10, 'expires_at' => now()->addMonth(), 'resource_limits' => [ 'disk_gb' => 10, 'max_domains' => 1, 'max_databases' => 2, 'max_email_accounts' => 5, ], ]); CustomerHostingOrder::create([ 'user_id' => $user->id, 'hosting_product_id' => $currentProduct->id, 'hosting_account_id' => $account->id, 'domain_name' => $account->primary_domain, 'billing_cycle' => CustomerHostingOrder::CYCLE_MONTHLY, 'amount_paid' => 10, 'currency' => 'GHS', 'status' => CustomerHostingOrder::STATUS_ACTIVE, 'expires_at' => now()->addMonth(), ]); $provider = \Mockery::mock(SharedNodeProvider::class); $provider->shouldReceive('syncAccountPackage')->once()->andReturn(true); $this->app->instance(SharedNodeProvider::class, $provider); $this->actingAs($user) ->postJson('/api/hosting/upgrade', [ 'hosting_account_id' => $account->id, 'target_product_id' => $targetProduct->id, ]) ->assertOk() ->assertJsonPath('data.quote.direction', HostingPlanChange::DIRECTION_UPGRADE) ->assertJsonPath('data.quote.charge_amount', 15) ->assertJsonPath('data.invoice.total_amount', '15.00'); $account->refresh(); $this->assertSame($targetProduct->id, $account->hosting_product_id); $this->assertSame(25, $account->allocated_disk_gb); $this->assertSame(5, data_get($account->resource_limits, 'max_domains')); $this->assertSame(15, data_get($account->resource_limits, 'max_email_accounts')); $this->assertDatabaseHas('hosting_plan_changes', [ 'hosting_account_id' => $account->id, 'direction' => HostingPlanChange::DIRECTION_UPGRADE, 'charge_amount' => 15, 'credit_amount' => 0, ]); $this->assertDatabaseHas('hosting_billing_invoices', [ 'hosting_account_id' => $account->id, 'invoice_type' => HostingBillingInvoice::TYPE_PLAN_CHANGE, 'status' => HostingBillingInvoice::STATUS_PENDING, 'total_amount' => 15, ]); } public function test_downgrade_endpoint_applies_credit_and_reduces_quota_immediately(): void { Carbon::setTestNow(Carbon::create(2026, 4, 6, 10, 0, 0)); $user = User::factory()->create(); $currentProduct = $this->makeProduct([ 'name' => 'Growth', 'slug' => 'growth-phase3-downgrade', 'price_monthly' => 25, 'disk_gb' => 25, 'max_domains' => 5, 'max_databases' => 10, 'max_email_accounts' => 15, ]); $targetProduct = $this->makeProduct([ 'name' => 'Starter', 'slug' => 'starter-phase3-downgrade', 'price_monthly' => 10, 'disk_gb' => 10, 'max_domains' => 1, 'max_databases' => 2, 'max_email_accounts' => 5, ]); $account = $this->makeAccount($user, $currentProduct, [ 'allocated_disk_gb' => 25, 'expires_at' => now()->addMonth(), 'resource_limits' => [ 'disk_gb' => 25, 'max_domains' => 5, 'max_databases' => 10, 'max_email_accounts' => 15, ], ]); CustomerHostingOrder::create([ 'user_id' => $user->id, 'hosting_product_id' => $currentProduct->id, 'hosting_account_id' => $account->id, 'domain_name' => $account->primary_domain, 'billing_cycle' => CustomerHostingOrder::CYCLE_MONTHLY, 'amount_paid' => 25, 'currency' => 'GHS', 'status' => CustomerHostingOrder::STATUS_ACTIVE, 'expires_at' => now()->addMonth(), ]); $provider = \Mockery::mock(SharedNodeProvider::class); $provider->shouldReceive('syncAccountPackage')->once()->andReturn(true); $this->app->instance(SharedNodeProvider::class, $provider); $this->actingAs($user) ->postJson('/api/hosting/downgrade', [ 'hosting_account_id' => $account->id, 'target_product_id' => $targetProduct->id, ]) ->assertOk() ->assertJsonPath('data.quote.direction', HostingPlanChange::DIRECTION_DOWNGRADE) ->assertJsonPath('data.quote.credit_amount', 15) ->assertJsonPath('data.invoice.status', HostingBillingInvoice::STATUS_CREDITED); $account->refresh(); $this->assertSame($targetProduct->id, $account->hosting_product_id); $this->assertSame(10, $account->allocated_disk_gb); $this->assertDatabaseHas('hosting_billing_invoices', [ 'hosting_account_id' => $account->id, 'invoice_type' => HostingBillingInvoice::TYPE_PLAN_CHANGE, 'status' => HostingBillingInvoice::STATUS_CREDITED, 'credit_amount' => 15, 'total_amount' => 0, ]); } public function test_paid_hosting_mailbox_creation_creates_addon_invoice(): void { $user = User::factory()->create(); $product = $this->makeProduct([ 'name' => 'Starter Mail', 'slug' => 'starter-mail-phase3', 'max_email_accounts' => 1, ]); $account = $this->makeAccount($user, $product); $domain = $this->makeDomain($user, $account, 'phase3-mail.test'); Mailbox::create([ 'user_id' => $user->id, 'hosting_account_id' => $account->id, 'domain_id' => $domain->id, 'display_name' => 'Free Mailbox', 'local_part' => 'info', 'address' => 'info@phase3-mail.test', 'password_hash' => Hash::make('password123'), 'quota_mb' => 2048, 'status' => 'active', 'maildir_path' => 'hosting/1/phase3-mail.test/info', 'credentials_issued_at' => now(), 'incoming_token' => 'token-free-1', 'inbound_enabled' => true, 'outbound_enabled' => true, 'outbound_provider' => 'smtp', 'is_paid' => false, ]); $this->actingAs($user) ->postJson('/api/email/create', [ 'hosting_account_id' => $account->id, 'domain_id' => $domain->id, 'local_part' => 'sales', 'display_name' => 'Sales', 'password' => 'password123', ]) ->assertCreated() ->assertJsonPath('data.mailbox.is_paid', true) ->assertJsonPath('data.usage.extra_count', 1) ->assertJsonPath('data.invoice.total_amount', '5.00'); $this->assertDatabaseHas('mailboxes', [ 'hosting_account_id' => $account->id, 'address' => 'sales@phase3-mail.test', 'is_paid' => true, ]); $this->assertDatabaseHas('hosting_billing_invoices', [ 'hosting_account_id' => $account->id, 'invoice_type' => HostingBillingInvoice::TYPE_EMAIL_ADDON, 'status' => HostingBillingInvoice::STATUS_PENDING, 'total_amount' => 5, ]); } public function test_mailbox_delete_requires_account_ownership(): void { $owner = User::factory()->create(); $otherUser = User::factory()->create(); $product = $this->makeProduct([ 'name' => 'Starter Delete', 'slug' => 'starter-delete-phase3', 'max_email_accounts' => 1, ]); $account = $this->makeAccount($owner, $product); $domain = $this->makeDomain($owner, $account, 'delete-mail.test'); $mailbox = Mailbox::create([ 'user_id' => $owner->id, 'hosting_account_id' => $account->id, 'domain_id' => $domain->id, 'display_name' => 'Owner Mailbox', 'local_part' => 'admin', 'address' => 'admin@delete-mail.test', 'password_hash' => Hash::make('password123'), 'quota_mb' => 2048, 'status' => 'active', 'maildir_path' => 'hosting/1/delete-mail.test/admin', 'credentials_issued_at' => now(), 'incoming_token' => 'token-delete-1', 'inbound_enabled' => true, 'outbound_enabled' => true, 'outbound_provider' => 'smtp', 'is_paid' => false, ]); $this->actingAs($otherUser) ->deleteJson('/api/email/delete', [ 'mailbox_id' => $mailbox->id, ]) ->assertForbidden(); } private function makeProduct(array $overrides = []): HostingProduct { return HostingProduct::create(array_merge([ 'name' => 'Shared Phase 3', 'slug' => 'shared-phase-3-' . str()->random(8), 'category' => HostingProduct::CATEGORY_SHARED, 'type' => HostingProduct::TYPE_SINGLE_DOMAIN, 'price_monthly' => 10, 'price_quarterly' => 30, 'price_yearly' => 120, 'price_biennial' => 240, 'currency' => 'GHS', 'disk_gb' => 10, 'max_domains' => 1, 'max_databases' => 2, 'max_email_accounts' => 5, 'is_active' => true, 'is_visible' => true, ], $overrides)); } private function makeAccount(User $user, HostingProduct $product, array $overrides = []): HostingAccount { return HostingAccount::unguarded(function () use ($user, $product, $overrides) { return HostingAccount::create(array_merge([ 'user_id' => $user->id, 'hosting_product_id' => $product->id, 'username' => 'acct' . random_int(1000, 9999), 'primary_domain' => 'phase3.example.test', 'type' => HostingAccount::TYPE_SHARED, 'status' => HostingAccount::STATUS_ACTIVE, 'allocated_disk_gb' => (int) ($product->disk_gb ?? 0), 'php_version' => '8.4', 'cpu_limit_percent' => 50, 'memory_limit_mb' => 512, 'process_limit' => 10, 'io_limit_mb' => 5, 'inode_limit' => 50000, 'resource_status' => HostingAccount::RESOURCE_STATUS_ACTIVE, 'resource_limits' => [ 'disk_gb' => (int) ($product->disk_gb ?? 0), 'max_domains' => $product->max_domains, 'max_databases' => $product->max_databases, 'max_email_accounts' => $product->max_email_accounts, ], ], $overrides)); }); } private function makeDomain(User $user, HostingAccount $account, string $host): Domain { return Domain::create([ 'user_id' => $user->id, 'website_id' => null, 'hosting_account_id' => $account->id, 'host' => $host, 'type' => 'custom', 'source' => 'hosting', 'status' => 'verified', 'onboarding_mode' => Domain::MODE_NS_AUTO, 'onboarding_state' => Domain::STATE_ACTIVE, 'dns_mode' => 'managed', 'verified_at' => now(), 'active_at' => now(), ]); } }