296 lines
12 KiB
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);
|
|
});
|