178 lines
6.7 KiB
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);
|
|
});
|