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(); $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(), ]); } }