From 4f037c8264eef02202f9500c699b73ef8591f0db Mon Sep 17 00:00:00 2001 From: nexxo Date: Fri, 31 Jul 2026 22:43:52 +0200 Subject: [PATCH] Mahnwesen: Einstellungen und die zwei Eingriffe von Hand --- app/Livewire/Admin/CloseDunningCase.php | 49 ++++++ app/Livewire/Admin/ExtendDunningDeadline.php | 49 ++++++ app/Livewire/Admin/PaymentProblems.php | 105 +++++++++++++ lang/de/payment_problems.php | 25 +++ lang/en/payment_problems.php | 25 +++ .../admin/close-dunning-case.blade.php | 18 +++ .../admin/extend-dunning-deadline.blade.php | 19 +++ .../livewire/admin/payment-problems.blade.php | 65 +++++++- tests/Feature/Billing/DunningControlsTest.php | 143 ++++++++++++++++++ 9 files changed, 496 insertions(+), 2 deletions(-) create mode 100644 app/Livewire/Admin/CloseDunningCase.php create mode 100644 app/Livewire/Admin/ExtendDunningDeadline.php create mode 100644 resources/views/livewire/admin/close-dunning-case.blade.php create mode 100644 resources/views/livewire/admin/extend-dunning-deadline.blade.php create mode 100644 tests/Feature/Billing/DunningControlsTest.php diff --git a/app/Livewire/Admin/CloseDunningCase.php b/app/Livewire/Admin/CloseDunningCase.php new file mode 100644 index 0000000..6df2162 --- /dev/null +++ b/app/Livewire/Admin/CloseDunningCase.php @@ -0,0 +1,49 @@ +authorize('billing.manage'); + + $case = DunningCase::query()->with('subscription.customer')->findOr($id, fn () => abort(404)); + + $this->id = $case->id; + $this->label = $case->subscription?->customer?->name ?? '—'; + } + + public function confirm(): void + { + $this->authorize('billing.manage'); + + $this->dispatch('dunning-case-closed', id: $this->id, days: (int) $this->days, note: $this->note); + $this->closeModal(); + } + + public function render() + { + return view('livewire.admin.close-dunning-case'); + } +} diff --git a/app/Livewire/Admin/ExtendDunningDeadline.php b/app/Livewire/Admin/ExtendDunningDeadline.php new file mode 100644 index 0000000..baea66a --- /dev/null +++ b/app/Livewire/Admin/ExtendDunningDeadline.php @@ -0,0 +1,49 @@ +authorize('billing.manage'); + + $case = DunningCase::query()->with('subscription.customer')->findOr($id, fn () => abort(404)); + + $this->id = $case->id; + $this->label = $case->subscription?->customer?->name ?? '—'; + } + + public function confirm(): void + { + $this->authorize('billing.manage'); + + $this->dispatch('dunning-deadline-extended', id: $this->id, days: (int) $this->days, note: $this->note); + $this->closeModal(); + } + + public function render() + { + return view('livewire.admin.extend-dunning-deadline'); + } +} diff --git a/app/Livewire/Admin/PaymentProblems.php b/app/Livewire/Admin/PaymentProblems.php index 72118ca..5b126aa 100644 --- a/app/Livewire/Admin/PaymentProblems.php +++ b/app/Livewire/Admin/PaymentProblems.php @@ -4,6 +4,8 @@ namespace App\Livewire\Admin; use App\Models\DunningCase; use App\Models\FailedCheckout; +use App\Services\Billing\DunningSchedule; +use App\Support\Settings; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Gate; @@ -33,9 +35,112 @@ use Livewire\Component; #[Layout('layouts.admin')] class PaymentProblems extends Component { + /** @var array Die fünf Fristen in Tagen, Stufe 0 bis 4. */ + public array $days = []; + + public int|string $feeFromLevel = 2; + + /** @var array Stufe => Gebühr in Cent */ + public array $fees = []; + public function mount(): void { abort_unless(Gate::allows('billing.manage'), 403); + + $this->days = DunningSchedule::days(); + $this->feeFromLevel = DunningSchedule::feeFromLevel(); + $this->fees = [2 => DunningSchedule::feeCents(2), 3 => DunningSchedule::feeCents(3)]; + } + + /** + * Fristen und Gebühren speichern. + * + * Rückwärts laufende Fristen werden ABGELEHNT, nicht still sortiert. Der + * Zeitplan selbst sortiert beim Lesen, damit ein Altbestand nichts kaputt + * macht — aber wer hier 20/5/12 eintippt, hat sich vertan und soll es + * sehen, statt eine andere Reihenfolge gespeichert zu bekommen als die, + * die er gelesen hat. + */ + public function saveSchedule(): void + { + $this->authorize('billing.manage'); + + $this->validate([ + 'days' => ['required', 'array', 'size:5'], + 'days.*' => ['required', 'integer', 'min:0', 'max:365'], + 'feeFromLevel' => ['required', 'integer', 'min:1', 'max:3'], + 'fees.*' => ['required', 'integer', 'min:0', 'max:100000'], + ]); + + $tage = array_map('intval', array_values($this->days)); + + if ($tage !== array_values(collect($tage)->sort()->all())) { + $this->addError('days', __('payment_problems.days_out_of_order')); + + return; + } + + Settings::set(DunningSchedule::DAYS, $tage); + Settings::set(DunningSchedule::FEE_FROM_LEVEL, (int) $this->feeFromLevel); + Settings::set(DunningSchedule::FEES, array_map('intval', $this->fees)); + + $this->dispatch('notify', message: __('payment_problems.schedule_saved')); + } + + /** ExtendDunningDeadline reicht das zurück (R23). */ + #[On('dunning-deadline-extended')] + public function onExtended(int $id, int $days, string $note = ''): void + { + $this->authorize('billing.manage'); + + $case = DunningCase::query()->findOr($id, fn () => abort(404)); + + // Ab der Sperre gibt es keine nächste Frist mehr: die Cloud steht, und + // was jetzt hilft, ist Bezahlen oder das Schliessen von Hand. + if ($case->next_step_at === null || $case->isSuspended()) { + $this->addError('days', __('payment_problems.extend_impossible')); + + return; + } + + $case->update([ + 'next_step_at' => $case->next_step_at->copy()->addDays(max(1, $days)), + 'note' => $this->stamp($case->note, $note), + ]); + + $this->dispatch('notify', message: __('payment_problems.extended')); + } + + /** CloseDunningCase reicht das zurück (R23). */ + #[On('dunning-case-closed')] + public function onClosed(int $id, string $note = '', int $days = 0): void + { + $this->authorize('billing.manage'); + + // Ohne Begründung nicht. Hier hängt Geld daran, das danach niemand + // mehr eintreibt — und „erledigt" allein beantwortet beim nächsten + // Nachfragen nichts. + if (trim($note) === '') { + $this->addError('note', __('payment_problems.close_needs_reason')); + + return; + } + + DunningCase::query()->findOr($id, fn () => abort(404))->update([ + 'settled_at' => Carbon::now(), + 'note' => $this->stamp(null, $note), + ]); + + $this->dispatch('notify', message: __('payment_problems.closed')); + } + + /** Begründung plus Person — wer und warum, nicht nur dass. */ + private function stamp(?string $bisher, string $note): string + { + $wer = Auth::guard('operator')->user()?->email ?? 'console'; + $neu = trim($note) !== '' ? trim($note).' — '.$wer : $wer; + + return trim(($bisher ? $bisher."\n" : '').$neu); } /** diff --git a/lang/de/payment_problems.php b/lang/de/payment_problems.php index a09dbe1..c243e9b 100644 --- a/lang/de/payment_problems.php +++ b/lang/de/payment_problems.php @@ -24,4 +24,29 @@ return [ 'resolve_title' => 'Als erledigt vermerken?', 'resolve_body' => 'Der Vorgang verschwindet aus dieser Liste. Was Sie hier eintragen, steht später daneben — zusammen mit Ihrer Adresse.', 'note_hint' => 'Zum Beispiel: hat überwiesen, hat neu bestellt, nicht erreichbar.', + 'schedule_title' => 'Fristen und Gebühren', + 'schedule_body' => 'Tage seit der ersten gescheiterten Abbuchung. Stufe 0 ist der Hinweis und kostet nie etwas — eine abgelaufene Karte ist keine Zahlungsverweigerung.', + 'day_0' => 'Hinweis', + 'day_1' => '1. Mahnung', + 'day_2' => '2. Mahnung', + 'day_3' => '3. Mahnung', + 'day_4' => 'Abschaltung', + 'fee_from' => 'Gebühr ab Stufe', + 'fee_2' => 'Gebühr 2. Mahnung (Cent)', + 'fee_3' => 'Gebühr 3. Mahnung (Cent)', + 'schedule_saved' => 'Fristen und Gebühren gespeichert. Sie gelten ab dem nächsten Tageslauf.', + 'days_out_of_order' => 'Die Fristen müssen aufsteigend sein. So stünde eine spätere Mahnung vor einer früheren.', + 'extend' => 'Frist verlängern', + 'extend_title' => 'Frist verlängern', + 'extend_days' => 'Um wie viele Tage', + 'extend_body' => 'Der nächste Schritt verschiebt sich. Alles Weitere rückt mit — die Abschaltung also auch.', + 'extend_note_hint' => 'Zum Beispiel: Kunde überweist bis Freitag.', + 'extended' => 'Frist verlängert.', + 'extend_impossible' => 'Dieser Fall hat keine nächste Frist mehr — die Cloud steht bereits.', + 'close' => 'Fall schliessen', + 'close_title' => 'Mahnfall von Hand schliessen?', + 'close_warning' => 'Die Automatik hört für diesen Fall auf: keine weitere Mahnung, keine Gebühr, keine Abschaltung. Der offene Betrag bleibt offen — hier wird nichts verbucht und nichts erlassen.', + 'close_note_hint' => 'Zum Beispiel: Überweisung am 30.7. eingegangen.', + 'close_needs_reason' => 'Bitte eine Begründung eintragen — ohne sie ist später nicht mehr nachvollziehbar, warum hier nichts weiter geschah.', + 'closed' => 'Mahnfall geschlossen.', ]; diff --git a/lang/en/payment_problems.php b/lang/en/payment_problems.php index be806e9..f29a572 100644 --- a/lang/en/payment_problems.php +++ b/lang/en/payment_problems.php @@ -24,4 +24,29 @@ return [ 'resolve_title' => 'Mark as dealt with?', 'resolve_body' => 'It leaves this list. What you write here stays with it afterwards, together with your address.', 'note_hint' => 'For example: paid by transfer, ordered again, unreachable.', + 'schedule_title' => 'Deadlines and fees', + 'schedule_body' => 'Days since the first failed charge. Level 0 is the notice and never costs anything — an expired card is not a refusal to pay.', + 'day_0' => 'Notice', + 'day_1' => '1st reminder', + 'day_2' => '2nd reminder', + 'day_3' => '3rd reminder', + 'day_4' => 'Shutdown', + 'fee_from' => 'Fee from level', + 'fee_2' => 'Fee, 2nd reminder (cents)', + 'fee_3' => 'Fee, 3rd reminder (cents)', + 'schedule_saved' => 'Deadlines and fees saved. They apply from the next daily run.', + 'days_out_of_order' => 'Deadlines must ascend. As entered, a later reminder would come before an earlier one.', + 'extend' => 'Extend deadline', + 'extend_title' => 'Extend the deadline', + 'extend_days' => 'By how many days', + 'extend_body' => 'The next step moves out. Everything after it moves with it — including the shutdown.', + 'extend_note_hint' => 'For example: customer pays by transfer on Friday.', + 'extended' => 'Deadline extended.', + 'extend_impossible' => 'This case has no next deadline — the cloud is already stopped.', + 'close' => 'Close case', + 'close_title' => 'Close the dunning case by hand?', + 'close_warning' => 'The automation stops for this case: no further reminder, no fee, no shutdown. The outstanding amount stays outstanding — nothing is booked and nothing is waived here.', + 'close_note_hint' => 'For example: bank transfer received on 30 July.', + 'close_needs_reason' => 'Please give a reason — without it nobody can tell later why nothing further happened here.', + 'closed' => 'Dunning case closed.', ]; diff --git a/resources/views/livewire/admin/close-dunning-case.blade.php b/resources/views/livewire/admin/close-dunning-case.blade.php new file mode 100644 index 0000000..65570fe --- /dev/null +++ b/resources/views/livewire/admin/close-dunning-case.blade.php @@ -0,0 +1,18 @@ + + {{-- Was das Schliessen WIRKLICH bedeutet, in einem Satz: die Automatik hört + auf, das Geld bleibt offen. Ohne diesen Satz liest sich „Fall + schliessen" wie „erledigt". --}} + {{ __('payment_problems.close_warning') }} + +
+ + + + + {{ __('common.cancel') }} + + {{ __('payment_problems.close') }} + + +
diff --git a/resources/views/livewire/admin/extend-dunning-deadline.blade.php b/resources/views/livewire/admin/extend-dunning-deadline.blade.php new file mode 100644 index 0000000..01977a1 --- /dev/null +++ b/resources/views/livewire/admin/extend-dunning-deadline.blade.php @@ -0,0 +1,19 @@ + +

{{ __('payment_problems.extend_body') }}

+ +
+
+ +
+ + + + + {{ __('common.cancel') }} + + {{ __('payment_problems.extend') }} + + +
diff --git a/resources/views/livewire/admin/payment-problems.blade.php b/resources/views/livewire/admin/payment-problems.blade.php index dca419f..ec12f52 100644 --- a/resources/views/livewire/admin/payment-problems.blade.php +++ b/resources/views/livewire/admin/payment-problems.blade.php @@ -4,6 +4,50 @@

{{ __('payment_problems.subtitle') }}

+ {{-- Die Einstellungen zugeklappt: die liest man einmal beim Einrichten, + die Fälle darunter jeden Tag. Aufgeklappt stünde die Arbeit hinter der + Konfiguration. --}} +
+ + +
+

{{ __('payment_problems.schedule_body') }}

+ +
+ @foreach ([0, 1, 2, 3, 4] as $stufe) + + @endforeach +
+ @error('days')

{{ $message }}

@enderror + +
+ + + +
+ +
+ + {{ __('common.save') }} + +
+
+
+ {{-- 1. Laufende Verträge im Rückstand. --}}
@@ -21,7 +65,8 @@ {{ __('payment_problems.customer') }} {{ __('payment_problems.level') }} {{ __('payment_problems.since') }} - {{ __('payment_problems.next_step') }} + {{ __('payment_problems.next_step') }} + @@ -37,9 +82,25 @@ {{-- R19: alles, was ein Mensch liest, geht über ->local(). --}} {{ $case->opened_at->local()->isoFormat('D. MMM YYYY') }} - + {{ $case->next_step_at?->local()->isoFormat('D. MMM YYYY') ?? '—' }} + {{-- R20: beide Eingriffe tragen ein Feld, gehen also + im Modal auf. Der Knopf schickt nur openModal. --}} + + + @unless ($case->isSuspended()) + + {{ __('payment_problems.extend') }} + + @endunless + + {{ __('payment_problems.close') }} + + + @endforeach diff --git a/tests/Feature/Billing/DunningControlsTest.php b/tests/Feature/Billing/DunningControlsTest.php new file mode 100644 index 0000000..321db0b --- /dev/null +++ b/tests/Feature/Billing/DunningControlsTest.php @@ -0,0 +1,143 @@ +create(['name' => 'Berger GmbH', 'stripe_customer_id' => 'cus_42']); + $subscription = Subscription::factory()->create([ + 'customer_id' => $customer->id, 'stripe_subscription_id' => 'sub_42', + ]); + + return DunningCase::query()->create([ + 'subscription_id' => $subscription->id, + 'stripe_invoice_id' => 'in_1', + 'level' => $level, + 'opened_at' => Carbon::now()->subDays(3), + 'next_step_at' => Carbon::now()->addDay(), + 'fee_invoice_ids' => [], + ]); +} + +// ---- Frist verlängern ---------------------------------------------------- + +it('pushes the deadline out and says who did it and why', function () { + $case = openCase(); + $vorher = $case->next_step_at; + + Livewire::actingAs(operator('Owner'), 'operator') + ->test(PaymentProblems::class) + ->call('onExtended', $case->id, 7, 'Kunde überweist bis Freitag') + ->assertHasNoErrors(); + + $case->refresh(); + + expect($case->next_step_at->isAfter($vorher->copy()->addDays(6)))->toBeTrue() + ->and($case->note)->toContain('Freitag'); +}); + +it('does not push a case that is already suspended', function () { + // Ab der Sperre gibt es keine nächste Frist mehr — die Cloud steht, und + // was jetzt hilft, ist Bezahlen oder das Schliessen von Hand. + $case = openCase(DunningSchedule::SUSPENDED); + $case->update(['next_step_at' => null]); + + Livewire::actingAs(operator('Owner'), 'operator') + ->test(PaymentProblems::class) + ->call('onExtended', $case->id, 7, 'egal') + ->assertHasErrors(); + + expect($case->fresh()->next_step_at)->toBeNull(); +}); + +// ---- Fall von Hand schliessen ------------------------------------------- + +it('closes a case by hand, with a reason', function () { + // Der Fall, für den es das gibt: jemand hat überwiesen statt die Karte zu + // reparieren. Stripe weiss davon nichts, und die Automatik käme nie zur + // Ruhe. + $case = openCase(); + + Livewire::actingAs(operator('Owner'), 'operator') + ->test(PaymentProblems::class) + ->call('onClosed', $case->id, 'Überweisung am 30.7. eingegangen') + ->assertHasNoErrors(); + + $case->refresh(); + + expect($case->settled_at)->not->toBeNull() + ->and($case->note)->toContain('Überweisung'); +}); + +it('refuses to close without a reason', function () { + // „Erledigt" ohne Begründung ist beim nächsten Nachfragen wertlos — und + // hier hängt Geld daran, das niemand mehr eintreibt. + $case = openCase(); + + Livewire::actingAs(operator('Owner'), 'operator') + ->test(PaymentProblems::class) + ->call('onClosed', $case->id, ' ') + ->assertHasErrors(); + + expect($case->fresh()->settled_at)->toBeNull(); +}); + +it('keeps both interventions behind billing.manage', function () { + $case = openCase(); + + Livewire::actingAs(operator('Support'), 'operator') + ->test(ExtendDunningDeadline::class, ['id' => $case->id]) + ->assertForbidden(); + + Livewire::actingAs(operator('Support'), 'operator') + ->test(CloseDunningCase::class, ['id' => $case->id]) + ->assertForbidden(); +}); + +// ---- Einstellungen ------------------------------------------------------- + +it('lets the console set the deadlines and the fees', function () { + Livewire::actingAs(operator('Owner'), 'operator') + ->test(PaymentProblems::class) + ->set('days', [0, 5, 12, 20, 30]) + ->set('feeFromLevel', 3) + ->set('fees', [2 => 500, 3 => 1500]) + ->call('saveSchedule') + ->assertHasNoErrors(); + + expect(DunningSchedule::dayOfLevel(1))->toBe(5) + ->and(DunningSchedule::feeFromLevel())->toBe(3) + ->and(DunningSchedule::feeCents(2))->toBe(0) + ->and(DunningSchedule::feeCents(3))->toBe(1500); +}); + +it('refuses a schedule that runs backwards', function () { + // Rückwärts laufende Fristen sind kein Zeitplan — der Tageslauf spränge + // über Stufen. Abgelehnt und gesagt, nicht still sortiert: wer 20/5/12 + // eintippt, hat sich vertan und soll es sehen. + Livewire::actingAs(operator('Owner'), 'operator') + ->test(PaymentProblems::class) + ->set('days', [0, 20, 5, 12, 30]) + ->call('saveSchedule') + ->assertHasErrors('days'); +}); + +it('does not let the settings be changed without billing.manage', function () { + Livewire::actingAs(operator('Support'), 'operator') + ->test(PaymentProblems::class) + ->assertForbidden(); +});