diff --git a/Changes.md b/Changes.md index 082cb8a..74d3778 100644 --- a/Changes.md +++ b/Changes.md @@ -1,5 +1,18 @@ # Ä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) - 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. diff --git a/public/index.php b/public/index.php index fa9cda4..c5f09f9 100644 --- a/public/index.php +++ b/public/index.php @@ -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 // 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. +$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->post('/verwaltung/export', fn() => (new ExportController())->index()); $router->get('/verwaltung/export/datei', fn() => (new ExportController())->datei()); diff --git a/src/Controllers/VerwaltungController.php b/src/Controllers/VerwaltungController.php index 4955fec..537181f 100644 --- a/src/Controllers/VerwaltungController.php +++ b/src/Controllers/VerwaltungController.php @@ -18,6 +18,7 @@ use Finanzmanager\Models\Vertrag; use Finanzmanager\Services\BargeldAbgleich; use Finanzmanager\Services\BargeldTool; use Finanzmanager\Services\Crypto; +use Finanzmanager\Services\Datenbanksicherung; use Finanzmanager\View; /** @@ -296,4 +297,48 @@ class VerwaltungController extends BaseController '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.' + ); + } } diff --git a/src/Services/Datenbanksicherung.php b/src/Services/Datenbanksicherung.php new file mode 100644 index 0000000..4dac257 --- /dev/null +++ b/src/Services/Datenbanksicherung.php @@ -0,0 +1,224 @@ + */ + 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 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 $spalten + * @param list> $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, zeilen: list}>} */ + 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 */ + 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; + } +} diff --git a/src/Services/FinTsEchtzeitUeberweisung.php b/src/Services/FinTsEchtzeitUeberweisung.php new file mode 100644 index 0000000..a06b72f --- /dev/null +++ b/src/Services/FinTsEchtzeitUeberweisung.php @@ -0,0 +1,42 @@ +statusOffen; + } + + public function processResponse(Message $response) + { + try { + parent::processResponse($response); + } catch (UnexpectedResponseException $e) { + if ($response->findRueckmeldungen(self::STATUS_OFFEN) === []) { + throw $e; + } + $this->statusOffen = true; + } + } +} diff --git a/src/Services/FinTsUeberweisung.php b/src/Services/FinTsUeberweisung.php index 939ac97..ec9289b 100644 --- a/src/Services/FinTsUeberweisung.php +++ b/src/Services/FinTsUeberweisung.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace Finanzmanager\Services; use Fhp\Action\GetSEPAAccounts; -use Fhp\Action\SendSEPARealtimeTransfer; use Fhp\Action\SendSEPATransfer; use Fhp\BaseAction; use Fhp\FinTs; @@ -343,7 +342,7 @@ class FinTsUeberweisung $xml = SepaXml::ueberweisung($this->auftragsdaten($auftrag, $sepa), $schema); $aktion = $echtzeit // Keine Umwandlung in eine gewöhnliche Überweisung – siehe oben. - ? SendSEPARealtimeTransfer::create($sepa, $xml, false) + ? FinTsEchtzeitUeberweisung::create($sepa, $xml, false) : SendSEPATransfer::create($sepa, $xml); try { @@ -468,7 +467,7 @@ class FinTsUeberweisung return $this->tanNoetig($fints, $aktion, 'Überweisung', 'senden'); } - return $this->angenommen($fints); + return $this->angenommen($fints, $aktion); } /** @@ -575,14 +574,21 @@ class FinTsUeberweisung } /** @return array */ - private function angenommen(FinTs $fints): array + private function angenommen(FinTs $fints, ?BaseAction $aktion = null): array { $auftrag = Zahlungsauftrag::find($this->auftragId); $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( $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(); @@ -592,6 +598,14 @@ class FinTsUeberweisung // 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 [ 'status' => 'erfolg', 'meldung' => $echtzeit diff --git a/src/Views/verwaltung/index.php b/src/Views/verwaltung/index.php index c5d0bd6..7f41418 100644 --- a/src/Views/verwaltung/index.php +++ b/src/Views/verwaltung/index.php @@ -5,6 +5,7 @@ use Finanzmanager\View; /** @var array $zahlen */ /** @var bool $bargeldAktiv */ +/** @var string $csrfToken */ include __DIR__ . '/../layout/kopf.php'; ?> @@ -59,6 +60,23 @@ include __DIR__ . '/../layout/kopf.php'; +
+

Backup

+

+ Der gesamte Datenbestand als eine Datei – Backup schreibt sie, Restore + ersetzt den aktuellen Bestand vollständig durch ihren Inhalt. +

+
+ Backup +
+ + + +
+
+
+

Online-Banking

diff --git a/tests/54_EchtzeitStatusOffenTest.php b/tests/54_EchtzeitStatusOffenTest.php new file mode 100644 index 0000000..d35323a --- /dev/null +++ b/tests/54_EchtzeitStatusOffenTest.php @@ -0,0 +1,33 @@ +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'); + } +} diff --git a/tests/55_DatenbanksicherungTest.php b/tests/55_DatenbanksicherungTest.php new file mode 100644 index 0000000..6d899b4 --- /dev/null +++ b/tests/55_DatenbanksicherungTest.php @@ -0,0 +1,52 @@ +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'); + } +}