CluPilotCloud/tests/Feature/Billing/PackageCancellationTest.php

296 lines
12 KiB
PHP

<?php
use App\Livewire\ConfirmCancelPackage;
use App\Mail\InvoiceMail;
use App\Models\Customer;
use App\Models\Instance;
use App\Models\Invoice;
use App\Models\Order;
use App\Models\Subscription;
use App\Models\SubscriptionRecord;
use App\Models\User;
use App\Services\Stripe\FakeStripeClient;
use App\Services\Stripe\StripeClient;
use App\Support\CompanyProfile;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Mail;
use Livewire\Livewire;
/**
* A cancellation, and the two things that used to happen instead of it.
*
* Nothing in this application could cancel a Stripe subscription — there was no
* such method on the client at all — so a customer who cancelled kept being
* charged every month, for ever, for a package that had ended. And the date they
* were promised was worked out by adding MONTHS to the order date whatever the
* term said, so a yearly customer who cancelled in March lost the nine months
* they had already paid for.
*
* The example these tests are built on, once, so the dates can be checked by hand
* rather than recomputed from the implementation:
*
* contract concluded 1 January 2026
* term yearly — 1 January 2026 → 1 January 2027
* cancellation 15 March 2026
*
* service ends 1 January 2027 ← the term that was paid for
* the old answer 1 April 2026 ← months added to the order date
*/
beforeEach(function () {
Carbon::setTestNow('2026-03-15 09:00:00');
CompanyProfile::put([
'name' => 'CluPilot Cloud e.U.',
'address' => 'Dreherstraße 66/1/8',
'postcode' => '1110',
'city' => 'Wien',
'vat_id' => 'ATU00000000',
]);
});
afterEach(function () {
Carbon::setTestNow();
});
/** A Stripe that records what it was asked to stop. */
function cancellationStripe(): FakeStripeClient
{
$fake = new FakeStripeClient;
app()->instance(StripeClient::class, $fake);
return $fake;
}
/**
* A running package its owner can reach the cancel modal for.
*
* @return array{0: User, 1: Instance, 2: Subscription}
*/
function cancellablePackage(string $term = Subscription::TERM_MONTHLY, array $contract = []): array
{
$user = User::factory()->create(['email' => 'berger@example.test', 'email_verified_at' => now()]);
$customer = Customer::factory()->create([
'email' => 'berger@example.test',
'user_id' => $user->id,
'status' => 'active',
]);
// Two and a half months ago, which is what makes the old month-walk visible:
// it would land on 1 April whatever the term is.
$order = Order::factory()->create([
'customer_id' => $customer->id,
'plan' => 'team',
'created_at' => '2026-01-01 09:00:00',
]);
$instance = Instance::factory()->create([
'customer_id' => $customer->id,
'order_id' => $order->id,
'plan' => 'team',
'status' => 'active',
'subdomain' => 'berger',
]);
$subscription = Subscription::factory()->plan('team', $term)->create(array_merge([
'customer_id' => $customer->id,
'order_id' => $order->id,
'instance_id' => $instance->id,
'stripe_subscription_id' => 'sub_cancel',
'started_at' => '2026-01-01 09:00:00',
'current_period_start' => '2026-01-01 09:00:00',
'current_period_end' => $term === Subscription::TERM_YEARLY
? '2027-01-01 09:00:00'
: '2026-04-01 09:00:00',
], $contract));
return [$user, $instance->refresh(), $subscription->refresh()];
}
it('tells Stripe to stop at the end of the term and keeps a yearly customer their year', function () {
$stripe = cancellationStripe();
[$user, $instance, $contract] = cancellablePackage(Subscription::TERM_YEARLY);
Livewire::actingAs($user)->test(ConfirmCancelPackage::class)
->set('confirmName', 'berger')
->set('reason', 'no_longer_needed')
->call('cancelPackage')
->assertHasNoErrors();
// Stripe is told, and told WHICH cancellation this is. At the period end,
// because the customer keeps everything they have paid for; an immediate stop
// would be a withdrawal, which is a different promise and a refund.
expect($stripe->cancellations)->toHaveCount(1)
->and($stripe->cancellations[0]['subscription'])->toBe('sub_cancel')
->and($stripe->cancellations[0]['when'])->toBe(StripeClient::CANCEL_AT_PERIOD_END);
$instance->refresh();
// The end of the YEAR that was bought, written out rather than recomputed —
// and explicitly not the date months-from-the-order-date used to produce.
expect($instance->status)->toBe('cancellation_scheduled')
->and($instance->service_ends_at->toDateTimeString())->toBe('2027-01-01 09:00:00')
->and($instance->service_ends_at->toDateString())->not->toBe('2026-04-01');
$contract->refresh();
// The contract carries the request — and is still ACTIVE, because until the
// first of January this is a paying customer with a running cloud. Only
// Stripe reporting the subscription gone ends it.
expect($contract->cancel_requested_at?->toDateTimeString())->toBe('2026-03-15 09:00:00')
->and($contract->status)->toBe('active')
->and($contract->cancelled_at)->toBeNull();
});
it('keeps a monthly customer their month and no longer', function () {
$stripe = cancellationStripe();
[$user, $instance] = cancellablePackage(Subscription::TERM_MONTHLY);
Livewire::actingAs($user)->test(ConfirmCancelPackage::class)
->set('confirmName', 'berger')
->set('reason', 'no_longer_needed')
->call('cancelPackage')
->assertHasNoErrors();
// The other half of the pair: a monthly contract really does end a month
// after its period started, so the yearly assertion above is about the TERM
// and not about this code being generous to everybody.
expect($instance->fresh()->service_ends_at->toDateTimeString())->toBe('2026-04-01 09:00:00')
->and($stripe->cancellations[0]['when'])->toBe(StripeClient::CANCEL_AT_PERIOD_END);
});
it('cancels a granted package cleanly and asks Stripe nothing at all', function () {
$stripe = cancellationStripe();
// A grant was never sold through Stripe — GrantSubscription leaves the id
// null on purpose — so there is no subscription to stop and nothing has gone
// wrong.
[$user, $instance, $contract] = cancellablePackage(Subscription::TERM_MONTHLY, [
'stripe_subscription_id' => null,
'granted_at' => '2026-01-01 09:00:00',
'granted_by' => admin()->id,
]);
Livewire::actingAs($user)->test(ConfirmCancelPackage::class)
->set('confirmName', 'berger')
->set('reason', 'no_longer_needed')
->call('cancelPackage')
->assertHasNoErrors();
expect($stripe->cancellations)->toBeEmpty()
->and($instance->fresh()->status)->toBe('cancellation_scheduled')
->and($instance->fresh()->service_ends_at->toDateTimeString())->toBe('2026-04-01 09:00:00')
->and($contract->fresh()->cancel_requested_at)->not->toBeNull();
});
it('records nothing at all when Stripe cannot be told to stop', function () {
$stripe = cancellationStripe();
$stripe->failWith = 'connection refused';
[$user, $instance, $contract] = cancellablePackage();
Livewire::actingAs($user)->test(ConfirmCancelPackage::class)
->set('confirmName', 'berger')
->set('reason', 'no_longer_needed')
->call('cancelPackage')
->assertHasErrors(['confirmName']);
// A cancellation we could not make effective is the defect this whole file
// is about: the customer would read "gekündigt" on their settings page and
// keep paying. Nothing is written, and they are asked to try again — which
// costs nobody anything, because a cancellation has no deadline.
expect($instance->fresh()->status)->toBe('active')
->and($instance->fresh()->service_ends_at)->toBeNull()
->and($contract->fresh()->cancel_requested_at)->toBeNull();
});
/**
* A contract that has ended, and a Stripe that has not noticed.
*/
function endedContract(string $endedAt = '2026-04-01 09:00:00'): Subscription
{
$customer = Customer::factory()->create(['name' => 'Berger GmbH', 'email' => 'kundin@example.test']);
return Subscription::factory()->plan('team')->create([
'customer_id' => $customer->id,
'stripe_subscription_id' => 'sub_ended',
'current_period_start' => '2026-03-01 09:00:00',
'current_period_end' => $endedAt,
'status' => 'cancelled',
'cancelled_at' => $endedAt,
]);
}
/** The event Stripe raises when a term rolls over and is paid. */
function cyclePaid(string $invoiceId, string $periodStart, string $periodEnd): array
{
return [
'id' => 'evt_'.$invoiceId,
'type' => 'invoice.paid',
'data' => ['object' => [
'id' => $invoiceId,
'subscription' => 'sub_ended',
'amount_paid' => 21480,
'total' => 21480,
'billing_reason' => 'subscription_cycle',
'period_start' => Carbon::parse($periodStart)->timestamp,
'period_end' => Carbon::parse($periodEnd)->timestamp,
]],
];
}
it('issues no invoice and uses no number for a term that begins after the contract ended', function () {
Mail::fake();
$contract = endedContract();
// Stripe charged for April, and the contract ended on the first of April.
// That is a month the customer does not have.
$this->postJson(route('webhooks.stripe'), cyclePaid('in_after', '2026-04-01 09:00:00', '2026-05-01 09:00:00'))
->assertOk();
// No document, and no mail carrying one.
expect(Invoice::query()->count())->toBe(0);
Mail::assertNothingQueued();
// The money moved all the same, and the register is where an operator finds
// the payments that have to go back — so it IS recorded. What must not
// happen is a lawful invoice being written for a service that no longer
// exists.
expect(SubscriptionRecord::query()
->where('subscription_id', $contract->id)
->where('event', SubscriptionRecord::EVENT_RENEWAL)
->count())->toBe(1);
// And the number was not merely unused, it was never drawn: the next
// legitimate document is still the first of the year. A gapless series
// cannot afford a number that went nowhere.
$final = cyclePaid('in_final', '2026-03-01 09:00:00', '2026-04-01 09:00:00');
$this->postJson(route('webhooks.stripe'), $final)->assertOk();
expect(Invoice::query()->sole()->number)->toBe('RE-2026-0001');
});
it('still issues the final invoice for a term the customer really did use', function () {
Mail::fake();
$contract = endedContract();
// The one case refusing everything would lose. The cycle invoice for March
// went unpaid, Stripe's dunning ran, the contract ended on the first of
// April in the middle of it — and then the customer paid. That money is for
// a month they had a running cloud in, and they are owed the document for it.
$this->travelTo('2026-04-05 11:00:00');
$this->postJson(route('webhooks.stripe'), cyclePaid('in_march', '2026-03-01 09:00:00', '2026-04-01 09:00:00'))
->assertOk();
$invoice = Invoice::query()->sole();
expect($invoice->stripe_invoice_id)->toBe('in_march')
->and($invoice->subscription_id)->toBe($contract->id)
->and($invoice->number)->toBe('RE-2026-0001')
// Issued AND sent: the customer gets the paper, not just a row.
->and($invoice->sent_at)->not->toBeNull();
Mail::assertQueued(InvoiceMail::class);
});