'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); });