verlangeAnmeldung(); Bankabruf::raeumeAlteAuf(); $this->render('banking/index.php', [ 'titel' => 'Bankverbindungen', 'zugaenge' => Bankzugang::alle(), 'abrufe' => Bankabruf::letzte(10), 'offen' => Bankumsatz::anzahlOffen(), 'schluessel' => Crypto::einsatzbereit(), ]); } /** * Die Wahl der Art: HBCI/FinTS oder PayPal. * * Beide brauchen völlig verschiedene Angaben – ein gemeinsames Formular mit * zwei ausgegrauten Hälften wäre für niemanden eine Hilfe. Die Wahl fällt * deshalb vorweg und ist danach nicht mehr umzustellen. */ /** Das Gesamtprotokoll: jede Kommunikation mit den Banken, filterbar. */ public function protokoll(): void { $this->verlangeAnmeldung(); $filter = [ 'zugang_id' => (int)$this->query('zugang_id'), 'von' => $this->query('von'), 'bis' => $this->query('bis'), 'text' => $this->query('text'), ]; $this->render('banking/protokoll.php', [ 'titel' => 'Protokoll', 'eintraege' => Bankprotokoll::suchen($filter), 'zugaenge' => Bankzugang::alle(), 'filter' => $filter, ]); } public function neu(): void { $this->verlangeAnmeldung(); $this->render('banking/typ.php', ['titel' => 'Bankverbindung anlegen']); } /** @param array $params */ public function neuFormular(array $params): void { $this->verlangeAnmeldung(); if (self::typ($params) === 'paypal') { $this->render('banking/paypal.php', [ 'titel' => 'PayPal-Verbindung anlegen', 'zugang' => null, 'bankkonto' => null, 'eigene' => Konto::alle(), ]); return; } // Aus dem Bankleitzahlen-Verzeichnis kommt man mit „?blz=…“ hierher; // dann stehen Bank, Bankleitzahl und FinTS-Adresse schon im Formular. $this->render('banking/formular.php', [ 'titel' => 'Bankverbindung anlegen', 'zugang' => null, 'konten' => [], 'vorgabe' => self::vorbelegung($this->query('blz')), ]); } /** * Vorbelegung eines leeren FinTS-Formulars aus dem Bankleitzahlen- * Verzeichnis: Von dort führt „Verbindung anlegen“ mit „?blz=…“ hierher. * * Ohne Treffer bleibt das Formular leer – eine Bankleitzahl, die das * Verzeichnis nicht kennt, ist deshalb noch lange nicht falsch. * * @return array */ private static function vorbelegung(string $blz): array { $institut = Bankinstitut::findeNachBlz($blz); if ($institut === null) { return []; } return [ // Der Name ist frei wählbar; der Institutsname ist der naheliegende // Vorschlag und lässt sich im Formular überschreiben. 'name' => (string)$institut['institut'], 'bankname' => (string)$institut['institut'], 'blz' => (string)$institut['blz'], 'fints_url' => (string)$institut['pintan_url'], ]; } /** @param array $params */ public function anlegen(array $params): void { $this->verlangeAnmeldung(); $typ = self::typ($params); try { $id = Bankzugang::anlegen( $typ === 'paypal' ? $this->paypalFormulardaten() : $this->formulardaten() ); } catch (\Throwable $e) { View::redirect('/banking/neu/' . $typ, $e->getMessage(), 'fehler'); return; } if ($typ === 'paypal') { // Kein zweiter und dritter Schritt: PayPal verlangt weder ein // TAN-Verfahren noch eine Kontenabfrage, und das eine Konto ist im // Formular schon zugeordnet worden. try { $neue = $this->paypalKontoZuordnen($id); } catch (\Throwable $e) { View::redirect('/banking/' . $id, $e->getMessage(), 'fehler'); return; } View::redirect('/banking', $neue ? 'PayPal-Verbindung angelegt, das Konto ist neu angelegt und zugeordnet.' : 'PayPal-Verbindung angelegt.'); return; } // Weiter zum zweiten Schritt: Erst jetzt – mit gespeicherten Zugangsdaten – // lässt sich die Bank nach ihren TAN-Verfahren fragen. View::redirect( '/banking/' . $id . '/verfahren?einrichtung=1', 'Zugangsdaten gespeichert. Jetzt das TAN-Verfahren wählen.' ); } /** * Die Art aus dem Pfad – alles Unbekannte gilt als FinTS. * * @param array $params */ private static function typ(array $params): string { $typ = (string)($params['typ'] ?? 'fints'); return array_key_exists($typ, Bankzugang::TYPEN) ? $typ : 'fints'; } /** @param array $params */ public function bearbeiten(array $params): void { $this->verlangeAnmeldung(); $zugang = Bankzugang::find((int)$params['id']); if ($zugang === null) { View::redirect('/banking', 'Die Bankverbindung gibt es nicht.', 'fehler'); return; } if ((string)$zugang['typ'] === 'paypal') { $konten = Bankzugang::konten((int)$zugang['id']); $this->render('banking/paypal.php', [ 'titel' => $zugang['name'], 'zugang' => $zugang, 'bankkonto' => $konten[0] ?? null, 'eigene' => Konto::alle(), 'abrufe' => Bankabruf::letzte(5, (int)$zugang['id']), ]); return; } $this->render('banking/formular.php', [ 'titel' => $zugang['name'], 'zugang' => $zugang, 'konten' => Bankzugang::konten((int)$zugang['id']), 'abrufe' => Bankabruf::letzte(5, (int)$zugang['id']), ]); } /** @param array $params */ public function speichern(array $params): void { $this->verlangeAnmeldung(); $id = (int)$params['id']; $zugang = Bankzugang::find($id); if ($zugang === null) { View::redirect('/banking', 'Die Bankverbindung gibt es nicht.', 'fehler'); return; } $paypal = (string)$zugang['typ'] === 'paypal'; try { Bankzugang::speichern($id, $paypal ? $this->paypalFormulardaten() : $this->formulardaten()); $neue = $paypal && $this->paypalKontoZuordnen($id); } catch (\Throwable $e) { View::redirect('/banking/' . $id, $e->getMessage(), 'fehler'); return; } View::redirect('/banking/' . $id, ($neue ?? false) ? 'Gespeichert, das Konto ist neu angelegt und zugeordnet.' : 'Bankverbindung gespeichert.'); } /** @param array $params */ public function loeschen(array $params): void { $this->verlangeAnmeldung(); Bankzugang::loeschen((int)$params['id']); View::redirect('/banking', 'Bankverbindung gelöscht.'); } // ── TAN-Verfahren ──────────────────────────────────────────────────────── /** * Der zweite Schritt der Einrichtung: das TAN-Verfahren. * * Die Liste wird beim Aufruf **selbst geholt** – erst mit gespeicherten * Zugangsdaten lässt sich die Bank fragen, und genau dann steht der Nutzer * hier. Ein eigener Knopf davor wäre ein Schritt, dessen Ergebnis ohnehin * feststeht. * * Das Ergebnis bleibt in der Sitzung, auch ein gescheitertes: Sonst liefe * jeder Seitenaufruf erneut in dieselbe Zeitüberschreitung. * * @param array $params */ public function verfahren(array $params): void { $this->verlangeAnmeldung(); $id = (int)$params['id']; $zugang = Bankzugang::find($id); if ($zugang === null) { View::redirect('/banking', 'Die Bankverbindung gibt es nicht.', 'fehler'); return; } if ((string)$zugang['typ'] === 'paypal') { View::redirect('/banking/' . $id, 'PayPal kennt kein TAN-Verfahren.', 'warnung'); return; } $abfrage = $this->gemerkteAbfrage($id) ?? $this->verfahrenHolen($id); $this->render('banking/verfahren.php', [ 'titel' => 'TAN-Verfahren – ' . $zugang['name'], 'zugang' => $zugang, 'medium' => Crypto::entschluessle((string)($zugang['tan_medium'] ?? '')), 'verfahren' => $abfrage['liste'], 'fehler' => $abfrage['status'] === 'fehler' ? $abfrage['meldung'] : '', 'einrichtung' => $this->query('einrichtung') === '1', ]); } /** * Ein erneuter Anlauf, wenn der erste scheiterte oder sich etwas geändert hat. * * @param array $params */ public function verfahrenAbfragen(array $params): void { $this->verlangeAnmeldung(); $id = (int)$params['id']; $abfrage = $this->verfahrenHolen($id); $ziel = '/banking/' . $id . '/verfahren' . ($this->post('einrichtung') === '1' ? '?einrichtung=1' : ''); View::redirect($ziel, $abfrage['meldung'], $abfrage['status'] === 'fehler' ? 'fehler' : 'erfolg'); } /** @param array $params */ public function verfahrenWaehlen(array $params): void { $this->verlangeAnmeldung(); $id = (int)$params['id']; $verfahren = (int)$this->post('verfahren_id'); $abfrage = $this->gemerkteAbfrage($id); try { FinTsVerfahren::uebernehmen( $id, $verfahren, // Das Medium des angekreuzten Verfahrens, nicht das irgendeiner // anderen Zeile. $this->post('tan_medium_' . $verfahren), $abfrage === null ? [] : $abfrage['liste'] ); } catch (\Throwable $e) { View::redirect('/banking/' . $id . '/verfahren', $e->getMessage(), 'fehler'); return; } // Dritter Schritt: Die Konten holen wir gleich hier – erst mit // gewähltem Verfahren lässt sich die Bank danach fragen, und ohne // Zuordnung wäre jeder Umsatz eines fremden Kontos wertlos. if ($this->post('einrichtung') === '1') { $this->zeigeAbruf(FinTsAbruf::starten($id, 'konten', true), $id); return; } View::redirect('/banking/' . $id, 'TAN-Verfahren übernommen.'); } // ── Kontenzuordnung ────────────────────────────────────────────────────── /** * Der dritte Schritt der Einrichtung: Welches Bankkonto gehört zu welchem * eigenen Konto? * * Ohne diese Zuordnung landet jeder abgerufene Umsatz in der * Vorschlagsliste, ohne dass sich sagen ließe, wohin er gebucht gehört. * Fehlt ein passendes eigenes Konto, lässt es sich hier gleich anlegen – * mit IBAN und Bankname aus dem, was die Bank gemeldet hat. * * @param array $params */ public function konten(array $params): void { $this->verlangeAnmeldung(); $zugang = Bankzugang::find((int)$params['id']); if ($zugang === null) { View::redirect('/banking', 'Die Bankverbindung gibt es nicht.', 'fehler'); return; } // Bei PayPal steht die Zuordnung im Zugangsformular: Es gibt genau ein // Konto, und abzufragen ist bei PayPal nichts. if ((string)$zugang['typ'] === 'paypal') { View::redirect('/banking/' . (int)$zugang['id']); return; } $this->render('banking/konten.php', [ 'titel' => 'Konten zuordnen – ' . $zugang['name'], 'zugang' => $zugang, 'konten' => Bankzugang::konten((int)$zugang['id']), 'eigene' => Konto::alle(), 'einrichtung' => $this->query('einrichtung') === '1', ]); } /** * Die Konten (erneut) bei der Bank erfragen. Ein eigener Knopf, weil dafür * eine Anmeldung nötig ist – und die verlangt bei den meisten Banken eine * TAN, die niemand beim bloßen Öffnen einer Seite erwartet. * * @param array $params */ public function kontenAbfragen(array $params): void { $this->verlangeAnmeldung(); $id = (int)$params['id']; $this->zeigeAbruf( FinTsAbruf::starten($id, 'konten', $this->post('einrichtung') === '1'), $id ); } /** @param array $params */ public function kontenSpeichern(array $params): void { $this->verlangeAnmeldung(); $id = (int)$params['id']; $zugang = Bankzugang::find($id); if ($zugang === null) { View::redirect('/banking', 'Die Bankverbindung gibt es nicht.', 'fehler'); return; } $einrichtung = $this->post('einrichtung') === '1'; $ziel = '/banking/' . $id . '/konten' . ($einrichtung ? '?einrichtung=1' : ''); try { $neue = $this->kontenzuordnungSpeichern($zugang); } catch (\Throwable $e) { View::redirect($ziel, $e->getMessage(), 'fehler'); return; } // Was schon abgerufen wurde, hat mit der Zuordnung eben sein Konto // bekommen – jetzt kann es dort auch gebucht werden. $gebucht = Bankabgleich::automatisch(); $meldung = $einrichtung ? 'Einrichtung abgeschlossen.' : 'Zuordnung gespeichert.'; if ($neue > 0) { $meldung .= sprintf(' %d Konto/Konten neu angelegt.', $neue); } if ($gebucht !== null && $gebucht['uebernommen'] + $gebucht['verbunden'] > 0) { $meldung .= ' ' . Bankabgleich::bericht($gebucht); } View::redirect(self::zielNachZuordnung($id, $einrichtung), $meldung); } /** * Wohin nach dem Speichern der Zuordnung? * * Am Ende der Einrichtung in die Übersicht der Bankverbindungen: Dort steht * die neue Verbindung fertig zwischen den anderen. Die Zugangsmaske hätte * nichts mehr zu zeigen, was gerade nicht schon durchlaufen wäre. * * Sonst zurück auf die Zuordnungsseite – wer sie einzeln aufruft, will das * Ergebnis an derselben Stelle sehen. */ public static function zielNachZuordnung(int $zugangId, bool $einrichtung): string { return $einrichtung ? '/banking' : '/banking/' . $zugangId . '/konten'; } /** * Fragt die Bank und merkt sich das Ergebnis für diese Sitzung. * * @return array{status: string, meldung: string, liste: list>} */ private function verfahrenHolen(int $zugangId): array { $ergebnis = FinTsVerfahren::abfragen($zugangId); $abfrage = [ 'status' => $ergebnis['status'], 'meldung' => $ergebnis['meldung'], 'liste' => $ergebnis['verfahren'], ]; Auth::startSession(); $_SESSION[self::SITZUNG_VERFAHREN] = ['zugang_id' => $zugangId] + $abfrage; return $abfrage; } /** * Die zuletzt abgefragte Liste – aber nur die zu diesem Zugang. * * @return array{status: string, meldung: string, liste: list>}|null */ private function gemerkteAbfrage(int $zugangId): ?array { Auth::startSession(); $gemerkt = $_SESSION[self::SITZUNG_VERFAHREN] ?? null; if (!is_array($gemerkt) || (int)($gemerkt['zugang_id'] ?? 0) !== $zugangId) { return null; } return [ 'status' => (string)($gemerkt['status'] ?? 'fehler'), 'meldung' => (string)($gemerkt['meldung'] ?? ''), 'liste' => is_array($gemerkt['liste'] ?? null) ? $gemerkt['liste'] : [], ]; } // ── Abruf ──────────────────────────────────────────────────────────────── /** * Umsätze abrufen. * * Das Stichtagsfeld neben dem Knopf gilt **nur für diesen Lauf**: Wer * einmalig weiter zurückgreifen will, soll dafür nicht die Einstellung des * Zugangs ändern und hinterher zurückstellen müssen. Bleibt es leer, gilt * der Abrufzeitraum der Bankverbindung. * * @param array $params */ public function abrufen(array $params): void { $this->verlangeAnmeldung(); // Umsätze, deren Buchung hinter unserem Rücken verschwunden ist, gehören // zurück in die Liste – sonst überspringt der Abruf sie gleich wieder // als schon vorhanden, und sie blieben für immer unsichtbar. Bankumsatz::verwaisteOeffnen(); // Ein einzelner Abruf löst einen liegengebliebenen Sammelabruf ab – // sonst liefe dessen Warteschlange nach der nächsten TAN weiter. $this->sammelabrufMerken(null); $id = (int)$params['id']; $zugang = Bankzugang::find($id); if ($zugang === null) { View::redirect('/banking', 'Die Bankverbindung gibt es nicht.', 'fehler'); return; } $this->zeigeAbruf($this->abrufStarten($zugang, $this->post('stichtag')), $id); } /** * „Alle Umsätze abrufen“: jede aktive Bankverbindung der Reihe nach, mit * dem jeweils eingestellten Abrufzeitraum. * * Was ohne Rückfrage geht, läuft in diesem einen Request durch. Verlangt * eine Bank eine Freigabe, steht die TAN-Maske da; nach der Freigabe geht * es in `tanEinreichen()` mit der nächsten Bank weiter. */ public function alleAbrufen(): void { $this->verlangeAnmeldung(); Bankumsatz::verwaisteOeffnen(); $zugaenge = Bankzugang::alle(true); if ($zugaenge === []) { View::redirect('/banking', 'Es gibt keine aktive Bankverbindung zum Abrufen.', 'fehler'); return; } $this->sammelabrufFortsetzen(Sammelabruf::fuer($zugaenge)); } /** * Die Warteschlange abarbeiten, bis sie leer ist oder eine Bank eine * Freigabe verlangt. */ private function sammelabrufFortsetzen(Sammelabruf $lauf): void { while (($naechste = $lauf->naechste()) !== null) { $zugang = Bankzugang::find($naechste['id']); $ergebnis = $zugang === null ? ['status' => 'fehler', 'meldung' => 'Die Bankverbindung gibt es nicht mehr.'] : $this->abrufStarten($zugang, null); if ($ergebnis['status'] === 'tan') { $this->sammelabrufMerken($lauf); $this->zeigeTan($ergebnis['tan'] ?? [], $ergebnis['meldung'], $ergebnis['protokoll'], $lauf); return; } $lauf->vermerke($ergebnis); } $this->sammelabrufBeenden($lauf); } /** Je Bankverbindung eine Meldung, dann dorthin, wo es weitergeht. */ private function sammelabrufBeenden(Sammelabruf $lauf): void { $this->sammelabrufMerken(null); foreach ($lauf->meldungen() as $meldung) { View::flash($meldung['meldung'], $meldung['typ']); } View::redirect(Bankumsatz::anzahlOffen() > 0 ? '/banking/umsaetze' : '/banking'); } /** Der Sammelabruf aus der Sitzung – `null`, wenn keiner läuft. */ private function sammelabruf(): ?Sammelabruf { Auth::startSession(); return Sammelabruf::aus($_SESSION[Sammelabruf::SITZUNG] ?? null); } /** Den Sammelabruf für den nächsten Request ablegen; `null` vergisst ihn. */ private function sammelabrufMerken(?Sammelabruf $lauf): void { Auth::startSession(); if ($lauf === null) { unset($_SESSION[Sammelabruf::SITZUNG]); } else { $_SESSION[Sammelabruf::SITZUNG] = $lauf->zustand(); } } /** * Einen Abruf je nach Art der Bankverbindung starten. * * @param array $zugang * @return array */ private function abrufStarten(array $zugang, ?string $stichtag): array { $id = (int)$zugang['id']; return (string)$zugang['typ'] === 'paypal' ? PaypalAbruf::starten($id, $stichtag) : FinTsAbruf::starten($id, 'voll', false, $stichtag); } /** Die TAN-Maske nach einem Neuladen der Seite. */ public function tanFormular(): void { $this->verlangeAnmeldung(); $tan = FinTsAbruf::offeneTanAnfrage(); if ($tan === null) { View::redirect('/banking', 'Es wartet kein Abruf auf eine Freigabe.'); return; } $this->zeigeTan($tan, 'Die Bank verlangt eine Freigabe.', [], $this->sammelabruf()); } public function tanEinreichen(): void { $this->verlangeAnmeldung(); $ergebnis = FinTsAbruf::tanEinreichen($this->post('tan')); // Gehört die Freigabe zu einem Sammelabruf, geht es nach ihr mit der // nächsten Bank weiter – nicht zurück in die Übersicht. $lauf = $this->sammelabruf(); if ($lauf !== null && $ergebnis['status'] !== 'tan' && ($lauf->aktuell()['id'] ?? null) === (int)($ergebnis['zugang_id'] ?? 0)) { $lauf->vermerke($ergebnis); $this->sammelabrufFortsetzen($lauf); return; } $this->zeigeAbruf($ergebnis, null); } /** * Den wartenden Abruf abbrechen. * * Im Sammelabruf gibt es zwei Wege: `weiter` lässt nur die Bank aus, die * gerade fragt, und macht mit der nächsten weiter – für die Bank, deren * Banking-Gerät gerade nicht zur Hand ist. Sonst endet der ganze Lauf. */ public function tanAbbrechen(): void { $this->verlangeAnmeldung(); FinTsAbruf::abbrechen(); $lauf = $this->sammelabruf(); if ($lauf === null) { View::redirect('/banking', 'Abruf abgebrochen.'); return; } if ($this->post('weiter') === '1') { $lauf->ueberspringe(); $this->sammelabrufFortsetzen($lauf); return; } $lauf->abbrechen(); $this->sammelabrufBeenden($lauf); } /** * Die TAN-Maske – im Sammelabruf mit dem Hinweis, welche Bank fragt und wie * viele noch folgen. * * @param array $tan * @param list $protokoll */ private function zeigeTan(array $tan, string $meldung, array $protokoll, ?Sammelabruf $lauf): void { $this->render('banking/tan.php', [ 'titel' => 'Freigabe erforderlich', 'tan' => $tan, 'meldung' => $meldung, 'protokoll' => $protokoll, 'sammelabruf' => $lauf?->aktuell() === null ? null : $lauf->stand(), ]); } /** * Ein Abrufergebnis anzeigen: entweder die TAN-Maske oder zurück zur * Übersicht mit Meldung. * * @param array $ergebnis */ private function zeigeAbruf(array $ergebnis, ?int $zugangId): void { if ($ergebnis['status'] === 'tan') { // Den Stand eines Sammelabrufs nur zeigen, wenn die Freigabe auch // zu dessen laufender Bank gehört. $lauf = $this->sammelabruf(); if (($lauf?->aktuell()['id'] ?? null) !== (int)($ergebnis['zugang_id'] ?? 0)) { $lauf = null; } $this->zeigeTan($ergebnis['tan'] ?? [], $ergebnis['meldung'], $ergebnis['protokoll'], $lauf); return; } // Nach einer TAN-Runde kennt nur das Ergebnis noch den Zugang: Der // Aufrufer war dann `tanEinreichen()` ohne jeden Zusammenhang. $zugangId ??= isset($ergebnis['zugang_id']) ? (int)$ergebnis['zugang_id'] : null; $kontenlauf = ($ergebnis['modus'] ?? 'voll') === 'konten'; $kontenseite = $zugangId === null ? '/banking' : '/banking/' . $zugangId . '/konten' . (($ergebnis['einrichtung'] ?? false) ? '?einrichtung=1' : ''); if ($ergebnis['status'] === 'fehler') { View::redirect( $kontenlauf ? $kontenseite : ($zugangId === null ? '/banking' : '/banking/' . $zugangId), 'Abruf fehlgeschlagen: ' . $ergebnis['meldung'], 'fehler' ); return; } if ($kontenlauf) { View::redirect($kontenseite, $ergebnis['meldung'] . ' Jetzt die Konten zuordnen.'); return; } // Automatisch gebuchte Umsätze stehen im Kontoblatt, nicht mehr in der // Vorschlagsliste – dorthin lohnt der Sprung nur, wenn dort etwas liegt. View::redirect( Bankumsatz::anzahlOffen() > 0 ? '/banking/umsaetze' : '/banking', $ergebnis['meldung'] ); } // ── Abgleich ───────────────────────────────────────────────────────────── public function umsaetze(): void { $this->verlangeAnmeldung(); Bankumsatz::verwaisteOeffnen(); $status = $this->query('status', 'offen'); if (!array_key_exists($status, Bankumsatz::STATUS)) { $status = 'offen'; } $regeln = AbgleichRegel::alle(true); $umsaetze = []; foreach (Bankumsatz::liste(['status' => $status]) as $umsatz) { $umsatz['vorschlag'] = $status === 'offen' ? Bankumsatz::vorschlag($umsatz, $regeln) : null; $umsaetze[] = $umsatz; } $this->render('banking/umsaetze.php', [ 'titel' => 'Umsätze abgleichen', 'status' => $status, 'umsaetze' => $umsaetze, 'kategorien' => Kategorie::baumFlach(null, true), 'klassen' => Klasse::alle(), 'konten' => Konto::alle(true), 'offen' => Bankumsatz::anzahlOffen(), ]); } /** @param array $params */ public function uebernehmen(array $params): void { $this->verlangeAnmeldung(); $id = (int)$params['id']; try { // Eine vorgeschlagene Buchung wird verbunden statt neu angelegt – // sonst stünde dieselbe Zahlung zweimal im Kontoblatt. $buchungId = (int)$this->post('buchung_id'); if ($buchungId > 0) { Bankumsatz::verbinden($id, $buchungId); } else { Bankumsatz::uebernehmen($id, [ 'konto_id' => $this->post('konto_id') !== '' ? (int)$this->post('konto_id') : null, 'kategorie_id' => $this->post('kategorie_id') !== '' ? (int)$this->post('kategorie_id') : null, 'klasse_id' => $this->post('klasse_id') !== '' ? (int)$this->post('klasse_id') : null, 'gegenkonto_id' => $this->post('gegenkonto_id') !== '' ? (int)$this->post('gegenkonto_id') : null, 'splits' => $this->splitsAusFormular(), ]); } } catch (\Throwable $e) { View::redirect('/banking/umsaetze', $e->getMessage(), 'fehler'); return; } View::redirect('/banking/umsaetze', 'Umsatz übernommen.'); } /** @param array $params */ public function verwerfen(array $params): void { $this->verlangeAnmeldung(); Bankumsatz::verwerfen((int)$params['id']); View::redirect('/banking/umsaetze', 'Umsatz verworfen.'); } /** @param array $params */ public function zuruecksetzen(array $params): void { $this->verlangeAnmeldung(); Bankumsatz::zuruecksetzen((int)$params['id']); View::redirect('/banking/umsaetze?status=offen', 'Umsatz steht wieder zum Abgleich bereit.'); } /** * Mehrere in der Abgleichliste angekreuzte Umsätze auf einmal löschen. * * Gelöscht wird nur die abgerufene Zeile. Eine daraus entstandene Buchung * bleibt im Kontoblatt stehen – wer sie loswerden will, löscht sie dort. */ public function umsaetzeLoeschen(): void { $this->verlangeAnmeldung(); // Zurück in denselben Reiter, aus dem gelöscht wurde. $status = $this->query('status', 'offen'); if (!array_key_exists($status, Bankumsatz::STATUS)) { $status = 'offen'; } $ziel = '/banking/umsaetze?status=' . $status; $ids = $this->postIds('umsaetze'); if ($ids === []) { View::redirect($ziel, 'Kein Umsatz ausgewählt.', 'warnung'); return; } $anzahl = Bankumsatz::mehrereLoeschen($ids); View::redirect($ziel, $anzahl === 1 ? 'Umsatz gelöscht.' : $anzahl . ' Umsätze gelöscht.'); } /** * Alles übernehmen, was einen eindeutigen Vorschlag hat. * * Ohne Regel bleibt ein Umsatz hier liegen – anders als beim automatischen * Lauf nach dem Abruf. Wer den Dialog öffnet, will die Zuordnung selbst * treffen; eine Buchung ohne Kategorie wäre ihm nur ungefragt aufgehalst. */ public function alleUebernehmen(): void { $this->verlangeAnmeldung(); View::redirect( '/banking/umsaetze', Bankabgleich::bericht(Bankabgleich::laufenLassen(false)) ); } // ── Regeln ─────────────────────────────────────────────────────────────── public function regeln(): void { $this->verlangeAnmeldung(); $this->render('banking/regeln.php', [ 'titel' => 'Regeln für den Abgleich', 'regeln' => AbgleichRegel::alle(), 'kategorien' => Kategorie::baumFlach(null, true), 'klassen' => Klasse::alle(), 'konten' => Konto::alle(true), ]); } public function regelAnlegen(): void { $this->verlangeAnmeldung(); try { AbgleichRegel::anlegen($this->regeldaten()); } catch (\Throwable $e) { View::redirect('/banking/regeln', $e->getMessage(), 'fehler'); return; } View::redirect('/banking/regeln', 'Regel angelegt.'); } /** @param array $params */ public function regelSpeichern(array $params): void { $this->verlangeAnmeldung(); try { AbgleichRegel::speichern((int)$params['id'], $this->regeldaten()); } catch (\Throwable $e) { View::redirect('/banking/regeln', $e->getMessage(), 'fehler'); return; } View::redirect('/banking/regeln', 'Regel gespeichert.'); } /** @param array $params */ public function regelLoeschen(array $params): void { $this->verlangeAnmeldung(); AbgleichRegel::loeschen((int)$params['id']); View::redirect('/banking/regeln', 'Regel gelöscht.'); } /** * Trägt bei allen Buchungen ohne Kategorie die erste passende Regel nach. * Maßgeblich ist die Kategorie, nicht der Abgleichsstatus: Eine bereits * übernommene Buchung ohne Kategorie soll die Regel genauso finden wie * ein frisch abgerufener Umsatz. */ public function regelnPruefen(): void { $this->verlangeAnmeldung(); $ergebnis = Bankabgleich::regelnAufBuchungenOhneKategorieAnwenden(); View::redirect('/banking/regeln', Bankabgleich::berichtOhneKategorie($ergebnis)); } // ── Formulardaten ──────────────────────────────────────────────────────── /** @return array */ private function formulardaten(): array { $blz = $this->post('blz'); // Bleibt die Adresse leer, wird sie im Bankleitzahlen-Verzeichnis // nachgeschlagen. Das ist der übliche Fall: Die Bankleitzahl kennt // jeder auswendig, den FinTS-Zugangspunkt niemand. $url = $this->post('fints_url'); if ($url === '') { $url = (string)(Bankinstitut::findeNachBlz($blz)['pintan_url'] ?? ''); } return [ 'name' => $this->post('name'), 'bankname' => $this->post('bankname'), 'blz' => $blz, 'fints_url' => $url, 'benutzerkennung' => $this->post('benutzerkennung'), 'pin' => $this->post('pin'), 'abrufzeitraum' => $this->post('abrufzeitraum'), 'abruf_stichtag' => $this->post('abruf_stichtag'), 'aktiv' => isset($_POST['aktiv']) ? 1 : 0, ]; } /** * Die Zugangsdaten einer PayPal-Verbindung aus dem Formular. * * Die drei Angaben stammen aus dem PayPal-Konto unter *Profil → Mein * Verkäuferkonto → API-Zugriff*: API-Benutzername, API-Kennwort und * Signatur. Leere Geheimnisse heißen beim Bearbeiten „unverändert“ – wie die * PIN eines FinTS-Zugangs. * * @return array */ private function paypalFormulardaten(): array { return [ 'typ' => 'paypal', 'name' => $this->post('name'), 'bankname' => 'PayPal', 'api_benutzer' => $this->post('api_benutzer'), 'api_kennwort' => $this->post('api_kennwort'), 'api_signatur' => $this->post('api_signatur'), 'sandbox' => isset($_POST['sandbox']) ? 1 : 0, 'abrufzeitraum' => $this->post('abrufzeitraum'), 'abruf_stichtag' => $this->post('abruf_stichtag'), 'aktiv' => isset($_POST['aktiv']) ? 1 : 0, ]; } /** * Ordnet das eine Konto eines PayPal-Zugangs zu. * * PayPal führt je Konto genau ein Guthabenkonto – eine eigene Seite wie bei * FinTS wäre eine Zeile Tabelle. Fehlt in der Kontenliste das passende * Konto, entsteht es hier gleich mit; abzutippen bleibt der Name. * * @return bool Ob dabei ein eigenes Konto neu angelegt wurde */ private function paypalKontoZuordnen(int $zugangId): bool { $wahl = (string)$this->post('konto_id'); // Ohne Zuordnung bleibt die Verbindung nutzbar: Die Umsätze kommen // herein und warten in der Abgleichliste, bis ein Konto feststeht. Beim // Anlegen entsteht dafür noch keine Kontozeile – die kommt beim ersten // Abruf. Steht sie schon da, heißt die leere Wahl dagegen ausdrücklich // „Zuordnung lösen“ und muss auch so wirken. if ($wahl === '' && Bankzugang::konten($zugangId) === []) { return false; } $neu = $wahl === self::KONTO_NEU; if ($neu && trim((string)$this->post('neuer_name')) === '') { throw new \InvalidArgumentException('Für das neue Konto fehlt der Name.'); } // Alles in einer Transaktion: Scheitert das neue Konto, soll auch die // halbe Zuordnung nicht stehen bleiben. Database::transaktion(function () use ($zugangId, $wahl, $neu): void { $bankkonto = Bankzugang::paypalKonto($zugangId); $kontoId = $wahl === '' ? null : ($neu ? Konto::anlegen([ 'name' => trim((string)$this->post('neuer_name')), 'kontoart' => $this->post('neue_kontoart', 'bank'), 'bankname' => 'PayPal', // Was PayPal an Umsätzen hergibt, reicht nicht bis zur // Kontoeröffnung zurück – der Anfangssaldo schließt die Lücke. 'anfangssaldo' => KontenController::zahl((string)$this->post('anfangssaldo', '0')), 'aktiv' => 1, 'in_summe' => 1, ]) : (int)$wahl); Bankzugang::ordneKontoZu((int)$bankkonto['id'], $kontoId); }); return $neu; } /** @return array */ private function regeldaten(): array { return [ 'name' => $this->post('name'), 'feld' => $this->post('feld'), 'vergleich' => $this->post('vergleich'), 'muster' => $this->post('muster'), 'betrag_von' => $this->post('betrag_von'), 'betrag_bis' => $this->post('betrag_bis'), 'kategorie_id' => $this->post('kategorie_id'), 'klasse_id' => $this->post('klasse_id'), 'gegenkonto_id' => $this->post('gegenkonto_id'), 'splits' => $this->regelsplits(), 'prioritaet' => $this->post('prioritaet', '100'), 'aktiv' => isset($_POST['aktiv']) ? 1 : 0, ]; } /** * Das Split-Schema einer Regel aus dem Formular. * * Eingegeben wird in Prozent, gespeichert wird als Anteil: Die Regel soll ja * gerade **nicht** an einem Betrag hängen, sondern bei jeder Rechnungshöhe * dasselbe Verhältnis treffen. * * @return list> */ private function regelsplits(): array { $roh = $_POST['split'] ?? []; if (!is_array($roh)) { return []; } $splits = []; foreach ($roh as $zeile) { if (!is_array($zeile)) { continue; } $prozent = KontenController::zahl((string)($zeile['prozent'] ?? '')); if (($zeile['kategorie_id'] ?? '') === '' || $prozent <= 0.0) { continue; } $splits[] = [ 'kategorie_id' => (int)$zeile['kategorie_id'], 'klasse_id' => ($zeile['klasse_id'] ?? '') === '' ? null : (int)$zeile['klasse_id'], 'anteil' => $prozent / 100, 'notiz' => (string)($zeile['notiz'] ?? ''), ]; } return $splits; } /** * Die Zuordnung Bankkonto → eigenes Konto aus dem Formular. * * Nur Bankkonten dieses Zugangs werden angefasst: Die Kennungen kommen aus * dem Formular, und eine fremde darf weder etwas zuordnen noch ein Konto * anlegen. * * Alles in einer Transaktion: Scheitert eine Zeile – meist ein neues Konto * ohne Namen –, soll die Seite wieder so dastehen wie vorher und nicht halb * zugeordnet. * * @param array $zugang * @return int Zahl der dabei neu angelegten eigenen Konten */ private function kontenzuordnungSpeichern(array $zugang): int { $zuordnung = $_POST['bankkonto'] ?? []; if (!is_array($zuordnung)) { return 0; } $bekannt = []; foreach (Bankzugang::konten((int)$zugang['id']) as $eintrag) { $bekannt[(int)$eintrag['id']] = $eintrag; } return (int)Database::transaktion(function () use ($zugang, $zuordnung, $bekannt): int { $neue = 0; foreach ($zuordnung as $bankKontoId => $werte) { $bankKontoId = (int)$bankKontoId; if (!is_array($werte) || !isset($bekannt[$bankKontoId])) { continue; } $wahl = (string)($werte['konto_id'] ?? ''); if ($wahl === self::KONTO_NEU) { $kontoId = $this->kontoAnlegen($zugang, $bekannt[$bankKontoId], $werte); $neue++; } else { $kontoId = $wahl === '' ? null : (int)$wahl; } Bankzugang::ordneKontoZu($bankKontoId, $kontoId, !empty($werte['abrufen'])); } return $neue; }); } /** * Legt das eigene Konto zu einem Bankkonto an. IBAN, Kontonummer und * Bankname kommen aus dem, was die Bank gemeldet hat – abtippen muss der * Nutzer nur den Namen, unter dem das Konto in seiner Liste stehen soll. * * @param array $zugang * @param array $bankkonto * @param array $werte */ private function kontoAnlegen(array $zugang, array $bankkonto, array $werte): int { $name = trim((string)($werte['neuer_name'] ?? '')); if ($name === '') { throw new \InvalidArgumentException( 'Für „' . ($bankkonto['iban'] ?: $bankkonto['kontonummer']) . '“ fehlt der Name des neuen Kontos.' ); } return Konto::anlegen([ 'name' => $name, 'kontoart' => (string)($werte['neue_kontoart'] ?? 'bank'), 'bankname' => (string)($zugang['bankname'] !== '' ? $zugang['bankname'] : $zugang['name']), 'iban' => (string)$bankkonto['iban'], 'kontonummer' => (string)$bankkonto['kontonummer'], // Was die Bank an Umsätzen liefert, reicht selten bis zur // Kontoeröffnung zurück – der Anfangssaldo schließt die Lücke. 'anfangssaldo' => KontenController::zahl((string)($werte['anfangssaldo'] ?? '0')), 'aktiv' => 1, 'in_summe' => 1, ]); } /** * Splitzeilen aus der Abgleichmaske. * * @return list> */ private function splitsAusFormular(): array { $roh = $_POST['split'] ?? []; if (!is_array($roh)) { return []; } $splits = []; foreach ($roh as $zeile) { if (!is_array($zeile) || ($zeile['betrag'] ?? '') === '') { continue; } $splits[] = [ 'kategorie_id' => ($zeile['kategorie_id'] ?? '') === '' ? null : (int)$zeile['kategorie_id'], 'klasse_id' => ($zeile['klasse_id'] ?? '') === '' ? null : (int)$zeile['klasse_id'], 'betrag' => (float)str_replace(',', '.', (string)$zeile['betrag']), 'notiz' => (string)($zeile['notiz'] ?? ''), ]; } return $splits; } }