CluPilotCloud/app/Livewire/Admin/PaymentProblems.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(),
]);
}
}