Finanzmanager/src/Controllers/BankingController.php
fds2610-bot 78f1b91bc0 Online-Banking: Kontenzuordnung, Sammellöschen und drei Umsatz-Fehler
Kontenzuordnung als dritter Einrichtungsschritt: Welches Bankkonto gehört zu
welchem eigenen Konto? Umsätze merken sich über `bank_konto_id`, woher sie
stammen, und ziehen mit, wenn die Zuordnung erst nach einem Abruf fällt.
Abgerufene Umsätze buchen sich auf Wunsch selbst (`Services\Bankabgleich`).

Im Kontoblatt lassen sich Buchungen sammelweise löschen; Statusmeldungen liegen
jetzt über der Ansicht, statt den Inhalt springen zu lassen.

Dazu drei Fehler, die zusammen dafür sorgten, dass abgerufene Umsätze spurlos
verschwanden – der Abruf meldete sie als vorhanden, das Kontoblatt zeigte
nichts, und in der Vorschlagsliste standen sie ebenfalls nicht:

- Eine gelöschte Buchung ließ ihren Umsatz auf „uebernommen“ stehen. Der
  Fremdschlüssel setzte nur `buchung_id` auf NULL. `Buchung::loeschen()` gibt
  den Umsatz jetzt vorher frei, `Bankumsatz::verwaisteOeffnen()` fängt die Wege
  ab, die daran vorbeiführen – Kaskaden lösen in MariaDB keine Trigger aus.
- Ein Bankkonto entstand doppelt, wenn die Bank die IBAN nicht bei jedem Dialog
  mitliefert. Gesucht wird jetzt über die Kontonummer; eine nachgelieferte IBAN
  wird am vorhandenen Eintrag nachgetragen.
- Der Fingerabdruck hing an der IBAN und kippte mit ihr. Er nimmt jetzt die
  `bank_konto_id`, die fest steht, solange es den Bankzugang gibt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 11:54:23 +02:00

795 lines
27 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\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\View;
/**
* Online-Banking: Bankverbindungen verwalten, Umsätze abrufen, abgleichen und
* Regeln pflegen.
*
* Der Abruf selbst steckt in `Services\FinTsAbruf`; dieser Controller reicht nur
* durch und übersetzt das Ergebnis in Seiten – vor allem in die TAN-Maske, die
* zwischen zwei Requests stehen bleibt.
*/
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(),
]);
}
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<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;
}
$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'];
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<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;
}
$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;
}
$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 ────────────────────────────────────────────────────────────────
/** @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();
$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<string,mixed> $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<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.');
}
/**
* 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.');
}
/**
* 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<string,mixed> */
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<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'),
'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<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;
}
}