From 9304382f62ee2fa403b2b6635c6a422c4ef4742a Mon Sep 17 00:00:00 2001 From: nexxo Date: Fri, 31 Jul 2026 21:51:42 +0200 Subject: [PATCH] Mahnwesen: Fall, Zeitplan und Gebuehrenstufen --- app/Actions/ApplyStripeBillingEvent.php | 8 ++ app/Actions/OpenDunningCase.php | 48 +++++++ app/Models/DunningCase.php | 46 +++++++ app/Services/Billing/DunningSchedule.php | 92 +++++++++++++ ...7_31_140000_create_dunning_cases_table.php | 58 ++++++++ .../Feature/Billing/DunningFoundationTest.php | 127 ++++++++++++++++++ 6 files changed, 379 insertions(+) create mode 100644 app/Actions/OpenDunningCase.php create mode 100644 app/Models/DunningCase.php create mode 100644 app/Services/Billing/DunningSchedule.php create mode 100644 database/migrations/2026_07_31_140000_create_dunning_cases_table.php create mode 100644 tests/Feature/Billing/DunningFoundationTest.php diff --git a/app/Actions/ApplyStripeBillingEvent.php b/app/Actions/ApplyStripeBillingEvent.php index 519d466..11441fe 100644 --- a/app/Actions/ApplyStripeBillingEvent.php +++ b/app/Actions/ApplyStripeBillingEvent.php @@ -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, diff --git a/app/Actions/OpenDunningCase.php b/app/Actions/OpenDunningCase.php new file mode 100644 index 0000000..0e55369 --- /dev/null +++ b/app/Actions/OpenDunningCase.php @@ -0,0 +1,48 @@ +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' => [], + ], + ); + } +} diff --git a/app/Models/DunningCase.php b/app/Models/DunningCase.php new file mode 100644 index 0000000..0b23117 --- /dev/null +++ b/app/Models/DunningCase.php @@ -0,0 +1,46 @@ + '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; + } +} diff --git a/app/Services/Billing/DunningSchedule.php b/app/Services/Billing/DunningSchedule.php new file mode 100644 index 0000000..ed8fe5e --- /dev/null +++ b/app/Services/Billing/DunningSchedule.php @@ -0,0 +1,92 @@ + Stufe => Tage seit dem ersten Fehlschlag */ + public const DEFAULT_DAYS = [0, 3, 10, 17, 24]; + + /** @var array 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 + */ + 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); + } +} diff --git a/database/migrations/2026_07_31_140000_create_dunning_cases_table.php b/database/migrations/2026_07_31_140000_create_dunning_cases_table.php new file mode 100644 index 0000000..6dc402d --- /dev/null +++ b/database/migrations/2026_07_31_140000_create_dunning_cases_table.php @@ -0,0 +1,58 @@ +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, 1–3 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'); + } +}; diff --git a/tests/Feature/Billing/DunningFoundationTest.php b/tests/Feature/Billing/DunningFoundationTest.php new file mode 100644 index 0000000..9f5853a --- /dev/null +++ b/tests/Feature/Billing/DunningFoundationTest.php @@ -0,0 +1,127 @@ +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); +});