Merge pull request 'Fix n.n.: Depotabruf per FinTS, Depot-Zuordnung, Bestand einbuchen' (#100) from fds2610-bot/Finanzmanager:main into main

Reviewed-on: https://codeberg.org/fds2610/Finanzmanager/pulls/100
This commit is contained in:
fds2610 2026-09-30 16:45:19 +02:00
commit 991911a19a
26 changed files with 1148 additions and 30 deletions

3
.gitignore vendored
View file

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

View file

@ -1,5 +1,47 @@
# Ä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
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.
- 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)
- 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

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

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,8 @@ $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->post('/wertpapiere/bank/einbuchen', fn() => (new WertpapiereController())->bankbestandEinbuchen());
$router->get('/wertpapiere/import', fn() => (new WertpapiereController())->importFormular());
$router->post('/wertpapiere/import', fn() => (new WertpapiereController())->importAusfuehren());
@ -329,6 +332,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

@ -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 <dialog> oder
// fetch bleibt der Status ein Link auf dieselbe Ansicht als eigene Seite.

View file

@ -396,12 +396,26 @@ 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',
]);
}
/**
* 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'
@ -1174,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;
}
@ -1196,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;
@ -1224,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'],

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,69 @@ 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'
)),
]);
}
/** 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

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

@ -435,18 +435,72 @@ class Bankzugang
}
/** @return list<array<string,mixed>> */
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<array<string,mixed>>
*/
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<string,mixed>
*/
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.
*

View file

