From 23efce3196aa8984e87a1452eb985f4340c84b7a Mon Sep 17 00:00:00 2001
From: fds2610-bot
Date: Tue, 18 Aug 2026 13:12:12 +0200
Subject: [PATCH] Fix #161: Restposten aus Tech-Debt-Analyse (O-CHAT.html,
fints.php/bankimport.php, Request-Klasse)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
(a) public/O-CHAT.html nach BEISPIELE/ verschoben, Test 93 hält den Webroot sauber.
(b) bank/fints.php in Teil-Views + public/static/js/fints.js aufgeteilt.
(c) settings/bankimport.php in Teil-Views + public/static/js/bankimport.js aufgeteilt.
(d) src/Request.php angelegt, BankController als erster Controller umgestellt.
---
{public => BEISPIELE}/O-CHAT.html | 0
Changes.md | 7 +
public/static/js/bankimport.js | 293 +++++++
public/static/js/fints.js | 568 ++++++++++++
src/Controllers/BankController.php | 105 +--
src/Request.php | 71 ++
src/Views/bank/_connection_grid.php | 75 ++
src/Views/bank/_modal_account_select.php | 14 +
src/Views/bank/_modal_edit_connection.php | 128 +++
src/Views/bank/_modal_new_connection.php | 162 ++++
src/Views/bank/_modal_run_log.php | 17 +
src/Views/bank/_modal_tan_input.php | 21 +
src/Views/bank/fints.php | 998 +---------------------
src/Views/settings/_format_card.php | 181 ++++
src/Views/settings/_suggestion_card.php | 172 ++++
src/Views/settings/bankimport.php | 652 +-------------
tests/93_WebrootSauberkeitTest.php | 38 +
tests/94_RequestHelperTest.php | 96 +++
tests/95_FinTsViewStrukturTest.php | 78 ++
tests/96_BankimportViewStrukturTest.php | 76 ++
20 files changed, 2066 insertions(+), 1686 deletions(-)
rename {public => BEISPIELE}/O-CHAT.html (100%)
create mode 100644 public/static/js/bankimport.js
create mode 100644 public/static/js/fints.js
create mode 100644 src/Request.php
create mode 100644 src/Views/bank/_connection_grid.php
create mode 100644 src/Views/bank/_modal_account_select.php
create mode 100644 src/Views/bank/_modal_edit_connection.php
create mode 100644 src/Views/bank/_modal_new_connection.php
create mode 100644 src/Views/bank/_modal_run_log.php
create mode 100644 src/Views/bank/_modal_tan_input.php
create mode 100644 src/Views/settings/_format_card.php
create mode 100644 src/Views/settings/_suggestion_card.php
create mode 100644 tests/93_WebrootSauberkeitTest.php
create mode 100644 tests/94_RequestHelperTest.php
create mode 100644 tests/95_FinTsViewStrukturTest.php
create mode 100644 tests/96_BankimportViewStrukturTest.php
diff --git a/public/O-CHAT.html b/BEISPIELE/O-CHAT.html
similarity index 100%
rename from public/O-CHAT.html
rename to BEISPIELE/O-CHAT.html
diff --git a/Changes.md b/Changes.md
index b2cb9fa..d2a47be 100644
--- a/Changes.md
+++ b/Changes.md
@@ -2,6 +2,13 @@
---
+## 2026-08-18 – Restposten: O-CHAT.html, fints.php/bankimport.php, Request-Abstraktion (#161)
+
+- **(a) `public/O-CHAT.html`:** Testartefakt einer eigenständigen Ollama-Chat-Oberfläche wurde von Apache direkt ausgeliefert (`.htaccess` routet nur nicht-existierende Dateien auf `index.php`) und lief damit an der Authentifizierung vorbei. Nach `BEISPIELE/O-CHAT.html` verschoben (gitignored, wird nicht ausgeliefert). **`tests/93_WebrootSauberkeitTest.php`** (neu) hält `public/` dauerhaft sauber.
+- **(b) `src/Views/bank/fints.php`** (986 → unter 300 Zeilen): Markup in Teil-Views (`_connection_grid.php`, `_modal_new_connection.php`, `_modal_edit_connection.php`, `_modal_account_select.php`, `_modal_tan_input.php`, `_modal_run_log.php`), die 517 Zeilen Inline-JavaScript nach `public/static/js/fints.js`. Die Verbindungsliste wird nicht mehr per PHP-Interpolation ins Script geschrieben, sondern über `data-connections` auf einem eigenen Datencontainer-Element gelesen. Keine Verhaltensänderung am FinTS-Ablauf (TAN-Eingabe, Kontoauswahl, Drag&Drop-Sortierung, Abbruch).
+- **(c) `src/Views/settings/bankimport.php`** (797 → unter 300 Zeilen): Markup in Teil-Views (`_suggestion_card.php`, `_format_card.php`), die 293 Zeilen JavaScript nach `public/static/js/bankimport.js`. Datumsformat-Optionen für die Inline-Bearbeitung wandern über `data-date-format-options` statt per `json_encode()` direkt ins Script.
+- **(d) `src/Request.php`** (neu): schlanke statische Hilfsklasse für Zugriffe auf `$_POST`/`$_GET` – `post()`, `postInt()`, `postFloat()` (deutsches Dezimalkomma), `postDate()` (liefert `null` bei ungültigem Format, normalisiert auf `Y-m-d`), `get()`, `has()`. Kein Framework, keine neue Abhängigkeit. `BankController` ist als erster Controller umgestellt (180 → 126 direkte `$_POST`/`$_GET`-Zugriffe in `src/Controllers/`); verbliebene Array-Zugriffe (`$_GET` als Filter-Array, `dup_idx[]`, `computeFilterFromPost($_POST)`) bleiben unverändert, da Request nur einzelne Werte kapselt. Bisheriges Verhalten bei fehlenden/ungültigen Werten bleibt exakt erhalten. Die übrigen Controller folgen schrittweise in eigenen Tickets. **`tests/94_RequestHelperTest.php`** (neu).
+
## 2026-08-18 – Modale Dialoge: Label und Feld in einer Zeile, Hilfetexte als Tooltip (#189)
- Einige Dialoge (v.a. „Neue Direktverbindung"/„Verbindung bearbeiten" unter Bank, „Rechnung per E-Mail senden"/„Zahlungen" unter Rechnungen) waren durch zweizeilige Formularfelder (Bezeichnung über dem Feld) höher als ein Standardbildschirm und mussten gescrollt werden.
diff --git a/public/static/js/bankimport.js b/public/static/js/bankimport.js
new file mode 100644
index 0000000..8cbe12f
--- /dev/null
+++ b/public/static/js/bankimport.js
@@ -0,0 +1,293 @@
+'use strict';
+
+(function () {
+ const DATE_FORMAT_OPTIONS = JSON.parse(document.getElementById('bankimport-data').dataset.dateFormatOptions);
+
+ function badgeTextColor(hex) {
+ const r = parseInt(hex.slice(1,3),16);
+ const g = parseInt(hex.slice(3,5),16);
+ const b = parseInt(hex.slice(5,7),16);
+ return (0.299*r + 0.587*g + 0.114*b) > 128 ? '#1e293b' : '#ffffff';
+ }
+
+ function updatePreview(colorInput, hex) {
+ const preview = colorInput.closest('div')?.querySelector('.bi-kuerzel-preview');
+ if (!preview) return;
+ preview.style.background = hex;
+ preview.style.color = badgeTextColor(hex);
+ }
+
+ // ── Hilfsfunktionen ──────────────────────────────────────────────
+
+ function getFormatId(cell) {
+ return cell.closest('[data-format-id]')?.dataset.formatId;
+ }
+
+ function showError(cell, msg) {
+ cell.classList.add('bi-cell--error');
+ let err = cell.parentElement.querySelector('.bi-error-msg');
+ if (!err) { err = document.createElement('span'); err.className = 'bi-error-msg'; cell.after(err); }
+ err.textContent = msg;
+ setTimeout(() => { cell.classList.remove('bi-cell--error'); err.remove(); }, 3000);
+ }
+
+ async function saveField(formatId, field, value) {
+ const body = new URLSearchParams({ field, value: value ?? '' });
+ const res = await fetch(`/settings/bankimport/${formatId}/field`, {
+ method: 'POST',
+ headers: { 'X-Requested-With': 'XMLHttpRequest', 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: body.toString(),
+ });
+ return res.json();
+ }
+
+ function displayForCell(type, value, field) {
+ if (type === 'number' || type === 'nullint') {
+ if (value === '' || value === null || value === undefined) {
+ return '–';
+ }
+ return `${value}`;
+ }
+ if (type === 'char' || type === 'char0') {
+ if (value === '') return 'keines';
+ return `${value.replace(/&/g,'&').replace(//g,'>')}`;
+ }
+ if (type === 'dateformat') {
+ const example = DATE_FORMAT_OPTIONS[value] || '?';
+ return `${value}→ ${example}`;
+ }
+ if (type === 'template') {
+ if (!value) return 'nicht gesetzt';
+ const esc = value.replace(/&/g,'&').replace(//g,'>');
+ return `${esc}`;
+ }
+ if (type === 'text') {
+ return value.replace(/&/g,'&').replace(//g,'>');
+ }
+ return value;
+ }
+
+ // ── Klick-Handler für .bi-cell ───────────────────────────────────
+ document.addEventListener('click', (e) => {
+ const cell = e.target.closest('.bi-cell');
+ if (!cell || cell.querySelector('input,select')) return;
+
+ const field = cell.dataset.field;
+ const type = cell.dataset.type;
+ const rawValue = cell.dataset.value ?? '';
+ const formatId = getFormatId(cell);
+ if (!formatId) return;
+
+ const originalHTML = cell.innerHTML;
+ cell.innerHTML = '';
+ cell.classList.remove('bi-cell--error');
+
+ let el;
+
+ if (type === 'number' || type === 'nullint') {
+ el = document.createElement('input');
+ el.type = 'number';
+ el.min = cell.dataset.min ?? '0';
+ el.max = cell.dataset.max ?? '99';
+ el.value = rawValue;
+ } else if (type === 'char') {
+ el = document.createElement('input');
+ el.type = 'text';
+ el.maxLength = 1;
+ el.value = rawValue;
+ } else if (type === 'char0') {
+ el = document.createElement('input');
+ el.type = 'text';
+ el.maxLength = 1;
+ el.value = rawValue;
+ el.placeholder = '(leer)';
+ el.style.width = '70px';
+ } else if (type === 'dateformat') {
+ el = document.createElement('select');
+ for (const [fmt, example] of Object.entries(DATE_FORMAT_OPTIONS)) {
+ const opt = document.createElement('option');
+ opt.value = fmt;
+ opt.textContent = `${fmt} → ${example}`;
+ if (fmt === rawValue) opt.selected = true;
+ el.appendChild(opt);
+ }
+ } else if (type === 'template') {
+ el = document.createElement('input');
+ el.type = 'text';
+ el.value = rawValue;
+ el.style.width = '100%';
+ el.style.boxSizing = 'border-box';
+ } else {
+ el = document.createElement('input');
+ el.type = 'text';
+ el.value = rawValue;
+ el.style.width = '180px';
+ }
+
+ cell.appendChild(el);
+ el.focus();
+ if (el.tagName === 'INPUT' && el.type === 'text') el.select();
+
+ let done = false;
+
+ async function commit() {
+ if (done) return;
+ done = true;
+
+ const newVal = el.tagName === 'SELECT' ? el.value : el.value;
+ cell.classList.add('bi-cell--saving');
+ cell.innerHTML = '';
+
+ let json;
+ try {
+ json = await saveField(formatId, field, newVal);
+ } catch (_) {
+ json = { error: 'Verbindungsfehler.' };
+ }
+
+ cell.classList.remove('bi-cell--saving');
+
+ if (json.error) {
+ cell.innerHTML = originalHTML;
+ showError(cell, json.error);
+ return;
+ }
+
+ cell.dataset.value = newVal;
+ cell.innerHTML = displayForCell(type, newVal, field);
+
+ // Formatname im Card-Header synchronisieren
+ if (field === 'name') {
+ const title = cell.closest('.card')?.querySelector('.card-title');
+ if (title) title.dataset.value = newVal;
+ }
+ // Kürzel-Vorschau-Badge aktualisieren
+ if (field === 'kuerzel') {
+ const preview = cell.closest('div')?.querySelector('.bi-kuerzel-preview');
+ if (preview) preview.textContent = newVal;
+ }
+ }
+
+ function cancel() {
+ if (done) return;
+ done = true;
+ cell.innerHTML = originalHTML;
+ }
+
+ if (el.tagName === 'SELECT') {
+ el.addEventListener('change', commit);
+ el.addEventListener('keydown', (ev) => { if (ev.key === 'Escape') { ev.stopPropagation(); cancel(); } });
+ el.addEventListener('blur', () => { if (!done) commit(); });
+ } else {
+ el.addEventListener('blur', commit);
+ el.addEventListener('keydown', (ev) => {
+ if (ev.key === 'Enter') { ev.preventDefault(); el.blur(); }
+ if (ev.key === 'Escape') { ev.preventDefault(); cancel(); }
+ });
+ }
+ });
+
+ // ── Colorpicker für farbe ────────────────────────────────────────
+ document.addEventListener('input', (e) => {
+ const ci = e.target.closest('.bi-color');
+ if (ci) updatePreview(ci, ci.value);
+ });
+
+ document.addEventListener('change', async (e) => {
+ const ci = e.target.closest('.bi-color');
+ if (!ci) return;
+
+ const formatId = getFormatId(ci);
+ const field = ci.dataset.field;
+ const value = ci.value;
+ ci.disabled = true;
+
+ let json;
+ try { json = await saveField(formatId, field, value); }
+ catch (_) { json = { error: 'Verbindungsfehler.' }; }
+
+ ci.disabled = false;
+
+ if (json.error) {
+ showError(ci, json.error);
+ } else {
+ updatePreview(ci, value);
+ }
+ });
+
+ // ── Format löschen ──────────────────────────────────────────────
+ document.addEventListener('click', async (e) => {
+ const btn = e.target.closest('.bi-delete-format');
+ if (!btn) return;
+
+ const formatId = btn.dataset.formatId;
+ const formatName = btn.dataset.formatName;
+
+ if (!confirm(`Format „${formatName}" wirklich löschen?`)) return;
+
+ btn.disabled = true;
+
+ let json;
+ try {
+ const res = await fetch(`/settings/bankimport/${formatId}/delete`, { method: 'POST' });
+ json = await res.json();
+ } catch (_) {
+ json = { error: 'Verbindungsfehler.' };
+ }
+
+ if (json.error) {
+ alert(json.error);
+ btn.disabled = false;
+ return;
+ }
+
+ btn.closest('.card').remove();
+ });
+
+ // ── Vorschlags-Card verwerfen ────────────────────────────────────
+ const dismissBtn = document.getElementById('suggestion-dismiss');
+ if (dismissBtn) {
+ dismissBtn.addEventListener('click', async () => {
+ dismissBtn.disabled = true;
+ try {
+ await fetch('/settings/bankimport/dismiss-suggestion', { method: 'POST' });
+ } catch (_) {}
+ const card = document.getElementById('suggestion-card');
+ if (card) card.remove();
+ });
+ }
+
+ // ── Checkboxen für has_header_row ────────────────────────────────
+ document.addEventListener('change', async (e) => {
+ const cb = e.target.closest('.bi-checkbox');
+ if (!cb) return;
+
+ const label = cb.closest('.bi-checkbox-label');
+ const textSpan = label?.querySelector('.bi-checkbox-text');
+ const field = cb.dataset.field;
+ const formatId = getFormatId(cb);
+ if (!formatId) return;
+
+ const newVal = cb.checked ? '1' : '0';
+ cb.disabled = true;
+
+ let json;
+ try {
+ json = await saveField(formatId, field, newVal);
+ } catch (_) {
+ json = { error: 'Verbindungsfehler.' };
+ }
+
+ cb.disabled = false;
+
+ if (json.error) {
+ cb.checked = !cb.checked; // Revert
+ if (textSpan) textSpan.textContent = cb.checked ? 'Ja' : 'Nein';
+ alert(json.error);
+ return;
+ }
+
+ if (textSpan) textSpan.textContent = cb.checked ? 'Ja' : 'Nein';
+ });
+
+})();
diff --git a/public/static/js/fints.js b/public/static/js/fints.js
new file mode 100644
index 0000000..6f5da56
--- /dev/null
+++ b/public/static/js/fints.js
@@ -0,0 +1,568 @@
+'use strict';
+
+(function () {
+ const connections = JSON.parse(document.getElementById('fints-data').dataset.connections);
+ const csrfToken = (document.querySelector('meta[name="csrf-token"]') || {}).content || '';
+
+ // ── NEU-Modal ────────────────────────────────────────────────
+ const modalNew = document.getElementById('modal-new-connection');
+ const btnNew = document.getElementById('btn-new-connection');
+ const btnClose = document.getElementById('btn-modal-close');
+ const btnCancel = document.getElementById('btn-modal-cancel');
+ const selectNew = document.getElementById('fc-provider');
+ const apiNew = document.getElementById('api-fields');
+ const fintsNew = document.getElementById('fints-fields');
+ const csvNew = document.getElementById('csv-fields');
+ const newBankWrap = document.getElementById('fc-new-bank-wrap');
+ const newBankInput = document.getElementById('fc-new-bank-name');
+
+ function openNew() { modalNew.style.display = 'flex'; }
+ function closeNew() { modalNew.style.display = 'none'; }
+
+ if (btnNew) btnNew.addEventListener('click', openNew);
+ if (btnClose) btnClose.addEventListener('click', closeNew);
+ if (btnCancel) btnCancel.addEventListener('click', closeNew);
+ modalNew.addEventListener('click', (e) => { if (e.target === modalNew) closeNew(); });
+
+ // ── Bankleitzahlen-Verzeichnis: Suche + Formular vorbefüllen (Fix #187) ──
+ (function () {
+ const blzField = document.getElementById('fc-blz');
+ const urlField = document.getElementById('fc-fints-url');
+ const searchInput = document.getElementById('fc-blz-search');
+ const resultsBox = document.getElementById('fc-blz-results');
+ if (!searchInput || !resultsBox) return;
+
+ function esc(str) {
+ return String(str ?? '').replace(/&/g,'&').replace(//g,'>');
+ }
+
+ let debounceTimer = null;
+ searchInput.addEventListener('input', () => {
+ clearTimeout(debounceTimer);
+ const q = searchInput.value.trim();
+ if (q.length < 2) { resultsBox.style.display = 'none'; resultsBox.innerHTML = ''; return; }
+ debounceTimer = setTimeout(async () => {
+ let rows;
+ try {
+ const res = await fetch('/bankinstitute/search?q=' + encodeURIComponent(q));
+ rows = await res.json();
+ } catch (_) { rows = []; }
+
+ if (!Array.isArray(rows) || rows.length === 0) {
+ resultsBox.innerHTML = 'Keine Treffer
';
+ resultsBox.style.display = 'block';
+ return;
+ }
+
+ resultsBox.innerHTML = rows.map((r, i) => `
+
+ ${esc(r.blz)} ${esc(r.name)} · ${esc(r.ort)}
+ ${r.pin_tan_url ? '' : ' (kein PIN/TAN-Zugang)'}
+
+ `).join('');
+ resultsBox.style.display = 'block';
+
+ resultsBox.querySelectorAll('.fc-blz-result').forEach((el) => {
+ el.addEventListener('click', () => {
+ const row = rows[parseInt(el.dataset.idx, 10)];
+ blzField.value = row.blz;
+ urlField.value = row.pin_tan_url || '';
+ resultsBox.style.display = 'none';
+ searchInput.value = '';
+ });
+ });
+ }, 300);
+ });
+ })();
+
+ function syncTypFields(typ) {
+ apiNew.style.display = (typ === 'API') ? 'block' : 'none';
+ fintsNew.style.display = (typ === 'FinTS') ? 'block' : 'none';
+ }
+
+ selectNew.addEventListener('change', () => {
+ const opt = selectNew.options[selectNew.selectedIndex];
+ const isNew = opt.value === '__new__';
+ const isCsv = opt.value === '__csv__';
+ newBankWrap.style.display = isNew ? 'block' : 'none';
+ newBankInput.required = isNew;
+ csvNew.style.display = isCsv ? 'block' : 'none';
+ if (isNew) {
+ const selected = modalNew.querySelector('input[name="new_bank_typ"]:checked');
+ syncTypFields(selected ? selected.value : 'FinTS');
+ } else if (isCsv) {
+ syncTypFields('');
+ } else {
+ syncTypFields(opt.dataset.typ);
+ }
+ });
+
+ modalNew.querySelectorAll('input[name="new_bank_typ"]').forEach(radio => {
+ radio.addEventListener('change', () => {
+ if (selectNew.value === '__new__') {
+ syncTypFields(radio.value);
+ }
+ });
+ });
+
+ // ── Bearbeiten-Modal ─────────────────────────────────────────
+ const modalEdit = document.getElementById('modal-edit-connection');
+ const btnEditClose = document.getElementById('btn-edit-close');
+ const btnEditCancel= document.getElementById('btn-edit-cancel');
+ const formEdit = document.getElementById('form-edit-connection');
+ const formDelete = document.getElementById('form-edit-delete');
+ const btnEditDelete = document.getElementById('btn-edit-delete');
+ const editName = document.getElementById('edit-provider-name');
+ const editBez = document.getElementById('edit-bezeichnung');
+ const editApiFields = document.getElementById('edit-api-fields');
+ const editFintsFields = document.getElementById('edit-fints-fields');
+ const editAppName = document.getElementById('edit-app-name');
+ const editClientId = document.getElementById('edit-client-id');
+ const editSecret = document.getElementById('edit-secret');
+ const editSandbox = document.getElementById('edit-sandbox');
+ const editKuerzel = document.getElementById('edit-kuerzel');
+ const editFarbe = document.getElementById('edit-farbe');
+ const editBlz = document.getElementById('edit-blz');
+ const editBenutzer = document.getElementById('edit-benutzerkennung');
+ const editPin = document.getElementById('edit-pin');
+ const editFintsUrl = document.getElementById('edit-fints-url');
+ const editTanModeId = document.getElementById('edit-tan-mode-id');
+ 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'; }
+
+ if (btnEditClose) btnEditClose.addEventListener('click', closeEdit);
+ if (btnEditCancel) btnEditCancel.addEventListener('click', closeEdit);
+ modalEdit.addEventListener('click', (e) => { if (e.target === modalEdit) closeEdit(); });
+
+ btnEditDelete.addEventListener('click', () => {
+ if (!confirm('Verbindung wirklich löschen?')) return;
+ formDelete.submit();
+ });
+
+ // ── Abruf-Modal ──────────────────────────────────────────────
+ const modalRun = document.getElementById('modal-run-log');
+ const runLogArea = document.getElementById('run-log-area');
+ const runTitle = document.getElementById('run-log-title');
+ const runSpinner = document.getElementById('run-log-spinner');
+ const btnRunClose= document.getElementById('btn-run-close');
+ let activeEs = null;
+ let runWasOk = false;
+
+ function closeRun() {
+ if (activeEs) { activeEs.close(); activeEs = null; }
+ if (runWasOk) {
+ window.location.href = '/bank';
+ return;
+ }
+ modalRun.style.display = 'none';
+ runLogArea.textContent = '';
+ }
+
+ btnRunClose.addEventListener('click', closeRun);
+ modalRun.addEventListener('click', (e) => { if (e.target === modalRun && !btnRunClose.disabled) closeRun(); });
+
+ // ── Konto-Auswahl-Modal ──────────────────────────────────────
+ const modalAcc = document.getElementById('modal-account-select');
+ const accList = document.getElementById('account-list');
+ const accError = document.getElementById('account-error');
+ const btnAccSubmit = document.getElementById('btn-account-submit');
+ const btnAccCancel = document.getElementById('btn-account-cancel');
+ let currentAccToken = null;
+
+ function openAccountModal(payload) {
+ currentAccToken = payload.token;
+ accList.innerHTML = '';
+ accError.style.display = 'none';
+ payload.accounts.forEach((acc) => {
+ const row = document.createElement('label');
+ row.style.display = 'flex';
+ row.style.alignItems = 'center';
+ row.style.gap = '.5rem';
+ row.style.cursor = 'pointer';
+ row.style.padding = '.35rem .5rem';
+ row.style.border = '1px solid #e2e8f0';
+ row.style.borderRadius = 'var(--radius)';
+ const cb = document.createElement('input');
+ cb.type = 'checkbox'; cb.value = acc.id; cb.checked = true;
+ cb.style.margin = '0';
+ const label = document.createElement('span');
+ label.textContent = acc.label;
+ row.appendChild(cb);
+ row.appendChild(label);
+ accList.appendChild(row);
+ });
+ modalAcc.style.display = 'flex';
+ }
+ function closeAccountModal() {
+ modalAcc.style.display = 'none';
+ currentAccToken = null;
+ }
+
+ btnAccCancel.addEventListener('click', () => {
+ closeAccountModal();
+ if (activeEs) { activeEs.close(); activeEs = null; }
+ runTitle.textContent = 'Abruf abgebrochen';
+ runSpinner.textContent = '✗ abgebrochen';
+ btnRunClose.disabled = false;
+ });
+
+ btnAccSubmit.addEventListener('click', async () => {
+ const ids = Array.from(accList.querySelectorAll('input[type=checkbox]:checked')).map(cb => cb.value);
+ if (ids.length === 0) {
+ accError.textContent = 'Mindestens ein Konto auswählen.';
+ accError.style.display = 'block';
+ return;
+ }
+ btnAccSubmit.disabled = true;
+ try {
+ const form = new FormData();
+ form.append('token', currentAccToken);
+ form.append('ids', JSON.stringify(ids));
+ const res = await fetch('/bank/direkt/' + currentSourceId + '/accounts', { method: 'POST', body: form });
+ const data = await res.json();
+ if (!res.ok || data.error) {
+ accError.textContent = data.error || 'Übermittlung fehlgeschlagen.';
+ accError.style.display = 'block';
+ return;
+ }
+ closeAccountModal();
+ } catch (e) {
+ accError.textContent = 'Netzwerkfehler: ' + e.message;
+ accError.style.display = 'block';
+ } finally {
+ btnAccSubmit.disabled = false;
+ }
+ });
+
+ // ── TAN-Modal ────────────────────────────────────────────────
+ const modalTan = document.getElementById('modal-tan-input');
+ const tanContext = document.getElementById('tan-context');
+ const tanChallenge = document.getElementById('tan-challenge');
+ const tanImageWrap = document.getElementById('tan-image-wrap');
+ const tanMedium = document.getElementById('tan-medium');
+ const tanInput = document.getElementById('tan-input');
+ const tanError = document.getElementById('tan-error');
+ const btnTanSubmit = document.getElementById('btn-tan-submit');
+ const btnTanCancel = document.getElementById('btn-tan-cancel');
+ let currentSourceId = null;
+ let currentTanToken = null;
+
+ function openTanModal(payload) {
+ currentTanToken = payload.token;
+ tanContext.textContent = payload.context ? 'Konto: ' + payload.context : '';
+ tanChallenge.textContent = payload.challenge || '';
+ tanMedium.textContent = payload.mediumName ? 'TAN-Medium: ' + payload.mediumName : '';
+ tanImageWrap.innerHTML = '';
+ if (payload.imageData && payload.imageMime) {
+ const img = new Image();
+ img.src = 'data:' + payload.imageMime + ';base64,' + payload.imageData;
+ img.style.maxWidth = '320px';
+ tanImageWrap.appendChild(img);
+ }
+ tanInput.value = '';
+ tanError.style.display = 'none';
+ modalTan.style.display = 'flex';
+ setTimeout(() => tanInput.focus(), 50);
+ }
+ function closeTanModal() {
+ modalTan.style.display = 'none';
+ currentTanToken = null;
+ }
+
+ btnTanCancel.addEventListener('click', () => {
+ closeTanModal();
+ if (activeEs) { activeEs.close(); activeEs = null; }
+ runTitle.textContent = 'Abruf abgebrochen';
+ runSpinner.textContent = '✗ abgebrochen';
+ btnRunClose.disabled = false;
+ });
+
+ async function submitTan() {
+ const tan = tanInput.value.trim();
+ if (!tan) { tanError.textContent = 'Bitte TAN eingeben.'; tanError.style.display = 'block'; return; }
+ if (!currentSourceId || !currentTanToken) return;
+ btnTanSubmit.disabled = true;
+ try {
+ const form = new FormData();
+ form.append('token', currentTanToken);
+ form.append('tan', tan);
+ const res = await fetch('/bank/direkt/' + currentSourceId + '/tan', { method: 'POST', body: form });
+ const data = await res.json();
+ if (!res.ok || data.error) {
+ tanError.textContent = data.error || 'TAN-Übermittlung fehlgeschlagen.';
+ tanError.style.display = 'block';
+ return;
+ }
+ closeTanModal();
+ } catch (e) {
+ tanError.textContent = 'Netzwerkfehler: ' + e.message;
+ tanError.style.display = 'block';
+ } finally {
+ btnTanSubmit.disabled = false;
+ }
+ }
+ btnTanSubmit.addEventListener('click', submitTan);
+ tanInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') submitTan(); });
+
+ // ── Drag & Drop für Kachel-Reihenfolge ──────────────────────
+ const grid = document.getElementById('connection-grid');
+ let dragSrc = null;
+ let didDrag = false;
+ if (grid) {
+ grid.querySelectorAll('.drag-handle').forEach(handle => {
+ handle.addEventListener('dragstart', (e) => {
+ dragSrc = handle.closest('.card');
+ didDrag = false;
+ if (!dragSrc) return;
+ e.dataTransfer.effectAllowed = 'move';
+ e.dataTransfer.setData('text/plain', dragSrc.dataset.id || '');
+ // Komplette Kachel als Drag-Image, am Mauszeiger an der Klick-Position
+ const rect = dragSrc.getBoundingClientRect();
+ e.dataTransfer.setDragImage(dragSrc, e.clientX - rect.left, e.clientY - rect.top);
+ // Ursprung subtil markieren, erst nach Snapshot (sonst landet das Highlight im Ghost)
+ setTimeout(() => {
+ if (dragSrc) {
+ dragSrc.style.outline = '2px dashed #6366f1';
+ dragSrc.style.outlineOffset = '2px';
+ }
+ }, 0);
+ });
+ handle.addEventListener('dragend', () => {
+ if (dragSrc) {
+ dragSrc.style.outline = '';
+ dragSrc.style.outlineOffset = '';
+ }
+ grid.querySelectorAll('.drop-before, .drop-after').forEach(c => {
+ c.classList.remove('drop-before', 'drop-after');
+ });
+ dragSrc = null;
+ setTimeout(() => { didDrag = false; }, 50);
+ });
+ });
+ function clearDropMarkers() {
+ grid.querySelectorAll('.drop-before, .drop-after').forEach(c => {
+ c.classList.remove('drop-before', 'drop-after');
+ });
+ }
+
+ function findDropTarget(clientX, clientY) {
+ const cards = [...grid.querySelectorAll('.card')].filter(c => c !== dragSrc);
+ // Erst: Karte unter dem Cursor (bevorzugt)
+ for (const card of cards) {
+ const r = card.getBoundingClientRect();
+ if (clientX >= r.left && clientX <= r.right && clientY >= r.top && clientY <= r.bottom) {
+ return { card, side: (clientX < r.left + r.width / 2) ? 'before' : 'after' };
+ }
+ }
+ // Fallback: nächste Karte in derselben Zeile (anhand Y)
+ let best = null, bestDist = Infinity;
+ for (const card of cards) {
+ const r = card.getBoundingClientRect();
+ if (clientY >= r.top && clientY <= r.bottom) {
+ const center = r.left + r.width / 2;
+ const dist = Math.abs(clientX - center);
+ if (dist < bestDist) {
+ bestDist = dist;
+ best = { card, side: (clientX < center) ? 'before' : 'after' };
+ }
+ }
+ }
+ return best;
+ }
+
+ grid.addEventListener('dragover', (e) => {
+ if (!dragSrc) return;
+ e.preventDefault();
+ e.dataTransfer.dropEffect = 'move';
+ const target = findDropTarget(e.clientX, e.clientY);
+ if (!target) { clearDropMarkers(); return; }
+ const cls = target.side === 'before' ? 'drop-before' : 'drop-after';
+ // Nur neu setzen wenn nötig (vermeidet Flackern)
+ if (target.card.classList.contains(cls)) return;
+ clearDropMarkers();
+ target.card.classList.add(cls);
+ });
+ grid.addEventListener('dragleave', (e) => {
+ // Nur wenn der Grid komplett verlassen wird
+ if (e.target === grid && !grid.contains(e.relatedTarget)) {
+ clearDropMarkers();
+ }
+ });
+ grid.addEventListener('drop', (e) => {
+ if (!dragSrc) return;
+ e.preventDefault();
+ e.stopPropagation();
+ const marker = grid.querySelector('.drop-before, .drop-after');
+ if (marker) {
+ const before = marker.classList.contains('drop-before');
+ marker.parentNode.insertBefore(dragSrc, before ? marker : marker.nextSibling);
+ didDrag = true;
+ persistOrder();
+ }
+ clearDropMarkers();
+ });
+ }
+ async function persistOrder() {
+ const ids = Array.from(grid.querySelectorAll('.card[data-id]')).map(c => c.dataset.id);
+ const form = new FormData();
+ form.append('ids', JSON.stringify(ids));
+ try {
+ await fetch('/bank/direkt/_order', { method: 'POST', body: form });
+ } catch (e) { /* still local */ }
+ }
+
+ document.querySelectorAll('.btn-run-connection').forEach(card => {
+ card.addEventListener('click', (e) => {
+ if (didDrag) { didDrag = false; return; }
+ if (e.target.closest('.btn-edit-connection')) return;
+ if (e.target.closest('.drag-handle')) return;
+
+ const id = card.dataset.id;
+ currentSourceId = id;
+ runLogArea.textContent = '';
+ runTitle.textContent = 'Abruf läuft…';
+ runSpinner.textContent = '⏳ wird abgerufen…';
+ btnRunClose.disabled = true;
+ runWasOk = false;
+ modalRun.style.display = 'flex';
+
+ if (activeEs) activeEs.close();
+ activeEs = new EventSource('/bank/direkt/' + id + '/run?csrf_token=' + encodeURIComponent(csrfToken));
+
+ activeEs.addEventListener('log', (ev) => {
+ runLogArea.textContent += ev.data + '\n';
+ runLogArea.scrollTop = runLogArea.scrollHeight;
+ });
+
+ activeEs.addEventListener('tan-required', (ev) => {
+ try {
+ openTanModal(JSON.parse(ev.data));
+ } catch (e) {
+ runLogArea.textContent += '\n[TAN-Anforderung konnte nicht geparst werden]\n';
+ }
+ });
+
+ activeEs.addEventListener('accounts-required', (ev) => {
+ try {
+ openAccountModal(JSON.parse(ev.data));
+ } catch (e) {
+ runLogArea.textContent += '\n[Konto-Auswahl konnte nicht geparst werden]\n';
+ }
+ });
+
+ activeEs.addEventListener('done', (ev) => {
+ activeEs.close();
+ activeEs = null;
+ runWasOk = ev.data === 'ok';
+ runTitle.textContent = runWasOk ? 'Abruf abgeschlossen' : 'Abruf mit Fehlern beendet';
+ runSpinner.textContent = runWasOk ? '✓ fertig' : '⚠ Fehler';
+ btnRunClose.disabled = false;
+ });
+
+ activeEs.addEventListener('error', () => {
+ activeEs.close();
+ activeEs = null;
+ runLogArea.textContent += '\n[Verbindung unterbrochen]\n';
+ runLogArea.scrollTop = runLogArea.scrollHeight;
+ runTitle.textContent = 'Abruf unterbrochen';
+ runSpinner.textContent = '✗ unterbrochen';
+ btnRunClose.disabled = false;
+ });
+ });
+ });
+
+ document.querySelectorAll('.btn-edit-connection').forEach(btn => {
+ btn.addEventListener('click', () => {
+ const id = parseInt(btn.dataset.id, 10);
+ const conn = connections.find(c => c.id === id);
+ if (!conn) return;
+
+ formEdit.action = '/bank/direkt/' + id;
+ formDelete.action = '/bank/direkt/' + id + '/delete';
+
+ editName.textContent = conn.provider_name;
+ editBez.value = conn.bezeichnung;
+ editKuerzel.value = conn.kuerzel || '';
+ editFarbe.value = conn.farbe || '#6366f1';
+
+ const isApi = conn.type === 'api';
+ const isFints = conn.type === 'fints';
+
+ 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 = '';
+ editSecret.value = '';
+ editSandbox.checked = conn.sandbox === true;
+ }
+ if (isFints) {
+ editBlz.value = conn.blz || '';
+ editBenutzer.value = conn.benutzerkennung || '';
+ editPin.value = '';
+ editFintsUrl.value = conn.fints_url || '';
+ editTanModeId.value = conn.tan_mode_id != null ? conn.tan_mode_id : '';
+ editResetAcc.checked = false;
+
+ // Konto-Checkboxen aus account_filter aufbauen
+ editAccountList.innerHTML = '';
+ let accounts = [];
+ if (conn.account_filter) {
+ try {
+ const arr = JSON.parse(conn.account_filter);
+ if (Array.isArray(arr)) accounts = arr.map(String);
+ } catch (e) { /* ignore */ }
+ }
+ const editAccountEmpty = document.getElementById('edit-account-empty');
+ if (accounts.length === 0) {
+ editAccountList.style.display = 'none';
+ editAccountEmpty.style.display = 'block';
+ } else {
+ editAccountList.style.display = 'flex';
+ editAccountEmpty.style.display = 'none';
+ accounts.forEach(id => {
+ const row = document.createElement('label');
+ row.style.display = 'flex';
+ row.style.alignItems = 'center';
+ row.style.gap = '.5rem';
+ row.style.cursor = 'pointer';
+ row.style.fontWeight = 'normal';
+ row.style.margin = '0';
+ const cb = document.createElement('input');
+ cb.type = 'checkbox';
+ cb.name = 'active_accounts[]';
+ cb.value = id;
+ cb.checked = true;
+ cb.style.width = 'auto';
+ cb.style.margin = '0';
+ const span = document.createElement('span');
+ // IBAN lesbarer machen (4er-Gruppen), AN:-Identifier wie geliefert
+ span.textContent = /^[A-Z]{2}\d{2}/.test(id)
+ ? id.replace(/(.{4})/g, '$1 ').trim()
+ : id;
+ span.style.fontFamily = 'monospace';
+ span.style.fontSize = '.85rem';
+ row.appendChild(cb);
+ row.appendChild(span);
+ editAccountList.appendChild(row);
+ });
+ }
+ }
+
+ modalEdit.style.display = 'flex';
+ });
+ });
+})();
diff --git a/src/Controllers/BankController.php b/src/Controllers/BankController.php
index f674a78..8181829 100644
--- a/src/Controllers/BankController.php
+++ b/src/Controllers/BankController.php
@@ -12,6 +12,7 @@ use Steuerkram\Models\BankTransaction;
use Steuerkram\Controllers\InvoiceController;
use Steuerkram\Models\Beleg;
use Steuerkram\Models\Invoice;
+use Steuerkram\Request;
use Steuerkram\Services\BankImportParser;
use Steuerkram\Services\FinTsFetchService;
use Steuerkram\Services\PayPalNvpFetchService;
@@ -29,7 +30,7 @@ class BankController extends BaseController
$filter['status'] = 'pending';
}
- $page = max(1, (int)($_GET['page'] ?? 1));
+ $page = max(1, (int)Request::get('page', 1));
$result = BankTransaction::all($filter, $page);
$formats = BankSource::all('csv');
@@ -120,7 +121,7 @@ class BankController extends BaseController
public function upload(array $params): void
{
- $formatId = (int)($_POST['format_id'] ?? 0);
+ $formatId = Request::postInt('format_id');
$format = BankSource::find($formatId);
if (!$format || $format['type'] !== 'csv') {
@@ -243,7 +244,7 @@ class BankController extends BaseController
public function updateStatus(array $params): void
{
$id = (int)($params['id'] ?? 0);
- $status = $_POST['status'] ?? '';
+ $status = Request::post('status');
if (!in_array($status, ['offen', 'teilweise', 'gebucht', 'ignoriert'], true)) {
View::redirect('/bank', 'Ungültiger Status.', 'error');
@@ -257,8 +258,8 @@ class BankController extends BaseController
public function linkBeleg(array $params): void
{
$id = (int)($params['id'] ?? 0);
- $belegId = (int)($_POST['beleg_id'] ?? 0);
- $betrag = round((float)str_replace(',', '.', $_POST['alloc_betrag'] ?? ''), 2);
+ $belegId = Request::postInt('beleg_id');
+ $betrag = round(Request::postFloat('alloc_betrag'), 2);
if (!$belegId) {
View::redirect("/bank/$id", 'Kein Beleg ausgewählt.', 'error');
@@ -273,8 +274,8 @@ class BankController extends BaseController
public function linkInvoice(array $params): void
{
$id = (int)($params['id'] ?? 0);
- $invoiceId = (int)($_POST['invoice_id'] ?? 0);
- $betrag = round((float)str_replace(',', '.', $_POST['alloc_betrag'] ?? ''), 2);
+ $invoiceId = Request::postInt('invoice_id');
+ $betrag = round(Request::postFloat('alloc_betrag'), 2);
if (!$invoiceId) {
View::redirect("/bank/$id", 'Keine Rechnung ausgewählt.', 'error');
@@ -303,8 +304,8 @@ class BankController extends BaseController
public function unlinkBeleg(array $params): void
{
$id = (int)($params['id'] ?? 0);
- $belegId = (int)($_POST['beleg_id'] ?? 0);
- $redirectTo = $_POST['redirect_to'] ?? '';
+ $belegId = Request::postInt('beleg_id');
+ $redirectTo = Request::post('redirect_to');
BankTransaction::unlinkBeleg($id, $belegId);
@@ -393,8 +394,8 @@ class BankController extends BaseController
public function direktStore(array $params): void
{
- $providerRaw = (string)($_POST['provider_id'] ?? '');
- $bezeichnung = trim($_POST['bezeichnung'] ?? '');
+ $providerRaw = (string)Request::post('provider_id');
+ $bezeichnung = trim((string)Request::post('bezeichnung'));
if ($bezeichnung === '') {
View::redirect('/bank/direkt', 'Bitte alle Pflichtfelder ausfüllen.', 'error');
@@ -425,27 +426,27 @@ class BankController extends BaseController
private function storeCsvFormat(string $bezeichnung): void
{
- $kuerzel = trim($_POST['kuerzel'] ?? '');
+ $kuerzel = trim((string)Request::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'] ?? '');
+ $delimiter = (string)Request::post('csv_delimiter', ';');
+ $enclosure = (string)Request::post('csv_enclosure', '"');
+ $dateFmt = (string)Request::post('csv_date_format', 'd.m.Y');
+ $decSep = (string)Request::post('csv_decimal_separator', ',');
+ $thouSep = (string)Request::post('csv_thousands_separator', '.');
+ $farbe = (string)Request::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,
+ 'skip_rows' => max(0, min(99, Request::postInt('csv_skip_rows'))),
+ 'has_header_row' => Request::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',
@@ -465,8 +466,8 @@ class BankController extends BaseController
private function resolveProviderId(string $providerRaw): ?int
{
if ($providerRaw === '__new__') {
- $newName = trim((string)($_POST['new_bank_name'] ?? ''));
- $newTyp = ($_POST['new_bank_typ'] ?? 'FinTS') === 'API' ? 'API' : 'FinTS';
+ $newName = trim((string)Request::post('new_bank_name'));
+ $newTyp = Request::post('new_bank_typ', 'FinTS') === 'API' ? 'API' : 'FinTS';
if ($newName === '') {
View::redirect('/bank/direkt', 'Bitte den Banknamen angeben.', 'error');
return null;
@@ -490,7 +491,7 @@ class BankController extends BaseController
$isApi = $provider['verbindungstyp'] === 'API';
$isFints = $provider['verbindungstyp'] === 'FinTS';
- $fintsUrl = $isFints ? (trim($_POST['fints_url'] ?? '') ?: null) : null;
+ $fintsUrl = $isFints ? (trim((string)Request::post('fints_url')) ?: null) : null;
if ($fintsUrl !== null) {
try {
SsrfGuard::assertPublicHttpsUrl($fintsUrl);
@@ -504,17 +505,17 @@ class BankController extends BaseController
'type' => $isApi ? 'api' : 'fints',
'provider_id' => $providerId,
'bezeichnung' => $bezeichnung,
- 'kuerzel' => trim($_POST['kuerzel'] ?? '') ?: null,
- 'farbe' => trim($_POST['farbe'] ?? '') ?: '#6366f1',
- 'sandbox' => $isApi ? (isset($_POST['sandbox']) ? 1 : 0) : 0,
- 'app_name' => $isApi ? (trim($_POST['app_name'] ?? '') ?: null) : null,
- 'client_id' => $isApi ? (trim($_POST['client_id'] ?? '') ?: null) : null,
- 'secret' => $isApi ? (trim($_POST['secret'] ?? '') ?: null) : null,
- 'blz' => $isFints ? (trim($_POST['blz'] ?? '') ?: null) : null,
+ 'kuerzel' => trim((string)Request::post('kuerzel')) ?: null,
+ 'farbe' => trim((string)Request::post('farbe')) ?: '#6366f1',
+ 'sandbox' => $isApi ? (Request::has('sandbox') ? 1 : 0) : 0,
+ 'app_name' => $isApi ? (trim((string)Request::post('app_name')) ?: null) : null,
+ 'client_id' => $isApi ? (trim((string)Request::post('client_id')) ?: null) : null,
+ 'secret' => $isApi ? (trim((string)Request::post('secret')) ?: null) : null,
+ 'blz' => $isFints ? (trim((string)Request::post('blz')) ?: null) : null,
'fints_url' => $fintsUrl,
- 'benutzerkennung' => $isFints ? (trim($_POST['benutzerkennung'] ?? '') ?: null) : null,
- 'pin' => $isFints ? (trim($_POST['pin'] ?? '') ?: null) : null,
- 'tan_mode_id' => $isFints ? (($_POST['tan_mode_id'] ?? '') !== '' ? (int)$_POST['tan_mode_id'] : null) : null,
+ 'benutzerkennung' => $isFints ? (trim((string)Request::post('benutzerkennung')) ?: null) : null,
+ 'pin' => $isFints ? (trim((string)Request::post('pin')) ?: null) : null,
+ 'tan_mode_id' => $isFints ? (Request::post('tan_mode_id') !== '' ? Request::postInt('tan_mode_id') : null) : null,
'account_filter' => null, // Konten werden beim ersten Abruf ausgewählt
]);
@@ -523,7 +524,7 @@ class BankController extends BaseController
public function direktRun(array $params): void
{
- Auth::validateCsrfToken((string)($_GET['csrf_token'] ?? ''));
+ Auth::validateCsrfToken((string)Request::get('csrf_token'));
$id = (int)($params['id'] ?? 0);
$source = BankSource::find($id);
@@ -578,7 +579,7 @@ class BankController extends BaseController
public function direktUpdate(array $params): void
{
$id = (int)($params['id'] ?? 0);
- $bezeichnung = trim($_POST['bezeichnung'] ?? '');
+ $bezeichnung = trim((string)Request::post('bezeichnung'));
if (!$id || $bezeichnung === '') {
View::redirect('/bank/direkt', 'Bitte alle Pflichtfelder ausfüllen.', 'error');
@@ -607,11 +608,11 @@ class BankController extends BaseController
$data = [
'bezeichnung' => $bezeichnung,
- 'kuerzel' => trim($_POST['kuerzel'] ?? '') ?: null,
- 'farbe' => trim($_POST['farbe'] ?? '') ?: '#6366f1',
+ 'kuerzel' => trim((string)Request::post('kuerzel')) ?: null,
+ 'farbe' => trim((string)Request::post('farbe')) ?: '#6366f1',
];
- $rangePost = (string)($_POST['fetch_range'] ?? '');
+ $rangePost = (string)Request::post('fetch_range');
if (in_array($rangePost, BankSource::FETCH_RANGES, true)) {
$data['fetch_range'] = $rangePost;
}
@@ -631,11 +632,11 @@ class BankController extends BaseController
private function buildApiUpdateData(array $source): array
{
- $newPwd = trim($_POST['client_id'] ?? '');
- $newSecret = trim($_POST['secret'] ?? '');
+ $newPwd = trim((string)Request::post('client_id'));
+ $newSecret = trim((string)Request::post('secret'));
return [
- 'sandbox' => isset($_POST['sandbox']) ? 1 : 0,
- 'app_name' => trim($_POST['app_name'] ?? '') ?: null,
+ 'sandbox' => Request::has('sandbox') ? 1 : 0,
+ 'app_name' => trim((string)Request::post('app_name')) ?: null,
'client_id' => $newPwd !== '' ? $newPwd : $source['client_id'],
'secret' => $newSecret !== '' ? $newSecret : $source['secret'],
];
@@ -643,8 +644,8 @@ class BankController extends BaseController
private function buildFintsUpdateData(array $source): ?array
{
- $newPin = trim($_POST['pin'] ?? '');
- $fintsUrl = trim($_POST['fints_url'] ?? '') ?: null;
+ $newPin = trim((string)Request::post('pin'));
+ $fintsUrl = trim((string)Request::post('fints_url')) ?: null;
if ($fintsUrl !== null) {
try {
SsrfGuard::assertPublicHttpsUrl($fintsUrl);
@@ -654,11 +655,11 @@ class BankController extends BaseController
}
}
$data = [
- 'blz' => trim($_POST['blz'] ?? '') ?: null,
+ 'blz' => trim((string)Request::post('blz')) ?: null,
'fints_url' => $fintsUrl,
- 'benutzerkennung' => trim($_POST['benutzerkennung'] ?? '') ?: null,
+ 'benutzerkennung' => trim((string)Request::post('benutzerkennung')) ?: null,
'pin' => $newPin !== '' ? $newPin : $source['pin'],
- 'tan_mode_id' => ($_POST['tan_mode_id'] ?? '') !== '' ? (int)$_POST['tan_mode_id'] : null,
+ 'tan_mode_id' => Request::post('tan_mode_id') !== '' ? Request::postInt('tan_mode_id') : null,
];
$filterUpdate = BankSource::computeFilterFromPost(
$source['account_filter'] ?? null,
@@ -682,7 +683,7 @@ class BankController extends BaseController
{
header('Content-Type: application/json');
- $raw = $_POST['ids'] ?? '[]';
+ $raw = Request::post('ids', '[]');
$ids = is_array($raw) ? $raw : (json_decode((string)$raw, true) ?: []);
if (!is_array($ids)) {
http_response_code(400);
@@ -698,8 +699,8 @@ class BankController extends BaseController
{
header('Content-Type: application/json');
- $token = trim((string)($_POST['token'] ?? ''));
- $tan = trim((string)($_POST['tan'] ?? ''));
+ $token = trim((string)Request::post('token'));
+ $tan = trim((string)Request::post('tan'));
if ($tan === '') {
http_response_code(400);
@@ -719,8 +720,8 @@ class BankController extends BaseController
{
header('Content-Type: application/json');
- $token = trim((string)($_POST['token'] ?? ''));
- $raw = $_POST['ids'] ?? '[]';
+ $token = trim((string)Request::post('token'));
+ $raw = Request::post('ids', '[]');
$ids = is_array($raw) ? $raw : (json_decode((string)$raw, true) ?: []);
if (!is_array($ids) || empty($ids)) {
diff --git a/src/Request.php b/src/Request.php
new file mode 100644
index 0000000..61e4679
--- /dev/null
+++ b/src/Request.php
@@ -0,0 +1,71 @@
+format('Y-m-d');
+ }
+
+ public static function get(string $key, mixed $default = ''): mixed
+ {
+ return $_GET[$key] ?? $default;
+ }
+
+ public static function has(string $key): bool
+ {
+ return isset($_POST[$key]);
+ }
+}
diff --git a/src/Views/bank/_connection_grid.php b/src/Views/bank/_connection_grid.php
new file mode 100644
index 0000000..a1154f7
--- /dev/null
+++ b/src/Views/bank/_connection_grid.php
@@ -0,0 +1,75 @@
+
+
+
+
+
+
+ Noch keine Direktverbindungen konfiguriert. Klicke oben rechts auf +, um eine hinzuzufügen.
+
+
+
+
+
+
+
+
⠿
+
+
+
+
+
+
+
= $h($conn['kuerzel']) ?>
+
+
= $h($conn['bezeichnung']) ?>
+
+
= $h($conn['provider_name']) ?>
+
+ = $h($conn['verbindungstyp']) ?>
+
+
+
+
Abruf:
+
Kontostand
+
= $h(View::formatDate($conn['last_fetched_at'])) ?>
+
= $h(View::formatMoney($conn['last_balance'])) ?>
+
+
+
+ Abruf: = $conn['last_fetched_at'] ? $h(View::formatDate($conn['last_fetched_at'])) : '' ?>
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Views/bank/_modal_account_select.php b/src/Views/bank/_modal_account_select.php
new file mode 100644
index 0000000..de03948
--- /dev/null
+++ b/src/Views/bank/_modal_account_select.php
@@ -0,0 +1,14 @@
+
+
+
+
Konten auswählen
+
Wähle die Konten, die regelmäßig importiert werden sollen. Die Auswahl wird gespeichert und kann später im Bearbeiten-Dialog zurückgesetzt werden.
+
+
+
+
+
+
+
+
diff --git a/src/Views/bank/_modal_edit_connection.php b/src/Views/bank/_modal_edit_connection.php
new file mode 100644
index 0000000..ac11447
--- /dev/null
+++ b/src/Views/bank/_modal_edit_connection.php
@@ -0,0 +1,128 @@
+
+
+
+
+
diff --git a/src/Views/bank/_modal_new_connection.php b/src/Views/bank/_modal_new_connection.php
new file mode 100644
index 0000000..b6dcd61
--- /dev/null
+++ b/src/Views/bank/_modal_new_connection.php
@@ -0,0 +1,162 @@
+
+
diff --git a/src/Views/bank/_modal_run_log.php b/src/Views/bank/_modal_run_log.php
new file mode 100644
index 0000000..a72460a
--- /dev/null
+++ b/src/Views/bank/_modal_run_log.php
@@ -0,0 +1,17 @@
+
+
+
+
+
Abruf läuft…
+ ⏳ wird abgerufen…
+
+
+
+
+
+
+
+
diff --git a/src/Views/bank/_modal_tan_input.php b/src/Views/bank/_modal_tan_input.php
new file mode 100644
index 0000000..ff6b269
--- /dev/null
+++ b/src/Views/bank/_modal_tan_input.php
@@ -0,0 +1,21 @@
+
+
diff --git a/src/Views/bank/fints.php b/src/Views/bank/fints.php
index 0ccc066..6c99793 100644
--- a/src/Views/bank/fints.php
+++ b/src/Views/bank/fints.php
@@ -40,993 +40,15 @@ $csvDateFormatOptions = [
'Y.m.d' => '2026.05.15',
'd-m-Y' => '15-05-2026',
];
+
+$av = defined('ASSET_VERSION') ? ASSET_VERSION : 'dev';
?>
-
-
-
- Noch keine Direktverbindungen konfiguriert. Klicke oben rechts auf +, um eine hinzuzufügen.
-
-
-
-
-
-
-
-
⠿
-
-
-
-
-
-
-
= $h($conn['kuerzel']) ?>
-
-
= $h($conn['bezeichnung']) ?>
-
-
= $h($conn['provider_name']) ?>
-
- = $h($conn['verbindungstyp']) ?>
-
-
-
-
Abruf:
-
Kontostand
-
= $h(View::formatDate($conn['last_fetched_at'])) ?>
-
= $h(View::formatMoney($conn['last_balance'])) ?>
-
-
-
- Abruf: = $conn['last_fetched_at'] ? $h(View::formatDate($conn['last_fetched_at'])) : '' ?>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Konten auswählen
-
Wähle die Konten, die regelmäßig importiert werden sollen. Die Auswahl wird gespeichert und kann später im Bearbeiten-Dialog zurückgesetzt werden.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Abruf läuft…
- ⏳ wird abgerufen…
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
diff --git a/src/Views/settings/_format_card.php b/src/Views/settings/_format_card.php
new file mode 100644
index 0000000..42c6d66
--- /dev/null
+++ b/src/Views/settings/_format_card.php
@@ -0,0 +1,181 @@
+
+
diff --git a/src/Views/settings/_suggestion_card.php b/src/Views/settings/_suggestion_card.php
new file mode 100644
index 0000000..cb5e169
--- /dev/null
+++ b/src/Views/settings/_suggestion_card.php
@@ -0,0 +1,172 @@
+ 'Buchungstag',
+ 'wertstellung' => 'Wertstellung',
+ 'vorgang' => 'Vorgang / Umsatztyp',
+ 'wert' => 'Betrag',
+ 'zahlungspflichtiger' => 'Zahlungspflichtiger',
+ 'zahlungsempfaenger' => 'Zahlungsempfänger',
+ 'verwendungszweck' => 'Verwendungszweck',
+];
+?>
+
diff --git a/src/Views/settings/bankimport.php b/src/Views/settings/bankimport.php
index 0b9b1b8..84cfd3c 100644
--- a/src/Views/settings/bankimport.php
+++ b/src/Views/settings/bankimport.php
@@ -29,6 +29,8 @@ $dateFormatOptions = [
'Y.m.d' => '2026.05.15',
'd-m-Y' => '15-05-2026',
];
+
+$av = defined('ASSET_VERSION') ? ASSET_VERSION : 'dev';
?>
@@ -40,178 +42,7 @@ $dateFormatOptions = [
- 'Buchungstag',
- 'wertstellung' => 'Wertstellung',
- 'vorgang' => 'Vorgang / Umsatztyp',
- 'wert' => 'Betrag',
- 'zahlungspflichtiger' => 'Zahlungspflichtiger',
- 'zahlungsempfaenger' => 'Zahlungsempfänger',
- 'verwendungszweck' => 'Verwendungszweck',
-];
-?>
-
+
@@ -225,187 +56,7 @@ $allMappingFields = [
-
-
+
@@ -501,296 +152,5 @@ $mappingRowsAfter = [
}
-
+
+
diff --git a/tests/93_WebrootSauberkeitTest.php b/tests/93_WebrootSauberkeitTest.php
new file mode 100644
index 0000000..6fb55c7
--- /dev/null
+++ b/tests/93_WebrootSauberkeitTest.php
@@ -0,0 +1,38 @@
+assert(is_dir($publicDir), 'public/ existiert');
+
+ $erlaubt = ['index.php', '.htaccess', 'static'];
+ $eintraege = array_diff(scandir($publicDir), ['.', '..']);
+
+ foreach ($eintraege as $eintrag) {
+ $this->assert(
+ in_array($eintrag, $erlaubt, true),
+ "Unerwarteter Eintrag im Webroot: public/$eintrag"
+ );
+ }
+
+ $this->assert(
+ !file_exists($publicDir . '/O-CHAT.html'),
+ 'public/O-CHAT.html ist entfernt'
+ );
+ }
+}
diff --git a/tests/94_RequestHelperTest.php b/tests/94_RequestHelperTest.php
new file mode 100644
index 0000000..d0b68bd
--- /dev/null
+++ b/tests/94_RequestHelperTest.php
@@ -0,0 +1,96 @@
+assertEquals('', Request::post('fehlt'), 'post(): fehlender Wert liefert Default ""');
+ $this->assertEquals('x', Request::post('fehlt', 'x'), 'post(): fehlender Wert liefert übergebenen Default');
+ $_POST['feld'] = 'wert';
+ $this->assertEquals('wert', Request::post('feld'), 'post(): vorhandener Wert wird durchgereicht');
+
+ // ── postInt() ────────────────────────────────────────────
+ unset($_POST['n']);
+ $this->assertEquals(0, Request::postInt('n'), 'postInt(): fehlender Wert liefert Default 0');
+ $this->assertEquals(7, Request::postInt('n', 7), 'postInt(): fehlender Wert liefert übergebenen Default');
+
+ $_POST['n'] = '';
+ $this->assertEquals(7, Request::postInt('n', 7), 'postInt(): leerer String liefert Default');
+
+ $_POST['n'] = 'abc';
+ $this->assertEquals(0, Request::postInt('n', 7), 'postInt(): "abc" wird zu 0 (kein Default, da Wert gesetzt ist)');
+
+ $_POST['n'] = '12';
+ $this->assertEquals(12, Request::postInt('n'), 'postInt(): "12" wird zu 12');
+
+ $_POST['n'] = '12.7';
+ $this->assertEquals(12, Request::postInt('n'), 'postInt(): "12.7" wird zu 12 (int-Cast schneidet ab)');
+
+ // ── postFloat() ──────────────────────────────────────────
+ unset($_POST['betrag']);
+ $this->assertEquals(0.0, Request::postFloat('betrag'), 'postFloat(): fehlender Wert liefert Default 0.0');
+ $this->assertEquals(9.5, Request::postFloat('betrag', 9.5), 'postFloat(): fehlender Wert liefert übergebenen Default');
+
+ $_POST['betrag'] = '';
+ $this->assertEquals(9.5, Request::postFloat('betrag', 9.5), 'postFloat(): leerer String liefert Default');
+
+ $_POST['betrag'] = '1234,56';
+ $this->assertEquals(1234.56, Request::postFloat('betrag'), 'postFloat(): deutsches Dezimalkomma wird korrekt geparst');
+
+ $_POST['betrag'] = '-119,00';
+ $this->assertEquals(-119.0, Request::postFloat('betrag'), 'postFloat(): negativer Betrag mit Komma');
+
+ // ── postDate() ───────────────────────────────────────────
+ unset($_POST['datum']);
+ $this->assertEquals(null, Request::postDate('datum'), 'postDate(): fehlender Wert liefert null');
+
+ $_POST['datum'] = '';
+ $this->assertEquals(null, Request::postDate('datum'), 'postDate(): leerer Wert liefert null');
+
+ $_POST['datum'] = '2026-05-15';
+ $this->assertEquals('2026-05-15', Request::postDate('datum'), 'postDate(): gültiges Format (Y-m-d) wird normalisiert zurückgegeben');
+
+ $_POST['datum'] = 'nicht-valide';
+ $this->assertEquals(null, Request::postDate('datum'), 'postDate(): ungültiges Format liefert null');
+
+ $_POST['datum'] = '15.05.2026';
+ $this->assertEquals('2026-05-15', Request::postDate('datum', 'd.m.Y'), 'postDate(): abweichendes Format wird korrekt geparst und normalisiert');
+ $this->assertEquals(null, Request::postDate('datum'), 'postDate(): Wert passt nicht zum Default-Format Y-m-d');
+
+ // ── get() ────────────────────────────────────────────────
+ $this->assertEquals('', Request::get('fehlt'), 'get(): fehlender Wert liefert Default ""');
+ $this->assertEquals(1, Request::get('seite', 1), 'get(): fehlender Wert liefert übergebenen Default');
+ $_GET['seite'] = '3';
+ $this->assertEquals('3', Request::get('seite', 1), 'get(): vorhandener Wert wird durchgereicht');
+
+ // ── has() ────────────────────────────────────────────────
+ unset($_POST['sandbox']);
+ $this->assert(Request::has('sandbox') === false, 'has(): fehlendes Feld liefert false');
+ $_POST['sandbox'] = '1';
+ $this->assert(Request::has('sandbox') === true, 'has(): vorhandenes Feld liefert true');
+ $_POST['leer'] = '';
+ $this->assert(Request::has('leer') === true, 'has(): auch ein leerer, aber gesetzter Wert zählt als vorhanden');
+
+ $_POST = [];
+ $_GET = [];
+ }
+}
diff --git a/tests/95_FinTsViewStrukturTest.php b/tests/95_FinTsViewStrukturTest.php
new file mode 100644
index 0000000..5f2f3f5
--- /dev/null
+++ b/tests/95_FinTsViewStrukturTest.php
@@ -0,0 +1,78 @@
+-Tag, das Markup steckt
+ * in Teil-Views, die Logik in einer eigenständigen JS-Datei.
+ */
+class FinTsViewStrukturTest extends TestCase
+{
+ protected string $testName = 'Test 95: Struktur von bank/fints.php';
+
+ public function run(): void
+ {
+ $viewDir = __DIR__ . '/../src/Views/bank';
+ $jsDir = __DIR__ . '/../public/static/js';
+ $viewFile = $viewDir . '/fints.php';
+ $content = file_get_contents($viewFile);
+
+ $this->assert($content !== false, 'bank/fints.php ist lesbar');
+
+ $lines = substr_count($content, "\n") + 1;
+ $this->assert(
+ $lines < 300,
+ "bank/fints.php bleibt unter 300 Zeilen (erhalten: $lines)"
+ );
+
+ $this->assert(
+ preg_match('/