Feature: "Abrufen ab"-Auswahl im Bearbeiten-Dialog der Direktverbindungen

This commit is contained in:
fds2610-bot 2026-05-22 13:02:32 +02:00
parent 0dc1b6c3a7
commit 6ba281cfc1
9 changed files with 189 additions and 29 deletions

View file

@ -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

View file

@ -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

View file

@ -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';

View file

@ -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'] ?? '');

View file

@ -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',
];

View file

@ -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

View file

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Steuerkram\Services;
use Steuerkram\Database;
use Steuerkram\Models\BankSource;
/**
* PayPal Classic NVP API – TransactionSearch
@ -65,14 +66,12 @@ class PayPalNvpFetchService
($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');
}
[$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

View file

@ -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)',
];
?>
<?php if (empty($connections)): ?>
@ -252,6 +261,17 @@ $connectionsJson = json_encode(array_map(fn($c) => [
<input type="checkbox" id="edit-sandbox" name="sandbox" value="1" style="width:auto;margin:0">
<label for="edit-sandbox" style="margin:0;font-weight:normal;cursor:pointer">Sandbox-Modus (Testumgebung)</label>
</div>
<div class="form-group">
<label for="edit-fetch-range-api">Abrufen ab</label>
<select id="edit-fetch-range-api" name="fetch_range" disabled>
<?php foreach ($fetchRangeOptions as $val => $label): ?>
<option value="<?= $h($val) ?>"><?= $h($label) ?></option>
<?php endforeach; ?>
</select>
<div class="text-muted text-sm">
Gilt nur für den nächsten Abruf – danach wieder „Ab letztem Abruf".
</div>
</div>
</div>
<div id="edit-fints-fields" style="display:none">
@ -272,6 +292,18 @@ $connectionsJson = json_encode(array_map(fn($c) => [
<label for="edit-fints-url">FinTS-Server-URL <span class="text-muted text-sm">(optional)</span></label>
<input type="url" id="edit-fints-url" name="fints_url" placeholder="https://...">
</div>
<div class="form-group">
<label for="edit-fetch-range-fints">Abrufen ab</label>
<select id="edit-fetch-range-fints" name="fetch_range" disabled>
<?php foreach ($fetchRangeOptions as $val => $label): ?>
<option value="<?= $h($val) ?>"><?= $h($label) ?></option>
<?php endforeach; ?>
</select>
<div class="text-muted text-sm">
Gilt nur für den nächsten Abruf – danach wieder „Ab letztem Abruf".
</div>
</div>
<div class="form-group">
<label>Aktive Konten</label>
<div id="edit-account-list" style="display:flex;flex-direction:column;gap:.3rem;padding:.2rem 0"></div>
@ -433,6 +465,8 @@ $connectionsJson = json_encode(array_map(fn($c) => [
const editFintsUrl = document.getElementById('edit-fints-url');
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 = '';

View file

@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
use Steuerkram\Models\BankSource;
/**
* Test 39: BankSource::computeStartDate – "Abrufen ab"-Auswahl im Direktverbindungs-Dialog.
*/
class BankFetchRangeTest extends TestCase
{
protected string $testName = 'Test 39: BankSource::computeStartDate – Start-Datum aus fetch_range';
public function run(): void
{
// Fester Bezugstag, damit der Test reproduzierbar bleibt.
$today = new \DateTimeImmutable('2026-05-22');
// 1. last_fetched mit vorhandenem Zeitstempel → Tag des letzten Abrufs 00:00 Uhr
[$start, $label] = BankSource::computeStartDate('last_fetched', '2026-05-15 13:42:01', $today);
$this->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');
}
}