Merge pull request 'Verwaltung: Kachel Backup mit Backup und Restore' (#114) from fds2610-bot/Finanzmanager:main into main
Reviewed-on: https://codeberg.org/fds2610/Finanzmanager/pulls/114
This commit is contained in:
commit
17124d26c8
9 changed files with 448 additions and 5 deletions
13
Changes.md
13
Changes.md
|
|
@ -1,5 +1,18 @@
|
||||||
# Änderungen
|
# Änderungen
|
||||||
|
|
||||||
|
## Verwaltung: Backup und Restore (01.10.2026)
|
||||||
|
|
||||||
|
- Neue Kachel „Backup“: „Backup“ lädt den gesamten Datenbestand als `finanzmanager-sicherung-….json.gz` herunter, „Restore“ ersetzt den Bestand durch den Inhalt einer solchen Datei (mit Rückfrage).
|
||||||
|
- `Services/Datenbanksicherung`: alle Tabellen mit Spalten und Zeilen; Restore in einer Transaktion (DELETE/INSERT), bei Fehler bleibt der alte Bestand. Abgelehnt werden fremde Dateien und Sicherungen, deren Spalten nicht zum Schema passen.
|
||||||
|
- Test: `tests/55_DatenbanksicherungTest.php`.
|
||||||
|
|
||||||
|
## Echtzeitüberweisung: offener Status ist kein Fehler (01.10.2026)
|
||||||
|
|
||||||
|
- Quittiert die Bank die TAN mit 3045 („noch keine Rückmeldung zum Status“), meldete php-fints „Bank did not confirm SEPATransfer execution“, obwohl der Auftrag bei der Bank liegt. Jetzt gilt der Auftrag als gesendet (gebucht), die Meldung weist darauf hin, den Kontoumsatz zu prüfen und nicht erneut zu senden.
|
||||||
|
- `Services/FinTsEchtzeitUeberweisung` erweitert `SendSEPARealtimeTransfer` um dieses Merkmal.
|
||||||
|
- Eine Statusabfrage bei der Bank (Nachfragen) ist noch offen: php-fints kennt dafür weder `HKIPS` noch `HKTAN` S für Nicht-Decoupled-Verfahren.
|
||||||
|
- Test: `tests/54_EchtzeitStatusOffenTest.php` (Antwort aus einem echten Lauf).
|
||||||
|
|
||||||
## Überweisung erfassen: QR-Code einlesen (01.10.2026)
|
## Überweisung erfassen: QR-Code einlesen (01.10.2026)
|
||||||
|
|
||||||
- Neben „Empfänger“ steht ein Bereich, auf den sich ein Bild (z. B. Rechnungs-Screenshot) ziehen lässt. Ein Klick öffnet einen Dialog zum Einfügen aus der Zwischenablage (Strg+V) oder zur Dateiauswahl.
|
- Neben „Empfänger“ steht ein Bereich, auf den sich ein Bild (z. B. Rechnungs-Screenshot) ziehen lässt. Ein Klick öffnet einen Dialog zum Einfügen aus der Zwischenablage (Strg+V) oder zur Dateiauswahl.
|
||||||
|
|
|
||||||
|
|
@ -347,6 +347,8 @@ $router->post('/verwaltung/import/{datei}/loeschen', fn(array $p) => (new Import
|
||||||
// Der Filter kommt als POST: Sind viele Kategorien angehakt, wird eine
|
// Der Filter kommt als POST: Sind viele Kategorien angehakt, wird eine
|
||||||
// GET-Adresse länger, als der Webserver annimmt. Die GET-Route bleibt für den
|
// GET-Adresse länger, als der Webserver annimmt. Die GET-Route bleibt für den
|
||||||
// ersten Aufruf und für kurze Adressen, die man sich merken mag.
|
// ersten Aufruf und für kurze Adressen, die man sich merken mag.
|
||||||
|
$router->get('/verwaltung/sicherung', fn() => (new VerwaltungController())->sicherungHerunterladen());
|
||||||
|
$router->post('/verwaltung/sicherung', fn() => (new VerwaltungController())->sicherungEinspielen());
|
||||||
$router->get('/verwaltung/export', fn() => (new ExportController())->index());
|
$router->get('/verwaltung/export', fn() => (new ExportController())->index());
|
||||||
$router->post('/verwaltung/export', fn() => (new ExportController())->index());
|
$router->post('/verwaltung/export', fn() => (new ExportController())->index());
|
||||||
$router->get('/verwaltung/export/datei', fn() => (new ExportController())->datei());
|
$router->get('/verwaltung/export/datei', fn() => (new ExportController())->datei());
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ use Finanzmanager\Models\Vertrag;
|
||||||
use Finanzmanager\Services\BargeldAbgleich;
|
use Finanzmanager\Services\BargeldAbgleich;
|
||||||
use Finanzmanager\Services\BargeldTool;
|
use Finanzmanager\Services\BargeldTool;
|
||||||
use Finanzmanager\Services\Crypto;
|
use Finanzmanager\Services\Crypto;
|
||||||
|
use Finanzmanager\Services\Datenbanksicherung;
|
||||||
use Finanzmanager\View;
|
use Finanzmanager\View;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -296,4 +297,48 @@ class VerwaltungController extends BaseController
|
||||||
'buchungen' => Buchung::mitBargeldFehler($fehlt ? 'fehlt' : 'fehler'),
|
'buchungen' => Buchung::mitBargeldFehler($fehlt ? 'fehlt' : 'fehler'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Die gesamte Datenbank als Download. */
|
||||||
|
public function sicherungHerunterladen(): void
|
||||||
|
{
|
||||||
|
$this->verlangeAnmeldung();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$inhalt = Datenbanksicherung::erzeugen();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
View::redirect('/verwaltung', 'Backup fehlgeschlagen: ' . $e->getMessage(), 'fehler');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
header('Content-Type: application/gzip');
|
||||||
|
header('Content-Disposition: attachment; filename="' . Datenbanksicherung::dateiname() . '"');
|
||||||
|
header('Content-Length: ' . strlen($inhalt));
|
||||||
|
header('Cache-Control: no-store');
|
||||||
|
echo $inhalt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ersetzt den gesamten Datenbestand durch den Inhalt einer hochgeladenen Sicherung. */
|
||||||
|
public function sicherungEinspielen(): void
|
||||||
|
{
|
||||||
|
$this->verlangeAnmeldung();
|
||||||
|
|
||||||
|
$datei = $_FILES['sicherung'] ?? null;
|
||||||
|
if (!is_array($datei) || ($datei['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK
|
||||||
|
|| !is_uploaded_file((string)$datei['tmp_name'])) {
|
||||||
|
View::redirect('/verwaltung', 'Es wurde keine Sicherung hochgeladen.', 'fehler');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$anzahl = Datenbanksicherung::wiederherstellen((string)file_get_contents((string)$datei['tmp_name']));
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
View::redirect('/verwaltung', 'Restore fehlgeschlagen, der Bestand blieb unverändert: ' . $e->getMessage(), 'fehler');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
View::redirect(
|
||||||
|
'/verwaltung',
|
||||||
|
'Restore abgeschlossen: ' . array_sum($anzahl) . ' Zeilen in ' . count($anzahl) . ' Tabellen eingespielt.'
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
224
src/Services/Datenbanksicherung.php
Normal file
224
src/Services/Datenbanksicherung.php
Normal file
|
|
@ -0,0 +1,224 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Finanzmanager\Services;
|
||||||
|
|
||||||
|
use Finanzmanager\Database;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sichert den gesamten Inhalt der Datenbank in eine Datei und liest ihn
|
||||||
|
* wieder ein.
|
||||||
|
*
|
||||||
|
* Die Sicherung ist ein gzip-komprimiertes JSON: je Tabelle die Spalten und
|
||||||
|
* alle Zeilen. Das Schema selbst gehört den Migrationen und steht nicht
|
||||||
|
* darin – eine Sicherung passt deshalb nur zu einem Stand mit denselben
|
||||||
|
* Spalten.
|
||||||
|
*
|
||||||
|
* Das Wiederherstellen läuft in einer Transaktion (nur DELETE und INSERT,
|
||||||
|
* kein TRUNCATE – das würde die Transaktion beenden): Scheitert es
|
||||||
|
* irgendwo, bleibt der alte Bestand unberührt.
|
||||||
|
*/
|
||||||
|
class Datenbanksicherung
|
||||||
|
{
|
||||||
|
private const KENNUNG = 'finanzmanager-sicherung';
|
||||||
|
private const VERSION = 1;
|
||||||
|
|
||||||
|
/** Zeilen je INSERT beim Einspielen. */
|
||||||
|
private const BLOCK = 200;
|
||||||
|
|
||||||
|
/** @return list<string> */
|
||||||
|
public static function tabellen(): array
|
||||||
|
{
|
||||||
|
$namen = [];
|
||||||
|
foreach (Database::fetchAll("SHOW FULL TABLES WHERE Table_type = 'BASE TABLE'") as $zeile) {
|
||||||
|
$namen[] = (string)reset($zeile);
|
||||||
|
}
|
||||||
|
sort($namen);
|
||||||
|
|
||||||
|
return $namen;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die komplette Sicherung als gzip-Bytes. */
|
||||||
|
public static function erzeugen(): string
|
||||||
|
{
|
||||||
|
$tabellen = [];
|
||||||
|
foreach (self::tabellen() as $name) {
|
||||||
|
$spalten = self::spalten($name);
|
||||||
|
$zeilen = [];
|
||||||
|
foreach (Database::fetchAll('SELECT * FROM ' . self::bezeichner($name)) as $zeile) {
|
||||||
|
$werte = [];
|
||||||
|
foreach ($spalten as $spalte) {
|
||||||
|
$werte[] = self::kodiere($zeile[$spalte]);
|
||||||
|
}
|
||||||
|
$zeilen[] = $werte;
|
||||||
|
}
|
||||||
|
$tabellen[$name] = ['spalten' => $spalten, 'zeilen' => $zeilen];
|
||||||
|
}
|
||||||
|
|
||||||
|
$json = json_encode([
|
||||||
|
'kennung' => self::KENNUNG,
|
||||||
|
'version' => self::VERSION,
|
||||||
|
'erstellt' => date('c'),
|
||||||
|
'tabellen' => $tabellen,
|
||||||
|
], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE);
|
||||||
|
|
||||||
|
$gzip = gzencode($json, 6);
|
||||||
|
if ($gzip === false) {
|
||||||
|
throw new RuntimeException('Die Sicherung ließ sich nicht komprimieren.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $gzip;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dateiname für den Download. */
|
||||||
|
public static function dateiname(): string
|
||||||
|
{
|
||||||
|
return 'finanzmanager-sicherung-' . date('Y-m-d_His') . '.json.gz';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spielt eine Sicherung ein und ersetzt dabei den gesamten Bestand.
|
||||||
|
*
|
||||||
|
* @return array<string,int> Zeilen je Tabelle
|
||||||
|
*/
|
||||||
|
public static function wiederherstellen(string $inhalt): array
|
||||||
|
{
|
||||||
|
$daten = self::lies($inhalt);
|
||||||
|
$vorhanden = self::tabellen();
|
||||||
|
|
||||||
|
foreach ($daten['tabellen'] as $name => $tabelle) {
|
||||||
|
if (!in_array($name, $vorhanden, true)) {
|
||||||
|
throw new RuntimeException('Die Tabelle „' . $name . '“ gibt es in dieser Installation nicht.');
|
||||||
|
}
|
||||||
|
if (self::spalten($name) !== $tabelle['spalten']) {
|
||||||
|
throw new RuntimeException(
|
||||||
|
'Die Spalten der Tabelle „' . $name . '“ passen nicht zum Stand dieser Installation.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo = Database::get();
|
||||||
|
|
||||||
|
return Database::transaktion(static function () use ($daten, $vorhanden, $pdo): array {
|
||||||
|
$pdo->exec('SET FOREIGN_KEY_CHECKS = 0');
|
||||||
|
try {
|
||||||
|
$anzahl = [];
|
||||||
|
foreach ($vorhanden as $name) {
|
||||||
|
Database::execute('DELETE FROM ' . self::bezeichner($name));
|
||||||
|
}
|
||||||
|
foreach ($daten['tabellen'] as $name => $tabelle) {
|
||||||
|
$anzahl[$name] = self::einspielen($name, $tabelle['spalten'], $tabelle['zeilen']);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
$pdo->exec('SET FOREIGN_KEY_CHECKS = 1');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $anzahl;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $spalten
|
||||||
|
* @param list<list<mixed>> $zeilen
|
||||||
|
*/
|
||||||
|
private static function einspielen(string $name, array $spalten, array $zeilen): int
|
||||||
|
{
|
||||||
|
if ($zeilen === []) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$kopf = 'INSERT INTO ' . self::bezeichner($name) . ' ('
|
||||||
|
. implode(', ', array_map([self::class, 'bezeichner'], $spalten)) . ') VALUES ';
|
||||||
|
$zeile = '(' . implode(', ', array_fill(0, count($spalten), '?')) . ')';
|
||||||
|
|
||||||
|
foreach (array_chunk($zeilen, self::BLOCK) as $block) {
|
||||||
|
$werte = [];
|
||||||
|
foreach ($block as $datensatz) {
|
||||||
|
if (!is_array($datensatz) || count($datensatz) !== count($spalten)) {
|
||||||
|
throw new RuntimeException('Beschädigte Zeile in der Tabelle „' . $name . '“.');
|
||||||
|
}
|
||||||
|
foreach ($datensatz as $wert) {
|
||||||
|
$werte[] = self::dekodiere($wert);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Database::execute($kopf . implode(', ', array_fill(0, count($block), $zeile)), $werte);
|
||||||
|
}
|
||||||
|
|
||||||
|
return count($zeilen);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{tabellen: array<string,array{spalten: list<string>, zeilen: list<mixed>}>} */
|
||||||
|
private static function lies(string $inhalt): array
|
||||||
|
{
|
||||||
|
$json = @gzdecode($inhalt);
|
||||||
|
if ($json === false) {
|
||||||
|
throw new RuntimeException('Die Datei ist keine Sicherung (gzip erwartet).');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$daten = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
} catch (\JsonException) {
|
||||||
|
throw new RuntimeException('Die Datei ist keine Sicherung (kein gültiges JSON).');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_array($daten) || ($daten['kennung'] ?? null) !== self::KENNUNG) {
|
||||||
|
throw new RuntimeException('Die Datei ist keine Sicherung des Finanzmanagers.');
|
||||||
|
}
|
||||||
|
if (($daten['version'] ?? null) !== self::VERSION) {
|
||||||
|
throw new RuntimeException('Die Version der Sicherung wird nicht unterstützt.');
|
||||||
|
}
|
||||||
|
if (!is_array($daten['tabellen'] ?? null)) {
|
||||||
|
throw new RuntimeException('Die Sicherung enthält keine Tabellen.');
|
||||||
|
}
|
||||||
|
foreach ($daten['tabellen'] as $tabelle) {
|
||||||
|
if (!is_array($tabelle) || !is_array($tabelle['spalten'] ?? null) || !is_array($tabelle['zeilen'] ?? null)) {
|
||||||
|
throw new RuntimeException('Die Sicherung ist beschädigt.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $daten;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<string> */
|
||||||
|
private static function spalten(string $tabelle): array
|
||||||
|
{
|
||||||
|
return array_map(
|
||||||
|
static fn(array $z): string => (string)$z['Field'],
|
||||||
|
Database::fetchAll('SHOW COLUMNS FROM ' . self::bezeichner($tabelle))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bezeichner sind nie Eingabe, sondern kommen aus dem Schema oder werden hier geprüft. */
|
||||||
|
private static function bezeichner(string $name): string
|
||||||
|
{
|
||||||
|
if (preg_match('/^[A-Za-z0-9_]+$/', $name) !== 1) {
|
||||||
|
throw new RuntimeException('Ungültiger Bezeichner: ' . $name);
|
||||||
|
}
|
||||||
|
|
||||||
|
return '`' . $name . '`';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Binärwerte, die kein UTF-8 sind, wandern als Base64 in die Datei. */
|
||||||
|
private static function kodiere(mixed $wert): mixed
|
||||||
|
{
|
||||||
|
if (is_string($wert) && !mb_check_encoding($wert, 'UTF-8')) {
|
||||||
|
return ['b64' => base64_encode($wert)];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $wert;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function dekodiere(mixed $wert): mixed
|
||||||
|
{
|
||||||
|
if (is_array($wert) && isset($wert['b64']) && is_string($wert['b64'])) {
|
||||||
|
return base64_decode($wert['b64'], true) ?: '';
|
||||||
|
}
|
||||||
|
if (is_bool($wert)) {
|
||||||
|
return (int)$wert;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $wert;
|
||||||
|
}
|
||||||
|
}
|
||||||
42
src/Services/FinTsEchtzeitUeberweisung.php
Normal file
42
src/Services/FinTsEchtzeitUeberweisung.php
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Finanzmanager\Services;
|
||||||
|
|
||||||
|
use Fhp\Action\SendSEPARealtimeTransfer;
|
||||||
|
use Fhp\Protocol\Message;
|
||||||
|
use Fhp\Protocol\UnexpectedResponseException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Echtzeitüberweisung, die eine noch offene Statusmeldung der Bank übersteht.
|
||||||
|
*
|
||||||
|
* Antwortet die Gegenstelle nicht rechtzeitig, quittiert die Bank die TAN mit
|
||||||
|
* 3045 („noch keine Rückmeldung zum Status“) statt mit „entgegengenommen“ oder
|
||||||
|
* „ausgeführt“. php-fints wirft dann eine Ausnahme, obwohl der Auftrag bei der
|
||||||
|
* Bank liegt. Hier bleibt nur das Merkmal `statusOffen` zurück.
|
||||||
|
*/
|
||||||
|
class FinTsEchtzeitUeberweisung extends SendSEPARealtimeTransfer
|
||||||
|
{
|
||||||
|
/** Rückmeldecode der Bank: Status der Echtzeitüberweisung noch unbekannt. */
|
||||||
|
public const STATUS_OFFEN = 3045;
|
||||||
|
|
||||||
|
private bool $statusOffen = false;
|
||||||
|
|
||||||
|
public function istStatusOffen(): bool
|
||||||
|
{
|
||||||
|
return $this->statusOffen;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function processResponse(Message $response)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
parent::processResponse($response);
|
||||||
|
} catch (UnexpectedResponseException $e) {
|
||||||
|
if ($response->findRueckmeldungen(self::STATUS_OFFEN) === []) {
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
$this->statusOffen = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,7 +5,6 @@ declare(strict_types=1);
|
||||||
namespace Finanzmanager\Services;
|
namespace Finanzmanager\Services;
|
||||||
|
|
||||||
use Fhp\Action\GetSEPAAccounts;
|
use Fhp\Action\GetSEPAAccounts;
|
||||||
use Fhp\Action\SendSEPARealtimeTransfer;
|
|
||||||
use Fhp\Action\SendSEPATransfer;
|
use Fhp\Action\SendSEPATransfer;
|
||||||
use Fhp\BaseAction;
|
use Fhp\BaseAction;
|
||||||
use Fhp\FinTs;
|
use Fhp\FinTs;
|
||||||
|
|
@ -343,7 +342,7 @@ class FinTsUeberweisung
|
||||||
$xml = SepaXml::ueberweisung($this->auftragsdaten($auftrag, $sepa), $schema);
|
$xml = SepaXml::ueberweisung($this->auftragsdaten($auftrag, $sepa), $schema);
|
||||||
$aktion = $echtzeit
|
$aktion = $echtzeit
|
||||||
// Keine Umwandlung in eine gewöhnliche Überweisung – siehe oben.
|
// Keine Umwandlung in eine gewöhnliche Überweisung – siehe oben.
|
||||||
? SendSEPARealtimeTransfer::create($sepa, $xml, false)
|
? FinTsEchtzeitUeberweisung::create($sepa, $xml, false)
|
||||||
: SendSEPATransfer::create($sepa, $xml);
|
: SendSEPATransfer::create($sepa, $xml);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -468,7 +467,7 @@ class FinTsUeberweisung
|
||||||
return $this->tanNoetig($fints, $aktion, 'Überweisung', 'senden');
|
return $this->tanNoetig($fints, $aktion, 'Überweisung', 'senden');
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->angenommen($fints);
|
return $this->angenommen($fints, $aktion);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -575,14 +574,21 @@ class FinTsUeberweisung
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return array<string,mixed> */
|
/** @return array<string,mixed> */
|
||||||
private function angenommen(FinTs $fints): array
|
private function angenommen(FinTs $fints, ?BaseAction $aktion = null): array
|
||||||
{
|
{
|
||||||
$auftrag = Zahlungsauftrag::find($this->auftragId);
|
$auftrag = Zahlungsauftrag::find($this->auftragId);
|
||||||
$echtzeit = $auftrag !== null && self::istEchtzeit($auftrag);
|
$echtzeit = $auftrag !== null && self::istEchtzeit($auftrag);
|
||||||
|
|
||||||
|
// Die Bank hat die TAN angenommen, den Ausgang aber noch nicht gemeldet.
|
||||||
|
// Der Auftrag gilt als gesendet – ein erneutes Senden wäre eine
|
||||||
|
// Doppelzahlung –, der Nutzer prüft den Umsatz.
|
||||||
|
$statusOffen = $aktion instanceof FinTsEchtzeitUeberweisung && $aktion->istStatusOffen();
|
||||||
|
|
||||||
Zahlungsauftrag::alsGesendetVermerken(
|
Zahlungsauftrag::alsGesendetVermerken(
|
||||||
$this->auftragId,
|
$this->auftragId,
|
||||||
$echtzeit ? 'Von der Bank als Echtzeitüberweisung angenommen.' : 'Von der Bank angenommen.'
|
$statusOffen
|
||||||
|
? 'Die Bank hat die Echtzeitüberweisung entgegengenommen, den Ausgang aber noch nicht bestätigt.'
|
||||||
|
: ($echtzeit ? 'Von der Bank als Echtzeitüberweisung angenommen.' : 'Von der Bank angenommen.')
|
||||||
);
|
);
|
||||||
self::vergessen();
|
self::vergessen();
|
||||||
|
|
||||||
|
|
@ -592,6 +598,14 @@ class FinTsUeberweisung
|
||||||
// Der Dialog ist zu Ende; ein Fehler beim Abmelden ändert nichts.
|
// Der Dialog ist zu Ende; ein Fehler beim Abmelden ändert nichts.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($statusOffen) {
|
||||||
|
return [
|
||||||
|
'status' => 'erfolg',
|
||||||
|
'meldung' => 'Die Bank hat die Echtzeitüberweisung entgegengenommen, den Ausgang aber noch nicht bestätigt. '
|
||||||
|
. 'Bitte im Kontoumsatz prüfen, ob sie ausgeführt wurde, und nicht erneut senden.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'status' => 'erfolg',
|
'status' => 'erfolg',
|
||||||
'meldung' => $echtzeit
|
'meldung' => $echtzeit
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ use Finanzmanager\View;
|
||||||
|
|
||||||
/** @var array<string,int> $zahlen */
|
/** @var array<string,int> $zahlen */
|
||||||
/** @var bool $bargeldAktiv */
|
/** @var bool $bargeldAktiv */
|
||||||
|
/** @var string $csrfToken */
|
||||||
|
|
||||||
include __DIR__ . '/../layout/kopf.php';
|
include __DIR__ . '/../layout/kopf.php';
|
||||||
?>
|
?>
|
||||||
|
|
@ -59,6 +60,23 @@ include __DIR__ . '/../layout/kopf.php';
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="kachel">
|
||||||
|
<header><h2>Backup</h2></header>
|
||||||
|
<p class="platzhalter">
|
||||||
|
Der gesamte Datenbestand als eine Datei – Backup schreibt sie, Restore
|
||||||
|
ersetzt den aktuellen Bestand vollständig durch ihren Inhalt.
|
||||||
|
</p>
|
||||||
|
<div class="knopfleiste">
|
||||||
|
<a href="/verwaltung/sicherung" class="knopf klein primaer" id="backup-erstellen">Backup</a>
|
||||||
|
<form method="post" action="/verwaltung/sicherung" enctype="multipart/form-data"
|
||||||
|
id="restore-formular" data-bestaetigen="Restore ersetzt den gesamten Datenbestand durch den Inhalt der Datei. Fortfahren?">
|
||||||
|
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||||
|
<input type="file" name="sicherung" id="restore-datei" accept=".gz" required>
|
||||||
|
<button type="submit" class="knopf klein" id="restore-ausfuehren">Restore</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="kachel">
|
<div class="kachel">
|
||||||
<header><h2>Online-Banking</h2></header>
|
<header><h2>Online-Banking</h2></header>
|
||||||
<p class="platzhalter">
|
<p class="platzhalter">
|
||||||
|
|
|
||||||
33
tests/54_EchtzeitStatusOffenTest.php
Normal file
33
tests/54_EchtzeitStatusOffenTest.php
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Fhp\Protocol\Message;
|
||||||
|
use Finanzmanager\Services\FinTsEchtzeitUeberweisung;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Echtzeitüberweisung mit offenem Status (Rückmeldung 3045).
|
||||||
|
*
|
||||||
|
* Die Antwort stammt aus einem echten Lauf: Die Bank nimmt die TAN an, meldet
|
||||||
|
* aber nur „noch keine Rückmeldung zum Status“. Das ist kein Fehler – der
|
||||||
|
* Auftrag liegt bei der Bank.
|
||||||
|
*/
|
||||||
|
class EchtzeitStatusOffenTest extends TestCase
|
||||||
|
{
|
||||||
|
protected string $testName = 'Test 54: Echtzeitüberweisung – Status offen';
|
||||||
|
|
||||||
|
private const ANTWORT = <<<'FINTS'
|
||||||
|
HNHBK:1:3+000000000620+300+YWPfOjdl96ABAACrZvYFQwSCCgQA+5+YWPfOjdl96ABAACrZvYFQwSCCgQA:5'HNVSK:998:3+PIN:2+998+1+2::6lrfOjdl96ABAACrZvYFQwSCCgQA+1:20261001:143605+2:2:13:@8@00000000:5:1+280:20041111:PRIVATE_:V:0:0+0'HNVSD:999:1+@374@HNSHK:2:4+PIN:2+902+7079006+1+1+2::6lrfOjdl96ABAACrZvYFQwSCCgQA+1+1:20261001:143605+1:999:1+6:10:19+280:20041111:PRIVATE_:S:0:0'HIRMG:3:2+3060::Bitte beachten Sie die enthaltenen Warnungen/Hinweise.'HIRMS:4:2:3+3045::Wir haben leider noch keine Rückmeldung zum Status ihrer Überweisung.'HITAN:5:6:3+2++47017475'HIIPZ:6:1:3+C6592DB8C24247C493F1B27377EC63DA'HNSHA:7:2+7079006''HNHBS:8:1+5'
|
||||||
|
FINTS;
|
||||||
|
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$antwort = Message::parse(mb_convert_encoding(self::ANTWORT, 'ISO-8859-1', 'UTF-8'));
|
||||||
|
$aktion = new FinTsEchtzeitUeberweisung();
|
||||||
|
|
||||||
|
$this->assert(!$aktion->istStatusOffen(), 'Ohne Antwort ist der Status nicht offen');
|
||||||
|
|
||||||
|
$aktion->processResponse($antwort);
|
||||||
|
$this->assert($aktion->istStatusOffen(), 'Rückmeldung 3045 wird als „Status offen“ erkannt, nicht als Fehler');
|
||||||
|
}
|
||||||
|
}
|
||||||
52
tests/55_DatenbanksicherungTest.php
Normal file
52
tests/55_DatenbanksicherungTest.php
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Finanzmanager\Database;
|
||||||
|
use Finanzmanager\Services\Datenbanksicherung;
|
||||||
|
|
||||||
|
class DatenbanksicherungTest extends TestCase
|
||||||
|
{
|
||||||
|
protected string $testName = 'Test 55: Datenbanksicherung';
|
||||||
|
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$this->rundlauf();
|
||||||
|
$this->ungueltigeDatei();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function rundlauf(): void
|
||||||
|
{
|
||||||
|
Database::execute("INSERT INTO einstellungen (schluessel, wert, bereich) VALUES ('sicherungstest', 'a', 'test')");
|
||||||
|
$vorher = (int)Database::fetchValue('SELECT COUNT(*) FROM einstellungen');
|
||||||
|
|
||||||
|
$sicherung = Datenbanksicherung::erzeugen();
|
||||||
|
$this->assert(strlen($sicherung) > 0, 'Sicherung ist nicht leer');
|
||||||
|
|
||||||
|
Database::execute("DELETE FROM einstellungen WHERE schluessel = 'sicherungstest'");
|
||||||
|
Database::execute("INSERT INTO einstellungen (schluessel, wert, bereich) VALUES ('nach_sicherung', 'b', 'test')");
|
||||||
|
|
||||||
|
$anzahl = Datenbanksicherung::wiederherstellen($sicherung);
|
||||||
|
$this->assertEquals($vorher, $anzahl['einstellungen'], 'Zeilenzahl der Tabelle einstellungen');
|
||||||
|
$this->assertEquals(1, (int)Database::fetchValue("SELECT COUNT(*) FROM einstellungen WHERE schluessel = 'sicherungstest'"),
|
||||||
|
'Gesicherte Zeile ist wieder da');
|
||||||
|
$this->assertEquals(0, (int)Database::fetchValue("SELECT COUNT(*) FROM einstellungen WHERE schluessel = 'nach_sicherung'"),
|
||||||
|
'Zeile nach der Sicherung ist verschwunden');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ungueltigeDatei(): void
|
||||||
|
{
|
||||||
|
$vorher = (int)Database::fetchValue('SELECT COUNT(*) FROM einstellungen');
|
||||||
|
foreach (['kein gzip', gzencode('kein json'), gzencode('{"kennung":"fremd"}')] as $inhalt) {
|
||||||
|
$geworfen = false;
|
||||||
|
try {
|
||||||
|
Datenbanksicherung::wiederherstellen($inhalt);
|
||||||
|
} catch (\RuntimeException) {
|
||||||
|
$geworfen = true;
|
||||||
|
}
|
||||||
|
$this->assert($geworfen, 'Ungültige Datei wird abgelehnt');
|
||||||
|
}
|
||||||
|
$this->assertEquals($vorher, (int)Database::fetchValue('SELECT COUNT(*) FROM einstellungen'),
|
||||||
|
'Bestand nach abgelehnter Datei unverändert');
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue