Mahnwesen: Einstellungen und die zwei Eingriffe von Hand

main
nexxo 2026-07-31 22:43:52 +02:00
parent fb1ef3fc63
commit 4f037c8264
9 changed files with 496 additions and 2 deletions

View File

@ -0,0 +1,49 @@
<?php
namespace App\Livewire\Admin;
use App\Models\DunningCase;
use LivewireUI\Modal\ModalComponent;
/**
* Ein Eingriff von Hand in einen laufenden Mahnfall (R20: Eingabefeld, also
* Modal).
*
* Mutiert nichts selbst (R23) der Bestätigen-Knopf löst ein Ereignis aus,
* das Admin\PaymentProblems auffängt. Die Berechtigungsprüfung bleibt damit an
* der einen Stelle, an der sie schon stand; hier wird sie zusätzlich gestellt,
* weil ein Modal ohne die Route-Middleware der Seite erreichbar ist.
*/
class CloseDunningCase extends ModalComponent
{
public int $id;
public string $note = '';
public int|string $days = 7;
public string $label = '';
public function mount(int $id): void
{
$this->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');
}
}

View File

@ -0,0 +1,49 @@
<?php
namespace App\Livewire\Admin;
use App\Models\DunningCase;
use LivewireUI\Modal\ModalComponent;
/**
* Ein Eingriff von Hand in einen laufenden Mahnfall (R20: Eingabefeld, also
* Modal).
*
* Mutiert nichts selbst (R23) der Bestätigen-Knopf löst ein Ereignis aus,
* das Admin\PaymentProblems auffängt. Die Berechtigungsprüfung bleibt damit an
* der einen Stelle, an der sie schon stand; hier wird sie zusätzlich gestellt,
* weil ein Modal ohne die Route-Middleware der Seite erreichbar ist.
*/
class ExtendDunningDeadline extends ModalComponent
{
public int $id;
public string $note = '';
public int|string $days = 7;
public string $label = '';
public function mount(int $id): void
{
$this->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');
}
}

View File

@ -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<int, int> Die fünf Fristen in Tagen, Stufe 0 bis 4. */
public array $days = [];
public int|string $feeFromLevel = 2;
/** @var array<int, int> 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);
}
/**

View File

@ -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.',
];

View File

@ -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.',
];

View File

@ -0,0 +1,18 @@
<x-ui.modal :title="__('payment_problems.close_title')" :subtitle="$label">
{{-- 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". --}}
<x-ui.alert variant="warning">{{ __('payment_problems.close_warning') }}</x-ui.alert>
<form id="close-dunning" wire:submit="confirm" class="mt-4">
<x-ui.input name="note" wire:model="note"
:label="__('payment_problems.note')" :hint="__('payment_problems.close_note_hint')" />
</form>
<x-slot:footer>
<x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('common.cancel') }}</x-ui.button>
<x-ui.button variant="primary" type="submit" form="close-dunning" wire:loading.attr="disabled">
{{ __('payment_problems.close') }}
</x-ui.button>
</x-slot:footer>
</x-ui.modal>

View File

@ -0,0 +1,19 @@
<x-ui.modal :title="__('payment_problems.extend_title')" :subtitle="$label">
<p class="text-sm text-muted">{{ __('payment_problems.extend_body') }}</p>
<form id="extend-dunning" wire:submit="confirm" class="mt-4 space-y-4">
<div class="w-32">
<x-ui.input name="days" type="number" min="1" max="90" wire:model="days"
:label="__('payment_problems.extend_days')" />
</div>
<x-ui.input name="note" wire:model="note"
:label="__('payment_problems.note')" :hint="__('payment_problems.extend_note_hint')" />
</form>
<x-slot:footer>
<x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('common.cancel') }}</x-ui.button>
<x-ui.button variant="primary" type="submit" form="extend-dunning" wire:loading.attr="disabled">
{{ __('payment_problems.extend') }}
</x-ui.button>
</x-slot:footer>
</x-ui.modal>

View File

@ -4,6 +4,50 @@
<p class="mt-1 max-w-[70ch] text-sm text-muted">{{ __('payment_problems.subtitle') }}</p>
</div>
{{-- Die Einstellungen zugeklappt: die liest man einmal beim Einrichten,
die Fälle darunter jeden Tag. Aufgeklappt stünde die Arbeit hinter der
Konfiguration. --}}
<div x-data="{ open: false }" class="overflow-hidden rounded-lg border border-line bg-surface shadow-xs animate-rise">
<button type="button" x-on:click="open = ! open" :aria-expanded="open"
class="flex w-full items-center justify-between gap-3 px-6 py-4 text-left transition-colors hover:bg-surface-hover">
<span class="flex items-center gap-2">
<x-ui.icon name="chevron-down" class="size-4 shrink-0 text-muted transition-transform"
x-bind:class="open ? '' : '-rotate-90'" />
<span class="font-semibold text-ink">{{ __('payment_problems.schedule_title') }}</span>
</span>
<span class="shrink-0 text-xs text-muted">
{{ implode(' · ', array_map(fn ($d) => $d.'d', $days)) }}
</span>
</button>
<form wire:submit="saveSchedule" x-show="open" x-cloak class="border-t border-line px-6 py-5">
<p class="max-w-[70ch] text-sm text-muted">{{ __('payment_problems.schedule_body') }}</p>
<div class="mt-4 grid gap-3 sm:grid-cols-5">
@foreach ([0, 1, 2, 3, 4] as $stufe)
<x-ui.input :name="'days.'.$stufe" type="number" min="0" max="365"
wire:model="days.{{ $stufe }}" :label="__('payment_problems.day_'.$stufe)" />
@endforeach
</div>
@error('days')<p class="mt-1.5 text-xs text-danger">{{ $message }}</p>@enderror
<div class="mt-4 grid gap-3 sm:grid-cols-3">
<x-ui.input name="feeFromLevel" type="number" min="1" max="3"
wire:model="feeFromLevel" :label="__('payment_problems.fee_from')" />
<x-ui.input name="fees.2" type="number" min="0" wire:model="fees.2"
:label="__('payment_problems.fee_2')" />
<x-ui.input name="fees.3" type="number" min="0" wire:model="fees.3"
:label="__('payment_problems.fee_3')" />
</div>
<div class="mt-5 border-t border-line pt-5">
<x-ui.button type="submit" variant="primary" wire:loading.attr="disabled" wire:target="saveSchedule">
{{ __('common.save') }}
</x-ui.button>
</div>
</form>
</div>
{{-- 1. Laufende Verträge im Rückstand. --}}
<div class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise">
<div>
@ -21,7 +65,8 @@
<th class="py-2 pr-4">{{ __('payment_problems.customer') }}</th>
<th class="py-2 pr-4">{{ __('payment_problems.level') }}</th>
<th class="py-2 pr-4">{{ __('payment_problems.since') }}</th>
<th class="py-2">{{ __('payment_problems.next_step') }}</th>
<th class="py-2 pr-4">{{ __('payment_problems.next_step') }}</th>
<th class="py-2"></th>
</tr>
</thead>
<tbody>
@ -37,9 +82,25 @@
</td>
{{-- R19: alles, was ein Mensch liest, geht über ->local(). --}}
<td class="py-3 pr-4 text-muted">{{ $case->opened_at->local()->isoFormat('D. MMM YYYY') }}</td>
<td class="py-3 text-muted">
<td class="py-3 pr-4 text-muted">
{{ $case->next_step_at?->local()->isoFormat('D. MMM YYYY') ?? '—' }}
</td>
{{-- R20: beide Eingriffe tragen ein Feld, gehen also
im Modal auf. Der Knopf schickt nur openModal. --}}
<td class="py-3 text-right">
<span class="inline-flex flex-wrap justify-end gap-2">
@unless ($case->isSuspended())
<x-ui.button variant="secondary" size="sm"
x-on:click="$dispatch('openModal', { component: 'admin.extend-dunning-deadline', arguments: { id: {{ $case->id }} } })">
{{ __('payment_problems.extend') }}
</x-ui.button>
@endunless
<x-ui.button variant="ghost" size="sm"
x-on:click="$dispatch('openModal', { component: 'admin.close-dunning-case', arguments: { id: {{ $case->id }} } })">
{{ __('payment_problems.close') }}
</x-ui.button>
</span>
</td>
</tr>
@endforeach
</tbody>

View File

@ -0,0 +1,143 @@
<?php
use App\Livewire\Admin\CloseDunningCase;
use App\Livewire\Admin\ExtendDunningDeadline;
use App\Livewire\Admin\PaymentProblems;
use App\Models\Customer;
use App\Models\DunningCase;
use App\Models\Subscription;
use App\Services\Billing\DunningSchedule;
use Illuminate\Support\Carbon;
use Livewire\Livewire;
/**
* Die zwei Eingriffe, die ein Mensch braucht, und die Einstellungen.
*
* Ohne sie ist der Mahnlauf nach dem ersten Telefonat im Weg: der Kunde sagt
* „ich überweise morgen", und die Automatik schaltet trotzdem am Freitag ab.
*/
function openCase(int $level = 1): DunningCase
{
$customer = Customer::factory()->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();
});