Stichtag für den Umsatzabruf

Der Abrufzeitraum kannte bisher nur Spannen. Der Stichtag steht jetzt an zwei
Stellen: als Vorgabe der Bankverbindung (neuer Abrufzeitraum "Ab Stichtag" mit
Datum in bank_zugaenge.abruf_stichtag) und als einmalige Angabe neben "Umsätze
abrufen", die der Vorgabe vorgeht und nichts speichert.

Der einmalige Stichtag liegt im Abrufzustand, nicht in der Datenbank: Nach einer
TAN-Runde läuft der Abruf im nächsten Request weiter und muss dort denselben
Zeitraum holen wie beim Start.

"Ab Stichtag" ohne Datum und unlesbare Datumsangaben werden abgewiesen; ein
Stichtag in der Zukunft wird auf heute gesetzt, statt den Abruf leer ausgehen zu
lassen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
fds2610-bot 2026-08-13 10:41:05 +02:00
parent 423ce4122d
commit 005df48fed
12 changed files with 376 additions and 20 deletions

View file

@ -1,5 +1,34 @@
# Änderungen # Änderungen
## Stichtag für den Umsatzabruf (13.08.2026)
Der Abrufzeitraum kannte bisher nur Spannen: seit dem letzten Abruf, 7, 30 oder 365 Tage.
Wer einen Bestand ab einem festen Datum aufbauen wollte – etwa ab dem Wechsel vom Lexware
FinanzManager –, hatte dafür keine Angabe. Der Stichtag steht jetzt an zwei Stellen.
**Vorgabe der Bankverbindung** (`Models\Bankzugang`, `Views/banking/formular.php`,
`migrations/011_abruf_stichtag.sql`)
- Neuer Abrufzeitraum *Ab Stichtag* und das dazugehörige Datumsfeld im Zugangsformular
(neue Spalte `bank_zugaenge.abruf_stichtag`). Gewählt, wird jeder Abruf ab diesem Tag
geholt.
- *Ab Stichtag* ohne Datum wird abgewiesen – eine Vorgabe, die nichts vorgibt, führte beim
Abruf nur zu einem anderen Zeitraum als gedacht. Ein unlesbares Datum ebenso.
- Die Übersicht der Bankverbindungen zeigt den Stichtag neben dem Zeitraum.
**Stichtag für einen einzelnen Abruf** (`Services\FinTsAbruf`, `BankingController`,
`Views/banking/index.php`)
- Neben *Umsätze abrufen* steht ein Datumsfeld. Ausgefüllt geht es dem eingestellten
Abrufzeitraum vor und gilt **nur für diesen Lauf**: Wer einmalig weiter zurückgreifen
will, muss die Einstellung des Zugangs nicht ändern und hinterher zurückstellen.
- Der Stichtag liegt im Abrufzustand, nicht in der Datenbank – nach einer TAN-Runde läuft
der Abruf im nächsten Request weiter und holt dort denselben Zeitraum wie beim Start.
- Ein Stichtag in der Zukunft wird auf heute gesetzt, statt den Abruf leer ausgehen zu
lassen; das Protokoll vermerkt es.
Angenommen werden das Format des Datumsfelds (`2026-01-01`) und die deutsche Schreibweise
(`01.01.2026`); alles andere gilt als Fehleingabe. Wie weit die Bank zurückreicht,
entscheidet sie selbst – 90 Tage sind üblich, mehr liefert nicht jede.
## Termine und Verträge wandern unter die Verwaltung (13.08.2026) ## Termine und Verträge wandern unter die Verwaltung (13.08.2026)
Die Seitenleiste war lang geworden. *Termine* und *Verträge* sind kein täglicher Einstieg – Die Seitenleiste war lang geworden. *Termine* und *Verträge* sind kein täglicher Einstieg –

View file

@ -24,8 +24,9 @@ Haushalt wird beides parallel genutzt.
Monat, Ausgaben nach Kategorie, Kontostände, offene Vorgänge. Monat, Ausgaben nach Kategorie, Kontostände, offene Vorgänge.
* **Online-Banking** – FinTS-Abruf mit TAN-Dialog über mehrere Requests, Einrichtung in drei * **Online-Banking** – FinTS-Abruf mit TAN-Dialog über mehrere Requests, Einrichtung in drei
Schritten (Zugangsdaten, TAN-Verfahren, Kontenzuordnung mit Anlegen fehlender Konten), Schritten (Zugangsdaten, TAN-Verfahren, Kontenzuordnung mit Anlegen fehlender Konten),
abgerufene Umsätze buchen sich auf Wunsch selbst, Abgleich gegen vorhandene Buchungen, Abrufzeitraum als Spanne oder Stichtag – auch für einen einzelnen Abruf –, abgerufene
Regeln für die automatische Kategorisierung. Umsätze buchen sich auf Wunsch selbst, Abgleich gegen vorhandene Buchungen, Regeln für
die automatische Kategorisierung.
* **Wertpapiere** – Depotbestände mit Einstand und Bewertung, Depotbuchungen, Kursabruf, * **Wertpapiere** – Depotbestände mit Einstand und Bewertung, Depotbuchungen, Kursabruf,
Veräußerungsgeschäfte und Erträge; Bestandslisten der Bank lassen sich als CSV Veräußerungsgeschäfte und Erträge; Bestandslisten der Bank lassen sich als CSV
übernehmen. übernehmen.

