Merge pull request 'Fix #102: CSV-Importformat im Direktverbindungs-Dialog' (#104) from fds2610-bot/Steuerkram:automate/issue-102 into main

Reviewed-on: https://codeberg.org/fds2610/Steuerkram/pulls/104
This commit is contained in:
fds2610 2026-06-11 20:04:40 +02:00
commit 6d8bbd863b
6 changed files with 180 additions and 1 deletions

View file

@ -2,6 +2,11 @@
--- ---
## 2026-06-11 – Feature: CSV-Importformat aus „Neue Direktverbindung"-Dialog anlegen (Issue #102)
- **`bank/fints.php`:** Neue Auswahl „+ CSV-Importformat…" im Anbieter-Dropdown des „Neue Direktverbindung"-Dialogs; zeigt Felder für Trennzeichen, Anführungszeichen, Datumsformat, Dezimal-/Tausendertrennzeichen, Headerzeile und übersprungene Zeilen
- **`BankController::direktStore()`:** Behandelt `provider_id === '__csv__'` gesondert – legt einen `bank_sources`-Eintrag mit `type='csv'` und `provider_id=NULL` an (Standard-Spaltenzuordnung: Buchungstag=0, Wertstellung=1, Vorgang=2, Betrag=4) und leitet zur Feinabstimmung nach `/settings/bankimport` weiter
## 2026-06-11 – Fix: Direktabruf springt nach Abschluss zurück zu „Bank buchen" (Issue #101) ## 2026-06-11 – Fix: Direktabruf springt nach Abschluss zurück zu „Bank buchen" (Issue #101)
- **`src/Views/bank/fints.php`:** Nach erfolgreichem Direktabruf (FinTS) führt der „Schließen"-Button im Abruf-Log-Modal jetzt zu einem Redirect auf `/bank` ("Bank buchen"); bei Abbruch oder Fehler bleibt die Anzeige wie bisher auf `/bank/direkt`, damit der Abruf wiederholt werden kann - **`src/Views/bank/fints.php`:** Nach erfolgreichem Direktabruf (FinTS) führt der „Schließen"-Button im Abruf-Log-Modal jetzt zu einem Redirect auf `/bank` ("Bank buchen"); bei Abbruch oder Fehler bleibt die Anzeige wie bisher auf `/bank/direkt`, damit der Abruf wiederholt werden kann

View file

@ -66,6 +66,7 @@ Anlagenspiegel für abnutzbare Wirtschaftsgüter – siehe [anlagevermögen.md](
- Import von Kontoauszügen über zwei Wege: **CSV-Upload** (TXT/XLSX ebenfalls) und **Direktverbindung** (API/FinTS) - Import von Kontoauszügen über zwei Wege: **CSV-Upload** (TXT/XLSX ebenfalls) und **Direktverbindung** (API/FinTS)
- **CSV-Upload**: konfigurierbare Spalten- und Datumsformate je Bank, automatische Format-Erkennung beim Datei-Upload, Vorschau-Panel mit Headerzeile und Spalten-Zuordnung; bei unbekannten Formaten wird ein Vorschlag generiert und auf der Settings-Seite zum expliziten Speichern angeboten - **CSV-Upload**: konfigurierbare Spalten- und Datumsformate je Bank, automatische Format-Erkennung beim Datei-Upload, Vorschau-Panel mit Headerzeile und Spalten-Zuordnung; bei unbekannten Formaten wird ein Vorschlag generiert und auf der Settings-Seite zum expliziten Speichern angeboten
- **Neues CSV-Format manuell anlegen**: im „Neue Direktverbindung"-Dialog (Importieren) kann per „+ CSV-Importformat…" ein neues CSV-Format mit Trennzeichen, Datums-/Dezimalformat etc. angelegt werden; Spaltenzuordnung wird anschließend unter Einstellungen → CSV Bankimport Formate verfeinert
- **Direktverbindung „PayPal"** (NVP/SOAP-API): API-Benutzer, API-Kennwort, Signature und Sandbox-Flag werden verschlüsselt abgelegt. Live-Log via Server-Sent Events - **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 - **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) - **Letzter Abrufzeitpunkt** wird pro Verbindung gespeichert; nächster Abruf startet ab 00:00 dieses Tages (max. ein Tag Überlappung für Dedup)

View file

@ -405,6 +405,47 @@ class BankController extends BaseController
return; return;
} }
// Neues CSV-Importformat anlegen, wenn Dropdown auf "__csv__" steht.
// Provider entfällt hier (provider_id = NULL); Spaltenzuordnung erfolgt
// anschließend unter /settings/bankimport.
if ($providerRaw === '__csv__') {
$kuerzel = trim($_POST['kuerzel'] ?? '');
if ($kuerzel === '' || mb_strlen($kuerzel) > 10) {
View::redirect('/bank/direkt', 'Bitte ein Kürzel angeben (max. 10 Zeichen).', 'error');
return;
}
$dateFormats = ['d.m.Y', 'd.m.y', 'Y-m-d', 'd/m/Y', 'm/d/Y', 'Y.m.d', 'd-m-Y'];
$delimiter = (string)($_POST['csv_delimiter'] ?? ';');
$enclosure = (string)($_POST['csv_enclosure'] ?? '"');
$dateFmt = (string)($_POST['csv_date_format'] ?? 'd.m.Y');
$decSep = (string)($_POST['csv_decimal_separator'] ?? ',');
$thouSep = (string)($_POST['csv_thousands_separator'] ?? '.');
$farbe = (string)($_POST['farbe'] ?? '');
$id = BankSource::create([
'type' => 'csv',
'bezeichnung' => $bezeichnung,
'kuerzel' => $kuerzel,
'farbe' => preg_match('/^#[0-9a-fA-F]{6}$/', $farbe) ? $farbe : '#6366f1',
'skip_rows' => max(0, min(99, (int)($_POST['csv_skip_rows'] ?? 0))),
'has_header_row' => ($_POST['csv_has_header_row'] ?? '1') === '0' ? 0 : 1,
'delimiter' => mb_strlen($delimiter) === 1 ? $delimiter : ';',
'enclosure' => mb_strlen($enclosure) === 1 ? $enclosure : '"',
'date_format' => in_array($dateFmt, $dateFormats, true) ? $dateFmt : 'd.m.Y',
'decimal_separator' => mb_strlen($decSep) === 1 ? $decSep : ',',
'thousands_separator' => mb_strlen($thouSep) <= 1 ? $thouSep : '.',
'col_buchungstag' => 0,
'col_wertstellung' => 1,
'col_vorgang' => 2,
'col_buchungstext' => 0,
'col_wert' => 4,
]);
View::redirect('/settings/bankimport#format-' . $id, 'CSV-Format gespeichert. Spaltenzuordnung bei Bedarf anpassen.');
return;
}
// Neue Bank anlegen, wenn Dropdown auf "__new__" steht. // Neue Bank anlegen, wenn Dropdown auf "__new__" steht.
// Verbindungstyp wählt der Nutzer im Formular (API oder FinTS). // Verbindungstyp wählt der Nutzer im Formular (API oder FinTS).
if ($providerRaw === '__new__') { if ($providerRaw === '__new__') {

View file

@ -31,6 +31,16 @@ $fetchRangeOptions = [
'last_30_days' => 'Letzte 30 Tage', 'last_30_days' => 'Letzte 30 Tage',
'full' => 'Ohne Datum (letzte 365 Tage)', 'full' => 'Ohne Datum (letzte 365 Tage)',
]; ];
$csvDateFormatOptions = [
'd.m.Y' => '15.05.2026',
'd.m.y' => '15.05.26',
'Y-m-d' => '2026-05-15',
'd/m/Y' => '15/05/2026',
'm/d/Y' => '05/15/2026',
'Y.m.d' => '2026.05.15',
'd-m-Y' => '15-05-2026',
];
?> ?>
<?php if (empty($connections)): ?> <?php if (empty($connections)): ?>
@ -119,6 +129,7 @@ $fetchRangeOptions = [
</option> </option>
<?php endforeach; ?> <?php endforeach; ?>
<option value="__new__" data-typ="FinTS">+ Neue Bank…</option> <option value="__new__" data-typ="FinTS">+ Neue Bank…</option>
<option value="__csv__" data-typ="CSV">+ CSV-Importformat…</option>
</select> </select>
</div> </div>
@ -200,6 +211,54 @@ $fetchRangeOptions = [
<div class="text-muted text-sm">Die Konto-Auswahl wird beim ersten Abruf abgefragt.</div> <div class="text-muted text-sm">Die Konto-Auswahl wird beim ersten Abruf abgefragt.</div>
</div> </div>
<div id="csv-fields" style="display:none">
<div class="text-muted text-sm" style="margin-bottom:.75rem">
Grundeinstellungen für ein neues CSV-Importformat. Die Spaltenzuordnung kann danach unter
<a href="/settings/bankimport">Einstellungen → CSV Bankimport Formate</a> verfeinert werden.
</div>
<div style="display:flex;gap:.75rem">
<div class="form-group" style="flex:1">
<label for="fc-csv-delimiter">Trennzeichen</label>
<input type="text" id="fc-csv-delimiter" name="csv_delimiter" value=";" maxlength="1">
</div>
<div class="form-group" style="flex:1">
<label for="fc-csv-enclosure">Anführungszeichen</label>
<input type="text" id="fc-csv-enclosure" name="csv_enclosure" value='"' maxlength="1">
</div>
</div>
<div class="form-group">
<label for="fc-csv-date-format">Datumsformat</label>
<select id="fc-csv-date-format" name="csv_date_format">
<?php foreach ($csvDateFormatOptions as $val => $label): ?>
<option value="<?= $h($val) ?>"><?= $h($label) ?></option>
<?php endforeach; ?>
</select>
</div>
<div style="display:flex;gap:.75rem">
<div class="form-group" style="flex:1">
<label for="fc-csv-decimal">Dezimaltrennzeichen</label>
<input type="text" id="fc-csv-decimal" name="csv_decimal_separator" value="," maxlength="1">
</div>
<div class="form-group" style="flex:1">
<label for="fc-csv-thousands">Tausendertrennzeichen</label>
<input type="text" id="fc-csv-thousands" name="csv_thousands_separator" value="." maxlength="1">
</div>
</div>
<div style="display:flex;gap:.75rem">
<div class="form-group" style="flex:1">
<label for="fc-csv-header">Headerzeile vorhanden</label>
<select id="fc-csv-header" name="csv_has_header_row">
<option value="1" selected>Ja</option>
<option value="0">Nein</option>
</select>
</div>
<div class="form-group" style="flex:1">
<label for="fc-csv-skip">Übersprungene Zeilen</label>
<input type="number" id="fc-csv-skip" name="csv_skip_rows" value="0" min="0" max="99">
</div>
</div>
</div>
<div style="display:flex;justify-content:flex-end;gap:.5rem;margin-top:1rem"> <div style="display:flex;justify-content:flex-end;gap:.5rem;margin-top:1rem">
<button type="button" id="btn-modal-cancel" class="btn btn-secondary">Abbrechen</button> <button type="button" id="btn-modal-cancel" class="btn btn-secondary">Abbrechen</button>
<button type="submit" class="btn btn-primary">Speichern</button> <button type="submit" class="btn btn-primary">Speichern</button>
@ -411,6 +470,7 @@ $fetchRangeOptions = [
const selectNew = document.getElementById('fc-provider'); const selectNew = document.getElementById('fc-provider');
const apiNew = document.getElementById('api-fields'); const apiNew = document.getElementById('api-fields');
const fintsNew = document.getElementById('fints-fields'); const fintsNew = document.getElementById('fints-fields');
const csvNew = document.getElementById('csv-fields');
const newBankWrap = document.getElementById('fc-new-bank-wrap'); const newBankWrap = document.getElementById('fc-new-bank-wrap');
const newBankInput = document.getElementById('fc-new-bank-name'); const newBankInput = document.getElementById('fc-new-bank-name');
@ -430,11 +490,15 @@ $fetchRangeOptions = [
selectNew.addEventListener('change', () => { selectNew.addEventListener('change', () => {
const opt = selectNew.options[selectNew.selectedIndex]; const opt = selectNew.options[selectNew.selectedIndex];
const isNew = opt.value === '__new__'; const isNew = opt.value === '__new__';
const isCsv = opt.value === '__csv__';
newBankWrap.style.display = isNew ? 'block' : 'none'; newBankWrap.style.display = isNew ? 'block' : 'none';
newBankInput.required = isNew; newBankInput.required = isNew;
csvNew.style.display = isCsv ? 'block' : 'none';
if (isNew) { if (isNew) {
const selected = modalNew.querySelector('input[name="new_bank_typ"]:checked'); const selected = modalNew.querySelector('input[name="new_bank_typ"]:checked');
syncTypFields(selected ? selected.value : 'FinTS'); syncTypFields(selected ? selected.value : 'FinTS');
} else if (isCsv) {
syncTypFields('');
} else { } else {
syncTypFields(opt.dataset.typ); syncTypFields(opt.dataset.typ);
} }

View file

@ -1 +1 @@
1-260608 1-260611

View file

@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
use Steuerkram\Models\BankSource;
/**
* Test 44: Neues CSV-Importformat über "Neue Direktverbindung"-Dialog (BankController::direktStore).
* Prüft die Default-Spaltenzuordnung und dass das Format provider_id=NULL hat
* und nicht in der Direktverbindungs-Liste (api/fints) auftaucht.
*/
class CsvDirektverbindungTest extends TestCase
{
protected string $testName = 'Test 44: CSV-Importformat aus Direktverbindungs-Dialog anlegen';
public function run(): void
{
// Entspricht BankController::direktStore() für provider_id === '__csv__'
// mit unveränderten Vorgabewerten aus dem Formular.
$id = BankSource::create([
'type' => 'csv',
'bezeichnung' => 'Test-CSV-Bank',
'kuerzel' => 'TCB',
'farbe' => '#6366f1',
'skip_rows' => 0,
'has_header_row' => 1,
'delimiter' => ';',
'enclosure' => '"',
'date_format' => 'd.m.Y',
'decimal_separator' => ',',
'thousands_separator' => '.',
'col_buchungstag' => 0,
'col_wertstellung' => 1,
'col_vorgang' => 2,
'col_buchungstext' => 0,
'col_wert' => 4,
]);
$this->assertGreaterThan(0, $id, 'CSV-Format wurde angelegt');
$format = BankSource::find($id);
$this->assertNotEmpty($format, 'CSV-Format abrufbar');
$this->assertEquals('csv', $format['type'], 'Typ ist csv');
$this->assertEquals('TCB', $format['kuerzel'], 'Kürzel korrekt gespeichert');
$this->assert($format['provider_id'] === null, 'provider_id ist NULL (kein Bank-Anbieter)');
$this->assertEquals(0, (int)$format['col_buchungstag'], 'Spalte Buchungstag = 0');
$this->assertEquals(1, (int)$format['col_wertstellung'], 'Spalte Wertstellung = 1');
$this->assertEquals(2, (int)$format['col_vorgang'], 'Spalte Vorgang = 2');
$this->assertEquals(4, (int)$format['col_wert'], 'Spalte Betrag = 4');
$this->assertEquals('d.m.Y', $format['date_format'], 'Datumsformat-Default');
// Format erscheint in der CSV-Formatliste …
$csvFormats = BankSource::all('csv');
$this->assert(
in_array($id, array_map(fn($f) => (int)$f['id'], $csvFormats), true),
'CSV-Format erscheint in BankSource::all(csv)'
);
// … aber nicht in der Direktverbindungs-Liste (BankController::direkt()).
$connections = array_values(array_filter(
BankSource::all(),
fn($s) => in_array($s['type'], ['api', 'fints'], true)
));
$this->assert(
!in_array($id, array_map(fn($c) => (int)$c['id'], $connections), true),
'CSV-Format erscheint nicht in der Direktverbindungs-Liste'
);
}
}