CluPilotCloud/tests/Feature/Billing/DunningMailTest.php

178 lines
6.7 KiB
PHP

<?php
use App\Actions\ApplyStripeBillingEvent;
use App\Mail\CloudResumedMail;
use App\Mail\CloudSuspendedMail;
use App\Mail\DunningNoticeMail;
use App\Models\Customer;
use App\Models\DunningCase;
use App\Models\Instance;
use App\Models\Order;
use App\Models\Subscription;
use App\Services\Billing\DunningSchedule;
use App\Services\Proxmox\FakeProxmoxClient;
use App\Services\Proxmox\ProxmoxClient;
use App\Services\Stripe\FakeStripeClient;
use App\Services\Stripe\StripeClient;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Mail;
/**
* Ohne die Mails merkt der Kunde von alldem nichts. Der Mahnlauf wäre dann eine
* Maschine, die still Fristen zählt und am 24. Tag die Cloud abschaltet — für
* den Kunden aus heiterem Himmel.
*/
function mailableCase(int $level = 0): DunningCase
{
$customer = Customer::factory()->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);
});