Fix n.n.: Depotabruf per FinTS und Nachweis zur Überweisung
Depotaufstellung (HKWPD, Version 6) unter Wertpapiere mit Vergleich zum eigenen Bestand; Prüfsumme und Textnachweis für das Übermittlungsprotokoll einer Überweisung; phpFinTS v4.1.5. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
d2635183ec
commit
b4a1b0acbc
20 changed files with 793 additions and 14 deletions
19
Changes.md
19
Changes.md
|
|
@ -1,5 +1,24 @@
|
||||||
# Änderungen
|
# Änderungen
|
||||||
|
|
||||||
|
## Depotabruf per FinTS und Nachweis zur Überweisung (30.09.2026)
|
||||||
|
|
||||||
|
- Das Depot (Kontoart 30, ohne IBAN) fehlte in der Kontenabfrage, weil `HKSPA` nur Konten mit
|
||||||
|
IBAN liefert. Es steht in den Kontostammdaten der Bank (`HIUPD`) mit erlaubtem `HKWPD`.
|
||||||
|
- Neuer Modus `depot` in `FinTsAbruf`: Anmeldung, Depots aus der UPD, je Depot
|
||||||
|
`GetDepotAufstellung` (gleiche TAN-Behandlung und Protokollierung wie der Umsatzabruf). Der
|
||||||
|
Knopf sitzt unter Wertpapiere → *Bestand laut Bank*, nicht im Umsatzabruf: andere Daten,
|
||||||
|
eigene TAN.
|
||||||
|
- Das Depot im Finanzmanager wird über die Kontonummer gefunden (Kontenverwaltung).
|
||||||
|
`Depotabgleich::uebernehmen()` speichert den Stand des letzten Abrufs
|
||||||
|
(`depot_bankbestaende`, Migration 021) und trägt die Bewertungskurse bekannter Wertpapiere
|
||||||
|
ein. Gebucht wird nichts; `Depotabgleich::vergleich()` zeigt Abweichungen im Bestand.
|
||||||
|
- Nachweis zur Überweisung: `bank_protokolle.pruefsumme` (SHA-256 des Protokolls, beim Speichern
|
||||||
|
gebildet). Im Protokoll einer Überweisung lädt *Nachweis herunterladen* eine Textdatei mit
|
||||||
|
Auftrag, allen Durchläufen im vollen Wortlaut und den Prüfsummen; ein nachträglich geändertes
|
||||||
|
Protokoll fällt dort als ABWEICHUNG auf.
|
||||||
|
- phpFinTS auf v4.1.5: Die DKB meldet die Depotaufstellung nur in Version 6 (`HKWPD`/`HIWPD`/`HIWPDS`); Upstream-PR nemiah/phpFinTS#598.
|
||||||
|
- Test 51.
|
||||||
|
|
||||||
## Buchungsmaske: Splitzeilen bleiben bei falscher Summe erhalten (29.09.2026)
|
## Buchungsmaske: Splitzeilen bleiben bei falscher Summe erhalten (29.09.2026)
|
||||||
|
|
||||||
- Scheiterte das Speichern einer Buchung an der Summenprüfung der Splitzeilen (Fehlermeldung
|
- Scheiterte das Speichern einer Buchung an der Summenprüfung der Splitzeilen (Fehlermeldung
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
],
|
],
|
||||||
"require": {
|
"require": {
|
||||||
"php": ">=8.3",
|
"php": ">=8.3",
|
||||||
"nemiah/php-fints": "4.1.4"
|
"nemiah/php-fints": "4.1.5"
|
||||||
},
|
},
|
||||||
"autoload": {
|
"autoload": {
|
||||||
"psr-4": {
|
"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",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "45b0a342d8df9f66cbfa77a47c308c8c",
|
"content-hash": "945e20000b1dbc31d67c0f961078a4bf",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "nemiah/php-fints",
|
"name": "nemiah/php-fints",
|
||||||
"version": "v4.1.4",
|
"version": "v4.1.5",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://codeberg.org/fds2610-bot/phpFinTS.git",
|
"url": "https://codeberg.org/fds2610-bot/phpFinTS.git",
|
||||||
"reference": "ded991612d10d4dcfe83824aa45d18203337a7a9"
|
"reference": "e083524a72856f47dd6ca01f94bbb6a74f9f7b16"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://codeberg.org/api/v1/repos/fds2610-bot/phpFinTS/archive/ded991612d10d4dcfe83824aa45d18203337a7a9.zip",
|
"url": "https://codeberg.org/api/v1/repos/fds2610-bot/phpFinTS/archive/e083524a72856f47dd6ca01f94bbb6a74f9f7b16.zip",
|
||||||
"reference": "ded991612d10d4dcfe83824aa45d18203337a7a9",
|
"reference": "e083524a72856f47dd6ca01f94bbb6a74f9f7b16",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
|
|
@ -67,10 +67,10 @@
|
||||||
"description": "PHP Library for the protocols fints and hbci",
|
"description": "PHP Library for the protocols fints and hbci",
|
||||||
"homepage": "https://codeberg.org/fds2610-bot/phpFinTS",
|
"homepage": "https://codeberg.org/fds2610-bot/phpFinTS",
|
||||||
"support": {
|
"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"
|
"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": [],
|
"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;
|
||||||
|
|
@ -256,6 +256,7 @@ $router->post('/zahlungsverkehr/tan/abbrechen', fn() => (new ZahlungsverkehrCont
|
||||||
$router->get('/zahlungsverkehr/vop', fn() => (new ZahlungsverkehrController())->vopFormular());
|
$router->get('/zahlungsverkehr/vop', fn() => (new ZahlungsverkehrController())->vopFormular());
|
||||||
$router->post('/zahlungsverkehr/vop', fn() => (new ZahlungsverkehrController())->vopBestaetigen());
|
$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}/protokoll', fn(array $p) => (new ZahlungsverkehrController())->protokoll($p));
|
||||||
$router->get('/zahlungsverkehr/{id}', fn(array $p) => (new ZahlungsverkehrController())->bearbeiten($p));
|
$router->get('/zahlungsverkehr/{id}', fn(array $p) => (new ZahlungsverkehrController())->bearbeiten($p));
|
||||||
$router->post('/zahlungsverkehr/{id}', fn(array $p) => (new ZahlungsverkehrController())->speichern($p));
|
$router->post('/zahlungsverkehr/{id}', fn(array $p) => (new ZahlungsverkehrController())->speichern($p));
|
||||||
|
|
@ -284,6 +285,7 @@ $router->post('/wertpapiere/stammdaten/{id}/kurs',
|
||||||
fn(array $p) => (new WertpapiereController())->kursSetzen($p));
|
fn(array $p) => (new WertpapiereController())->kursSetzen($p));
|
||||||
$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/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());
|
||||||
|
|
||||||
|
|
@ -329,6 +331,7 @@ $router->get('/banking/{id}/verfahren', fn(array $p) => (new BankingController(
|
||||||
$router->post('/banking/{id}/verfahren', fn(array $p) => (new BankingController())->verfahrenWaehlen($p));
|
$router->post('/banking/{id}/verfahren', fn(array $p) => (new BankingController())->verfahrenWaehlen($p));
|
||||||
$router->post('/banking/{id}/verfahren/abfragen',
|
$router->post('/banking/{id}/verfahren/abfragen',
|
||||||
fn(array $p) => (new BankingController())->verfahrenAbfragen($p));
|
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->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', fn(array $p) => (new BankingController())->kontenSpeichern($p));
|
||||||
$router->post('/banking/{id}/konten/abfragen',
|
$router->post('/banking/{id}/konten/abfragen',
|
||||||
|
|
|
||||||
|
|
@ -402,6 +402,20 @@ class BankingController extends BaseController
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Den Depotbestand bei der Bank abrufen. Der Knopf sitzt unter Wertpapiere,
|
||||||
|
* nicht beim Umsatzabruf: andere Daten, anderer Rhythmus, eigene TAN.
|
||||||
|
*
|
||||||
|
* @param array<string,string> $params
|
||||||
|
*/
|
||||||
|
public function depotAbrufen(array $params): void
|
||||||
|
{
|
||||||
|
$this->verlangeAnmeldung();
|
||||||
|
|
||||||
|
$id = (int)$params['id'];
|
||||||
|
$this->zeigeAbruf(FinTsAbruf::starten($id, 'depot'), $id);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Die Konten (erneut) bei der Bank erfragen. Ein eigener Knopf, weil dafür
|
* 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
|
* 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.
|
// Aufrufer war dann `tanEinreichen()` ohne jeden Zusammenhang.
|
||||||
$zugangId ??= isset($ergebnis['zugang_id']) ? (int)$ergebnis['zugang_id'] : null;
|
$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';
|
$kontenlauf = ($ergebnis['modus'] ?? 'voll') === 'konten';
|
||||||
$kontenseite = $zugangId === null
|
$kontenseite = $zugangId === null
|
||||||
? '/banking'
|
? '/banking'
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,14 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace Finanzmanager\Controllers;
|
namespace Finanzmanager\Controllers;
|
||||||
|
|
||||||
|
use Finanzmanager\Models\Bankzugang;
|
||||||
use Finanzmanager\Models\Depotbuchung;
|
use Finanzmanager\Models\Depotbuchung;
|
||||||
use Finanzmanager\Models\Einstellung;
|
use Finanzmanager\Models\Einstellung;
|
||||||
use Finanzmanager\Models\Konto;
|
use Finanzmanager\Models\Konto;
|
||||||
use Finanzmanager\Models\Wertpapier;
|
use Finanzmanager\Models\Wertpapier;
|
||||||
use Finanzmanager\Services\Depot;
|
use Finanzmanager\Services\Depot;
|
||||||
use Finanzmanager\Services\DepotbestandImport;
|
use Finanzmanager\Services\DepotbestandImport;
|
||||||
|
use Finanzmanager\Services\Depotabgleich;
|
||||||
use Finanzmanager\Services\Kursabruf;
|
use Finanzmanager\Services\Kursabruf;
|
||||||
use Finanzmanager\Services\Zeitraum;
|
use Finanzmanager\Services\Zeitraum;
|
||||||
use Finanzmanager\View;
|
use Finanzmanager\View;
|
||||||
|
|
@ -237,6 +239,34 @@ class WertpapiereController extends BaseController
|
||||||
return '/wertpapiere/buchungen' . ($query === [] ? '' : '?' . http_build_query($query));
|
return '/wertpapiere/buchungen' . ($query === [] ? '' : '?' . http_build_query($query));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Bestand laut Bank ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Depotbestand, wie ihn die Bank per FinTS meldet, neben dem eigenen
|
||||||
|
* Bestand. Der Abruf selbst läuft über den Banking-Bereich (TAN-Maske).
|
||||||
|
*/
|
||||||
|
public function bankbestand(): void
|
||||||
|
{
|
||||||
|
$this->verlangeAnmeldung();
|
||||||
|
|
||||||
|
$depots = [];
|
||||||
|
foreach (Depot::depots(true) as $depot) {
|
||||||
|
$depots[] = $depot + [
|
||||||
|
'vergleich' => Depotabgleich::vergleich((int)$depot['id']),
|
||||||
|
'abgerufen_am' => Depotabgleich::abgerufenAm((int)$depot['id']),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->render('wertpapiere/bank.php', [
|
||||||
|
'titel' => 'Depotbestand laut Bank',
|
||||||
|
'depots' => $depots,
|
||||||
|
'zugaenge' => array_values(array_filter(
|
||||||
|
Bankzugang::alle(true),
|
||||||
|
static fn(array $zugang): bool => (string)$zugang['typ'] === 'fints'
|
||||||
|
)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Bestandsimport aus einer CSV-Datei ───────────────────────────────────
|
// ── Bestandsimport aus einer CSV-Datei ───────────────────────────────────
|
||||||
|
|
||||||
public function importFormular(): void
|
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
|
public function neu(): void
|
||||||
{
|
{
|
||||||
$this->verlangeAnmeldung();
|
$this->verlangeAnmeldung();
|
||||||
|
|
|
||||||
|
|
@ -39,8 +39,8 @@ class Bankprotokoll
|
||||||
): int {
|
): int {
|
||||||
Database::execute(
|
Database::execute(
|
||||||
'INSERT INTO bank_protokolle
|
'INSERT INTO bank_protokolle
|
||||||
(benutzer_id, zugang_id, auftrag_id, art, status, meldung, protokoll)
|
(benutzer_id, zugang_id, auftrag_id, art, status, meldung, protokoll, pruefsumme)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)',
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||||
[
|
[
|
||||||
Mandant::id(),
|
Mandant::id(),
|
||||||
$zugangId === 0 ? null : $zugangId,
|
$zugangId === 0 ? null : $zugangId,
|
||||||
|
|
@ -49,12 +49,70 @@ class Bankprotokoll
|
||||||
array_key_exists($status, self::STATUS) ? $status : 'fehler',
|
array_key_exists($status, self::STATUS) ? $status : 'fehler',
|
||||||
mb_substr($meldung, 0, 2000),
|
mb_substr($meldung, 0, 2000),
|
||||||
$protokoll,
|
$protokoll,
|
||||||
|
hash('sha256', $protokoll),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
return Database::lastInsertId();
|
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.
|
* Alle Durchläufe zu einer Überweisung, der älteste zuerst.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
152
src/Services/Depotabgleich.php
Normal file
152
src/Services/Depotabgleich.php
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Finanzmanager\Services;
|
||||||
|
|
||||||
|
use Finanzmanager\Database;
|
||||||
|
use Finanzmanager\Mandant;
|
||||||
|
use Finanzmanager\Models\Wertpapier;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Depotbestand laut Bank (FinTS-Depotaufstellung) im Vergleich mit dem
|
||||||
|
* eigenen Bestand.
|
||||||
|
*
|
||||||
|
* Übernommen wird nur, was sicher ist: der Bestand landet in
|
||||||
|
* `depot_bankbestaende`, und die Bewertungskurse der Papiere, die es im
|
||||||
|
* Finanzmanager schon gibt, werden als Kurs des Abrufdatums eingetragen. Es
|
||||||
|
* entstehen weder Depotbuchungen noch neue Wertpapiere – ein Unterschied im
|
||||||
|
* Bestand ist ein Hinweis, keine Buchung.
|
||||||
|
*/
|
||||||
|
class Depotabgleich
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Ersetzt den Bankbestand eines Depots durch den frischen Abruf.
|
||||||
|
*
|
||||||
|
* @param list<\Fhp\Model\StatementOfHoldings\Holding> $positionen
|
||||||
|
* @return array{positionen: int, kurse: int}
|
||||||
|
*/
|
||||||
|
public static function uebernehmen(int $kontoId, array $positionen): array
|
||||||
|
{
|
||||||
|
return Database::transaktion(static function () use ($kontoId, $positionen): array {
|
||||||
|
Database::execute(
|
||||||
|
'DELETE FROM depot_bankbestaende WHERE konto_id = ? AND benutzer_id = ?',
|
||||||
|
[$kontoId, Mandant::id()]
|
||||||
|
);
|
||||||
|
|
||||||
|
$kurse = 0;
|
||||||
|
foreach ($positionen as $position) {
|
||||||
|
$isin = strtoupper(trim((string)$position->getISIN()));
|
||||||
|
$wkn = trim((string)$position->getWKN());
|
||||||
|
$name = trim((string)$position->getName());
|
||||||
|
$stand = $position->getDate()?->format('Y-m-d');
|
||||||
|
$kurs = $position->getPrice();
|
||||||
|
|
||||||
|
Database::execute(
|
||||||
|
'INSERT INTO depot_bankbestaende
|
||||||
|
(benutzer_id, konto_id, isin, wkn, name, stueck, einstiegskurs, kurs, wert, waehrung, stand)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||||
|
[
|
||||||
|
Mandant::id(),
|
||||||
|
$kontoId,
|
||||||
|
$isin,
|
||||||
|
$wkn,
|
||||||
|
mb_substr($name, 0, 255),
|
||||||
|
(float)$position->getAmount(),
|
||||||
|
$position->getAcquisitionPrice(),
|
||||||
|
$kurs,
|
||||||
|
$position->getValue() === null ? null : round($position->getValue(), 2),
|
||||||
|
strtoupper((string)$position->getCurrency()) ?: 'EUR',
|
||||||
|
$stand,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($kurs === null || $kurs <= 0.0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$papier = Wertpapier::finde($isin, $wkn, $name);
|
||||||
|
if ($papier !== null) {
|
||||||
|
Wertpapier::kursSetzen(
|
||||||
|
(int)$papier['id'],
|
||||||
|
$stand ?? date('Y-m-d'),
|
||||||
|
$kurs,
|
||||||
|
'bank',
|
||||||
|
strtoupper((string)$position->getCurrency()) ?: 'EUR'
|
||||||
|
);
|
||||||
|
$kurse++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['positionen' => count($positionen), 'kurse' => $kurse];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bankbestand und eigener Bestand nebeneinander, je Papier eine Zeile.
|
||||||
|
* Verglichen wird über die ISIN, ohne ISIN über den Namen.
|
||||||
|
*
|
||||||
|
* @return list<array<string,mixed>> mit `bank`, `eigen` und `abweichung`
|
||||||
|
* (Stück laut Bank minus eigene Stück)
|
||||||
|
*/
|
||||||
|
public static function vergleich(int $kontoId): array
|
||||||
|
{
|
||||||
|
$bank = Database::fetchAll(
|
||||||
|
'SELECT * FROM depot_bankbestaende WHERE konto_id = ? AND benutzer_id = ? ORDER BY name',
|
||||||
|
[$kontoId, Mandant::id()]
|
||||||
|
);
|
||||||
|
|
||||||
|
$eigen = [];
|
||||||
|
foreach (Depot::bestand($kontoId)['positionen'] as $position) {
|
||||||
|
if (abs((float)$position['stueck']) < 0.000001) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$eigen[self::schluessel((string)$position['isin'], (string)$position['name'])] = $position;
|
||||||
|
}
|
||||||
|
|
||||||
|
$zeilen = [];
|
||||||
|
foreach ($bank as $eintrag) {
|
||||||
|
$schluessel = self::schluessel((string)$eintrag['isin'], (string)$eintrag['name']);
|
||||||
|
$mein = $eigen[$schluessel] ?? null;
|
||||||
|
unset($eigen[$schluessel]);
|
||||||
|
|
||||||
|
$zeilen[] = [
|
||||||
|
'name' => (string)$eintrag['name'],
|
||||||
|
'isin' => (string)$eintrag['isin'],
|
||||||
|
'bank' => $eintrag,
|
||||||
|
'eigen' => $mein,
|
||||||
|
'abweichung' => round((float)$eintrag['stueck'] - (float)($mein['stueck'] ?? 0.0), 6),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Was nur im Finanzmanager steht, hat die Bank nicht (mehr) im Depot.
|
||||||
|
foreach ($eigen as $mein) {
|
||||||
|
$zeilen[] = [
|
||||||
|
'name' => (string)$mein['name'],
|
||||||
|
'isin' => (string)$mein['isin'],
|
||||||
|
'bank' => null,
|
||||||
|
'eigen' => $mein,
|
||||||
|
'abweichung' => round(-(float)$mein['stueck'], 6),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $zeilen;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wann der Bankbestand des Depots zuletzt abgerufen wurde. */
|
||||||
|
public static function abgerufenAm(int $kontoId): ?string
|
||||||
|
{
|
||||||
|
$wert = Database::fetchValue(
|
||||||
|
'SELECT MAX(abgerufen_am) FROM depot_bankbestaende WHERE konto_id = ? AND benutzer_id = ?',
|
||||||
|
[$kontoId, Mandant::id()]
|
||||||
|
);
|
||||||
|
|
||||||
|
return $wert === false || $wert === null ? null : (string)$wert;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function schluessel(string $isin, string $name): string
|
||||||
|
{
|
||||||
|
$isin = strtoupper(trim($isin));
|
||||||
|
|
||||||
|
return $isin !== '' ? $isin : 'name:' . mb_strtolower(trim($name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||||
namespace Finanzmanager\Services;
|
namespace Finanzmanager\Services;
|
||||||
|
|
||||||
use Fhp\Action\GetBalance;
|
use Fhp\Action\GetBalance;
|
||||||
|
use Fhp\Action\GetDepotAufstellung;
|
||||||
use Fhp\Action\GetSEPAAccounts;
|
use Fhp\Action\GetSEPAAccounts;
|
||||||
use Fhp\Action\GetStatementOfAccount;
|
use Fhp\Action\GetStatementOfAccount;
|
||||||
use Fhp\BaseAction;
|
use Fhp\BaseAction;
|
||||||
|
|
@ -52,6 +53,11 @@ use Finanzmanager\Models\Einstellung;
|
||||||
* Einrichtungsschritt: erst zuordnen, dann Umsätze holen – dieselbe
|
* Einrichtungsschritt: erst zuordnen, dann Umsätze holen – dieselbe
|
||||||
* Zustandsmaschine, dieselbe TAN-Behandlung, nur kürzer.
|
* 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
|
* Ist `banking_automatisch_buchen` eingeschaltet, bucht `Bankabgleich` am Ende
|
||||||
* eines vollen Laufs, was ein Konto hat (siehe dort).
|
* eines vollen Laufs, was ein Konto hat (siehe dort).
|
||||||
*/
|
*/
|
||||||
|
|
@ -87,7 +93,8 @@ class FinTsAbruf
|
||||||
* `$modus` entscheidet, wie weit er läuft: `voll` ruft die Umsätze ab,
|
* `$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
|
* `konten` hält nach der Kontenabfrage an. Der kurze Lauf ist der dritte
|
||||||
* Einrichtungsschritt – dort sollen erst die Konten zugeordnet werden, bevor
|
* 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
|
* `$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
|
* vor. Er liegt im Zustand, nicht in der Datenbank: Nach einer TAN-Runde
|
||||||
|
|
@ -124,7 +131,7 @@ class FinTsAbruf
|
||||||
|
|
||||||
$abruf = new self($zugang, Bankabruf::beginnen($zugangId), [
|
$abruf = new self($zugang, Bankabruf::beginnen($zugangId), [
|
||||||
'schritt' => 'anmelden',
|
'schritt' => 'anmelden',
|
||||||
'modus' => $modus === 'konten' ? 'konten' : 'voll',
|
'modus' => in_array($modus, ['konten', 'depot'], true) ? $modus : 'voll',
|
||||||
'einrichtung' => $einrichtung,
|
'einrichtung' => $einrichtung,
|
||||||
'stichtag' => $stichtag,
|
'stichtag' => $stichtag,
|
||||||
'konto_nummer' => 0,
|
'konto_nummer' => 0,
|
||||||
|
|
@ -263,7 +270,11 @@ class FinTsAbruf
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->notiere('Angemeldet bei ' . ($this->zugang['bankname'] ?: $this->zugang['name']) . '.');
|
$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') {
|
if ($this->zustand['schritt'] === 'konten') {
|
||||||
|
|
@ -486,6 +497,160 @@ class FinTsAbruf
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Depot ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private function naechsterSchrittNachAnmeldung(): string
|
||||||
|
{
|
||||||
|
return ($this->zustand['modus'] ?? 'voll') === 'depot' ? 'depot' : 'konten';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holt die Depotaufstellung jedes Depots, das die Bank in den
|
||||||
|
* Kontostammdaten führt. Der Index steht im Zustand – verlangt ein Depot
|
||||||
|
* eine TAN, geht es danach genau dort weiter.
|
||||||
|
*
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
private function depotsAbrufen(): array
|
||||||
|
{
|
||||||
|
if (!isset($this->zustand['depots'])) {
|
||||||
|
$this->zustand['depots'] = $this->depotkonten();
|
||||||
|
$this->zustand['depot_nummer'] = 0;
|
||||||
|
$this->zustand['depot_geholt'] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$depots = $this->zustand['depots'];
|
||||||
|
if ($depots === []) {
|
||||||
|
return $this->beenden('fehler', 'Die Bank meldet für diesen Zugang kein Depot mit Abrufmöglichkeit.');
|
||||||
|
}
|
||||||
|
|
||||||
|
for ($nummer = (int)$this->zustand['depot_nummer']; $nummer < count($depots); $nummer++) {
|
||||||
|
$this->zustand['depot_nummer'] = $nummer;
|
||||||
|
$depot = $depots[$nummer];
|
||||||
|
|
||||||
|
if (self::eigenesDepot((string)$depot['kontonummer']) === null) {
|
||||||
|
$this->notiere('Übersprungen: Depot ' . $depot['kontonummer']
|
||||||
|
. ' – im Finanzmanager gibt es kein Depot mit dieser Kontonummer.');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sepa = new SEPAAccount();
|
||||||
|
$sepa->setAccountNumber((string)$depot['kontonummer']);
|
||||||
|
$sepa->setSubAccount($depot['unterkonto'] !== '' ? (string)$depot['unterkonto'] : null);
|
||||||
|
$sepa->setBlz((string)$this->zugang['blz']);
|
||||||
|
|
||||||
|
$aktion = GetDepotAufstellung::create($sepa);
|
||||||
|
$this->fints->execute($aktion);
|
||||||
|
|
||||||
|
$wartet = $this->pruefeTan($aktion, 'Depot ' . $depot['kontonummer'], 'depot');
|
||||||
|
if ($wartet !== null) {
|
||||||
|
return $wartet;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->schreibeDepot($depot, $aktion);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int)$this->zustand['depot_geholt'] === 0) {
|
||||||
|
return $this->beenden(
|
||||||
|
'fehler',
|
||||||
|
'Kein Depot der Bank passt zu einem Depot im Finanzmanager. Das Konto der Kontoart '
|
||||||
|
. 'Depot braucht die Kontonummer ' . implode(' / ', array_column($depots, 'kontonummer')) . '.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->beenden('erfolg', sprintf(
|
||||||
|
'Depotbestand abgerufen: %d Positionen in %d Depot(s).',
|
||||||
|
$this->zustand['neu'],
|
||||||
|
$this->zustand['depot_geholt']
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Depots aus den Kontostammdaten der Bank (UPD, `HIUPD`): Kontoart
|
||||||
|
* 30–39 oder ein erlaubter Geschäftsvorfall `HKWPD`. Die Bibliothek reicht die
|
||||||
|
* UPD nicht nach außen; sie steht nach der Anmeldung im FinTs-Objekt.
|
||||||
|
*
|
||||||
|
* @return list<array{kontonummer: string, unterkonto: string, name: string}>
|
||||||
|
*/
|
||||||
|
private function depotkonten(): array
|
||||||
|
{
|
||||||
|
$upd = (new \ReflectionProperty(FinTs::class, 'upd'))->getValue($this->fints);
|
||||||
|
if ($upd === null) {
|
||||||
|
$this->notiere('Die Bank hat keine Kontostammdaten (UPD) geliefert.');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$depots = [];
|
||||||
|
foreach ($upd->hiupd as $hiupd) {
|
||||||
|
$verbindung = $hiupd->getKontoverbindung();
|
||||||
|
$art = $hiupd->getKontoart();
|
||||||
|
$abrufbar = false;
|
||||||
|
foreach ($hiupd->getErlaubteGeschaeftsvorfaelle() as $erlaubt) {
|
||||||
|
$abrufbar = $abrufbar || $erlaubt->getGeschaeftsvorfall() === 'HKWPD';
|
||||||
|
}
|
||||||
|
|
||||||
|
$istDepot = ($art !== null && $art >= 30 && $art <= 39) || $abrufbar;
|
||||||
|
if ($verbindung === null || !$istDepot) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$nummer = (string)$verbindung->getAccountNumber();
|
||||||
|
if (!$abrufbar) {
|
||||||
|
$this->notiere('Depot ' . $nummer . ' übersprungen – die Bank erlaubt dafür keine Depotaufstellung.');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$depots[] = [
|
||||||
|
'kontonummer' => $nummer,
|
||||||
|
'unterkonto' => (string)$verbindung->unterkontomerkmal,
|
||||||
|
'name' => trim((string)$hiupd->getKontoproduktbezeichnung()),
|
||||||
|
];
|
||||||
|
$this->notiere('Depot bei der Bank: ' . $nummer . ' (' . trim((string)$hiupd->getName1()) . ').');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $depots;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Das Depot im Finanzmanager, dessen Kontonummer zu der der Bank passt. */
|
||||||
|
private static function eigenesDepot(string $kontonummer): ?array
|
||||||
|
{
|
||||||
|
$gesucht = ltrim(trim($kontonummer), '0');
|
||||||
|
if ($gesucht === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (Depot::depots() as $depot) {
|
||||||
|
if (ltrim(trim((string)$depot['kontonummer']), '0') === $gesucht) {
|
||||||
|
return $depot;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string,string> $depot */
|
||||||
|
private function schreibeDepot(array $depot, GetDepotAufstellung $aktion): void
|
||||||
|
{
|
||||||
|
$konto = self::eigenesDepot((string)$depot['kontonummer']);
|
||||||
|
if ($konto === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ergebnis = Depotabgleich::uebernehmen((int)$konto['id'], $aktion->getStatement()->getHoldings());
|
||||||
|
|
||||||
|
$this->zustand['neu'] += $ergebnis['positionen'];
|
||||||
|
$this->zustand['depot_geholt'] = (int)($this->zustand['depot_geholt'] ?? 0) + 1;
|
||||||
|
$this->zustand['depot_nummer'] = (int)$this->zustand['depot_nummer'] + 1;
|
||||||
|
|
||||||
|
$this->notiere(sprintf(
|
||||||
|
'%s: %d Positionen, Depotwert laut Bank %s €, %d Kurse übernommen.',
|
||||||
|
$konto['name'],
|
||||||
|
$ergebnis['positionen'],
|
||||||
|
number_format($aktion->getDepotWert(), 2, ',', '.'),
|
||||||
|
$ergebnis['kurse']
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
// ── TAN ──────────────────────────────────────────────────────────────────
|
// ── TAN ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -581,7 +746,12 @@ class FinTsAbruf
|
||||||
$this->merkeKonten($aktion->getAccounts());
|
$this->merkeKonten($aktion->getAccounts());
|
||||||
$this->zustand['schritt'] = 'umsaetze';
|
$this->zustand['schritt'] = 'umsaetze';
|
||||||
} elseif ($this->zustand['schritt'] === 'anmelden') {
|
} 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) {
|
} elseif ($this->zustand['schritt'] === 'umsaetze' && $aktion instanceof GetStatementOfAccount) {
|
||||||
$konten = $this->abzurufendeKonten();
|
$konten = $this->abzurufendeKonten();
|
||||||
$konto = $konten[(int)$this->zustand['konto_nummer']] ?? null;
|
$konto = $konten[(int)$this->zustand['konto_nummer']] ?? null;
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ include __DIR__ . '/../layout/kopf.php';
|
||||||
|
|
||||||
<div class="tableiste">
|
<div class="tableiste">
|
||||||
<a href="/wertpapiere" class="tab">Bestände</a>
|
<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/buchungen" class="tab">Depotbuchungen</a>
|
||||||
<a href="/wertpapiere/stammdaten" class="tab">Wertpapiere</a>
|
<a href="/wertpapiere/stammdaten" class="tab">Wertpapiere</a>
|
||||||
<a href="/wertpapiere/auswertung?art=veraeusserungen"
|
<a href="/wertpapiere/auswertung?art=veraeusserungen"
|
||||||
|
|
|
||||||
111
src/Views/wertpapiere/bank.php
Normal file
111
src/Views/wertpapiere/bank.php
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Finanzmanager\View;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Depotbestand laut Bank (FinTS-Depotaufstellung) neben dem eigenen Bestand.
|
||||||
|
*
|
||||||
|
* @var list<array<string,mixed>> $depots Depots mit `vergleich` und `abgerufen_am`
|
||||||
|
* @var list<array<string,mixed>> $zugaenge aktive FinTS-Bankverbindungen
|
||||||
|
* @var string $csrfToken
|
||||||
|
*/
|
||||||
|
|
||||||
|
include __DIR__ . '/../layout/kopf.php';
|
||||||
|
?>
|
||||||
|
|
||||||
|
<div class="tableiste">
|
||||||
|
<a href="/wertpapiere" class="tab">Bestände</a>
|
||||||
|
<span class="tab aktiv">Bestand laut Bank</span>
|
||||||
|
<a href="/wertpapiere/buchungen" class="tab">Depotbuchungen</a>
|
||||||
|
<a href="/wertpapiere/stammdaten" class="tab">Wertpapiere</a>
|
||||||
|
<a href="/wertpapiere/auswertung?art=veraeusserungen" class="tab">Veräußerungen</a>
|
||||||
|
<a href="/wertpapiere/auswertung?art=ertraege" class="tab">Erträge</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if ($zugaenge === []): ?>
|
||||||
|
<p class="platzhalter">
|
||||||
|
Es gibt keine aktive FinTS-Bankverbindung. Der Bestand lässt sich nur über
|
||||||
|
<a href="/banking">Online-Banking</a> abrufen.
|
||||||
|
</p>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="seitenaktionen">
|
||||||
|
<?php foreach ($zugaenge as $zugang): ?>
|
||||||
|
<form method="post" action="/banking/<?= (int)$zugang['id'] ?>/depot" class="inline">
|
||||||
|
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||||
|
<button type="submit" class="knopf primaer">
|
||||||
|
Depot abrufen – <?= View::h($zugang['name']) ?>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<p class="nebentext">
|
||||||
|
Der Abruf fragt die Bank nach der Depotaufstellung (FinTS HKWPD) und verlangt in der Regel
|
||||||
|
eine TAN. Er ändert keine Buchungen: Die Kurse bekannter Wertpapiere werden übernommen,
|
||||||
|
Unterschiede im Bestand zeigt die Tabelle nur an. Das Depot im Finanzmanager braucht dafür
|
||||||
|
die Kontonummer der Bank (Kontenverwaltung).
|
||||||
|
</p>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if ($depots === []): ?>
|
||||||
|
<p class="platzhalter">Noch kein Depot vorhanden.</p>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php foreach ($depots as $depot): ?>
|
||||||
|
<h2><?= View::h($depot['name']) ?></h2>
|
||||||
|
|
||||||
|
<?php if ($depot['abgerufen_am'] === null): ?>
|
||||||
|
<p class="platzhalter">
|
||||||
|
Noch nicht abgerufen<?= trim((string)$depot['kontonummer']) === ''
|
||||||
|
? ' – dem Depot fehlt die Kontonummer.' : '.' ?>
|
||||||
|
</p>
|
||||||
|
<?php continue; ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<p class="nebentext">Abgerufen am <?= View::h(View::datum(substr((string)$depot['abgerufen_am'], 0, 10))) ?>
|
||||||
|
um <?= View::h(substr((string)$depot['abgerufen_am'], 11, 5)) ?> Uhr.</p>
|
||||||
|
|
||||||
|
<table class="liste zahlen">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Wertpapier</th>
|
||||||
|
<th class="rechts">Stück laut Bank</th>
|
||||||
|
<th class="rechts">Stück im Finanzmanager</th>
|
||||||
|
<th class="rechts">Abweichung</th>
|
||||||
|
<th class="rechts">Kurs</th>
|
||||||
|
<th class="rechts">Wert laut Bank</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($depot['vergleich'] as $zeile): ?>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<?= View::h($zeile['name']) ?>
|
||||||
|
<?php if ($zeile['isin'] !== ''): ?>
|
||||||
|
<span class="nebentext"><?= View::h($zeile['isin']) ?></span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
<td class="rechts">
|
||||||
|
<?= $zeile['bank'] === null ? '–' : View::h(View::stueck($zeile['bank']['stueck'])) ?>
|
||||||
|
</td>
|
||||||
|
<td class="rechts">
|
||||||
|
<?= $zeile['eigen'] === null ? '–' : View::h(View::stueck($zeile['eigen']['stueck'])) ?>
|
||||||
|
</td>
|
||||||
|
<td class="rechts <?= abs((float)$zeile['abweichung']) < 0.000001 ? '' : 'negativ' ?>">
|
||||||
|
<?= abs((float)$zeile['abweichung']) < 0.000001
|
||||||
|
? '✓' : View::h(View::stueck($zeile['abweichung'])) ?>
|
||||||
|
</td>
|
||||||
|
<td class="rechts">
|
||||||
|
<?= $zeile['bank'] === null || $zeile['bank']['kurs'] === null
|
||||||
|
? '–' : View::h(number_format((float)$zeile['bank']['kurs'], 2, ',', '.')) ?>
|
||||||
|
</td>
|
||||||
|
<td class="rechts">
|
||||||
|
<?= $zeile['bank'] === null || $zeile['bank']['wert'] === null
|
||||||
|
? '–' : View::betrag($zeile['bank']['wert']) ?>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
|
||||||
|
<?php include __DIR__ . '/../layout/fuss.php'; ?>
|
||||||
|
|
@ -28,6 +28,7 @@ include __DIR__ . '/../layout/kopf.php';
|
||||||
|
|
||||||
<div class="tableiste">
|
<div class="tableiste">
|
||||||
<a href="/wertpapiere" class="tab">Bestände</a>
|
<a href="/wertpapiere" class="tab">Bestände</a>
|
||||||
|
<a href="/wertpapiere/bank" class="tab">Bestand laut Bank</a>
|
||||||
<span class="tab aktiv">Depotbuchungen</span>
|
<span class="tab aktiv">Depotbuchungen</span>
|
||||||
<a href="/wertpapiere/stammdaten" class="tab">Wertpapiere</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=veraeusserungen" class="tab">Veräußerungen</a>
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ include __DIR__ . '/../layout/kopf.php';
|
||||||
|
|
||||||
<div class="tableiste">
|
<div class="tableiste">
|
||||||
<a href="/wertpapiere" class="tab">Bestände</a>
|
<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/buchungen" class="tab">Depotbuchungen</a>
|
||||||
<a href="/wertpapiere/stammdaten" class="tab">Wertpapiere</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=veraeusserungen" class="tab">Veräußerungen</a>
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ include __DIR__ . '/../layout/kopf.php';
|
||||||
|
|
||||||
<div class="tableiste">
|
<div class="tableiste">
|
||||||
<span class="tab aktiv">Bestände</span>
|
<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/buchungen" class="tab">Depotbuchungen</a>
|
||||||
<a href="/wertpapiere/stammdaten" class="tab">Wertpapiere</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=veraeusserungen" class="tab">Veräußerungen</a>
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@ include __DIR__ . '/../layout/kopf.php';
|
||||||
|
|
||||||
<div class="tableiste">
|
<div class="tableiste">
|
||||||
<a href="/wertpapiere" class="tab">Bestände</a>
|
<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/buchungen" class="tab">Depotbuchungen</a>
|
||||||
<a href="/wertpapiere/stammdaten" class="tab aktiv">Wertpapiere</a>
|
<a href="/wertpapiere/stammdaten" class="tab aktiv">Wertpapiere</a>
|
||||||
<a href="/wertpapiere/auswertung?art=veraeusserungen" class="tab">Veräußerungen</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">
|
<div class="tableiste">
|
||||||
<a href="/wertpapiere" class="tab">Bestände</a>
|
<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/buchungen" class="tab">Depotbuchungen</a>
|
||||||
<span class="tab aktiv">Wertpapiere</span>
|
<span class="tab aktiv">Wertpapiere</span>
|
||||||
<a href="/wertpapiere/auswertung?art=veraeusserungen" class="tab">Veräußerungen</a>
|
<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>
|
<p class="platzhalter">Zu dieser Überweisung liegt kein Protokoll vor.</p>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<?php $mitAuftrag = false; include __DIR__ . '/../banking/protokolleintraege.php'; ?>
|
<?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 endif; ?>
|
||||||
|
|
||||||
<?php if (!$fragment): ?>
|
<?php if (!$fragment): ?>
|
||||||
|
|
|
||||||
150
tests/51_DepotBankbestandTest.php
Normal file
150
tests/51_DepotBankbestandTest.php
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Fhp\Model\StatementOfHoldings\Holding;
|
||||||
|
use Finanzmanager\Models\Bankprotokoll;
|
||||||
|
use Finanzmanager\Models\Depotbuchung;
|
||||||
|
use Finanzmanager\Models\Konto;
|
||||||
|
use Finanzmanager\Models\Wertpapier;
|
||||||
|
use Finanzmanager\Models\Zahlungsauftrag;
|
||||||
|
use Finanzmanager\Services\Depotabgleich;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Depotbestand laut Bank (FinTS-Depotaufstellung) und Nachweis zur Überweisung.
|
||||||
|
*
|
||||||
|
* Geprüft wird, was nach dem Abruf in der Datenbank steht: der Bestand des
|
||||||
|
* letzten Abrufs, der Kurs bekannter Papiere, der Vergleich mit dem eigenen
|
||||||
|
* Bestand – und beim Nachweis die Prüfsumme des Protokolls.
|
||||||
|
*/
|
||||||
|
class DepotBankbestandTest extends TestCase
|
||||||
|
{
|
||||||
|
protected string $testName = 'Test 51: Depotbestand laut Bank, Nachweis';
|
||||||
|
|
||||||
|
private int $depot = 0;
|
||||||
|
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$this->mandantAnlegen();
|
||||||
|
|
||||||
|
$this->depot = Konto::anlegen([
|
||||||
|
'name' => 'Bankdepot', 'kontoart' => 'depot', 'kontonummer' => '502022353',
|
||||||
|
'anfangssaldo' => 0, 'aktiv' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->bestandUndKurs();
|
||||||
|
$this->vergleichZeigtAbweichungen();
|
||||||
|
$this->zweiterAbrufErsetztDenErsten();
|
||||||
|
$this->nachweisMitPruefsumme();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function position(string $isin, string $name, float $stueck, float $kurs): Holding
|
||||||
|
{
|
||||||
|
return (new Holding())
|
||||||
|
->setISIN($isin)
|
||||||
|
->setName($name)
|
||||||
|
->setAmount($stueck)
|
||||||
|
->setPrice($kurs)
|
||||||
|
->setValue($stueck * $kurs)
|
||||||
|
->setCurrency('EUR')
|
||||||
|
->setDate(new \DateTime('2026-09-30'));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function bestandUndKurs(): void
|
||||||
|
{
|
||||||
|
$bekannt = Wertpapier::findenOderAnlegen([
|
||||||
|
'name' => 'Bekannte AG', 'isin' => 'DE0001111111', 'art' => 'aktie',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$ergebnis = Depotabgleich::uebernehmen($this->depot, [
|
||||||
|
$this->position('DE0001111111', 'Bekannte AG', 10.0, 12.5),
|
||||||
|
$this->position('DE0002222222', 'Unbekannte AG', 4.0, 100.0),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertEquals(2, $ergebnis['positionen'], 'Beide Positionen stehen im Bankbestand');
|
||||||
|
$this->assertEquals(1, $ergebnis['kurse'], 'Nur das bekannte Papier bekommt einen Kurs');
|
||||||
|
$this->assertEquals(
|
||||||
|
12.5,
|
||||||
|
(float)Wertpapier::letzterKurs($bekannt)['kurs'],
|
||||||
|
'Der Bewertungskurs der Bank ist der Kurs des Papiers'
|
||||||
|
);
|
||||||
|
$this->assertNull(
|
||||||
|
Wertpapier::finde('DE0002222222'),
|
||||||
|
'Das unbekannte Papier wird nicht angelegt'
|
||||||
|
);
|
||||||
|
$this->assertNotEmpty(Depotabgleich::abgerufenAm($this->depot), 'Der Abrufzeitpunkt ist vermerkt');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function vergleichZeigtAbweichungen(): void
|
||||||
|
{
|
||||||
|
$bekannt = Wertpapier::finde('DE0001111111');
|
||||||
|
Depotbuchung::anlegen([
|
||||||
|
'konto_id' => $this->depot, 'wertpapier_id' => (int)$bekannt['id'],
|
||||||
|
'vorgang' => 'kauf', 'datum' => '2026-01-05', 'stueck' => 8, 'kurs' => 10, 'betrag' => 80,
|
||||||
|
]);
|
||||||
|
$verkauftes = Wertpapier::findenOderAnlegen([
|
||||||
|
'name' => 'Alte AG', 'isin' => 'DE0003333333', 'art' => 'aktie',
|
||||||
|
]);
|
||||||
|
Depotbuchung::anlegen([
|
||||||
|
'konto_id' => $this->depot, 'wertpapier_id' => $verkauftes,
|
||||||
|
'vorgang' => 'kauf', 'datum' => '2026-01-05', 'stueck' => 3, 'kurs' => 10, 'betrag' => 30,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$je = [];
|
||||||
|
foreach (Depotabgleich::vergleich($this->depot) as $zeile) {
|
||||||
|
$je[$zeile['isin']] = $zeile;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assertEquals(2.0, $je['DE0001111111']['abweichung'], 'Die Bank hat 2 Stück mehr als gebucht');
|
||||||
|
$this->assertEquals(4.0, $je['DE0002222222']['abweichung'], 'Nur bei der Bank: ganze Position abweichend');
|
||||||
|
$this->assertNull($je['DE0002222222']['eigen'], 'Für das unbekannte Papier gibt es keinen eigenen Bestand');
|
||||||
|
$this->assertEquals(-3.0, $je['DE0003333333']['abweichung'], 'Nur im Finanzmanager: Bank führt es nicht');
|
||||||
|
$this->assertNull($je['DE0003333333']['bank'], 'Bei der Bank fehlt das Papier');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function zweiterAbrufErsetztDenErsten(): void
|
||||||
|
{
|
||||||
|
Depotabgleich::uebernehmen($this->depot, [
|
||||||
|
$this->position('DE0001111111', 'Bekannte AG', 8.0, 13.0),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$bank = array_filter(
|
||||||
|
Depotabgleich::vergleich($this->depot),
|
||||||
|
static fn(array $zeile): bool => $zeile['bank'] !== null
|
||||||
|
);
|
||||||
|
$this->assertEquals(1, count($bank), 'Der neue Abruf ersetzt den alten Bankbestand');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function nachweisMitPruefsumme(): void
|
||||||
|
{
|
||||||
|
$verrechnung = Konto::anlegen([
|
||||||
|
'name' => 'Nachweisgiro', 'kontoart' => 'bank', 'anfangssaldo' => 100, 'aktiv' => 1,
|
||||||
|
]);
|
||||||
|
$auftragId = Zahlungsauftrag::anlegen([
|
||||||
|
'konto_id' => $verrechnung, 'empfaenger' => 'Beweis GmbH',
|
||||||
|
'iban' => 'DE02120300000000202051', 'betrag' => 12.34,
|
||||||
|
'verwendungszweck' => 'Rechnung 42',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$dialog = "> HNHBK:1:3+000000000100'\n< HIRMG:2:2+0010::Nachricht entgegengenommen'";
|
||||||
|
$id = Bankprotokoll::speichern(null, 'ueberweisung', 'erfolg', 'übergeben', $dialog, $auftragId);
|
||||||
|
$this->assertNotEmpty($id, 'Das Protokoll wird gespeichert');
|
||||||
|
|
||||||
|
$auftrag = Zahlungsauftrag::find($auftragId);
|
||||||
|
$nachweis = Bankprotokoll::nachweis($auftrag);
|
||||||
|
|
||||||
|
$this->assert(str_contains($nachweis, 'Beweis GmbH'), 'Der Nachweis nennt den Empfänger');
|
||||||
|
$this->assert(str_contains($nachweis, $dialog), 'Der Nachweis enthält den vollständigen Dialog');
|
||||||
|
$this->assert(str_contains($nachweis, hash('sha256', $dialog)), 'Der Nachweis nennt die Prüfsumme');
|
||||||
|
$this->assert(str_contains($nachweis, 'unverändert seit dem Speichern'), 'Unveränderter Text wird bestätigt');
|
||||||
|
|
||||||
|
\Finanzmanager\Database::execute(
|
||||||
|
'UPDATE bank_protokolle SET protokoll = ? WHERE id = ?',
|
||||||
|
[$dialog . "\nnachträglich", $id]
|
||||||
|
);
|
||||||
|
$this->assert(
|
||||||
|
str_contains(Bankprotokoll::nachweis($auftrag), 'ABWEICHUNG'),
|
||||||
|
'Ein nachträglich geänderter Text fällt im Nachweis auf'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue