Finanzmanager/src/Controllers/BankingController.php
fds2610-bot 4d833f273b Fix #75: Knopf „Alle Umsätze abrufen“ ruft jede Bank der Reihe nach ab
Über der Tabelle der Bankverbindungen ruft ein neuer Knopf jede aktive
Bankverbindung einzeln nacheinander ab. Die Warteschlange (Services\Sammelabruf)
übersteht TAN-Runden in der Sitzung; nach der Freigabe geht es mit der nächsten
Bank weiter. Die TAN-Maske zeigt den Stand und erlaubt, eine Bank zu
überspringen. Am Ende steht je Bankverbindung eine eigene Meldung.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 12:38:24 +02:00

1250 lines
44 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
declare(strict_types=1);
namespace Finanzmanager\Controllers;
use Finanzmanager\Auth;
use Finanzmanager\Database;
use Finanzmanager\Models\AbgleichRegel;
use Finanzmanager\Models\Bankabruf;
use Finanzmanager\Models\Bankinstitut;
use Finanzmanager\Models\Bankprotokoll;
use Finanzmanager\Models\Bankumsatz;
use Finanzmanager\Models\Bankzugang;
use Finanzmanager\Models\Kategorie;
use Finanzmanager\Models\Klasse;
use Finanzmanager\Models\Konto;
use Finanzmanager\Services\Bankabgleich;
use Finanzmanager\Services\Crypto;
use Finanzmanager\Services\FinTsAbruf;
use Finanzmanager\Services\FinTsVerfahren;
use Finanzmanager\Services\PaypalAbruf;
use Finanzmanager\Services\Sammelabruf;
use Finanzmanager\View;
/**
* Online-Banking: Bankverbindungen verwalten, Umsätze abrufen, abgleichen und
* Regeln pflegen.
*
* Der Abruf selbst steckt in `Services\FinTsAbruf` beziehungsweise
* `Services\PaypalAbruf`; dieser Controller reicht nur durch und übersetzt das
* Ergebnis in Seiten – vor allem in die TAN-Maske, die zwischen zwei Requests
* stehen bleibt. Beide Dienste liefern dasselbe Ergebnis, deshalb wertet
* `zeigeAbruf()` sie gleich aus.
*
* Eine Bankverbindung ist entweder ein **HBCI/FinTS**-Zugang oder ein
* **PayPal**-Zugang. Der FinTS-Weg wird in drei Schritten eingerichtet
* (Zugangsdaten → TAN-Verfahren → Konten zuordnen), weil jeder Schritt die Bank
* fragen muss. PayPal braucht das nicht: Die Zugangsdaten stehen von vornherein
* fest, und ein PayPal-Konto führt genau ein Guthabenkonto – die Zuordnung
* steht deshalb gleich mit im Formular.
*/
class BankingController extends BaseController
{
/** Schlüssel der abgefragten TAN-Verfahren in der Sitzung. */
private const SITZUNG_VERFAHREN = 'banking_verfahren';
/** Wert der Kontenauswahl, der für „neues Konto anlegen“ steht. */
public const KONTO_NEU = 'neu';
// ── Bankverbindungen ─────────────────────────────────────────────────────
public function index(): void
{
$this->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<string,string> $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<string,string>
*/
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<string,string> $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<string,string> $params
*/
private static function typ(array $params): string
{
$typ = (string)($params['typ'] ?? 'fints');
return array_key_exists($typ, Bankzugang::TYPEN) ? $typ : 'fints';
}
/** @param array<string,string> $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<string,string> $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<string,string> $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<string,string> $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<string,string> $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<string,string> $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<string,string> $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<string,string> $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<string,string> $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<array<string,mixed>>}
*/
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<array<string,mixed>>}|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<string,string> $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<string,mixed> $zugang
* @return array<string,mixed>
*/
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<string,mixed> $tan
* @param list<string> $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<string,mixed> $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<string,string> $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<string,string> $params */
public function verwerfen(array $params): void
{
$this->verlangeAnmeldung();
Bankumsatz::verwerfen((int)$params['id']);
View::redirect('/banking/umsaetze', 'Umsatz verworfen.');
}
/** @param array<string,string> $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<string,string> $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<string,string> $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<string,mixed> */
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<string,mixed>
*/
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<string,mixed> */
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<array<string,mixed>>
*/
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<string,mixed> $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<string,mixed> $zugang
* @param array<string,mixed> $bankkonto
* @param array<string,mixed> $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<array<string,mixed>>
*/
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;
}
}