verlangeAnmeldung(); Bankabruf::raeumeAlteAuf(); $this->render('banking/index.php', [ 'titel' => 'Bankverbindungen', 'zugaenge' => Bankzugang::alle(), 'abrufe' => Bankabruf::letzte(10), 'offen' => Bankumsatz::anzahlOffen(), 'schluessel' => Crypto::einsatzbereit(), ]); } public function neu(): void { $this->verlangeAnmeldung(); $this->render('banking/formular.php', [ 'titel' => 'Bankverbindung anlegen', 'zugang' => null, 'konten' => [], ]); } public function anlegen(): void { $this->verlangeAnmeldung(); try { $id = Bankzugang::anlegen($this->formulardaten()); } catch (\Throwable $e) { View::redirect('/banking/neu', $e->getMessage(), 'fehler'); 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.' ); } /** @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; } $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']; try { Bankzugang::speichern($id, $this->formulardaten()); } catch (\Throwable $e) { View::redirect('/banking/' . $id, $e->getMessage(), 'fehler'); return; } View::redirect('/banking/' . $id, '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; } $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; } $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 ──────────────────────────────────────────────────────────────── /** @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(); $ergebnis = FinTsAbruf::starten((int)$params['id']); $this->zeigeAbruf($ergebnis, (int)$params['id']); } /** 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->render('banking/tan.php', [ 'titel' => 'Freigabe erforderlich', 'tan' => $tan, 'meldung' => 'Die Bank verlangt eine Freigabe.', 'protokoll' => [], ]); } public function tanEinreichen(): void { $this->verlangeAnmeldung(); $ergebnis = FinTsAbruf::tanEinreichen($this->post('tan')); $this->zeigeAbruf($ergebnis, null); } public function tanAbbrechen(): void { $this->verlangeAnmeldung(); FinTsAbruf::abbrechen(); View::redirect('/banking', 'Abruf abgebrochen.'); } /** * 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') { $this->render('banking/tan.php', [ 'titel' => 'Freigabe erforderlich', 'tan' => $ergebnis['tan'] ?? [], 'meldung' => $ergebnis['meldung'], 'protokoll' => $ergebnis['protokoll'], ]); 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.'); } /** * 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.'); } /** * Wendet die Regeln auf die offenen Umsätze an, ohne sie zu übernehmen – * damit lässt sich eine neue Regel ausprobieren, bevor sie bucht. */ public function regelnPruefen(): void { $this->verlangeAnmeldung(); $regeln = AbgleichRegel::alle(true); $treffer = 0; $gesamt = 0; foreach (Bankumsatz::liste(['status' => 'offen']) as $umsatz) { $gesamt++; if (AbgleichRegel::passendeRegel($umsatz, $regeln) !== null) { $treffer++; } } View::redirect('/banking/regeln', sprintf( 'Von %d offenen Umsätzen treffen die Regeln auf %d.', $gesamt, $treffer )); } // ── Formulardaten ──────────────────────────────────────────────────────── /** @return array */ private function formulardaten(): array { return [ 'name' => $this->post('name'), 'bankname' => $this->post('bankname'), 'blz' => $this->post('blz'), 'fints_url' => $this->post('fints_url'), 'benutzerkennung' => $this->post('benutzerkennung'), 'pin' => $this->post('pin'), 'abrufzeitraum' => $this->post('abrufzeitraum'), 'aktiv' => isset($_POST['aktiv']) ? 1 : 0, ]; } /** @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'), 'prioritaet' => $this->post('prioritaet', '100'), 'aktiv' => isset($_POST['aktiv']) ? 1 : 0, ]; } /** * 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; } }