Mahnwesen: Fall, Zeitplan und Gebuehrenstufen

main
nexxo 2026-07-31 21:51:42 +02:00
parent 93e848d3f9
commit 9304382f62
6 changed files with 379 additions and 0 deletions

View File

@ -281,6 +281,14 @@ class ApplyStripeBillingEvent
// since paid back to past_due.
$this->mutateInOrder($subscription, $eventAt, self::RANK_FAILED, fn () => ['stripe_status' => 'past_due']);
// Der Mahnfall. Bis hierher endete es an dieser Stelle: `past_due`
// gesetzt, Datensatz geschrieben, und niemand erfuhr davon — nicht der
// Kunde, nicht der Betreiber. Je Rechnung genau einmal, siehe
// OpenDunningCase.
if ($invoiceId !== '') {
app(OpenDunningCase::class)($subscription, $invoiceId);
}
return $this->recordOnce(
fn () => ($this->record)(
event: SubscriptionRecord::EVENT_PAYMENT_FAILED,

View File

@ -0,0 +1,48 @@
<?php
namespace App\Actions;
use App\Models\DunningCase;
use App\Models\Subscription;
use App\Services\Billing\DunningSchedule;
use Illuminate\Support\Carbon;
/**
* Der Fall, der entsteht, wenn eine Abbuchung zum ersten Mal scheitert.
*
* Hängt an ApplyStripeBillingEvent::invoicePaymentFailed(), das bis hierher
* `past_due` setzte und schwieg. Der Kunde erfuhr davon erst, wenn seine Cloud
* stand.
*
* JE RECHNUNG GENAU EINMAL. Stripe versucht dieselbe Rechnung mehrfach, und
* jeder Versuch ist ein eigenes Ereignis ohne diese Bedingung entstünden drei
* Fälle für eine Schuld, mit drei Mahnläufen und dreifachen Gebühren. Die
* Eindeutigkeit steht zusätzlich als Index in der Tabelle, damit zwei
* gleichzeitig eintreffende Ereignisse sie nicht umgehen können.
*
* Eröffnet wird auf Stufe 0 dem HINWEIS. Keine Mahnung, keine Gebühr: eine
* abgelaufene Karte ist keine Zahlungsverweigerung.
*/
class OpenDunningCase
{
public function __invoke(Subscription $subscription, string $invoiceId): ?DunningCase
{
if ($invoiceId === '') {
return null;
}
$now = Carbon::now();
return DunningCase::query()->firstOrCreate(
['stripe_invoice_id' => $invoiceId],
[
'subscription_id' => $subscription->id,
'level' => 0,
'opened_at' => $now,
// Die erste Mahnung, nicht der Hinweis: der geht sofort hinaus.
'next_step_at' => $now->copy()->addDays(DunningSchedule::dayOfLevel(1)),
'fee_invoice_ids' => [],
],
);
}
}

View File

@ -0,0 +1,46 @@
<?php
namespace App\Models;
use App\Services\Billing\DunningSchedule;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Eine Rückstands-Episode: von der ersten gescheiterten Abbuchung bis zur
* Begleichung oder zur Sperre.
*/
class DunningCase extends Model
{
protected $fillable = [
'subscription_id', 'stripe_invoice_id', 'level',
'opened_at', 'next_step_at', 'settled_at', 'fee_invoice_ids', 'note',
];
protected function casts(): array
{
return [
'level' => 'integer',
'opened_at' => 'datetime',
'next_step_at' => 'datetime',
'settled_at' => 'datetime',
'fee_invoice_ids' => 'array',
];
}
public function subscription(): BelongsTo
{
return $this->belongsTo(Subscription::class);
}
/** Läuft noch — weder beglichen noch von Hand geschlossen. */
public function isOpen(): bool
{
return $this->settled_at === null;
}
public function isSuspended(): bool
{
return $this->level >= DunningSchedule::SUSPENDED;
}
}

View File

@ -0,0 +1,92 @@
<?php
namespace App\Services\Billing;
use App\Support\Settings;
/**
* Wann gemahnt wird und was es kostet an einer Stelle, einstellbar.
*
* Fünf Stufen, gezählt in Tagen seit der ersten gescheiterten Abbuchung:
*
* 0 Hinweis „Ihre Abbuchung ist gescheitert, hier ist der Knopf"
* 1 erste Mahnung
* 2 zweite Mahnung ab hier Gebühr (Vorgabe)
* 3 dritte Mahnung
* 4 Cloud aus
*
* **Stufe 0 ist keine Mahnung.** Keine Gebühr, keine Frist im Rechtssinn nur
* die Nachricht, dass etwas schiefging. Eine abgelaufene Karte ist keine
* Zahlungsverweigerung, und wer sie am selben Tag mahnt, verliert Kunden, die
* zahlen wollten.
*/
final class DunningSchedule
{
public const DAYS = 'dunning.days';
public const FEE_FROM_LEVEL = 'dunning.fee_from_level';
public const FEES = 'dunning.fee_cents';
/** Die letzte Stufe: ab hier steht die Cloud. */
public const SUSPENDED = 4;
/** @var array<int, int> Stufe => Tage seit dem ersten Fehlschlag */
public const DEFAULT_DAYS = [0, 3, 10, 17, 24];
/** @var array<int, int> Stufe => Gebühr in Cent */
public const DEFAULT_FEES = [2 => 500, 3 => 1000];
public const DEFAULT_FEE_FROM_LEVEL = 2;
/**
* Die fünf Fristen, aufsteigend.
*
* Sortiert statt geglaubt: eine Reihenfolge, die rückwärts läuft, ist kein
* Zeitplan der Tageslauf spränge über Stufen, und die dritte Mahnung
* stünde vor der ersten. Ein Formular kann das erzeugen, also wird es hier
* abgefangen und nicht dort erhofft.
*
* @return array<int, int>
*/
public static function days(): array
{
$stored = Settings::get(self::DAYS);
$days = is_array($stored) && count($stored) === count(self::DEFAULT_DAYS)
? array_map('intval', array_values($stored))
: self::DEFAULT_DAYS;
sort($days);
return $days;
}
public static function dayOfLevel(int $level): int
{
return self::days()[$level] ?? self::DEFAULT_DAYS[$level] ?? 0;
}
/** Ab welcher Stufe eine Gebühr anfällt. */
public static function feeFromLevel(): int
{
$stored = Settings::get(self::FEE_FROM_LEVEL);
// Nie vor Stufe 1: Stufe 0 ist der Hinweis, und eine Gebühr darauf
// wäre eine Mahngebühr ohne Mahnung.
return is_numeric($stored) ? max(1, (int) $stored) : self::DEFAULT_FEE_FROM_LEVEL;
}
/** Was diese Stufe kostet, in Cent. Null unterhalb der eingestellten Stufe. */
public static function feeCents(int $level): int
{
if ($level < self::feeFromLevel()) {
return 0;
}
$stored = Settings::get(self::FEES);
$fees = is_array($stored) ? $stored : self::DEFAULT_FEES;
return (int) ($fees[$level] ?? self::DEFAULT_FEES[$level] ?? 0);
}
}

View File

@ -0,0 +1,58 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Eine Zeile je RÜCKSTANDS-EPISODE, nicht je Vertrag.
*
* Eine Spalte an `subscriptions` wäre kürzer gewesen und falsch: ein Kunde
* kann im März und im Juni in Rückstand geraten, und die zweite Episode
* überschriebe die erste. Genau die will jemand sehen, wenn der Kunde anruft
* und fragt, warum schon wieder Gebühren anfallen.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('dunning_cases', function (Blueprint $table) {
$table->id();
$table->foreignId('subscription_id')->constrained()->cascadeOnDelete();
/**
* Die gescheiterte Rechnung. EINDEUTIG: Stripe versucht dieselbe
* mehrfach, und jeder Versuch ist ein eigenes Ereignis ohne
* diesen Index entstünden drei Fälle für eine Schuld, mit drei
* Mahnläufen und dreifachen Gebühren.
*/
$table->string('stripe_invoice_id')->unique();
// 0 Hinweis, 13 Mahnstufen, 4 gesperrt. Siehe DunningSchedule.
$table->unsignedTinyInteger('level')->default(0);
$table->timestamp('opened_at');
$table->timestamp('next_step_at')->nullable()->index();
$table->timestamp('settled_at')->nullable()->index();
/**
* Die ausgestellten Gebührenrechnungen, eine je Stufe.
*
* Als Liste am Fall und nicht als eigene Tabelle: sie werden nur
* gemeinsam mit ihrem Fall gelesen, nie für sich, und eine Tabelle
* mit höchstens drei Zeilen je Fall wäre ein Join ohne Gewinn.
*/
$table->json('fee_invoice_ids')->nullable();
/** Was ein Mensch eingegriffen hat — Frist verlängert, Fall geschlossen. */
$table->text('note')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('dunning_cases');
}
};

View File

@ -0,0 +1,127 @@
<?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);
});