Ü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>
1250 lines
44 KiB
PHP
1250 lines
44 KiB
PHP
<?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;
|
||
}
|
||
}
|