diff --git a/Changes.md b/Changes.md index 438e5ed..6447f1b 100644 --- a/Changes.md +++ b/Changes.md @@ -2,6 +2,15 @@ --- +## 2026-05-22 – Feature: „Abrufen ab"-Auswahl für Direktverbindungen + +- Im Bearbeiten-Dialog jeder FinTS- bzw. API-Direktverbindung gibt es ein neues Dropdown **Abrufen ab** mit den Optionen *Ab letztem Abruf (00:00 Uhr)* (Default), *Letzte 7 Tage*, *Aktueller Monat (ab 01.)*, *Letzte 30 Tage*, *Ohne Datum (letzte 365 Tage)* +- Die Auswahl gilt nur für den nächsten Abruf: nach erfolgreichem Lauf setzt `BankSource::updateLastFetched()` `fetch_range` wieder auf `'last_fetched'` zurück +- Migration `072_bank_sources_fetch_range.sql` ergänzt die neue ENUM-Spalte (Default `'last_fetched'`, bestehende Verbindungen verhalten sich unverändert) +- Logik in `BankSource::computeStartDate()` zentralisiert; `FinTsFetchService` und `PayPalNvpFetchService` nutzen sie. Neuer Test `39_BankFetchRangeTest` deckt alle fünf Range-Werte plus Fallbacks ab + +--- + ## 2026-05-22 – Fix: FinTS-Konto-Auswahl wird jetzt gespeichert + Bearbeiten-Dialog mit Checkboxen - **Bug:** Beim ersten Abruf erschien das Konto-Auswahl-Modal, das Speichern blieb aber hängen. Ursache war PHP-Session-Locking: der SSE-Stream `/bank/direkt/{id}/run` hielt die Session-Datei während des `awaitAccountSelection()`-Polling-Loops gesperrt, der parallele POST `/bank/direkt/{id}/accounts` blockierte beim `session_start()` → Deadlock bis Timeout diff --git a/docs/funktionen.md b/docs/funktionen.md index a823d32..f8d79e3 100644 --- a/docs/funktionen.md +++ b/docs/funktionen.md @@ -69,6 +69,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 - **Letzter Abrufzeitpunkt** wird pro Verbindung gespeichert; nächster Abruf startet ab 00:00 dieses Tages (max. ein Tag Überlappung für Dedup) +- **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 - **Teilzuordnung**: eine Transaktion kann schrittweise mehreren Belegen anteilig zugeordnet werden diff --git a/migrations/072_bank_sources_fetch_range.sql b/migrations/072_bank_sources_fetch_range.sql new file mode 100644 index 0000000..ee851b6 --- /dev/null +++ b/migrations/072_bank_sources_fetch_range.sql @@ -0,0 +1,6 @@ +-- Neue Spalte für "Abrufen ab"-Auswahl im Direktverbindungs-Bearbeiten-Dialog. +-- Default 'last_fetched' = ab 0:00 Uhr des letzten Abrufs (bzw. 365 Tage falls noch nie). +ALTER TABLE bank_sources + ADD COLUMN IF NOT EXISTS fetch_range + ENUM('last_fetched','last_week','current_month','last_30_days','full') + NOT NULL DEFAULT 'last_fetched'; diff --git a/src/Controllers/BankController.php b/src/Controllers/BankController.php index 00b518e..f19098a 100644 --- a/src/Controllers/BankController.php +++ b/src/Controllers/BankController.php @@ -526,6 +526,11 @@ class BankController extends BaseController 'farbe' => trim($_POST['farbe'] ?? '') ?: '#6366f1', ]; + $rangePost = (string)($_POST['fetch_range'] ?? ''); + if (in_array($rangePost, BankSource::FETCH_RANGES, true)) { + $data['fetch_range'] = $rangePost; + } + if ($isApi) { $newPwd = trim($_POST['client_id'] ?? ''); $newSecret = trim($_POST['secret'] ?? ''); diff --git a/src/Models/BankSource.php b/src/Models/BankSource.php index 0d029c9..597e66a 100644 --- a/src/Models/BankSource.php +++ b/src/Models/BankSource.php @@ -87,7 +87,58 @@ class BankSource public static function updateLastFetched(int $id): void { - Database::execute('UPDATE bank_sources SET last_fetched_at = NOW() WHERE id = ?', [$id]); + // Setzt zugleich fetch_range auf den Default zurück: einmalige Sonderauswahl + // (z.B. "letzte Woche") gilt nur für genau einen Abruf. + Database::execute( + "UPDATE bank_sources + SET last_fetched_at = NOW(), + fetch_range = 'last_fetched' + WHERE id = ?", + [$id] + ); + } + + /** Erlaubte Werte für die "Abrufen ab"-Auswahl. */ + public const FETCH_RANGES = [ + 'last_fetched', 'last_week', 'current_month', 'last_30_days', 'full', + ]; + + /** + * Berechnet das Start-Datum für einen Direktverbindungs-Abruf anhand der + * Auswahl im Bearbeiten-Dialog und des letzten Abruf-Zeitstempels. + * + * Liefert ein Tupel [\DateTimeImmutable $start, string $label]. + * $label ist eine sprechende Beschreibung für das Abruf-Log. + */ + public static function computeStartDate(?string $range, ?string $lastFetchedAt, ?\DateTimeImmutable $today = null): array + { + $today = $today ?? new \DateTimeImmutable('today'); + $range = in_array($range, self::FETCH_RANGES, true) ? $range : 'last_fetched'; + + switch ($range) { + case 'last_week': + return [$today->modify('-7 days'), 'letzte 7 Tage']; + + case 'current_month': + $start = $today->modify('first day of this month'); + return [$start, 'aktueller Monat ab ' . $start->format('d.m.Y')]; + + case 'last_30_days': + return [$today->modify('-30 days'), 'letzte 30 Tage']; + + case 'full': + return [$today->modify('-365 days'), 'letzte 365 Tage (ohne Datumsbegrenzung)']; + + case 'last_fetched': + default: + if ($lastFetchedAt) { + $start = new \DateTimeImmutable( + (new \DateTimeImmutable($lastFetchedAt))->format('Y-m-d') + ); + return [$start, 'ab letztem Abruf am ' . $start->format('d.m.Y') . ' 00:00 Uhr']; + } + return [$today->modify('-365 days'), 'kein vorheriger Abruf – starte mit den letzten 365 Tagen']; + } } /** @@ -169,7 +220,7 @@ class BankSource 'col_zahlungspflichtiger', 'col_zahlungsempfaenger', 'col_verwendungszweck', 'date_format', 'decimal_separator', 'thousands_separator', 'buchungstext_template_debit', 'buchungstext_template_credit', - 'sandbox', 'last_fetched_at', + 'sandbox', 'last_fetched_at', 'fetch_range', 'app_name', 'client_id_enc', 'secret_enc', 'blz', 'fints_url', 'benutzerkennung', 'pin_enc', 'account_filter', ]; diff --git a/src/Services/FinTsFetchService.php b/src/Services/FinTsFetchService.php index e4b4b0c..19e2594 100644 --- a/src/Services/FinTsFetchService.php +++ b/src/Services/FinTsFetchService.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Steuerkram\Services; use Steuerkram\Database; +use Steuerkram\Models\BankSource; use Fhp\Action\GetSEPAAccounts; use Fhp\Action\GetStatementOfAccount; @@ -168,14 +169,12 @@ class FinTsFetchService } $end = new \DateTime('today'); - $lastFetched = $this->source['last_fetched_at'] ?? null; - if ($lastFetched) { - $start = new \DateTime((new \DateTime($lastFetched))->format('Y-m-d')); - $log('Letzter Abruf: ' . $start->format('d.m.Y') . ' – starte ab 00:00 Uhr dieses Tages.'); - } else { - $start = (new \DateTime('today'))->modify('-365 days'); - $log('Kein vorheriger Abruf – hole die letzten 365 Tage.'); - } + [$startImm, $rangeLabel] = BankSource::computeStartDate( + $this->source['fetch_range'] ?? null, + $this->source['last_fetched_at'] ?? null + ); + $start = \DateTime::createFromImmutable($startImm); + $log('Zeitraum: ' . $rangeLabel . ' – Start ' . $start->format('d.m.Y') . ' 00:00 Uhr.'); $batchId = $this->createBatch(); $log('Batch #' . $batchId . ' angelegt.'); @@ -509,10 +508,7 @@ class FinTsFetchService private function updateLastFetched(): void { - Database::execute( - 'UPDATE bank_sources SET last_fetched_at = NOW() WHERE id = ?', - [$this->sourceId] - ); + BankSource::updateLastFetched($this->sourceId); } private function externalIdExists(string $externalId): bool diff --git a/src/Services/PayPalNvpFetchService.php b/src/Services/PayPalNvpFetchService.php index ddcd140..a5a180a 100644 --- a/src/Services/PayPalNvpFetchService.php +++ b/src/Services/PayPalNvpFetchService.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Steuerkram\Services; use Steuerkram\Database; +use Steuerkram\Models\BankSource; /** * PayPal Classic NVP API – TransactionSearch @@ -64,15 +65,13 @@ class PayPalNvpFetchService $batchId = $this->createBatch(); ($this->log)('Batch #' . $batchId . ' angelegt.'); - $end = new \DateTimeImmutable('today'); - $lastFetched = $this->source['last_fetched_at'] ?? null; - if ($lastFetched) { - $start = new \DateTimeImmutable((new \DateTimeImmutable($lastFetched))->format('Y-m-d')); - ($this->log)('Letzter Abruf: ' . $start->format('d.m.Y') . ' – starte ab 00:00 Uhr dieses Tages'); - } else { - $start = $end->modify('-365 days'); - ($this->log)('Kein vorheriger Abruf – hole die letzten 365 Tage'); - } + $end = new \DateTimeImmutable('today'); + [$start, $rangeLabel] = BankSource::computeStartDate( + $this->source['fetch_range'] ?? null, + $this->source['last_fetched_at'] ?? null, + $end + ); + ($this->log)('Zeitraum: ' . $rangeLabel . ' – Start ' . $start->format('d.m.Y') . ' 00:00 Uhr'); $chunk = new \DateInterval('P30D'); $cursor = $start; @@ -275,10 +274,7 @@ class PayPalNvpFetchService private function updateLastFetched(): void { - Database::execute( - 'UPDATE bank_sources SET last_fetched_at = NOW() WHERE id = ?', - [$this->sourceId] - ); + BankSource::updateLastFetched($this->sourceId); } private function externalIdExists(string $externalId): bool diff --git a/src/Views/bank/fints.php b/src/Views/bank/fints.php index 1439a43..9b30ead 100644 --- a/src/Views/bank/fints.php +++ b/src/Views/bank/fints.php @@ -20,7 +20,16 @@ $connectionsJson = json_encode(array_map(fn($c) => [ 'fints_url' => $c['fints_url'] ?? '', 'benutzerkennung' => $c['benutzerkennung'] ?? '', 'account_filter' => $c['account_filter'] ?? null, + 'fetch_range' => $c['fetch_range'] ?? 'last_fetched', ], $connections)); + +$fetchRangeOptions = [ + 'last_fetched' => 'Ab letztem Abruf (00:00 Uhr)', + 'last_week' => 'Letzte 7 Tage', + 'current_month' => 'Aktueller Monat (ab 01.)', + 'last_30_days' => 'Letzte 30 Tage', + 'full' => 'Ohne Datum (letzte 365 Tage)', +]; ?> @@ -252,6 +261,17 @@ $connectionsJson = json_encode(array_map(fn($c) => [ +
+ + +
+ Gilt nur für den nächsten Abruf – danach wieder „Ab letztem Abruf". +
+
+
+ + +
+ Gilt nur für den nächsten Abruf – danach wieder „Ab letztem Abruf". +
+
+
@@ -431,8 +463,10 @@ $connectionsJson = json_encode(array_map(fn($c) => [ const editBenutzer = document.getElementById('edit-benutzerkennung'); const editPin = document.getElementById('edit-pin'); const editFintsUrl = document.getElementById('edit-fints-url'); - const editAccountList = document.getElementById('edit-account-list'); - const editResetAcc = document.getElementById('edit-reset-accounts'); + const editAccountList = document.getElementById('edit-account-list'); + const editResetAcc = document.getElementById('edit-reset-accounts'); + const editFetchRangeApi = document.getElementById('edit-fetch-range-api'); + const editFetchRangeFints= document.getElementById('edit-fetch-range-fints'); function closeEdit() { modalEdit.style.display = 'none'; } @@ -790,6 +824,14 @@ $connectionsJson = json_encode(array_map(fn($c) => [ editApiFields.style.display = isApi ? 'block' : 'none'; editFintsFields.style.display = isFints ? 'block' : 'none'; + // fetch_range steht in beiden Type-Blöcken; nur der zum aktiven Typ + // gehörige Select wird enabled (disabled Felder werden nicht submitted). + const fetchVal = conn.fetch_range || 'last_fetched'; + editFetchRangeApi.value = fetchVal; + editFetchRangeFints.value = fetchVal; + editFetchRangeApi.disabled = !isApi; + editFetchRangeFints.disabled= !isFints; + if (isApi) { editAppName.value = conn.app_name || ''; editClientId.value = ''; diff --git a/tests/39_BankFetchRangeTest.php b/tests/39_BankFetchRangeTest.php new file mode 100644 index 0000000..780649b --- /dev/null +++ b/tests/39_BankFetchRangeTest.php @@ -0,0 +1,54 @@ +assertEquals('2026-05-15', $start->format('Y-m-d'), 'last_fetched nutzt Datum des letzten Abrufs'); + $this->assert(str_contains($label, '15.05.2026'), 'Label enthält Datum des letzten Abrufs'); + + // 2. last_fetched ohne vorherigen Abruf → Fallback 365 Tage + [$start] = BankSource::computeStartDate('last_fetched', null, $today); + $this->assertEquals('2025-05-22', $start->format('Y-m-d'), 'Ohne last_fetched: 365 Tage zurück'); + + // 3. last_week → 7 Tage zurück + [$start, $label] = BankSource::computeStartDate('last_week', '2026-05-20 00:00:00', $today); + $this->assertEquals('2026-05-15', $start->format('Y-m-d'), 'last_week = today - 7d'); + $this->assert(str_contains($label, '7 Tage'), 'Label nennt 7 Tage'); + + // 4. current_month → 01. des aktuellen Monats + [$start] = BankSource::computeStartDate('current_month', null, $today); + $this->assertEquals('2026-05-01', $start->format('Y-m-d'), 'current_month = erster Tag des Monats'); + + // 5. last_30_days → 30 Tage zurück + [$start] = BankSource::computeStartDate('last_30_days', null, $today); + $this->assertEquals('2026-04-22', $start->format('Y-m-d'), 'last_30_days = today - 30d'); + + // 6. full → 365 Tage zurück (überschreibt last_fetched_at) + [$start, $label] = BankSource::computeStartDate('full', '2026-05-20 00:00:00', $today); + $this->assertEquals('2025-05-22', $start->format('Y-m-d'), 'full ignoriert last_fetched'); + $this->assert(str_contains($label, '365'), 'Label nennt 365 Tage'); + + // 7. Unbekannter Wert fällt auf last_fetched zurück (Validierung im Modell selbst) + [$start] = BankSource::computeStartDate('quark', '2026-05-10 00:00:00', $today); + $this->assertEquals('2026-05-10', $start->format('Y-m-d'), 'Unbekanntes Range → last_fetched'); + + // 8. null Range → last_fetched + [$start] = BankSource::computeStartDate(null, '2026-05-10 00:00:00', $today); + $this->assertEquals('2026-05-10', $start->format('Y-m-d'), 'null Range → last_fetched'); + } +}