diff --git a/.env.example b/.env.example index 6b5bed4..d172c53 100644 --- a/.env.example +++ b/.env.example @@ -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. # diff --git a/Changes.md b/Changes.md index 08eb7e0..76006f8 100644 --- a/Changes.md +++ b/Changes.md @@ -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) diff --git a/composer.json b/composer.json index 5ac7bc3..90c2644 100644 --- a/composer.json +++ b/composer.json @@ -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": { diff --git a/composer.lock b/composer.lock index ca15d8f..2e941d3 100644 --- a/composer.lock +++ b/composer.lock @@ -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": [], diff --git a/docker-compose.yml b/docker-compose.yml index 92736dc..f3c2b43 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/docs/funktionen.md b/docs/funktionen.md index 477c0fc..a823d32 100644 --- a/docs/funktionen.md +++ b/docs/funktionen.md @@ -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 --- diff --git a/migrations/062_kontenrahmen_sync.sql b/migrations/062_kontenrahmen_sync.sql new file mode 100644 index 0000000..c29dadf --- /dev/null +++ b/migrations/062_kontenrahmen_sync.sql @@ -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`); diff --git a/migrations/063_bank_direktverbindungen.sql b/migrations/063_bank_direktverbindungen.sql new file mode 100644 index 0000000..35f80d4 --- /dev/null +++ b/migrations/063_bank_direktverbindungen.sql @@ -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; diff --git a/migrations/064_bank_connections_last_fetched.sql b/migrations/064_bank_connections_last_fetched.sql new file mode 100644 index 0000000..168aeed --- /dev/null +++ b/migrations/064_bank_connections_last_fetched.sql @@ -0,0 +1,2 @@ +ALTER TABLE bank_connections + ADD COLUMN IF NOT EXISTS last_fetched_at TIMESTAMP NULL DEFAULT NULL; diff --git a/migrations/065_bank_api_fetch.sql b/migrations/065_bank_api_fetch.sql new file mode 100644 index 0000000..83bbb9c --- /dev/null +++ b/migrations/065_bank_api_fetch.sql @@ -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); diff --git a/migrations/066_bank_connection_sandbox.sql b/migrations/066_bank_connection_sandbox.sql new file mode 100644 index 0000000..0ba5237 --- /dev/null +++ b/migrations/066_bank_connection_sandbox.sql @@ -0,0 +1,2 @@ +ALTER TABLE bank_connections + ADD COLUMN IF NOT EXISTS sandbox TINYINT(1) NOT NULL DEFAULT 0; diff --git a/migrations/067_bank_connection_badge.sql b/migrations/067_bank_connection_badge.sql new file mode 100644 index 0000000..3643807 --- /dev/null +++ b/migrations/067_bank_connection_badge.sql @@ -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'; diff --git a/migrations/068_bank_sources.sql b/migrations/068_bank_sources.sql new file mode 100644 index 0000000..96160a5 --- /dev/null +++ b/migrations/068_bank_sources.sql @@ -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; diff --git a/migrations/069_bank_providers_fints_url.sql b/migrations/069_bank_providers_fints_url.sql new file mode 100644 index 0000000..1c7b66a --- /dev/null +++ b/migrations/069_bank_providers_fints_url.sql @@ -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 = ''); diff --git a/migrations/070_bank_account_filter.sql b/migrations/070_bank_account_filter.sql new file mode 100644 index 0000000..6e31b5f --- /dev/null +++ b/migrations/070_bank_account_filter.sql @@ -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::". + +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; diff --git a/public/index.php b/public/index.php index 2449aca..810e2a7 100644 --- a/public/index.php +++ b/public/index.php @@ -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)); diff --git a/scripts/encrypt_bank_secrets.php b/scripts/encrypt_bank_secrets.php new file mode 100644 index 0000000..a4b9ca3 --- /dev/null +++ b/scripts/encrypt_bank_secrets.php @@ -0,0 +1,64 @@ + "$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"; diff --git a/src/Config.php b/src/Config.php index d6c5c76..56fa1dc 100644 --- a/src/Config.php +++ b/src/Config.php @@ -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' => '', ]; diff --git a/src/Controllers/BankController.php b/src/Controllers/BankController.php index 464f7c8..ce4b85c 100644 --- a/src/Controllers/BankController.php +++ b/src/Controllers/BankController.php @@ -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' => 'Upload', + 'topbarActions' => 'Importieren', ]); } 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' => 'Formate bearbeiten', + 'topbarActions' => 'Formate bearbeiten', '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' => '', + '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]); + } + } diff --git a/src/Controllers/SettingsController.php b/src/Controllers/SettingsController.php index a5395b1..2eaf2da 100644 --- a/src/Controllers/SettingsController.php +++ b/src/Controllers/SettingsController.php @@ -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]); } diff --git a/src/Models/BankConnection.php b/src/Models/BankConnection.php new file mode 100644 index 0000000..fbc3e8d --- /dev/null +++ b/src/Models/BankConnection.php @@ -0,0 +1,74 @@ + "`$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; + } +} diff --git a/src/Models/BankTransaction.php b/src/Models/BankTransaction.php index 738ac86..26feca9 100644 --- a/src/Models/BankTransaction.php +++ b/src/Models/BankTransaction.php @@ -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(); } diff --git a/src/Services/Crypto.php b/src/Services/Crypto.php new file mode 100644 index 0000000..c7d935d --- /dev/null +++ b/src/Services/Crypto.php @@ -0,0 +1,91 @@ +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::". 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|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'], + ] + ); + } +} diff --git a/src/Services/PayPalNvpFetchService.php b/src/Services/PayPalNvpFetchService.php new file mode 100644 index 0000000..ddcd140 --- /dev/null +++ b/src/Services/PayPalNvpFetchService.php @@ -0,0 +1,311 @@ +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> */ + 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'], + ] + ); + } +} diff --git a/src/Views/bank/fints.php b/src/Views/bank/fints.php new file mode 100644 index 0000000..a47ef26 --- /dev/null +++ b/src/Views/bank/fints.php @@ -0,0 +1,683 @@ + 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)); +?> + + +
+
+ Noch keine Direktverbindungen konfiguriert. Klicke auf NEU, um eine hinzuzufügen. +
+
+ +
+ +
+
+
+
+
+ + + + +
+
+
+ + + +
+ Abruf: ' ?> +
+
+ +
+
+
+ +
+ + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/bank/upload.php b/src/Views/bank/upload.php index a4ca827..22a51e7 100644 --- a/src/Views/bank/upload.php +++ b/src/Views/bank/upload.php @@ -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'); }); })(); diff --git a/src/Views/layout/nav.php b/src/Views/layout/nav.php index b772a59..172c5eb 100644 --- a/src/Views/layout/nav.php +++ b/src/Views/layout/nav.php @@ -95,7 +95,7 @@ function navActive(string $prefix, string $current): string { - Bankimport + CSV Bankimport Formate diff --git a/src/version.txt b/src/version.txt index 2947c29..4883423 100644 --- a/src/version.txt +++ b/src/version.txt @@ -1 +1 @@ -1-260521 +1-260522 diff --git a/tests/36_CryptoTest.php b/tests/36_CryptoTest.php new file mode 100644 index 0000000..270cd41 --- /dev/null +++ b/tests/36_CryptoTest.php @@ -0,0 +1,75 @@ +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(); + } + } +} diff --git a/tests/37_BankSourceTest.php b/tests/37_BankSourceTest.php new file mode 100644 index 0000000..ad0b128 --- /dev/null +++ b/tests/37_BankSourceTest.php @@ -0,0 +1,129 @@ + '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(); + } + } +}