set('constants.coolify.self_hosted', false); config()->set('subscription.provider', 'stripe'); config()->set('subscription.stripe_api_key', 'sk_test_fake'); InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0])); $this->team = Team::factory()->create(); $this->user = User::factory()->create(); $this->team->members()->attach($this->user->id, ['role' => 'owner']); Subscription::create([ 'team_id' => $this->team->id, 'stripe_subscription_id' => 'sub_test_123', 'stripe_customer_id' => 'cus_test_123', 'stripe_invoice_paid' => true, 'stripe_plan_id' => 'price_test_123', 'stripe_cancel_at_period_end' => false, 'stripe_past_due' => false, ]); $this->actingAs($this->user); session(['currentTeam' => $this->team]); }); describe('cancelImmediately with refund option', function () { test('refunds and cancels via RefundSubscription when refund checkbox is selected', function () { $mock = Mockery::mock(RefundSubscription::class); $mock->shouldReceive('execute')->once()->andReturn(['success' => true, 'error' => null]); $this->instance(RefundSubscription::class, $mock); Livewire::test(Actions::class) ->call('cancelImmediately', 'password', ['refundLatestPayment']) ->assertDispatched('success') ->assertRedirect(route('subscription.index')); }); test('dispatches error when refund fails', function () { $mock = Mockery::mock(RefundSubscription::class); $mock->shouldReceive('execute')->once()->andReturn(['success' => false, 'error' => 'No paid invoice found to refund.']); $this->instance(RefundSubscription::class, $mock); Livewire::test(Actions::class) ->call('cancelImmediately', 'password', ['refundLatestPayment']) ->assertDispatched('error'); }); test('rejects invalid password before refunding', function () { $mock = Mockery::mock(RefundSubscription::class); $mock->shouldNotReceive('execute'); $this->instance(RefundSubscription::class, $mock); Livewire::test(Actions::class) ->call('cancelImmediately', 'wrong-password', ['refundLatestPayment']) ->assertReturned('Invalid password.'); }); }); describe('password confirmation', function () { test('a user with a linked oauth identity cancels without a password', function () { OauthIdentity::create([ 'user_id' => $this->user->id, 'provider' => 'oidc', 'issuer' => 'https://idp.example.com', 'provider_user_id' => 'oauth-user-id', ]); $mock = Mockery::mock(RefundSubscription::class); $mock->shouldReceive('execute')->once()->andReturn(['success' => true, 'error' => null]); $this->instance(RefundSubscription::class, $mock); Livewire::test(Actions::class) ->call('cancelImmediately', '', ['refundLatestPayment']) ->assertDispatched('success') ->assertRedirect(route('subscription.index')); }); test('a user without a linked oauth identity is rejected with an empty password', function () { $mock = Mockery::mock(RefundSubscription::class); $mock->shouldNotReceive('execute'); $this->instance(RefundSubscription::class, $mock); Livewire::test(Actions::class) ->call('refundSubscription', '') ->assertReturned('Invalid password.'); }); }); describe('cancelImmediately with account deletion', function () { beforeEach(function () { fakeOpenStripeSubscriptions(); $this->stripe = Mockery::mock(StripeClient::class); $this->stripe->subscriptions = Mockery::mock(SubscriptionService::class); $this->instance(StripeClient::class, $this->stripe); }); test('cancels the subscription, removes the team data from coolify, and deletes the account', function () { $this->stripe->subscriptions->shouldReceive('cancel')->once()->with('sub_test_123'); $privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]); $server = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $privateKey->id]); $destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); $project = Project::factory()->create(['team_id' => $this->team->id]); $environment = Environment::factory()->create(['project_id' => $project->id]); $application = Application::factory()->create([ 'environment_id' => $environment->id, 'destination_id' => $destination->id, 'destination_type' => StandaloneDocker::class, ]); $githubApp = GithubApp::create([ 'name' => 'Team GitHub App', 'team_id' => $this->team->id, 'private_key_id' => $privateKey->id, 'api_url' => 'https://api.github.com', 'html_url' => 'https://github.com', 'is_public' => false, ]); Livewire::test(Actions::class) ->call('cancelImmediately', 'password', ['deleteAccount']) ->assertRedirect(route('login')); expect(User::find($this->user->id))->toBeNull() ->and(Team::find($this->team->id))->toBeNull() ->and(Server::withTrashed()->find($server->id))->toBeNull() ->and(Project::find($project->id))->toBeNull() ->and(Application::withTrashed()->find($application->id))->toBeNull() ->and(GithubApp::find($githubApp->id))->toBeNull() ->and(Subscription::where('team_id', $this->team->id)->exists())->toBeFalse() ->and(auth()->check())->toBeFalse(); }); test('refunds, cancels, and deletes the account when both options are selected', function () { $refund = Mockery::mock(RefundSubscription::class); $refund->shouldReceive('execute')->once()->andReturnUsing(function (Team $team) { $team->subscriptionEnded($team->subscription); return ['success' => true, 'error' => null]; }); $this->instance(RefundSubscription::class, $refund); $this->stripe->subscriptions->shouldNotReceive('cancel'); Livewire::test(Actions::class) ->call('cancelImmediately', 'password', ['refundLatestPayment', 'deleteAccount']) ->assertRedirect(route('login')); expect(User::find($this->user->id))->toBeNull(); }); test('keeps the account when cancellation fails after a successful refund', function () { $this->stripe->invoices = Mockery::mock(InvoiceService::class); $this->stripe->refunds = Mockery::mock(RefundService::class); $this->stripe->subscriptions->shouldReceive('retrieve')->with('sub_test_123')->andReturn((object) [ 'status' => 'active', 'start_date' => now()->subDays(10)->timestamp, 'current_period_end' => now()->addDays(20)->timestamp, ]); $this->stripe->invoices->shouldReceive('all')->andReturn((object) ['data' => [ (object) ['payment_intent' => 'pi_test_123'], ]]); $this->stripe->refunds->shouldReceive('create')->once() ->with(['payment_intent' => 'pi_test_123']) ->andReturn((object) ['id' => 're_test_123']); $this->stripe->subscriptions->shouldReceive('cancel')->once()->with('sub_test_123') ->andThrow(new RuntimeException('Stripe cancel API error')); Livewire::test(Actions::class) ->call('cancelImmediately', 'password', ['refundLatestPayment', 'deleteAccount']) ->assertDispatched('error') ->assertNotDispatched('success') ->assertNoRedirect(); $this->assertModelExists($this->user); $this->assertModelExists($this->team); expect($this->team->subscription->fresh()->stripe_subscription_id)->toBe('sub_test_123') ->and($this->team->subscription->fresh()->stripe_refunded_at)->not->toBeNull() ->and(auth()->check())->toBeTrue(); }); test('does not cancel when the account cannot be deleted', function () { $member = User::factory()->create(); $sharedTeam = Team::factory()->create(['name' => 'Shared']); $sharedTeam->members()->attach($this->user->id, ['role' => 'owner']); $sharedTeam->members()->attach($member->id, ['role' => 'member']); $this->stripe->subscriptions->shouldNotReceive('cancel'); Livewire::test(Actions::class) ->call('cancelImmediately', 'password', ['deleteAccount']) ->assertReturned('Make another member of the team "Shared" an admin or owner.'); expect(User::find($this->user->id))->not->toBeNull() ->and($this->team->subscription->fresh()->stripe_subscription_id)->toBe('sub_test_123'); }); test('keeps the account when delete my account is not selected', function () { $this->stripe->subscriptions->shouldReceive('cancel')->once()->with('sub_test_123'); Livewire::test(Actions::class) ->call('cancelImmediately', 'password', []) ->assertDispatched('success') ->assertRedirect(route('subscription.index')); expect(User::find($this->user->id))->not->toBeNull() ->and(Team::find($this->team->id))->not->toBeNull(); }); test('does nothing with a wrong password', function () { $this->stripe->subscriptions->shouldNotReceive('cancel'); Livewire::test(Actions::class) ->call('cancelImmediately', 'wrong-password', ['deleteAccount']) ->assertReturned('Invalid password.'); expect(User::find($this->user->id))->not->toBeNull(); }); test('keeps the account and reports an error when deletion fails after the cancellation', function () { $this->stripe->subscriptions->shouldReceive('cancel')->once()->with('sub_test_123'); $deleteAccount = Mockery::mock(DeleteUserAccount::class)->makePartial(); $deleteAccount->shouldReceive('handle')->once()->andThrow(new RuntimeException('Runner busy')); $this->instance(DeleteUserAccount::class, $deleteAccount); Livewire::test(Actions::class) ->call('cancelImmediately', 'password', ['deleteAccount']) ->assertDispatched('error') ->assertRedirect(route('subscription.index')); expect(User::find($this->user->id))->not->toBeNull() ->and(auth()->check())->toBeTrue(); }); });