Der Abrufzeitraum kannte bisher nur Spannen. Der Stichtag steht jetzt an zwei Stellen: als Vorgabe der Bankverbindung (neuer Abrufzeitraum "Ab Stichtag" mit Datum in bank_zugaenge.abruf_stichtag) und als einmalige Angabe neben "Umsätze abrufen", die der Vorgabe vorgeht und nichts speichert. Der einmalige Stichtag liegt im Abrufzustand, nicht in der Datenbank: Nach einer TAN-Runde läuft der Abruf im nächsten Request weiter und muss dort denselben Zeitraum holen wie beim Start. "Ab Stichtag" ohne Datum und unlesbare Datumsangaben werden abgewiesen; ein Stichtag in der Zukunft wird auf heute gesetzt, statt den Abruf leer ausgehen zu lassen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
805 lines
28 KiB
PHP
805 lines
28 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\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 ────────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* 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();
|
||
|
||
$ergebnis = FinTsAbruf::starten((int)$params['id'], 'voll', false, $this->post('stichtag'));
|
||
|
||
$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'),
|
||
'abruf_stichtag' => $this->post('abruf_stichtag'),
|
||
'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;
|
||
}
|
||
}
|