CluPilotCloud/tests/Feature/Billing/DunningFoundationTest.php

128 lines
5.1 KiB
PHP

<?php
use App\Actions\ApplyStripeBillingEvent;
use App\Models\Customer;
use App\Models\DunningCase;
use App\Models\Subscription;
use App\Services\Billing\DunningSchedule;
use App\Support\Settings;
use Illuminate\Support\Carbon;
/**
* Das Fundament des Mahnlaufs.
*
* Bis hierher setzte `invoicePaymentFailed()` den Vertrag auf `past_due` und
* schwieg — keine Mail, keine Anzeige, keine Frist. Der Kunde erfuhr davon
* erst, wenn seine Cloud stand.
*
* Der Zustand liegt in einer EIGENEN Tabelle, nicht als Spalte am Vertrag: ein
* Kunde kann mehr als einmal in Rückstand geraten, und eine Spalte
* überschriebe die vorige Episode — genau die will jemand sehen, wenn der
* Kunde anruft und fragt, warum schon wieder Gebühren anfallen.
*/
function subscriptionInArrears(): Subscription
{
$customer = Customer::factory()->create(['stripe_customer_id' => 'cus_42']);
return Subscription::factory()->create([
'customer_id' => $customer->id,
'stripe_subscription_id' => 'sub_42',
'stripe_status' => 'active',
]);
}
// ---- Der Zeitplan --------------------------------------------------------
it('starts with the schedule the owner agreed', function () {
expect(DunningSchedule::dayOfLevel(0))->toBe(0)
->and(DunningSchedule::dayOfLevel(1))->toBe(3)
->and(DunningSchedule::dayOfLevel(2))->toBe(10)
->and(DunningSchedule::dayOfLevel(3))->toBe(17)
->and(DunningSchedule::dayOfLevel(DunningSchedule::SUSPENDED))->toBe(24);
});
it('charges nothing before the configured level, and the agreed amounts after', function () {
// Tag 0 ist ein HINWEIS, keine Mahnung: eine abgelaufene Karte ist keine
// Zahlungsverweigerung, und wer sie am selben Tag mit einer Gebühr
// bedenkt, verliert Kunden, die zahlen wollten.
expect(DunningSchedule::feeCents(0))->toBe(0)
->and(DunningSchedule::feeCents(1))->toBe(0)
->and(DunningSchedule::feeCents(2))->toBe(500)
->and(DunningSchedule::feeCents(3))->toBe(1000);
});
it('lets the console move the level a fee starts at', function () {
Settings::set(DunningSchedule::FEE_FROM_LEVEL, 3);
expect(DunningSchedule::feeCents(2))->toBe(0)
->and(DunningSchedule::feeCents(3))->toBe(1000);
});
it('lets the console move the deadlines, and keeps them in order', function () {
Settings::set(DunningSchedule::DAYS, [0, 5, 12, 20, 30]);
expect(DunningSchedule::dayOfLevel(1))->toBe(5)
->and(DunningSchedule::dayOfLevel(DunningSchedule::SUSPENDED))->toBe(30);
// Eine Reihenfolge, die rückwärts läuft, ist kein Zeitplan. Geklemmt statt
// gespeichert: sonst stünde die dritte Mahnung vor der ersten und der
// Tageslauf spränge über Stufen.
Settings::set(DunningSchedule::DAYS, [0, 20, 5, 12, 30]);
$tage = DunningSchedule::days();
expect($tage)->toBe(array_values(collect($tage)->sort()->all()));
});
// ---- Der Fall entsteht ---------------------------------------------------
it('opens a case the first time a payment fails', function () {
$subscription = subscriptionInArrears();
app(ApplyStripeBillingEvent::class)->invoicePaymentFailed([
'id' => 'in_1', 'subscription' => 'sub_42', 'attempt_count' => 1,
]);
$case = DunningCase::query()->where('subscription_id', $subscription->id)->sole();
expect($case->level)->toBe(0)
->and($case->stripe_invoice_id)->toBe('in_1')
->and($case->settled_at)->toBeNull()
// Tag 0 ist heute — der Hinweis geht sofort hinaus, die erste Mahnung
// erst nach der ersten Frist.
->and($case->next_step_at->isSameDay(Carbon::now()->addDays(3)))->toBeTrue();
});
it('does not open a second case for the same invoice', function () {
// Stripe versucht dieselbe Rechnung mehrfach, und jeder Versuch ist ein
// eigenes Ereignis. Zwei Fälle für eine Schuld hiesse zwei Mahnläufe und
// doppelte Gebühren.
subscriptionInArrears();
foreach ([1, 2, 3] as $attempt) {
app(ApplyStripeBillingEvent::class)->invoicePaymentFailed([
'id' => 'in_1', 'subscription' => 'sub_42', 'attempt_count' => $attempt,
]);
}
expect(DunningCase::query()->count())->toBe(1);
});
it('opens a fresh case for a later, different invoice', function () {
// Der Grund für die eigene Tabelle: ein Kunde kann im März und im Juni in
// Rückstand geraten, und beide Episoden gehören nachlesbar nebeneinander.
$subscription = subscriptionInArrears();
app(ApplyStripeBillingEvent::class)->invoicePaymentFailed(['id' => 'in_1', 'subscription' => 'sub_42']);
DunningCase::query()->first()->update(['settled_at' => Carbon::now()]);
app(ApplyStripeBillingEvent::class)->invoicePaymentFailed(['id' => 'in_2', 'subscription' => 'sub_42']);
expect(DunningCase::query()->where('subscription_id', $subscription->id)->count())->toBe(2)
->and(DunningCase::query()->whereNull('settled_at')->sole()->stripe_invoice_id)->toBe('in_2');
});
it('leaves the contract alone when nothing can be resolved', function () {
app(ApplyStripeBillingEvent::class)->invoicePaymentFailed(['id' => 'in_1', 'subscription' => 'sub_unbekannt']);
expect(DunningCase::query()->count())->toBe(0);
});