186 lines
6.5 KiB
PHP
186 lines
6.5 KiB
PHP
<?php
|
|
|
|
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;
|
|
use Livewire\Attributes\Layout;
|
|
use Livewire\Attributes\On;
|
|
use Livewire\Component;
|
|
|
|
/**
|
|
* Wo Geld ausbleibt — beide Arten an einer Stelle.
|
|
*
|
|
* 1. **Mahnfälle.** Ein laufender Vertrag, dessen Abbuchung gescheitert ist.
|
|
* Der Fall wandert durch die Stufen des DunningSchedule bis zur Begleichung
|
|
* oder zur Sperre.
|
|
* 2. **Geplatzte Käufe.** Bei SEPA oder Sofortüberweisung klärt die Zahlung
|
|
* erst nach dem Abschluss der Kasse. Klärt sie nicht, entsteht kein Auftrag
|
|
* — richtig so — und bis zum 31.7.2026 auch sonst nichts. Ein verlorener
|
|
* Verkauf, von dem niemand hörte.
|
|
*
|
|
* Eine Seite und nicht zwei: beide Male ist die Frage dieselbe — wer schuldet
|
|
* was, und was ist der nächste Schritt. Zwei Seiten hiessen zwei Orte, an denen
|
|
* man nachsehen muss, und einer davon wird vergessen.
|
|
*
|
|
* Hinter `billing.manage`, nicht hinter `hosts.manage`: hier stehen Kundennamen,
|
|
* Beträge und offene Schulden. Die Rolle `Billing` hat genau dieses Recht und
|
|
* sonst nur `console.view` — die Buchhaltung sieht das, ohne Admin zu sein.
|
|
*/
|
|
#[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);
|
|
}
|
|
|
|
/**
|
|
* Einen geplatzten Kauf als erledigt abhaken.
|
|
*
|
|
* Mit Begründung, nicht nur mit einem Haken: „erledigt" allein ist beim
|
|
* nächsten Nachfragen wertlos — überwiesen? neu bestellt? nicht erreichbar?
|
|
* Wer es war, steht mit dabei.
|
|
*/
|
|
#[On('failed-checkout-resolved')]
|
|
public function onResolved(int $id, string $note = ''): void
|
|
{
|
|
$this->authorize('billing.manage');
|
|
|
|
$problem = FailedCheckout::query()->findOr($id, fn () => abort(404));
|
|
|
|
$begruendung = trim($note);
|
|
$wer = Auth::guard('operator')->user()?->email ?? 'console';
|
|
|
|
$problem->update([
|
|
'resolved_at' => Carbon::now(),
|
|
'note' => $begruendung !== '' ? $begruendung.' — '.$wer : $wer,
|
|
]);
|
|
|
|
$this->dispatch('notify', message: __('payment_problems.resolved'));
|
|
}
|
|
|
|
public function render()
|
|
{
|
|
return view('livewire.admin.payment-problems', [
|
|
'cases' => DunningCase::query()
|
|
->whereNull('settled_at')
|
|
->with('subscription.customer')
|
|
->orderBy('next_step_at')
|
|
->get(),
|
|
'failed' => FailedCheckout::query()
|
|
->whereNull('resolved_at')
|
|
->orderByDesc('failed_at')
|
|
->get(),
|
|
]);
|
|
}
|
|
}
|