Merge pull request 'Feature: Kontenrahmen + Online Bankenabruf FinTS + Paypal API' (#79) from fds2610-bot/Steuerkram:main into main

Reviewed-on: https://codeberg.org/fds2610/Steuerkram/pulls/79
This commit is contained in:
fds2610 2026-05-22 07:33:35 +02:00
commit a800b2553d
33 changed files with 2892 additions and 83 deletions

View file

@ -4,6 +4,12 @@ DB_NAME=steuerkram
DB_USER=steuerkram
DB_PASS=steuerkram
# App-Schlüssel für die Verschlüsselung sensibler Felder (PayPal-Signature, FinTS-PIN).
# Einmalig generieren und niemals ändern – ohne diesen Key sind verschlüsselte
# Werte in der Datenbank verloren. Im Backup mitsichern!
# Erzeugen mit: php -r "echo bin2hex(random_bytes(32));"
APP_KEY=
# KI-Kontierungsvorschlag (optional)
# Wenn nichts konfiguriert wird, sucht die App automatisch nach einem lokalen Ollama auf localhost:11434.
#

View file

@ -2,6 +2,20 @@
---
## 2026-05-21 – Feature: FinTS-Direktverbindung + Vereinheitlichung der Bank-Importquellen
- **FinTS-Abruf** für Comdirect, DKB und weitere Banken über `nemiah/php-fints` (PHP-Lib für HBCI/FinTS 3.0). Leseabruf mit PIN; TAN-pflichtige Aktionen werden mit klarer Fehlermeldung abgebrochen
- **Verschlüsselung sensibler Felder**: Neuer `Crypto`-Service (AES-256-GCM, Magic-Prefix `enc:v1:`) verschlüsselt PayPal-Signature und FinTS-PIN. Schlüssel aus `APP_KEY` in `.env` – im Backup mitsichern, Verlust = Verlust aller verschlüsselten Werte
- **Datenmodell-Fusion**: Neue Tabelle `bank_sources` ersetzt `bank_import_formats` + `bank_connections` mit `type`-Spalte (`csv`/`api`/`fints`). `bank_import_batches.source_id` löst `format_id`/`connection_id` ab (alte Spalten bleiben vorerst bestehen für rollback-Sicherheit)
- **Route-Umbenennung**: `/bank/fints*` → `/bank/direkt*` (semantisch korrekt – PayPal ist keine FinTS-Verbindung). Topbar in /bank vereinheitlicht zu einer „Importieren"-Schaltfläche
- **NEU/Edit-Modal** zeigt typabhängig API-Felder (PayPal: App-Name, Kennwort, Signature, Sandbox) oder FinTS-Felder (BLZ, Benutzerkennung, PIN, optional FinTS-URL und IBAN-Filter)
- **`FinTsFetchService`**: Login → SEPA-Konten abrufen → Statements seit `last_fetched_at` (00:00) → Buchungen mit Hash-basierter `external_id` (IBAN+Datum+Betrag+Verwendungszweck) deduplizieren
- Migrationen 068 (bank_sources + Backfill), 069 (default_fints_url auf bank_providers für Comdirect/DKB)
- `scripts/encrypt_bank_secrets.php` – einmaliges idempotentes Verschlüsseln der Klartext-Werte nach Backfill
- Neue Tests 36 (Crypto-Roundtrip) und 37 (BankSource CRUD über alle drei Typen)
---
## 2026-05-17 – Feature: Automatischer Format-Vorschlag beim Bank-Import
- **Format-Vorschlag**: Wenn eine hochgeladene CSV keinem bekannten Format eindeutig entspricht, analysiert `BankImportParser::suggestFormat()` die Datei automatisch (Headerzeilen-Erkennung bei skip_rows 0–10, Delimiter-Erkennung, Keyword-basiertes Spalten-Mapping)

View file

@ -5,7 +5,8 @@
"require": {
"php": ">=8.3",
"mpdf/mpdf": "^8.2",
"horstoeko/zugferd": "^1.0"
"horstoeko/zugferd": "^1.0",
"nemiah/php-fints": "^4.1"
},
"autoload": {
"psr-4": {

130
composer.lock generated
View file

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "71b50dea7db6ada8de06901c67446092",
"content-hash": "6753ca62c2c530201bde7ca190364ed9",
"packages": [
{
"name": "doctrine/instantiator",
@ -798,6 +798,54 @@
],
"time": "2025-08-01T08:46:24+00:00"
},
{
"name": "nemiah/php-fints",
"version": "4.1.0",
"source": {
"type": "git",
"url": "https://github.com/nemiah/phpFinTS.git",
"reference": "845c8cc973310c11ae2441e88e118c505d1b59f1"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/nemiah/phpFinTS/zipball/845c8cc973310c11ae2441e88e118c505d1b59f1",
"reference": "845c8cc973310c11ae2441e88e118c505d1b59f1",
"shasum": ""
},
"require": {
"ext-curl": "*",
"ext-mbstring": "*",
"php": ">=8.0",
"psr/log": "^1|^2|^3"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.0",
"php-mock/php-mock-phpunit": "^2.6",
"phpunit/phpunit": "^9.5"
},
"suggest": {
"abcaeffchen/sephpa": "1.*",
"monolog/monolog": "Allow sending log messages to a variety of different handlers",
"nemiah/php-sepa-xml": "dev-master"
},
"type": "library",
"autoload": {
"psr-4": {
"Fhp\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "PHP Library for the protocols fints and hbci",
"homepage": "https://github.com/nemiah/phpFinTS",
"support": {
"issues": "https://github.com/nemiah/phpFinTS/issues",
"source": "https://github.com/nemiah/phpFinTS/tree/4.1"
},
"time": "2026-05-10T19:07:56+00:00"
},
{
"name": "paragonie/random_compat",
"version": "v9.99.100",
@ -1046,16 +1094,16 @@
},
{
"name": "setasign/fpdi",
"version": "v2.6.6",
"version": "v2.6.7",
"source": {
"type": "git",
"url": "https://github.com/Setasign/FPDI.git",
"reference": "de0cf35911be3e9ea63b48e0f307883b1c7c48ac"
"reference": "388c51e69982a3fc16698710b763e8107a49f510"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Setasign/FPDI/zipball/de0cf35911be3e9ea63b48e0f307883b1c7c48ac",
"reference": "de0cf35911be3e9ea63b48e0f307883b1c7c48ac",
"url": "https://api.github.com/repos/Setasign/FPDI/zipball/388c51e69982a3fc16698710b763e8107a49f510",
"reference": "388c51e69982a3fc16698710b763e8107a49f510",
"shasum": ""
},
"require": {
@ -1106,7 +1154,7 @@
],
"support": {
"issues": "https://github.com/Setasign/FPDI/issues",
"source": "https://github.com/Setasign/FPDI/tree/v2.6.6"
"source": "https://github.com/Setasign/FPDI/tree/v2.6.7"
},
"funding": [
{
@ -1114,7 +1162,7 @@
"type": "tidelift"
}
],
"time": "2026-03-13T08:38:20+00:00"
"time": "2026-05-13T10:16:22+00:00"
},
{
"name": "smalot/pdfparser",
@ -1169,16 +1217,16 @@
},
{
"name": "symfony/deprecation-contracts",
"version": "v3.6.0",
"version": "v3.7.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/deprecation-contracts.git",
"reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62"
"reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62",
"reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62",
"url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b",
"reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b",
"shasum": ""
},
"require": {
@ -1191,7 +1239,7 @@
"name": "symfony/contracts"
},
"branch-alias": {
"dev-main": "3.6-dev"
"dev-main": "3.7-dev"
}
},
"autoload": {
@ -1216,7 +1264,7 @@
"description": "A generic function and convention to trigger deprecation notices",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0"
"source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0"
},
"funding": [
{
@ -1227,12 +1275,16 @@
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2024-09-25T14:21:43+00:00"
"time": "2026-04-13T15:52:40+00:00"
},
{
"name": "symfony/finder",
@ -1552,16 +1604,16 @@
},
{
"name": "symfony/process",
"version": "v7.4.8",
"version": "v7.4.11",
"source": {
"type": "git",
"url": "https://github.com/symfony/process.git",
"reference": "60f19cd3badc8de688421e21e4305eba50f8089a"
"reference": "d9593c9efa40499eb078b81144de42cbc28a31f0"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/process/zipball/60f19cd3badc8de688421e21e4305eba50f8089a",
"reference": "60f19cd3badc8de688421e21e4305eba50f8089a",
"url": "https://api.github.com/repos/symfony/process/zipball/d9593c9efa40499eb078b81144de42cbc28a31f0",
"reference": "d9593c9efa40499eb078b81144de42cbc28a31f0",
"shasum": ""
},
"require": {
@ -1593,7 +1645,7 @@
"description": "Executes commands in sub-processes",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/process/tree/v7.4.8"
"source": "https://github.com/symfony/process/tree/v7.4.11"
},
"funding": [
{
@ -1613,20 +1665,20 @@
"type": "tidelift"
}
],
"time": "2026-03-24T13:12:05+00:00"
"time": "2026-05-11T16:55:21+00:00"
},
{
"name": "symfony/translation-contracts",
"version": "v3.6.1",
"version": "v3.7.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/translation-contracts.git",
"reference": "65a8bc82080447fae78373aa10f8d13b38338977"
"reference": "0ab302977a952b42fd51475c4ebac81f8da0a95d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/translation-contracts/zipball/65a8bc82080447fae78373aa10f8d13b38338977",
"reference": "65a8bc82080447fae78373aa10f8d13b38338977",
"url": "https://api.github.com/repos/symfony/translation-contracts/zipball/0ab302977a952b42fd51475c4ebac81f8da0a95d",
"reference": "0ab302977a952b42fd51475c4ebac81f8da0a95d",
"shasum": ""
},
"require": {
@ -1639,7 +1691,7 @@
"name": "symfony/contracts"
},
"branch-alias": {
"dev-main": "3.6-dev"
"dev-main": "3.7-dev"
}
},
"autoload": {
@ -1675,7 +1727,7 @@
"standards"
],
"support": {
"source": "https://github.com/symfony/translation-contracts/tree/v3.6.1"
"source": "https://github.com/symfony/translation-contracts/tree/v3.7.0"
},
"funding": [
{
@ -1695,20 +1747,20 @@
"type": "tidelift"
}
],
"time": "2025-07-15T13:41:35+00:00"
"time": "2026-01-05T13:30:16+00:00"
},
{
"name": "symfony/validator",
"version": "v7.4.8",
"version": "v7.4.10",
"source": {
"type": "git",
"url": "https://github.com/symfony/validator.git",
"reference": "8f73cbddae916756f319b3e195088da216f0f12f"
"reference": "c76458623af9a3fe3b2e5b09b36453f334c2a361"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/validator/zipball/8f73cbddae916756f319b3e195088da216f0f12f",
"reference": "8f73cbddae916756f319b3e195088da216f0f12f",
"url": "https://api.github.com/repos/symfony/validator/zipball/c76458623af9a3fe3b2e5b09b36453f334c2a361",
"reference": "c76458623af9a3fe3b2e5b09b36453f334c2a361",
"shasum": ""
},
"require": {
@ -1779,7 +1831,7 @@
"description": "Provides tools to validate values",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/validator/tree/v7.4.8"
"source": "https://github.com/symfony/validator/tree/v7.4.10"
},
"funding": [
{
@ -1799,20 +1851,20 @@
"type": "tidelift"
}
],
"time": "2026-03-30T12:55:43+00:00"
"time": "2026-05-05T15:30:56+00:00"
},
{
"name": "symfony/yaml",
"version": "v7.4.8",
"version": "v7.4.12",
"source": {
"type": "git",
"url": "https://github.com/symfony/yaml.git",
"reference": "c58fdf7b3d6c2995368264c49e4e8b05bcff2883"
"reference": "8b6952b56ca6417f25f7a65758cadd0ce02edc51"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/yaml/zipball/c58fdf7b3d6c2995368264c49e4e8b05bcff2883",
"reference": "c58fdf7b3d6c2995368264c49e4e8b05bcff2883",
"url": "https://api.github.com/repos/symfony/yaml/zipball/8b6952b56ca6417f25f7a65758cadd0ce02edc51",
"reference": "8b6952b56ca6417f25f7a65758cadd0ce02edc51",
"shasum": ""
},
"require": {
@ -1855,7 +1907,7 @@
"description": "Loads and dumps YAML files",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/yaml/tree/v7.4.8"
"source": "https://github.com/symfony/yaml/tree/v7.4.12"
},
"funding": [
{
@ -1875,7 +1927,7 @@
"type": "tidelift"
}
],
"time": "2026-03-24T13:12:05+00:00"
"time": "2026-05-20T07:20:23+00:00"
}
],
"packages-dev": [],

View file

@ -18,6 +18,7 @@ services:
DB_USER: steuerkram
DB_PASS: steuerkram
APP_SECRET: change-this-in-production
APP_KEY: ${APP_KEY:-}
OLLAMA_URL: ${OLLAMA_URL:-http://host-gateway:11434}
OLLAMA_MODEL: ${OLLAMA_MODEL:-qwen3-vl}
extra_hosts:

View file

@ -64,16 +64,17 @@ Anlagenspiegel für abnutzbare Wirtschaftsgüter – siehe [anlagevermögen.md](
## Bank buchen
- Import von Kontoauszügen: CSV, TXT und XLSX (konfigurierbare Spalten- und Datumsformate je Bank)
- **Unterstützte Importformate**: Comdirect, DKB Bank
- **Automatische Format-Erkennung**: beim Datei-Upload wird das Format anhand von Datumsfeldern und Betragsspalten automatisch erkannt; bei eindeutigem Treffer wird der Import direkt gestartet
- **Format-Vorschau**: bei unbekanntem Format zeigt die Upload-Seite Überschriften- und erste Werte-Zeile sowie die konkrete Spalten-Zuordnung für das gewählte Format an (wechselt live)
- **Automatischer Format-Vorschlag**: wenn kein gespeichertes Format passt, analysiert das System die CSV automatisch (Headerzeilen-Erkennung, Keyword-Matching); über „Formate bearbeiten" öffnet sich die Einstellungsseite mit einem vorausgefüllten Vorschlags-Formular – das neue Format wird erst nach explizitem Klick auf „Speichern" angelegt
- **Dublettenprüfung**: beim Import werden Zeilen, die bereits in der DB vorhanden sind, als Duplikate markiert und nicht erneut importiert
- 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
- **Direktverbindung „PayPal"** (NVP/SOAP-API): API-Benutzer, API-Kennwort, Signature und Sandbox-Flag werden verschlüsselt abgelegt. Live-Log via Server-Sent Events
- **Direktverbindung „FinTS"** (Comdirect, DKB und weitere): BLZ, Benutzerkennung und PIN, optional FinTS-Server-URL (sonst Default aus dem Provider) und IBAN-Filter. PIN wird verschlüsselt abgelegt (AES-256-GCM). Leseabruf mit PIN; TAN-pflichtige Aktionen werden mit Fehlermeldung abgebrochen
- **Letzter Abrufzeitpunkt** wird pro Verbindung gespeichert; nächster Abruf startet ab 00:00 dieses Tages (max. ein Tag Überlappung für Dedup)
- **Dublettenprüfung**: CSV-Import vergleicht Datum+Betrag+Text; Direktverbindungen nutzen eine `external_id` (Hash aus IBAN/Datum/Betrag/Verwendungszweck bei FinTS, Transaktions-ID bei PayPal)
- **Beleg-Zuordnung**: jede Banktransaktion kann einem oder mehreren Belegen zugeordnet werden; der zugeordnete Betrag wird separat erfasst
- **Teilzuordnung**: eine Transaktion kann schrittweise mehreren Belegen anteilig zugeordnet werden
- **Status-Logik**: `offen` → keine Zuordnung, `teilweise` → Restsaldo > 0,01 €, `gebucht` → vollständig zugeordnet
- Direkter Beleg-Upload aus der Transaktionsansicht heraus (erzeugt neuen Beleg und verknüpft ihn)
- **Verschlüsselung**: Der `APP_KEY` in `.env` ist Pflicht für Direktverbindungen – im Backup mitsichern, Verlust = Verlust aller gespeicherten Credentials
---

View file

@ -0,0 +1,166 @@
-- Kontenrahmen-Sync vom 2026-05-21 (Prod-Stand)
-- Verwendet ON DUPLICATE KEY UPDATE – bestehende Buchungen bleiben erhalten.
INSERT INTO `accounts` (`id`,`kontonummer`,`bezeichnung`,`kontenart`,`beschreibung`,`steuercode`,`ustva_kz`,`euer_zeile`,`is_system`,`aktiv`,`created_at`,`updated_at`) VALUES
(1,'0027','EDV-Software','Anlagevermögen',NULL,NULL,NULL,NULL,1,1,'2026-04-11 06:39:07','2026-04-11 15:18:46'),
(2,'0030','Lizenzen an gewerblichen Schutzrechten','Anlagevermögen',NULL,NULL,NULL,NULL,1,1,'2026-04-11 06:39:07','2026-04-11 15:18:46'),
(3,'0210','Maschinen','Anlagevermögen',NULL,NULL,NULL,NULL,1,1,'2026-04-11 06:39:07','2026-04-11 15:18:46'),
(4,'0320','Pkw','Anlagevermögen',NULL,NULL,NULL,NULL,1,1,'2026-04-11 06:39:07','2026-04-11 15:18:46'),
(5,'0350','Lkw','Anlagevermögen',NULL,NULL,NULL,NULL,1,1,'2026-04-11 06:39:07','2026-04-11 15:18:46'),
(6,'0400','Betriebsausstattung','Anlagevermögen',NULL,NULL,NULL,NULL,1,1,'2026-04-11 06:39:07','2026-04-11 15:18:46'),
(7,'0410','Geschäftsausstattung','Anlagevermögen',NULL,NULL,NULL,NULL,1,1,'2026-04-11 06:39:07','2026-04-11 15:18:46'),
(8,'0420','Büroeinrichtung','Anlagevermögen',NULL,NULL,NULL,NULL,1,1,'2026-04-11 06:39:07','2026-04-11 15:18:46'),
(9,'0440','Werkzeuge','Anlagevermögen',NULL,NULL,NULL,NULL,1,1,'2026-04-11 06:39:07','2026-04-11 15:18:46'),
(10,'0480','Geringwertige Wirtschaftsgüter','Anlagevermögen',NULL,NULL,NULL,NULL,1,1,'2026-04-11 06:39:07','2026-04-11 15:18:46'),
(11,'1000','Kasse','Zahlungskonto',NULL,NULL,NULL,NULL,1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
(12,'1010','Nebenkasse 1','Zahlungskonto',NULL,NULL,NULL,NULL,1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
(13,'1200','Bank 1 - Comdirekt','Zahlungskonto','','',NULL,NULL,1,1,'2026-04-11 06:39:07','2026-05-14 16:28:49'),
(14,'1210','Bank 2 - Sonstige Banken und Kasse','Zahlungskonto','','',NULL,NULL,1,1,'2026-04-11 06:39:07','2026-05-14 16:28:49'),
(15,'1220','Bank 3','Zahlungskonto',NULL,NULL,NULL,NULL,1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
(16,'1230','Bank 4','Zahlungskonto',NULL,NULL,NULL,NULL,1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
(17,'1240','Bank 5','Zahlungskonto',NULL,NULL,NULL,NULL,1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
(18,'1390','Verrechnungskonto Ist-Versteuerung','Verbindlichkeit','','',NULL,NULL,1,1,'2026-04-11 06:39:07','2026-05-14 16:28:49'),
(19,'1770','Umsatzsteuer','Verbindlichkeit','','',NULL,NULL,1,1,'2026-04-11 06:39:07','2026-05-14 16:28:49'),
(20,'1771','Umsatzsteuer 7 %','Verbindlichkeit','','','83',NULL,1,1,'2026-04-11 06:39:07','2026-05-14 16:28:49'),
(21,'1776','Umsatzsteuer 19 %','Verbindlichkeit','','','83',NULL,1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
(22,'1780','Umsatzsteuer-Vorauszahlungen lfd. Jahr','Verbindlichkeit','','',NULL,NULL,1,1,'2026-04-11 06:39:07','2026-05-14 16:28:49'),
(23,'1781','Umsatzsteuer-Vorauszahlungen 1/11','Verbindlichkeit','','',NULL,NULL,1,1,'2026-04-11 06:39:07','2026-05-14 16:28:49'),
(24,'1800','Privatentnahmen','Privat',NULL,NULL,NULL,NULL,1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
(25,'1810','Privatsteuern','Privat',NULL,NULL,NULL,NULL,1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
(26,'1820','Sonderausgaben beschränkt abzugsfähig','Privat',NULL,NULL,NULL,NULL,1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
(27,'1830','Sonderausgaben unbeschränkt abzugsfähig','Privat',NULL,NULL,NULL,NULL,1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
(28,'1840','Zuwendungen, Spenden','Privat',NULL,NULL,NULL,NULL,1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
(29,'1890','Privateinlagen','Privat',NULL,NULL,NULL,NULL,1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
(30,'3106','Fremdleistungen 19 % Vorsteuer','Betriebsausgabe',NULL,'VSt19',NULL,'29',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
(31,'3108','Fremdleistungen 7 % Vorsteuer','Betriebsausgabe',NULL,'VSt7',NULL,'29',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
(32,'3109','Fremdleistungen ohne Vorsteuer','Betriebsausgabe',NULL,NULL,NULL,'29',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
(33,'3300','Wareneingang 7 % Vorsteuer','Betriebsausgabe',NULL,'VSt7',NULL,'28',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
(34,'3349','Wareneingang ohne Vorsteuerabzug','Betriebsausgabe',NULL,NULL,NULL,'26',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
(35,'3400','Wareneingang 19 % Vorsteuer','Betriebsausgabe',NULL,'VSt19',NULL,'27',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
(36,'4100','Löhne und Gehälter','Betriebsausgabe',NULL,NULL,NULL,'30',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
(37,'4110','Löhne','Betriebsausgabe',NULL,NULL,NULL,'30',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
(38,'4120','Gehälter','Betriebsausgabe',NULL,NULL,NULL,'30',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
(39,'4130','Gesetzliche soziale Aufwendungen','Betriebsausgabe',NULL,NULL,NULL,'31',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
(40,'4138','Beiträge zur Berufsgenossenschaft','Betriebsausgabe',NULL,NULL,NULL,'31',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
(41,'4140','Freiwillige soziale Aufwendungen, lohnsteuerfrei','Betriebsausgabe',NULL,NULL,NULL,'32',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
(42,'4145','Freiwillige soziale Aufwendungen, lohnsteuerpflichtig','Betriebsausgabe',NULL,NULL,NULL,'32',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
(43,'4180','Aushilfslöhne','Betriebsausgabe',NULL,NULL,NULL,'30',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
(44,'4190','Aushilfslöhne Minijobs','Betriebsausgabe',NULL,NULL,NULL,'30',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
(45,'4210','Miete (unbewegliche Wirtschaftsgüter)','Betriebsausgabe',NULL,NULL,NULL,'33',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
(46,'4211','Miete/Pacht unbewegliche WG (gewerbesteuerl. hinzuzur.)','Betriebsausgabe',NULL,NULL,NULL,'33',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
(47,'4220','Pacht (unbewegliche Wirtschaftsgüter)','Betriebsausgabe',NULL,NULL,NULL,'33',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
(48,'4230','Heizung','Betriebsausgabe',NULL,'VSt19',NULL,'34',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
(49,'4240','Gas, Strom, Wasser','Betriebsausgabe',NULL,'VSt19',NULL,'34',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
(50,'4250','Reinigung','Betriebsausgabe',NULL,'VSt19',NULL,'37',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
(51,'4260','Instandhaltung betrieblicher Räume','Betriebsausgabe',NULL,'VSt19',NULL,'37',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
(52,'4280','Sonstige Raumkosten','Betriebsausgabe',NULL,'VSt19',NULL,'33',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
(53,'4287','Tagespauschale für Tätigkeit in der häuslichen Wohnung','Betriebsausgabe',NULL,NULL,NULL,'66',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
(54,'4288','Häusliches Arbeitszimmer (abziehbarer Anteil)','Betriebsausgabe',NULL,NULL,NULL,'65',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
(55,'4289','Häusliches Arbeitszimmer (nicht abziehbarer Anteil)','Betriebsausgabe',NULL,NULL,NULL,'51',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
(56,'4300','Nicht abziehbare Vorsteuer','Betriebsausgabe',NULL,NULL,NULL,'52',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
(57,'4301','Nicht abziehbare Vorsteuer 7 %','Betriebsausgabe',NULL,NULL,NULL,'52',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
(58,'4306','Nicht abziehbare Vorsteuer 19 %','Betriebsausgabe',NULL,NULL,NULL,'52',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
(59,'4320','Gewerbesteuer','Betriebsausgabe',NULL,NULL,NULL,'51',1,1,'2026-04-11 06:39:07','2026-04-12 20:33:08'),
(60,'4340','Sonstige Betriebssteuern','Betriebsausgabe',NULL,NULL,NULL,'52',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
(61,'4350','Verbrauchsteuer (sonstige Steuern)','Betriebsausgabe',NULL,NULL,NULL,'52',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
(62,'4360','Versicherungen','Betriebsausgabe',NULL,NULL,NULL,'52',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
(63,'4366','Versicherungen für Gebäude','Betriebsausgabe',NULL,NULL,NULL,'52',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
(64,'4380','Beiträge','Betriebsausgabe',NULL,NULL,NULL,'52',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
(65,'4390','Sonstige Abgaben','Betriebsausgabe',NULL,NULL,NULL,'52',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
(66,'4500','Fahrzeugkosten (allgemein)','Betriebsausgabe',NULL,NULL,NULL,'40',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
(67,'4510','Kfz-Steuer','Betriebsausgabe',NULL,NULL,NULL,'40',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
(68,'4520','Fahrzeug-Versicherungen','Betriebsausgabe',NULL,NULL,NULL,'40',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
(69,'4530','Laufende Fahrzeug-Betriebskosten','Betriebsausgabe',NULL,'VSt19',NULL,'40',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
(70,'4540','Fahrzeug-Reparaturen','Betriebsausgabe',NULL,'VSt19',NULL,'40',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
(71,'4550','Garagenmiete','Betriebsausgabe',NULL,'VSt19',NULL,'40',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
(72,'4560','Mautgebühren','Betriebsausgabe',NULL,NULL,NULL,'40',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
(73,'4570','Mietleasing Kfz','Betriebsausgabe',NULL,'VSt19',NULL,'40',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
(74,'4575','Mietleasing Elektrofahrzeuge/Fahrräder (gewerbesteuerl. hinzuzurechnen)','Betriebsausgabe',NULL,'VSt19',NULL,'40',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
(75,'4580','Sonstige Fahrzeugkosten','Betriebsausgabe',NULL,'VSt19',NULL,'40',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
(76,'4590','Fremdfahrzeugkosten','Betriebsausgabe',NULL,'VSt19',NULL,'40',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
(77,'4600','Werbekosten','Betriebsausgabe',NULL,'VSt19',NULL,'41',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
(78,'4605','Streuartikel','Betriebsausgabe',NULL,'VSt19',NULL,'41',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
(79,'4630','Geschenke abzugsfähig ohne § 37b EStG','Betriebsausgabe',NULL,'VSt19',NULL,'41',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
(80,'4631','Geschenke abzugsfähig mit § 37b EStG (§ 37b-Steuer)','Betriebsausgabe',NULL,'VSt19',NULL,'41',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
(81,'4635','Geschenke nicht abzugsfähig ohne § 37b EStG','Betriebsausgabe',NULL,'VSt19',NULL,'51',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
(82,'4636','Geschenke nicht abzugsfähig mit § 37b EStG','Betriebsausgabe',NULL,'VSt19',NULL,'51',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
(83,'4650','Bewirtungskosten (70 % abzugsfähig) VSt. 19%','Betriebsausgabe',NULL,'VSt19',NULL,'44',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
(84,'4651','Bewirtungskosten (70 % abzugsfähig) VSt. 7%','Betriebsausgabe',NULL,'VSt7',NULL,'44',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
(85,'4654','Nicht abzugsfähige Bewirtungskosten VSt. 19%','Betriebsausgabe',NULL,'VSt19',NULL,'51',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
(86,'4660','Reisekosten Arbeitnehmer','Betriebsausgabe',NULL,NULL,NULL,'42',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
(87,'4663','Reisekosten Arbeitnehmer Fahrtkosten','Betriebsausgabe',NULL,NULL,NULL,'42',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
(88,'4664','Reisekosten Arbeitnehmer Verpflegungsmehraufwand','Betriebsausgabe',NULL,NULL,NULL,'42',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
(89,'4666','Reisekosten Arbeitnehmer Übernachtungsaufwand','Betriebsausgabe',NULL,'VSt19',NULL,'42',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
(90,'4668','Kilometergelderstattung Arbeitnehmer','Betriebsausgabe',NULL,NULL,NULL,'42',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
(91,'4670','Reisekosten Unternehmer','Betriebsausgabe',NULL,NULL,NULL,'43',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
(92,'4672','Reisekosten Unternehmer (nicht abziehbarer Anteil)','Betriebsausgabe',NULL,NULL,NULL,'51',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
(93,'4673','Reisekosten Unternehmer Fahrtkosten','Betriebsausgabe',NULL,NULL,NULL,'43',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
(94,'4674','Reisekosten Unternehmer Verpflegungsmehraufwand','Betriebsausgabe',NULL,NULL,NULL,'43',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
(95,'4676','Reisekosten Unternehmer Übernachtungsaufwand','Betriebsausgabe','','',NULL,'42',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
(96,'4780','Fremdarbeiten','Betriebsausgabe',NULL,'VSt19',NULL,'29',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
(97,'4800','Reparaturen und Instandhaltungen (Maschinen, techn. Anlagen)','Betriebsausgabe',NULL,'VSt19',NULL,'38',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
(98,'4801','Reparaturen und Instandhaltung von Bauten','Betriebsausgabe',NULL,'VSt19',NULL,'37',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
(99,'4805','Reparaturen und Instandhaltungen (andere Anlagen, BGA)','Betriebsausgabe',NULL,'VSt19',NULL,'38',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
(100,'4806','Wartungskosten Hard- und Software','Betriebsausgabe',NULL,'VSt19',NULL,'38',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
(101,'4809','Sonstige Reparaturen und Instandhaltungen','Betriebsausgabe',NULL,'VSt19',NULL,'38',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
(102,'4822','Abschreibungen auf immaterielle Vermögensgegenstände','Betriebsausgabe',NULL,NULL,NULL,'49',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
(103,'4830','Abschreibungen auf Sachanlagen (ohne AfA Fzg./Gebäude)','Betriebsausgabe',NULL,NULL,NULL,'49',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
(104,'4831','Abschreibungen auf Gebäude','Betriebsausgabe',NULL,NULL,NULL,'49',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
(105,'4832','Abschreibungen auf Fahrzeuge','Betriebsausgabe',NULL,NULL,NULL,'49',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
(106,'4855','Sofortabschreibung geringwertiger Wirtschaftsgüter','Betriebsausgabe',NULL,NULL,NULL,'50',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
(107,'4856','Sonderabschreibungen nach § 7b EStG (Mietwohnungsneubau)','Betriebsausgabe',NULL,NULL,NULL,'37',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
(108,'4900','Sonstige betriebliche Aufwendungen','Betriebsausgabe',NULL,'VSt19',NULL,'52',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
(109,'4909','Fremdleistungen / Fremdarbeiten','Betriebsausgabe',NULL,'VSt19',NULL,'29',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
(110,'4910','Porto','Betriebsausgabe',NULL,NULL,NULL,'47',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
(111,'4920','Telefon','Betriebsausgabe',NULL,'VSt19',NULL,'47',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
(112,'4925','Internetkosten','Betriebsausgabe',NULL,'VSt19',NULL,'47',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
(113,'4930','Bürobedarf','Betriebsausgabe',NULL,'VSt19',NULL,'47',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
(114,'4940','Zeitschriften, Bücher, digitale Medien (Fachliteratur)','Betriebsausgabe',NULL,'VSt7',NULL,'47',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
(115,'4945','Fortbildungskosten','Betriebsausgabe',NULL,'VSt19',NULL,'52',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
(116,'4950','Rechts- und Beratungskosten','Betriebsausgabe',NULL,'VSt19',NULL,'52',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
(117,'4955','Buchführungskosten','Betriebsausgabe',NULL,'VSt19',NULL,'46',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
(118,'4957','Abschluss- und Prüfungskosten','Betriebsausgabe',NULL,'VSt19',NULL,'46',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
(119,'4960','Mieten für Einrichtungen (bewegliche Wirtschaftsgüter)','Betriebsausgabe',NULL,'VSt19',NULL,'48',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
(120,'4964','Zeitlich befristete Überlassung von Rechten (Lizenzen)','Betriebsausgabe',NULL,'VSt19',NULL,'48',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
(121,'4965','Mietleasing (bewegliche WG / Geschäftsausstattung)','Betriebsausgabe',NULL,'VSt19',NULL,'48',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
(122,'4969','Abraum- und Abfallbeseitigung','Betriebsausgabe',NULL,'VSt19',NULL,'52',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
(123,'4970','Nebenkosten des Geldverkehrs (Bankgebühren, Zinsen)','Betriebsausgabe',NULL,NULL,NULL,'52',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
(124,'4971','Verwahrentgelt','Betriebsausgabe',NULL,NULL,NULL,'52',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
(125,'4980','Sonstiger Betriebsbedarf','Betriebsausgabe',NULL,'VSt19',NULL,'52',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
(126,'4985','Werkzeuge und Kleingeräte','Betriebsausgabe',NULL,'VSt19',NULL,'52',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
(127,'8100','Steuerfreie Umsätze § 4 Nr. 8 ff. UStG','Betriebseinnahme',NULL,NULL,NULL,'17',1,1,'2026-04-11 06:39:07','2026-05-14 16:28:49'),
(128,'8105','Steuerfreie Umsätze § 4 Nr. 12 UStG (Vermietung/Verpachtung)','Betriebseinnahme',NULL,NULL,NULL,'17',1,1,'2026-04-11 06:39:07','2026-05-14 16:28:49'),
(129,'8110','Sonstige steuerfreie Umsätze Inland','Betriebseinnahme',NULL,NULL,NULL,'17',1,1,'2026-04-11 06:39:07','2026-05-14 16:28:49'),
(130,'8120','Steuerfreie Umsätze § 4 Nr. 1a UStG','Betriebseinnahme',NULL,NULL,NULL,'18',1,1,'2026-04-11 06:39:07','2026-05-14 16:28:49'),
(131,'8125','Steuerfreie innergemeinschaftliche Lieferungen § 4 Nr. 1b UStG','Betriebseinnahme',NULL,NULL,NULL,'18',1,1,'2026-04-11 06:39:07','2026-05-14 16:28:49'),
(132,'8300','Erlöse 7 % USt','Betriebseinnahme',NULL,'USt7',NULL,'15',1,1,'2026-04-11 06:39:07','2026-05-14 16:28:49'),
(133,'8400','Erlöse 19 % USt','Betriebseinnahme',NULL,'USt19','81','16',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
(134,'8710','Erlösschmälerungen 7 % USt','Betriebseinnahme',NULL,'USt7',NULL,'15',1,1,'2026-04-11 06:39:07','2026-05-14 16:28:49'),
(135,'8720','Erlösschmälerungen 19 % USt','Betriebseinnahme',NULL,'USt19','81','16',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
(136,'8570','Sonstige Erträge aus Provisionen, Lizenzen und Patenten','Betriebseinnahme',NULL,'USt19','81','16',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
(137,'8800','Erlöse aus Verkäufen Sachanlagevermögen (bei Buchverlust)','Betriebseinnahme',NULL,NULL,NULL,'22',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
(138,'8801','Erlöse aus Verkäufen Sachanlagevermögen 19 % USt (bei Buchverlust)','Betriebseinnahme',NULL,'USt19','81','22',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
(139,'8820','Erlöse aus Verkäufen Sachanlagevermögen 19 % USt (bei Buchgewinn)','Betriebseinnahme',NULL,'USt19','81','22',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
(140,'8920','Verwendung von Gegenständen für Zwecke außerhalb des Unternehmens','Betriebseinnahme',NULL,NULL,NULL,'22',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
(141,'8921','Verwendung von Gegenständen für Zwecke außerhalb des Unternehmens 7 % USt','Betriebseinnahme',NULL,'USt7',NULL,'22',1,1,'2026-04-11 06:39:07','2026-05-14 16:28:49'),
(142,'8922','Verwendung von Gegenständen für Zwecke außerhalb des Unternehmens 19 % USt','Betriebseinnahme',NULL,'USt19','81','22',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
(143,'8900','Sonstige betriebliche Erträge','Betriebseinnahme',NULL,NULL,NULL,'23',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
(144,'8910','Sonstige betriebliche Erträge (steuerfrei)','Betriebseinnahme',NULL,NULL,NULL,'23',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
(145,'8950','Nicht steuerbare Umsätze (Innenenumsätze)','Betriebseinnahme',NULL,NULL,NULL,'21',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
(146,'4656','nicht abzugsfähige Bewirtungskosten VSt. 7%','Betriebsausgabe',NULL,'VSt7',NULL,'51',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
(148,'4652','Bewirtungskosten (70 % abzugsfähig) ohne Vorsteuer','Betriebsausgabe','Bewirtungskosten ohne Vorsteuer (Trinkgeld)','',NULL,'44',0,1,'2026-04-11 07:38:26','2026-05-14 16:28:49'),
(149,'4657','Nicht abzugsfähige Bewirtungskosten ohne Vorsteuer','Betriebsausgabe','nicht abzugsfähige Bewirtungskosten ohne Vorsteuer (Eigenanteil Trinkgeld)','',NULL,'51',0,1,'2026-04-11 07:39:29','2026-05-14 16:28:49'),
(152,'1574','Abziehbare Vorsteuer aus innergemeinschaftlichem Erwerb 19 %','Zahlungskonto','','VSt19','61',NULL,1,1,'2026-05-10 14:45:37','2026-05-14 16:28:49'),
(153,'1774','Umsatzsteuer aus innergemeinschaftlichem Erwerb 19 %','Verbindlichkeit',NULL,'USt19','83',NULL,1,1,'2026-05-10 14:45:37','2026-05-14 16:28:49'),
(154,'1571','Abziehbare Vorsteuer 7 %','Zahlungskonto','','VSt7','66',NULL,1,1,'2026-05-10 14:45:37','2026-05-14 16:28:49'),
(155,'1576','Abziehbare Vorsteuer 19 % (Eingangsrechnungen)','Zahlungskonto','','VSt19','66',NULL,1,1,'2026-05-10 14:45:37','2026-05-14 16:28:49')
ON DUPLICATE KEY UPDATE
`kontonummer` = VALUES(`kontonummer`),
`bezeichnung` = VALUES(`bezeichnung`),
`kontenart` = VALUES(`kontenart`),
`beschreibung` = VALUES(`beschreibung`),
`steuercode` = VALUES(`steuercode`),
`ustva_kz` = VALUES(`ustva_kz`),
`euer_zeile` = VALUES(`euer_zeile`),
`is_system` = VALUES(`is_system`),
`aktiv` = VALUES(`aktiv`);

View file

@ -0,0 +1,20 @@
-- Unterstützte Banken/Provider (Stammdaten). Initial leer –
-- Anbieter werden über den "Neue Bank"-Dialog vom Nutzer selbst angelegt.
CREATE TABLE IF NOT EXISTS bank_providers (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
verbindungstyp ENUM('API','FinTS') NOT NULL DEFAULT 'FinTS',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Konfigurierte Bankverbindungen des Nutzers
CREATE TABLE IF NOT EXISTS bank_connections (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
provider_id INT UNSIGNED NOT NULL,
bezeichnung VARCHAR(150) NOT NULL,
app_name VARCHAR(150) NULL,
client_id VARCHAR(255) NULL,
secret VARCHAR(255) NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (provider_id) REFERENCES bank_providers(id) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View file

@ -0,0 +1,2 @@
ALTER TABLE bank_connections
ADD COLUMN IF NOT EXISTS last_fetched_at TIMESTAMP NULL DEFAULT NULL;

View file

@ -0,0 +1,9 @@
-- format_id optional machen (API-Batches haben kein CSV-Format)
ALTER TABLE bank_import_batches
MODIFY COLUMN format_id INT UNSIGNED NULL,
ADD COLUMN IF NOT EXISTS connection_id INT UNSIGNED NULL;
-- Externe Transaktions-ID für Duplikaterkennung bei API-Abrufen
ALTER TABLE bank_transactions
ADD COLUMN IF NOT EXISTS external_id VARCHAR(100) NULL,
ADD INDEX IF NOT EXISTS idx_bt_external_id (external_id);

View file

@ -0,0 +1,2 @@
ALTER TABLE bank_connections
ADD COLUMN IF NOT EXISTS sandbox TINYINT(1) NOT NULL DEFAULT 0;

View file

@ -0,0 +1,3 @@
ALTER TABLE bank_connections
ADD COLUMN IF NOT EXISTS kuerzel VARCHAR(10) NULL,
ADD COLUMN IF NOT EXISTS farbe VARCHAR(7) NULL DEFAULT '#6366f1';

View file

@ -0,0 +1,115 @@
-- Einheitliche Tabelle für alle Bank-Importquellen (CSV-Formate + Direktverbindungen).
-- bank_import_formats und bank_connections bleiben vorerst bestehen, werden aber
-- nicht mehr vom Code gelesen. Drop in späterer Migration.
CREATE TABLE IF NOT EXISTS bank_sources (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
type ENUM('csv','api','fints') NOT NULL,
provider_id INT UNSIGNED NULL,
bezeichnung VARCHAR(150) NOT NULL,
kuerzel VARCHAR(10) NULL,
farbe VARCHAR(7) NULL DEFAULT '#6366f1',
-- CSV-Konfiguration (NULL bei api/fints)
skip_rows INT NULL,
has_header_row TINYINT NULL,
`delimiter` CHAR(1) NULL,
enclosure CHAR(1) NULL,
col_buchungstag INT NULL,
col_wertstellung INT NULL,
col_vorgang INT NULL,
col_buchungstext INT NULL,
col_wert INT NULL,
col_zahlungspflichtiger INT NULL,
col_zahlungsempfaenger INT NULL,
col_verwendungszweck INT NULL,
date_format VARCHAR(20) NULL,
decimal_separator CHAR(1) NULL,
thousands_separator CHAR(1) NULL,
buchungstext_template_debit VARCHAR(500) NULL,
buchungstext_template_credit VARCHAR(500) NULL,
-- API/FinTS gemeinsam
sandbox TINYINT(1) NOT NULL DEFAULT 0,
last_fetched_at TIMESTAMP NULL DEFAULT NULL,
-- API-spezifisch (PayPal-NVP)
app_name VARCHAR(150) NULL,
client_id_enc TEXT NULL,
secret_enc TEXT NULL,
-- FinTS-spezifisch
blz VARCHAR(11) NULL,
fints_url VARCHAR(255) NULL,
benutzerkennung VARCHAR(100) NULL,
pin_enc TEXT NULL,
iban_filter VARCHAR(34) NULL,
-- Backfill-Tracking (wird am Ende dieser Migration entfernt)
legacy_format_id INT UNSIGNED NULL,
legacy_connection_id INT UNSIGNED NULL,
created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_bs_type (type),
INDEX idx_bs_legacy_format (legacy_format_id),
INDEX idx_bs_legacy_connection (legacy_connection_id),
FOREIGN KEY (provider_id) REFERENCES bank_providers(id) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Backfill: CSV-Formate übernehmen
INSERT INTO bank_sources (
type, bezeichnung, kuerzel, farbe,
skip_rows, has_header_row, `delimiter`, enclosure,
col_buchungstag, col_wertstellung, col_vorgang, col_buchungstext, col_wert,
col_zahlungspflichtiger, col_zahlungsempfaenger, col_verwendungszweck,
date_format, decimal_separator, thousands_separator,
buchungstext_template_debit, buchungstext_template_credit,
legacy_format_id
)
SELECT 'csv', name, kuerzel, farbe,
skip_rows, has_header_row, `delimiter`, enclosure,
col_buchungstag, col_wertstellung, col_vorgang, col_buchungstext, col_wert,
col_zahlungspflichtiger, col_zahlungsempfaenger, col_verwendungszweck,
date_format, decimal_separator, thousands_separator,
buchungstext_template_debit, buchungstext_template_credit,
id
FROM bank_import_formats;
-- Backfill: Direktverbindungen übernehmen (Klartext-Credentials in *_enc-Spalten;
-- werden im Anschluss durch scripts/encrypt_bank_secrets.php verschlüsselt).
INSERT INTO bank_sources (
type, provider_id, bezeichnung, kuerzel, farbe, sandbox, last_fetched_at,
app_name, client_id_enc, secret_enc,
legacy_connection_id
)
SELECT
CASE bp.verbindungstyp WHEN 'API' THEN 'api' ELSE 'fints' END,
bc.provider_id, bc.bezeichnung, bc.kuerzel, bc.farbe, bc.sandbox, bc.last_fetched_at,
bc.app_name, bc.client_id, bc.secret,
bc.id
FROM bank_connections bc
JOIN bank_providers bp ON bp.id = bc.provider_id;
-- source_id-Spalte auf bank_import_batches
ALTER TABLE bank_import_batches
ADD COLUMN IF NOT EXISTS source_id INT UNSIGNED NULL,
ADD INDEX IF NOT EXISTS idx_bib_source (source_id);
-- Backfill: source_id aus format_id (CSV-Batches)
UPDATE bank_import_batches bib
JOIN bank_sources bs ON bs.legacy_format_id = bib.format_id AND bs.type = 'csv'
SET bib.source_id = bs.id
WHERE bib.format_id IS NOT NULL AND bib.source_id IS NULL;
-- Backfill: source_id aus connection_id (API/FinTS-Batches)
UPDATE bank_import_batches bib
JOIN bank_sources bs ON bs.legacy_connection_id = bib.connection_id AND bs.type IN ('api','fints')
SET bib.source_id = bs.id
WHERE bib.connection_id IS NOT NULL AND bib.source_id IS NULL;
-- Backfill-Tracking-Spalten wieder entfernen
ALTER TABLE bank_sources
DROP INDEX idx_bs_legacy_format,
DROP INDEX idx_bs_legacy_connection,
DROP COLUMN legacy_format_id,
DROP COLUMN legacy_connection_id;

View file

@ -0,0 +1,11 @@
-- Default-FinTS-Server-URL pro Provider. Wird verwendet, wenn in der Verbindung
-- selbst keine fints_url angegeben ist. Vom Nutzer in der Verbindung überschreibbar.
ALTER TABLE bank_providers
ADD COLUMN IF NOT EXISTS default_fints_url VARCHAR(255) NULL;
UPDATE bank_providers SET default_fints_url = 'https://fints.comdirect.de/fints'
WHERE name = 'Comdirect' AND (default_fints_url IS NULL OR default_fints_url = '');
UPDATE bank_providers SET default_fints_url = 'https://banking-dkb.s-fints-pt-dkb.de/fints30'
WHERE name = 'DKB Bank' AND (default_fints_url IS NULL OR default_fints_url = '');

View file

@ -0,0 +1,15 @@
-- iban_filter (VARCHAR 34) → account_filter (TEXT) als JSON-Array von Konto-Identifiern.
-- Identifier-Schema: IBAN (z.B. "DE53...") oder bei fehlender IBAN "AN:<accountNumber>:<subAccount>".
ALTER TABLE bank_sources
ADD COLUMN IF NOT EXISTS account_filter TEXT NULL;
-- Bestehende Single-IBAN-Werte als 1-Element-JSON-Array übernehmen
UPDATE bank_sources
SET account_filter = JSON_ARRAY(iban_filter)
WHERE iban_filter IS NOT NULL
AND iban_filter <> ''
AND account_filter IS NULL;
ALTER TABLE bank_sources
DROP COLUMN iban_filter;

View file

@ -66,6 +66,13 @@ $router->post('/belege/{id}/vorschlag/reset', fn($p) => (new BelegController(
// ── Bank buchen ──────────────────────────────────────────────
$router->get('/bank', fn($p) => (new BankController())->index($p));
$router->get('/bank/direkt', fn($p) => (new BankController())->direkt($p));
$router->post('/bank/direkt', fn($p) => (new BankController())->direktStore($p));
$router->get('/bank/direkt/{id}/run', fn($p) => (new BankController())->direktRun($p));
$router->post('/bank/direkt/{id}', fn($p) => (new BankController())->direktUpdate($p));
$router->post('/bank/direkt/{id}/delete', fn($p) => (new BankController())->direktDelete($p));
$router->post('/bank/direkt/{id}/tan', fn($p) => (new BankController())->direktTan($p));
$router->post('/bank/direkt/{id}/accounts', fn($p) => (new BankController())->direktAccounts($p));
$router->get('/bank/upload', fn($p) => (new BankController())->uploadForm($p));
$router->post('/bank/upload', fn($p) => (new BankController())->upload($p));
$router->post('/bank/detect-format', fn($p) => (new BankController())->detectFormat($p));

View file

@ -0,0 +1,64 @@
<?php
/**
* Verschlüsselt die nach Migration 068 noch im Klartext in bank_sources liegenden
* Felder (client_id_enc, secret_enc, pin_enc) mit dem APP_KEY.
*
* Das Skript ist idempotent: bereits verschlüsselte Werte (Magic-Prefix "enc:v1:")
* werden übersprungen. Nach Ausführung kann es gefahrlos erneut aufgerufen werden.
*
* Aufruf:
* docker compose exec web php scripts/encrypt_bank_secrets.php
*
* Voraussetzung: APP_KEY in .env gesetzt (64-stellige Hex-Zeichenkette).
*/
declare(strict_types=1);
require __DIR__ . '/../vendor/autoload.php';
use Steuerkram\Config;
use Steuerkram\Database;
use Steuerkram\Services\Crypto;
Config::load();
if ((string)Config::get('app_key', '') === '') {
fwrite(STDERR, "FEHLER: APP_KEY ist nicht gesetzt. Bitte zuerst in .env hinterlegen.\n");
fwrite(STDERR, "Generieren mit: php -r \"echo bin2hex(random_bytes(32));\"\n");
exit(1);
}
$rows = Database::fetchAll('SELECT id, client_id_enc, secret_enc, pin_enc FROM bank_sources');
$touched = 0;
$skipped = 0;
foreach ($rows as $row) {
$update = [];
foreach (['client_id_enc', 'secret_enc', 'pin_enc'] as $field) {
$value = $row[$field] ?? null;
if ($value === null || $value === '') {
continue;
}
if (Crypto::isEncrypted((string)$value)) {
continue;
}
$update[$field] = Crypto::encrypt((string)$value);
}
if (empty($update)) {
$skipped++;
continue;
}
$sets = implode(', ', array_map(fn($k) => "$k = ?", array_keys($update)));
$params = array_values($update);
$params[] = (int)$row['id'];
Database::execute("UPDATE bank_sources SET $sets WHERE id = ?", $params);
$touched++;
echo "Verschlüsselt: bank_sources.id={$row['id']} (Felder: " . implode(', ', array_keys($update)) . ")\n";
}
echo "\n";
echo "Fertig: {$touched} Zeile(n) aktualisiert, {$skipped} bereits verschlüsselt oder leer.\n";

View file

@ -15,6 +15,7 @@ class Config
'db_name' => $_ENV['DB_NAME'] ?? getenv('DB_NAME') ?: 'steuerkram',
'db_user' => $_ENV['DB_USER'] ?? getenv('DB_USER') ?: 'steuerkram',
'db_pass' => $_ENV['DB_PASS'] ?? getenv('DB_PASS') ?: 'steuerkram',
'app_key' => $_ENV['APP_KEY'] ?? getenv('APP_KEY') ?: '',
'upload_dir' => dirname(__DIR__) . '/public/static/uploads',
'base_url' => '',
];

View file

@ -4,13 +4,16 @@ declare(strict_types=1);
namespace Steuerkram\Controllers;
use Steuerkram\Models\BankImportFormat;
use Steuerkram\Models\BankProvider;
use Steuerkram\Models\BankSource;
use Steuerkram\Models\BankTransaction;
use Steuerkram\Controllers\InvoiceController;
use Steuerkram\Database;
use Steuerkram\Models\Beleg;
use Steuerkram\Models\Invoice;
use Steuerkram\Services\BankImportParser;
use Steuerkram\Services\FinTsFetchService;
use Steuerkram\Services\PayPalNvpFetchService;
use Steuerkram\View;
class BankController extends BaseController
@ -26,7 +29,7 @@ class BankController extends BaseController
$page = max(1, (int)($_GET['page'] ?? 1));
$result = BankTransaction::all($filter, $page);
$formats = BankImportFormat::all();
$formats = BankSource::all('csv');
$view = new View();
$view->render('layout/base.php', [
@ -39,17 +42,17 @@ class BankController extends BaseController
'filter' => $filter,
'formats' => $formats,
]),
'topbarActions' => '<a href="/bank/upload" class="btn btn-primary">Upload</a>',
'topbarActions' => '<a href="/bank/direkt" class="btn btn-primary">Importieren</a>',
]);
}
public function uploadForm(array $params): void
{
$formats = BankImportFormat::all();
$formats = BankSource::all('csv');
$view = new View();
$view->render('layout/base.php', [
'pageTitle' => 'Bank-Buchungen importieren',
'topbarActions' => '<a href="/settings/bankimport" class="btn btn-secondary btn-sm">Formate bearbeiten</a>',
'topbarActions' => '<a href="/bank/direkt" class="btn btn-secondary btn-sm">Formate bearbeiten</a>',
'content' => $this->renderView('bank/upload', [
'formats' => $formats,
'errors' => [],
@ -75,7 +78,7 @@ class BankController extends BaseController
return;
}
$formats = BankImportFormat::all();
$formats = BankSource::all('csv');
$parser = new BankImportParser();
$formatsData = [];
@ -116,9 +119,9 @@ class BankController extends BaseController
public function upload(array $params): void
{
$formatId = (int)($_POST['format_id'] ?? 0);
$format = BankImportFormat::find($formatId);
$format = BankSource::find($formatId);
if (!$format) {
if (!$format || $format['type'] !== 'csv') {
View::redirect('/bank/upload', 'Unbekanntes Import-Format.', 'error');
}
@ -194,11 +197,11 @@ class BankController extends BaseController
$this->doImport($pending['format_id'], $pending['filename'], $rows);
}
private function doImport(int $formatId, string $filename, array $rows): void
private function doImport(int $sourceId, string $filename, array $rows): void
{
Database::beginTransaction();
try {
$batchId = BankTransaction::createBatch($formatId, $filename, count($rows));
$batchId = BankTransaction::createBatch($sourceId, $filename, count($rows));
foreach ($rows as $row) {
BankTransaction::insertRow($batchId, $row);
}
@ -364,4 +367,230 @@ class BankController extends BaseController
View::redirect("/bank/$id", 'Beleg hochgeladen – bitte jetzt verknüpfen.');
}
public function direkt(array $params): void
{
$connections = array_values(array_filter(
BankSource::all(),
fn($s) => in_array($s['type'], ['api', 'fints'], true)
));
$providers = BankProvider::all();
$view = new View();
$view->render('layout/base.php', [
'pageTitle' => 'Direktverbindungen',
'topbarActions' => '<button type="button" class="btn btn-primary" id="btn-new-connection">NEU</button>',
'content' => $this->renderView('bank/fints', [
'connections' => $connections,
'providers' => $providers,
]),
]);
}
public function direktStore(array $params): void
{
$providerRaw = (string)($_POST['provider_id'] ?? '');
$bezeichnung = trim($_POST['bezeichnung'] ?? '');
if ($bezeichnung === '') {
View::redirect('/bank/direkt', 'Bitte alle Pflichtfelder ausfüllen.', 'error');
return;
}
// Neue Bank anlegen, wenn Dropdown auf "__new__" steht.
// Verbindungstyp wählt der Nutzer im Formular (API oder FinTS).
if ($providerRaw === '__new__') {
$newName = trim((string)($_POST['new_bank_name'] ?? ''));
$newTyp = ($_POST['new_bank_typ'] ?? 'FinTS') === 'API' ? 'API' : 'FinTS';
if ($newName === '') {
View::redirect('/bank/direkt', 'Bitte den Banknamen angeben.', 'error');
return;
}
$existing = BankProvider::findByName($newName);
$providerId = $existing ? (int)$existing['id'] : BankProvider::create($newName, $newTyp);
} else {
$providerId = (int)$providerRaw;
}
if (!$providerId) {
View::redirect('/bank/direkt', 'Bitte alle Pflichtfelder ausfüllen.', 'error');
return;
}
$provider = BankProvider::find($providerId);
if (!$provider) {
View::redirect('/bank/direkt', 'Unbekannter Anbieter.', 'error');
return;
}
$isApi = $provider['verbindungstyp'] === 'API';
$isFints = $provider['verbindungstyp'] === 'FinTS';
BankSource::create([
'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,
'fints_url' => $isFints ? (trim($_POST['fints_url'] ?? '') ?: null) : null,
'benutzerkennung' => $isFints ? (trim($_POST['benutzerkennung'] ?? '') ?: null) : null,
'pin' => $isFints ? (trim($_POST['pin'] ?? '') ?: null) : null,
'account_filter' => null, // Konten werden beim ersten Abruf ausgewählt
]);
View::redirect('/bank/direkt', 'Verbindung gespeichert.');
}
public function direktRun(array $params): void
{
$id = (int)($params['id'] ?? 0);
$source = BankSource::find($id);
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
header('X-Accel-Buffering: no');
header('Connection: keep-alive');
while (ob_get_level()) {
ob_end_clean();
}
$send = function (string $event, string $data): void {
foreach (explode("\n", $data) as $line) {
echo 'event: ' . $event . "\n";
echo 'data: ' . $line . "\n\n";
}
flush();
};
if (!$source) {
$send('log', 'FEHLER: Verbindung nicht gefunden.');
$send('done', 'error');
return;
}
$log = fn(string $msg) => $send('log', $msg);
try {
if ($source['type'] === 'api') {
(new PayPalNvpFetchService($source, $log))->run();
} elseif ($source['type'] === 'fints') {
(new FinTsFetchService($source, $log, $send))->run();
} else {
$send('log', 'Kein Abruf für Typ ' . $source['type'] . '.');
$send('done', 'error');
return;
}
} catch (\Throwable $e) {
$send('log', 'AUSNAHME: ' . $e->getMessage());
}
$send('done', 'ok');
}
public function direktUpdate(array $params): void
{
$id = (int)($params['id'] ?? 0);
$bezeichnung = trim($_POST['bezeichnung'] ?? '');
if (!$id || $bezeichnung === '') {
View::redirect('/bank/direkt', 'Bitte alle Pflichtfelder ausfüllen.', 'error');
return;
}
$source = BankSource::find($id);
if (!$source) {
View::redirect('/bank/direkt', 'Verbindung nicht gefunden.', 'error');
return;
}
$isApi = $source['type'] === 'api';
$isFints = $source['type'] === 'fints';
$data = [
'bezeichnung' => $bezeichnung,
'kuerzel' => trim($_POST['kuerzel'] ?? '') ?: null,
'farbe' => trim($_POST['farbe'] ?? '') ?: '#6366f1',
];
if ($isApi) {
$newPwd = trim($_POST['client_id'] ?? '');
$newSecret = trim($_POST['secret'] ?? '');
$data += [
'sandbox' => isset($_POST['sandbox']) ? 1 : 0,
'app_name' => trim($_POST['app_name'] ?? '') ?: null,
'client_id' => $newPwd !== '' ? $newPwd : $source['client_id'],
'secret' => $newSecret !== '' ? $newSecret : $source['secret'],
];
} elseif ($isFints) {
$newPin = trim($_POST['pin'] ?? '');
$data += [
'blz' => trim($_POST['blz'] ?? '') ?: null,
'fints_url' => trim($_POST['fints_url'] ?? '') ?: null,
'benutzerkennung' => trim($_POST['benutzerkennung'] ?? '') ?: null,
'pin' => $newPin !== '' ? $newPin : $source['pin'],
];
// Reset-Button: Konto-Filter explizit löschen → beim nächsten Abruf neu auswählen
if (isset($_POST['reset_account_filter'])) {
$data['account_filter'] = null;
}
}
BankSource::update($id, $data);
View::redirect('/bank/direkt', 'Verbindung gespeichert.');
}
public function direktDelete(array $params): void
{
$id = (int)($params['id'] ?? 0);
BankSource::delete($id);
View::redirect('/bank/direkt', 'Verbindung gelöscht.');
}
/** Nimmt die im Browser eingegebene TAN für einen wartenden FinTS-Abruf entgegen. */
public function direktTan(array $params): void
{
header('Content-Type: application/json');
$token = trim((string)($_POST['token'] ?? ''));
$tan = trim((string)($_POST['tan'] ?? ''));
if ($tan === '') {
http_response_code(400);
echo json_encode(['error' => 'TAN fehlt.']);
return;
}
if (!FinTsFetchService::submitPendingTan($token, $tan)) {
http_response_code(400);
echo json_encode(['error' => 'Ungültiger TAN-Token.']);
return;
}
echo json_encode(['ok' => true]);
}
/** Nimmt die im Browser getroffene Konto-Auswahl für einen wartenden FinTS-Abruf entgegen. */
public function direktAccounts(array $params): void
{
header('Content-Type: application/json');
$token = trim((string)($_POST['token'] ?? ''));
$raw = $_POST['ids'] ?? '[]';
$ids = is_array($raw) ? $raw : (json_decode((string)$raw, true) ?: []);
if (!is_array($ids) || empty($ids)) {
http_response_code(400);
echo json_encode(['error' => 'Mindestens ein Konto auswählen.']);
return;
}
if (!FinTsFetchService::submitAccountSelection($token, $ids)) {
http_response_code(400);
echo json_encode(['error' => 'Ungültiger Token.']);
return;
}
echo json_encode(['ok' => true]);
}
}

View file

@ -4,7 +4,7 @@ declare(strict_types=1);
namespace Steuerkram\Controllers;
use Steuerkram\Models\BankImportFormat;
use Steuerkram\Models\BankSource;
use Steuerkram\Models\Setting;
use Steuerkram\Services\EmailService;
use Steuerkram\View;
@ -141,12 +141,12 @@ class SettingsController extends BaseController
public function bankImport(array $params): void
{
$formats = BankImportFormat::all();
$formats = BankSource::all('csv');
$suggestion = $_SESSION['bank_import_suggestion'] ?? null;
$view = new View();
$view->render('layout/base.php', [
'pageTitle' => 'Bankimport-Formate',
'pageTitle' => 'CSV Bankimport Formate',
'content' => $this->renderView('settings/bankimport', [
'formats' => $formats,
'suggestion' => $suggestion,
@ -159,14 +159,14 @@ class SettingsController extends BaseController
header('Content-Type: application/json');
$id = (int)($params['id'] ?? 0);
$fmt = BankImportFormat::find($id);
if (!$fmt) {
$fmt = BankSource::find($id);
if (!$fmt || $fmt['type'] !== 'csv') {
http_response_code(404);
echo json_encode(['error' => 'Format nicht gefunden.']);
return;
}
BankImportFormat::delete($id);
BankSource::delete($id);
echo json_encode(['ok' => true]);
}
@ -238,10 +238,10 @@ class SettingsController extends BaseController
if (!empty($errors)) {
// Bei Validierungsfehler zurück mit Fehlermeldung
$suggestion = $_SESSION['bank_import_suggestion'] ?? null;
$formats = BankImportFormat::all();
$formats = BankSource::all('csv');
$view = new View();
$view->render('layout/base.php', [
'pageTitle' => 'Bankimport-Formate',
'pageTitle' => 'CSV Bankimport Formate',
'content' => $this->renderView('settings/bankimport', [
'formats' => $formats,
'suggestion' => $suggestion,
@ -264,8 +264,9 @@ class SettingsController extends BaseController
return $v !== '' ? (int)$v : $default;
};
$id = BankImportFormat::create([
'name' => $name,
$id = BankSource::create([
'type' => 'csv',
'bezeichnung' => $name,
'kuerzel' => $kuerzel,
'farbe' => $farbe,
'skip_rows' => $skipRows,
@ -298,8 +299,8 @@ class SettingsController extends BaseController
header('Content-Type: application/json');
$id = (int)($params['id'] ?? 0);
$fmt = BankImportFormat::find($id);
if (!$fmt) {
$fmt = BankSource::find($id);
if (!$fmt || $fmt['type'] !== 'csv') {
http_response_code(404);
echo json_encode(['error' => 'Format nicht gefunden.']);
return;
@ -381,7 +382,11 @@ class SettingsController extends BaseController
return;
}
BankImportFormat::updateField($id, $field, $value);
// Mapping: settings/bankimport-View kennt "name", bank_sources nutzt "bezeichnung".
if ($field === 'name') {
$field = 'bezeichnung';
}
BankSource::updateField($id, $field, $value);
echo json_encode(['ok' => true]);
}

View file

@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace Steuerkram\Models;
use Steuerkram\Database;
class BankConnection
{
public static function all(): array
{
return Database::fetchAll(
'SELECT bc.*, bp.name AS provider_name, bp.verbindungstyp
FROM bank_connections bc
JOIN bank_providers bp ON bp.id = bc.provider_id
ORDER BY bc.bezeichnung'
);
}
public static function find(int $id): ?array
{
$row = Database::fetchOne(
'SELECT bc.*, bp.name AS provider_name, bp.verbindungstyp
FROM bank_connections bc
JOIN bank_providers bp ON bp.id = bc.provider_id
WHERE bc.id = ?',
[$id]
);
return $row ?: null;
}
public static function create(array $data): void
{
Database::execute(
'INSERT INTO bank_connections (provider_id, bezeichnung, app_name, client_id, secret, sandbox, kuerzel, farbe)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[
(int)$data['provider_id'],
$data['bezeichnung'],
$data['app_name'] ?? null,
$data['client_id'] ?? null,
$data['secret'] ?? null,
(int)($data['sandbox'] ?? 0),
$data['kuerzel'] ?? null,
$data['farbe'] ?? '#6366f1',
]
);
}
public static function update(int $id, array $data): void
{
Database::execute(
'UPDATE bank_connections
SET bezeichnung = ?, app_name = ?, client_id = ?, secret = ?, sandbox = ?, kuerzel = ?, farbe = ?
WHERE id = ?',
[
$data['bezeichnung'],
$data['app_name'] ?? null,
$data['client_id'] ?? null,
$data['secret'] ?? null,
(int)($data['sandbox'] ?? 0),
$data['kuerzel'] ?? null,
$data['farbe'] ?? '#6366f1',
$id,
]
);
}
public static function delete(int $id): void
{
Database::execute('DELETE FROM bank_connections WHERE id = ?', [$id]);
}
}

View file

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Steuerkram\Models;
use Steuerkram\Database;
class BankProvider
{
public static function all(): array
{
return Database::fetchAll('SELECT * FROM bank_providers ORDER BY name');
}
public static function find(int $id): ?array
{
$row = Database::fetchOne('SELECT * FROM bank_providers WHERE id = ?', [$id]);
return $row ?: null;
}
public static function findByName(string $name): ?array
{
$row = Database::fetchOne('SELECT * FROM bank_providers WHERE name = ?', [$name]);
return $row ?: null;
}
public static function create(string $name, string $verbindungstyp = 'FinTS'): int
{
Database::execute(
'INSERT INTO bank_providers (name, verbindungstyp) VALUES (?, ?)',
[$name, $verbindungstyp]
);
return (int)Database::lastInsertId();
}
}

138
src/Models/BankSource.php Normal file
View file

@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
namespace Steuerkram\Models;
use Steuerkram\Database;
use Steuerkram\Services\Crypto;
/**
* Einheitliche Tabelle für alle Bank-Importquellen:
* - type='csv' – CSV-Importformat (Spaltenmapping, Trennzeichen, Templates)
* - type='api' – Direktverbindung über REST/NVP API (PayPal)
* - type='fints' – Direktverbindung über FinTS (Comdirect, DKB, …)
*
* Beim Lesen werden die verschlüsselten Spalten (client_id_enc, secret_enc, pin_enc)
* automatisch zu Klartext-Schlüsseln (client_id, secret, pin) entschlüsselt.
* Außerdem wird "name" als Alias für "bezeichnung" gesetzt, damit der bestehende
* BankImportParser-Code ($format['name'] etc.) ohne Anpassung funktioniert.
*/
class BankSource
{
public static function all(?string $type = null): array
{
$sql = 'SELECT bs.*, bp.name AS provider_name, bp.verbindungstyp, bp.default_fints_url
FROM bank_sources bs
LEFT JOIN bank_providers bp ON bp.id = bs.provider_id';
$params = [];
if ($type !== null) {
$sql .= ' WHERE bs.type = ?';
$params[] = $type;
}
$sql .= ' ORDER BY bs.type, bs.bezeichnung';
$rows = Database::fetchAll($sql, $params);
return array_map([self::class, 'hydrate'], $rows);
}
public static function find(int $id): ?array
{
$row = Database::fetchOne(
'SELECT bs.*, bp.name AS provider_name, bp.verbindungstyp, bp.default_fints_url
FROM bank_sources bs
LEFT JOIN bank_providers bp ON bp.id = bs.provider_id
WHERE bs.id = ?',
[$id]
);
return $row ? self::hydrate($row) : null;
}
public static function create(array $data): int
{
$type = $data['type'] ?? 'csv';
$row = self::dehydrate($data);
$cols = array_keys($row);
$sql = 'INSERT INTO bank_sources (' . implode(',', array_map(fn($c) => "`$c`", $cols)) . ')
VALUES (' . implode(',', array_fill(0, count($cols), '?')) . ')';
Database::execute($sql, array_values($row));
return (int)Database::lastInsertId();
}
public static function update(int $id, array $data): void
{
$row = self::dehydrate($data);
if (empty($row)) {
return;
}
$sets = implode(', ', array_map(fn($c) => "`$c` = ?", array_keys($row)));
$params = array_values($row);
$params[] = $id;
Database::execute("UPDATE bank_sources SET $sets WHERE id = ?", $params);
}
public static function updateField(int $id, string $field, mixed $value): void
{
if (in_array($field, ['client_id', 'secret', 'pin'], true)) {
$field = $field . '_enc';
$value = $value !== null && $value !== '' ? Crypto::encrypt((string)$value) : null;
}
Database::execute("UPDATE bank_sources SET `$field` = ? WHERE id = ?", [$value, $id]);
}
public static function delete(int $id): void
{
Database::execute('DELETE FROM bank_sources WHERE id = ?', [$id]);
}
public static function updateLastFetched(int $id): void
{
Database::execute('UPDATE bank_sources SET last_fetched_at = NOW() WHERE id = ?', [$id]);
}
private static function hydrate(array $row): array
{
$row['name'] = $row['bezeichnung'];
$row['client_id'] = Crypto::decrypt((string)($row['client_id_enc'] ?? ''));
$row['secret'] = Crypto::decrypt((string)($row['secret_enc'] ?? ''));
$row['pin'] = Crypto::decrypt((string)($row['pin_enc'] ?? ''));
return $row;
}
/**
* Überführt ein Eingabe-Array in die DB-Spalten:
* - client_id/secret/pin → *_enc (verschlüsselt)
* - nur erlaubte Spalten werden zurückgegeben
*/
private static function dehydrate(array $data): array
{
$allowed = [
'type', 'provider_id', 'bezeichnung', 'kuerzel', 'farbe',
'skip_rows', 'has_header_row', 'delimiter', 'enclosure',
'col_buchungstag', 'col_wertstellung', 'col_vorgang', 'col_buchungstext', 'col_wert',
'col_zahlungspflichtiger', 'col_zahlungsempfaenger', 'col_verwendungszweck',
'date_format', 'decimal_separator', 'thousands_separator',
'buchungstext_template_debit', 'buchungstext_template_credit',
'sandbox', 'last_fetched_at',
'app_name', 'client_id_enc', 'secret_enc',
'blz', 'fints_url', 'benutzerkennung', 'pin_enc', 'account_filter',
];
$out = [];
foreach ($allowed as $col) {
if (array_key_exists($col, $data)) {
$out[$col] = $data[$col];
}
}
// Klartext-Felder verschlüsseln
foreach (['client_id' => 'client_id_enc', 'secret' => 'secret_enc', 'pin' => 'pin_enc'] as $plain => $enc) {
if (array_key_exists($plain, $data)) {
$val = $data[$plain];
$out[$enc] = ($val === null || $val === '') ? null : Crypto::encrypt((string)$val);
}
}
return $out;
}
}

View file

@ -43,7 +43,7 @@ class BankTransaction
$params[] = $s;
}
if (!empty($filter['format_id'])) {
$where[] = 'bib.format_id = ?';
$where[] = 'bs.id = ?';
$params[] = (int)$filter['format_id'];
}
@ -64,17 +64,19 @@ class BankTransaction
"SELECT COUNT(*) AS n
FROM bank_transactions bt
JOIN bank_import_batches bib ON bt.batch_id = bib.id
LEFT JOIN bank_sources bs ON bib.source_id = bs.id
WHERE $whereStr",
$params
)['n'] ?? 0);
$rows = Database::fetchAll(
"SELECT bt.*, bib.filename AS batch_filename, bif.name AS format_name, bif.kuerzel, bif.farbe,
"SELECT bt.*, bib.filename AS batch_filename, bs.bezeichnung AS format_name,
bs.kuerzel AS kuerzel, bs.farbe AS farbe,
btb_agg.beleg_id AS beleg_id, btb_agg.beleg_count,
b.beleg_nr
FROM bank_transactions bt
JOIN bank_import_batches bib ON bt.batch_id = bib.id
LEFT JOIN bank_import_formats bif ON bib.format_id = bif.id
LEFT JOIN bank_sources bs ON bib.source_id = bs.id
LEFT JOIN (
SELECT bank_tx_id, MIN(beleg_id) AS beleg_id, COUNT(*) AS beleg_count
FROM bank_transaction_belege
@ -97,10 +99,11 @@ class BankTransaction
public static function find(int $id): ?array
{
$row = Database::fetchOne(
'SELECT bt.*, bib.filename AS batch_filename, bif.name AS format_name, bif.kuerzel, bif.farbe
'SELECT bt.*, bib.filename AS batch_filename, bs.bezeichnung AS format_name,
bs.kuerzel AS kuerzel, bs.farbe AS farbe
FROM bank_transactions bt
JOIN bank_import_batches bib ON bt.batch_id = bib.id
JOIN bank_import_formats bif ON bib.format_id = bif.id
LEFT JOIN bank_sources bs ON bib.source_id = bs.id
WHERE bt.id = ?',
[$id]
);
@ -201,11 +204,11 @@ class BankTransaction
public static function findByBelegId(int $belegId): ?array
{
$row = Database::fetchOne(
'SELECT bt.*, bib.filename AS batch_filename, bif.kuerzel, bif.farbe, btb.betrag AS alloc_betrag
'SELECT bt.*, bib.filename AS batch_filename, bs.kuerzel, bs.farbe, btb.betrag AS alloc_betrag
FROM bank_transaction_belege btb
JOIN bank_transactions bt ON bt.id = btb.bank_tx_id
JOIN bank_import_batches bib ON bib.id = bt.batch_id
JOIN bank_import_formats bif ON bif.id = bib.format_id
LEFT JOIN bank_sources bs ON bs.id = bib.source_id
WHERE btb.beleg_id = ?
LIMIT 1',
[$belegId]
@ -258,11 +261,11 @@ class BankTransaction
return ['new' => $new, 'dup' => $dup];
}
public static function createBatch(int $formatId, string $filename, int $rowCount): int
public static function createBatch(int $sourceId, string $filename, int $rowCount): int
{
Database::execute(
'INSERT INTO bank_import_batches (format_id, filename, row_count) VALUES (?, ?, ?)',
[$formatId, $filename, $rowCount]
'INSERT INTO bank_import_batches (source_id, filename, row_count) VALUES (?, ?, ?)',
[$sourceId, $filename, $rowCount]
);
return (int)Database::lastInsertId();
}

91
src/Services/Crypto.php Normal file
View file

@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace Steuerkram\Services;
use Steuerkram\Config;
use RuntimeException;
/**
* Symmetrische Verschlüsselung sensibler Felder (PayPal-Signature, FinTS-PIN).
*
* Format: "enc:v1:" + base64( iv(12) || tag(16) || ciphertext )
* Algorithmus: AES-256-GCM, Schlüssel als 64-stellige Hex-Zeichenkette aus Config::get('app_key').
*
* Migrationsmodus: decrypt() reicht Werte ohne Prefix unverändert durch (Klartext),
* sodass Klartext-Werte aus dem SQL-Backfill funktionieren, bis das Verschlüsselungsskript
* sie idempotent in den enc-Form überführt hat.
*/
class Crypto
{
private const PREFIX = 'enc:v1:';
private const CIPHER = 'aes-256-gcm';
private const IV_LEN = 12;
private const TAG_LEN = 16;
public static function encrypt(string $plain): string
{
if ($plain === '') {
return '';
}
if (self::isEncrypted($plain)) {
return $plain;
}
$key = self::key();
$iv = random_bytes(self::IV_LEN);
$tag = '';
$cipher = openssl_encrypt($plain, self::CIPHER, $key, OPENSSL_RAW_DATA, $iv, $tag, '', self::TAG_LEN);
if ($cipher === false) {
throw new RuntimeException('Crypto::encrypt fehlgeschlagen.');
}
return self::PREFIX . base64_encode($iv . $tag . $cipher);
}
public static function decrypt(string $value): string
{
if ($value === '') {
return '';
}
if (!self::isEncrypted($value)) {
return $value;
}
$raw = base64_decode(substr($value, strlen(self::PREFIX)), true);
if ($raw === false || strlen($raw) < self::IV_LEN + self::TAG_LEN) {
throw new RuntimeException('Crypto::decrypt: ungültiges Format.');
}
$iv = substr($raw, 0, self::IV_LEN);
$tag = substr($raw, self::IV_LEN, self::TAG_LEN);
$cipher = substr($raw, self::IV_LEN + self::TAG_LEN);
$plain = openssl_decrypt($cipher, self::CIPHER, self::key(), OPENSSL_RAW_DATA, $iv, $tag);
if ($plain === false) {
throw new RuntimeException('Crypto::decrypt fehlgeschlagen (falscher Schlüssel oder Datenkorruption).');
}
return $plain;
}
public static function isEncrypted(string $value): bool
{
return str_starts_with($value, self::PREFIX);
}
private static function key(): string
{
$hex = (string)Config::get('app_key', '');
if ($hex === '') {
throw new RuntimeException('APP_KEY ist nicht gesetzt. Bitte in .env hinterlegen.');
}
$key = @hex2bin($hex);
if ($key === false || strlen($key) !== 32) {
throw new RuntimeException('APP_KEY muss eine 64-stellige Hex-Zeichenkette (32 Byte) sein.');
}
return $key;
}
}

View file

@ -0,0 +1,545 @@
<?php
declare(strict_types=1);
namespace Steuerkram\Services;
use Steuerkram\Database;
use Fhp\Action\GetSEPAAccounts;
use Fhp\Action\GetStatementOfAccount;
use Fhp\BaseAction;
use Fhp\FinTs;
use Fhp\Model\StatementOfAccount\Transaction;
use Fhp\Model\TanRequestChallengeImage;
use Fhp\Options\Credentials;
use Fhp\Options\FinTsOptions;
/**
* FinTS-Leseabruf für Bankverbindungen mit type='fints'.
*
* Erwartet eine entschlüsselte bank_sources-Zeile mit:
* - blz, benutzerkennung, pin (verschlüsselt in DB, hier im Klartext erwartet)
* - fints_url (optional, sonst default_fints_url aus dem Provider-Join)
* - iban_filter (optional, nur dieses Konto abrufen)
*
* TAN-Pflicht: In dieser ersten Version werden Aktionen, die eine TAN erfordern,
* mit einer klaren Fehlermeldung abgebrochen. Die meisten Banken erlauben den
* Leseabruf via PIN ohne zusätzliche TAN.
*/
class FinTsFetchService
{
private array $source;
private int $sourceId;
private ?FinTs $fints = null;
/** @var callable */
private $log;
/** @var callable|null Wenn gesetzt, statt $this->log nutzen, um SSE-Events mit eigenem Namen zu schicken */
private $send;
public function __construct(array $source, callable $log, ?callable $send = null)
{
$this->source = $source;
$this->sourceId = (int)$source['id'];
$this->log = $log;
$this->send = $send;
}
public function run(): void
{
$log = $this->log;
$blz = trim((string)($this->source['blz'] ?? ''));
$user = trim((string)($this->source['benutzerkennung'] ?? ''));
$pin = trim((string)($this->source['pin'] ?? ''));
$url = trim((string)($this->source['fints_url'] ?? ''));
if ($url === '') {
$url = trim((string)($this->source['default_fints_url'] ?? ''));
}
$log('Nutze FinTS-Direktverbindung');
$log('Bank: ' . ($this->source['provider_name'] ?? '?'));
$log('BLZ: ' . ($blz !== '' ? $blz : 'FEHLT'));
$log('URL: ' . ($url !== '' ? $url : 'FEHLT'));
$log('User: ' . ($user !== '' ? $user : 'FEHLT'));
$log('PIN: ' . ($pin !== '' ? strlen($pin) . ' Zeichen' : 'FEHLT'));
if ($blz === '' || $user === '' || $pin === '' || $url === '') {
$log('FEHLER: Pflichtfelder unvollständig (BLZ, Benutzerkennung, PIN, URL).');
return;
}
$options = new FinTsOptions();
$options->url = $url;
$options->bankCode = $blz;
$options->productName = 'Steuerkram';
$options->productVersion = '1.0';
$credentials = Credentials::create($user, $pin);
try {
$this->fints = FinTs::new($options, $credentials);
} catch (\Throwable $e) {
$log('FEHLER bei Initialisierung: ' . $e->getMessage());
return;
}
$fints = $this->fints;
$log('TAN-Verfahren werden ermittelt…');
try {
$tanModes = $fints->getTanModes();
} catch (\Throwable $e) {
$log('FEHLER bei TAN-Modi-Abruf: ' . $e->getMessage());
return;
}
if (empty($tanModes)) {
$log('FEHLER: Bank meldet keine TAN-Verfahren.');
return;
}
foreach ($tanModes as $idx => $mode) {
$log(sprintf(' [%d] %s (ID %d)', $idx, $mode->getName(), $mode->getId()));
}
$selectedTanMode = reset($tanModes);
$log('Auswahl: ' . $selectedTanMode->getName() . ' (erstes verfügbares Verfahren)');
$selectedTanMedium = null;
if ($selectedTanMode->needsTanMedium()) {
try {
$tanMedia = $fints->getTanMedia($selectedTanMode);
} catch (\Throwable $e) {
$log('FEHLER beim TAN-Medien-Abruf: ' . $e->getMessage());
return;
}
if (empty($tanMedia)) {
$log('FEHLER: Verfahren benötigt TAN-Medium, Bank liefert aber keines.');
return;
}
$selectedTanMedium = reset($tanMedia);
$log('TAN-Medium: ' . $selectedTanMedium->getName());
}
try {
$fints->selectTanMode($selectedTanMode, $selectedTanMedium);
} catch (\Throwable $e) {
$log('FEHLER bei selectTanMode: ' . $e->getMessage());
return;
}
$log('Login wird versucht…');
try {
$login = $fints->login();
if ($login->needsTan()) {
$log('FEHLER: Bank verlangt eine TAN für den Login.');
$log('TAN-Eingabe ist in dieser Version noch nicht unterstützt.');
return;
}
} catch (\Throwable $e) {
$log('FEHLER beim Login: ' . $e->getMessage());
return;
}
$log('Login OK.');
try {
$getAccounts = GetSEPAAccounts::create();
$fints->execute($getAccounts);
if ($getAccounts->needsTan()) {
$log('FEHLER: TAN für Kontenabfrage erforderlich. Nicht unterstützt.');
return;
}
$accounts = $getAccounts->getAccounts();
} catch (\Throwable $e) {
$log('FEHLER bei Kontenabfrage: ' . $e->getMessage());
return;
}
$log(count($accounts) . ' SEPA-Konten verfügbar.');
$accounts = $this->applyAccountFilter($accounts);
if ($accounts === null) {
return;
}
if (empty($accounts)) {
$log('Kein Konto entspricht der Auswahl – Abbruch.');
return;
}
$end = new \DateTime('today');
$lastFetched = $this->source['last_fetched_at'] ?? null;
if ($lastFetched) {
$start = new \DateTime((new \DateTime($lastFetched))->format('Y-m-d'));
$log('Letzter Abruf: ' . $start->format('d.m.Y') . ' – starte ab 00:00 Uhr dieses Tages.');
} else {
$start = (new \DateTime('today'))->modify('-365 days');
$log('Kein vorheriger Abruf – hole die letzten 365 Tage.');
}
$batchId = $this->createBatch();
$log('Batch #' . $batchId . ' angelegt.');
$totalNew = 0;
$totalSkip = 0;
foreach ($accounts as $account) {
$iban = $account->getIban();
$log('Konto ' . $iban . ':');
try {
$action = GetStatementOfAccount::create($account, $start, $end, false, true);
$fints->execute($action);
if ($action->needsTan()) {
if (!$this->handleStrongAuthentication($action, $iban)) {
continue;
}
}
$soa = $action->getStatement();
} catch (\Throwable $e) {
$log(' → Fehler beim Abruf: ' . $e->getMessage());
continue;
}
$accountNew = 0;
foreach ($soa->getStatements() as $statement) {
foreach ($statement->getTransactions() as $tx) {
$date = $tx->getValutaDate() ?: $tx->getBookingDate() ?: new \DateTime();
$sign = $tx->getCreditDebit() === Transaction::CD_DEBIT ? -1 : 1;
$amt = round($sign * (float)$tx->getAmount(), 2);
$name = trim((string)$tx->getName());
$desc = trim((string)$tx->getMainDescription());
$vorgang = trim((string)$tx->getBookingText()) ?: 'FinTS';
$extId = sha1($iban . '|' . $date->format('Y-m-d') . '|' . number_format($amt, 2, '.', '') . '|' . $desc);
if ($this->externalIdExists($extId)) {
$totalSkip++;
continue;
}
if ($name !== '' && $desc !== '') {
$buchungstext = $name . ' | ' . $desc;
} elseif ($desc !== '') {
$buchungstext = $desc;
} elseif ($name !== '') {
$buchungstext = $name;
} else {
$buchungstext = $vorgang;
}
$this->insertRow($batchId, [
'external_id' => $extId,
'buchungstag' => ($tx->getBookingDate() ?: $date)->format('Y-m-d'),
'wertstellung' => $date->format('Y-m-d'),
'vorgang' => mb_substr($vorgang, 0, 100),
'buchungstext' => mb_substr($buchungstext, 0, 1000),
'wert' => $amt,
'raw_line' => json_encode([
'iban' => $iban,
'booking_text' => $tx->getBookingText(),
'name' => $name,
'desc' => $desc,
'amount' => $amt,
'eref' => method_exists($tx, 'getEndToEndID') ? $tx->getEndToEndID() : null,
], JSON_UNESCAPED_UNICODE),
]);
$totalNew++;
$accountNew++;
}
}
$log(' → ' . $accountNew . ' neue Buchungen.');
}
$this->updateBatchCount($batchId, $totalNew);
$this->updateLastFetched();
$log(sprintf('Fertig. %d neu importiert, %d bereits vorhanden.', $totalNew, $totalSkip));
}
/**
* Behandelt eine TAN-Anfrage einer FinTS-Aktion.
* Decoupled (pushTAN/App-Bestätigung) → Polling.
* Klassisch (photoTAN/chipTAN) → Bild + Eingabefeld via SSE, Wartezeit auf Datei-Rendezvous.
*
* @return bool true, wenn die Aktion erfolgreich abgeschlossen wurde
*/
private function handleStrongAuthentication(BaseAction $action, string $contextLabel): bool
{
$log = $this->log;
$send = $this->send ?: function (string $event, string $data) use ($log): void {
// Fallback ohne SSE: nur loggen
$log(' → [event:' . $event . '] ' . $data);
};
$tanMode = $this->fints->getSelectedTanMode();
$tanRequest = $action->getTanRequest();
if ($tanMode->isDecoupled()) {
$log(' → ' . $contextLabel . ': Bitte im Banking-Gerät bestätigen…');
if ($tanRequest->getChallenge() !== null) {
$log(' Hinweis: ' . $tanRequest->getChallenge());
}
if (!$tanMode->allowsAutomatedPolling()) {
$log(' → Bank erlaubt kein automatisches Polling – aktuell nicht unterstützt.');
return false;
}
sleep((int)$tanMode->getFirstDecoupledCheckDelaySeconds());
$maxChecks = (int)$tanMode->getMaxDecoupledChecks();
$period = max(1, (int)$tanMode->getPeriodicDecoupledCheckDelaySeconds());
for ($attempt = 0; $attempt < $maxChecks; $attempt++) {
try {
$this->fints->checkDecoupledSubmission($action);
return true;
} catch (\Throwable $e) {
sleep($period);
}
}
$log(' → Zeitüberschreitung bei App-Bestätigung.');
return false;
}
// Klassisch: photoTAN/chipTAN – Bild zeigen, TAN entgegennehmen
$token = bin2hex(random_bytes(16));
$payload = [
'token' => $token,
'context' => $contextLabel,
'challenge' => $tanRequest->getChallenge(),
'mediumName' => $tanRequest->getTanMediumName(),
'imageMime' => null,
'imageData' => null,
];
if ($tanRequest->getChallengeHhdUc()) {
try {
$img = new TanRequestChallengeImage($tanRequest->getChallengeHhdUc());
$payload['imageMime'] = $img->getMimeType();
$payload['imageData'] = base64_encode($img->getData());
} catch (\Throwable) {
// war kein photoTAN-Bild (z.B. Flicker) – ohne Bild fortfahren
}
}
$log(' → ' . $contextLabel . ': TAN angefordert. Bitte im Browser eingeben…');
($send)('tan-required', json_encode($payload, JSON_UNESCAPED_UNICODE));
$tan = self::awaitTan($token, 300);
if ($tan === null) {
$log(' → Zeitüberschreitung bei TAN-Eingabe.');
return false;
}
try {
$this->fints->submitTan($action, $tan);
$log(' → TAN akzeptiert.');
return true;
} catch (\Throwable $e) {
$log(' → TAN abgelehnt: ' . $e->getMessage());
return false;
}
}
/**
* Eindeutiger Identifier für ein FinTS-Konto. IBAN wenn vorhanden, sonst
* "AN:<accountNumber>:<subAccount>". Robust gegen Depots ohne IBAN.
*/
private static function accountId($account): string
{
$iban = trim((string)$account->getIban());
if ($iban !== '') {
return $iban;
}
$sub = method_exists($account, 'getSubAccount') ? (string)$account->getSubAccount() : '';
return 'AN:' . $account->getAccountNumber() . ':' . $sub;
}
/**
* Wendet den gespeicherten account_filter an. Wenn kein Filter gesetzt und
* mehr als ein Konto verfügbar ist, wird der Nutzer per SSE-Event nach einer
* Auswahl gefragt und das Ergebnis persistiert.
*
* @return array<int,mixed>|null Liste der erlaubten Konten oder null bei Abbruch
*/
private function applyAccountFilter(array $accounts): ?array
{
$log = $this->log;
$send = $this->send ?: function (string $event, string $data) use ($log): void {
$log(' → [event:' . $event . '] ' . $data);
};
$rawFilter = $this->source['account_filter'] ?? null;
$allowedIds = null;
if ($rawFilter !== null && $rawFilter !== '') {
$decoded = json_decode((string)$rawFilter, true);
if (is_array($decoded)) {
$allowedIds = array_values(array_map('strval', $decoded));
}
}
// Erste Verwendung & mehrere Konten: Auswahl vom Nutzer einholen
if ($allowedIds === null && count($accounts) > 1) {
$token = bin2hex(random_bytes(16));
$payload = [
'token' => $token,
'accounts' => array_map(function ($a) {
$iban = trim((string)$a->getIban());
$accNo = (string)$a->getAccountNumber();
$sub = method_exists($a, 'getSubAccount') ? (string)$a->getSubAccount() : '';
return [
'id' => self::accountId($a),
'iban' => $iban,
'label' => $iban !== ''
? $iban
: ('Konto ' . $accNo . ($sub !== '' ? ' (' . $sub . ')' : '')),
];
}, $accounts),
];
$log('Mehrere Konten verfügbar – bitte Auswahl im Browser treffen.');
($send)('accounts-required', json_encode($payload, JSON_UNESCAPED_UNICODE));
$selected = self::awaitAccountSelection($token, 300);
if ($selected === null) {
$log('FEHLER: Zeitüberschreitung bei Konto-Auswahl.');
return null;
}
if (empty($selected)) {
$log('FEHLER: Keine Konten ausgewählt.');
return null;
}
$allowedIds = $selected;
Database::execute(
'UPDATE bank_sources SET account_filter = ? WHERE id = ?',
[json_encode(array_values($allowedIds), JSON_UNESCAPED_UNICODE), $this->sourceId]
);
$log('Konto-Auswahl gespeichert: ' . count($allowedIds) . ' Konto(n).');
}
if ($allowedIds === null) {
return $accounts;
}
$filtered = array_values(array_filter(
$accounts,
fn($a) => in_array(self::accountId($a), $allowedIds, true)
));
$log('Filter aktiv: ' . count($filtered) . ' Konto(n) verbleiben.');
return $filtered;
}
private static function tanDir(): string
{
$dir = sys_get_temp_dir() . '/steuerkram-tan';
if (!is_dir($dir)) {
@mkdir($dir, 0700, true);
}
return $dir;
}
/** Wird vom POST-Endpoint aufgerufen, um die vom Nutzer eingegebene TAN abzulegen. */
public static function submitPendingTan(string $token, string $tan): bool
{
if (!preg_match('/^[a-f0-9]{32}$/', $token)) {
return false;
}
return (bool)@file_put_contents(self::tanDir() . '/' . $token . '.tan', $tan);
}
/** Wird vom POST-Endpoint aufgerufen, um die Konto-Auswahl abzulegen. */
public static function submitAccountSelection(string $token, array $ids): bool
{
if (!preg_match('/^[a-f0-9]{32}$/', $token)) {
return false;
}
$ids = array_values(array_filter(array_map('strval', $ids), fn($s) => $s !== ''));
return (bool)@file_put_contents(
self::tanDir() . '/' . $token . '.accounts',
json_encode($ids, JSON_UNESCAPED_UNICODE)
);
}
/** Polled bis zu $timeoutSec Sekunden auf die TAN-Datei. */
private static function awaitTan(string $token, int $timeoutSec): ?string
{
$file = self::tanDir() . '/' . $token . '.tan';
$start = time();
while (time() - $start < $timeoutSec) {
if (file_exists($file)) {
$tan = trim((string)@file_get_contents($file));
@unlink($file);
return $tan !== '' ? $tan : null;
}
sleep(1);
}
return null;
}
/** Polled bis zu $timeoutSec Sekunden auf die Account-Auswahl-Datei. */
private static function awaitAccountSelection(string $token, int $timeoutSec): ?array
{
$file = self::tanDir() . '/' . $token . '.accounts';
$start = time();
while (time() - $start < $timeoutSec) {
if (file_exists($file)) {
$raw = (string)@file_get_contents($file);
@unlink($file);
$decoded = json_decode($raw, true);
return is_array($decoded) ? array_values(array_map('strval', $decoded)) : null;
}
sleep(1);
}
return null;
}
private function createBatch(): int
{
Database::execute(
'INSERT INTO bank_import_batches (source_id, filename, row_count)
VALUES (?, ?, 0)',
[$this->sourceId, 'fints-' . date('Y-m-d')]
);
return (int)Database::lastInsertId();
}
private function updateBatchCount(int $batchId, int $count): void
{
Database::execute(
'UPDATE bank_import_batches SET row_count = ? WHERE id = ?',
[$count, $batchId]
);
}
private function updateLastFetched(): void
{
Database::execute(
'UPDATE bank_sources SET last_fetched_at = NOW() WHERE id = ?',
[$this->sourceId]
);
}
private function externalIdExists(string $externalId): bool
{
$row = Database::fetchOne(
'SELECT id FROM bank_transactions WHERE external_id = ? LIMIT 1',
[$externalId]
);
return $row !== false && $row !== null;
}
private function insertRow(int $batchId, array $data): void
{
Database::execute(
'INSERT INTO bank_transactions
(batch_id, buchungstag, wertstellung, vorgang, buchungstext, wert, raw_line, external_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[
$batchId,
$data['buchungstag'],
$data['wertstellung'],
$data['vorgang'],
$data['buchungstext'],
$data['wert'],
$data['raw_line'] ?? null,
$data['external_id'],
]
);
}
}

View file

@ -0,0 +1,311 @@
<?php
declare(strict_types=1);
namespace Steuerkram\Services;
use Steuerkram\Database;
/**
* PayPal Classic NVP API – TransactionSearch
* Credentials: API-Benutzer (app_name), API-Kennwort (client_id), Signature (secret)
*/
class PayPalNvpFetchService
{
private string $endpoint;
private string $apiUser;
private string $apiPwd;
private string $signature;
private int $sourceId;
private array $source;
/** @var callable */
private $log;
private const VERSION = '204';
public function __construct(array $source, callable $log)
{
$sandbox = (bool)($source['sandbox'] ?? false);
$this->endpoint = $sandbox
? 'https://api-3t.sandbox.paypal.com/nvp'
: 'https://api-3t.paypal.com/nvp';
$this->apiUser = $source['app_name'] ?? '';
$this->apiPwd = $source['client_id'] ?? '';
$this->signature = $source['secret'] ?? '';
$this->sourceId = (int)$source['id'];
$this->source = $source;
$this->log = $log;
}
public function run(): void
{
($this->log)('Nutze PayPal Classic NVP API');
($this->log)('Endpunkt: ' . $this->endpoint);
$maskedUser = $this->apiUser
? substr($this->apiUser, 0, 6) . '…'
: '(leer)';
($this->log)('API-Benutzer: ' . $maskedUser . ' (' . strlen($this->apiUser) . ' Zeichen)');
($this->log)('Kennwort: ' . ($this->apiPwd ? strlen($this->apiPwd) . ' Zeichen' : 'FEHLT'));
($this->log)('Signature: ' . ($this->signature ? strlen($this->signature) . ' Zeichen' : 'FEHLT'));
if (!$this->apiUser || !$this->apiPwd || !$this->signature) {
($this->log)('FEHLER: Credentials unvollständig.');
return;
}
($this->log)('Verbindung wird getestet…');
if (!$this->testCredentials()) {
return;
}
($this->log)('Verbindung OK.');
$batchId = $this->createBatch();
($this->log)('Batch #' . $batchId . ' angelegt.');
$end = new \DateTimeImmutable('today');
$lastFetched = $this->source['last_fetched_at'] ?? null;
if ($lastFetched) {
$start = new \DateTimeImmutable((new \DateTimeImmutable($lastFetched))->format('Y-m-d'));
($this->log)('Letzter Abruf: ' . $start->format('d.m.Y') . ' – starte ab 00:00 Uhr dieses Tages');
} else {
$start = $end->modify('-365 days');
($this->log)('Kein vorheriger Abruf – hole die letzten 365 Tage');
}
$chunk = new \DateInterval('P30D');
$cursor = $start;
$totalNew = 0;
$totalSkip = 0;
while ($cursor < $end) {
$chunkEnd = $cursor->add($chunk);
if ($chunkEnd > $end) {
$chunkEnd = $end;
}
$from = $cursor->format('Y-m-d') . 'T00:00:00Z';
$to = $chunkEnd->format('Y-m-d') . 'T23:59:59Z';
($this->log)(sprintf('Zeitraum: %s → %s', $cursor->format('d.m.Y'), $chunkEnd->format('d.m.Y')));
$rows = $this->fetchTransactions($from, $to);
($this->log)(' → ' . count($rows) . ' Transaktionen gefunden');
foreach ($rows as $tx) {
$extId = $tx['external_id'];
if ($this->externalIdExists($extId)) {
$totalSkip++;
continue;
}
$this->insertRow($batchId, $tx);
$totalNew++;
($this->log)(sprintf(
' + %s %s %s €',
$extId,
mb_substr($tx['buchungstext'], 0, 40),
number_format((float)$tx['wert'], 2, ',', '.')
));
}
$cursor = $chunkEnd->modify('+1 day');
}
$this->updateBatchCount($batchId, $totalNew);
$this->updateLastFetched();
($this->log)(sprintf('Fertig. %d neu importiert, %d bereits vorhanden.', $totalNew, $totalSkip));
}
// ── NVP-API ──────────────────────────────────────────────────
private function nvpPost(array $params): ?array
{
$base = [
'USER' => $this->apiUser,
'PWD' => $this->apiPwd,
'SIGNATURE' => $this->signature,
'VERSION' => self::VERSION,
];
$body = http_build_query(array_merge($base, $params));
$ch = curl_init($this->endpoint);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_HTTPHEADER => ['Content-Type: application/x-www-form-urlencoded'],
CURLOPT_TIMEOUT => 30,
]);
$response = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code !== 200 || !$response) {
($this->log)('HTTP-Status: ' . $code);
return null;
}
parse_str((string)$response, $result);
return $result;
}
private function testCredentials(): bool
{
$result = $this->nvpPost(['METHOD' => 'GetBalance']);
if ($result === null) {
($this->log)('FEHLER: Keine Antwort vom API-Endpunkt.');
return false;
}
$ack = $result['ACK'] ?? '';
if (!in_array($ack, ['Success', 'SuccessWithWarning'], true)) {
$msg = $result['L_LONGMESSAGE0'] ?? $result['L_SHORTMESSAGE0'] ?? 'Unbekannt';
($this->log)('FEHLER: ' . $msg);
($this->log)('ACK: ' . $ack);
if (stripos($msg, 'Security header') !== false) {
($this->log)('');
($this->log)('Ursache: Credentials passen nicht zum Endpunkt.');
($this->log)(' → Live-Credentials: Sandbox-Häkchen DEAKTIVIEREN');
($this->log)(' → Sandbox-Credentials: Sandbox-Häkchen AKTIVIEREN');
($this->log)('');
($this->log)('Live-API-Credentials findest du unter:');
($this->log)(' paypal.com → Konto → Profil → Mein Verkäuferkonto → API-Zugriff');
}
return false;
}
return true;
}
/** @return array<array<string,mixed>> */
private function fetchTransactions(string $from, string $to): array
{
$result = $this->nvpPost([
'METHOD' => 'TransactionSearch',
'STARTDATE' => $from,
'ENDDATE' => $to,
'STATUS' => 'All',
]);
if ($result === null) {
return [];
}
$ack = $result['ACK'] ?? '';
if (!in_array($ack, ['Success', 'SuccessWithWarning'], true)) {
($this->log)(' API-Fehler: ' . ($result['L_LONGMESSAGE0'] ?? $ack));
return [];
}
$rows = [];
$i = 0;
while (isset($result['L_TRANSACTIONID' . $i])) {
$txId = $result['L_TRANSACTIONID' . $i];
$type = $result['L_TYPE' . $i] ?? '';
$status = $result['L_STATUS' . $i] ?? '';
$name = trim($result['L_NAME' . $i] ?? '');
$email = trim($result['L_EMAIL' . $i] ?? '');
$amt = (float)($result['L_AMT' . $i] ?? 0);
$net = isset($result['L_NETAMT' . $i])
? (float)$result['L_NETAMT' . $i]
: $amt;
$dateRaw = $result['L_TIMESTAMP' . $i] ?? '';
$date = $dateRaw
? (new \DateTimeImmutable($dateRaw))->format('Y-m-d')
: date('Y-m-d');
// 0-€-Einträge (Agreements, Subscriptions) überspringen
if ($net == 0.0 && $amt == 0.0) {
$i++;
continue;
}
if ($name && $email) {
$buchungstext = $name . ' <' . $email . '>';
} elseif ($email) {
$buchungstext = $email;
} elseif ($name) {
$buchungstext = $name;
} else {
$buchungstext = 'PayPal ' . $type;
}
$rows[] = [
'external_id' => $txId,
'buchungstag' => $date,
'wertstellung' => $date,
'vorgang' => $type,
'buchungstext' => $buchungstext,
'wert' => $net,
'raw_line' => json_encode([
'id' => $txId,
'type' => $type,
'status' => $status,
'amt' => $amt,
'net' => $net,
]),
];
$i++;
}
return $rows;
}
// ── Datenbankoperationen ─────────────────────────────────────
private function createBatch(): int
{
Database::execute(
'INSERT INTO bank_import_batches (source_id, filename, row_count)
VALUES (?, ?, 0)',
[$this->sourceId, 'paypal-nvp-' . date('Y-m-d')]
);
$row = Database::fetchOne('SELECT LAST_INSERT_ID() AS id');
return (int)$row['id'];
}
private function updateBatchCount(int $batchId, int $count): void
{
Database::execute(
'UPDATE bank_import_batches SET row_count = ? WHERE id = ?',
[$count, $batchId]
);
}
private function updateLastFetched(): void
{
Database::execute(
'UPDATE bank_sources SET last_fetched_at = NOW() WHERE id = ?',
[$this->sourceId]
);
}
private function externalIdExists(string $externalId): bool
{
$row = Database::fetchOne(
'SELECT id FROM bank_transactions WHERE external_id = ? LIMIT 1',
[$externalId]
);
return $row !== false && $row !== null;
}
private function insertRow(int $batchId, array $data): void
{
Database::execute(
'INSERT INTO bank_transactions
(batch_id, buchungstag, wertstellung, vorgang, buchungstext, wert, raw_line, external_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[
$batchId,
$data['buchungstag'],
$data['wertstellung'],
$data['vorgang'],
$data['buchungstext'],
$data['wert'],
$data['raw_line'] ?? null,
$data['external_id'],
]
);
}
}

683
src/Views/bank/fints.php Normal file
View file

@ -0,0 +1,683 @@
<?php
use Steuerkram\View;
$h = fn($v) => View::h($v);
$kTextClr = fn(string $hex): string => (0.299*hexdec(substr($hex,1,2))+0.587*hexdec(substr($hex,3,2))+0.114*hexdec(substr($hex,5,2))) > 128 ? '#1e293b' : '#ffffff';
// Verbindungsdaten als JSON für JS bereitstellen.
// PINs und API-Secrets werden bewusst NICHT mitgeschickt – im Formular blanko, leer = unverändert.
$connectionsJson = json_encode(array_map(fn($c) => [
'id' => (int)$c['id'],
'bezeichnung' => $c['bezeichnung'],
'type' => $c['type'],
'provider_id' => (int)$c['provider_id'],
'provider_name' => $c['provider_name'],
'verbindungstyp' => $c['verbindungstyp'],
'kuerzel' => $c['kuerzel'] ?? '',
'farbe' => $c['farbe'] ?? '#6366f1',
'app_name' => $c['app_name'] ?? '',
'sandbox' => (bool)($c['sandbox'] ?? false),
'blz' => $c['blz'] ?? '',
'fints_url' => $c['fints_url'] ?? '',
'benutzerkennung' => $c['benutzerkennung'] ?? '',
'account_filter' => $c['account_filter'] ?? null,
], $connections));
?>
<?php if (empty($connections)): ?>
<div class="card">
<div class="card-body" style="text-align:center;padding:3rem;color:var(--color-muted)">
Noch keine Direktverbindungen konfiguriert. Klicke auf <strong>NEU</strong>, um eine hinzuzufügen.
</div>
</div>
<?php else: ?>
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:1rem">
<?php foreach ($connections as $conn): ?>
<div class="card btn-run-connection" data-id="<?= (int)$conn['id'] ?>" style="margin:0;cursor:pointer" title="Abruf starten">
<div class="card-body">
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:.5rem">
<div>
<div style="display:flex;align-items:center;gap:.5rem;flex-wrap:wrap">
<?php if (!empty($conn['kuerzel'])): ?>
<?php $kf = $conn['farbe'] ?? '#6366f1'; ?>
<span class="kuerzel-badge" style="background:<?= $h($kf) ?>;color:<?= $kTextClr($kf) ?>"><?= $h($conn['kuerzel']) ?></span>
<?php endif; ?>
<div style="font-weight:600;font-size:1rem"><?= $h($conn['bezeichnung']) ?></div>
</div>
<div class="text-muted text-sm" style="margin-top:.2rem"><?= $h($conn['provider_name']) ?></div>
<span class="badge <?= $conn['verbindungstyp'] === 'API' ? 'badge-blue' : 'badge-green' ?>" style="margin-top:.5rem">
<?= $h($conn['verbindungstyp']) ?>
</span>
<div class="text-muted text-sm" style="margin-top:.6rem">
Abruf: <?= $conn['last_fetched_at'] ? $h(View::formatDate($conn['last_fetched_at'])) : '<kein>' ?>
</div>
</div>
<button type="button"
class="btn-edit-connection"
data-id="<?= (int)$conn['id'] ?>"
style="background:none;border:none;cursor:pointer;color:var(--color-muted);padding:.2rem;line-height:1;flex-shrink:0"
title="Bearbeiten">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
</button>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<!-- Modal: Neue Verbindung -->
<div id="modal-new-connection"
style="display:none;position:fixed;inset:0;z-index:1000;background:rgba(0,0,0,.4);align-items:center;justify-content:center">
<div style="background:#fff;border-radius:var(--radius);box-shadow:0 8px 32px rgba(0,0,0,.15);width:100%;max-width:480px;padding:1.5rem">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1.25rem">
<h2 style="margin:0;font-size:1.125rem">Neue Direktverbindung</h2>
<button type="button" id="btn-modal-close"
style="background:none;border:none;font-size:1.25rem;cursor:pointer;color:var(--color-muted)">&times;</button>
</div>
<form method="post" action="/bank/direkt">
<div class="form-group">
<label for="fc-provider">Bank / Anbieter</label>
<select id="fc-provider" name="provider_id" required>
<option value="">Bitte wählen…</option>
<?php foreach ($providers as $p): ?>
<option value="<?= (int)$p['id'] ?>"
data-typ="<?= $h($p['verbindungstyp']) ?>">
<?= $h($p['name']) ?>
</option>
<?php endforeach; ?>
<option value="__new__" data-typ="FinTS">+ Neue Bank…</option>
</select>
</div>
<div id="fc-new-bank-wrap" style="display:none">
<div class="form-group">
<label for="fc-new-bank-name">Name der neuen Bank</label>
<input type="text" id="fc-new-bank-name" name="new_bank_name"
placeholder="z. B. Sparkasse Musterstadt" maxlength="100">
</div>
<div class="form-group">
<label>Verbindungstyp</label>
<div style="display:flex;gap:1rem;padding:.25rem 0">
<label style="display:flex;align-items:center;gap:.4rem;font-weight:normal;cursor:pointer">
<input type="radio" name="new_bank_typ" value="FinTS" checked style="width:auto;margin:0">
<span>FinTS</span>
</label>
<label style="display:flex;align-items:center;gap:.4rem;font-weight:normal;cursor:pointer">
<input type="radio" name="new_bank_typ" value="API" style="width:auto;margin:0">
<span>API</span>
</label>
</div>
<div class="text-muted text-sm">FinTS = klassischer Bank-Abruf via PIN. API = REST/NVP-Schnittstelle (wie PayPal).</div>
</div>
</div>
<div class="form-group">
<label for="fc-bezeichnung">Bezeichnung</label>
<input type="text" id="fc-bezeichnung" name="bezeichnung" required
placeholder="z. B. Geschäftskonto PayPal">
</div>
<div style="display:flex;gap:.75rem;align-items:flex-end">
<div class="form-group" style="flex:1;margin-bottom:0">
<label for="fc-kuerzel">Kürzel (Badge)</label>
<input type="text" id="fc-kuerzel" name="kuerzel" maxlength="10" placeholder="PP">
</div>
<div class="form-group" style="margin-bottom:0">
<label for="fc-farbe">Farbe</label>
<input type="color" id="fc-farbe" name="farbe" value="#6366f1" style="width:48px;height:36px;padding:2px;cursor:pointer">
</div>
</div>
<div id="api-fields" style="display:none">
<div class="form-group">
<label for="fc-app-name">API-Benutzer</label>
<input type="text" id="fc-app-name" name="app_name" autocomplete="off">
</div>
<div class="form-group">
<label for="fc-client-id">API-Kennwort</label>
<input type="password" id="fc-client-id" name="client_id" autocomplete="new-password">
</div>
<div class="form-group">
<label for="fc-secret">Signature (Secret)</label>
<input type="password" id="fc-secret" name="secret" autocomplete="new-password">
</div>
<div class="form-group" style="display:flex;align-items:center;gap:.5rem">
<input type="checkbox" id="fc-sandbox" name="sandbox" value="1" style="width:auto;margin:0">
<label for="fc-sandbox" style="margin:0;font-weight:normal;cursor:pointer">Sandbox-Modus (Testumgebung)</label>
</div>
</div>
<div id="fints-fields" style="display:none">
<div class="form-group">
<label for="fc-blz">Bankleitzahl (BLZ)</label>
<input type="text" id="fc-blz" name="blz" maxlength="11" placeholder="z. B. 12030000">
</div>
<div class="form-group">
<label for="fc-benutzerkennung">Benutzerkennung / Anmeldename</label>
<input type="text" id="fc-benutzerkennung" name="benutzerkennung" autocomplete="off">
</div>
<div class="form-group">
<label for="fc-pin">PIN</label>
<input type="password" id="fc-pin" name="pin" autocomplete="new-password">
</div>
<div class="form-group">
<label for="fc-fints-url">FinTS-Server-URL <span class="text-muted text-sm">(optional, wird sonst über BLZ ermittelt)</span></label>
<input type="url" id="fc-fints-url" name="fints_url" placeholder="https://...">
</div>
<div class="text-muted text-sm">Die Konto-Auswahl wird beim ersten Abruf abgefragt.</div>
</div>
<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="submit" class="btn btn-primary">Speichern</button>
</div>
</form>
</div>
</div>
<!-- Lösch-Formular (außerhalb des Modals, damit keine verschachtelten Forms entstehen) -->
<form id="form-edit-delete" method="post" action="" style="display:none"></form>
<!-- Modal: Verbindung bearbeiten -->
<div id="modal-edit-connection"
style="display:none;position:fixed;inset:0;z-index:1000;background:rgba(0,0,0,.4);align-items:center;justify-content:center">
<div style="background:#fff;border-radius:var(--radius);box-shadow:0 8px 32px rgba(0,0,0,.15);width:100%;max-width:480px;padding:1.5rem">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1.25rem">
<h2 style="margin:0;font-size:1.125rem">Verbindung bearbeiten</h2>
<button type="button" id="btn-edit-close"
style="background:none;border:none;font-size:1.25rem;cursor:pointer;color:var(--color-muted)">&times;</button>
</div>
<form id="form-edit-connection" method="post" action="">
<div class="form-group">
<label>Bank / Anbieter</label>
<div id="edit-provider-name" style="padding:.4rem 0;font-weight:500"></div>
</div>
<div class="form-group">
<label for="edit-bezeichnung">Bezeichnung</label>
<input type="text" id="edit-bezeichnung" name="bezeichnung" required>
</div>
<div style="display:flex;gap:.75rem;align-items:flex-end">
<div class="form-group" style="flex:1;margin-bottom:0">
<label for="edit-kuerzel">Kürzel (Badge)</label>
<input type="text" id="edit-kuerzel" name="kuerzel" maxlength="10" placeholder="PP">
</div>
<div class="form-group" style="margin-bottom:0">
<label for="edit-farbe">Farbe</label>
<input type="color" id="edit-farbe" name="farbe" value="#6366f1" style="width:48px;height:36px;padding:2px;cursor:pointer">
</div>
</div>
<div id="edit-api-fields" style="display:none">
<div class="form-group">
<label for="edit-app-name">API-Benutzer</label>
<input type="text" id="edit-app-name" name="app_name" autocomplete="off">
</div>
<div class="form-group">
<label for="edit-client-id">API-Kennwort</label>
<input type="password" id="edit-client-id" name="client_id" autocomplete="new-password"
placeholder="Leer = unverändert behalten">
</div>
<div class="form-group">
<label for="edit-secret">Signature (Secret)</label>
<input type="password" id="edit-secret" name="secret" autocomplete="new-password"
placeholder="Leer = unverändert behalten">
</div>
<div class="form-group" style="display:flex;align-items:center;gap:.5rem">
<input type="checkbox" id="edit-sandbox" name="sandbox" value="1" style="width:auto;margin:0">
<label for="edit-sandbox" style="margin:0;font-weight:normal;cursor:pointer">Sandbox-Modus (Testumgebung)</label>
</div>
</div>
<div id="edit-fints-fields" style="display:none">
<div class="form-group">
<label for="edit-blz">Bankleitzahl (BLZ)</label>
<input type="text" id="edit-blz" name="blz" maxlength="11">
</div>
<div class="form-group">
<label for="edit-benutzerkennung">Benutzerkennung / Anmeldename</label>
<input type="text" id="edit-benutzerkennung" name="benutzerkennung" autocomplete="off">
</div>
<div class="form-group">
<label for="edit-pin">PIN</label>
<input type="password" id="edit-pin" name="pin" autocomplete="new-password"
placeholder="Leer = unverändert behalten">
</div>
<div class="form-group">
<label for="edit-fints-url">FinTS-Server-URL <span class="text-muted text-sm">(optional)</span></label>
<input type="url" id="edit-fints-url" name="fints_url" placeholder="https://...">
</div>
<div class="form-group">
<label>Aktive Konten</label>
<div id="edit-account-list" class="text-sm" style="padding:.4rem 0;color:#475569">–</div>
<label style="display:flex;align-items:center;gap:.5rem;font-weight:normal;cursor:pointer;margin-top:.4rem">
<input type="checkbox" id="edit-reset-accounts" name="reset_account_filter" value="1" style="width:auto;margin:0">
<span>Auswahl zurücksetzen (beim nächsten Abruf neu fragen)</span>
</label>
</div>
</div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:1rem">
<button type="button" id="btn-edit-delete" class="btn btn-danger btn-sm">Löschen</button>
<div style="display:flex;gap:.5rem">
<button type="button" id="btn-edit-cancel" class="btn btn-secondary">Abbrechen</button>
<button type="submit" class="btn btn-primary">Speichern</button>
</div>
</div>
</form>
</div>
</div>
<!-- Modal: Konto-Auswahl bei mehreren Konten -->
<div id="modal-account-select"
style="display:none;position:fixed;inset:0;z-index:1100;background:rgba(0,0,0,.4);align-items:center;justify-content:center">
<div style="background:#fff;border-radius:var(--radius);box-shadow:0 8px 32px rgba(0,0,0,.15);width:100%;max-width:480px;padding:1.5rem;display:flex;flex-direction:column;gap:.75rem">
<h2 style="margin:0;font-size:1.125rem">Konten auswählen</h2>
<div class="text-muted text-sm">Wähle die Konten, die regelmäßig importiert werden sollen. Die Auswahl wird gespeichert und kann später im Bearbeiten-Dialog zurückgesetzt werden.</div>
<div id="account-list" style="display:flex;flex-direction:column;gap:.4rem;max-height:280px;overflow-y:auto;padding:.25rem"></div>
<div id="account-error" class="text-sm" style="color:#dc2626;display:none"></div>
<div style="display:flex;justify-content:flex-end;gap:.5rem">
<button type="button" id="btn-account-cancel" class="btn btn-secondary">Abbrechen</button>
<button type="button" id="btn-account-submit" class="btn btn-primary">Auswahl speichern</button>
</div>
</div>
</div>
<!-- Modal: TAN-Eingabe (photoTAN/chipTAN) -->
<div id="modal-tan-input"
style="display:none;position:fixed;inset:0;z-index:1100;background:rgba(0,0,0,.4);align-items:center;justify-content:center">
<div style="background:#fff;border-radius:var(--radius);box-shadow:0 8px 32px rgba(0,0,0,.15);width:100%;max-width:480px;padding:1.5rem;display:flex;flex-direction:column;gap:.75rem">
<h2 style="margin:0;font-size:1.125rem">TAN eingeben</h2>
<div id="tan-context" class="text-muted text-sm"></div>
<div id="tan-challenge" class="text-sm" style="white-space:pre-wrap"></div>
<div id="tan-image-wrap" style="text-align:center"></div>
<div id="tan-medium" class="text-muted text-sm"></div>
<div class="form-group" style="margin:0">
<label for="tan-input">TAN</label>
<input type="text" id="tan-input" autocomplete="off" inputmode="numeric"
style="font-size:1.25rem;letter-spacing:.25em;text-align:center">
</div>
<div id="tan-error" class="text-sm" style="color:#dc2626;display:none"></div>
<div style="display:flex;justify-content:flex-end;gap:.5rem">
<button type="button" id="btn-tan-cancel" class="btn btn-secondary">Abbrechen</button>
<button type="button" id="btn-tan-submit" class="btn btn-primary">TAN absenden</button>
</div>
</div>
</div>
<!-- Modal: Abruf-Log -->
<div id="modal-run-log"
style="display:none;position:fixed;inset:0;z-index:1000;background:rgba(0,0,0,.4);align-items:center;justify-content:center">
<div style="background:#fff;border-radius:var(--radius);box-shadow:0 8px 32px rgba(0,0,0,.15);width:100%;max-width:680px;padding:1.5rem;display:flex;flex-direction:column;gap:1rem">
<div style="display:flex;justify-content:space-between;align-items:center">
<h2 style="margin:0;font-size:1.125rem" id="run-log-title">Abruf läuft…</h2>
<span id="run-log-spinner" style="font-size:.8rem;color:var(--color-muted)">⏳ wird abgerufen…</span>
</div>
<div id="run-log-area"
style="background:#0f172a;color:#e2e8f0;font-family:monospace;font-size:.8rem;
padding:1rem;border-radius:var(--radius);height:340px;overflow-y:auto;white-space:pre-wrap;line-height:1.5">
</div>
<div style="display:flex;justify-content:flex-end">
<button type="button" id="btn-run-close" class="btn btn-primary" disabled>Schließen</button>
</div>
</div>
</div>
<script>
(function () {
const connections = <?= $connectionsJson ?>;
// ── 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 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(); });
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__';
newBankWrap.style.display = isNew ? 'block' : 'none';
newBankInput.required = isNew;
if (isNew) {
const selected = modalNew.querySelector('input[name="new_bank_typ"]:checked');
syncTypFields(selected ? selected.value : 'FinTS');
} 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 editAccountList = document.getElementById('edit-account-list');
const editResetAcc = document.getElementById('edit-reset-accounts');
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;
function closeRun() {
if (activeEs) { activeEs.close(); activeEs = null; }
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(); });
document.querySelectorAll('.btn-run-connection').forEach(card => {
card.addEventListener('click', (e) => {
if (e.target.closest('.btn-edit-connection')) return;
const id = card.dataset.id;
currentSourceId = id;
runLogArea.textContent = '';
runTitle.textContent = 'Abruf läuft…';
runSpinner.textContent = '⏳ wird abgerufen…';
btnRunClose.disabled = true;
modalRun.style.display = 'flex';
if (activeEs) activeEs.close();
activeEs = new EventSource('/bank/direkt/' + id + '/run');
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;
runTitle.textContent = ev.data === 'ok' ? 'Abruf abgeschlossen' : 'Abruf mit Fehlern beendet';
runSpinner.textContent = ev.data === 'ok' ? '✓ 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';
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 || '';
editResetAcc.checked = false;
let accountsText = '–';
if (conn.account_filter) {
try {
const arr = JSON.parse(conn.account_filter);
if (Array.isArray(arr) && arr.length > 0) {
accountsText = arr.join(', ');
}
} catch (e) { /* ignore */ }
} else {
accountsText = '(wird beim nächsten Abruf abgefragt)';
}
editAccountList.textContent = accountsText;
}
modalEdit.style.display = 'flex';
});
});
})();
</script>

View file

@ -294,7 +294,7 @@
// "Formate bearbeiten"-Link: Suggestion in Session speichern, dann navigieren
document.addEventListener('click', async (e) => {
const link = e.target.closest('a[href="/settings/bankimport"]');
const link = e.target.closest('a[href="/bank/direkt"], a[href="/settings/bankimport"]');
if (!link || !suggestedFormat) return;
e.preventDefault();
@ -305,7 +305,7 @@
body: JSON.stringify(suggestedFormat),
});
} catch (_) {}
window.location.href = '/settings/bankimport';
window.location.href = link.getAttribute('href');
});
})();
</script>

View file

@ -95,7 +95,7 @@ function navActive(string $prefix, string $current): string {
</a>
<a href="/settings/bankimport" class="<?= navActive('/settings/bankimport', $path) ?>" style="padding-left:2.25rem;font-size:.875rem">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/><line x1="6" y1="15" x2="10" y2="15"/></svg>
Bankimport
CSV Bankimport Formate
</a>
<?php endif; ?>
</nav>

View file

@ -1 +1 @@
1-260521
1-260522

75
tests/36_CryptoTest.php Normal file
View file

@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
use Steuerkram\Config;
use Steuerkram\Services\Crypto;
/**
* Test 36: Crypto – symmetrische Verschlüsselung für sensible Felder.
*/
class CryptoTest extends TestCase
{
protected string $testName = 'Test 36: Crypto – AES-256-GCM Roundtrip und Idempotenz';
public function run(): void
{
// Test-Key setzen und Config neu laden, damit Config::get('app_key') greift.
$oldKey = $_ENV['APP_KEY'] ?? null;
$_ENV['APP_KEY'] = str_repeat('a1', 32); // 64 Hex-Zeichen
Config::load();
try {
// 1. Roundtrip
$plain = 'streng-geheimes-passwort!';
$cipher = Crypto::encrypt($plain);
$this->assert($cipher !== $plain, 'Verschlüsselter Wert unterscheidet sich vom Klartext');
$this->assert(str_starts_with($cipher, 'enc:v1:'), 'Verschlüsselter Wert hat Magic-Prefix enc:v1:');
$this->assertEquals($plain, Crypto::decrypt($cipher), 'Entschlüsselung liefert Originaltext');
// 2. Leerer String
$this->assertEquals('', Crypto::encrypt(''), 'Leerer Eingabewert wird leer zurückgegeben (encrypt)');
$this->assertEquals('', Crypto::decrypt(''), 'Leerer Eingabewert wird leer zurückgegeben (decrypt)');
// 3. Idempotenz: bereits verschlüsselt → unverändert
$cipher2 = Crypto::encrypt($cipher);
$this->assertEquals($cipher, $cipher2, 'encrypt() auf bereits verschlüsselten Wert ist idempotent');
// 4. Migration: Klartext-Wert ohne Prefix → unverändert durch decrypt()
$this->assertEquals('roher-text', Crypto::decrypt('roher-text'), 'decrypt() reicht Klartext (ohne Prefix) unverändert durch');
// 5. Zwei Verschlüsselungen desselben Klartextes ergeben verschiedene Cipher (zufälliges IV)
$a = Crypto::encrypt('hallo');
$b = Crypto::encrypt('hallo');
$this->assert($a !== $b, 'Zwei encrypt()-Aufrufe ergeben verschiedene Cipher (IV ist zufällig)');
$this->assertEquals('hallo', Crypto::decrypt($a), 'Cipher A entschlüsselt korrekt');
$this->assertEquals('hallo', Crypto::decrypt($b), 'Cipher B entschlüsselt korrekt');
// 6. Falscher Key → Exception
$_ENV['APP_KEY'] = str_repeat('b2', 32);
Config::load();
$threw = false;
try {
Crypto::decrypt($cipher);
} catch (\RuntimeException) {
$threw = true;
}
$this->assert($threw, 'decrypt() mit falschem Schlüssel wirft RuntimeException');
// 7. isEncrypted-Helper
$_ENV['APP_KEY'] = str_repeat('a1', 32);
Config::load();
$this->assert(Crypto::isEncrypted($cipher), 'isEncrypted erkennt verschlüsselten Wert');
$this->assert(!Crypto::isEncrypted('plain'), 'isEncrypted erkennt Klartext nicht als verschlüsselt');
$this->assert(!Crypto::isEncrypted(''), 'isEncrypted ist false für leeren String');
} finally {
// Original-Key wiederherstellen
if ($oldKey === null) {
unset($_ENV['APP_KEY']);
} else {
$_ENV['APP_KEY'] = $oldKey;
}
Config::load();
}
}
}

129
tests/37_BankSourceTest.php Normal file
View file

@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
use Steuerkram\Config;
use Steuerkram\Database;
use Steuerkram\Models\BankSource;
/**
* Test 37: BankSource – CRUD über alle drei Typen, Verschlüsselungs-Roundtrip,
* Dedup über external_id und FK-Integrität bank_import_batches.source_id.
*/
class BankSourceTest extends TestCase
{
protected string $testName = 'Test 37: BankSource – CSV/API/FinTS Roundtrip & Verschlüsselung';
public function run(): void
{
// Test-Key setzen (falls noch nicht aus .env)
$oldKey = $_ENV['APP_KEY'] ?? null;
if (empty(Config::get('app_key', ''))) {
$_ENV['APP_KEY'] = str_repeat('c3', 32);
Config::load();
}
try {
// 1. CSV-Quelle anlegen und wieder lesen
$csvId = BankSource::create([
'type' => 'csv',
'bezeichnung' => 'TestCSV',
'kuerzel' => 'TC',
'farbe' => '#abcdef',
'skip_rows' => 3,
'has_header_row' => 1,
'delimiter' => ';',
'enclosure' => '"',
'col_buchungstag' => 0,
'col_wertstellung' => 1,
'col_vorgang' => 2,
'col_buchungstext' => 3,
'col_wert' => 4,
'date_format' => 'd.m.Y',
'decimal_separator' => ',',
'thousands_separator' => '.',
]);
$this->assertGreaterThan(0, $csvId, 'CSV-Quelle wurde angelegt');
$csv = BankSource::find($csvId);
$this->assertNotEmpty($csv, 'CSV-Quelle gefunden');
$this->assertEquals('csv', $csv['type'], 'CSV-Quelle hat type=csv');
$this->assertEquals('TestCSV', $csv['name'], 'BankSource liefert Alias name = bezeichnung');
$this->assertEquals('TC', $csv['kuerzel'], 'Kürzel korrekt');
// 2. API-Quelle mit verschlüsselten Credentials
$providerApi = Database::fetchOne("SELECT id FROM bank_providers WHERE verbindungstyp='API' LIMIT 1");
if ($providerApi) {
$apiId = BankSource::create([
'type' => 'api',
'provider_id' => (int)$providerApi['id'],
'bezeichnung' => 'TestAPI',
'app_name' => 'app-user',
'client_id' => 'secret-key-1',
'secret' => 'super-signature',
'sandbox' => 0,
]);
$this->assertGreaterThan(0, $apiId, 'API-Quelle wurde angelegt');
// DB-Spalte muss verschlüsselt sein
$raw = Database::fetchOne('SELECT client_id_enc, secret_enc FROM bank_sources WHERE id = ?', [$apiId]);
$this->assert(str_starts_with($raw['client_id_enc'], 'enc:v1:'), 'client_id_enc ist verschlüsselt');
$this->assert(str_starts_with($raw['secret_enc'], 'enc:v1:'), 'secret_enc ist verschlüsselt');
// BankSource::find liefert entschlüsselt
$api = BankSource::find($apiId);
$this->assertEquals('secret-key-1', $api['client_id'], 'client_id entschlüsselt korrekt');
$this->assertEquals('super-signature', $api['secret'], 'secret entschlüsselt korrekt');
}
// 3. FinTS-Quelle mit PIN
$providerFints = Database::fetchOne("SELECT id FROM bank_providers WHERE verbindungstyp='FinTS' LIMIT 1");
if ($providerFints) {
$fintsId = BankSource::create([
'type' => 'fints',
'provider_id' => (int)$providerFints['id'],
'bezeichnung' => 'TestFinTS',
'blz' => '12030000',
'benutzerkennung' => 'kundennummer',
'pin' => 'g3h31m',
]);
$this->assertGreaterThan(0, $fintsId, 'FinTS-Quelle wurde angelegt');
$raw = Database::fetchOne('SELECT pin_enc FROM bank_sources WHERE id = ?', [$fintsId]);
$this->assert(str_starts_with($raw['pin_enc'], 'enc:v1:'), 'pin_enc ist verschlüsselt');
$fints = BankSource::find($fintsId);
$this->assertEquals('g3h31m', $fints['pin'], 'PIN entschlüsselt korrekt');
$this->assertEquals('12030000', $fints['blz'], 'BLZ korrekt');
// 4. updateField mit Klartext-PIN → muss verschlüsselt landen
BankSource::updateField($fintsId, 'pin', 'neue-pin');
$raw2 = Database::fetchOne('SELECT pin_enc FROM bank_sources WHERE id = ?', [$fintsId]);
$this->assert(str_starts_with($raw2['pin_enc'], 'enc:v1:'), 'updateField verschlüsselt pin');
$fints2 = BankSource::find($fintsId);
$this->assertEquals('neue-pin', $fints2['pin'], 'pin nach updateField wieder entschlüsselbar');
}
// 5. Filter all('csv') liefert nur CSV-Einträge
$allCsv = BankSource::all('csv');
foreach ($allCsv as $row) {
$this->assertEquals('csv', $row['type'], 'all(csv) liefert nur csv-Einträge');
}
// 6. Batch mit source_id anlegen funktioniert (FK)
Database::execute(
'INSERT INTO bank_import_batches (source_id, filename, row_count) VALUES (?, ?, 0)',
[$csvId, 'test-batch.csv']
);
$bid = (int)Database::lastInsertId();
$this->assertGreaterThan(0, $bid, 'Batch mit source_id angelegt');
} finally {
if ($oldKey === null) {
unset($_ENV['APP_KEY']);
} else {
$_ENV['APP_KEY'] = $oldKey;
}
Config::load();
}
}
}