Fix n.n.: Depot-Zuordnung, Bestand einbuchen mit Stichtag

- Depot der Bank wird einem Depot im Finanzmanager zugeordnet (Migration 022)
- Bestand einbuchen: Ein-/Auslieferungen aus dem Bankbestand, Stichtag per Dialog
- Zuordnungsliste: Depots nur noch als Wertpapiergeschäft

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
fds2610-bot 2026-09-30 15:10:23 +02:00
parent b4a1b0acbc
commit 9a3472f4b7
13 changed files with 378 additions and 42 deletions

View file

@ -1,5 +1,18 @@
# Änderungen # Ä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) ## 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 - Das Depot (Kontoart 30, ohne IBAN) fehlte in der Kontenabfrage, weil `HKSPA` nur Konten mit
@ -17,6 +30,16 @@
Auftrag, allen Durchläufen im vollen Wortlaut und den Prüfsummen; ein nachträglich geändertes Auftrag, allen Durchläufen im vollen Wortlaut und den Prüfsummen; ein nachträglich geändertes
Protokoll fällt dort als ABWEICHUNG auf. 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. - 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. - Test 51.
## Buchungsmaske: Splitzeilen bleiben bei falscher Summe erhalten (29.09.2026) ## Buchungsmaske: Splitzeilen bleiben bei falscher Summe erhalten (29.09.2026)

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

@ -286,6 +286,7 @@ $router->post('/wertpapiere/stammdaten/{id}/kurs',
$router->post('/wertpapiere/kurse', fn() => (new WertpapiereController())->kurseAbrufen()); $router->post('/wertpapiere/kurse', fn() => (new WertpapiereController())->kurseAbrufen());
$router->get('/wertpapiere/bank', fn() => (new WertpapiereController())->bankbestand()); $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->get('/wertpapiere/import', fn() => (new WertpapiereController())->importFormular());
$router->post('/wertpapiere/import', fn() => (new WertpapiereController())->importAusfuehren()); $router->post('/wertpapiere/import', fn() => (new WertpapiereController())->importAusfuehren());

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 // Übermittlungsprotokoll im Zahlungsverkehr: Ein Klick auf den Status holt das
// Protokoll der Überweisung in einen modalen Dialog. Ohne <dialog> oder // Protokoll der Überweisung in einen modalen Dialog. Ohne <dialog> oder
// fetch bleibt der Status ein Link auf dieselbe Ansicht als eigene Seite. // fetch bleibt der Status ein Link auf dieselbe Ansicht als eigene Seite.

View file

@ -396,7 +396,7 @@ class BankingController extends BaseController
$this->render('banking/konten.php', [ $this->render('banking/konten.php', [
'titel' => 'Konten zuordnen – ' . $zugang['name'], 'titel' => 'Konten zuordnen – ' . $zugang['name'],
'zugang' => $zugang, 'zugang' => $zugang,
'konten' => Bankzugang::konten((int)$zugang['id']), 'konten' => Bankzugang::konten((int)$zugang['id'], true),
'eigene' => Konto::alle(), 'eigene' => Konto::alle(),
'einrichtung' => $this->query('einrichtung') === '1', 'einrichtung' => $this->query('einrichtung') === '1',
]); ]);
@ -1197,7 +1197,7 @@ class BankingController extends BaseController
} }
$bekannt = []; $bekannt = [];
foreach (Bankzugang::konten((int)$zugang['id']) as $eintrag) { foreach (Bankzugang::konten((int)$zugang['id'], true) as $eintrag) {
$bekannt[(int)$eintrag['id']] = $eintrag; $bekannt[(int)$eintrag['id']] = $eintrag;
} }
@ -1219,7 +1219,9 @@ class BankingController extends BaseController
$kontoId = $wahl === '' ? null : (int)$wahl; $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; return $neue;
@ -1247,7 +1249,7 @@ class BankingController extends BaseController
return Konto::anlegen([ return Konto::anlegen([
'name' => $name, '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']), 'bankname' => (string)($zugang['bankname'] !== '' ? $zugang['bankname'] : $zugang['name']),
'iban' => (string)$bankkonto['iban'], 'iban' => (string)$bankkonto['iban'],
'kontonummer' => (string)$bankkonto['kontonummer'], 'kontonummer' => (string)$bankkonto['kontonummer'],

View file

@ -267,6 +267,41 @@ class WertpapiereController extends BaseController
]); ]);
} }
/** Bucht die Unterschiede zwischen Bank- und eigenem Bestand ein. */
public function bankbestandEinbuchen(): void
{
$this->verlangeAnmeldung();
$depotId = (int)$this->post('konto_id');
if (Depot::depots(true) === [] || Konto::find($depotId) === null) {
View::redirect('/wertpapiere/bank', 'Dieses Depot gibt es nicht.', 'fehler');
return;
}
$stichtag = trim((string)$this->post('stichtag'));
if ($stichtag !== '') {
$datum = \DateTimeImmutable::createFromFormat('!Y-m-d', $stichtag);
if ($datum === false || $datum->format('Y-m-d') !== $stichtag) {
View::redirect('/wertpapiere/bank', 'Der Stichtag ist kein gültiges Datum.', 'fehler');
return;
}
}
try {
$e = Depotabgleich::einbuchen($depotId, $stichtag !== '' ? $stichtag : null);
} catch (\Throwable $ex) {
View::redirect('/wertpapiere/bank', 'Einbuchen fehlgeschlagen: ' . $ex->getMessage(), 'fehler');
return;
}
View::redirect('/wertpapiere/bank', sprintf(
'%d Einlieferung(en), %d Auslieferung(en) gebucht, %d Wertpapier(e) neu angelegt.',
$e['eingeliefert'],
$e['ausgeliefert'],
$e['angelegt']
));
}
// ── Bestandsimport aus einer CSV-Datei ─────────────────────────────────── // ── Bestandsimport aus einer CSV-Datei ───────────────────────────────────
public function importFormular(): void public function importFormular(): void

View file

@ -435,18 +435,72 @@ class Bankzugang
} }
/** @return list<array<string,mixed>> */ /** @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( return Database::fetchAll(
'SELECT bk.*, k.name AS kontoname 'SELECT bk.*, k.name AS kontoname
FROM bank_konten bk FROM bank_konten bk
LEFT JOIN konten k ON k.id = bk.konto_id LEFT JOIN konten k ON k.id = bk.konto_id
WHERE bk.zugang_id = ? WHERE bk.zugang_id = ?' . ($mitDepots ? '' : " AND bk.art = 'konto'") . '
ORDER BY bk.iban, bk.kontonummer', ORDER BY bk.art, bk.iban, bk.kontonummer',
[$zugangId] [$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. * Das eine Konto eines PayPal-Zugangs – angelegt, falls es noch keines gibt.
* *

View file

@ -6,17 +6,18 @@ namespace Finanzmanager\Services;
use Finanzmanager\Database; use Finanzmanager\Database;
use Finanzmanager\Mandant; use Finanzmanager\Mandant;
use Finanzmanager\Models\Depotbuchung;
use Finanzmanager\Models\Wertpapier; use Finanzmanager\Models\Wertpapier;
/** /**
* Der Depotbestand laut Bank (FinTS-Depotaufstellung) im Vergleich mit dem * Der Depotbestand laut Bank (FinTS-Depotaufstellung) im Vergleich mit dem
* eigenen Bestand. * eigenen Bestand.
* *
* Übernommen wird nur, was sicher ist: der Bestand landet in * Der Abruf übernimmt nur, was sicher ist: der Bestand landet in
* `depot_bankbestaende`, und die Bewertungskurse der Papiere, die es im * `depot_bankbestaende`, und die Bewertungskurse der Papiere, die es im
* Finanzmanager schon gibt, werden als Kurs des Abrufdatums eingetragen. Es * Finanzmanager schon gibt, werden als Kurs des Abrufdatums eingetragen. Ein
* entstehen weder Depotbuchungen noch neue Wertpapiere – ein Unterschied im * Unterschied im Bestand ist zunächst ein Hinweis; erst `einbuchen()` macht
* Bestand ist ein Hinweis, keine Buchung. * daraus Depotbuchungen (auf ausdrücklichen Knopfdruck).
*/ */
class Depotabgleich class Depotabgleich
{ {
@ -132,6 +133,75 @@ class Depotabgleich
return $zeilen; 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. */ /** Wann der Bankbestand des Depots zuletzt abgerufen wurde. */
public static function abgerufenAm(int $kontoId): ?string public static function abgerufenAm(int $kontoId): ?string
{ {

View file

@ -23,6 +23,7 @@ use Finanzmanager\Models\Bankprotokoll;
use Finanzmanager\Models\Bankumsatz; use Finanzmanager\Models\Bankumsatz;
use Finanzmanager\Models\Bankzugang; use Finanzmanager\Models\Bankzugang;
use Finanzmanager\Models\Einstellung; use Finanzmanager\Models\Einstellung;
use Finanzmanager\Models\Konto;
/** /**
* Der Umsatzabruf über FinTS. * Der Umsatzabruf über FinTS.
@ -528,9 +529,8 @@ class FinTsAbruf
$this->zustand['depot_nummer'] = $nummer; $this->zustand['depot_nummer'] = $nummer;
$depot = $depots[$nummer]; $depot = $depots[$nummer];
if (self::eigenesDepot((string)$depot['kontonummer']) === null) { if ($this->eigenesDepot($depot) === null) {
$this->notiere('Übersprungen: Depot ' . $depot['kontonummer'] $this->notiere('Übersprungen: Depot ' . $depot['kontonummer'] . ' ist keinem Depot zugeordnet.');
. ' – im Finanzmanager gibt es kein Depot mit dieser Kontonummer.');
continue; continue;
} }
@ -553,8 +553,8 @@ class FinTsAbruf
if ((int)$this->zustand['depot_geholt'] === 0) { if ((int)$this->zustand['depot_geholt'] === 0) {
return $this->beenden( return $this->beenden(
'fehler', 'fehler',
'Kein Depot der Bank passt zu einem Depot im Finanzmanager. Das Konto der Kontoart ' 'Das Depot ' . implode(' / ', array_column($depots, 'kontonummer'))
. 'Depot braucht die Kontonummer ' . implode(' / ', array_column($depots, 'kontonummer')) . '.' . ' ist noch keinem Depot im Finanzmanager zugeordnet. Bitte unter Online-Banking → Konten zuordnen zuordnen und dann erneut abrufen.'
); );
} }
@ -611,27 +611,24 @@ class FinTsAbruf
return $depots; return $depots;
} }
/** Das Depot im Finanzmanager, dessen Kontonummer zu der der Bank passt. */ /**
private static function eigenesDepot(string $kontonummer): ?array * Das Depot im Finanzmanager, dem das Depot der Bank zugeordnet ist.
*
* @param array<string,string> $depot
* @return array<string,mixed>|null
*/
private function eigenesDepot(array $depot): ?array
{ {
$gesucht = ltrim(trim($kontonummer), '0'); $bankkonto = Bankzugang::findeOderLegeDepotAn((int)$this->zugang['id'], $depot['kontonummer'], $depot['name']);
if ($gesucht === '') { $kontoId = (int)($bankkonto['konto_id'] ?? 0);
return null;
}
foreach (Depot::depots() as $depot) { return $kontoId > 0 ? Konto::find($kontoId) : null;
if (ltrim(trim((string)$depot['kontonummer']), '0') === $gesucht) {
return $depot;
}
}
return null;
} }
/** @param array<string,string> $depot */ /** @param array<string,string> $depot */
private function schreibeDepot(array $depot, GetDepotAufstellung $aktion): void private function schreibeDepot(array $depot, GetDepotAufstellung $aktion): void
{ {
$konto = self::eigenesDepot((string)$depot['kontonummer']); $konto = $this->eigenesDepot($depot);
if ($konto === null) { if ($konto === null) {
return; return;
} }
@ -825,6 +822,26 @@ class FinTsAbruf
} }
$this->notiere(count($konten) . ' Konto/Konten bei der Bank gefunden.'); $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 */ /** @param array<string,mixed> $bankkonto */

View file

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

View file

@ -23,8 +23,9 @@ use Finanzmanager\View;
* diesmal als Wertpapiergeschäft. Ein Kauf ist am Ende zwar eine Umbuchung ins * diesmal als Wertpapiergeschäft. Ein Kauf ist am Ende zwar eine Umbuchung ins
* Depot, aber erst, wenn Stückzahl, Kurs und Nebenkosten feststehen – die * Depot, aber erst, wenn Stückzahl, Kurs und Nebenkosten feststehen – die
* Auswahl führt deshalb in die Wertpapiermaske, statt die Zuordnung sofort zu * Auswahl führt deshalb in die Wertpapiermaske, statt die Zuordnung sofort zu
* speichern. Die schlichte Umbuchung bleibt daneben stehen: Auf ein Depot lässt * speichern. Wo diese Gruppe steht, fehlen die Depots in der Gruppe „Umbuchung
* sich auch Geld überweisen, ohne dass etwas gekauft wird. * 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 * 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. * 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"> <optgroup label="Umbuchung auf ein Konto">
<?php foreach ($konten as $eintrag): ?> <?php foreach ($konten as $eintrag): ?>
<?php if ((int)$eintrag['id'] === (int)$eigenesKonto) { continue; } ?> <?php if ((int)$eintrag['id'] === (int)$eigenesKonto) { continue; } ?>
<?php if ($depots !== [] && $istDepot($eintrag)) { continue; } ?>
<?php $wert = 'konto:' . (int)$eintrag['id']; ?> <?php $wert = 'konto:' . (int)$eintrag['id']; ?>
<option value="<?= View::h($wert) ?>"<?= ($gewaehlt ?? '') === $wert ? ' selected' : '' ?>> <option value="<?= View::h($wert) ?>"<?= ($gewaehlt ?? '') === $wert ? ' selected' : '' ?>>
[<?= View::h($eintrag['name']) ?>] [<?= View::h($eintrag['name']) ?>]

View file

@ -40,9 +40,9 @@ include __DIR__ . '/../layout/kopf.php';
</div> </div>
<p class="nebentext"> <p class="nebentext">
Der Abruf fragt die Bank nach der Depotaufstellung (FinTS HKWPD) und verlangt in der Regel 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, eine TAN. Der Abruf ändert keine Buchungen: Die Kurse bekannter Wertpapiere werden übernommen,
Unterschiede im Bestand zeigt die Tabelle nur an. Das Depot im Finanzmanager braucht dafür Unterschiede im Bestand zeigt die Tabelle an. „Bestand einbuchen“ macht daraus Einlieferungen (Mehrbestand) und Auslieferungen (Minderbestand). Das Depot der Bank muss dafür einem
die Kontonummer der Bank (Kontenverwaltung). Depot im Finanzmanager zugeordnet sein (Online-Banking → Konten zuordnen).
</p> </p>
<?php endif; ?> <?php endif; ?>
@ -64,6 +64,28 @@ include __DIR__ . '/../layout/kopf.php';
<p class="nebentext">Abgerufen am <?= View::h(View::datum(substr((string)$depot['abgerufen_am'], 0, 10))) ?> <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> 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"> <table class="liste zahlen">
<thead> <thead>
<tr> <tr>
@ -71,6 +93,7 @@ include __DIR__ . '/../layout/kopf.php';
<th class="rechts">Stück laut Bank</th> <th class="rechts">Stück laut Bank</th>
<th class="rechts">Stück im Finanzmanager</th> <th class="rechts">Stück im Finanzmanager</th>
<th class="rechts">Abweichung</th> <th class="rechts">Abweichung</th>
<th class="rechts">Einstandskurs</th>
<th class="rechts">Kurs</th> <th class="rechts">Kurs</th>
<th class="rechts">Wert laut Bank</th> <th class="rechts">Wert laut Bank</th>
</tr> </tr>
@ -94,6 +117,10 @@ include __DIR__ . '/../layout/kopf.php';
<?= abs((float)$zeile['abweichung']) < 0.000001 <?= abs((float)$zeile['abweichung']) < 0.000001
? '✓' : View::h(View::stueck($zeile['abweichung'])) ?> ? '✓' : View::h(View::stueck($zeile['abweichung'])) ?>
</td> </td>
<td class="rechts">
<?= $zeile['bank'] === null || $zeile['bank']['einstiegskurs'] === null
? '–' : View::h(number_format((float)$zeile['bank']['einstiegskurs'], 2, ',', '.')) ?>
</td>
<td class="rechts"> <td class="rechts">
<?= $zeile['bank'] === null || $zeile['bank']['kurs'] === null <?= $zeile['bank'] === null || $zeile['bank']['kurs'] === null
? '–' : View::h(number_format((float)$zeile['bank']['kurs'], 2, ',', '.')) ?> ? '–' : View::h(number_format((float)$zeile['bank']['kurs'], 2, ',', '.')) ?>

View file

@ -4,6 +4,7 @@ declare(strict_types=1);
use Fhp\Model\StatementOfHoldings\Holding; use Fhp\Model\StatementOfHoldings\Holding;
use Finanzmanager\Models\Bankprotokoll; use Finanzmanager\Models\Bankprotokoll;
use Finanzmanager\Models\Bankzugang;
use Finanzmanager\Models\Depotbuchung; use Finanzmanager\Models\Depotbuchung;
use Finanzmanager\Models\Konto; use Finanzmanager\Models\Konto;
use Finanzmanager\Models\Wertpapier; use Finanzmanager\Models\Wertpapier;
@ -34,7 +35,10 @@ class DepotBankbestandTest extends TestCase
$this->bestandUndKurs(); $this->bestandUndKurs();
$this->vergleichZeigtAbweichungen(); $this->vergleichZeigtAbweichungen();
$this->einbuchen();
$this->einbuchenMitStichtag();
$this->zweiterAbrufErsetztDenErsten(); $this->zweiterAbrufErsetztDenErsten();
$this->depotZuordnung();
$this->nachweisMitPruefsumme(); $this->nachweisMitPruefsumme();
} }
@ -75,6 +79,42 @@ class DepotBankbestandTest extends TestCase
$this->assertNotEmpty(Depotabgleich::abgerufenAm($this->depot), 'Der Abrufzeitpunkt ist vermerkt'); $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 private function vergleichZeigtAbweichungen(): void
{ {
$bekannt = Wertpapier::finde('DE0001111111'); $bekannt = Wertpapier::finde('DE0001111111');
@ -115,6 +155,45 @@ class DepotBankbestandTest extends TestCase
$this->assertEquals(1, count($bank), 'Der neue Abruf ersetzt den alten Bankbestand'); $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 private function nachweisMitPruefsumme(): void
{ {
$verrechnung = Konto::anlegen([ $verrechnung = Konto::anlegen([