create([ 'name' => 'Berger GmbH', 'email' => 'berger@example.test', 'stripe_customer_id' => 'cus_42', ]); $order = Order::factory()->create(['customer_id' => $customer->id, 'plan' => 'start']); Instance::factory()->create([ 'customer_id' => $customer->id, 'order_id' => $order->id, 'plan' => 'start', 'status' => 'active', 'vmid' => 101, ]); $subscription = Subscription::factory()->create([ 'customer_id' => $customer->id, 'order_id' => $order->id, 'stripe_subscription_id' => 'sub_42', 'stripe_status' => 'past_due', 'price_cents' => 21480, ]); return DunningCase::query()->create([ 'subscription_id' => $subscription->id, 'stripe_invoice_id' => 'in_rueckstand', 'level' => $level, 'opened_at' => Carbon::now()->subDays(DunningSchedule::dayOfLevel($level)), 'next_step_at' => Carbon::now()->subMinute(), 'fee_invoice_ids' => [], // Alles bis zur aktuellen Stufe wurde bereits mitgeteilt — so sieht ein // Fall im Betrieb aus. Ohne diese Zeile hielte der Nachhol-Lauf die // Nachricht zur aktuellen Stufe für verloren und schickte sie zu Recht // nach. 'notified_levels' => range(0, $level), ]); } it('tells the customer at once when the charge fails', function () { // Sofort, nicht erst mit dem Tageslauf: der Kunde soll davon erfahren, // solange er noch weiss, wovon die Rede ist. Mail::fake(); $customer = Customer::factory()->create(['email' => 'berger@example.test', 'stripe_customer_id' => 'cus_42']); Subscription::factory()->create([ 'customer_id' => $customer->id, 'stripe_subscription_id' => 'sub_42', 'price_cents' => 21480, ]); app(ApplyStripeBillingEvent::class)->invoicePaymentFailed([ 'id' => 'in_1', 'subscription' => 'sub_42', ]); Mail::assertQueued(DunningNoticeMail::class, fn ($mail) => $mail->level === 0 && $mail->feeCents === 0 && $mail->hasTo('berger@example.test')); }); it('does not write again for a second failed attempt on the same invoice', function () { Mail::fake(); $customer = Customer::factory()->create(['email' => 'berger@example.test', 'stripe_customer_id' => 'cus_42']); Subscription::factory()->create([ 'customer_id' => $customer->id, 'stripe_subscription_id' => 'sub_42', ]); foreach ([1, 2, 3] as $attempt) { app(ApplyStripeBillingEvent::class)->invoicePaymentFailed([ 'id' => 'in_1', 'subscription' => 'sub_42', 'attempt_count' => $attempt, ]); } Mail::assertQueued(DunningNoticeMail::class, 1); }); it('sends a reminder with the fee spelled out separately', function () { // Rückstand und Mahnspesen getrennt: wer eine Zahl liest, die höher ist als // seine Rechnung, hält sie für einen Fehler und schreibt dem Support, // statt zu zahlen. Mail::fake(); $case = mailableCase(1); $case->update(['fee_invoice_ids' => []]); app()->instance(StripeClient::class, new FakeStripeClient); app()->instance(ProxmoxClient::class, new FakeProxmoxClient); $this->artisan('clupilot:advance-dunning')->assertSuccessful(); Mail::assertQueued(DunningNoticeMail::class, fn ($mail) => $mail->level === 2 && $mail->amountCents === 21480 && $mail->feeTotalCents === 500); }); it('names the shutdown date while it is still ahead', function () { Mail::fake(); mailableCase(0); app()->instance(StripeClient::class, new FakeStripeClient); app()->instance(ProxmoxClient::class, new FakeProxmoxClient); $this->artisan('clupilot:advance-dunning'); Mail::assertQueued(DunningNoticeMail::class, fn ($mail) => $mail->level === 1 && $mail->suspendOn !== null); }); it('sends the shutdown notice, not a fourth reminder', function () { Mail::fake(); mailableCase(DunningSchedule::SUSPENDED - 1); app()->instance(StripeClient::class, new FakeStripeClient); app()->instance(ProxmoxClient::class, new FakeProxmoxClient); $this->artisan('clupilot:advance-dunning'); Mail::assertQueued(CloudSuspendedMail::class, 1); Mail::assertNotQueued(DunningNoticeMail::class); }); it('sends the all-clear only to somebody whose cloud actually stood', function () { Mail::fake(); $case = mailableCase(DunningSchedule::SUSPENDED); app()->instance(ProxmoxClient::class, new FakeProxmoxClient); app()->instance(StripeClient::class, new FakeStripeClient); app(ApplyStripeBillingEvent::class)->invoicePaid([ 'id' => 'in_rueckstand', 'subscription' => 'sub_42', 'billing_reason' => 'subscription_cycle', ]); expect($case->fresh()->settled_at)->not->toBeNull(); Mail::assertQueued(CloudResumedMail::class, 1); }); it('stays quiet about a cloud that never stood', function () { // Wer auf Stufe 1 bezahlt, hat nie eine Sperre gesehen. Ihm zu schreiben, // seine Cloud laufe wieder, wäre eine Nachricht über etwas, das nie war. Mail::fake(); mailableCase(1); app()->instance(ProxmoxClient::class, new FakeProxmoxClient); app()->instance(StripeClient::class, new FakeStripeClient); app(ApplyStripeBillingEvent::class)->invoicePaid([ 'id' => 'in_rueckstand', 'subscription' => 'sub_42', 'billing_reason' => 'subscription_cycle', ]); Mail::assertNotQueued(CloudResumedMail::class); }); it('advances the case even when the mail cannot be sent', function () { // Ein Mailserver, der gerade nicht antwortet, darf keinen Fall aufhalten — // sonst bleibt der Kunde auf der Stufe stehen, auf der die Mail scheiterte, // und die Sperre kommt nie. $case = mailableCase(0); app()->instance(StripeClient::class, new FakeStripeClient); app()->instance(ProxmoxClient::class, new FakeProxmoxClient); Mail::shouldReceive('to')->andThrow(new RuntimeException('kein Mailserver')); $this->artisan('clupilot:advance-dunning')->assertSuccessful(); expect($case->fresh()->level)->toBe(1); });