CluPilotCloud/tests/Feature/Billing/AddonCancellationTest.php

204 lines
7.7 KiB
PHP

<?php
use App\Actions\BookAddon;
use App\Livewire\Billing;
use App\Models\Customer;
use App\Models\Instance;
use App\Models\Subscription;
use App\Models\SubscriptionAddon;
use App\Models\User;
use App\Services\Stripe\FakeStripeClient;
use App\Services\Stripe\StripeClient;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Support\Carbon;
use Livewire\Livewire;
/**
* Cancelling a booked module from the portal.
*
* The owner's rule was already true of the action — "die Addons können monatlich
* gekündigt werden", BookAddon::cancelAtPeriodEnd() — and false of the product:
* the method had no caller anywhere in the interface, so a customer could book a
* recurring charge in two clicks and had no way at all to stop it.
*
* Three things have to hold at once, and they pull against each other:
* the module keeps running to the end of the term already paid for; Stripe stops
* billing it from the NEXT cycle; and until the term ends the customer can change
* their mind without being charged for the privilege.
*/
beforeEach(function () {
Carbon::setTestNow('2026-03-10 09:00:00');
});
afterEach(function () {
Carbon::setTestNow();
});
/** A Stripe that answers, with the catalogue mirrored into it. */
function cancellableStripe(): FakeStripeClient
{
$fake = new FakeStripeClient;
app()->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();
});