@ -0,0 +1,222 @@
<?php
declare(strict_types=1);
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.
*
* 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. Ein
* Unterschied im Bestand ist zunächst ein Hinweis; erst `einbuchen()` macht
* daraus Depotbuchungen (auf ausdrücklichen Knopfdruck).
*/
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;
}
/**
* 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
{
$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;
@ -22,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.
@ -52,6 +54,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 +94,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 +132,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 +271,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 +498,156 @@ 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 ($this->eigenesDepot($depot) === null) {
$this->notiere('Übersprungen: Depot ' . $depot['kontonummer'] . ' ist keinem Depot zugeordnet.');
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',
'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.'
);
}
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, dem das Depot der Bank zugeordnet ist.
*
* @param array<string,string> $depot
* @return array<string,mixed>|null
*/
private function eigenesDepot(array $depot): ?array
{
$bankkonto = Bankzugang::findeOderLegeDepotAn((int)$this->zugang['id'], $depot['kontonummer'], $depot['name']);
$kontoId = (int)($bankkonto['konto_id'] ?? 0);
return $kontoId > 0 ? Konto::find($kontoId) : null;
}
/** @param array<string,string> $depot */
private function schreibeDepot(array $depot, GetDepotAufstellung $aktion): void
{
$konto = $this->eigenesDepot($depot);
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 +743,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;
@ -655,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<string,mixed> $bankkonto */

View file

@ -73,6 +73,7 @@ include __DIR__ . '/../layout/kopf.php';
<?php foreach ($konten as $bankkonto): ?>
<?php
$id = (int)$bankkonto['id'];
$istDepot = (string)$bankkonto['art'] === 'depot';
$kennung = (string)($bankkonto['iban'] ?: $bankkonto['kontonummer']);
// Namensvorschlag für ein neues Konto: Bank und die letzten vier
// Stellen – „Sparkasse 2051“ ist in der Kontenliste eindeutiger
@ -83,6 +84,7 @@ include __DIR__ . '/../layout/kopf.php';
<tr>
<td>
<?= View::h($kennung) ?>
<?php if ($istDepot): ?><span class="nebentext">Depot</span><?php endif; ?>
<?php if ($bankkonto['bezeichnung'] !== '' && $bankkonto['bezeichnung'] !== $kennung): ?>
<span class="nebentext"><?= View::h($bankkonto['bezeichnung']) ?></span>
<?php endif; ?>
@ -92,6 +94,7 @@ include __DIR__ . '/../layout/kopf.php';
data-neues-konto="neues-konto-<?= $id ?>">
<option value="">– nicht zugeordnet –</option>
<?php foreach ($eigene as $konto): ?>
<?php if ($istDepot !== ((string)$konto['kontoart'] === 'depot')) { continue; } ?>
<option value="<?= (int)$konto['id'] ?>"
<?= (int)$bankkonto['konto_id'] === (int)$konto['id'] ? 'selected' : '' ?>>
<?= View::h($konto['name']) ?>
@ -108,11 +111,15 @@ include __DIR__ . '/../layout/kopf.php';
: View::betrag($bankkonto['saldo']) ?>
</td>
<td>
<?php if ($istDepot): ?>
<span class="nebentext">unter Wertpapiere</span>
<?php else: ?>
<label class="ankreuzfeld">
<input type="checkbox" name="bankkonto[<?= $id ?>][abrufen]" value="1"
<?= (int)$bankkonto['abrufen'] === 1 ? 'checked' : '' ?>>
ja
</label>
<?php endif; ?>
</td>
</tr>
<?php /* Die Felder des neuen Kontos stehen in einer eigenen Zeile:
@ -128,10 +135,11 @@ include __DIR__ . '/../layout/kopf.php';
value="<?= View::h($vorschlag) ?>">
<label for="neue-kontoart-<?= $id ?>">Kontoart</label>
<select id="neue-kontoart-<?= $id ?>" name="bankkonto[<?= $id ?>][neue_kontoart]">
<select id="neue-kontoart-<?= $id ?>" name="bankkonto[<?= $id ?>][neue_kontoart]"
<?= $istDepot ? 'disabled' : '' ?>>
<?php foreach (Konto::ARTEN as $schluessel => $beschriftung): ?>
<option value="<?= View::h($schluessel) ?>"
<?= $schluessel === 'bank' ? 'selected' : '' ?>>
<?= $schluessel === ($istDepot ? 'depot' : 'bank') ? 'selected' : '' ?>>
<?= View::h($beschriftung) ?>
</option>
<?php endforeach; ?>

View file

@ -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) : [];
<optgroup label="Umbuchung auf ein Konto">
<?php foreach ($konten as $eintrag): ?>
<?php if ((int)$eintrag['id'] === (int)$eigenesKonto) { continue; } ?>
<?php if ($depots !== [] && $istDepot($eintrag)) { continue; } ?>
<?php $wert = 'konto:' . (int)$eintrag['id']; ?>
<option value="<?= View::h($wert) ?>"<?= ($gewaehlt ?? '') === $wert ? ' selected' : '' ?>>
[<?= View::h($eintrag['name']) ?>]

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,138 @@
<?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. 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).
</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>
<?php $dialogId = 'einbuchdialog-' . (int)$depot['id']; ?>
<button type="button" class="knopf" data-einbuchdialog="<?= View::h($dialogId) ?>">Bestand einbuchen</button>
<dialog class="formatdialog" id="<?= View::h($dialogId) ?>">
<h2>Bestand einbuchen – <?= View::h($depot['name']) ?></h2>
<form method="post" action="/wertpapiere/bank/einbuchen">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<input type="hidden" name="konto_id" value="<?= (int)$depot['id'] ?>">
<label for="<?= View::h($dialogId) ?>-stichtag">Stichtag</label>
<input type="date" id="<?= View::h($dialogId) ?>-stichtag" name="stichtag" required
value="<?= View::h(substr((string)$depot['abgerufen_am'], 0, 10)) ?>">
<p class="nebentext">
Die Unterschiede werden als Einlieferungen (Mehrbestand) und Auslieferungen
(Minderbestand) zu diesem Datum gebucht.
</p>
<div class="knopfleiste">
<button type="submit" class="knopf primaer">Einbuchen</button>
<button type="submit" class="knopf" formmethod="dialog" formnovalidate>Abbrechen</button>
</div>
</form>
</dialog>
<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">Einstandskurs</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']['einstiegskurs'] === null
? '–' : View::h(number_format((float)$zeile['bank']['einstiegskurs'], 2, ',', '.')) ?>
</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,229 @@
<?php
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;
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->einbuchen();
$this->einbuchenMitStichtag();
$this->zweiterAbrufErsetztDenErsten();
$this->depotZuordnung();
$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 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');
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 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([
'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'
);
}
}