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(); // In EURO ins Formular, in Cent in die Datenbank. // // Hier stand Cent, und im Feld stand „500". Wer eine Mahngebühr // einträgt, denkt in Euro — und tippt „5". Aus fünf Euro wurden damit // fünf Cent, ohne dass irgendetwas widersprochen hätte. $this->fees = [ 2 => self::centsToEuro(DunningSchedule::feeCents(2)), 3 => self::centsToEuro(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. */ /** Cent → das, was im Feld stehen soll: „5" statt „5,00", aber „5,50" wenn nötig. */ private static function centsToEuro(int $cents): string { return $cents % 100 === 0 ? (string) intdiv($cents, 100) : number_format($cents / 100, 2, ',', ''); } /** Was im Feld steht → Zahl. Komma und Punkt gelten beide als Dezimaltrenner. */ private static function euroToFloat(string $eingabe): float { return (float) str_replace(',', '.', trim($eingabe)); } 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'], // Euro, mit Komma oder Punkt, höchstens zwei Nachkommastellen. // 1000,00 € ist die Obergrenze — dieselbe wie vorher, nur lesbar. 'fees.*' => ['required', 'regex:/^\\d{1,4}([.,]\\d{1,2})?$/'], ]); $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); // Gerundet, nicht abgeschnitten: (int) (19.99 * 100) ist in PHP 1998, // weil 19.99 als Gleitkommazahl knapp darunter liegt. Ein Cent, der bei // jedem Speichern verschwindet, fällt erst nach Monaten auf. Settings::set(DunningSchedule::FEES, array_map( fn ($euro) => (int) round(self::euroToFloat((string) $euro) * 100), $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(), ]); } }