instance(StripeClient::class, $fake); app(Kernel::class)->call('stripe:sync-catalogue'); return $fake; } /** * A signed-in customer on a contract with one module booked. * * @return array{0: User, 1: Subscription, 2: SubscriptionAddon} */ function contractWithModule(string $key = 'priority_support'): array { $customer = Customer::factory()->consumer()->create(['email' => 'modul@example.test']); $user = User::factory()->create(['email' => $customer->email]); $customer->update(['user_id' => $user->id]); $contract = Subscription::factory()->plan('team')->create([ 'customer_id' => $customer->id, 'stripe_subscription_id' => 'sub_module', 'stripe_item_id' => 'si_plan', 'current_period_start' => Carbon::parse('2026-03-01 09:00:00'), 'current_period_end' => Carbon::parse('2026-04-01 09:00:00'), ]); $instance = Instance::factory()->create([ 'customer_id' => $customer->id, 'plan' => 'team', 'status' => 'active', ]); $contract->update(['instance_id' => $instance->id]); $addon = app(BookAddon::class)($contract->refresh(), $key); return [$user, $contract->refresh(), $addon->refresh()]; } it('cancels a module from the portal, and it runs to the end of the paid term', function () { $stripe = cancellableStripe(); [$user, $contract, $addon] = contractWithModule(); // Booked and billed: Stripe carries an item for it. expect($stripe->itemsOn('sub_module'))->toHaveCount(1) ->and($addon->stripe_item_id)->not->toBeNull(); Livewire::actingAs($user) ->test(Billing::class) ->call('cancelAddon', 'priority_support') ->assertDispatched('notify', message: __('billing.addon_cancel_done', [ 'date' => $contract->current_period_end->local()->isoFormat('LL'), ])); $addon->refresh(); // Still running — the customer paid for this term and keeps it. expect($addon->isActive())->toBeTrue() ->and($addon->cancelled_at)->toBeNull() ->and($addon->endsAtPeriodEnd())->toBeTrue() ->and($addon->cancels_at->toDateTimeString())->toBe('2026-04-01 09:00:00'); }); it('takes the module off the next cycle invoice without raising a credit', function () { $stripe = cancellableStripe(); [$user] = contractWithModule(); Livewire::actingAs($user)->test(Billing::class)->call('cancelAddon', 'priority_support'); // Stripe is what raises the cycle invoice, and it no longer has an item for // this module — so the next one carries no such line. That is the mechanism, // and it is why the item comes off NOW rather than at the boundary: waiting // would be a race against Stripe generating that very invoice. expect($stripe->itemsOn('sub_module'))->toBeEmpty(); $removal = collect($stripe->itemCalls)->last(); // No credit for the term in progress: it was paid in advance and the // customer keeps the module through it, so nothing is settled. expect($removal['call'])->toBe('remove') ->and($removal['proration'])->toBe(StripeClient::PRORATE_NONE); }); it('lets the customer take the cancellation back while the module is still running', function () { $stripe = cancellableStripe(); [$user] = contractWithModule(); $component = Livewire::actingAs($user)->test(Billing::class); $component->call('cancelAddon', 'priority_support'); $component->call('resumeAddon', 'priority_support') ->assertDispatched('notify', message: __('billing.addon_resumed')); $addon = SubscriptionAddon::query()->where('addon_key', 'priority_support')->firstOrFail(); expect($addon->cancels_at)->toBeNull() ->and($addon->isActive())->toBeTrue() // Billed again from the next cycle: the item is back on the // subscription. ->and($stripe->itemsOn('sub_module'))->toHaveCount(1); // And not charged for it. The term was already paid for, so putting the // item back must settle nothing — `always_invoice` here would bill the // customer a second time for days they have already bought. $restore = collect($stripe->itemCalls)->last(); expect($restore['call'])->toBe('add') ->and($restore['proration'])->toBe(StripeClient::PRORATE_NONE); }); it('shows the date it runs until, and the way back, on the card', function () { cancellableStripe(); [$user, $contract] = contractWithModule(); Livewire::actingAs($user)->test(Billing::class) ->assertSee(__('billing.addon_cancel_cta')) ->call('cancelAddon', 'priority_support') ->assertSee(__('billing.addon_runs_until', [ 'date' => $contract->current_period_end->local()->isoFormat('LL'), ])) ->assertSee(__('billing.addon_resume_cta')) // The card that says "gekündigt" must not also offer to cancel it again. ->assertDontSee(__('billing.addon_cancel_cta')); }); it('will not take a cancellation back once the module has actually ended', function () { cancellableStripe(); [$user, , $addon] = contractWithModule(); Livewire::actingAs($user)->test(Billing::class)->call('cancelAddon', 'priority_support'); // The appointment is kept by the scheduled command, exactly as it is in // production — not by a hand-written update that would prove nothing. Carbon::setTestNow('2026-04-01 09:00:01'); app(Kernel::class)->call('clupilot:end-cancelled-addons'); expect($addon->refresh()->cancelled_at)->not->toBeNull(); Livewire::actingAs($user)->test(Billing::class) ->call('resumeAddon', 'priority_support') ->assertDispatched('notify', message: __('billing.addon_resume_none')); expect($addon->refresh()->cancels_at)->not->toBeNull() ->and($addon->isActive())->toBeFalse(); }); it('never cancels a module that belongs to somebody else', function () { cancellableStripe(); [, , $addon] = contractWithModule(); // A second customer who can post to /livewire/update with any key at all. $stranger = Customer::factory()->consumer()->create(['email' => 'fremd@example.test']); $strangerUser = User::factory()->create(['email' => $stranger->email]); $stranger->update(['user_id' => $strangerUser->id]); Livewire::actingAs($strangerUser) ->test(Billing::class) ->call('cancelAddon', 'priority_support') ->assertDispatched('notify', message: __('billing.addon_cancel_none')); expect($addon->refresh()->cancels_at)->toBeNull() ->and($addon->isActive())->toBeTrue(); });