From b4a1b0acbc312159307448765aa804553fc0a56d Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Wed, 30 Sep 2026 14:30:44 +0200 Subject: [PATCH 1/3] =?UTF-8?q?Fix=20n.n.:=20Depotabruf=20per=20FinTS=20un?= =?UTF-8?q?d=20Nachweis=20zur=20=C3=9Cberweisung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Depotaufstellung (HKWPD, Version 6) unter Wertpapiere mit Vergleich zum eigenen Bestand; Prüfsumme und Textnachweis für das Übermittlungsprotokoll einer Überweisung; phpFinTS v4.1.5. Co-Authored-By: Claude Sonnet 5.5 --- Changes.md | 19 ++ composer.json | 2 +- composer.lock | 14 +- migrations/021_depot_bankbestand.sql | 31 +++ public/index.php | 3 + src/Controllers/BankingController.php | 23 +++ src/Controllers/WertpapiereController.php | 30 +++ src/Controllers/ZahlungsverkehrController.php | 21 +++ src/Models/Bankprotokoll.php | 62 +++++- src/Services/Depotabgleich.php | 152 +++++++++++++++ src/Services/FinTsAbruf.php | 178 +++++++++++++++++- src/Views/wertpapiere/auswertung.php | 1 + src/Views/wertpapiere/bank.php | 111 +++++++++++ src/Views/wertpapiere/buchungen.php | 1 + src/Views/wertpapiere/import.php | 1 + src/Views/wertpapiere/index.php | 1 + src/Views/wertpapiere/papier.php | 1 + src/Views/wertpapiere/stammdaten.php | 1 + src/Views/zahlungsverkehr/protokoll.php | 5 + tests/51_DepotBankbestandTest.php | 150 +++++++++++++++ 20 files changed, 793 insertions(+), 14 deletions(-) create mode 100644 migrations/021_depot_bankbestand.sql create mode 100644 src/Services/Depotabgleich.php create mode 100644 src/Views/wertpapiere/bank.php create mode 100644 tests/51_DepotBankbestandTest.php diff --git a/Changes.md b/Changes.md index 9abfcab..9635556 100644 --- a/Changes.md +++ b/Changes.md @@ -1,5 +1,24 @@ # Änderungen +## 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 + IBAN liefert. Es steht in den Kontostammdaten der Bank (`HIUPD`) mit erlaubtem `HKWPD`. +- Neuer Modus `depot` in `FinTsAbruf`: Anmeldung, Depots aus der UPD, je Depot + `GetDepotAufstellung` (gleiche TAN-Behandlung und Protokollierung wie der Umsatzabruf). Der + Knopf sitzt unter Wertpapiere → *Bestand laut Bank*, nicht im Umsatzabruf: andere Daten, + eigene TAN. +- Das Depot im Finanzmanager wird über die Kontonummer gefunden (Kontenverwaltung). + `Depotabgleich::uebernehmen()` speichert den Stand des letzten Abrufs + (`depot_bankbestaende`, Migration 021) und trägt die Bewertungskurse bekannter Wertpapiere + ein. Gebucht wird nichts; `Depotabgleich::vergleich()` zeigt Abweichungen im Bestand. +- Nachweis zur Überweisung: `bank_protokolle.pruefsumme` (SHA-256 des Protokolls, beim Speichern + gebildet). Im Protokoll einer Überweisung lädt *Nachweis herunterladen* eine Textdatei mit + 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. +- Test 51. + ## Buchungsmaske: Splitzeilen bleiben bei falscher Summe erhalten (29.09.2026) - Scheiterte das Speichern einer Buchung an der Summenprüfung der Splitzeilen (Fehlermeldung diff --git a/composer.json b/composer.json index 089be9c..6da61b3 100644 --- a/composer.json +++ b/composer.json @@ -11,7 +11,7 @@ ], "require": { "php": ">=8.3", - "nemiah/php-fints": "4.1.4" + "nemiah/php-fints": "4.1.5" }, "autoload": { "psr-4": { diff --git a/composer.lock b/composer.lock index 8ef9611..a9e69ce 100644 --- a/composer.lock +++ b/composer.lock @@ -4,20 +4,20 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "45b0a342d8df9f66cbfa77a47c308c8c", + "content-hash": "945e20000b1dbc31d67c0f961078a4bf", "packages": [ { "name": "nemiah/php-fints", - "version": "v4.1.4", + "version": "v4.1.5", "source": { "type": "git", "url": "https://codeberg.org/fds2610-bot/phpFinTS.git", - "reference": "ded991612d10d4dcfe83824aa45d18203337a7a9" + "reference": "e083524a72856f47dd6ca01f94bbb6a74f9f7b16" }, "dist": { "type": "zip", - "url": "https://codeberg.org/api/v1/repos/fds2610-bot/phpFinTS/archive/ded991612d10d4dcfe83824aa45d18203337a7a9.zip", - "reference": "ded991612d10d4dcfe83824aa45d18203337a7a9", + "url": "https://codeberg.org/api/v1/repos/fds2610-bot/phpFinTS/archive/e083524a72856f47dd6ca01f94bbb6a74f9f7b16.zip", + "reference": "e083524a72856f47dd6ca01f94bbb6a74f9f7b16", "shasum": "" }, "require": { @@ -67,10 +67,10 @@ "description": "PHP Library for the protocols fints and hbci", "homepage": "https://codeberg.org/fds2610-bot/phpFinTS", "support": { - "source": "https://codeberg.org/fds2610-bot/phpFinTS/tag/v4.1.4", + "source": "https://codeberg.org/fds2610-bot/phpFinTS/tag/v4.1.5", "issues": "https://codeberg.org/fds2610-bot/phpFinTS/issues" }, - "time": "2026-09-04T09:39:44+02:00" + "time": "2026-09-30T14:24:52+02:00" } ], "packages-dev": [], diff --git a/migrations/021_depot_bankbestand.sql b/migrations/021_depot_bankbestand.sql new file mode 100644 index 0000000..ee71677 --- /dev/null +++ b/migrations/021_depot_bankbestand.sql @@ -0,0 +1,31 @@ +-- Depotbestand laut Bank (FinTS-Depotaufstellung, HKWPD). +-- +-- Je Depot bleibt nur der Stand des letzten Abrufs stehen; die vollständige +-- Bankantwort liegt im Übermittlungsprotokoll (`bank_protokolle`). Der Bestand +-- dient dem Vergleich mit den eigenen Depotbuchungen – gebucht wird daraus nichts. +CREATE TABLE IF NOT EXISTS depot_bankbestaende ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + benutzer_id INT UNSIGNED NOT NULL, + konto_id INT UNSIGNED NOT NULL, + isin VARCHAR(16) NOT NULL DEFAULT '', + wkn VARCHAR(16) NOT NULL DEFAULT '', + name VARCHAR(255) NOT NULL DEFAULT '', + stueck DECIMAL(18,6) NOT NULL DEFAULT 0, + einstiegskurs DECIMAL(18,6) NULL, + kurs DECIMAL(18,6) NULL, + wert DECIMAL(15,2) NULL, + waehrung CHAR(3) NOT NULL DEFAULT 'EUR', + stand DATE NULL, + abgerufen_am DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_bankbestand_konto (konto_id), + CONSTRAINT fk_bankbestand_benutzer FOREIGN KEY (benutzer_id) + REFERENCES benutzer (id) ON DELETE CASCADE, + CONSTRAINT fk_bankbestand_konto FOREIGN KEY (konto_id) + REFERENCES konten (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Prüfsumme (SHA-256) des Protokolltextes, beim Speichern gebildet: Der +-- Nachweis-Export zeigt damit, ob das Protokoll seither unverändert ist. +ALTER TABLE bank_protokolle + ADD COLUMN IF NOT EXISTS pruefsumme CHAR(64) NULL AFTER protokoll; diff --git a/public/index.php b/public/index.php index b8c83e0..b1d48fc 100644 --- a/public/index.php +++ b/public/index.php @@ -256,6 +256,7 @@ $router->post('/zahlungsverkehr/tan/abbrechen', fn() => (new ZahlungsverkehrCont $router->get('/zahlungsverkehr/vop', fn() => (new ZahlungsverkehrController())->vopFormular()); $router->post('/zahlungsverkehr/vop', fn() => (new ZahlungsverkehrController())->vopBestaetigen()); +$router->get('/zahlungsverkehr/{id}/nachweis', fn(array $p) => (new ZahlungsverkehrController())->nachweis($p)); $router->get('/zahlungsverkehr/{id}/protokoll', fn(array $p) => (new ZahlungsverkehrController())->protokoll($p)); $router->get('/zahlungsverkehr/{id}', fn(array $p) => (new ZahlungsverkehrController())->bearbeiten($p)); $router->post('/zahlungsverkehr/{id}', fn(array $p) => (new ZahlungsverkehrController())->speichern($p)); @@ -284,6 +285,7 @@ $router->post('/wertpapiere/stammdaten/{id}/kurs', fn(array $p) => (new WertpapiereController())->kursSetzen($p)); $router->post('/wertpapiere/kurse', fn() => (new WertpapiereController())->kurseAbrufen()); +$router->get('/wertpapiere/bank', fn() => (new WertpapiereController())->bankbestand()); $router->get('/wertpapiere/import', fn() => (new WertpapiereController())->importFormular()); $router->post('/wertpapiere/import', fn() => (new WertpapiereController())->importAusfuehren()); @@ -329,6 +331,7 @@ $router->get('/banking/{id}/verfahren', fn(array $p) => (new BankingController( $router->post('/banking/{id}/verfahren', fn(array $p) => (new BankingController())->verfahrenWaehlen($p)); $router->post('/banking/{id}/verfahren/abfragen', fn(array $p) => (new BankingController())->verfahrenAbfragen($p)); +$router->post('/banking/{id}/depot', fn(array $p) => (new BankingController())->depotAbrufen($p)); $router->get('/banking/{id}/konten', fn(array $p) => (new BankingController())->konten($p)); $router->post('/banking/{id}/konten', fn(array $p) => (new BankingController())->kontenSpeichern($p)); $router->post('/banking/{id}/konten/abfragen', diff --git a/src/Controllers/BankingController.php b/src/Controllers/BankingController.php index a9eb329..aac9573 100644 --- a/src/Controllers/BankingController.php +++ b/src/Controllers/BankingController.php @@ -402,6 +402,20 @@ class BankingController extends BaseController ]); } + /** + * Den Depotbestand bei der Bank abrufen. Der Knopf sitzt unter Wertpapiere, + * nicht beim Umsatzabruf: andere Daten, anderer Rhythmus, eigene TAN. + * + * @param array $params + */ + public function depotAbrufen(array $params): void + { + $this->verlangeAnmeldung(); + + $id = (int)$params['id']; + $this->zeigeAbruf(FinTsAbruf::starten($id, 'depot'), $id); + } + /** * 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 @@ -746,6 +760,15 @@ class BankingController extends BaseController // Aufrufer war dann `tanEinreichen()` ohne jeden Zusammenhang. $zugangId ??= isset($ergebnis['zugang_id']) ? (int)$ergebnis['zugang_id'] : null; + if (($ergebnis['modus'] ?? 'voll') === 'depot') { + View::redirect( + '/wertpapiere/bank', + $ergebnis['status'] === 'fehler' ? 'Depotabruf fehlgeschlagen: ' . $ergebnis['meldung'] : $ergebnis['meldung'], + $ergebnis['status'] === 'fehler' ? 'fehler' : 'erfolg' + ); + return; + } + $kontenlauf = ($ergebnis['modus'] ?? 'voll') === 'konten'; $kontenseite = $zugangId === null ? '/banking' diff --git a/src/Controllers/WertpapiereController.php b/src/Controllers/WertpapiereController.php index d622603..7aff5f3 100644 --- a/src/Controllers/WertpapiereController.php +++ b/src/Controllers/WertpapiereController.php @@ -4,12 +4,14 @@ declare(strict_types=1); namespace Finanzmanager\Controllers; +use Finanzmanager\Models\Bankzugang; use Finanzmanager\Models\Depotbuchung; use Finanzmanager\Models\Einstellung; use Finanzmanager\Models\Konto; use Finanzmanager\Models\Wertpapier; use Finanzmanager\Services\Depot; use Finanzmanager\Services\DepotbestandImport; +use Finanzmanager\Services\Depotabgleich; use Finanzmanager\Services\Kursabruf; use Finanzmanager\Services\Zeitraum; use Finanzmanager\View; @@ -237,6 +239,34 @@ class WertpapiereController extends BaseController return '/wertpapiere/buchungen' . ($query === [] ? '' : '?' . http_build_query($query)); } + // ── Bestand laut Bank ──────────────────────────────────────────────────── + + /** + * Der Depotbestand, wie ihn die Bank per FinTS meldet, neben dem eigenen + * Bestand. Der Abruf selbst läuft über den Banking-Bereich (TAN-Maske). + */ + public function bankbestand(): void + { + $this->verlangeAnmeldung(); + + $depots = []; + foreach (Depot::depots(true) as $depot) { + $depots[] = $depot + [ + 'vergleich' => Depotabgleich::vergleich((int)$depot['id']), + 'abgerufen_am' => Depotabgleich::abgerufenAm((int)$depot['id']), + ]; + } + + $this->render('wertpapiere/bank.php', [ + 'titel' => 'Depotbestand laut Bank', + 'depots' => $depots, + 'zugaenge' => array_values(array_filter( + Bankzugang::alle(true), + static fn(array $zugang): bool => (string)$zugang['typ'] === 'fints' + )), + ]); + } + // ── Bestandsimport aus einer CSV-Datei ─────────────────────────────────── public function importFormular(): void diff --git a/src/Controllers/ZahlungsverkehrController.php b/src/Controllers/ZahlungsverkehrController.php index 4aac4f2..eafdbee 100644 --- a/src/Controllers/ZahlungsverkehrController.php +++ b/src/Controllers/ZahlungsverkehrController.php @@ -58,6 +58,27 @@ class ZahlungsverkehrController extends BaseController ]); } + /** + * Der Nachweis zu einer Überweisung als Textdatei – für den Fall, dass eine + * Bank behauptet, sie nie erhalten zu haben. + * + * @param array $params + */ + public function nachweis(array $params): void + { + $this->verlangeAnmeldung(); + + $auftrag = Zahlungsauftrag::find((int)$params['id']); + if ($auftrag === null) { + View::redirect('/zahlungsverkehr', 'Den Zahlungsauftrag gibt es nicht.', 'fehler'); + return; + } + + header('Content-Type: text/plain; charset=utf-8'); + header('Content-Disposition: attachment; filename="nachweis-ueberweisung-' . (int)$auftrag['id'] . '.txt"'); + echo Bankprotokoll::nachweis($auftrag); + } + public function neu(): void { $this->verlangeAnmeldung(); diff --git a/src/Models/Bankprotokoll.php b/src/Models/Bankprotokoll.php index 9f6b867..68a104d 100644 --- a/src/Models/Bankprotokoll.php +++ b/src/Models/Bankprotokoll.php @@ -39,8 +39,8 @@ class Bankprotokoll ): int { Database::execute( 'INSERT INTO bank_protokolle - (benutzer_id, zugang_id, auftrag_id, art, status, meldung, protokoll) - VALUES (?, ?, ?, ?, ?, ?, ?)', + (benutzer_id, zugang_id, auftrag_id, art, status, meldung, protokoll, pruefsumme) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)', [ Mandant::id(), $zugangId === 0 ? null : $zugangId, @@ -49,12 +49,70 @@ class Bankprotokoll array_key_exists($status, self::STATUS) ? $status : 'fehler', mb_substr($meldung, 0, 2000), $protokoll, + hash('sha256', $protokoll), ] ); return Database::lastInsertId(); } + /** + * Der Nachweis zu einer Überweisung als Text: Auftrag, jeder Durchlauf mit + * dem vollständigen Dialog und den Prüfsummen (SHA-256). Die gespeicherte + * Prüfsumme entstand beim Sichern des Protokolls; stimmt sie nicht mehr mit + * dem Text überein, steht es im Nachweis. + * + * @param array $auftrag + */ + public static function nachweis(array $auftrag): string + { + $eintraege = self::fuerAuftrag((int)$auftrag['id']); + + $kopf = [ + 'NACHWEIS DER ÜBERMITTLUNG – Überweisung Nr. ' . (int)$auftrag['id'], + 'Erstellt am: ' . date('d.m.Y H:i:s'), + 'Empfänger: ' . ($auftrag['empfaenger'] ?? ''), + 'IBAN: ' . ($auftrag['iban'] ?? ''), + 'Betrag: ' . number_format((float)($auftrag['betrag'] ?? 0), 2, ',', '.') + . ' ' . ($auftrag['waehrung'] ?? 'EUR'), + 'Verwendungszweck: ' . ($auftrag['verwendungszweck'] ?? ''), + 'Ausführung: ' . (($auftrag['ausfuehrung_am'] ?? '') ?: 'sofort'), + 'Status: ' . (Zahlungsauftrag::STATUS[$auftrag['status'] ?? ''] ?? ($auftrag['status'] ?? '')), + 'Durchläufe: ' . count($eintraege), + 'Hinweis: PIN und Benutzerkennung sind im Protokoll maskiert.', + ]; + + $text = implode("\n", $kopf) . "\n"; + + foreach ($eintraege as $nr => $eintrag) { + $gespeichert = (string)($eintrag['pruefsumme'] ?? ''); + $aktuell = hash('sha256', (string)$eintrag['protokoll']); + + $text .= "\n" . str_repeat('=', 78) . "\n" + . sprintf( + "Durchlauf %d von %d – Protokoll Nr. %d, %s, %s\n", + $nr + 1, + count($eintraege), + (int)$eintrag['id'], + $eintrag['erstellt_am'], + self::STATUS[$eintrag['status']] ?? $eintrag['status'] + ) + . 'Bankverbindung: ' . ($eintrag['zugangsname'] ?? '–') . "\n" + . 'Meldung: ' . ($eintrag['meldung'] ?? '') . "\n" + . 'SHA-256 des Protokolls: ' . $aktuell . "\n" + . 'Prüfung: ' . match (true) { + $gespeichert === '' => 'keine Prüfsumme gespeichert (Protokoll älter als die Prüfsumme)', + $gespeichert === $aktuell => 'unverändert seit dem Speichern', + default => 'ABWEICHUNG – gespeichert war ' . $gespeichert, + } . "\n" + . str_repeat('-', 78) . "\n" + . $eintrag['protokoll'] . "\n"; + } + + return $text . "\n" . str_repeat('=', 78) . "\nSHA-256 dieses Nachweises (ohne diese Zeile): " + . hash('sha256', $text) . "\n"; + } + /** * Alle Durchläufe zu einer Überweisung, der älteste zuerst. * diff --git a/src/Services/Depotabgleich.php b/src/Services/Depotabgleich.php new file mode 100644 index 0000000..61a140f --- /dev/null +++ b/src/Services/Depotabgleich.php @@ -0,0 +1,152 @@ + $positionen + * @return array{positionen: int, kurse: int} + */ + public static function uebernehmen(int $kontoId, array $positionen): array + { + return Database::transaktion(static function () use ($kontoId, $positionen): array { + Database::execute( + 'DELETE FROM depot_bankbestaende WHERE konto_id = ? AND benutzer_id = ?', + [$kontoId, Mandant::id()] + ); + + $kurse = 0; + foreach ($positionen as $position) { + $isin = strtoupper(trim((string)$position->getISIN())); + $wkn = trim((string)$position->getWKN()); + $name = trim((string)$position->getName()); + $stand = $position->getDate()?->format('Y-m-d'); + $kurs = $position->getPrice(); + + Database::execute( + 'INSERT INTO depot_bankbestaende + (benutzer_id, konto_id, isin, wkn, name, stueck, einstiegskurs, kurs, wert, waehrung, stand) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + [ + Mandant::id(), + $kontoId, + $isin, + $wkn, + mb_substr($name, 0, 255), + (float)$position->getAmount(), + $position->getAcquisitionPrice(), + $kurs, + $position->getValue() === null ? null : round($position->getValue(), 2), + strtoupper((string)$position->getCurrency()) ?: 'EUR', + $stand, + ] + ); + + if ($kurs === null || $kurs <= 0.0) { + continue; + } + $papier = Wertpapier::finde($isin, $wkn, $name); + if ($papier !== null) { + Wertpapier::kursSetzen( + (int)$papier['id'], + $stand ?? date('Y-m-d'), + $kurs, + 'bank', + strtoupper((string)$position->getCurrency()) ?: 'EUR' + ); + $kurse++; + } + } + + return ['positionen' => count($positionen), 'kurse' => $kurse]; + }); + } + + /** + * Bankbestand und eigener Bestand nebeneinander, je Papier eine Zeile. + * Verglichen wird über die ISIN, ohne ISIN über den Namen. + * + * @return list> mit `bank`, `eigen` und `abweichung` + * (Stück laut Bank minus eigene Stück) + */ + public static function vergleich(int $kontoId): array + { + $bank = Database::fetchAll( + 'SELECT * FROM depot_bankbestaende WHERE konto_id = ? AND benutzer_id = ? ORDER BY name', + [$kontoId, Mandant::id()] + ); + + $eigen = []; + foreach (Depot::bestand($kontoId)['positionen'] as $position) { + if (abs((float)$position['stueck']) < 0.000001) { + continue; + } + $eigen[self::schluessel((string)$position['isin'], (string)$position['name'])] = $position; + } + + $zeilen = []; + foreach ($bank as $eintrag) { + $schluessel = self::schluessel((string)$eintrag['isin'], (string)$eintrag['name']); + $mein = $eigen[$schluessel] ?? null; + unset($eigen[$schluessel]); + + $zeilen[] = [ + 'name' => (string)$eintrag['name'], + 'isin' => (string)$eintrag['isin'], + 'bank' => $eintrag, + 'eigen' => $mein, + 'abweichung' => round((float)$eintrag['stueck'] - (float)($mein['stueck'] ?? 0.0), 6), + ]; + } + + // Was nur im Finanzmanager steht, hat die Bank nicht (mehr) im Depot. + foreach ($eigen as $mein) { + $zeilen[] = [ + 'name' => (string)$mein['name'], + 'isin' => (string)$mein['isin'], + 'bank' => null, + 'eigen' => $mein, + 'abweichung' => round(-(float)$mein['stueck'], 6), + ]; + } + + return $zeilen; + } + + /** Wann der Bankbestand des Depots zuletzt abgerufen wurde. */ + public static function abgerufenAm(int $kontoId): ?string + { + $wert = Database::fetchValue( + 'SELECT MAX(abgerufen_am) FROM depot_bankbestaende WHERE konto_id = ? AND benutzer_id = ?', + [$kontoId, Mandant::id()] + ); + + return $wert === false || $wert === null ? null : (string)$wert; + } + + private static function schluessel(string $isin, string $name): string + { + $isin = strtoupper(trim($isin)); + + return $isin !== '' ? $isin : 'name:' . mb_strtolower(trim($name)); + } +} diff --git a/src/Services/FinTsAbruf.php b/src/Services/FinTsAbruf.php index 5982d42..846f2b1 100644 --- a/src/Services/FinTsAbruf.php +++ b/src/Services/FinTsAbruf.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Finanzmanager\Services; use Fhp\Action\GetBalance; +use Fhp\Action\GetDepotAufstellung; use Fhp\Action\GetSEPAAccounts; use Fhp\Action\GetStatementOfAccount; use Fhp\BaseAction; @@ -52,6 +53,11 @@ use Finanzmanager\Models\Einstellung; * Einrichtungsschritt: erst zuordnen, dann Umsätze holen – dieselbe * Zustandsmaschine, dieselbe TAN-Behandlung, nur kürzer. * + * Im Modus `depot` folgt auf die Anmeldung die Depotaufstellung (HKWPD) je + * Depot, das die Bank in den Kontostammdaten (UPD) führt. Ein Depot hat keine + * IBAN und fehlt deshalb in der Kontenabfrage (HKSPA). Der Modus läuft bewusst + * getrennt vom Umsatzabruf: andere Daten, anderer Rhythmus, eigene TAN. + * * Ist `banking_automatisch_buchen` eingeschaltet, bucht `Bankabgleich` am Ende * eines vollen Laufs, was ein Konto hat (siehe dort). */ @@ -87,7 +93,8 @@ class FinTsAbruf * `$modus` entscheidet, wie weit er läuft: `voll` ruft die Umsätze ab, * `konten` hält nach der Kontenabfrage an. Der kurze Lauf ist der dritte * Einrichtungsschritt – dort sollen erst die Konten zugeordnet werden, bevor - * Umsätze hereinkommen, die sonst ohne Konto dastünden. + * Umsätze hereinkommen, die sonst ohne Konto dastünden. `depot` holt statt + * der Umsätze die Depotaufstellung. * * `$stichtag` gilt nur für diesen Lauf und geht dem Abrufzeitraum des Zugangs * vor. Er liegt im Zustand, nicht in der Datenbank: Nach einer TAN-Runde @@ -124,7 +131,7 @@ class FinTsAbruf $abruf = new self($zugang, Bankabruf::beginnen($zugangId), [ 'schritt' => 'anmelden', - 'modus' => $modus === 'konten' ? 'konten' : 'voll', + 'modus' => in_array($modus, ['konten', 'depot'], true) ? $modus : 'voll', 'einrichtung' => $einrichtung, 'stichtag' => $stichtag, 'konto_nummer' => 0, @@ -263,7 +270,11 @@ class FinTsAbruf } $this->notiere('Angemeldet bei ' . ($this->zugang['bankname'] ?: $this->zugang['name']) . '.'); - $this->zustand['schritt'] = 'konten'; + $this->zustand['schritt'] = $this->naechsterSchrittNachAnmeldung(); + } + + if ($this->zustand['schritt'] === 'depot') { + return $this->depotsAbrufen(); } if ($this->zustand['schritt'] === 'konten') { @@ -486,6 +497,160 @@ class FinTsAbruf } } + // ── Depot ──────────────────────────────────────────────────────────────── + + private function naechsterSchrittNachAnmeldung(): string + { + return ($this->zustand['modus'] ?? 'voll') === 'depot' ? 'depot' : 'konten'; + } + + /** + * Holt die Depotaufstellung jedes Depots, das die Bank in den + * Kontostammdaten führt. Der Index steht im Zustand – verlangt ein Depot + * eine TAN, geht es danach genau dort weiter. + * + * @return array + */ + private function depotsAbrufen(): array + { + if (!isset($this->zustand['depots'])) { + $this->zustand['depots'] = $this->depotkonten(); + $this->zustand['depot_nummer'] = 0; + $this->zustand['depot_geholt'] = 0; + } + + $depots = $this->zustand['depots']; + if ($depots === []) { + return $this->beenden('fehler', 'Die Bank meldet für diesen Zugang kein Depot mit Abrufmöglichkeit.'); + } + + for ($nummer = (int)$this->zustand['depot_nummer']; $nummer < count($depots); $nummer++) { + $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.'); + continue; + } + + $sepa = new SEPAAccount(); + $sepa->setAccountNumber((string)$depot['kontonummer']); + $sepa->setSubAccount($depot['unterkonto'] !== '' ? (string)$depot['unterkonto'] : null); + $sepa->setBlz((string)$this->zugang['blz']); + + $aktion = GetDepotAufstellung::create($sepa); + $this->fints->execute($aktion); + + $wartet = $this->pruefeTan($aktion, 'Depot ' . $depot['kontonummer'], 'depot'); + if ($wartet !== null) { + return $wartet; + } + + $this->schreibeDepot($depot, $aktion); + } + + 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')) . '.' + ); + } + + return $this->beenden('erfolg', sprintf( + 'Depotbestand abgerufen: %d Positionen in %d Depot(s).', + $this->zustand['neu'], + $this->zustand['depot_geholt'] + )); + } + + /** + * Die Depots aus den Kontostammdaten der Bank (UPD, `HIUPD`): Kontoart + * 30–39 oder ein erlaubter Geschäftsvorfall `HKWPD`. Die Bibliothek reicht die + * UPD nicht nach außen; sie steht nach der Anmeldung im FinTs-Objekt. + * + * @return list + */ + private function depotkonten(): array + { + $upd = (new \ReflectionProperty(FinTs::class, 'upd'))->getValue($this->fints); + if ($upd === null) { + $this->notiere('Die Bank hat keine Kontostammdaten (UPD) geliefert.'); + return []; + } + + $depots = []; + foreach ($upd->hiupd as $hiupd) { + $verbindung = $hiupd->getKontoverbindung(); + $art = $hiupd->getKontoart(); + $abrufbar = false; + foreach ($hiupd->getErlaubteGeschaeftsvorfaelle() as $erlaubt) { + $abrufbar = $abrufbar || $erlaubt->getGeschaeftsvorfall() === 'HKWPD'; + } + + $istDepot = ($art !== null && $art >= 30 && $art <= 39) || $abrufbar; + if ($verbindung === null || !$istDepot) { + continue; + } + + $nummer = (string)$verbindung->getAccountNumber(); + if (!$abrufbar) { + $this->notiere('Depot ' . $nummer . ' übersprungen – die Bank erlaubt dafür keine Depotaufstellung.'); + continue; + } + + $depots[] = [ + 'kontonummer' => $nummer, + 'unterkonto' => (string)$verbindung->unterkontomerkmal, + 'name' => trim((string)$hiupd->getKontoproduktbezeichnung()), + ]; + $this->notiere('Depot bei der Bank: ' . $nummer . ' (' . trim((string)$hiupd->getName1()) . ').'); + } + + return $depots; + } + + /** Das Depot im Finanzmanager, dessen Kontonummer zu der der Bank passt. */ + private static function eigenesDepot(string $kontonummer): ?array + { + $gesucht = ltrim(trim($kontonummer), '0'); + if ($gesucht === '') { + return null; + } + + foreach (Depot::depots() as $depot) { + if (ltrim(trim((string)$depot['kontonummer']), '0') === $gesucht) { + return $depot; + } + } + + return null; + } + + /** @param array $depot */ + private function schreibeDepot(array $depot, GetDepotAufstellung $aktion): void + { + $konto = self::eigenesDepot((string)$depot['kontonummer']); + if ($konto === null) { + return; + } + + $ergebnis = Depotabgleich::uebernehmen((int)$konto['id'], $aktion->getStatement()->getHoldings()); + + $this->zustand['neu'] += $ergebnis['positionen']; + $this->zustand['depot_geholt'] = (int)($this->zustand['depot_geholt'] ?? 0) + 1; + $this->zustand['depot_nummer'] = (int)$this->zustand['depot_nummer'] + 1; + + $this->notiere(sprintf( + '%s: %d Positionen, Depotwert laut Bank %s €, %d Kurse übernommen.', + $konto['name'], + $ergebnis['positionen'], + number_format($aktion->getDepotWert(), 2, ',', '.'), + $ergebnis['kurse'] + )); + } + // ── TAN ────────────────────────────────────────────────────────────────── /** @@ -581,7 +746,12 @@ class FinTsAbruf $this->merkeKonten($aktion->getAccounts()); $this->zustand['schritt'] = 'umsaetze'; } elseif ($this->zustand['schritt'] === 'anmelden') { - $this->zustand['schritt'] = 'konten'; + $this->zustand['schritt'] = $this->naechsterSchrittNachAnmeldung(); + } elseif ($this->zustand['schritt'] === 'depot' && $aktion instanceof GetDepotAufstellung) { + $depot = $this->zustand['depots'][(int)$this->zustand['depot_nummer']] ?? null; + if ($depot !== null) { + $this->schreibeDepot($depot, $aktion); + } } elseif ($this->zustand['schritt'] === 'umsaetze' && $aktion instanceof GetStatementOfAccount) { $konten = $this->abzurufendeKonten(); $konto = $konten[(int)$this->zustand['konto_nummer']] ?? null; diff --git a/src/Views/wertpapiere/auswertung.php b/src/Views/wertpapiere/auswertung.php index f51f02f..5083d89 100644 --- a/src/Views/wertpapiere/auswertung.php +++ b/src/Views/wertpapiere/auswertung.php @@ -18,6 +18,7 @@ include __DIR__ . '/../layout/kopf.php';
Bestände + Bestand laut Bank Depotbuchungen Wertpapiere > $depots Depots mit `vergleich` und `abgerufen_am` + * @var list> $zugaenge aktive FinTS-Bankverbindungen + * @var string $csrfToken + */ + +include __DIR__ . '/../layout/kopf.php'; +?> + + + + +

+ Es gibt keine aktive FinTS-Bankverbindung. Der Bestand lässt sich nur über + Online-Banking abrufen. +

+ +
+ +
+ + +
+ +
+

+ Der Abruf fragt die Bank nach der Depotaufstellung (FinTS HKWPD) und verlangt in der Regel + eine TAN. Er ändert keine Buchungen: Die Kurse bekannter Wertpapiere werden übernommen, + Unterschiede im Bestand zeigt die Tabelle nur an. Das Depot im Finanzmanager braucht dafür + die Kontonummer der Bank (Kontenverwaltung). +

+ + + +

Noch kein Depot vorhanden.

+ + + +

+ + +

+ Noch nicht abgerufen +

+ + + +

Abgerufen am + um Uhr.

+ + + + + + + + + + + + + + + + + + + + + + + + +
WertpapierStück laut BankStück im FinanzmanagerAbweichungKursWert laut Bank
+ + + + + + + + + + + + + + +
+ + + diff --git a/src/Views/wertpapiere/buchungen.php b/src/Views/wertpapiere/buchungen.php index 3a0edc2..56ed335 100644 --- a/src/Views/wertpapiere/buchungen.php +++ b/src/Views/wertpapiere/buchungen.php @@ -28,6 +28,7 @@ include __DIR__ . '/../layout/kopf.php';
Bestände + Bestand laut Bank Depotbuchungen Wertpapiere Veräußerungen diff --git a/src/Views/wertpapiere/import.php b/src/Views/wertpapiere/import.php index bc4f491..70851b3 100644 --- a/src/Views/wertpapiere/import.php +++ b/src/Views/wertpapiere/import.php @@ -23,6 +23,7 @@ include __DIR__ . '/../layout/kopf.php';
Bestände + Bestand laut Bank Depotbuchungen Wertpapiere Veräußerungen diff --git a/src/Views/wertpapiere/index.php b/src/Views/wertpapiere/index.php index 512b8dc..0bf666f 100644 --- a/src/Views/wertpapiere/index.php +++ b/src/Views/wertpapiere/index.php @@ -18,6 +18,7 @@ include __DIR__ . '/../layout/kopf.php';
Bestände + Bestand laut Bank Depotbuchungen Wertpapiere Veräußerungen diff --git a/src/Views/wertpapiere/papier.php b/src/Views/wertpapiere/papier.php index ea9b7b8..3172a58 100644 --- a/src/Views/wertpapiere/papier.php +++ b/src/Views/wertpapiere/papier.php @@ -56,6 +56,7 @@ include __DIR__ . '/../layout/kopf.php';
Bestände + Bestand laut Bank Depotbuchungen Wertpapiere Veräußerungen diff --git a/src/Views/wertpapiere/stammdaten.php b/src/Views/wertpapiere/stammdaten.php index 51d56af..89c7539 100644 --- a/src/Views/wertpapiere/stammdaten.php +++ b/src/Views/wertpapiere/stammdaten.php @@ -16,6 +16,7 @@ include __DIR__ . '/../layout/kopf.php';
Bestände + Bestand laut Bank Depotbuchungen Wertpapiere Veräußerungen diff --git a/src/Views/zahlungsverkehr/protokoll.php b/src/Views/zahlungsverkehr/protokoll.php index 74d0d17..3cba000 100644 --- a/src/Views/zahlungsverkehr/protokoll.php +++ b/src/Views/zahlungsverkehr/protokoll.php @@ -27,6 +27,11 @@ if (!$fragment) {

Zu dieser Überweisung liegt kein Protokoll vor.

+

+ + Nachweis herunterladen (Text mit Prüfsummen) + +

diff --git a/tests/51_DepotBankbestandTest.php b/tests/51_DepotBankbestandTest.php new file mode 100644 index 0000000..2bb0f2d --- /dev/null +++ b/tests/51_DepotBankbestandTest.php @@ -0,0 +1,150 @@ +mandantAnlegen(); + + $this->depot = Konto::anlegen([ + 'name' => 'Bankdepot', 'kontoart' => 'depot', 'kontonummer' => '502022353', + 'anfangssaldo' => 0, 'aktiv' => 1, + ]); + + $this->bestandUndKurs(); + $this->vergleichZeigtAbweichungen(); + $this->zweiterAbrufErsetztDenErsten(); + $this->nachweisMitPruefsumme(); + } + + private function position(string $isin, string $name, float $stueck, float $kurs): Holding + { + return (new Holding()) + ->setISIN($isin) + ->setName($name) + ->setAmount($stueck) + ->setPrice($kurs) + ->setValue($stueck * $kurs) + ->setCurrency('EUR') + ->setDate(new \DateTime('2026-09-30')); + } + + private function bestandUndKurs(): void + { + $bekannt = Wertpapier::findenOderAnlegen([ + 'name' => 'Bekannte AG', 'isin' => 'DE0001111111', 'art' => 'aktie', + ]); + + $ergebnis = Depotabgleich::uebernehmen($this->depot, [ + $this->position('DE0001111111', 'Bekannte AG', 10.0, 12.5), + $this->position('DE0002222222', 'Unbekannte AG', 4.0, 100.0), + ]); + + $this->assertEquals(2, $ergebnis['positionen'], 'Beide Positionen stehen im Bankbestand'); + $this->assertEquals(1, $ergebnis['kurse'], 'Nur das bekannte Papier bekommt einen Kurs'); + $this->assertEquals( + 12.5, + (float)Wertpapier::letzterKurs($bekannt)['kurs'], + 'Der Bewertungskurs der Bank ist der Kurs des Papiers' + ); + $this->assertNull( + Wertpapier::finde('DE0002222222'), + 'Das unbekannte Papier wird nicht angelegt' + ); + $this->assertNotEmpty(Depotabgleich::abgerufenAm($this->depot), 'Der Abrufzeitpunkt ist vermerkt'); + } + + private function vergleichZeigtAbweichungen(): void + { + $bekannt = Wertpapier::finde('DE0001111111'); + Depotbuchung::anlegen([ + 'konto_id' => $this->depot, 'wertpapier_id' => (int)$bekannt['id'], + 'vorgang' => 'kauf', 'datum' => '2026-01-05', 'stueck' => 8, 'kurs' => 10, 'betrag' => 80, + ]); + $verkauftes = Wertpapier::findenOderAnlegen([ + 'name' => 'Alte AG', 'isin' => 'DE0003333333', 'art' => 'aktie', + ]); + Depotbuchung::anlegen([ + 'konto_id' => $this->depot, 'wertpapier_id' => $verkauftes, + 'vorgang' => 'kauf', 'datum' => '2026-01-05', 'stueck' => 3, 'kurs' => 10, 'betrag' => 30, + ]); + + $je = []; + foreach (Depotabgleich::vergleich($this->depot) as $zeile) { + $je[$zeile['isin']] = $zeile; + } + + $this->assertEquals(2.0, $je['DE0001111111']['abweichung'], 'Die Bank hat 2 Stück mehr als gebucht'); + $this->assertEquals(4.0, $je['DE0002222222']['abweichung'], 'Nur bei der Bank: ganze Position abweichend'); + $this->assertNull($je['DE0002222222']['eigen'], 'Für das unbekannte Papier gibt es keinen eigenen Bestand'); + $this->assertEquals(-3.0, $je['DE0003333333']['abweichung'], 'Nur im Finanzmanager: Bank führt es nicht'); + $this->assertNull($je['DE0003333333']['bank'], 'Bei der Bank fehlt das Papier'); + } + + private function zweiterAbrufErsetztDenErsten(): void + { + Depotabgleich::uebernehmen($this->depot, [ + $this->position('DE0001111111', 'Bekannte AG', 8.0, 13.0), + ]); + + $bank = array_filter( + Depotabgleich::vergleich($this->depot), + static fn(array $zeile): bool => $zeile['bank'] !== null + ); + $this->assertEquals(1, count($bank), 'Der neue Abruf ersetzt den alten Bankbestand'); + } + + private function nachweisMitPruefsumme(): void + { + $verrechnung = Konto::anlegen([ + 'name' => 'Nachweisgiro', 'kontoart' => 'bank', 'anfangssaldo' => 100, 'aktiv' => 1, + ]); + $auftragId = Zahlungsauftrag::anlegen([ + 'konto_id' => $verrechnung, 'empfaenger' => 'Beweis GmbH', + 'iban' => 'DE02120300000000202051', 'betrag' => 12.34, + 'verwendungszweck' => 'Rechnung 42', + ]); + + $dialog = "> HNHBK:1:3+000000000100'\n< HIRMG:2:2+0010::Nachricht entgegengenommen'"; + $id = Bankprotokoll::speichern(null, 'ueberweisung', 'erfolg', 'übergeben', $dialog, $auftragId); + $this->assertNotEmpty($id, 'Das Protokoll wird gespeichert'); + + $auftrag = Zahlungsauftrag::find($auftragId); + $nachweis = Bankprotokoll::nachweis($auftrag); + + $this->assert(str_contains($nachweis, 'Beweis GmbH'), 'Der Nachweis nennt den Empfänger'); + $this->assert(str_contains($nachweis, $dialog), 'Der Nachweis enthält den vollständigen Dialog'); + $this->assert(str_contains($nachweis, hash('sha256', $dialog)), 'Der Nachweis nennt die Prüfsumme'); + $this->assert(str_contains($nachweis, 'unverändert seit dem Speichern'), 'Unveränderter Text wird bestätigt'); + + \Finanzmanager\Database::execute( + 'UPDATE bank_protokolle SET protokoll = ? WHERE id = ?', + [$dialog . "\nnachträglich", $id] + ); + $this->assert( + str_contains(Bankprotokoll::nachweis($auftrag), 'ABWEICHUNG'), + 'Ein nachträglich geänderter Text fällt im Nachweis auf' + ); + } +} From 9a3472f4b7d98bf7737db94fd23ce7874b72ccc3 Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Wed, 30 Sep 2026 15:10:23 +0200 Subject: [PATCH 2/3] Fix n.n.: Depot-Zuordnung, Bestand einbuchen mit Stichtag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Depot der Bank wird einem Depot im Finanzmanager zugeordnet (Migration 022) - Bestand einbuchen: Ein-/Auslieferungen aus dem Bankbestand, Stichtag per Dialog - Zuordnungsliste: Depots nur noch als Wertpapiergeschäft Co-Authored-By: Claude Sonnet 5.5 --- Changes.md | 23 +++++++ migrations/022_bankdepots.sql | 8 +++ public/index.php | 1 + public/static/app.js | 10 +++ src/Controllers/BankingController.php | 10 +-- src/Controllers/WertpapiereController.php | 35 ++++++++++ src/Models/Bankzugang.php | 60 ++++++++++++++++- src/Services/Depotabgleich.php | 78 ++++++++++++++++++++-- src/Services/FinTsAbruf.php | 55 ++++++++++------ src/Views/banking/konten.php | 22 +++++-- src/Views/buchungen/zuordnungsliste.php | 6 +- src/Views/wertpapiere/bank.php | 33 +++++++++- tests/51_DepotBankbestandTest.php | 79 +++++++++++++++++++++++ 13 files changed, 378 insertions(+), 42 deletions(-) create mode 100644 migrations/022_bankdepots.sql 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) : []; +

Der Abruf fragt die Bank nach der Depotaufstellung (FinTS HKWPD) und verlangt in der Regel - eine TAN. Er ändert keine Buchungen: Die Kurse bekannter Wertpapiere werden übernommen, - Unterschiede im Bestand zeigt die Tabelle nur an. Das Depot im Finanzmanager braucht dafür - die Kontonummer der Bank (Kontenverwaltung). + eine TAN. Der Abruf ändert keine Buchungen: Die Kurse bekannter Wertpapiere werden übernommen, + Unterschiede im Bestand zeigt die Tabelle an. „Bestand einbuchen“ macht daraus Einlieferungen (Mehrbestand) und Auslieferungen (Minderbestand). Das Depot der Bank muss dafür einem + Depot im Finanzmanager zugeordnet sein (Online-Banking → Konten zuordnen).

@@ -64,6 +64,28 @@ include __DIR__ . '/../layout/kopf.php';

Abgerufen am um Uhr.

+ + + + +

Bestand einbuchen –

+
+ + + + +

+ Die Unterschiede werden als Einlieferungen (Mehrbestand) und Auslieferungen + (Minderbestand) zu diesem Datum gebucht. +

+
+ + +
+
+
+ @@ -71,6 +93,7 @@ include __DIR__ . '/../layout/kopf.php'; + @@ -94,6 +117,10 @@ include __DIR__ . '/../layout/kopf.php'; +
Stück laut Bank Stück im Finanzmanager AbweichungEinstandskurs Kurs Wert laut Bank
+ + diff --git a/tests/51_DepotBankbestandTest.php b/tests/51_DepotBankbestandTest.php index 2bb0f2d..e361e79 100644 --- a/tests/51_DepotBankbestandTest.php +++ b/tests/51_DepotBankbestandTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); use Fhp\Model\StatementOfHoldings\Holding; use Finanzmanager\Models\Bankprotokoll; +use Finanzmanager\Models\Bankzugang; use Finanzmanager\Models\Depotbuchung; use Finanzmanager\Models\Konto; use Finanzmanager\Models\Wertpapier; @@ -34,7 +35,10 @@ class DepotBankbestandTest extends TestCase $this->bestandUndKurs(); $this->vergleichZeigtAbweichungen(); + $this->einbuchen(); + $this->einbuchenMitStichtag(); $this->zweiterAbrufErsetztDenErsten(); + $this->depotZuordnung(); $this->nachweisMitPruefsumme(); } @@ -75,6 +79,42 @@ class DepotBankbestandTest extends TestCase $this->assertNotEmpty(Depotabgleich::abgerufenAm($this->depot), 'Der Abrufzeitpunkt ist vermerkt'); } + private function einbuchen(): void + { + $e = Depotabgleich::einbuchen($this->depot); + + $this->assertEquals(2, $e['eingeliefert'], 'Zwei Positionen mit Mehrbestand eingeliefert'); + $this->assertEquals(1, $e['ausgeliefert'], 'Das nur im Finanzmanager geführte Papier ausgeliefert'); + $this->assertEquals(1, $e['angelegt'], 'Das unbekannte Papier wurde angelegt'); + $this->assertNotEmpty(Wertpapier::finde('DE0002222222'), 'Neues Wertpapier existiert'); + + foreach (Depotabgleich::vergleich($this->depot) as $zeile) { + $this->assert(abs((float)$zeile['abweichung']) < 0.000001, 'Nach dem Einbuchen stimmt ' . $zeile['name']); + } + $this->assertEquals( + ['eingeliefert' => 0, 'ausgeliefert' => 0, 'angelegt' => 0], + Depotabgleich::einbuchen($this->depot), + 'Ein zweites Einbuchen ändert nichts' + ); + } + + private function einbuchenMitStichtag(): void + { + $depot = Konto::anlegen([ + 'name' => 'Stichtagsdepot', 'kontoart' => 'depot', 'kontonummer' => '502022999', + 'anfangssaldo' => 0, 'aktiv' => 1, + ]); + Depotabgleich::uebernehmen($depot, [$this->position('DE0004444444', 'Stichtag AG', 5.0, 20.0)]); + + $e = Depotabgleich::einbuchen($depot, '2025-12-31'); + $this->assertEquals(1, $e['eingeliefert'], 'Der Mehrbestand wird eingeliefert'); + + $buchungen = Depotbuchung::liste(['konto_id' => $depot]); + $this->assertEquals(1, count($buchungen), 'Eine Depotbuchung entstanden'); + $this->assertEquals('2025-12-31', substr((string)$buchungen[0]['datum'], 0, 10), 'Die Einlieferung trägt den Stichtag'); + $this->assert(str_contains((string)$buchungen[0]['notiz'], '30.09.2026'), 'Die Notiz nennt den Bankstand'); + } + private function vergleichZeigtAbweichungen(): void { $bekannt = Wertpapier::finde('DE0001111111'); @@ -115,6 +155,45 @@ class DepotBankbestandTest extends TestCase $this->assertEquals(1, count($bank), 'Der neue Abruf ersetzt den alten Bankbestand'); } + private function depotZuordnung(): void + { + $zugang = Bankzugang::anlegen([ + 'name' => 'Depotbank', 'blz' => '12030000', 'fints_url' => 'https://example.org/fints', + 'benutzerkennung' => 'kennung', 'pin' => '1234', + ]); + + $giro = Bankzugang::findeOderLegeKontoAn($zugang, 'DE02120300000000202051', '202051', 'Giro'); + $bankdepot = Bankzugang::findeOderLegeDepotAn($zugang, '0502022353', 'Depot'); + + $this->assertEquals('depot', $bankdepot['art'], 'Das Depot der Bank ist als Depot vermerkt'); + $this->assertEquals( + $this->depot, + (int)$bankdepot['konto_id'], + 'Passt die Kontonummer (auch mit führender Null), wird gleich zugeordnet' + ); + $this->assertEquals( + (int)$bankdepot['id'], + (int)Bankzugang::findeOderLegeDepotAn($zugang, '0502022353')['id'], + 'Ein zweites Erkennen legt nichts doppelt an' + ); + $this->assertEquals( + [(int)$giro['id']], + array_map(static fn(array $z): int => (int)$z['id'], Bankzugang::konten($zugang)), + 'Der Umsatzabruf sieht das Depot nicht' + ); + $this->assertEquals(1, count(Bankzugang::depots($zugang)), 'Das Depot steht in der Depotliste'); + + $unbekannt = Bankzugang::findeOderLegeDepotAn($zugang, '999'); + $this->assertNull($unbekannt['konto_id'], 'Ohne passende Kontonummer bleibt das Depot unzugeordnet'); + + Bankzugang::ordneKontoZu((int)$unbekannt['id'], $this->depot, true); + $this->assertEquals( + $this->depot, + (int)Bankzugang::depots($zugang)[1]['konto_id'], + 'Die Zuordnung lässt sich von Hand treffen' + ); + } + private function nachweisMitPruefsumme(): void { $verrechnung = Konto::anlegen([ From 98d62b823e8b3d45161cf6ecc2368e1a9ad3ac91 Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Wed, 30 Sep 2026 16:44:26 +0200 Subject: [PATCH 3/3] Fix n.n.: FinTS-Registrierungsdaten aus dem Repository heraushalten Co-Authored-By: Claude Sonnet 5.5 --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index e522bd5..15c2676 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,9 @@ # Altdaten enthalten echte Kontodaten und gehören nicht ins Repository. /Altdaten/ +# FinTS-Registrierungsdaten (Produkt-ID u. Ä.) gehören nicht ins Repository. +/FinTS/ + .DS_Store *.swp