Feature: Kontostand nach Direktabruf auf Bank-Verbindungskachel

This commit is contained in:
fds2610-bot 2026-07-21 18:43:41 +02:00
parent a7e888a0c9
commit 2b93d38513
9 changed files with 120 additions and 6 deletions

View file

@ -2,6 +2,15 @@
---
## 2026-07-21 – Feature: Kontostand nach Direktabruf auf der Bank-Verbindungskachel
- **`migrations/087_bank_sources_last_balance.sql`:** Neue Spalte `last_balance` in `bank_sources`.
- **`src/Models/BankSource.php`:** `updateLastFetched()` nimmt optional den beim Abruf ermittelten Kontostand entgegen und persistiert ihn; ohne neuen Wert bleibt der zuletzt bekannte Stand erhalten (kein Verlust bei Fehlern während des Abrufs).
- **`src/Services/FinTsFetchService.php`:** Summiert nach dem Abruf pro Konto den Schlusssaldo (`getEndBalance()`, Fallback `getStartBalance()`) des letzten Statements und übergibt ihn an `updateLastFetched()`.
- **`src/Services/PayPalNvpFetchService.php`:** Der bereits vorhandene `GetBalance`-Aufruf in `testCredentials()` liefert den aktuellen Kontostand (`AMT`), der nun mitgespeichert wird.
- **`src/Views/bank/fints.php`:** Kachel zeigt bei vorhandenem Kontostand rechts neben dem Abrufdatum den Betrag an (gleiche Höhe wie das Datum), mit dem Label „Kontostand" rechtsbündig darüber. Kachelgröße unverändert.
- **`tests/57_BankSourceLastBalanceTest.php`:** Neuer Test prüft, dass `last_balance` beim Abruf gesetzt wird und bei einem Abruf ohne ermittelten Kontostand erhalten bleibt.
## 2026-07-16 – Fix: Stored XSS über KI-Kontierungsvorschlag (supplier_name) in Beleg-Detailansicht (Issue #129)
- **Mittel-Hoch:** `supplier_name` aus dem per LLM erzeugten Kontierungsvorschlag (`KontierungsvorschlagService`) wird nur `trim()`t und ungeprüft in `belege.ki_vorschlag` (JSON) gespeichert. `_showKiBadgeDone()` in `src/Views/belege/detail.php` baute daraus String-Konkatenation und wies sie ohne Escaping über `innerHTML` zu. Da der Belegtext (PDF/Bild/ZUGFeRD-XML) von einem externen Rechnungssteller stammt, konnte ein präparierter Beleg das LLM per Prompt-Injection dazu bringen, für `supplier_name` HTML/JS-Payload statt eines Firmennamens auszugeben – dieser wurde beim Öffnen des Belegs im Browser-Kontext des eingeloggten Nutzers ausgeführt (CWE-79).

View file

@ -72,6 +72,7 @@ Anlagenspiegel für abnutzbare Wirtschaftsgüter – siehe [anlagevermögen.md](
- **Direktverbindung „PayPal"** (NVP/SOAP-API): API-Benutzer, API-Kennwort, Signature und Sandbox-Flag werden verschlüsselt abgelegt. Live-Log via Server-Sent Events
- **Direktverbindung „FinTS"** (Comdirect, DKB und weitere): BLZ, Benutzerkennung und PIN, optional FinTS-Server-URL (sonst Default aus dem Provider) und IBAN-Filter. PIN wird verschlüsselt abgelegt (AES-256-GCM). Leseabruf mit PIN; TAN-pflichtige Aktionen werden mit Fehlermeldung abgebrochen. Die FinTS-Server-URL wird beim Speichern und erneut vor jedem Abruf mit `SsrfGuard` geprüft: nur `https://` zu öffentlich erreichbaren Hosts ist erlaubt, private/interne Ziele (RFC1918, Loopback, Link-Local) werden abgelehnt (Schutz vor SSRF)
- **Letzter Abrufzeitpunkt** wird pro Verbindung gespeichert; nächster Abruf startet ab 00:00 dieses Tages (max. ein Tag Überlappung für Dedup)
- **Kontostand**: FinTS-Abrufe liefern den Schlusssaldo des letzten Statements, PayPal den aktuellen Saldo über `GetBalance`; der Wert wird zusammen mit dem Abrufzeitpunkt auf der Verbindungskachel angezeigt und bleibt bei Abrufen ohne ermittelten Kontostand erhalten
- **Abrufen ab**: Im Bearbeiten-Dialog kann pro Direktverbindung ausgewählt werden, ab wann der nächste Lauf Buchungen holt: *Ab letztem Abruf* (Default), *Letzte 7 Tage*, *Aktueller Monat (ab 01.)*, *Letzte 30 Tage* oder *Ohne Datum (letzte 365 Tage)*. Die Sonderauswahl gilt nur einmalig – nach erfolgreichem Abruf fällt die Verbindung wieder auf den Default zurück
- **Dublettenprüfung**: CSV-Import vergleicht Datum+Betrag+Text; Direktverbindungen nutzen eine `external_id` (Hash aus IBAN/Datum/Betrag/Verwendungszweck bei FinTS, Transaktions-ID bei PayPal)
- **Beleg-Zuordnung**: jede Banktransaktion kann einem oder mehreren Belegen zugeordnet werden; der zugeordnete Betrag wird separat erfasst

View file

@ -0,0 +1,2 @@
ALTER TABLE bank_sources
ADD COLUMN IF NOT EXISTS last_balance DECIMAL(14,2) NULL DEFAULT NULL AFTER last_fetched_at;

View file

@ -85,10 +85,23 @@ class BankSource
Database::execute('DELETE FROM bank_sources WHERE id = ?', [$id]);
}
public static function updateLastFetched(int $id): void
public static function updateLastFetched(int $id, ?float $balance = null): void
{
// Setzt zugleich fetch_range auf den Default zurück: einmalige Sonderauswahl
// (z.B. "letzte Woche") gilt nur für genau einen Abruf.
// last_balance wird nur überschrieben, wenn der Abruf einen Kontostand geliefert
// hat – sonst bleibt der zuletzt bekannte Stand erhalten.
if ($balance !== null) {
Database::execute(
"UPDATE bank_sources
SET last_fetched_at = NOW(),
fetch_range = 'last_fetched',
last_balance = ?
WHERE id = ?",
[$balance, $id]
);
return;
}
Database::execute(
"UPDATE bank_sources
SET last_fetched_at = NOW(),

View file

@ -205,6 +205,7 @@ class FinTsFetchService
$totalNew = 0;
$totalSkip = 0;
$balance = null;
foreach ($accounts as $account) {
$iban = $account->getIban();
@ -224,6 +225,16 @@ class FinTsFetchService
continue;
}
$statements = $soa->getStatements();
if (!empty($statements)) {
$lastStatement = end($statements);
$accountBalance = $lastStatement->getEndBalance() ?? $lastStatement->getStartBalance();
if ($accountBalance !== null) {
$balance = ($balance ?? 0.0) + $accountBalance;
$log(' → Kontostand: ' . number_format($accountBalance, 2, ',', '.') . ' €');
}
}
$accountNew = 0;
foreach ($soa->getStatements() as $statement) {
foreach ($statement->getTransactions() as $tx) {
@ -276,7 +287,7 @@ class FinTsFetchService
}
$this->updateBatchCount($batchId, $totalNew);
$this->updateLastFetched();
$this->updateLastFetched($balance);
$log(sprintf('Fertig. %d neu importiert, %d bereits vorhanden.', $totalNew, $totalSkip));
}
@ -533,9 +544,9 @@ class FinTsFetchService
);
}
private function updateLastFetched(): void
private function updateLastFetched(?float $balance): void
{
BankSource::updateLastFetched($this->sourceId);
BankSource::updateLastFetched($this->sourceId, $balance);
}
private function externalIdExists(string $externalId): bool

View file

