204 lines
7.7 KiB
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();
|
|
});
|