Merge pull request 'Feature: Kontostand nach Direktabruf auf Bank-Verbindungskachel' (#134) from fds2610-bot/Steuerkram:main into main
Reviewed-on: https://codeberg.org/fds2610/Steuerkram/pulls/134
This commit is contained in:
commit
b6cea7caff
12 changed files with 187 additions and 26 deletions
17
Changes.md
17
Changes.md
|
|
@ -2,6 +2,23 @@
|
|||
|
||||
---
|
||||
|
||||
## 2026-07-21 – Fix: Zwei dauerhaft fehlschlagende Tests (Session-Cookie-Härtung, Nextcloud-SSRF)
|
||||
|
||||
- **Test 54 (Session-Cookie-Härtung):** Der Test rief echtes `Auth::startSession()` auf. Da `tests/run.php` Ergebnisse laufend per `echo` ausgibt und Test 54 der einzige Test ist, der Sessions anfasst, war zum Testzeitpunkt bereits Output gesendet – PHP lehnt `session_set_cookie_params()`/`session_start()` danach mit „headers already sent" ab (Warnung + wirkungslos). Ein nachträglicher Output-Buffer im Test half nicht, da bereits vor dem Test unbuffered Output geflossen war.
|
||||
- **`src/Auth.php`:** Neue reine Methode `Auth::cookieParams()` berechnet die gehärteten Cookie-Attribute (HttpOnly, SameSite, Secure inkl. Override/Proxy-Erkennung) ohne Seiteneffekt; `startSession()` nutzt sie jetzt intern. Verhalten zur Laufzeit unverändert.
|
||||
- **`tests/54_SessionCookieHardeningTest.php`:** Prüft jetzt `Auth::cookieParams()` direkt statt eine echte Session zu starten – unabhängig vom bereits erfolgten Output der Test-Suite.
|
||||
- **Test 56 (Nextcloud-SSRF):** `SsrfGuard::assertPublicHttpsUrl()` löst den Hostnamen bewusst per echtem DNS auf (Schutz vor DNS-Rebinding). Der Test nutzte `https://cloud.example.com` als „gültiges" Beispiel – diese Subdomain ist nicht registriert (NXDOMAIN), wurde also fälschlich als ungültig abgelehnt.
|
||||
- **`tests/56_NextcloudSsrfTest.php`:** Nutzt stattdessen die laut IANA dauerhaft real auflösbare Apex-Domain `https://example.com`.
|
||||
|
||||
## 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`:** Fragt pro Konto zusätzlich per dedizierter `GetBalance`-Aktion (HKSAL) den aktuellen gebuchten Saldo ab und summiert diesen. Fällt nur auf den Schlusssaldo des letzten Statements zurück, wenn die Saldenabfrage fehlschlägt – der ursprüngliche Ansatz (Saldo nur aus dem Statement ableiten) lieferte keinen Wert, wenn die Bank mangels neuer Buchungen kein MT940/CAMT-Statement zurückgab (häufigster Fall bei täglichen Abrufen ohne neue Umsätze).
|
||||
- **`src/Services/PayPalNvpFetchService.php`:** Der bereits vorhandene `GetBalance`-Aufruf in `testCredentials()` liefert den aktuellen Kontostand, der nun mitgespeichert und im Abruf-Log ausgegeben wird. Live-Konten liefern dabei offenbar durchgängig das Listenformat `L_AMT0`/`L_CURRENCYCODE0` statt der laut NVP-Doku primären Felder `AMT`/`CURRENCYCODE` – beide Formate werden jetzt ausgewertet.
|
||||
- **`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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
2
migrations/087_bank_sources_last_balance.sql
Normal file
2
migrations/087_bank_sources_last_balance.sql
Normal 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;
|
||||
18
src/Auth.php
18
src/Auth.php
|
|
@ -21,6 +21,18 @@ class Auth
|
|||
return;
|
||||
}
|
||||
|
||||
session_set_cookie_params(self::cookieParams());
|
||||
session_start();
|
||||
}
|
||||
|
||||
/**
|
||||
* Berechnet die gehärteten Cookie-Attribute (HttpOnly, SameSite, Secure) anhand
|
||||
* des aktuellen Requests bzw. des SESSION_COOKIE_SECURE-Overrides. Bewusst als
|
||||
* reine Funktion ohne Seiteneffekt von startSession() getrennt, damit sie sich
|
||||
* isoliert testen lässt, ohne eine echte PHP-Session zu starten.
|
||||
*/
|
||||
public static function cookieParams(): array
|
||||
{
|
||||
$override = getenv('SESSION_COOKIE_SECURE');
|
||||
if ($override === '1' || $override === '0') {
|
||||
$secure = $override === '1';
|
||||
|
|
@ -29,16 +41,14 @@ class Auth
|
|||
|| ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https';
|
||||
}
|
||||
|
||||
session_set_cookie_params([
|
||||
return [
|
||||
'lifetime' => 0,
|
||||
'path' => '/',
|
||||
'domain' => '',
|
||||
'secure' => $secure,
|
||||
'httponly' => true,
|
||||
'samesite' => 'Lax',
|
||||
]);
|
||||
|
||||
session_start();
|
||||
];
|
||||
}
|
||||
|
||||
public static function require(): void
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ namespace Steuerkram\Services;
|
|||
use Steuerkram\Database;
|
||||
use Steuerkram\Models\BankSource;
|
||||
|
||||
use Fhp\Action\GetBalance;
|
||||
use Fhp\Action\GetSEPAAccounts;
|
||||
use Fhp\Action\GetStatementOfAccount;
|
||||
use Fhp\BaseAction;
|
||||
|
|
@ -205,6 +206,7 @@ class FinTsFetchService
|
|||
|
||||
$totalNew = 0;
|
||||
$totalSkip = 0;
|
||||
$balance = null;
|
||||
|
||||
foreach ($accounts as $account) {
|
||||
$iban = $account->getIban();
|
||||
|
|
@ -224,6 +226,35 @@ class FinTsFetchService
|
|||
continue;
|
||||
}
|
||||
|
||||
$accountBalance = null;
|
||||
try {
|
||||
$balAction = GetBalance::create($account);
|
||||
$fints->execute($balAction);
|
||||
if ($balAction->needsTan()) {
|
||||
if (!$this->handleStrongAuthentication($balAction, $iban . ' (Saldo)')) {
|
||||
throw new \RuntimeException('TAN für Saldenabfrage nicht bestätigt.');
|
||||
}
|
||||
}
|
||||
foreach ($balAction->getBalances() as $hisal) {
|
||||
$accountBalance = ($accountBalance ?? 0.0) + $hisal->getGebuchterSaldo()->getAmount();
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$log(' → Saldenabfrage fehlgeschlagen: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
// Fallback: Saldo aus dem letzten Statement ableiten, falls die dedizierte
|
||||
// Saldenabfrage (HKSAL) fehlschlägt oder von der Bank nicht unterstützt wird.
|
||||
$statements = $soa->getStatements();
|
||||
if ($accountBalance === null && !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 +307,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 +564,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
|
||||
|
|
|
|||
|
|
@ -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,23 @@ class PayPalNvpFetchService
|
|||
}
|
||||
return false;
|
||||
}
|
||||
// GetBalance liefert je nach Konto/Live-Verhalten entweder das flache Feld AMT/CURRENCYCODE
|
||||
// (Doku-Standardfall) oder – wie auf Live-Konten offenbar üblich – die Liste L_AMT0/L_CURRENCYCODE0
|
||||
// (identisch zum RETURNALLCURRENCIES=1-Format). Primärwährung ist Index 0.
|
||||
$currency = null;
|
||||
if (isset($result['AMT']) && is_numeric($result['AMT'])) {
|
||||
$this->lastBalance = (float)$result['AMT'];
|
||||
$currency = $result['CURRENCYCODE'] ?? null;
|
||||
} elseif (isset($result['L_AMT0']) && is_numeric($result['L_AMT0'])) {
|
||||
$this->lastBalance = (float)$result['L_AMT0'];
|
||||
$currency = $result['L_CURRENCYCODE0'] ?? null;
|
||||
}
|
||||
|
||||
if ($this->lastBalance !== null) {
|
||||
($this->log)('Kontostand: ' . number_format($this->lastBalance, 2, ',', '.') . ' ' . ($currency ?? 'EUR'));
|
||||
} else {
|
||||
($this->log)('Kontostand: nicht in GetBalance-Antwort enthalten (Felder: ' . implode(', ', array_keys($result)) . ')');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -422,7 +440,7 @@ class PayPalNvpFetchService
|
|||
|
||||
private function updateLastFetched(): void
|
||||
{
|
||||
BankSource::updateLastFetched($this->sourceId);
|
||||
BankSource::updateLastFetched($this->sourceId, $this->lastBalance);
|
||||
}
|
||||
|
||||
private function externalIdExists(string $externalId): bool
|
||||
|
|
|
|||
|
|
@ -85,9 +85,18 @@ $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 class="text-muted text-sm" style="display:grid;grid-template-columns:1fr auto;column-gap:.5rem;row-gap:.1rem;align-items:baseline;margin-top:.6rem">
|
||||
<div>Abruf:</div>
|
||||
<div style="text-align:right">Kontostand</div>
|
||||
<div><?= $h(View::formatDate($conn['last_fetched_at'])) ?></div>
|
||||
<div style="text-align:right"><?= $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"
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
1-260703
|
||||
1-260721
|
||||
|
|
|
|||
|
|
@ -10,43 +10,39 @@ class SessionCookieHardeningTest extends TestCase
|
|||
|
||||
public function run(): void
|
||||
{
|
||||
$httpsBackup = $_SERVER['HTTPS'] ?? null;
|
||||
$forwardBackup = $_SERVER['HTTP_X_FORWARDED_PROTO'] ?? null;
|
||||
$httpsBackup = $_SERVER['HTTPS'] ?? null;
|
||||
$forwardBackup = $_SERVER['HTTP_X_FORWARDED_PROTO'] ?? null;
|
||||
unset($_SERVER['HTTPS'], $_SERVER['HTTP_X_FORWARDED_PROTO']);
|
||||
putenv('SESSION_COOKIE_SECURE');
|
||||
|
||||
// Auth::cookieParams() berechnet die Cookie-Attribute ohne Seiteneffekt (kein echter
|
||||
// session_start()). Ein echter Sessionstart würde hier scheitern, da der Test-Runner
|
||||
// bereits Ergebnisse per echo ausgibt – PHP behandelt Header/Session-Cookies danach
|
||||
// als "bereits gesendet". cookieParams() prüft dieselbe Logik ohne dieses Problem.
|
||||
try {
|
||||
// Plain HTTP ohne Override: httponly/samesite immer gesetzt, secure aus
|
||||
Auth::startSession();
|
||||
$params = session_get_cookie_params();
|
||||
$params = Auth::cookieParams();
|
||||
$this->assert($params['httponly'] === true, 'HttpOnly-Flag ist gesetzt');
|
||||
$this->assertEquals('Lax', $params['samesite'], 'SameSite steht auf Lax');
|
||||
$this->assert($params['secure'] === false, 'Secure ist bei Plain-HTTP ohne Override deaktiviert');
|
||||
session_write_close();
|
||||
|
||||
// Erzwungenes Secure per Env, unabhängig vom Request
|
||||
putenv('SESSION_COOKIE_SECURE=1');
|
||||
Auth::startSession();
|
||||
$params = session_get_cookie_params();
|
||||
$params = Auth::cookieParams();
|
||||
$this->assert($params['secure'] === true, 'SESSION_COOKIE_SECURE=1 erzwingt Secure-Flag');
|
||||
session_write_close();
|
||||
|
||||
// Override hat Vorrang vor erkanntem HTTPS-Request
|
||||
putenv('SESSION_COOKIE_SECURE=0');
|
||||
$_SERVER['HTTPS'] = 'on';
|
||||
Auth::startSession();
|
||||
$params = session_get_cookie_params();
|
||||
$params = Auth::cookieParams();
|
||||
$this->assert($params['secure'] === false, 'SESSION_COOKIE_SECURE=0 deaktiviert Secure trotz HTTPS-Request');
|
||||
session_write_close();
|
||||
|
||||
// Ohne Override: Reverse-Proxy-Header aktiviert Secure automatisch
|
||||
putenv('SESSION_COOKIE_SECURE');
|
||||
unset($_SERVER['HTTPS']);
|
||||
$_SERVER['HTTP_X_FORWARDED_PROTO'] = 'https';
|
||||
Auth::startSession();
|
||||
$params = session_get_cookie_params();
|
||||
$params = Auth::cookieParams();
|
||||
$this->assert($params['secure'] === true, 'X-Forwarded-Proto: https aktiviert Secure automatisch');
|
||||
session_write_close();
|
||||
} finally {
|
||||
putenv('SESSION_COOKIE_SECURE');
|
||||
if ($httpsBackup === null) {
|
||||
|
|
|
|||
|
|
@ -25,13 +25,16 @@ class NextcloudSsrfTest extends TestCase
|
|||
$this->assertRejected('https://localhost', 'localhost wird abgelehnt');
|
||||
|
||||
// Gültiges Ziel: baseUrl bleibt gesetzt, kein configError, davBase korrekt gebildet.
|
||||
$this->configure('https://cloud.example.com', 'importuser', 'geheim');
|
||||
// Hinweis: SsrfGuard löst den Hostnamen per echtem DNS auf (Schutz vor DNS-Rebinding) –
|
||||
// "cloud.example.com" ist keine registrierte Subdomain und liefert NXDOMAIN. Die
|
||||
// Apex-Domain "example.com" ist dagegen laut IANA dauerhaft real auflösbar.
|
||||
$this->configure('https://example.com', 'importuser', 'geheim');
|
||||
$svc = new NextcloudImportService();
|
||||
$baseUrl = $this->readProperty($svc, 'baseUrl');
|
||||
$davBase = $this->readProperty($svc, 'davBase');
|
||||
$cfgError = $this->readProperty($svc, 'configError');
|
||||
|
||||
$this->assertEquals('https://cloud.example.com', $baseUrl, 'Gültige https-URL bleibt als baseUrl erhalten');
|
||||
$this->assertEquals('https://example.com', $baseUrl, 'Gültige https-URL bleibt als baseUrl erhalten');
|
||||
$this->assert($cfgError === null, 'Kein configError bei gültiger URL');
|
||||
$this->assert(
|
||||
str_contains((string)$davBase, '/remote.php/dav/files/importuser'),
|
||||
|
|
|
|||
61
tests/57_BankSourceLastBalanceTest.php
Normal file
61
tests/57_BankSourceLastBalanceTest.php
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue