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' + ); + } +}