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:
commit
991911a19a
26 changed files with 1148 additions and 30 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
42
Changes.md
42
Changes.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
14
composer.lock
generated
|
|
@ -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": [],
|
||||
|
|
|
|||
31
migrations/021_depot_bankbestand.sql
Normal file
31
migrations/021_depot_bankbestand.sql
Normal 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;
|
||||
8
migrations/022_bankdepots.sql
Normal file
8
migrations/022_bankdepots.sql
Normal 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;
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
222
src/Services/Depotabgleich.php
Normal file
222
src/Services/Depotabgleich.php
Normal 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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; ?>
|
||||
|
|
|
|||
|
|
@ -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']) ?>]
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
138
src/Views/wertpapiere/bank.php
Normal file
138
src/Views/wertpapiere/bank.php
Normal 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'; ?>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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): ?>
|
||||
|
|
|
|||
229
tests/51_DepotBankbestandTest.php
Normal file
229
tests/51_DepotBankbestandTest.php
Normal 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'
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue