$params */ public function kontoblatt(array $params): void { $this->verlangeAnmeldung(); $konto = Konto::find((int)$params['id']); if ($konto === null) { $this->nichtGefunden('/konten/' . $params['id']); return; } $filter = [ 'suche' => $this->query('suche'), 'von' => $this->query('von'), 'bis' => $this->query('bis'), 'kategorie_id' => $this->query('kategorie_id'), 'klasse_id' => $this->query('klasse_id'), 'nur_offene' => $this->query('nur_offene') === '1', ]; $zeilen = Buchung::kontoblatt((int)$konto['id'], $filter); // Die neuesten Buchungen zuerst zeigen, den Saldo aber chronologisch // aufbauen – deshalb erst rechnen, dann umdrehen. $zeilen = array_reverse($zeilen); $grenze = Einstellung::getInt('zeilen_pro_seite', 100); $gesamt = count($zeilen); $seite = max(1, (int)$this->query('seite', '1')); $zeilen = array_slice($zeilen, ($seite - 1) * $grenze, $grenze); // Die Splitzeilen der angezeigten Buchungen in einer Abfrage – der // Splitdialog zeigt damit eine bestehende Aufteilung, ohne sie // nachzuladen. $gesplittete = []; foreach ($zeilen as $zeile) { if ((int)$zeile['gesplittet'] === 1 && !$zeile['gegensicht']) { $gesplittete[] = (int)$zeile['id']; } } $this->render('buchungen/kontoblatt.php', [ 'titel' => 'Kontoblatt: ' . $konto['name'], 'konto' => Konto::mitSalden($konto), 'zeilen' => $zeilen, 'gesamt' => $gesamt, 'seite' => $seite, 'seiten' => max(1, (int)ceil($gesamt / max(1, $grenze))), 'filter' => $filter, 'kategorien' => Kategorie::baumFlach(), 'klassen' => Klasse::alle(), 'konten' => Konto::alle(), 'splits' => Buchung::splitsJeBuchung($gesplittete), // Für den Wertpapierdialog, den die Zuordnung auf ein Depot öffnet. 'wertpapiere' => Wertpapier::alle(true), // Die Abgleichspalte erscheint nur, wenn es ein Bargeld-Tool gibt – // sonst stünde dort in jeder Zeile derselbe graue Kreis. 'bargeld' => BargeldAbgleich::eingerichtet(), // Kommt die Seite vom Toggle „Regel anlegen“ in der Buchungsmaske, // steht hier die frisch erzeugte Regel zum Prüfen im Dialog. 'neueRegel' => AbgleichRegel::find((int)$this->query('neue_regel')), ]); } /** @param array $params */ public function neu(array $params): void { $this->verlangeAnmeldung(); $konto = Konto::find((int)$params['id']); if ($konto === null) { $this->nichtGefunden('/konten/' . $params['id']); return; } $this->maske($konto, null); } /** @param array $params */ public function anlegen(array $params): void { $this->verlangeAnmeldung(); $kontoId = (int)$params['id']; if (Konto::find($kontoId) === null) { $this->nichtGefunden('/konten/' . $params['id']); return; } try { [$daten, $splits] = $this->formulardaten($kontoId); $id = Buchung::anlegen($daten, $splits); } catch (\Throwable $e) { View::redirect('/konten/' . $kontoId . '/buchung', $e->getMessage(), 'fehler'); return; } $depotId = self::depotAus($this->post('zuordnung')); if ($depotId !== null) { View::redirect('/buchungen/' . $id . '/wertpapier?depot=' . $depotId); return; } $abgleich = BargeldAbgleich::pruefe($id); View::redirect( $this->kontoblattZiel($kontoId, $id), 'Buchung angelegt.' . BargeldAbgleich::rueckmeldung($abgleich) ); } /** @param array $params */ public function bearbeiten(array $params): void { $this->verlangeAnmeldung(); $buchung = Buchung::find((int)$params['id']); if ($buchung === null) { $this->nichtGefunden('/buchungen/' . $params['id']); return; } $this->maske(Konto::find((int)$buchung['konto_id']), $buchung); } /** @param array $params */ public function speichern(array $params): void { $this->verlangeAnmeldung(); $id = (int)$params['id']; $buchung = Buchung::find($id); if ($buchung === null) { $this->nichtGefunden('/buchungen/' . $params['id']); return; } try { [$daten, $splits] = $this->formulardaten((int)$buchung['konto_id']); Buchung::speichern($id, $daten, $splits); } catch (\Throwable $e) { View::redirect('/buchungen/' . $id, $e->getMessage(), 'fehler'); return; } // Ein Depot in der Zuordnungsliste ist der Anfang eines Geschäfts: Die // Buchung steht jetzt mit ihrem Betrag fest, die Wertpapiermaske baut // darauf auf. $depotId = self::depotAus($this->post('zuordnung')); if ($depotId !== null) { View::redirect('/buchungen/' . $id . '/wertpapier?depot=' . $depotId); return; } $abgleich = BargeldAbgleich::pruefe($id); View::redirect( $this->kontoblattZiel((int)$buchung['konto_id'], $id), 'Buchung gespeichert.' . BargeldAbgleich::rueckmeldung($abgleich) ); } /** @param array $params */ public function loeschen(array $params): void { $this->verlangeAnmeldung(); $buchung = Buchung::find((int)$params['id']); if ($buchung === null) { $this->nichtGefunden('/buchungen/' . $params['id']); return; } Buchung::loeschen((int)$buchung['id']); View::redirect('/konten/' . $buchung['konto_id'], 'Buchung gelöscht.'); } /** * Schnellzuordnung aus dem Kontoblatt: Kategorie oder Gegenkonto einer * Buchung setzen, ohne die Maske zu öffnen. * * @param array $params */ public function zuordnung(array $params): void { $this->verlangeAnmeldung(); $buchung = Buchung::find((int)$params['id']); if ($buchung === null) { $this->nichtGefunden('/buchungen/' . $params['id']); return; } $ziel = $this->kontoblattPfad((int)$buchung['konto_id']); // Ein Depot ist keine Zuordnung, sondern der Anfang eines Geschäfts: // Stückzahl, Kurs und Nebenkosten fehlen noch. Mit JavaScript öffnet // das Kontoblatt dafür einen Dialog und kommt hier gar nicht an; ohne // führt derselbe Weg auf die Seite mit denselben Feldern. $depotId = self::depotAus($this->post('zuordnung')); if ($depotId !== null) { View::redirect('/buchungen/' . (int)$buchung['id'] . '/wertpapier?depot=' . $depotId); return; } try { $zuordnung = self::zuordnungAus($this->post('zuordnung')); Buchung::zuordnen( (int)$buchung['id'], $zuordnung['kategorie_id'], $zuordnung['gegenkonto_id'] ); } catch (\Throwable $e) { $this->schnellantwort($ziel, $e->getMessage(), 'fehler'); return; } // Die zugewiesene Kategorie kann eine sein, die gegen das Bargeld-Tool // zu prüfen ist. Das Ergebnis landet an der Buchung und steht danach // als Symbol im Kontoblatt. $abgleich = BargeldAbgleich::pruefe((int)$buchung['id']); $bargeldHtml = null; if (BargeldAbgleich::eingerichtet()) { $frisch = Buchung::find((int)$buchung['id']); $bargeldHtml = View::bargeldMarke( $frisch['bargeld_status'] ?? null, $frisch['bargeld_hinweis'] ?? null ); } $this->schnellantwort( $ziel, 'Zuordnung gespeichert.' . BargeldAbgleich::rueckmeldung($abgleich), 'erfolg', ['bargeldHtml' => $bargeldHtml] ); } /** * Schnellauswahl aus dem Kontoblatt: die Klasse einer Buchung setzen. Ein * leerer Wert löscht die vorhandene Klasse. * * @param array $params */ public function klasse(array $params): void { $this->verlangeAnmeldung(); $buchung = Buchung::find((int)$params['id']); if ($buchung === null) { $this->nichtGefunden('/buchungen/' . $params['id']); return; } $ziel = $this->kontoblattPfad((int)$buchung['konto_id']); $klasseId = (int)$this->post('klasse_id'); try { Buchung::klasseSetzen((int)$buchung['id'], $klasseId > 0 ? $klasseId : null); } catch (\Throwable $e) { $this->schnellantwort($ziel, $e->getMessage(), 'fehler'); return; } $this->schnellantwort($ziel, $klasseId > 0 ? 'Klasse gespeichert.' : 'Klasse gelöscht.'); } /** * Antwort auf eine Schnellzuordnung aus dem Kontoblatt: kontoblatt.js * schickt sie im Hintergrund und braucht nur die Flash-Meldung als JSON, * um die Seite ohne Neuladen an Ort und Stelle zu aktualisieren – Text und * Zuordnung der Zelle kennt es selbst aus der gewählten Option. Ohne * JavaScript bleibt es bei der bisherigen Weiterleitung. * * @param array $daten zusätzliche Felder für die JSON-Antwort */ private function schnellantwort(string $ziel, string $meldung, string $typ = 'erfolg', array $daten = []): void { if ($this->istAjax()) { if ($typ === 'fehler') { http_response_code(422); } $this->json($daten + ['meldung' => $meldung, 'typ' => $typ]); return; } View::redirect($ziel, $meldung, $typ); } /** * Der Splitdialog des Kontoblatts: die Zeilen einer Buchung ersetzen. * Ohne Zeilen wird die Splitbuchung aufgehoben. * * Der Knopf „Splitbuchung löschen“ schickt dasselbe Formular mit * `aktion=loeschen`. Seine Zeilen zählen dann nicht: Gelöscht wird die * Aufteilung, und die Buchung steht danach ohne Zuordnung da. * * @param array $params */ public function splitbuchung(array $params): void { $this->verlangeAnmeldung(); $buchung = Buchung::find((int)$params['id']); if ($buchung === null) { $this->nichtGefunden('/buchungen/' . $params['id']); return; } $ziel = $this->kontoblattPfad((int)$buchung['konto_id']); $loeschen = $this->post('aktion') === 'loeschen'; try { Buchung::splitsErsetzen((int)$buchung['id'], $loeschen ? [] : $this->splitzeilen()); } catch (\Throwable $e) { View::redirect($ziel, $e->getMessage(), 'fehler'); return; } // Eine Splitbuchung trägt keine eigene Kategorie mehr; ein früherer // Abgleichstatus wäre danach eine Aussage über eine Zuordnung, die es // nicht mehr gibt. `pruefe()` räumt ihn weg. BargeldAbgleich::pruefe((int)$buchung['id']); View::redirect($ziel, $loeschen ? 'Splitbuchung gelöscht.' : 'Splitbuchung gespeichert.'); } /** * Die Wertpapiermaske zu einer Buchung als eigene Seite – der Weg ohne * JavaScript. Sie zeigt dieselben Felder wie der Dialog des Kontoblatts und * schickt sie an dasselbe Ziel. * * @param array $params */ public function wertpapierFormular(array $params): void { $this->verlangeAnmeldung(); $buchung = Buchung::find((int)$params['id']); if ($buchung === null) { $this->nichtGefunden('/buchungen/' . $params['id']); return; } $depot = Konto::find((int)$this->query('depot')); if ($depot === null || (string)$depot['kontoart'] !== 'depot') { View::redirect( $this->kontoblattPfad((int)$buchung['konto_id']), 'Für ein Wertpapiergeschäft fehlt das Depot.', 'fehler' ); return; } $this->render('buchungen/wertpapier.php', [ 'titel' => 'Wertpapiergeschäft', 'buchung' => $buchung, 'konto' => Konto::find((int)$buchung['konto_id']), 'depot' => $depot, 'wertpapiere' => Wertpapier::alle(true), 'kategorien' => Kategorie::baumFlach(), 'vorschlag' => Wertpapier::ausText((string)$buchung['buchungstext'] . ' ' . (string)$buchung['empfaenger']), ]); } /** * Ein Wertpapiergeschäft zu einer vorhandenen Buchung. * * Die Buchung bleibt, wie die Bank sie geliefert hat – ihr Betrag ist die * feste Größe. Aus ihm werden die Nebenkosten herausgerechnet; was übrig * bleibt, ist der Kurswert und damit der Einstandspreis. Beides erscheint * als Splitbuchung: je Kostenart eine Zeile in ihrer Kategorie, der Rest als * Umbuchung ins Depot. * * @param array $params */ public function wertpapier(array $params): void { $this->verlangeAnmeldung(); $buchung = Buchung::find((int)$params['id']); if ($buchung === null) { $this->nichtGefunden('/buchungen/' . $params['id']); return; } $ziel = $this->kontoblattPfad((int)$buchung['konto_id']); try { $geschaeft = Wertpapiergeschaeft::buchen($buchung, $this->wertpapiereingaben()); } catch (\Throwable $e) { View::redirect( '/buchungen/' . (int)$buchung['id'] . '/wertpapier?depot=' . (int)$this->post('depot_id'), $e->getMessage(), 'fehler' ); return; } View::redirect($ziel, sprintf( '%s gebucht – Einstandspreis %s €.', Depotbuchung::VORGAENGE[$geschaeft['vorgang']], number_format($geschaeft['betrag'], 2, ',', '.') )); } /** * Die Formulareingaben des Wertpapierdialogs, wie der Dienst sie erwartet. * * @return array */ private function wertpapiereingaben(): array { $kosten = []; foreach (array_keys(Wertpapierkosten::arten()) as $art) { $kosten[$art] = KontenController::zahl($this->post('kosten_' . $art, '')); } return [ 'depot_id' => (int)$this->post('depot_id'), 'wertpapier_id' => (int)$this->post('wertpapier_id'), 'wertpapier_name' => $this->post('wertpapier_name'), 'isin' => $this->post('isin'), 'wkn' => $this->post('wkn'), 'art' => $this->post('art', 'aktie'), 'vorgang' => $this->post('vorgang'), 'datum' => $this->post('datum'), 'valuta' => $this->post('valuta'), 'stueck' => KontenController::zahl($this->post('stueck', '0')), 'kurs' => KontenController::zahl($this->post('kurs', '0')), 'notiz' => $this->post('notiz'), 'kosten' => $kosten, 'kosten_sonstige_kategorie' => (int)$this->post('kosten_sonstige_kategorie') ?: null, ]; } /** * Mehrere im Kontoblatt angekreuzte Buchungen auf einmal löschen. * * @param array $params */ public function auswahlLoeschen(array $params): void { $this->verlangeAnmeldung(); $kontoId = (int)$params['id']; if (Konto::find($kontoId) === null) { $this->nichtGefunden('/konten/' . $params['id']); return; } $ziel = $this->kontoblattPfad($kontoId); $ids = $this->postIds('buchungen'); if ($ids === []) { View::redirect($ziel, 'Keine Buchung ausgewählt.', 'warnung'); return; } $anzahl = 0; foreach ($ids as $id) { $buchung = Buchung::find($id); // Nur eigene Buchungen dieses Kontos: Die Gegenseite einer fremden // Umbuchung gehört dem Ursprungskonto und wird auch nur dort // gelöscht. if ($buchung === null || (int)$buchung['konto_id'] !== $kontoId) { continue; } try { Buchung::loeschen($id); } catch (\Throwable $e) { View::redirect($ziel, $e->getMessage(), 'fehler'); return; } $anzahl++; } View::redirect($ziel, $anzahl === 1 ? 'Buchung gelöscht.' : $anzahl . ' Buchungen gelöscht.'); } /** * Führt zwei angehakte Buchungen dieses Kontoblatts zusammen: eine schon * fertige Umbuchung und eine einzeln erfasste Buchung, die denselben * Vorgang doppelt zeigen, weil sie zu weit auseinanderlagen, um sich * automatisch zu finden. * * @param array $params */ public function verknuepfen(array $params): void { $this->verlangeAnmeldung(); $kontoId = (int)$params['id']; if (Konto::find($kontoId) === null) { $this->nichtGefunden('/konten/' . $params['id']); return; } $ziel = $this->kontoblattPfad($kontoId); $ids = $this->postIds('buchungen'); if (count($ids) !== 2) { View::redirect($ziel, 'Zum Verknüpfen bitte genau zwei Buchungen ankreuzen.', 'warnung'); return; } try { Buchung::verknuepfen($kontoId, $ids[0], $ids[1]); } catch (\Throwable $e) { View::redirect($ziel, $e->getMessage(), 'fehler'); return; } View::redirect($ziel, 'Buchungen verknüpft.'); } /** * Weg zurück ins Kontoblatt – mit demselben Filter und derselben Seite. * * Die Werte kommen aus der Abfragezeichenfolge des Formularziels und werden * hier neu zusammengesetzt statt übernommen: So kann kein fremdes Ziel * eingeschleust werden. */ private function kontoblattPfad(int $kontoId): string { $query = array_filter([ 'suche' => $this->query('suche'), 'von' => $this->query('von'), 'bis' => $this->query('bis'), 'kategorie_id' => $this->query('kategorie_id'), 'klasse_id' => $this->query('klasse_id'), 'nur_offene' => $this->query('nur_offene') === '1' ? '1' : '', 'seite' => $this->query('seite'), ], static fn(string $wert): bool => $wert !== ''); return '/konten/' . $kontoId . ($query === [] ? '' : '?' . http_build_query($query)); } /** * Ziel nach dem Speichern einer Buchung: das Kontoblatt – mit angehängter * neuer Regel, wenn der Toggle „Regel anlegen“ gesetzt war. Ohne Muster * (kein Empfänger, kein Buchungstext) entsteht keine Regel; das Ziel bleibt * dann das gewöhnliche. */ private function kontoblattZiel(int $kontoId, int $buchungId): string { $ziel = '/konten/' . $kontoId; if (!isset($_POST['regel_anlegen'])) { return $ziel; } $regelId = AbgleichRegel::ausBuchung($buchungId); return $regelId === null ? $ziel : $ziel . '?neue_regel=' . $regelId; } /** @param array|null $konto */ private function maske(?array $konto, ?array $buchung): void { if ($konto === null) { $this->nichtGefunden('/konten'); return; } $this->render('buchungen/formular.php', [ 'titel' => $buchung === null ? 'Neue Buchung' : 'Buchung bearbeiten', 'konto' => $konto, 'buchung' => $buchung, 'splits' => $buchung === null ? [] : Buchung::splits((int)$buchung['id']), 'kategorien' => Kategorie::baumFlach(), 'klassen' => Klasse::alle(), 'konten' => Konto::alle(), ]); } /** * @return array{0: array, 1: list>} */ private function formulardaten(int $kontoId): array { // In der Maske wird ein positiver Betrag eingegeben; die Richtung // bestimmt die Auswahl Einnahme / Ausgabe. $betrag = abs(KontenController::zahl($this->post('betrag', '0'))); if ($this->post('art', 'ausgabe') !== 'einnahme') { $betrag = -$betrag; } // Kategorie und Gegenkonto stehen in einem Feld: Ein Konto in der Liste // macht die Buchung zur Umbuchung – wie das Kategoriefeld im // FinanzManager, das `[Konto]` annimmt. $zuordnung = self::zuordnungAus($this->post('zuordnung')); $daten = [ 'konto_id' => $kontoId, 'datum' => $this->post('datum'), 'valuta' => $this->post('valuta'), 'betrag' => $betrag, 'empfaenger' => $this->post('empfaenger'), 'buchungstext' => $this->post('buchungstext'), 'nummer' => $this->post('nummer'), 'abgeglichen' => isset($_POST['abgeglichen']), 'kategorie_id' => $zuordnung['kategorie_id'], 'klasse_id' => $this->post('klasse_id'), 'gegenkonto_id' => $zuordnung['gegenkonto_id'], 'quelle' => 'manuell', ]; return [$daten, $this->splitzeilen()]; } /** * Die ausgefüllten Splitzeilen eines Formulars. Zeilen ohne Betrag zählen * nicht – so bleiben die leeren Zeilen der Maske folgenlos. * * @return list> */ private function splitzeilen(): array { $zeilen = $_POST['split'] ?? []; if (!is_array($zeilen)) { return []; } $splits = []; foreach ($zeilen as $zeile) { if (!is_array($zeile)) { continue; } $wert = trim((string)($zeile['betrag'] ?? '')); if ($wert === '') { continue; } $zuordnung = self::zuordnungAus((string)($zeile['zuordnung'] ?? '')); $splits[] = [ 'kategorie_id' => $zuordnung['kategorie_id'], 'gegenkonto_id' => $zuordnung['gegenkonto_id'], 'klasse_id' => $zeile['klasse_id'] ?? null, 'betrag' => KontenController::zahl($wert), 'notiz' => (string)($zeile['notiz'] ?? ''), ]; } return $splits; } /** * Ein Auswahlwert aus der gemeinsamen Liste „Kategorie oder Gegenkonto“: * `kategorie:12`, `konto:7` oder leer. * * @return array{kategorie_id: ?int, gegenkonto_id: ?int} */ public static function zuordnungAus(string $wert): array { $leer = ['kategorie_id' => null, 'gegenkonto_id' => null]; [$art, $id] = array_pad(explode(':', $wert, 2), 2, ''); if ((int)$id <= 0) { return $leer; } return match ($art) { 'kategorie' => ['kategorie_id' => (int)$id, 'gegenkonto_id' => null], 'konto' => ['kategorie_id' => null, 'gegenkonto_id' => (int)$id], default => $leer, }; } /** * Das Depot aus einem Auswahlwert `depot:7` – oder null, wenn der Wert für * eine gewöhnliche Zuordnung steht. */ public static function depotAus(string $wert): ?int { [$art, $id] = array_pad(explode(':', $wert, 2), 2, ''); return $art === 'depot' && (int)$id > 0 ? (int)$id : null; } private function nichtGefunden(string $pfad): void { http_response_code(404); $this->render('layout/404.php', ['pfad' => $pfad, 'titel' => 'Nicht gefunden']); } }