diff --git a/Changes.md b/Changes.md index 9635556..223ab5d 100644 --- a/Changes.md +++ b/Changes.md @@ -1,5 +1,18 @@ # Änderungen +## Stichtag beim Einbuchen des Depotbestands (30.09.2026) + +- „Bestand einbuchen“ (Wertpapiere → Bestand laut Bank) fragt in einem modalen Dialog nach dem + Stichtag. Er ist das Datum aller Ein- und Auslieferungen des Laufs; vorbelegt ist der Stand des + Bankabrufs. Die Notiz nennt weiterhin den Bankstand. + +## Zuordnungsliste: Depots nur noch als Wertpapiergeschäft (30.09.2026) + +- Wo die Gruppe „Wertpapiergeschäft im Depot“ steht (Kontoblatt, Buchungsmaske), fehlen die Depots + in der Gruppe „Umbuchung auf ein Konto“. Die fast gleich benannten Einträge führten dazu, dass + ein Kauf als schlichte Umbuchung ohne Stückzahl, Kurs und Nebenkosten gespeichert wurde. In den + Splitzeilen bleibt die Umbuchung ins Depot erhalten. + ## Depotabruf per FinTS und Nachweis zur Überweisung (30.09.2026) - Das Depot (Kontoart 30, ohne IBAN) fehlte in der Kontenabfrage, weil `HKSPA` nur Konten mit @@ -17,6 +30,16 @@ Auftrag, allen Durchläufen im vollen Wortlaut und den Prüfsummen; ein nachträglich geändertes Protokoll fällt dort als ABWEICHUNG auf. - phpFinTS auf v4.1.5: Die DKB meldet die Depotaufstellung nur in Version 6 (`HKWPD`/`HIWPD`/`HIWPDS`); Upstream-PR nemiah/phpFinTS#598. +- Zuordnung des Depots der Bank zu einem Depot im Finanzmanager (Migration 022, + `bank_konten.art`): Das Depot steht als eigene Zeile bei den Bankkonten des Zugangs und wird beim + Kontenabruf (ohne zusätzliche TAN) oder beim Depotabruf erkannt. Passt die Kontonummer zu einem + eigenen Depot, wird gleich zugeordnet; sonst unter Online-Banking → Konten zuordnen (dort auch „neues Konto anlegen“). Der Umsatzabruf sieht nur + Konten (`Bankzugang::konten()`), Depots über `Bankzugang::depots()`. +- „Bestand einbuchen“ über der Tabelle unter Wertpapiere → Bestand laut Bank + (`Depotabgleich::einbuchen()`): Mehrbestand wird als Einlieferung, Minderbestand als Auslieferung gebucht + (ohne Geldseite), unbekannte Wertpapiere werden angelegt. Kurs der Einlieferung: Einstandskurs der Bank, + sonst Bewertungskurs. +- Spalte „Einstandskurs“ (soweit die Bank ihn meldet) in der Tabelle Bestand laut Bank. - Test 51. ## Buchungsmaske: Splitzeilen bleiben bei falscher Summe erhalten (29.09.2026) diff --git a/migrations/022_bankdepots.sql b/migrations/022_bankdepots.sql new file mode 100644 index 0000000..1f40806 --- /dev/null +++ b/migrations/022_bankdepots.sql @@ -0,0 +1,8 @@ +-- Depots der Bank neben den Konten mit IBAN. +-- +-- Ein Depot hat keine IBAN und fehlt in der Kontenabfrage (HKSPA). Es steht in den +-- Kontostammdaten der Bank und wird als eigene Zeile geführt, damit es wie ein +-- Bankkonto einem eigenen Konto (der Kontoart Depot) zugeordnet werden kann. Der +-- Umsatzabruf sieht nur `art = 'konto'`. +ALTER TABLE bank_konten + ADD COLUMN IF NOT EXISTS art ENUM('konto','depot') NOT NULL DEFAULT 'konto' AFTER konto_id; diff --git a/public/index.php b/public/index.php index b1d48fc..e4257a9 100644 --- a/public/index.php +++ b/public/index.php @@ -286,6 +286,7 @@ $router->post('/wertpapiere/stammdaten/{id}/kurs', $router->post('/wertpapiere/kurse', fn() => (new WertpapiereController())->kurseAbrufen()); $router->get('/wertpapiere/bank', fn() => (new WertpapiereController())->bankbestand()); +$router->post('/wertpapiere/bank/einbuchen', fn() => (new WertpapiereController())->bankbestandEinbuchen()); $router->get('/wertpapiere/import', fn() => (new WertpapiereController())->importFormular()); $router->post('/wertpapiere/import', fn() => (new WertpapiereController())->importAusfuehren()); diff --git a/public/static/app.js b/public/static/app.js index 17e6ade..9231e70 100644 --- a/public/static/app.js +++ b/public/static/app.js @@ -304,6 +304,16 @@ }); } + // Depotbestand einbuchen: Der Knopf öffnet den Dialog mit der Stichtagsabfrage. + document.querySelectorAll('[data-einbuchdialog]').forEach(function (knopf) { + knopf.addEventListener('click', function () { + const dialog = document.getElementById(knopf.dataset.einbuchdialog); + if (dialog && typeof dialog.showModal === 'function') { + dialog.showModal(); + } + }); + }); + // Übermittlungsprotokoll im Zahlungsverkehr: Ein Klick auf den Status holt das // Protokoll der Überweisung in einen modalen Dialog. Ohne oder // fetch bleibt der Status ein Link auf dieselbe Ansicht als eigene Seite. diff --git a/src/Controllers/BankingController.php b/src/Controllers/BankingController.php index aac9573..be02086 100644 --- a/src/Controllers/BankingController.php +++ b/src/Controllers/BankingController.php @@ -396,7 +396,7 @@ class BankingController extends BaseController $this->render('banking/konten.php', [ 'titel' => 'Konten zuordnen – ' . $zugang['name'], 'zugang' => $zugang, - 'konten' => Bankzugang::konten((int)$zugang['id']), + 'konten' => Bankzugang::konten((int)$zugang['id'], true), 'eigene' => Konto::alle(), 'einrichtung' => $this->query('einrichtung') === '1', ]); @@ -1197,7 +1197,7 @@ class BankingController extends BaseController } $bekannt = []; - foreach (Bankzugang::konten((int)$zugang['id']) as $eintrag) { + foreach (Bankzugang::konten((int)$zugang['id'], true) as $eintrag) { $bekannt[(int)$eintrag['id']] = $eintrag; } @@ -1219,7 +1219,9 @@ class BankingController extends BaseController $kontoId = $wahl === '' ? null : (int)$wahl; } - Bankzugang::ordneKontoZu($bankKontoId, $kontoId, !empty($werte['abrufen'])); + // Ein Depot hat keinen Umsatzabruf, den man abwählen könnte. + $istDepot = (string)$bekannt[$bankKontoId]['art'] === 'depot'; + Bankzugang::ordneKontoZu($bankKontoId, $kontoId, $istDepot || !empty($werte['abrufen'])); } return $neue; @@ -1247,7 +1249,7 @@ class BankingController extends BaseController return Konto::anlegen([ 'name' => $name, - 'kontoart' => (string)($werte['neue_kontoart'] ?? 'bank'), + 'kontoart' => (string)$bankkonto['art'] === 'depot' ? 'depot' : (string)($werte['neue_kontoart'] ?? 'bank'), 'bankname' => (string)($zugang['bankname'] !== '' ? $zugang['bankname'] : $zugang['name']), 'iban' => (string)$bankkonto['iban'], 'kontonummer' => (string)$bankkonto['kontonummer'], diff --git a/src/Controllers/WertpapiereController.php b/src/Controllers/WertpapiereController.php index 7aff5f3..9a71408 100644 --- a/src/Controllers/WertpapiereController.php +++ b/src/Controllers/WertpapiereController.php @@ -267,6 +267,41 @@ class WertpapiereController extends BaseController ]); } + /** Bucht die Unterschiede zwischen Bank- und eigenem Bestand ein. */ + public function bankbestandEinbuchen(): void + { + $this->verlangeAnmeldung(); + + $depotId = (int)$this->post('konto_id'); + if (Depot::depots(true) === [] || Konto::find($depotId) === null) { + View::redirect('/wertpapiere/bank', 'Dieses Depot gibt es nicht.', 'fehler'); + return; + } + + $stichtag = trim((string)$this->post('stichtag')); + if ($stichtag !== '') { + $datum = \DateTimeImmutable::createFromFormat('!Y-m-d', $stichtag); + if ($datum === false || $datum->format('Y-m-d') !== $stichtag) { + View::redirect('/wertpapiere/bank', 'Der Stichtag ist kein gültiges Datum.', 'fehler'); + return; + } + } + + try { + $e = Depotabgleich::einbuchen($depotId, $stichtag !== '' ? $stichtag : null); + } catch (\Throwable $ex) { + View::redirect('/wertpapiere/bank', 'Einbuchen fehlgeschlagen: ' . $ex->getMessage(), 'fehler'); + return; + } + + View::redirect('/wertpapiere/bank', sprintf( + '%d Einlieferung(en), %d Auslieferung(en) gebucht, %d Wertpapier(e) neu angelegt.', + $e['eingeliefert'], + $e['ausgeliefert'], + $e['angelegt'] + )); + } + // ── Bestandsimport aus einer CSV-Datei ─────────────────────────────────── public function importFormular(): void diff --git a/src/Models/Bankzugang.php b/src/Models/Bankzugang.php index ee8a430..86b9032 100644 --- a/src/Models/Bankzugang.php +++ b/src/Models/Bankzugang.php @@ -435,18 +435,72 @@ class Bankzugang } /** @return list> */ - public static function konten(int $zugangId): array + public static function konten(int $zugangId, bool $mitDepots = false): array { return Database::fetchAll( 'SELECT bk.*, k.name AS kontoname FROM bank_konten bk LEFT JOIN konten k ON k.id = bk.konto_id - WHERE bk.zugang_id = ? - ORDER BY bk.iban, bk.kontonummer', + WHERE bk.zugang_id = ?' . ($mitDepots ? '' : " AND bk.art = 'konto'") . ' + ORDER BY bk.art, bk.iban, bk.kontonummer', [$zugangId] ); } + /** + * Die Depots, die die Bank für diesen Zugang gemeldet hat. + * + * @return list> + */ + public static function depots(int $zugangId): array + { + return array_values(array_filter( + self::konten($zugangId, true), + static fn(array $zeile): bool => (string)$zeile['art'] === 'depot' + )); + } + + /** + * Das Depot der Bank mit dieser Depotnummer – angelegt, falls es noch fehlt. + * + * Gibt es im Finanzmanager schon ein Depot (Kontoart Depot) mit derselben + * Kontonummer, ist die Zuordnung offensichtlich und wird gleich getroffen. + * + * @return array + */ + public static function findeOderLegeDepotAn(int $zugangId, string $depotnummer, string $bezeichnung = ''): array + { + $depotnummer = trim($depotnummer); + + $zeile = Database::fetchOne( + "SELECT * FROM bank_konten WHERE zugang_id = ? AND art = 'depot' AND kontonummer = ? LIMIT 1", + [$zugangId, $depotnummer] + ); + if ($zeile !== false) { + return $zeile; + } + + $gesucht = ltrim($depotnummer, '0'); + $konto = null; + foreach (Database::fetchAll( + "SELECT id, kontonummer FROM konten WHERE benutzer_id = ? AND kontoart = 'depot'", + [Mandant::id()] + ) as $eigenes) { + if ($gesucht !== '' && ltrim(trim((string)$eigenes['kontonummer']), '0') === $gesucht) { + $konto = (int)$eigenes['id']; + break; + } + } + + Database::execute( + "INSERT INTO bank_konten (zugang_id, konto_id, art, iban, kontonummer, bezeichnung) + VALUES (?, ?, 'depot', '', ?, ?)", + [$zugangId, $konto, mb_substr($depotnummer, 0, 34), mb_substr($bezeichnung, 0, 128)] + ); + + return Database::fetchOne('SELECT * FROM bank_konten WHERE id = ?', [Database::lastInsertId()]) ?: []; + } + /** * Das eine Konto eines PayPal-Zugangs – angelegt, falls es noch keines gibt. * diff --git a/src/Services/Depotabgleich.php b/src/Services/Depotabgleich.php index 61a140f..5739858 100644 --- a/src/Services/Depotabgleich.php +++ b/src/Services/Depotabgleich.php @@ -6,17 +6,18 @@ namespace Finanzmanager\Services; use Finanzmanager\Database; use Finanzmanager\Mandant; +use Finanzmanager\Models\Depotbuchung; use Finanzmanager\Models\Wertpapier; /** * Der Depotbestand laut Bank (FinTS-Depotaufstellung) im Vergleich mit dem * eigenen Bestand. * - * Übernommen wird nur, was sicher ist: der Bestand landet in + * Der Abruf übernimmt nur, was sicher ist: der Bestand landet in * `depot_bankbestaende`, und die Bewertungskurse der Papiere, die es im - * Finanzmanager schon gibt, werden als Kurs des Abrufdatums eingetragen. Es - * entstehen weder Depotbuchungen noch neue Wertpapiere – ein Unterschied im - * Bestand ist ein Hinweis, keine Buchung. + * Finanzmanager schon gibt, werden als Kurs des Abrufdatums eingetragen. Ein + * Unterschied im Bestand ist zunächst ein Hinweis; erst `einbuchen()` macht + * daraus Depotbuchungen (auf ausdrücklichen Knopfdruck). */ class Depotabgleich { @@ -132,6 +133,75 @@ class Depotabgleich return $zeilen; } + /** + * Bucht die Unterschiede zwischen Bankbestand und eigenem Bestand ein: + * Mehrbestand als Einlieferung, Minderbestand als Auslieferung, jeweils + * ohne Geldseite (kein Verrechnungskonto). Unbekannte Wertpapiere werden + * angelegt. Der Einlieferungskurs ist der Einstandskurs der Bank, ohne + * den der Bewertungskurs. Danach stimmen beide Bestände überein. + * + * Der Stichtag (`Y-m-d`) ist das Datum aller Buchungen; ohne Angabe gilt + * der Stand des Bankabrufs, sonst heute. + * + * @return array{eingeliefert: int, ausgeliefert: int, angelegt: int} + */ + public static function einbuchen(int $kontoId, ?string $stichtag = null): array + { + return Database::transaktion(static function () use ($kontoId, $stichtag): array { + $ergebnis = ['eingeliefert' => 0, 'ausgeliefert' => 0, 'angelegt' => 0]; + + foreach (self::vergleich($kontoId) as $zeile) { + $diff = (float)$zeile['abweichung']; + if (abs($diff) < 0.000001) { + continue; + } + + $bank = $zeile['bank']; + $stand = (string)($bank['stand'] ?? '') !== '' ? (string)$bank['stand'] : date('Y-m-d'); + $datum = $stichtag ?? $stand; + + if ($diff > 0) { + $papier = Wertpapier::finde((string)$zeile['isin'], (string)($bank['wkn'] ?? ''), (string)$zeile['name']); + if ($papier === null) { + $ergebnis['angelegt']++; + } + $wertpapierId = $papier !== null ? (int)$papier['id'] : Wertpapier::findenOderAnlegen([ + 'name' => $zeile['name'], + 'isin' => $zeile['isin'], + 'wkn' => $bank['wkn'] ?? '', + 'art' => 'aktie', + 'waehrung' => $bank['waehrung'] ?? 'EUR', + ]); + $kurs = (float)($bank['einstiegskurs'] ?? 0) > 0 + ? (float)$bank['einstiegskurs'] : (float)($bank['kurs'] ?? 0); + $vorgang = 'einlage'; + } else { + $wertpapierId = (int)Wertpapier::finde((string)$zeile['isin'], '', (string)$zeile['name'])['id']; + $kurs = 0.0; + $vorgang = 'entnahme'; + } + + Depotbuchung::anlegen([ + 'konto_id' => $kontoId, + 'wertpapier_id' => $wertpapierId, + 'datum' => $datum, + 'vorgang' => $vorgang, + 'stueck' => abs($diff), + 'kurs' => $kurs > 0 ? $kurs : null, + 'betrag' => round(abs($diff) * $kurs, 2), + 'waehrung' => $bank['waehrung'] ?? 'EUR', + 'notiz' => 'Bestand laut Bank vom ' . date('d.m.Y', strtotime($stand)) + . ($datum !== $stand ? ', gebucht zum ' . date('d.m.Y', strtotime($datum)) : ''), + 'wertpapiername' => $zeile['name'], + 'quelle' => 'fints', + ]); + $ergebnis[$vorgang === 'einlage' ? 'eingeliefert' : 'ausgeliefert']++; + } + + return $ergebnis; + }); + } + /** Wann der Bankbestand des Depots zuletzt abgerufen wurde. */ public static function abgerufenAm(int $kontoId): ?string { diff --git a/src/Services/FinTsAbruf.php b/src/Services/FinTsAbruf.php index 846f2b1..d25a4c1 100644 --- a/src/Services/FinTsAbruf.php +++ b/src/Services/FinTsAbruf.php @@ -23,6 +23,7 @@ use Finanzmanager\Models\Bankprotokoll; use Finanzmanager\Models\Bankumsatz; use Finanzmanager\Models\Bankzugang; use Finanzmanager\Models\Einstellung; +use Finanzmanager\Models\Konto; /** * Der Umsatzabruf über FinTS. @@ -528,9 +529,8 @@ class FinTsAbruf $this->zustand['depot_nummer'] = $nummer; $depot = $depots[$nummer]; - if (self::eigenesDepot((string)$depot['kontonummer']) === null) { - $this->notiere('Übersprungen: Depot ' . $depot['kontonummer'] - . ' – im Finanzmanager gibt es kein Depot mit dieser Kontonummer.'); + if ($this->eigenesDepot($depot) === null) { + $this->notiere('Übersprungen: Depot ' . $depot['kontonummer'] . ' ist keinem Depot zugeordnet.'); continue; } @@ -553,8 +553,8 @@ class FinTsAbruf if ((int)$this->zustand['depot_geholt'] === 0) { return $this->beenden( 'fehler', - 'Kein Depot der Bank passt zu einem Depot im Finanzmanager. Das Konto der Kontoart ' - . 'Depot braucht die Kontonummer ' . implode(' / ', array_column($depots, 'kontonummer')) . '.' + 'Das Depot ' . implode(' / ', array_column($depots, 'kontonummer')) + . ' ist noch keinem Depot im Finanzmanager zugeordnet. Bitte unter Online-Banking → Konten zuordnen zuordnen und dann erneut abrufen.' ); } @@ -611,27 +611,24 @@ class FinTsAbruf return $depots; } - /** Das Depot im Finanzmanager, dessen Kontonummer zu der der Bank passt. */ - private static function eigenesDepot(string $kontonummer): ?array + /** + * Das Depot im Finanzmanager, dem das Depot der Bank zugeordnet ist. + * + * @param array $depot + * @return array|null + */ + private function eigenesDepot(array $depot): ?array { - $gesucht = ltrim(trim($kontonummer), '0'); - if ($gesucht === '') { - return null; - } + $bankkonto = Bankzugang::findeOderLegeDepotAn((int)$this->zugang['id'], $depot['kontonummer'], $depot['name']); + $kontoId = (int)($bankkonto['konto_id'] ?? 0); - foreach (Depot::depots() as $depot) { - if (ltrim(trim((string)$depot['kontonummer']), '0') === $gesucht) { - return $depot; - } - } - - return null; + return $kontoId > 0 ? Konto::find($kontoId) : null; } /** @param array $depot */ private function schreibeDepot(array $depot, GetDepotAufstellung $aktion): void { - $konto = self::eigenesDepot((string)$depot['kontonummer']); + $konto = $this->eigenesDepot($depot); if ($konto === null) { return; } @@ -825,6 +822,26 @@ class FinTsAbruf } $this->notiere(count($konten) . ' Konto/Konten bei der Bank gefunden.'); + + // Depots haben keine IBAN und stehen nicht in dieser Liste, sondern in + // den Kontostammdaten. Sie werden hier gleich mit vermerkt, damit sie + // sich zuordnen lassen – ohne zusätzliche TAN. + try { + $this->merkeDepots(); + } catch (\Throwable $e) { + $this->notiere('Depots nicht auslesbar: ' . $e->getMessage()); + } + } + + private function merkeDepots(): void + { + foreach ($this->depotkonten() as $depot) { + Bankzugang::findeOderLegeDepotAn( + (int)$this->zugang['id'], + $depot['kontonummer'], + $depot['name'] !== '' ? $depot['name'] : 'Depot' + ); + } } /** @param array $bankkonto */ diff --git a/src/Views/banking/konten.php b/src/Views/banking/konten.php index 95c652b..1b839ac 100644 --- a/src/Views/banking/konten.php +++ b/src/Views/banking/konten.php @@ -73,6 +73,7 @@ include __DIR__ . '/../layout/kopf.php'; + Depot @@ -92,6 +94,7 @@ include __DIR__ . '/../layout/kopf.php'; data-neues-konto="neues-konto-"> + diff --git a/src/Views/buchungen/zuordnungsliste.php b/src/Views/buchungen/zuordnungsliste.php index 44821ee..0577382 100644 --- a/src/Views/buchungen/zuordnungsliste.php +++ b/src/Views/buchungen/zuordnungsliste.php @@ -23,8 +23,9 @@ use Finanzmanager\View; * diesmal als Wertpapiergeschäft. Ein Kauf ist am Ende zwar eine Umbuchung ins * Depot, aber erst, wenn Stückzahl, Kurs und Nebenkosten feststehen – die * Auswahl führt deshalb in die Wertpapiermaske, statt die Zuordnung sofort zu - * speichern. Die schlichte Umbuchung bleibt daneben stehen: Auf ein Depot lässt - * sich auch Geld überweisen, ohne dass etwas gekauft wird. + * speichern. Wo diese Gruppe steht, fehlen die Depots in der Gruppe „Umbuchung + * auf ein Konto“: Zwei fast gleich benannte Einträge führten dazu, dass ein + * Kauf als schlichte Umbuchung ohne Stückzahl und Nebenkosten endete. * * In den Splitzeilen fehlt die Gruppe. Dort steht der Betrag der Zeile schon * fest, und die Umbuchung ins Depot ist genau das, was gebraucht wird. @@ -48,6 +49,7 @@ $depots = !empty($mitDepot) ? array_filter($konten, $istDepot) : []; +