View file

@ -50,6 +50,41 @@ Platz, und ein Request, der fünf Minuten auf eine TAN wartet, blockiert einen W
Was in der Sitzung liegt, enthält keine PIN – die Bibliothek schließt sie beim Persistieren Was in der Sitzung liegt, enthält keine PIN – die Bibliothek schließt sie beim Persistieren
ausdrücklich aus. Sie wird bei jedem Request neu aus der Datenbank geholt und entschlüsselt. ausdrücklich aus. Sie wird bei jedem Request neu aus der Datenbank geholt und entschlüsselt.
### Wie weit zurück: Zeitraum und Stichtag
Ab wann abgerufen wird, bestimmt `Bankzugang::abrufBeginn()`. Zwei Angaben spielen hinein:
* Der **Abrufzeitraum** der Bankverbindung (`bank_zugaenge.abrufzeitraum`): seit dem letzten
Abruf, letzte 7/30/365 Tage – oder *Ab Stichtag* mit dem Datum aus
`bank_zugaenge.abruf_stichtag`.
* Ein **Stichtag für diesen einen Abruf**, angegeben im Datumsfeld neben *Umsätze abrufen*.
Er geht der Vorgabe vor und wird **nirgends gespeichert**.
Beides nebeneinander, weil beides gebraucht wird: Die Vorgabe beantwortet den Alltag („immer
seit dem letzten Abruf“), der einmalige Stichtag den Sonderfall – einen Bestand ab einem
festen Datum aufbauen, eine Lücke nachholen. Ohne ihn müsste man die Einstellung des Zugangs
ändern und hinterher zurückstellen, mit dem üblichen Ausgang, dass man das Zurückstellen
vergisst.
Der einmalige Stichtag liegt im **Abrufzustand**, nicht in der Datenbank: Nach einer
TAN-Runde läuft der Abruf im nächsten Request weiter und muss dort denselben Zeitraum holen
wie beim Start.
„Seit dem letzten Abruf“ nimmt den **Tag** des letzten Laufs, nicht die Uhrzeit: Banken
buchen nachträglich, und ein Umsatz, der schon da war, fällt ohnehin über seinen
Fingerabdruck heraus. Beim allerersten Abruf gilt ein Jahr zurück – sonst käme entweder
nichts oder alles.
Ein Stichtag **in der Zukunft** wird auf heute gesetzt statt abgewiesen: Aus der Zukunft
liefert keine Bank etwas, und ein verrutschtes Datum soll den Abruf nicht ergebnislos machen
– das Protokoll vermerkt die Korrektur. *Ab Stichtag* **ohne** Datum lässt sich dagegen gar
nicht erst speichern; eine Vorgabe, die nichts vorgibt, führte beim Abruf nur zu einem
anderen Zeitraum als gedacht.
Wie weit die Bank tatsächlich zurückreicht, entscheidet sie selbst – 90 Tage sind üblich,
mehr liefert nicht jede. Ein früherer Stichtag holt deshalb nicht zwingend mehr; die Lücke
davor schließt der Anfangssaldo des Kontos oder der QIF-Import.
### TAN-Verfahren ### TAN-Verfahren
* **Klassisch** (chipTAN, photoTAN): Die Maske zeigt den Hinweistext der Bank und, wenn * **Klassisch** (chipTAN, photoTAN): Die Maske zeigt den Hinweistext der Bank und, wenn
@ -244,7 +279,9 @@ der Bibliothek an das eigene Log hängt. PIN und TAN maskieren beide Seiten.
`tests/16_BankingTest.php` prüft alles um den Dialog herum: Verschlüsselung (Kennung, kein `tests/16_BankingTest.php` prüft alles um den Dialog herum: Verschlüsselung (Kennung, kein
Klartext, wechselnder Geheimtext, kein Verschachteln), PIN bleibt beim Speichern erhalten, Klartext, wechselnder Geheimtext, kein Verschachteln), PIN bleibt beim Speichern erhalten,
Abrufzeiträume, Dublettenerkennung, Regeln nach Priorität und Feld, Splitschema mit Abrufzeiträume samt Stichtag (gespeicherte Vorgabe, einmalige Angabe geht ihr vor und lässt
die Vorgabe stehen, Zukunft wird auf heute gesetzt, unlesbare Datumsangaben werden
abgewiesen), Dublettenerkennung, Regeln nach Priorität und Feld, Splitschema mit
Restausgleich, Abweisung ungültiger Ausdrücke, Abgleich gegen vorhandene Buchungen samt Restausgleich, Abweisung ungültiger Ausdrücke, Abgleich gegen vorhandene Buchungen samt
Toleranz sowie Übernehmen, Verwerfen und Zurücksetzen. Toleranz sowie Übernehmen, Verwerfen und Zurücksetzen.

View file

@ -0,0 +1,10 @@
-- Stichtag für den Umsatzabruf.
--
-- Bisher konnte der Abrufzeitraum nur relativ gewählt werden (letzter Abruf,
-- 7/30/365 Tage). Wer einen Bestand ab einem festen Datum aufbauen will – etwa
-- ab dem Wechsel vom Lexware FinanzManager –, braucht ein Datum statt einer
-- Spanne. Der Wert ist die Vorgabe des Zugangs; beim einzelnen Abruf lässt sich
-- ein abweichender Stichtag angeben, der nicht gespeichert wird.
ALTER TABLE bank_zugaenge
ADD COLUMN IF NOT EXISTS abruf_stichtag DATE NULL AFTER abrufzeitraum;

View file

@ -219,6 +219,32 @@ code {
form.inline { display: inline; margin: 0; } form.inline { display: inline; margin: 0; }
/* Der Abrufknopf mit dem Stichtagsfeld davor: Beide gehören zusammen und sollen
auf einer Zeile bleiben – auch in der rechtsbündigen Aktionsspalte. */
.abrufform {
display: inline-flex;
align-items: center;
gap: .4rem;
}
.stichtagwahl {
display: inline-flex;
align-items: center;
gap: .35rem;
font-size: .88rem;
color: var(--text-gedaempft);
}
.stichtagfeld {
padding: .25rem .4rem;
border: 1px solid var(--rand);
border-radius: 4px;
background: var(--flaeche);
color: var(--text);
font: inherit;
font-size: .85rem;
}
.inhalt { .inhalt {
padding: 1.25rem 1.5rem 3rem; padding: 1.25rem 1.5rem 3rem;
flex: 1; flex: 1;

View file

@ -363,7 +363,16 @@ class BankingController extends BaseController
// ── Abruf ──────────────────────────────────────────────────────────────── // ── Abruf ────────────────────────────────────────────────────────────────
/** @param array<string,string> $params */ /**
* Umsätze abrufen.
*
* Das Stichtagsfeld neben dem Knopf gilt **nur für diesen Lauf**: Wer
* einmalig weiter zurückgreifen will, soll dafür nicht die Einstellung des
* Zugangs ändern und hinterher zurückstellen müssen. Bleibt es leer, gilt
* der Abrufzeitraum der Bankverbindung.
*
* @param array<string,string> $params
*/
public function abrufen(array $params): void public function abrufen(array $params): void
{ {
$this->verlangeAnmeldung(); $this->verlangeAnmeldung();
@ -373,7 +382,7 @@ class BankingController extends BaseController
// als schon vorhanden, und sie blieben für immer unsichtbar. // als schon vorhanden, und sie blieben für immer unsichtbar.
Bankumsatz::verwaisteOeffnen(); Bankumsatz::verwaisteOeffnen();
$ergebnis = FinTsAbruf::starten((int)$params['id']); $ergebnis = FinTsAbruf::starten((int)$params['id'], 'voll', false, $this->post('stichtag'));
$this->zeigeAbruf($ergebnis, (int)$params['id']); $this->zeigeAbruf($ergebnis, (int)$params['id']);
} }
@ -658,6 +667,7 @@ class BankingController extends BaseController
'benutzerkennung' => $this->post('benutzerkennung'), 'benutzerkennung' => $this->post('benutzerkennung'),
'pin' => $this->post('pin'), 'pin' => $this->post('pin'),
'abrufzeitraum' => $this->post('abrufzeitraum'), 'abrufzeitraum' => $this->post('abrufzeitraum'),
'abruf_stichtag' => $this->post('abruf_stichtag'),
'aktiv' => isset($_POST['aktiv']) ? 1 : 0, 'aktiv' => isset($_POST['aktiv']) ? 1 : 0,
]; ];
} }

View file

@ -24,6 +24,7 @@ class Bankzugang
'sieben_tage' => 'Letzte 7 Tage', 'sieben_tage' => 'Letzte 7 Tage',
'dreissig_tage' => 'Letzte 30 Tage', 'dreissig_tage' => 'Letzte 30 Tage',
'jahr' => 'Letzte 365 Tage', 'jahr' => 'Letzte 365 Tage',
'stichtag' => 'Ab Stichtag',
]; ];
/** @return list<array<string,mixed>> */ /** @return list<array<string,mixed>> */
@ -74,13 +75,14 @@ class Bankzugang
Database::execute( Database::execute(
'INSERT INTO bank_zugaenge 'INSERT INTO bank_zugaenge
(benutzer_id, name, bankname, blz, fints_url, benutzerkennung, pin, (benutzer_id, name, bankname, blz, fints_url, benutzerkennung, pin,
tan_verfahren_id, tan_verfahren_name, tan_medium, abrufzeitraum, aktiv) tan_verfahren_id, tan_verfahren_name, tan_medium, abrufzeitraum,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', abruf_stichtag, aktiv)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[ [
Mandant::id(), $felder['name'], $felder['bankname'], $felder['blz'], Mandant::id(), $felder['name'], $felder['bankname'], $felder['blz'],
$felder['fints_url'], $felder['benutzerkennung'], $felder['pin'], $felder['fints_url'], $felder['benutzerkennung'], $felder['pin'],
$felder['tan_verfahren_id'], $felder['tan_verfahren_name'], $felder['tan_medium'], $felder['tan_verfahren_id'], $felder['tan_verfahren_name'], $felder['tan_medium'],
$felder['abrufzeitraum'], $felder['aktiv'], $felder['abrufzeitraum'], $felder['abruf_stichtag'], $felder['aktiv'],
] ]
); );
@ -117,13 +119,13 @@ class Bankzugang
Database::execute( Database::execute(
'UPDATE bank_zugaenge SET name = ?, bankname = ?, blz = ?, fints_url = ?, 'UPDATE bank_zugaenge SET name = ?, bankname = ?, blz = ?, fints_url = ?,
benutzerkennung = ?, pin = ?, tan_verfahren_id = ?, tan_verfahren_name = ?, benutzerkennung = ?, pin = ?, tan_verfahren_id = ?, tan_verfahren_name = ?,
tan_medium = ?, abrufzeitraum = ?, aktiv = ? tan_medium = ?, abrufzeitraum = ?, abruf_stichtag = ?, aktiv = ?
WHERE id = ? AND benutzer_id = ?', WHERE id = ? AND benutzer_id = ?',
[ [
$felder['name'], $felder['bankname'], $felder['blz'], $felder['fints_url'], $felder['name'], $felder['bankname'], $felder['blz'], $felder['fints_url'],
$felder['benutzerkennung'], $felder['pin'], $felder['tan_verfahren_id'], $felder['benutzerkennung'], $felder['pin'], $felder['tan_verfahren_id'],
$felder['tan_verfahren_name'], $felder['tan_medium'], $felder['tan_verfahren_name'], $felder['tan_medium'],
$felder['abrufzeitraum'], $felder['aktiv'], $felder['abrufzeitraum'], $felder['abruf_stichtag'], $felder['aktiv'],
$id, Mandant::id(), $id, Mandant::id(),
] ]
); );
@ -162,6 +164,34 @@ class Bankzugang
); );
} }
/**
* Ab wann für diesen Zugang abgerufen wird.
*
* Ein **beim Abruf angegebener Stichtag** geht der gespeicherten Vorgabe vor
* und wird nirgends festgehalten: Er gilt für diesen einen Lauf – etwa um
* einmalig weiter zurückzugreifen, ohne die Einstellung des Zugangs zu
* ändern.
*
* @param array<string,mixed> $zugang
* @return array{0: \DateTimeImmutable, 1: string} Startdatum und Beschreibung
*/
public static function abrufBeginn(
array $zugang,
?string $stichtag = null,
?\DateTimeImmutable $heute = null
): array {
if (trim((string)$stichtag) !== '') {
return self::startdatum('stichtag', null, $heute, $stichtag);
}
return self::startdatum(
(string)($zugang['abrufzeitraum'] ?? ''),
($zugang['zuletzt_am'] ?? null) === null ? null : (string)$zugang['zuletzt_am'],
$heute,
($zugang['abruf_stichtag'] ?? null) === null ? null : (string)$zugang['abruf_stichtag']
);
}
/** /**
* Ab wann abgerufen wird. „Seit dem letzten Abruf“ nimmt den Tag des letzten * Ab wann abgerufen wird. „Seit dem letzten Abruf“ nimmt den Tag des letzten
* Laufs – bewusst den ganzen Tag und nicht die Uhrzeit: Banken buchen * Laufs – bewusst den ganzen Tag und nicht die Uhrzeit: Banken buchen
@ -173,7 +203,8 @@ class Bankzugang
public static function startdatum( public static function startdatum(
?string $zeitraum, ?string $zeitraum,
?string $zuletztAm, ?string $zuletztAm,
?\DateTimeImmutable $heute = null ?\DateTimeImmutable $heute = null,
?string $stichtag = null
): array { ): array {
$heute = $heute ?? new \DateTimeImmutable('today'); $heute = $heute ?? new \DateTimeImmutable('today');
$zeitraum = array_key_exists((string)$zeitraum, self::ZEITRAEUME) ? (string)$zeitraum : 'letzter_abruf'; $zeitraum = array_key_exists((string)$zeitraum, self::ZEITRAEUME) ? (string)$zeitraum : 'letzter_abruf';
@ -182,10 +213,59 @@ class Bankzugang
'sieben_tage' => [$heute->modify('-7 days'), 'letzte 7 Tage'], 'sieben_tage' => [$heute->modify('-7 days'), 'letzte 7 Tage'],
'dreissig_tage' => [$heute->modify('-30 days'), 'letzte 30 Tage'], 'dreissig_tage' => [$heute->modify('-30 days'), 'letzte 30 Tage'],
'jahr' => [$heute->modify('-365 days'), 'letzte 365 Tage'], 'jahr' => [$heute->modify('-365 days'), 'letzte 365 Tage'],
// Ohne brauchbares Datum wäre „Ab Stichtag“ eine Angabe ohne Inhalt –
// dann gilt dieselbe Regel wie ohne Vorgabe.
'stichtag' => self::abStichtag($heute, $stichtag) ?? self::seitLetztemAbruf($heute, $zuletztAm),
default => self::seitLetztemAbruf($heute, $zuletztAm), default => self::seitLetztemAbruf($heute, $zuletztAm),
}; };
} }
/**
* Ein Stichtag als Datum – oder null, wenn sich keines daraus lesen lässt.
*
* Angenommen werden das Format des Datumsfelds (`2026-08-13`) und die
* deutsche Schreibweise (`13.08.2026`); alles andere gilt als Fehleingabe
* statt als „irgendein Tag“.
*/
public static function stichtagDatum(?string $roh): ?\DateTimeImmutable
{
$roh = trim((string)$roh);
if ($roh === '') {
return null;
}
foreach (['Y-m-d', 'd.m.Y'] as $format) {
$datum = \DateTimeImmutable::createFromFormat('!' . $format, $roh);
$fehler = \DateTimeImmutable::getLastErrors();
// `createFromFormat` biegt Unsinn wie den 31.02. still zurecht – die
// Warnungen sind das Einzige, woran sich das erkennen lässt.
if ($datum !== false
&& ($fehler === false || ($fehler['warning_count'] === 0 && $fehler['error_count'] === 0))) {
return $datum;
}
}
return null;
}
/** @return array{0: \DateTimeImmutable, 1: string}|null */
private static function abStichtag(\DateTimeImmutable $heute, ?string $stichtag): ?array
{
$datum = self::stichtagDatum($stichtag);
if ($datum === null) {
return null;
}
// Aus der Zukunft liefert keine Bank etwas; ein verrutschter Stichtag
// soll den Abruf nicht ergebnislos machen.
if ($datum > $heute) {
return [$heute, 'ab Stichtag ' . $heute->format('d.m.Y') . ' (in der Zukunft, auf heute gesetzt)'];
}
return [$datum, 'ab Stichtag ' . $datum->format('d.m.Y')];
}
/** @return array{0: \DateTimeImmutable, 1: string} */ /** @return array{0: \DateTimeImmutable, 1: string} */
private static function seitLetztemAbruf(\DateTimeImmutable $heute, ?string $zuletztAm): array private static function seitLetztemAbruf(\DateTimeImmutable $heute, ?string $zuletztAm): array
{ {
@ -223,6 +303,17 @@ class Bankzugang
$verfahren = ($verfahren === null || $verfahren === '') ? null : (int)$verfahren; $verfahren = ($verfahren === null || $verfahren === '') ? null : (int)$verfahren;
$zeitraum = (string)($daten['abrufzeitraum'] ?? 'letzter_abruf'); $zeitraum = (string)($daten['abrufzeitraum'] ?? 'letzter_abruf');
$zeitraum = array_key_exists($zeitraum, self::ZEITRAEUME) ? $zeitraum : 'letzter_abruf';
// Ein leeres Datumsfeld ist kein Fehler; „Ab Stichtag“ ohne Datum schon –
// sonst stünde eine Vorgabe da, die nichts vorgibt.
$stichtag = self::stichtagDatum((string)($daten['abruf_stichtag'] ?? ''));
if ($stichtag === null && trim((string)($daten['abruf_stichtag'] ?? '')) !== '') {
throw new \InvalidArgumentException('Der Stichtag ist kein gültiges Datum.');
}
if ($stichtag === null && $zeitraum === 'stichtag') {
throw new \InvalidArgumentException('Für „Ab Stichtag“ fehlt das Datum.');
}
return [ return [
'name' => mb_substr($name, 0, 128), 'name' => mb_substr($name, 0, 128),
@ -234,7 +325,8 @@ class Bankzugang
'tan_verfahren_id' => $verfahren, 'tan_verfahren_id' => $verfahren,
'tan_verfahren_name' => mb_substr(trim((string)($daten['tan_verfahren_name'] ?? '')), 0, 128), 'tan_verfahren_name' => mb_substr(trim((string)($daten['tan_verfahren_name'] ?? '')), 0, 128),
'tan_medium' => Crypto::verschluessle(trim((string)($daten['tan_medium'] ?? ''))), 'tan_medium' => Crypto::verschluessle(trim((string)($daten['tan_medium'] ?? ''))),
'abrufzeitraum' => array_key_exists($zeitraum, self::ZEITRAEUME) ? $zeitraum : 'letzter_abruf', 'abrufzeitraum' => $zeitraum,
'abruf_stichtag' => $stichtag?->format('Y-m-d'),
'aktiv' => !empty($daten['aktiv']) ? 1 : 0, 'aktiv' => !empty($daten['aktiv']) ? 1 : 0,
]; ];
} }

View file

@ -83,11 +83,20 @@ class FinTsAbruf
* Einrichtungsschritt – dort sollen erst die Konten zugeordnet werden, bevor * Einrichtungsschritt – dort sollen erst die Konten zugeordnet werden, bevor
* Umsätze hereinkommen, die sonst ohne Konto dastünden. * Umsätze hereinkommen, die sonst ohne Konto dastünden.
* *
* `$stichtag` gilt nur für diesen Lauf und geht dem Abrufzeitraum des Zugangs
* vor. Er liegt im Zustand, nicht in der Datenbank: Nach einer TAN-Runde
* läuft der Abruf im nächsten Request weiter und muss dort denselben Zeitraum
* holen wie beim Start.
*
* @return array{status: string, meldung: string, protokoll: list<string>, * @return array{status: string, meldung: string, protokoll: list<string>,
* neu: int, uebersprungen: int, tan?: array<string,mixed>} * neu: int, uebersprungen: int, tan?: array<string,mixed>}
*/ */
public static function starten(int $zugangId, string $modus = 'voll', bool $einrichtung = false): array public static function starten(
{ int $zugangId,
string $modus = 'voll',
bool $einrichtung = false,
?string $stichtag = null
): array {
$zugang = Bankzugang::zugangsdaten($zugangId); $zugang = Bankzugang::zugangsdaten($zugangId);
if ($zugang === null) { if ($zugang === null) {
return self::ergebnis('fehler', 'Den Bankzugang gibt es nicht.', []); return self::ergebnis('fehler', 'Den Bankzugang gibt es nicht.', []);
@ -100,12 +109,18 @@ class FinTsAbruf
} }
} }
$stichtag = trim((string)$stichtag);
if ($stichtag !== '' && Bankzugang::stichtagDatum($stichtag) === null) {
return self::ergebnis('fehler', 'Der Stichtag ist kein gültiges Datum.', []);
}
self::vergessen(); self::vergessen();
$abruf = new self($zugang, Bankabruf::beginnen($zugangId), [ $abruf = new self($zugang, Bankabruf::beginnen($zugangId), [
'schritt' => 'anmelden', 'schritt' => 'anmelden',
'modus' => $modus === 'konten' ? 'konten' : 'voll', 'modus' => $modus === 'konten' ? 'konten' : 'voll',
'einrichtung' => $einrichtung, 'einrichtung' => $einrichtung,
'stichtag' => $stichtag,
'konto_nummer' => 0, 'konto_nummer' => 0,
'neu' => 0, 'neu' => 0,
'uebersprungen' => 0, 'uebersprungen' => 0,
@ -253,9 +268,9 @@ class FinTsAbruf
private function umsaetzeAbrufen(): array private function umsaetzeAbrufen(): array
{ {
$konten = $this->abzurufendeKonten(); $konten = $this->abzurufendeKonten();
[$von, $beschreibung] = Bankzugang::startdatum( [$von, $beschreibung] = Bankzugang::abrufBeginn(
(string)$this->zugang['abrufzeitraum'], $this->zugang,
$this->zugang['zuletzt_am'] === null ? null : (string)$this->zugang['zuletzt_am'] (string)($this->zustand['stichtag'] ?? '')
); );
if ($this->zustand['konto_nummer'] === 0) { if ($this->zustand['konto_nummer'] === 0) {

View file

@ -22,8 +22,15 @@ include __DIR__ . '/../layout/kopf.php';
<div class="seitenaktionen"> <div class="seitenaktionen">
<a href="/banking" class="knopf">Zurück zu den Bankverbindungen</a> <a href="/banking" class="knopf">Zurück zu den Bankverbindungen</a>
<?php if (!$neu): ?> <?php if (!$neu): ?>
<form method="post" action="/banking/<?= (int)$zugang['id'] ?>/abrufen" class="inline"> <form method="post" action="/banking/<?= (int)$zugang['id'] ?>/abrufen" class="inline abrufform">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>"> <input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<?php /* Nur für diesen Lauf – die Vorgabe des Zugangs steht unten im
Formular und bleibt davon unberührt. */ ?>
<label class="stichtagwahl">
Stichtag
<input type="date" name="stichtag" class="stichtagfeld" max="<?= date('Y-m-d') ?>"
title="Ab diesem Tag abrufen – leer: eingestellter Abrufzeitraum">
</label>
<button type="submit" class="knopf primaer">Umsätze abrufen</button> <button type="submit" class="knopf primaer">Umsätze abrufen</button>
</form> </form>
<?php endif; ?> <?php endif; ?>
@ -72,6 +79,15 @@ include __DIR__ . '/../layout/kopf.php';
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
<label for="abruf_stichtag">Stichtag</label>
<input type="date" id="abruf_stichtag" name="abruf_stichtag"
value="<?= View::h($wert('abruf_stichtag')) ?>" max="<?= date('Y-m-d') ?>">
<p class="feldhinweis">
Gilt, wenn oben <em>Ab Stichtag</em> gewählt ist: Abgerufen wird dann jedes Mal ab
diesem Tag. Für einen einzelnen Abruf lässt sich oben neben <em>Umsätze abrufen</em>
ein abweichender Stichtag angeben, ohne diese Einstellung zu ändern.
</p>
<label class="ankreuzfeld"> <label class="ankreuzfeld">
<input type="checkbox" name="aktiv" value="1" <?= $neu || (int)$zugang['aktiv'] === 1 ? 'checked' : '' ?>> <input type="checkbox" name="aktiv" value="1" <?= $neu || (int)$zugang['aktiv'] === 1 ? 'checked' : '' ?>>
aktiv aktiv

View file

@ -57,15 +57,28 @@ include __DIR__ . '/../layout/kopf.php';
<span class="nebentext">BLZ <?= View::h($zugang['blz']) ?></span> <span class="nebentext">BLZ <?= View::h($zugang['blz']) ?></span>
<?php endif; ?> <?php endif; ?>
</td> </td>
<td><?= View::h(Bankzugang::ZEITRAEUME[$zugang['abrufzeitraum']] ?? $zugang['abrufzeitraum']) ?></td> <td>
<?= View::h(Bankzugang::ZEITRAEUME[$zugang['abrufzeitraum']] ?? $zugang['abrufzeitraum']) ?>
<?php if ($zugang['abrufzeitraum'] === 'stichtag' && $zugang['abruf_stichtag'] !== null): ?>
<span class="nebentext">
<?= View::h(date('d.m.Y', strtotime((string)$zugang['abruf_stichtag']))) ?>
</span>
<?php endif; ?>
</td>
<td> <td>
<?= $zugang['zuletzt_am'] === null <?= $zugang['zuletzt_am'] === null
? '<span class="nebentext">noch nie</span>' ? '<span class="nebentext">noch nie</span>'
: View::h(date('d.m.Y H:i', strtotime((string)$zugang['zuletzt_am']))) ?> : View::h(date('d.m.Y H:i', strtotime((string)$zugang['zuletzt_am']))) ?>
</td> </td>
<td class="rechts nowrap"> <td class="rechts nowrap">
<form method="post" action="/banking/<?= (int)$zugang['id'] ?>/abrufen" class="inline"> <?php /* Der Stichtag gilt nur für diesen Lauf; leer heißt: der
eingestellte Abrufzeitraum der Verbindung. */ ?>
<form method="post" action="/banking/<?= (int)$zugang['id'] ?>/abrufen" class="inline abrufform">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>"> <input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<input type="date" name="stichtag" class="stichtagfeld"
max="<?= date('Y-m-d') ?>"
aria-label="Stichtag für diesen Abruf"
title="Ab diesem Tag abrufen – leer: eingestellter Abrufzeitraum">
<button type="submit" class="knopf klein primaer">Umsätze abrufen</button> <button type="submit" class="knopf klein primaer">Umsätze abrufen</button>
</form> </form>
<a href="/banking/<?= (int)$zugang['id'] ?>" class="knopf klein">Bearbeiten</a> <a href="/banking/<?= (int)$zugang['id'] ?>" class="knopf klein">Bearbeiten</a>
@ -116,4 +129,11 @@ include __DIR__ . '/../layout/kopf.php';
übernommen oder verworfen werden. übernommen oder verworfen werden.
</p> </p>
<p class="hinweis">
Das Datumsfeld neben <em>Umsätze abrufen</em> ist ein <strong>Stichtag für diesen einen
Abruf</strong>: Es wird ab diesem Tag geholt, ohne den eingestellten Abrufzeitraum zu
ändern. Leer gelassen gilt der Zeitraum der Bankverbindung. Wie weit die Bank
zurückreicht, entscheidet sie selbst – 90 Tage sind üblich, mehr liefert nicht jede.
</p>
<?php include __DIR__ . '/../layout/fuss.php'; ?> <?php include __DIR__ . '/../layout/fuss.php'; ?>

View file

@ -39,6 +39,7 @@ class BankingTest extends TestCase
$this->tanVerfahrenLaesstSichWaehlen(); $this->tanVerfahrenLaesstSichWaehlen();
$this->tanVerfahrenUeberstehtDasSpeichern(); $this->tanVerfahrenUeberstehtDasSpeichern();
$this->zeitraumRichtetSichNachDemLetztenAbruf(); $this->zeitraumRichtetSichNachDemLetztenAbruf();
$this->stichtagBestimmtDenAbrufbeginn();
$this->umsaetzeKommenNurEinmalHerein(); $this->umsaetzeKommenNurEinmalHerein();
$this->regelnGreifenNachPrioritaet(); $this->regelnGreifenNachPrioritaet();
$this->regelnRechnenSplitsAus(); $this->regelnRechnenSplitsAus();
@ -296,6 +297,90 @@ class BankingTest extends TestCase
$this->assertEquals('2025-08-11', $von->format('Y-m-d'), 'Unbekannte Vorgabe fällt zurück'); $this->assertEquals('2025-08-11', $von->format('Y-m-d'), 'Unbekannte Vorgabe fällt zurück');
} }
/**
* Der Stichtag: als Vorgabe des Zugangs und als einmalige Angabe beim Abruf.
*
* Die einmalige Angabe darf die gespeicherte Einstellung weder verwenden noch
* verändern – sonst wäre der nächste reguläre Abruf ein anderer als gedacht.
*/
private function stichtagBestimmtDenAbrufbeginn(): void
{
$heute = new \DateTimeImmutable('2026-08-11');
[$von, $text] = Bankzugang::startdatum('stichtag', null, $heute, '2026-01-01');
$this->assertEquals('2026-01-01', $von->format('Y-m-d'), 'Ab dem Stichtag');
$this->assert(str_contains($text, '01.01.2026'), 'Und das Protokoll sagt es');
// Aus der Zukunft liefert keine Bank etwas.
[$von] = Bankzugang::startdatum('stichtag', null, $heute, '2027-03-01');
$this->assertEquals('2026-08-11', $von->format('Y-m-d'), 'Ein Stichtag in der Zukunft wird auf heute gesetzt');
// Ohne brauchbares Datum bleibt nur die Regel ohne Vorgabe.
[$von] = Bankzugang::startdatum('stichtag', '2026-08-09 22:15:00', $heute, '');
$this->assertEquals('2026-08-09', $von->format('Y-m-d'), 'Ohne Datum wie „seit dem letzten Abruf“');
$this->assertNull(Bankzugang::stichtagDatum('31.02.2026'), 'Den 31. Februar gibt es nicht');
$this->assertNull(Bankzugang::stichtagDatum('irgendwann'), 'Text ist kein Datum');
$this->assertEquals(
'2026-08-13',
Bankzugang::stichtagDatum('13.08.2026')?->format('Y-m-d'),
'Auch die deutsche Schreibweise wird gelesen'
);
// Die Vorgabe des Zugangs …
Bankzugang::speichern($this->zugang, [
'name' => 'Testbank',
'blz' => '12030000',
'fints_url' => 'https://banking.example.org/fints',
'benutzerkennung' => 'testkunde',
'pin' => '',
'abrufzeitraum' => 'stichtag',
'abruf_stichtag' => '2026-01-01',
'aktiv' => 1,
]);
$zugang = Bankzugang::find($this->zugang);
$this->assertEquals('2026-01-01', (string)$zugang['abruf_stichtag'], 'Der Stichtag wurde gespeichert');
[$von] = Bankzugang::abrufBeginn($zugang, null, $heute);
$this->assertEquals('2026-01-01', $von->format('Y-m-d'), 'Der gespeicherte Stichtag gilt');
// … und die einmalige Angabe, die ihr vorgeht.
[$von] = Bankzugang::abrufBeginn($zugang, '2026-07-01', $heute);
$this->assertEquals('2026-07-01', $von->format('Y-m-d'), 'Der Stichtag des Laufs geht vor');
$this->assertEquals(
'2026-01-01',
(string)Bankzugang::find($this->zugang)['abruf_stichtag'],
'Und lässt die gespeicherte Vorgabe stehen'
);
$this->assert(
$this->wirftFehler(fn() => Bankzugang::speichern($this->zugang, [
'name' => 'Testbank',
'blz' => '12030000',
'fints_url' => 'https://banking.example.org/fints',
'benutzerkennung' => 'testkunde',
'pin' => '',
'abrufzeitraum' => 'stichtag',
'abruf_stichtag' => '',
'aktiv' => 1,
])),
'„Ab Stichtag“ ohne Datum wird abgewiesen'
);
// Zurück auf die Vorgabe – die folgenden Prüfungen rechnen damit.
Bankzugang::speichern($this->zugang, [
'name' => 'Testbank',
'blz' => '12030000',
'fints_url' => 'https://banking.example.org/fints',
'benutzerkennung' => 'testkunde',
'pin' => '',
'abrufzeitraum' => 'letzter_abruf',
'aktiv' => 1,
]);
}
private function umsaetzeKommenNurEinmalHerein(): void private function umsaetzeKommenNurEinmalHerein(): void
{ {
$erste = $this->umsatzAnlegen([ $erste = $this->umsatzAnlegen([

View file

@ -123,6 +123,21 @@ test.describe('Module der Phasen 4 bis 7', () => {
// Ohne Wahl bleibt der Hinweis stehen, und die Seite ist wieder erreichbar. // Ohne Wahl bleibt der Hinweis stehen, und die Seite ist wieder erreichbar.
await expect(page.locator('a:has-text("TAN-Verfahren einstellen")')).toHaveCount(1) await expect(page.locator('a:has-text("TAN-Verfahren einstellen")')).toHaveCount(1)
// Der Stichtag gibt es zweimal: als Vorgabe des Zugangs …
await page.selectOption('#abrufzeitraum', 'stichtag')
await page.fill('#abruf_stichtag', '2026-01-01')
await page.click('button:has-text("Speichern")')
await expect(page.locator('.meldung')).toContainText('gespeichert')
await expect(page.locator('#abruf_stichtag')).toHaveValue('2026-01-01')
// … und als einmalige Angabe neben dem Abrufknopf, die nichts speichert.
await expect(page.locator('.abrufform input[name="stichtag"]')).toHaveCount(1)
await page.goto('/banking')
await expect(page.locator('tr:has-text("E2E-Testbank")')).toContainText('Ab Stichtag')
await expect(page.locator('tr:has-text("E2E-Testbank")')).toContainText('01.01.2026')
await page.click('td a:has-text("E2E-Testbank")')
// Schritt 3: die Kontenzuordnung. Ohne erreichbare Bank kennt sie noch // Schritt 3: die Kontenzuordnung. Ohne erreichbare Bank kennt sie noch
// kein Konto und bietet die Abfrage an, statt eine leere Tabelle zu zeigen. // kein Konto und bietet die Abfrage an, statt eine leere Tabelle zu zeigen.
await page.click('a:has-text("Konten zuordnen")') await page.click('a:has-text("Konten zuordnen")')