128 lines
5.1 KiB
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);
|
|
});
|