Merge pull request 'Feature: "Abrufen ab"-Auswahl im Bearbeiten-Dialog der Direktverbindungen' (#84) from fds2610-bot/Steuerkram:main into main
Reviewed-on: https://codeberg.org/fds2610/Steuerkram/pulls/84
This commit is contained in:
commit
d3f3f13c79
9 changed files with 189 additions and 29 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
6
migrations/072_bank_sources_fetch_range.sql
Normal file
6
migrations/072_bank_sources_fetch_range.sql
Normal 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';
|
||||
|
|
@ -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'] ?? '');
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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 = '';
|
||||
|
|
|
|||
54
tests/39_BankFetchRangeTest.php
Normal file
54
tests/39_BankFetchRangeTest.php
Normal 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');
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue