Mahnwesen: Einstellungen und die zwei Eingriffe von Hand
parent
fb1ef3fc63
commit
4f037c8264
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
Loading…
Reference in New Issue