@ -19,6 +19,7 @@ class PayPalNvpFetchService
private string $signature;
private int $sourceId;
private array $source;
private ?float $lastBalance = null;
/** @var callable */
private $log;
@ -311,6 +312,9 @@ class PayPalNvpFetchService
}
return false;
}
if (isset($result['AMT']) && is_numeric($result['AMT'])) {
$this->lastBalance = (float)$result['AMT'];
}
return true;
}
@ -422,7 +426,7 @@ class PayPalNvpFetchService
private function updateLastFetched(): void
{
BankSource::updateLastFetched($this->sourceId);
BankSource::updateLastFetched($this->sourceId, $this->lastBalance);
}
private function externalIdExists(string $externalId): bool

View file

@ -85,9 +85,22 @@ $csvDateFormatOptions = [
<span class="badge <?= $conn['verbindungstyp'] === 'API' ? 'badge-blue' : 'badge-green' ?>" style="margin-top:.5rem">
<?= $h($conn['verbindungstyp']) ?>
</span>
<?php if ($conn['last_fetched_at'] && $conn['last_balance'] !== null): ?>
<div style="display:grid;grid-template-columns:1fr auto;column-gap:.5rem;align-items:baseline;margin-top:.6rem">
<div></div>
<div class="text-muted" style="text-align:right;font-size:.65rem">Kontostand</div>
<div class="text-muted text-sm">
Abruf: <?= $h(View::formatDate($conn['last_fetched_at'])) ?>
</div>
<div style="text-align:right;font-weight:600;font-size:.9rem;white-space:nowrap">
<?= $h(View::formatMoney($conn['last_balance'])) ?>
</div>
</div>
<?php else: ?>
<div class="text-muted text-sm" style="margin-top:.6rem">
Abruf: <?= $conn['last_fetched_at'] ? $h(View::formatDate($conn['last_fetched_at'])) : '<kein>' ?>
</div>
<?php endif; ?>
</div>
<button type="button"
class="btn-edit-connection"

View file

@ -1 +1 @@
1-260703
1-260721

View file

@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
use Steuerkram\Config;
use Steuerkram\Models\BankSource;
/**
* Test 57: BankSource::updateLastFetched persistiert optional den beim Abruf
* ermittelten Kontostand (last_balance) und lässt ihn unangetastet, wenn kein
* neuer Kontostand geliefert wurde.
*/
class BankSourceLastBalanceTest extends TestCase
{
protected string $testName = 'Test 57: BankSource – Kontostand nach Direktabruf';
public function run(): void
{
$oldKey = $_ENV['APP_KEY'] ?? null;
if (empty(Config::get('app_key', ''))) {
$_ENV['APP_KEY'] = str_repeat('c3', 32);
Config::load();
}
try {
$id = BankSource::create([
'type' => 'fints',
'bezeichnung' => 'TestFinTS-Balance',
'blz' => '12030000',
'benutzerkennung' => 'kundennummer',
'pin' => 'g3h31m',
]);
$this->assertGreaterThan(0, $id, 'FinTS-Quelle wurde angelegt');
$fresh = BankSource::find($id);
$this->assert($fresh['last_balance'] === null, 'Kontostand ist vor dem ersten Abruf leer');
BankSource::updateLastFetched($id, 1234.56);
$after1 = BankSource::find($id);
$this->assertNotEmpty($after1['last_fetched_at'], 'last_fetched_at wurde gesetzt');
$this->assertEquals(1234.56, (float)$after1['last_balance'], 'Kontostand wurde übernommen');
// Ein Abruf ohne ermittelten Kontostand (z.B. Fehler bei der Kontoabfrage)
// darf den zuletzt bekannten Stand nicht löschen.
BankSource::updateLastFetched($id, null);
$after2 = BankSource::find($id);
$this->assertEquals(1234.56, (float)$after2['last_balance'], 'Kontostand bleibt bei fehlendem neuen Wert erhalten');
BankSource::updateLastFetched($id, -42.10);
$after3 = BankSource::find($id);
$this->assertEquals(-42.10, (float)$after3['last_balance'], 'Negativer Kontostand (Dispo) wird korrekt übernommen');
} finally {
if ($oldKey === null) {
unset($_ENV['APP_KEY']);
} else {
$_ENV['APP_KEY'] = $oldKey;
}
Config::load();
}
}
}