Fix n.n.: Depotabruf per FinTS und Nachweis zur Überweisung

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 <noreply@anthropic.com>
This commit is contained in:
fds2610-bot 2026-09-30 14:30:44 +02:00
parent d2635183ec
commit b4a1b0acbc
20 changed files with 793 additions and 14 deletions

View file

@ -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

View file

@ -11,7 +11,7 @@
],
"require": {
"php": ">=8.3",
"nemiah/php-fints": "4.1.4"
"nemiah/php-fints": "4.1.5"
},
"autoload": {
"psr-4": {

14
composer.lock generated
View file

@ -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": [],

View file

@ -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;

View file

@ -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',

View file

@ -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<string,string> $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'

View file

@ -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

View file

@ -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<string,string> $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();

View file

@ -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<string,mixed> $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.
*

View file

@ -0,0 +1,152 @@
<?php
declare(strict_types=1);
namespace Finanzmanager\Services;
use Finanzmanager\Database;
use Finanzmanager\Mandant;
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
* `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.
*/
class Depotabgleich
{
/**
* Ersetzt den Bankbestand eines Depots durch den frischen Abruf.
*
* @param list<\Fhp\Model\StatementOfHoldings\Holding> $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<array<string,mixed>> 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));
}
}

View file

@ -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<string,mixed>
*/
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<array{kontonummer: string, unterkonto: string, name: string}>
*/
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<string,string> $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;

View file

@ -18,6 +18,7 @@ include __DIR__ . '/../layout/kopf.php';
<div class="tableiste">
<a href="/wertpapiere" class="tab">Bestände</a>
<a href="/wertpapiere/bank" class="tab">Bestand laut Bank</a>
<a href="/wertpapiere/buchungen" class="tab">Depotbuchungen</a>
<a href="/wertpapiere/stammdaten" class="tab">Wertpapiere</a>
<a href="/wertpapiere/auswertung?art=veraeusserungen"

View file

@ -0,0 +1,111 @@
<?php
use Finanzmanager\View;
/**
* Depotbestand laut Bank (FinTS-Depotaufstellung) neben dem eigenen Bestand.
*
* @var list<array<string,mixed>> $depots Depots mit `vergleich` und `abgerufen_am`
* @var list<array<string,mixed>> $zugaenge aktive FinTS-Bankverbindungen
* @var string $csrfToken
*/
include __DIR__ . '/../layout/kopf.php';
?>
<div class="tableiste">
<a href="/wertpapiere" class="tab">Bestände</a>
<span class="tab aktiv">Bestand laut Bank</span>
<a href="/wertpapiere/buchungen" class="tab">Depotbuchungen</a>
<a href="/wertpapiere/stammdaten" class="tab">Wertpapiere</a>
<a href="/wertpapiere/auswertung?art=veraeusserungen" class="tab">Veräußerungen</a>
<a href="/wertpapiere/auswertung?art=ertraege" class="tab">Erträge</a>
</div>
<?php if ($zugaenge === []): ?>
<p class="platzhalter">
Es gibt keine aktive FinTS-Bankverbindung. Der Bestand lässt sich nur über
<a href="/banking">Online-Banking</a> abrufen.
</p>
<?php else: ?>
<div class="seitenaktionen">
<?php foreach ($zugaenge as $zugang): ?>
<form method="post" action="/banking/<?= (int)$zugang['id'] ?>/depot" class="inline">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<button type="submit" class="knopf primaer">
Depot abrufen – <?= View::h($zugang['name']) ?>
</button>
</form>
<?php endforeach; ?>
</div>
<p class="nebentext">
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).
</p>
<?php endif; ?>
<?php if ($depots === []): ?>
<p class="platzhalter">Noch kein Depot vorhanden.</p>
<?php endif; ?>
<?php foreach ($depots as $depot): ?>
<h2><?= View::h($depot['name']) ?></h2>
<?php if ($depot['abgerufen_am'] === null): ?>
<p class="platzhalter">
Noch nicht abgerufen<?= trim((string)$depot['kontonummer']) === ''
? ' – dem Depot fehlt die Kontonummer.' : '.' ?>
</p>
<?php continue; ?>
<?php endif; ?>
<p class="nebentext">Abgerufen am <?= View::h(View::datum(substr((string)$depot['abgerufen_am'], 0, 10))) ?>
um <?= View::h(substr((string)$depot['abgerufen_am'], 11, 5)) ?> Uhr.</p>
<table class="liste zahlen">
<thead>
<tr>
<th>Wertpapier</th>
<th class="rechts">Stück laut Bank</th>
<th class="rechts">Stück im Finanzmanager</th>
<th class="rechts">Abweichung</th>
<th class="rechts">Kurs</th>
<th class="rechts">Wert laut Bank</th>
</tr>
</thead>
<tbody>
<?php foreach ($depot['vergleich'] as $zeile): ?>
<tr>
<td>
<?= View::h($zeile['name']) ?>
<?php if ($zeile['isin'] !== ''): ?>
<span class="nebentext"><?= View::h($zeile['isin']) ?></span>
<?php endif; ?>
</td>
<td class="rechts">
<?= $zeile['bank'] === null ? '–' : View::h(View::stueck($zeile['bank']['stueck'])) ?>
</td>
<td class="rechts">
<?= $zeile['eigen'] === null ? '–' : View::h(View::stueck($zeile['eigen']['stueck'])) ?>
</td>
<td class="rechts <?= abs((float)$zeile['abweichung']) < 0.000001 ? '' : 'negativ' ?>">
<?= abs((float)$zeile['abweichung']) < 0.000001
? '✓' : View::h(View::stueck($zeile['abweichung'])) ?>
</td>
<td class="rechts">
<?= $zeile['bank'] === null || $zeile['bank']['kurs'] === null
? '–' : View::h(number_format((float)$zeile['bank']['kurs'], 2, ',', '.')) ?>
</td>
<td class="rechts">
<?= $zeile['bank'] === null || $zeile['bank']['wert'] === null
? '–' : View::betrag($zeile['bank']['wert']) ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endforeach; ?>
<?php include __DIR__ . '/../layout/fuss.php'; ?>

View file

@ -28,6 +28,7 @@ include __DIR__ . '/../layout/kopf.php';
<div class="tableiste">
<a href="/wertpapiere" class="tab">Bestände</a>
<a href="/wertpapiere/bank" class="tab">Bestand laut Bank</a>
<span class="tab aktiv">Depotbuchungen</span>
<a href="/wertpapiere/stammdaten" class="tab">Wertpapiere</a>
<a href="/wertpapiere/auswertung?art=veraeusserungen" class="tab">Veräußerungen</a>

View file

@ -23,6 +23,7 @@ include __DIR__ . '/../layout/kopf.php';
<div class="tableiste">
<a href="/wertpapiere" class="tab">Bestände</a>
<a href="/wertpapiere/bank" class="tab">Bestand laut Bank</a>
<a href="/wertpapiere/buchungen" class="tab">Depotbuchungen</a>
<a href="/wertpapiere/stammdaten" class="tab">Wertpapiere</a>
<a href="/wertpapiere/auswertung?art=veraeusserungen" class="tab">Veräußerungen</a>

View file

@ -18,6 +18,7 @@ include __DIR__ . '/../layout/kopf.php';
<div class="tableiste">
<span class="tab aktiv">Bestände</span>
<a href="/wertpapiere/bank" class="tab">Bestand laut Bank</a>
<a href="/wertpapiere/buchungen" class="tab">Depotbuchungen</a>
<a href="/wertpapiere/stammdaten" class="tab">Wertpapiere</a>
<a href="/wertpapiere/auswertung?art=veraeusserungen" class="tab">Veräußerungen</a>

View file

@ -56,6 +56,7 @@ include __DIR__ . '/../layout/kopf.php';
<div class="tableiste">
<a href="/wertpapiere" class="tab">Bestände</a>
<a href="/wertpapiere/bank" class="tab">Bestand laut Bank</a>
<a href="/wertpapiere/buchungen" class="tab">Depotbuchungen</a>
<a href="/wertpapiere/stammdaten" class="tab aktiv">Wertpapiere</a>
<a href="/wertpapiere/auswertung?art=veraeusserungen" class="tab">Veräußerungen</a>

View file

@ -16,6 +16,7 @@ include __DIR__ . '/../layout/kopf.php';
<div class="tableiste">
<a href="/wertpapiere" class="tab">Bestände</a>
<a href="/wertpapiere/bank" class="tab">Bestand laut Bank</a>
<a href="/wertpapiere/buchungen" class="tab">Depotbuchungen</a>
<span class="tab aktiv">Wertpapiere</span>
<a href="/wertpapiere/auswertung?art=veraeusserungen" class="tab">Veräußerungen</a>

View file

@ -27,6 +27,11 @@ if (!$fragment) {
<p class="platzhalter">Zu dieser Überweisung liegt kein Protokoll vor.</p>
<?php else: ?>
<?php $mitAuftrag = false; include __DIR__ . '/../banking/protokolleintraege.php'; ?>
<p>
<a href="/zahlungsverkehr/<?= (int)$auftrag['id'] ?>/nachweis" class="knopf">
Nachweis herunterladen (Text mit Prüfsummen)
</a>
</p>
<?php endif; ?>
<?php if (!$fragment): ?>

View file

@ -0,0 +1,150 @@
<?php
declare(strict_types=1);
use Fhp\Model\StatementOfHoldings\Holding;
use Finanzmanager\Models\Bankprotokoll;
use Finanzmanager\Models\Depotbuchung;
use Finanzmanager\Models\Konto;
use Finanzmanager\Models\Wertpapier;
use Finanzmanager\Models\Zahlungsauftrag;
use Finanzmanager\Services\Depotabgleich;
/**
* Depotbestand laut Bank (FinTS-Depotaufstellung) und Nachweis zur Überweisung.
*
* Geprüft wird, was nach dem Abruf in der Datenbank steht: der Bestand des
* letzten Abrufs, der Kurs bekannter Papiere, der Vergleich mit dem eigenen
* Bestand – und beim Nachweis die Prüfsumme des Protokolls.
*/
class DepotBankbestandTest extends TestCase
{
protected string $testName = 'Test 51: Depotbestand laut Bank, Nachweis';
private int $depot = 0;
public function run(): void
{
$this->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'
);
}
}