Merge pull request 'Feature: Kontenrahmen + Online Bankenabruf FinTS + Paypal API' (#79) from fds2610-bot/Steuerkram:main into main
Reviewed-on: https://codeberg.org/fds2610/Steuerkram/pulls/79
This commit is contained in:
commit
a800b2553d
33 changed files with 2892 additions and 83 deletions
|
|
@ -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.
|
||||
#
|
||||
|
|
|
|||
14
Changes.md
14
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)
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@
|
|||
"require": {
|
||||
"php": ">=8.3",
|
||||
"mpdf/mpdf": "^8.2",
|
||||
"horstoeko/zugferd": "^1.0"
|
||||
"horstoeko/zugferd": "^1.0",
|
||||
"nemiah/php-fints": "^4.1"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
|
|
|
|||
130
composer.lock
generated
130
composer.lock
generated
|
|
@ -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": [],
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
166
migrations/062_kontenrahmen_sync.sql
Normal file
166
migrations/062_kontenrahmen_sync.sql
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
-- Kontenrahmen-Sync vom 2026-05-21 (Prod-Stand)
|
||||
-- Verwendet ON DUPLICATE KEY UPDATE – bestehende Buchungen bleiben erhalten.
|
||||
|
||||
INSERT INTO `accounts` (`id`,`kontonummer`,`bezeichnung`,`kontenart`,`beschreibung`,`steuercode`,`ustva_kz`,`euer_zeile`,`is_system`,`aktiv`,`created_at`,`updated_at`) VALUES
|
||||
(1,'0027','EDV-Software','Anlagevermögen',NULL,NULL,NULL,NULL,1,1,'2026-04-11 06:39:07','2026-04-11 15:18:46'),
|
||||
(2,'0030','Lizenzen an gewerblichen Schutzrechten','Anlagevermögen',NULL,NULL,NULL,NULL,1,1,'2026-04-11 06:39:07','2026-04-11 15:18:46'),
|
||||
(3,'0210','Maschinen','Anlagevermögen',NULL,NULL,NULL,NULL,1,1,'2026-04-11 06:39:07','2026-04-11 15:18:46'),
|
||||
(4,'0320','Pkw','Anlagevermögen',NULL,NULL,NULL,NULL,1,1,'2026-04-11 06:39:07','2026-04-11 15:18:46'),
|
||||
(5,'0350','Lkw','Anlagevermögen',NULL,NULL,NULL,NULL,1,1,'2026-04-11 06:39:07','2026-04-11 15:18:46'),
|
||||
(6,'0400','Betriebsausstattung','Anlagevermögen',NULL,NULL,NULL,NULL,1,1,'2026-04-11 06:39:07','2026-04-11 15:18:46'),
|
||||
(7,'0410','Geschäftsausstattung','Anlagevermögen',NULL,NULL,NULL,NULL,1,1,'2026-04-11 06:39:07','2026-04-11 15:18:46'),
|
||||
(8,'0420','Büroeinrichtung','Anlagevermögen',NULL,NULL,NULL,NULL,1,1,'2026-04-11 06:39:07','2026-04-11 15:18:46'),
|
||||
(9,'0440','Werkzeuge','Anlagevermögen',NULL,NULL,NULL,NULL,1,1,'2026-04-11 06:39:07','2026-04-11 15:18:46'),
|
||||
(10,'0480','Geringwertige Wirtschaftsgüter','Anlagevermögen',NULL,NULL,NULL,NULL,1,1,'2026-04-11 06:39:07','2026-04-11 15:18:46'),
|
||||
(11,'1000','Kasse','Zahlungskonto',NULL,NULL,NULL,NULL,1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
|
||||
(12,'1010','Nebenkasse 1','Zahlungskonto',NULL,NULL,NULL,NULL,1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
|
||||
(13,'1200','Bank 1 - Comdirekt','Zahlungskonto','','',NULL,NULL,1,1,'2026-04-11 06:39:07','2026-05-14 16:28:49'),
|
||||
(14,'1210','Bank 2 - Sonstige Banken und Kasse','Zahlungskonto','','',NULL,NULL,1,1,'2026-04-11 06:39:07','2026-05-14 16:28:49'),
|
||||
(15,'1220','Bank 3','Zahlungskonto',NULL,NULL,NULL,NULL,1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
|
||||
(16,'1230','Bank 4','Zahlungskonto',NULL,NULL,NULL,NULL,1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
|
||||
(17,'1240','Bank 5','Zahlungskonto',NULL,NULL,NULL,NULL,1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
|
||||
(18,'1390','Verrechnungskonto Ist-Versteuerung','Verbindlichkeit','','',NULL,NULL,1,1,'2026-04-11 06:39:07','2026-05-14 16:28:49'),
|
||||
(19,'1770','Umsatzsteuer','Verbindlichkeit','','',NULL,NULL,1,1,'2026-04-11 06:39:07','2026-05-14 16:28:49'),
|
||||
(20,'1771','Umsatzsteuer 7 %','Verbindlichkeit','','','83',NULL,1,1,'2026-04-11 06:39:07','2026-05-14 16:28:49'),
|
||||
(21,'1776','Umsatzsteuer 19 %','Verbindlichkeit','','','83',NULL,1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
|
||||
(22,'1780','Umsatzsteuer-Vorauszahlungen lfd. Jahr','Verbindlichkeit','','',NULL,NULL,1,1,'2026-04-11 06:39:07','2026-05-14 16:28:49'),
|
||||
(23,'1781','Umsatzsteuer-Vorauszahlungen 1/11','Verbindlichkeit','','',NULL,NULL,1,1,'2026-04-11 06:39:07','2026-05-14 16:28:49'),
|
||||
(24,'1800','Privatentnahmen','Privat',NULL,NULL,NULL,NULL,1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
|
||||
(25,'1810','Privatsteuern','Privat',NULL,NULL,NULL,NULL,1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
|
||||
(26,'1820','Sonderausgaben beschränkt abzugsfähig','Privat',NULL,NULL,NULL,NULL,1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
|
||||
(27,'1830','Sonderausgaben unbeschränkt abzugsfähig','Privat',NULL,NULL,NULL,NULL,1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
|
||||
(28,'1840','Zuwendungen, Spenden','Privat',NULL,NULL,NULL,NULL,1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
|
||||
(29,'1890','Privateinlagen','Privat',NULL,NULL,NULL,NULL,1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
|
||||
(30,'3106','Fremdleistungen 19 % Vorsteuer','Betriebsausgabe',NULL,'VSt19',NULL,'29',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
|
||||
(31,'3108','Fremdleistungen 7 % Vorsteuer','Betriebsausgabe',NULL,'VSt7',NULL,'29',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
|
||||
(32,'3109','Fremdleistungen ohne Vorsteuer','Betriebsausgabe',NULL,NULL,NULL,'29',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
|
||||
(33,'3300','Wareneingang 7 % Vorsteuer','Betriebsausgabe',NULL,'VSt7',NULL,'28',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
|
||||
(34,'3349','Wareneingang ohne Vorsteuerabzug','Betriebsausgabe',NULL,NULL,NULL,'26',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
|
||||
(35,'3400','Wareneingang 19 % Vorsteuer','Betriebsausgabe',NULL,'VSt19',NULL,'27',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
|
||||
(36,'4100','Löhne und Gehälter','Betriebsausgabe',NULL,NULL,NULL,'30',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
|
||||
(37,'4110','Löhne','Betriebsausgabe',NULL,NULL,NULL,'30',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
|
||||
(38,'4120','Gehälter','Betriebsausgabe',NULL,NULL,NULL,'30',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
|
||||
(39,'4130','Gesetzliche soziale Aufwendungen','Betriebsausgabe',NULL,NULL,NULL,'31',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
|
||||
(40,'4138','Beiträge zur Berufsgenossenschaft','Betriebsausgabe',NULL,NULL,NULL,'31',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
|
||||
(41,'4140','Freiwillige soziale Aufwendungen, lohnsteuerfrei','Betriebsausgabe',NULL,NULL,NULL,'32',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
|
||||
(42,'4145','Freiwillige soziale Aufwendungen, lohnsteuerpflichtig','Betriebsausgabe',NULL,NULL,NULL,'32',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
|
||||
(43,'4180','Aushilfslöhne','Betriebsausgabe',NULL,NULL,NULL,'30',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
|
||||
(44,'4190','Aushilfslöhne Minijobs','Betriebsausgabe',NULL,NULL,NULL,'30',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
|
||||
(45,'4210','Miete (unbewegliche Wirtschaftsgüter)','Betriebsausgabe',NULL,NULL,NULL,'33',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
|
||||
(46,'4211','Miete/Pacht unbewegliche WG (gewerbesteuerl. hinzuzur.)','Betriebsausgabe',NULL,NULL,NULL,'33',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
|
||||
(47,'4220','Pacht (unbewegliche Wirtschaftsgüter)','Betriebsausgabe',NULL,NULL,NULL,'33',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
|
||||
(48,'4230','Heizung','Betriebsausgabe',NULL,'VSt19',NULL,'34',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
|
||||
(49,'4240','Gas, Strom, Wasser','Betriebsausgabe',NULL,'VSt19',NULL,'34',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
|
||||
(50,'4250','Reinigung','Betriebsausgabe',NULL,'VSt19',NULL,'37',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
|
||||
(51,'4260','Instandhaltung betrieblicher Räume','Betriebsausgabe',NULL,'VSt19',NULL,'37',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
|
||||
(52,'4280','Sonstige Raumkosten','Betriebsausgabe',NULL,'VSt19',NULL,'33',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
|
||||
(53,'4287','Tagespauschale für Tätigkeit in der häuslichen Wohnung','Betriebsausgabe',NULL,NULL,NULL,'66',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
|
||||
(54,'4288','Häusliches Arbeitszimmer (abziehbarer Anteil)','Betriebsausgabe',NULL,NULL,NULL,'65',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
|
||||
(55,'4289','Häusliches Arbeitszimmer (nicht abziehbarer Anteil)','Betriebsausgabe',NULL,NULL,NULL,'51',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
|
||||
(56,'4300','Nicht abziehbare Vorsteuer','Betriebsausgabe',NULL,NULL,NULL,'52',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
|
||||
(57,'4301','Nicht abziehbare Vorsteuer 7 %','Betriebsausgabe',NULL,NULL,NULL,'52',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
|
||||
(58,'4306','Nicht abziehbare Vorsteuer 19 %','Betriebsausgabe',NULL,NULL,NULL,'52',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
|
||||
(59,'4320','Gewerbesteuer','Betriebsausgabe',NULL,NULL,NULL,'51',1,1,'2026-04-11 06:39:07','2026-04-12 20:33:08'),
|
||||
(60,'4340','Sonstige Betriebssteuern','Betriebsausgabe',NULL,NULL,NULL,'52',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
|
||||
(61,'4350','Verbrauchsteuer (sonstige Steuern)','Betriebsausgabe',NULL,NULL,NULL,'52',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
|
||||
(62,'4360','Versicherungen','Betriebsausgabe',NULL,NULL,NULL,'52',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
|
||||
(63,'4366','Versicherungen für Gebäude','Betriebsausgabe',NULL,NULL,NULL,'52',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
|
||||
(64,'4380','Beiträge','Betriebsausgabe',NULL,NULL,NULL,'52',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
|
||||
(65,'4390','Sonstige Abgaben','Betriebsausgabe',NULL,NULL,NULL,'52',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
|
||||
(66,'4500','Fahrzeugkosten (allgemein)','Betriebsausgabe',NULL,NULL,NULL,'40',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
|
||||
(67,'4510','Kfz-Steuer','Betriebsausgabe',NULL,NULL,NULL,'40',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
|
||||
(68,'4520','Fahrzeug-Versicherungen','Betriebsausgabe',NULL,NULL,NULL,'40',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
|
||||
(69,'4530','Laufende Fahrzeug-Betriebskosten','Betriebsausgabe',NULL,'VSt19',NULL,'40',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
|
||||
(70,'4540','Fahrzeug-Reparaturen','Betriebsausgabe',NULL,'VSt19',NULL,'40',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
|
||||
(71,'4550','Garagenmiete','Betriebsausgabe',NULL,'VSt19',NULL,'40',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
|
||||
(72,'4560','Mautgebühren','Betriebsausgabe',NULL,NULL,NULL,'40',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
|
||||
(73,'4570','Mietleasing Kfz','Betriebsausgabe',NULL,'VSt19',NULL,'40',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
|
||||
(74,'4575','Mietleasing Elektrofahrzeuge/Fahrräder (gewerbesteuerl. hinzuzurechnen)','Betriebsausgabe',NULL,'VSt19',NULL,'40',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
|
||||
(75,'4580','Sonstige Fahrzeugkosten','Betriebsausgabe',NULL,'VSt19',NULL,'40',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
|
||||
(76,'4590','Fremdfahrzeugkosten','Betriebsausgabe',NULL,'VSt19',NULL,'40',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
|
||||
(77,'4600','Werbekosten','Betriebsausgabe',NULL,'VSt19',NULL,'41',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
|
||||
(78,'4605','Streuartikel','Betriebsausgabe',NULL,'VSt19',NULL,'41',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
|
||||
(79,'4630','Geschenke abzugsfähig ohne § 37b EStG','Betriebsausgabe',NULL,'VSt19',NULL,'41',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
|
||||
(80,'4631','Geschenke abzugsfähig mit § 37b EStG (§ 37b-Steuer)','Betriebsausgabe',NULL,'VSt19',NULL,'41',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
|
||||
(81,'4635','Geschenke nicht abzugsfähig ohne § 37b EStG','Betriebsausgabe',NULL,'VSt19',NULL,'51',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
|
||||
(82,'4636','Geschenke nicht abzugsfähig mit § 37b EStG','Betriebsausgabe',NULL,'VSt19',NULL,'51',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
|
||||
(83,'4650','Bewirtungskosten (70 % abzugsfähig) VSt. 19%','Betriebsausgabe',NULL,'VSt19',NULL,'44',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
|
||||
(84,'4651','Bewirtungskosten (70 % abzugsfähig) VSt. 7%','Betriebsausgabe',NULL,'VSt7',NULL,'44',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
|
||||
(85,'4654','Nicht abzugsfähige Bewirtungskosten VSt. 19%','Betriebsausgabe',NULL,'VSt19',NULL,'51',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
|
||||
(86,'4660','Reisekosten Arbeitnehmer','Betriebsausgabe',NULL,NULL,NULL,'42',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
|
||||
(87,'4663','Reisekosten Arbeitnehmer Fahrtkosten','Betriebsausgabe',NULL,NULL,NULL,'42',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
|
||||
(88,'4664','Reisekosten Arbeitnehmer Verpflegungsmehraufwand','Betriebsausgabe',NULL,NULL,NULL,'42',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
|
||||
(89,'4666','Reisekosten Arbeitnehmer Übernachtungsaufwand','Betriebsausgabe',NULL,'VSt19',NULL,'42',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
|
||||
(90,'4668','Kilometergelderstattung Arbeitnehmer','Betriebsausgabe',NULL,NULL,NULL,'42',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
|
||||
(91,'4670','Reisekosten Unternehmer','Betriebsausgabe',NULL,NULL,NULL,'43',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
|
||||
(92,'4672','Reisekosten Unternehmer (nicht abziehbarer Anteil)','Betriebsausgabe',NULL,NULL,NULL,'51',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
|
||||
(93,'4673','Reisekosten Unternehmer Fahrtkosten','Betriebsausgabe',NULL,NULL,NULL,'43',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
|
||||
(94,'4674','Reisekosten Unternehmer Verpflegungsmehraufwand','Betriebsausgabe',NULL,NULL,NULL,'43',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
|
||||
(95,'4676','Reisekosten Unternehmer Übernachtungsaufwand','Betriebsausgabe','','',NULL,'42',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
|
||||
(96,'4780','Fremdarbeiten','Betriebsausgabe',NULL,'VSt19',NULL,'29',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
|
||||
(97,'4800','Reparaturen und Instandhaltungen (Maschinen, techn. Anlagen)','Betriebsausgabe',NULL,'VSt19',NULL,'38',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
|
||||
(98,'4801','Reparaturen und Instandhaltung von Bauten','Betriebsausgabe',NULL,'VSt19',NULL,'37',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
|
||||
(99,'4805','Reparaturen und Instandhaltungen (andere Anlagen, BGA)','Betriebsausgabe',NULL,'VSt19',NULL,'38',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
|
||||
(100,'4806','Wartungskosten Hard- und Software','Betriebsausgabe',NULL,'VSt19',NULL,'38',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
|
||||
(101,'4809','Sonstige Reparaturen und Instandhaltungen','Betriebsausgabe',NULL,'VSt19',NULL,'38',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
|
||||
(102,'4822','Abschreibungen auf immaterielle Vermögensgegenstände','Betriebsausgabe',NULL,NULL,NULL,'49',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
|
||||
(103,'4830','Abschreibungen auf Sachanlagen (ohne AfA Fzg./Gebäude)','Betriebsausgabe',NULL,NULL,NULL,'49',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
|
||||
(104,'4831','Abschreibungen auf Gebäude','Betriebsausgabe',NULL,NULL,NULL,'49',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
|
||||
(105,'4832','Abschreibungen auf Fahrzeuge','Betriebsausgabe',NULL,NULL,NULL,'49',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
|
||||
(106,'4855','Sofortabschreibung geringwertiger Wirtschaftsgüter','Betriebsausgabe',NULL,NULL,NULL,'50',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
|
||||
(107,'4856','Sonderabschreibungen nach § 7b EStG (Mietwohnungsneubau)','Betriebsausgabe',NULL,NULL,NULL,'37',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
|
||||
(108,'4900','Sonstige betriebliche Aufwendungen','Betriebsausgabe',NULL,'VSt19',NULL,'52',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
|
||||
(109,'4909','Fremdleistungen / Fremdarbeiten','Betriebsausgabe',NULL,'VSt19',NULL,'29',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
|
||||
(110,'4910','Porto','Betriebsausgabe',NULL,NULL,NULL,'47',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
|
||||
(111,'4920','Telefon','Betriebsausgabe',NULL,'VSt19',NULL,'47',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
|
||||
(112,'4925','Internetkosten','Betriebsausgabe',NULL,'VSt19',NULL,'47',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
|
||||
(113,'4930','Bürobedarf','Betriebsausgabe',NULL,'VSt19',NULL,'47',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
|
||||
(114,'4940','Zeitschriften, Bücher, digitale Medien (Fachliteratur)','Betriebsausgabe',NULL,'VSt7',NULL,'47',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
|
||||
(115,'4945','Fortbildungskosten','Betriebsausgabe',NULL,'VSt19',NULL,'52',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
|
||||
(116,'4950','Rechts- und Beratungskosten','Betriebsausgabe',NULL,'VSt19',NULL,'52',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
|
||||
(117,'4955','Buchführungskosten','Betriebsausgabe',NULL,'VSt19',NULL,'46',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
|
||||
(118,'4957','Abschluss- und Prüfungskosten','Betriebsausgabe',NULL,'VSt19',NULL,'46',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
|
||||
(119,'4960','Mieten für Einrichtungen (bewegliche Wirtschaftsgüter)','Betriebsausgabe',NULL,'VSt19',NULL,'48',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
|
||||
(120,'4964','Zeitlich befristete Überlassung von Rechten (Lizenzen)','Betriebsausgabe',NULL,'VSt19',NULL,'48',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
|
||||
(121,'4965','Mietleasing (bewegliche WG / Geschäftsausstattung)','Betriebsausgabe',NULL,'VSt19',NULL,'48',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
|
||||
(122,'4969','Abraum- und Abfallbeseitigung','Betriebsausgabe',NULL,'VSt19',NULL,'52',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
|
||||
(123,'4970','Nebenkosten des Geldverkehrs (Bankgebühren, Zinsen)','Betriebsausgabe',NULL,NULL,NULL,'52',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
|
||||
(124,'4971','Verwahrentgelt','Betriebsausgabe',NULL,NULL,NULL,'52',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
|
||||
(125,'4980','Sonstiger Betriebsbedarf','Betriebsausgabe',NULL,'VSt19',NULL,'52',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
|
||||
(126,'4985','Werkzeuge und Kleingeräte','Betriebsausgabe',NULL,'VSt19',NULL,'52',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
|
||||
(127,'8100','Steuerfreie Umsätze § 4 Nr. 8 ff. UStG','Betriebseinnahme',NULL,NULL,NULL,'17',1,1,'2026-04-11 06:39:07','2026-05-14 16:28:49'),
|
||||
(128,'8105','Steuerfreie Umsätze § 4 Nr. 12 UStG (Vermietung/Verpachtung)','Betriebseinnahme',NULL,NULL,NULL,'17',1,1,'2026-04-11 06:39:07','2026-05-14 16:28:49'),
|
||||
(129,'8110','Sonstige steuerfreie Umsätze Inland','Betriebseinnahme',NULL,NULL,NULL,'17',1,1,'2026-04-11 06:39:07','2026-05-14 16:28:49'),
|
||||
(130,'8120','Steuerfreie Umsätze § 4 Nr. 1a UStG','Betriebseinnahme',NULL,NULL,NULL,'18',1,1,'2026-04-11 06:39:07','2026-05-14 16:28:49'),
|
||||
(131,'8125','Steuerfreie innergemeinschaftliche Lieferungen § 4 Nr. 1b UStG','Betriebseinnahme',NULL,NULL,NULL,'18',1,1,'2026-04-11 06:39:07','2026-05-14 16:28:49'),
|
||||
(132,'8300','Erlöse 7 % USt','Betriebseinnahme',NULL,'USt7',NULL,'15',1,1,'2026-04-11 06:39:07','2026-05-14 16:28:49'),
|
||||
(133,'8400','Erlöse 19 % USt','Betriebseinnahme',NULL,'USt19','81','16',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
|
||||
(134,'8710','Erlösschmälerungen 7 % USt','Betriebseinnahme',NULL,'USt7',NULL,'15',1,1,'2026-04-11 06:39:07','2026-05-14 16:28:49'),
|
||||
(135,'8720','Erlösschmälerungen 19 % USt','Betriebseinnahme',NULL,'USt19','81','16',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
|
||||
(136,'8570','Sonstige Erträge aus Provisionen, Lizenzen und Patenten','Betriebseinnahme',NULL,'USt19','81','16',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
|
||||
(137,'8800','Erlöse aus Verkäufen Sachanlagevermögen (bei Buchverlust)','Betriebseinnahme',NULL,NULL,NULL,'22',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
|
||||
(138,'8801','Erlöse aus Verkäufen Sachanlagevermögen 19 % USt (bei Buchverlust)','Betriebseinnahme',NULL,'USt19','81','22',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
|
||||
(139,'8820','Erlöse aus Verkäufen Sachanlagevermögen 19 % USt (bei Buchgewinn)','Betriebseinnahme',NULL,'USt19','81','22',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
|
||||
(140,'8920','Verwendung von Gegenständen für Zwecke außerhalb des Unternehmens','Betriebseinnahme',NULL,NULL,NULL,'22',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
|
||||
(141,'8921','Verwendung von Gegenständen für Zwecke außerhalb des Unternehmens 7 % USt','Betriebseinnahme',NULL,'USt7',NULL,'22',1,1,'2026-04-11 06:39:07','2026-05-14 16:28:49'),
|
||||
(142,'8922','Verwendung von Gegenständen für Zwecke außerhalb des Unternehmens 19 % USt','Betriebseinnahme',NULL,'USt19','81','22',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
|
||||
(143,'8900','Sonstige betriebliche Erträge','Betriebseinnahme',NULL,NULL,NULL,'23',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
|
||||
(144,'8910','Sonstige betriebliche Erträge (steuerfrei)','Betriebseinnahme',NULL,NULL,NULL,'23',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
|
||||
(145,'8950','Nicht steuerbare Umsätze (Innenenumsätze)','Betriebseinnahme',NULL,NULL,NULL,'21',1,1,'2026-04-11 06:39:07','2026-04-11 06:39:07'),
|
||||
(146,'4656','nicht abzugsfähige Bewirtungskosten VSt. 7%','Betriebsausgabe',NULL,'VSt7',NULL,'51',1,1,'2026-04-11 06:39:07','2026-05-10 14:45:37'),
|
||||
(148,'4652','Bewirtungskosten (70 % abzugsfähig) ohne Vorsteuer','Betriebsausgabe','Bewirtungskosten ohne Vorsteuer (Trinkgeld)','',NULL,'44',0,1,'2026-04-11 07:38:26','2026-05-14 16:28:49'),
|
||||
(149,'4657','Nicht abzugsfähige Bewirtungskosten ohne Vorsteuer','Betriebsausgabe','nicht abzugsfähige Bewirtungskosten ohne Vorsteuer (Eigenanteil Trinkgeld)','',NULL,'51',0,1,'2026-04-11 07:39:29','2026-05-14 16:28:49'),
|
||||
(152,'1574','Abziehbare Vorsteuer aus innergemeinschaftlichem Erwerb 19 %','Zahlungskonto','','VSt19','61',NULL,1,1,'2026-05-10 14:45:37','2026-05-14 16:28:49'),
|
||||
(153,'1774','Umsatzsteuer aus innergemeinschaftlichem Erwerb 19 %','Verbindlichkeit',NULL,'USt19','83',NULL,1,1,'2026-05-10 14:45:37','2026-05-14 16:28:49'),
|
||||
(154,'1571','Abziehbare Vorsteuer 7 %','Zahlungskonto','','VSt7','66',NULL,1,1,'2026-05-10 14:45:37','2026-05-14 16:28:49'),
|
||||
(155,'1576','Abziehbare Vorsteuer 19 % (Eingangsrechnungen)','Zahlungskonto','','VSt19','66',NULL,1,1,'2026-05-10 14:45:37','2026-05-14 16:28:49')
|
||||
ON DUPLICATE KEY UPDATE
|
||||
`kontonummer` = VALUES(`kontonummer`),
|
||||
`bezeichnung` = VALUES(`bezeichnung`),
|
||||
`kontenart` = VALUES(`kontenart`),
|
||||
`beschreibung` = VALUES(`beschreibung`),
|
||||
`steuercode` = VALUES(`steuercode`),
|
||||
`ustva_kz` = VALUES(`ustva_kz`),
|
||||
`euer_zeile` = VALUES(`euer_zeile`),
|
||||
`is_system` = VALUES(`is_system`),
|
||||
`aktiv` = VALUES(`aktiv`);
|
||||
20
migrations/063_bank_direktverbindungen.sql
Normal file
20
migrations/063_bank_direktverbindungen.sql
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
-- Unterstützte Banken/Provider (Stammdaten). Initial leer –
|
||||
-- Anbieter werden über den "Neue Bank"-Dialog vom Nutzer selbst angelegt.
|
||||
CREATE TABLE IF NOT EXISTS bank_providers (
|
||||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
verbindungstyp ENUM('API','FinTS') NOT NULL DEFAULT 'FinTS',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Konfigurierte Bankverbindungen des Nutzers
|
||||
CREATE TABLE IF NOT EXISTS bank_connections (
|
||||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
provider_id INT UNSIGNED NOT NULL,
|
||||
bezeichnung VARCHAR(150) NOT NULL,
|
||||
app_name VARCHAR(150) NULL,
|
||||
client_id VARCHAR(255) NULL,
|
||||
secret VARCHAR(255) NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (provider_id) REFERENCES bank_providers(id) ON DELETE RESTRICT
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
2
migrations/064_bank_connections_last_fetched.sql
Normal file
2
migrations/064_bank_connections_last_fetched.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
ALTER TABLE bank_connections
|
||||
ADD COLUMN IF NOT EXISTS last_fetched_at TIMESTAMP NULL DEFAULT NULL;
|
||||
9
migrations/065_bank_api_fetch.sql
Normal file
9
migrations/065_bank_api_fetch.sql
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
-- format_id optional machen (API-Batches haben kein CSV-Format)
|
||||
ALTER TABLE bank_import_batches
|
||||
MODIFY COLUMN format_id INT UNSIGNED NULL,
|
||||
ADD COLUMN IF NOT EXISTS connection_id INT UNSIGNED NULL;
|
||||
|
||||
-- Externe Transaktions-ID für Duplikaterkennung bei API-Abrufen
|
||||
ALTER TABLE bank_transactions
|
||||
ADD COLUMN IF NOT EXISTS external_id VARCHAR(100) NULL,
|
||||
ADD INDEX IF NOT EXISTS idx_bt_external_id (external_id);
|
||||
2
migrations/066_bank_connection_sandbox.sql
Normal file
2
migrations/066_bank_connection_sandbox.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
ALTER TABLE bank_connections
|
||||
ADD COLUMN IF NOT EXISTS sandbox TINYINT(1) NOT NULL DEFAULT 0;
|
||||
3
migrations/067_bank_connection_badge.sql
Normal file
3
migrations/067_bank_connection_badge.sql
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
ALTER TABLE bank_connections
|
||||
ADD COLUMN IF NOT EXISTS kuerzel VARCHAR(10) NULL,
|
||||
ADD COLUMN IF NOT EXISTS farbe VARCHAR(7) NULL DEFAULT '#6366f1';
|
||||
115
migrations/068_bank_sources.sql
Normal file
115
migrations/068_bank_sources.sql
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
-- Einheitliche Tabelle für alle Bank-Importquellen (CSV-Formate + Direktverbindungen).
|
||||
-- bank_import_formats und bank_connections bleiben vorerst bestehen, werden aber
|
||||
-- nicht mehr vom Code gelesen. Drop in späterer Migration.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS bank_sources (
|
||||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
type ENUM('csv','api','fints') NOT NULL,
|
||||
provider_id INT UNSIGNED NULL,
|
||||
bezeichnung VARCHAR(150) NOT NULL,
|
||||
kuerzel VARCHAR(10) NULL,
|
||||
farbe VARCHAR(7) NULL DEFAULT '#6366f1',
|
||||
|
||||
-- CSV-Konfiguration (NULL bei api/fints)
|
||||
skip_rows INT NULL,
|
||||
has_header_row TINYINT NULL,
|
||||
`delimiter` CHAR(1) NULL,
|
||||
enclosure CHAR(1) NULL,
|
||||
col_buchungstag INT NULL,
|
||||
col_wertstellung INT NULL,
|
||||
col_vorgang INT NULL,
|
||||
col_buchungstext INT NULL,
|
||||
col_wert INT NULL,
|
||||
col_zahlungspflichtiger INT NULL,
|
||||
col_zahlungsempfaenger INT NULL,
|
||||
col_verwendungszweck INT NULL,
|
||||
date_format VARCHAR(20) NULL,
|
||||
decimal_separator CHAR(1) NULL,
|
||||
thousands_separator CHAR(1) NULL,
|
||||
buchungstext_template_debit VARCHAR(500) NULL,
|
||||
buchungstext_template_credit VARCHAR(500) NULL,
|
||||
|
||||
-- API/FinTS gemeinsam
|
||||
sandbox TINYINT(1) NOT NULL DEFAULT 0,
|
||||
last_fetched_at TIMESTAMP NULL DEFAULT NULL,
|
||||
|
||||
-- API-spezifisch (PayPal-NVP)
|
||||
app_name VARCHAR(150) NULL,
|
||||
client_id_enc TEXT NULL,
|
||||
secret_enc TEXT NULL,
|
||||
|
||||
-- FinTS-spezifisch
|
||||
blz VARCHAR(11) NULL,
|
||||
fints_url VARCHAR(255) NULL,
|
||||
benutzerkennung VARCHAR(100) NULL,
|
||||
pin_enc TEXT NULL,
|
||||
iban_filter VARCHAR(34) NULL,
|
||||
|
||||
-- Backfill-Tracking (wird am Ende dieser Migration entfernt)
|
||||
legacy_format_id INT UNSIGNED NULL,
|
||||
legacy_connection_id INT UNSIGNED NULL,
|
||||
|
||||
created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_bs_type (type),
|
||||
INDEX idx_bs_legacy_format (legacy_format_id),
|
||||
INDEX idx_bs_legacy_connection (legacy_connection_id),
|
||||
FOREIGN KEY (provider_id) REFERENCES bank_providers(id) ON DELETE RESTRICT
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Backfill: CSV-Formate übernehmen
|
||||
INSERT INTO bank_sources (
|
||||
type, bezeichnung, kuerzel, farbe,
|
||||
skip_rows, has_header_row, `delimiter`, enclosure,
|
||||
col_buchungstag, col_wertstellung, col_vorgang, col_buchungstext, col_wert,
|
||||
col_zahlungspflichtiger, col_zahlungsempfaenger, col_verwendungszweck,
|
||||
date_format, decimal_separator, thousands_separator,
|
||||
buchungstext_template_debit, buchungstext_template_credit,
|
||||
legacy_format_id
|
||||
)
|
||||
SELECT 'csv', name, kuerzel, farbe,
|
||||
skip_rows, has_header_row, `delimiter`, enclosure,
|
||||
col_buchungstag, col_wertstellung, col_vorgang, col_buchungstext, col_wert,
|
||||
col_zahlungspflichtiger, col_zahlungsempfaenger, col_verwendungszweck,
|
||||
date_format, decimal_separator, thousands_separator,
|
||||
buchungstext_template_debit, buchungstext_template_credit,
|
||||
id
|
||||
FROM bank_import_formats;
|
||||
|
||||
-- Backfill: Direktverbindungen übernehmen (Klartext-Credentials in *_enc-Spalten;
|
||||
-- werden im Anschluss durch scripts/encrypt_bank_secrets.php verschlüsselt).
|
||||
INSERT INTO bank_sources (
|
||||
type, provider_id, bezeichnung, kuerzel, farbe, sandbox, last_fetched_at,
|
||||
app_name, client_id_enc, secret_enc,
|
||||
legacy_connection_id
|
||||
)
|
||||
SELECT
|
||||
CASE bp.verbindungstyp WHEN 'API' THEN 'api' ELSE 'fints' END,
|
||||
bc.provider_id, bc.bezeichnung, bc.kuerzel, bc.farbe, bc.sandbox, bc.last_fetched_at,
|
||||
bc.app_name, bc.client_id, bc.secret,
|
||||
bc.id
|
||||
FROM bank_connections bc
|
||||
JOIN bank_providers bp ON bp.id = bc.provider_id;
|
||||
|
||||
-- source_id-Spalte auf bank_import_batches
|
||||
ALTER TABLE bank_import_batches
|
||||
ADD COLUMN IF NOT EXISTS source_id INT UNSIGNED NULL,
|
||||
ADD INDEX IF NOT EXISTS idx_bib_source (source_id);
|
||||
|
||||
-- Backfill: source_id aus format_id (CSV-Batches)
|
||||
UPDATE bank_import_batches bib
|
||||
JOIN bank_sources bs ON bs.legacy_format_id = bib.format_id AND bs.type = 'csv'
|
||||
SET bib.source_id = bs.id
|
||||
WHERE bib.format_id IS NOT NULL AND bib.source_id IS NULL;
|
||||
|
||||
-- Backfill: source_id aus connection_id (API/FinTS-Batches)
|
||||
UPDATE bank_import_batches bib
|
||||
JOIN bank_sources bs ON bs.legacy_connection_id = bib.connection_id AND bs.type IN ('api','fints')
|
||||
SET bib.source_id = bs.id
|
||||
WHERE bib.connection_id IS NOT NULL AND bib.source_id IS NULL;
|
||||
|
||||
-- Backfill-Tracking-Spalten wieder entfernen
|
||||
ALTER TABLE bank_sources
|
||||
DROP INDEX idx_bs_legacy_format,
|
||||
DROP INDEX idx_bs_legacy_connection,
|
||||
DROP COLUMN legacy_format_id,
|
||||
DROP COLUMN legacy_connection_id;
|
||||
11
migrations/069_bank_providers_fints_url.sql
Normal file
11
migrations/069_bank_providers_fints_url.sql
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
-- Default-FinTS-Server-URL pro Provider. Wird verwendet, wenn in der Verbindung
|
||||
-- selbst keine fints_url angegeben ist. Vom Nutzer in der Verbindung überschreibbar.
|
||||
|
||||
ALTER TABLE bank_providers
|
||||
ADD COLUMN IF NOT EXISTS default_fints_url VARCHAR(255) NULL;
|
||||
|
||||
UPDATE bank_providers SET default_fints_url = 'https://fints.comdirect.de/fints'
|
||||
WHERE name = 'Comdirect' AND (default_fints_url IS NULL OR default_fints_url = '');
|
||||
|
||||
UPDATE bank_providers SET default_fints_url = 'https://banking-dkb.s-fints-pt-dkb.de/fints30'
|
||||
WHERE name = 'DKB Bank' AND (default_fints_url IS NULL OR default_fints_url = '');
|
||||
15
migrations/070_bank_account_filter.sql
Normal file
15
migrations/070_bank_account_filter.sql
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
-- iban_filter (VARCHAR 34) → account_filter (TEXT) als JSON-Array von Konto-Identifiern.
|
||||
-- Identifier-Schema: IBAN (z.B. "DE53...") oder bei fehlender IBAN "AN:<accountNumber>:<subAccount>".
|
||||
|
||||
ALTER TABLE bank_sources
|
||||
ADD COLUMN IF NOT EXISTS account_filter TEXT NULL;
|
||||
|
||||
-- Bestehende Single-IBAN-Werte als 1-Element-JSON-Array übernehmen
|
||||
UPDATE bank_sources
|
||||
SET account_filter = JSON_ARRAY(iban_filter)
|
||||
WHERE iban_filter IS NOT NULL
|
||||
AND iban_filter <> ''
|
||||
AND account_filter IS NULL;
|
||||
|
||||
ALTER TABLE bank_sources
|
||||
DROP COLUMN iban_filter;
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
64
scripts/encrypt_bank_secrets.php
Normal file
64
scripts/encrypt_bank_secrets.php
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
<?php
|
||||
/**
|
||||
* Verschlüsselt die nach Migration 068 noch im Klartext in bank_sources liegenden
|
||||
* Felder (client_id_enc, secret_enc, pin_enc) mit dem APP_KEY.
|
||||
*
|
||||
* Das Skript ist idempotent: bereits verschlüsselte Werte (Magic-Prefix "enc:v1:")
|
||||
* werden übersprungen. Nach Ausführung kann es gefahrlos erneut aufgerufen werden.
|
||||
*
|
||||
* Aufruf:
|
||||
* docker compose exec web php scripts/encrypt_bank_secrets.php
|
||||
*
|
||||
* Voraussetzung: APP_KEY in .env gesetzt (64-stellige Hex-Zeichenkette).
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/../vendor/autoload.php';
|
||||
|
||||
use Steuerkram\Config;
|
||||
use Steuerkram\Database;
|
||||
use Steuerkram\Services\Crypto;
|
||||
|
||||
Config::load();
|
||||
|
||||
if ((string)Config::get('app_key', '') === '') {
|
||||
fwrite(STDERR, "FEHLER: APP_KEY ist nicht gesetzt. Bitte zuerst in .env hinterlegen.\n");
|
||||
fwrite(STDERR, "Generieren mit: php -r \"echo bin2hex(random_bytes(32));\"\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$rows = Database::fetchAll('SELECT id, client_id_enc, secret_enc, pin_enc FROM bank_sources');
|
||||
|
||||
$touched = 0;
|
||||
$skipped = 0;
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$update = [];
|
||||
|
||||
foreach (['client_id_enc', 'secret_enc', 'pin_enc'] as $field) {
|
||||
$value = $row[$field] ?? null;
|
||||
if ($value === null || $value === '') {
|
||||
continue;
|
||||
}
|
||||
if (Crypto::isEncrypted((string)$value)) {
|
||||
continue;
|
||||
}
|
||||
$update[$field] = Crypto::encrypt((string)$value);
|
||||
}
|
||||
|
||||
if (empty($update)) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$sets = implode(', ', array_map(fn($k) => "$k = ?", array_keys($update)));
|
||||
$params = array_values($update);
|
||||
$params[] = (int)$row['id'];
|
||||
Database::execute("UPDATE bank_sources SET $sets WHERE id = ?", $params);
|
||||
$touched++;
|
||||
echo "Verschlüsselt: bank_sources.id={$row['id']} (Felder: " . implode(', ', array_keys($update)) . ")\n";
|
||||
}
|
||||
|
||||
echo "\n";
|
||||
echo "Fertig: {$touched} Zeile(n) aktualisiert, {$skipped} bereits verschlüsselt oder leer.\n";
|
||||
|
|
@ -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' => '',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -4,13 +4,16 @@ declare(strict_types=1);
|
|||
|
||||
namespace Steuerkram\Controllers;
|
||||
|
||||
use Steuerkram\Models\BankImportFormat;
|
||||
use Steuerkram\Models\BankProvider;
|
||||
use Steuerkram\Models\BankSource;
|
||||
use Steuerkram\Models\BankTransaction;
|
||||
use Steuerkram\Controllers\InvoiceController;
|
||||
use Steuerkram\Database;
|
||||
use Steuerkram\Models\Beleg;
|
||||
use Steuerkram\Models\Invoice;
|
||||
use Steuerkram\Services\BankImportParser;
|
||||
use Steuerkram\Services\FinTsFetchService;
|
||||
use Steuerkram\Services\PayPalNvpFetchService;
|
||||
use Steuerkram\View;
|
||||
|
||||
class BankController extends BaseController
|
||||
|
|
@ -26,7 +29,7 @@ class BankController extends BaseController
|
|||
|
||||
$page = max(1, (int)($_GET['page'] ?? 1));
|
||||
$result = BankTransaction::all($filter, $page);
|
||||
$formats = BankImportFormat::all();
|
||||
$formats = BankSource::all('csv');
|
||||
|
||||
$view = new View();
|
||||
$view->render('layout/base.php', [
|
||||
|
|
@ -39,17 +42,17 @@ class BankController extends BaseController
|
|||
'filter' => $filter,
|
||||
'formats' => $formats,
|
||||
]),
|
||||
'topbarActions' => '<a href="/bank/upload" class="btn btn-primary">Upload</a>',
|
||||
'topbarActions' => '<a href="/bank/direkt" class="btn btn-primary">Importieren</a>',
|
||||
]);
|
||||
}
|
||||
|
||||
public function uploadForm(array $params): void
|
||||
{
|
||||
$formats = BankImportFormat::all();
|
||||
$formats = BankSource::all('csv');
|
||||
$view = new View();
|
||||
$view->render('layout/base.php', [
|
||||
'pageTitle' => 'Bank-Buchungen importieren',
|
||||
'topbarActions' => '<a href="/settings/bankimport" class="btn btn-secondary btn-sm">Formate bearbeiten</a>',
|
||||
'topbarActions' => '<a href="/bank/direkt" class="btn btn-secondary btn-sm">Formate bearbeiten</a>',
|
||||
'content' => $this->renderView('bank/upload', [
|
||||
'formats' => $formats,
|
||||
'errors' => [],
|
||||
|
|
@ -75,7 +78,7 @@ class BankController extends BaseController
|
|||
return;
|
||||
}
|
||||
|
||||
$formats = BankImportFormat::all();
|
||||
$formats = BankSource::all('csv');
|
||||
$parser = new BankImportParser();
|
||||
|
||||
$formatsData = [];
|
||||
|
|
@ -116,9 +119,9 @@ class BankController extends BaseController
|
|||
public function upload(array $params): void
|
||||
{
|
||||
$formatId = (int)($_POST['format_id'] ?? 0);
|
||||
$format = BankImportFormat::find($formatId);
|
||||
$format = BankSource::find($formatId);
|
||||
|
||||
if (!$format) {
|
||||
if (!$format || $format['type'] !== 'csv') {
|
||||
View::redirect('/bank/upload', 'Unbekanntes Import-Format.', 'error');
|
||||
}
|
||||
|
||||
|
|
@ -194,11 +197,11 @@ class BankController extends BaseController
|
|||
$this->doImport($pending['format_id'], $pending['filename'], $rows);
|
||||
}
|
||||
|
||||
private function doImport(int $formatId, string $filename, array $rows): void
|
||||
private function doImport(int $sourceId, string $filename, array $rows): void
|
||||
{
|
||||
Database::beginTransaction();
|
||||
try {
|
||||
$batchId = BankTransaction::createBatch($formatId, $filename, count($rows));
|
||||
$batchId = BankTransaction::createBatch($sourceId, $filename, count($rows));
|
||||
foreach ($rows as $row) {
|
||||
BankTransaction::insertRow($batchId, $row);
|
||||
}
|
||||
|
|
@ -364,4 +367,230 @@ class BankController extends BaseController
|
|||
View::redirect("/bank/$id", 'Beleg hochgeladen – bitte jetzt verknüpfen.');
|
||||
}
|
||||
|
||||
public function direkt(array $params): void
|
||||
{
|
||||
$connections = array_values(array_filter(
|
||||
BankSource::all(),
|
||||
fn($s) => in_array($s['type'], ['api', 'fints'], true)
|
||||
));
|
||||
$providers = BankProvider::all();
|
||||
|
||||
$view = new View();
|
||||
$view->render('layout/base.php', [
|
||||
'pageTitle' => 'Direktverbindungen',
|
||||
'topbarActions' => '<button type="button" class="btn btn-primary" id="btn-new-connection">NEU</button>',
|
||||
'content' => $this->renderView('bank/fints', [
|
||||
'connections' => $connections,
|
||||
'providers' => $providers,
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public function direktStore(array $params): void
|
||||
{
|
||||
$providerRaw = (string)($_POST['provider_id'] ?? '');
|
||||
$bezeichnung = trim($_POST['bezeichnung'] ?? '');
|
||||
|
||||
if ($bezeichnung === '') {
|
||||
View::redirect('/bank/direkt', 'Bitte alle Pflichtfelder ausfüllen.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Neue Bank anlegen, wenn Dropdown auf "__new__" steht.
|
||||
// Verbindungstyp wählt der Nutzer im Formular (API oder FinTS).
|
||||
if ($providerRaw === '__new__') {
|
||||
$newName = trim((string)($_POST['new_bank_name'] ?? ''));
|
||||
$newTyp = ($_POST['new_bank_typ'] ?? 'FinTS') === 'API' ? 'API' : 'FinTS';
|
||||
if ($newName === '') {
|
||||
View::redirect('/bank/direkt', 'Bitte den Banknamen angeben.', 'error');
|
||||
return;
|
||||
}
|
||||
$existing = BankProvider::findByName($newName);
|
||||
$providerId = $existing ? (int)$existing['id'] : BankProvider::create($newName, $newTyp);
|
||||
} else {
|
||||
$providerId = (int)$providerRaw;
|
||||
}
|
||||
|
||||
if (!$providerId) {
|
||||
View::redirect('/bank/direkt', 'Bitte alle Pflichtfelder ausfüllen.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
$provider = BankProvider::find($providerId);
|
||||
if (!$provider) {
|
||||
View::redirect('/bank/direkt', 'Unbekannter Anbieter.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
$isApi = $provider['verbindungstyp'] === 'API';
|
||||
$isFints = $provider['verbindungstyp'] === 'FinTS';
|
||||
|
||||
BankSource::create([
|
||||
'type' => $isApi ? 'api' : 'fints',
|
||||
'provider_id' => $providerId,
|
||||
'bezeichnung' => $bezeichnung,
|
||||
'kuerzel' => trim($_POST['kuerzel'] ?? '') ?: null,
|
||||
'farbe' => trim($_POST['farbe'] ?? '') ?: '#6366f1',
|
||||
'sandbox' => $isApi ? (isset($_POST['sandbox']) ? 1 : 0) : 0,
|
||||
'app_name' => $isApi ? (trim($_POST['app_name'] ?? '') ?: null) : null,
|
||||
'client_id' => $isApi ? (trim($_POST['client_id'] ?? '') ?: null) : null,
|
||||
'secret' => $isApi ? (trim($_POST['secret'] ?? '') ?: null) : null,
|
||||
'blz' => $isFints ? (trim($_POST['blz'] ?? '') ?: null) : null,
|
||||
'fints_url' => $isFints ? (trim($_POST['fints_url'] ?? '') ?: null) : null,
|
||||
'benutzerkennung' => $isFints ? (trim($_POST['benutzerkennung'] ?? '') ?: null) : null,
|
||||
'pin' => $isFints ? (trim($_POST['pin'] ?? '') ?: null) : null,
|
||||
'account_filter' => null, // Konten werden beim ersten Abruf ausgewählt
|
||||
]);
|
||||
|
||||
View::redirect('/bank/direkt', 'Verbindung gespeichert.');
|
||||
}
|
||||
|
||||
public function direktRun(array $params): void
|
||||
{
|
||||
$id = (int)($params['id'] ?? 0);
|
||||
$source = BankSource::find($id);
|
||||
|
||||
header('Content-Type: text/event-stream');
|
||||
header('Cache-Control: no-cache');
|
||||
header('X-Accel-Buffering: no');
|
||||
header('Connection: keep-alive');
|
||||
while (ob_get_level()) {
|
||||
ob_end_clean();
|
||||
}
|
||||
|
||||
$send = function (string $event, string $data): void {
|
||||
foreach (explode("\n", $data) as $line) {
|
||||
echo 'event: ' . $event . "\n";
|
||||
echo 'data: ' . $line . "\n\n";
|
||||
}
|
||||
flush();
|
||||
};
|
||||
|
||||
if (!$source) {
|
||||
$send('log', 'FEHLER: Verbindung nicht gefunden.');
|
||||
$send('done', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
$log = fn(string $msg) => $send('log', $msg);
|
||||
|
||||
try {
|
||||
if ($source['type'] === 'api') {
|
||||
(new PayPalNvpFetchService($source, $log))->run();
|
||||
} elseif ($source['type'] === 'fints') {
|
||||
(new FinTsFetchService($source, $log, $send))->run();
|
||||
} else {
|
||||
$send('log', 'Kein Abruf für Typ ' . $source['type'] . '.');
|
||||
$send('done', 'error');
|
||||
return;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$send('log', 'AUSNAHME: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
$send('done', 'ok');
|
||||
}
|
||||
|
||||
public function direktUpdate(array $params): void
|
||||
{
|
||||
$id = (int)($params['id'] ?? 0);
|
||||
$bezeichnung = trim($_POST['bezeichnung'] ?? '');
|
||||
|
||||
if (!$id || $bezeichnung === '') {
|
||||
View::redirect('/bank/direkt', 'Bitte alle Pflichtfelder ausfüllen.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
$source = BankSource::find($id);
|
||||
if (!$source) {
|
||||
View::redirect('/bank/direkt', 'Verbindung nicht gefunden.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
$isApi = $source['type'] === 'api';
|
||||
$isFints = $source['type'] === 'fints';
|
||||
|
||||
$data = [
|
||||
'bezeichnung' => $bezeichnung,
|
||||
'kuerzel' => trim($_POST['kuerzel'] ?? '') ?: null,
|
||||
'farbe' => trim($_POST['farbe'] ?? '') ?: '#6366f1',
|
||||
];
|
||||
|
||||
if ($isApi) {
|
||||
$newPwd = trim($_POST['client_id'] ?? '');
|
||||
$newSecret = trim($_POST['secret'] ?? '');
|
||||
$data += [
|
||||
'sandbox' => isset($_POST['sandbox']) ? 1 : 0,
|
||||
'app_name' => trim($_POST['app_name'] ?? '') ?: null,
|
||||
'client_id' => $newPwd !== '' ? $newPwd : $source['client_id'],
|
||||
'secret' => $newSecret !== '' ? $newSecret : $source['secret'],
|
||||
];
|
||||
} elseif ($isFints) {
|
||||
$newPin = trim($_POST['pin'] ?? '');
|
||||
$data += [
|
||||
'blz' => trim($_POST['blz'] ?? '') ?: null,
|
||||
'fints_url' => trim($_POST['fints_url'] ?? '') ?: null,
|
||||
'benutzerkennung' => trim($_POST['benutzerkennung'] ?? '') ?: null,
|
||||
'pin' => $newPin !== '' ? $newPin : $source['pin'],
|
||||
];
|
||||
// Reset-Button: Konto-Filter explizit löschen → beim nächsten Abruf neu auswählen
|
||||
if (isset($_POST['reset_account_filter'])) {
|
||||
$data['account_filter'] = null;
|
||||
}
|
||||
}
|
||||
|
||||
BankSource::update($id, $data);
|
||||
View::redirect('/bank/direkt', 'Verbindung gespeichert.');
|
||||
}
|
||||
|
||||
public function direktDelete(array $params): void
|
||||
{
|
||||
$id = (int)($params['id'] ?? 0);
|
||||
BankSource::delete($id);
|
||||
View::redirect('/bank/direkt', 'Verbindung gelöscht.');
|
||||
}
|
||||
|
||||
/** Nimmt die im Browser eingegebene TAN für einen wartenden FinTS-Abruf entgegen. */
|
||||
public function direktTan(array $params): void
|
||||
{
|
||||
header('Content-Type: application/json');
|
||||
|
||||
$token = trim((string)($_POST['token'] ?? ''));
|
||||
$tan = trim((string)($_POST['tan'] ?? ''));
|
||||
|
||||
if ($tan === '') {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'TAN fehlt.']);
|
||||
return;
|
||||
}
|
||||
if (!FinTsFetchService::submitPendingTan($token, $tan)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'Ungültiger TAN-Token.']);
|
||||
return;
|
||||
}
|
||||
echo json_encode(['ok' => true]);
|
||||
}
|
||||
|
||||
/** Nimmt die im Browser getroffene Konto-Auswahl für einen wartenden FinTS-Abruf entgegen. */
|
||||
public function direktAccounts(array $params): void
|
||||
{
|
||||
header('Content-Type: application/json');
|
||||
|
||||
$token = trim((string)($_POST['token'] ?? ''));
|
||||
$raw = $_POST['ids'] ?? '[]';
|
||||
$ids = is_array($raw) ? $raw : (json_decode((string)$raw, true) ?: []);
|
||||
|
||||
if (!is_array($ids) || empty($ids)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'Mindestens ein Konto auswählen.']);
|
||||
return;
|
||||
}
|
||||
if (!FinTsFetchService::submitAccountSelection($token, $ids)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'Ungültiger Token.']);
|
||||
return;
|
||||
}
|
||||
echo json_encode(['ok' => true]);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
|
||||
|
|
|
|||
74
src/Models/BankConnection.php
Normal file
74
src/Models/BankConnection.php
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Steuerkram\Models;
|
||||
|
||||
use Steuerkram\Database;
|
||||
|
||||
class BankConnection
|
||||
{
|
||||
public static function all(): array
|
||||
{
|
||||
return Database::fetchAll(
|
||||
'SELECT bc.*, bp.name AS provider_name, bp.verbindungstyp
|
||||
FROM bank_connections bc
|
||||
JOIN bank_providers bp ON bp.id = bc.provider_id
|
||||
ORDER BY bc.bezeichnung'
|
||||
);
|
||||
}
|
||||
|
||||
public static function find(int $id): ?array
|
||||
{
|
||||
$row = Database::fetchOne(
|
||||
'SELECT bc.*, bp.name AS provider_name, bp.verbindungstyp
|
||||
FROM bank_connections bc
|
||||
JOIN bank_providers bp ON bp.id = bc.provider_id
|
||||
WHERE bc.id = ?',
|
||||
[$id]
|
||||
);
|
||||
return $row ?: null;
|
||||
}
|
||||
|
||||
public static function create(array $data): void
|
||||
{
|
||||
Database::execute(
|
||||
'INSERT INTO bank_connections (provider_id, bezeichnung, app_name, client_id, secret, sandbox, kuerzel, farbe)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[
|
||||
(int)$data['provider_id'],
|
||||
$data['bezeichnung'],
|
||||
$data['app_name'] ?? null,
|
||||
$data['client_id'] ?? null,
|
||||
$data['secret'] ?? null,
|
||||
(int)($data['sandbox'] ?? 0),
|
||||
$data['kuerzel'] ?? null,
|
||||
$data['farbe'] ?? '#6366f1',
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
public static function update(int $id, array $data): void
|
||||
{
|
||||
Database::execute(
|
||||
'UPDATE bank_connections
|
||||
SET bezeichnung = ?, app_name = ?, client_id = ?, secret = ?, sandbox = ?, kuerzel = ?, farbe = ?
|
||||
WHERE id = ?',
|
||||
[
|
||||
$data['bezeichnung'],
|
||||
$data['app_name'] ?? null,
|
||||
$data['client_id'] ?? null,
|
||||
$data['secret'] ?? null,
|
||||
(int)($data['sandbox'] ?? 0),
|
||||
$data['kuerzel'] ?? null,
|
||||
$data['farbe'] ?? '#6366f1',
|
||||
$id,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
public static function delete(int $id): void
|
||||
{
|
||||
Database::execute('DELETE FROM bank_connections WHERE id = ?', [$id]);
|
||||
}
|
||||
}
|
||||
36
src/Models/BankProvider.php
Normal file
36
src/Models/BankProvider.php
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Steuerkram\Models;
|
||||
|
||||
use Steuerkram\Database;
|
||||
|
||||
class BankProvider
|
||||
{
|
||||
public static function all(): array
|
||||
{
|
||||
return Database::fetchAll('SELECT * FROM bank_providers ORDER BY name');
|
||||
}
|
||||
|
||||
public static function find(int $id): ?array
|
||||
{
|
||||
$row = Database::fetchOne('SELECT * FROM bank_providers WHERE id = ?', [$id]);
|
||||
return $row ?: null;
|
||||
}
|
||||
|
||||
public static function findByName(string $name): ?array
|
||||
{
|
||||
$row = Database::fetchOne('SELECT * FROM bank_providers WHERE name = ?', [$name]);
|
||||
return $row ?: null;
|
||||
}
|
||||
|
||||
public static function create(string $name, string $verbindungstyp = 'FinTS'): int
|
||||
{
|
||||
Database::execute(
|
||||
'INSERT INTO bank_providers (name, verbindungstyp) VALUES (?, ?)',
|
||||
[$name, $verbindungstyp]
|
||||
);
|
||||
return (int)Database::lastInsertId();
|
||||
}
|
||||
}
|
||||
138
src/Models/BankSource.php
Normal file
138
src/Models/BankSource.php
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Steuerkram\Models;
|
||||
|
||||
use Steuerkram\Database;
|
||||
use Steuerkram\Services\Crypto;
|
||||
|
||||
/**
|
||||
* Einheitliche Tabelle für alle Bank-Importquellen:
|
||||
* - type='csv' – CSV-Importformat (Spaltenmapping, Trennzeichen, Templates)
|
||||
* - type='api' – Direktverbindung über REST/NVP API (PayPal)
|
||||
* - type='fints' – Direktverbindung über FinTS (Comdirect, DKB, …)
|
||||
*
|
||||
* Beim Lesen werden die verschlüsselten Spalten (client_id_enc, secret_enc, pin_enc)
|
||||
* automatisch zu Klartext-Schlüsseln (client_id, secret, pin) entschlüsselt.
|
||||
* Außerdem wird "name" als Alias für "bezeichnung" gesetzt, damit der bestehende
|
||||
* BankImportParser-Code ($format['name'] etc.) ohne Anpassung funktioniert.
|
||||
*/
|
||||
class BankSource
|
||||
{
|
||||
public static function all(?string $type = null): array
|
||||
{
|
||||
$sql = 'SELECT bs.*, bp.name AS provider_name, bp.verbindungstyp, bp.default_fints_url
|
||||
FROM bank_sources bs
|
||||
LEFT JOIN bank_providers bp ON bp.id = bs.provider_id';
|
||||
$params = [];
|
||||
if ($type !== null) {
|
||||
$sql .= ' WHERE bs.type = ?';
|
||||
$params[] = $type;
|
||||
}
|
||||
$sql .= ' ORDER BY bs.type, bs.bezeichnung';
|
||||
$rows = Database::fetchAll($sql, $params);
|
||||
return array_map([self::class, 'hydrate'], $rows);
|
||||
}
|
||||
|
||||
public static function find(int $id): ?array
|
||||
{
|
||||
$row = Database::fetchOne(
|
||||
'SELECT bs.*, bp.name AS provider_name, bp.verbindungstyp, bp.default_fints_url
|
||||
FROM bank_sources bs
|
||||
LEFT JOIN bank_providers bp ON bp.id = bs.provider_id
|
||||
WHERE bs.id = ?',
|
||||
[$id]
|
||||
);
|
||||
return $row ? self::hydrate($row) : null;
|
||||
}
|
||||
|
||||
public static function create(array $data): int
|
||||
{
|
||||
$type = $data['type'] ?? 'csv';
|
||||
$row = self::dehydrate($data);
|
||||
|
||||
$cols = array_keys($row);
|
||||
$sql = 'INSERT INTO bank_sources (' . implode(',', array_map(fn($c) => "`$c`", $cols)) . ')
|
||||
VALUES (' . implode(',', array_fill(0, count($cols), '?')) . ')';
|
||||
Database::execute($sql, array_values($row));
|
||||
return (int)Database::lastInsertId();
|
||||
}
|
||||
|
||||
public static function update(int $id, array $data): void
|
||||
{
|
||||
$row = self::dehydrate($data);
|
||||
if (empty($row)) {
|
||||
return;
|
||||
}
|
||||
$sets = implode(', ', array_map(fn($c) => "`$c` = ?", array_keys($row)));
|
||||
$params = array_values($row);
|
||||
$params[] = $id;
|
||||
Database::execute("UPDATE bank_sources SET $sets WHERE id = ?", $params);
|
||||
}
|
||||
|
||||
public static function updateField(int $id, string $field, mixed $value): void
|
||||
{
|
||||
if (in_array($field, ['client_id', 'secret', 'pin'], true)) {
|
||||
$field = $field . '_enc';
|
||||
$value = $value !== null && $value !== '' ? Crypto::encrypt((string)$value) : null;
|
||||
}
|
||||
Database::execute("UPDATE bank_sources SET `$field` = ? WHERE id = ?", [$value, $id]);
|
||||
}
|
||||
|
||||
public static function delete(int $id): void
|
||||
{
|
||||
Database::execute('DELETE FROM bank_sources WHERE id = ?', [$id]);
|
||||
}
|
||||
|
||||
public static function updateLastFetched(int $id): void
|
||||
{
|
||||
Database::execute('UPDATE bank_sources SET last_fetched_at = NOW() WHERE id = ?', [$id]);
|
||||
}
|
||||
|
||||
private static function hydrate(array $row): array
|
||||
{
|
||||
$row['name'] = $row['bezeichnung'];
|
||||
$row['client_id'] = Crypto::decrypt((string)($row['client_id_enc'] ?? ''));
|
||||
$row['secret'] = Crypto::decrypt((string)($row['secret_enc'] ?? ''));
|
||||
$row['pin'] = Crypto::decrypt((string)($row['pin_enc'] ?? ''));
|
||||
return $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* Überführt ein Eingabe-Array in die DB-Spalten:
|
||||
* - client_id/secret/pin → *_enc (verschlüsselt)
|
||||
* - nur erlaubte Spalten werden zurückgegeben
|
||||
*/
|
||||
private static function dehydrate(array $data): array
|
||||
{
|
||||
$allowed = [
|
||||
'type', 'provider_id', 'bezeichnung', 'kuerzel', 'farbe',
|
||||
'skip_rows', 'has_header_row', 'delimiter', 'enclosure',
|
||||
'col_buchungstag', 'col_wertstellung', 'col_vorgang', 'col_buchungstext', 'col_wert',
|
||||
'col_zahlungspflichtiger', 'col_zahlungsempfaenger', 'col_verwendungszweck',
|
||||
'date_format', 'decimal_separator', 'thousands_separator',
|
||||
'buchungstext_template_debit', 'buchungstext_template_credit',
|
||||
'sandbox', 'last_fetched_at',
|
||||
'app_name', 'client_id_enc', 'secret_enc',
|
||||
'blz', 'fints_url', 'benutzerkennung', 'pin_enc', 'account_filter',
|
||||
];
|
||||
|
||||
$out = [];
|
||||
foreach ($allowed as $col) {
|
||||
if (array_key_exists($col, $data)) {
|
||||
$out[$col] = $data[$col];
|
||||
}
|
||||
}
|
||||
|
||||
// Klartext-Felder verschlüsseln
|
||||
foreach (['client_id' => 'client_id_enc', 'secret' => 'secret_enc', 'pin' => 'pin_enc'] as $plain => $enc) {
|
||||
if (array_key_exists($plain, $data)) {
|
||||
$val = $data[$plain];
|
||||
$out[$enc] = ($val === null || $val === '') ? null : Crypto::encrypt((string)$val);
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
|
|
@ -43,7 +43,7 @@ class BankTransaction
|
|||
$params[] = $s;
|
||||
}
|
||||
if (!empty($filter['format_id'])) {
|
||||
$where[] = 'bib.format_id = ?';
|
||||
$where[] = 'bs.id = ?';
|
||||
$params[] = (int)$filter['format_id'];
|
||||
}
|
||||
|
||||
|
|
@ -64,17 +64,19 @@ class BankTransaction
|
|||
"SELECT COUNT(*) AS n
|
||||
FROM bank_transactions bt
|
||||
JOIN bank_import_batches bib ON bt.batch_id = bib.id
|
||||
LEFT JOIN bank_sources bs ON bib.source_id = bs.id
|
||||
WHERE $whereStr",
|
||||
$params
|
||||
)['n'] ?? 0);
|
||||
|
||||
$rows = Database::fetchAll(
|
||||
"SELECT bt.*, bib.filename AS batch_filename, bif.name AS format_name, bif.kuerzel, bif.farbe,
|
||||
"SELECT bt.*, bib.filename AS batch_filename, bs.bezeichnung AS format_name,
|
||||
bs.kuerzel AS kuerzel, bs.farbe AS farbe,
|
||||
btb_agg.beleg_id AS beleg_id, btb_agg.beleg_count,
|
||||
b.beleg_nr
|
||||
FROM bank_transactions bt
|
||||
JOIN bank_import_batches bib ON bt.batch_id = bib.id
|
||||
LEFT JOIN bank_import_formats bif ON bib.format_id = bif.id
|
||||
LEFT JOIN bank_sources bs ON bib.source_id = bs.id
|
||||
LEFT JOIN (
|
||||
SELECT bank_tx_id, MIN(beleg_id) AS beleg_id, COUNT(*) AS beleg_count
|
||||
FROM bank_transaction_belege
|
||||
|
|
@ -97,10 +99,11 @@ class BankTransaction
|
|||
public static function find(int $id): ?array
|
||||
{
|
||||
$row = Database::fetchOne(
|
||||
'SELECT bt.*, bib.filename AS batch_filename, bif.name AS format_name, bif.kuerzel, bif.farbe
|
||||
'SELECT bt.*, bib.filename AS batch_filename, bs.bezeichnung AS format_name,
|
||||
bs.kuerzel AS kuerzel, bs.farbe AS farbe
|
||||
FROM bank_transactions bt
|
||||
JOIN bank_import_batches bib ON bt.batch_id = bib.id
|
||||
JOIN bank_import_formats bif ON bib.format_id = bif.id
|
||||
LEFT JOIN bank_sources bs ON bib.source_id = bs.id
|
||||
WHERE bt.id = ?',
|
||||
[$id]
|
||||
);
|
||||
|
|
@ -201,11 +204,11 @@ class BankTransaction
|
|||
public static function findByBelegId(int $belegId): ?array
|
||||
{
|
||||
$row = Database::fetchOne(
|
||||
'SELECT bt.*, bib.filename AS batch_filename, bif.kuerzel, bif.farbe, btb.betrag AS alloc_betrag
|
||||
'SELECT bt.*, bib.filename AS batch_filename, bs.kuerzel, bs.farbe, btb.betrag AS alloc_betrag
|
||||
FROM bank_transaction_belege btb
|
||||
JOIN bank_transactions bt ON bt.id = btb.bank_tx_id
|
||||
JOIN bank_import_batches bib ON bib.id = bt.batch_id
|
||||
JOIN bank_import_formats bif ON bif.id = bib.format_id
|
||||
LEFT JOIN bank_sources bs ON bs.id = bib.source_id
|
||||
WHERE btb.beleg_id = ?
|
||||
LIMIT 1',
|
||||
[$belegId]
|
||||
|
|
@ -258,11 +261,11 @@ class BankTransaction
|
|||
return ['new' => $new, 'dup' => $dup];
|
||||
}
|
||||
|
||||
public static function createBatch(int $formatId, string $filename, int $rowCount): int
|
||||
public static function createBatch(int $sourceId, string $filename, int $rowCount): int
|
||||
{
|
||||
Database::execute(
|
||||
'INSERT INTO bank_import_batches (format_id, filename, row_count) VALUES (?, ?, ?)',
|
||||
[$formatId, $filename, $rowCount]
|
||||
'INSERT INTO bank_import_batches (source_id, filename, row_count) VALUES (?, ?, ?)',
|
||||
[$sourceId, $filename, $rowCount]
|
||||
);
|
||||
return (int)Database::lastInsertId();
|
||||
}
|
||||
|
|
|
|||
91
src/Services/Crypto.php
Normal file
91
src/Services/Crypto.php
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Steuerkram\Services;
|
||||
|
||||
use Steuerkram\Config;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Symmetrische Verschlüsselung sensibler Felder (PayPal-Signature, FinTS-PIN).
|
||||
*
|
||||
* Format: "enc:v1:" + base64( iv(12) || tag(16) || ciphertext )
|
||||
* Algorithmus: AES-256-GCM, Schlüssel als 64-stellige Hex-Zeichenkette aus Config::get('app_key').
|
||||
*
|
||||
* Migrationsmodus: decrypt() reicht Werte ohne Prefix unverändert durch (Klartext),
|
||||
* sodass Klartext-Werte aus dem SQL-Backfill funktionieren, bis das Verschlüsselungsskript
|
||||
* sie idempotent in den enc-Form überführt hat.
|
||||
*/
|
||||
class Crypto
|
||||
{
|
||||
private const PREFIX = 'enc:v1:';
|
||||
private const CIPHER = 'aes-256-gcm';
|
||||
private const IV_LEN = 12;
|
||||
private const TAG_LEN = 16;
|
||||
|
||||
public static function encrypt(string $plain): string
|
||||
{
|
||||
if ($plain === '') {
|
||||
return '';
|
||||
}
|
||||
if (self::isEncrypted($plain)) {
|
||||
return $plain;
|
||||
}
|
||||
|
||||
$key = self::key();
|
||||
$iv = random_bytes(self::IV_LEN);
|
||||
$tag = '';
|
||||
$cipher = openssl_encrypt($plain, self::CIPHER, $key, OPENSSL_RAW_DATA, $iv, $tag, '', self::TAG_LEN);
|
||||
|
||||
if ($cipher === false) {
|
||||
throw new RuntimeException('Crypto::encrypt fehlgeschlagen.');
|
||||
}
|
||||
|
||||
return self::PREFIX . base64_encode($iv . $tag . $cipher);
|
||||
}
|
||||
|
||||
public static function decrypt(string $value): string
|
||||
{
|
||||
if ($value === '') {
|
||||
return '';
|
||||
}
|
||||
if (!self::isEncrypted($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
$raw = base64_decode(substr($value, strlen(self::PREFIX)), true);
|
||||
if ($raw === false || strlen($raw) < self::IV_LEN + self::TAG_LEN) {
|
||||
throw new RuntimeException('Crypto::decrypt: ungültiges Format.');
|
||||
}
|
||||
|
||||
$iv = substr($raw, 0, self::IV_LEN);
|
||||
$tag = substr($raw, self::IV_LEN, self::TAG_LEN);
|
||||
$cipher = substr($raw, self::IV_LEN + self::TAG_LEN);
|
||||
|
||||
$plain = openssl_decrypt($cipher, self::CIPHER, self::key(), OPENSSL_RAW_DATA, $iv, $tag);
|
||||
if ($plain === false) {
|
||||
throw new RuntimeException('Crypto::decrypt fehlgeschlagen (falscher Schlüssel oder Datenkorruption).');
|
||||
}
|
||||
|
||||
return $plain;
|
||||
}
|
||||
|
||||
public static function isEncrypted(string $value): bool
|
||||
{
|
||||
return str_starts_with($value, self::PREFIX);
|
||||
}
|
||||
|
||||
private static function key(): string
|
||||
{
|
||||
$hex = (string)Config::get('app_key', '');
|
||||
if ($hex === '') {
|
||||
throw new RuntimeException('APP_KEY ist nicht gesetzt. Bitte in .env hinterlegen.');
|
||||
}
|
||||
$key = @hex2bin($hex);
|
||||
if ($key === false || strlen($key) !== 32) {
|
||||
throw new RuntimeException('APP_KEY muss eine 64-stellige Hex-Zeichenkette (32 Byte) sein.');
|
||||
}
|
||||
return $key;
|
||||
}
|
||||
}
|
||||
545
src/Services/FinTsFetchService.php
Normal file
545
src/Services/FinTsFetchService.php
Normal file
|
|
@ -0,0 +1,545 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Steuerkram\Services;
|
||||
|
||||
use Steuerkram\Database;
|
||||
|
||||
use Fhp\Action\GetSEPAAccounts;
|
||||
use Fhp\Action\GetStatementOfAccount;
|
||||
use Fhp\BaseAction;
|
||||
use Fhp\FinTs;
|
||||
use Fhp\Model\StatementOfAccount\Transaction;
|
||||
use Fhp\Model\TanRequestChallengeImage;
|
||||
use Fhp\Options\Credentials;
|
||||
use Fhp\Options\FinTsOptions;
|
||||
|
||||
/**
|
||||
* FinTS-Leseabruf für Bankverbindungen mit type='fints'.
|
||||
*
|
||||
* Erwartet eine entschlüsselte bank_sources-Zeile mit:
|
||||
* - blz, benutzerkennung, pin (verschlüsselt in DB, hier im Klartext erwartet)
|
||||
* - fints_url (optional, sonst default_fints_url aus dem Provider-Join)
|
||||
* - iban_filter (optional, nur dieses Konto abrufen)
|
||||
*
|
||||
* TAN-Pflicht: In dieser ersten Version werden Aktionen, die eine TAN erfordern,
|
||||
* mit einer klaren Fehlermeldung abgebrochen. Die meisten Banken erlauben den
|
||||
* Leseabruf via PIN ohne zusätzliche TAN.
|
||||
*/
|
||||
class FinTsFetchService
|
||||
{
|
||||
private array $source;
|
||||
private int $sourceId;
|
||||
private ?FinTs $fints = null;
|
||||
|
||||
/** @var callable */
|
||||
private $log;
|
||||
|
||||
/** @var callable|null Wenn gesetzt, statt $this->log nutzen, um SSE-Events mit eigenem Namen zu schicken */
|
||||
private $send;
|
||||
|
||||
public function __construct(array $source, callable $log, ?callable $send = null)
|
||||
{
|
||||
$this->source = $source;
|
||||
$this->sourceId = (int)$source['id'];
|
||||
$this->log = $log;
|
||||
$this->send = $send;
|
||||
}
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$log = $this->log;
|
||||
|
||||
$blz = trim((string)($this->source['blz'] ?? ''));
|
||||
$user = trim((string)($this->source['benutzerkennung'] ?? ''));
|
||||
$pin = trim((string)($this->source['pin'] ?? ''));
|
||||
$url = trim((string)($this->source['fints_url'] ?? ''));
|
||||
if ($url === '') {
|
||||
$url = trim((string)($this->source['default_fints_url'] ?? ''));
|
||||
}
|
||||
|
||||
$log('Nutze FinTS-Direktverbindung');
|
||||
$log('Bank: ' . ($this->source['provider_name'] ?? '?'));
|
||||
$log('BLZ: ' . ($blz !== '' ? $blz : 'FEHLT'));
|
||||
$log('URL: ' . ($url !== '' ? $url : 'FEHLT'));
|
||||
$log('User: ' . ($user !== '' ? $user : 'FEHLT'));
|
||||
$log('PIN: ' . ($pin !== '' ? strlen($pin) . ' Zeichen' : 'FEHLT'));
|
||||
|
||||
if ($blz === '' || $user === '' || $pin === '' || $url === '') {
|
||||
$log('FEHLER: Pflichtfelder unvollständig (BLZ, Benutzerkennung, PIN, URL).');
|
||||
return;
|
||||
}
|
||||
|
||||
$options = new FinTsOptions();
|
||||
$options->url = $url;
|
||||
$options->bankCode = $blz;
|
||||
$options->productName = 'Steuerkram';
|
||||
$options->productVersion = '1.0';
|
||||
$credentials = Credentials::create($user, $pin);
|
||||
|
||||
try {
|
||||
$this->fints = FinTs::new($options, $credentials);
|
||||
} catch (\Throwable $e) {
|
||||
$log('FEHLER bei Initialisierung: ' . $e->getMessage());
|
||||
return;
|
||||
}
|
||||
$fints = $this->fints;
|
||||
|
||||
$log('TAN-Verfahren werden ermittelt…');
|
||||
try {
|
||||
$tanModes = $fints->getTanModes();
|
||||
} catch (\Throwable $e) {
|
||||
$log('FEHLER bei TAN-Modi-Abruf: ' . $e->getMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
if (empty($tanModes)) {
|
||||
$log('FEHLER: Bank meldet keine TAN-Verfahren.');
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($tanModes as $idx => $mode) {
|
||||
$log(sprintf(' [%d] %s (ID %d)', $idx, $mode->getName(), $mode->getId()));
|
||||
}
|
||||
|
||||
$selectedTanMode = reset($tanModes);
|
||||
$log('Auswahl: ' . $selectedTanMode->getName() . ' (erstes verfügbares Verfahren)');
|
||||
|
||||
$selectedTanMedium = null;
|
||||
if ($selectedTanMode->needsTanMedium()) {
|
||||
try {
|
||||
$tanMedia = $fints->getTanMedia($selectedTanMode);
|
||||
} catch (\Throwable $e) {
|
||||
$log('FEHLER beim TAN-Medien-Abruf: ' . $e->getMessage());
|
||||
return;
|
||||
}
|
||||
if (empty($tanMedia)) {
|
||||
$log('FEHLER: Verfahren benötigt TAN-Medium, Bank liefert aber keines.');
|
||||
return;
|
||||
}
|
||||
$selectedTanMedium = reset($tanMedia);
|
||||
$log('TAN-Medium: ' . $selectedTanMedium->getName());
|
||||
}
|
||||
|
||||
try {
|
||||
$fints->selectTanMode($selectedTanMode, $selectedTanMedium);
|
||||
} catch (\Throwable $e) {
|
||||
$log('FEHLER bei selectTanMode: ' . $e->getMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
$log('Login wird versucht…');
|
||||
try {
|
||||
$login = $fints->login();
|
||||
if ($login->needsTan()) {
|
||||
$log('FEHLER: Bank verlangt eine TAN für den Login.');
|
||||
$log('TAN-Eingabe ist in dieser Version noch nicht unterstützt.');
|
||||
return;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$log('FEHLER beim Login: ' . $e->getMessage());
|
||||
return;
|
||||
}
|
||||
$log('Login OK.');
|
||||
|
||||
try {
|
||||
$getAccounts = GetSEPAAccounts::create();
|
||||
$fints->execute($getAccounts);
|
||||
if ($getAccounts->needsTan()) {
|
||||
$log('FEHLER: TAN für Kontenabfrage erforderlich. Nicht unterstützt.');
|
||||
return;
|
||||
}
|
||||
$accounts = $getAccounts->getAccounts();
|
||||
} catch (\Throwable $e) {
|
||||
$log('FEHLER bei Kontenabfrage: ' . $e->getMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
$log(count($accounts) . ' SEPA-Konten verfügbar.');
|
||||
|
||||
$accounts = $this->applyAccountFilter($accounts);
|
||||
if ($accounts === null) {
|
||||
return;
|
||||
}
|
||||
if (empty($accounts)) {
|
||||
$log('Kein Konto entspricht der Auswahl – Abbruch.');
|
||||
return;
|
||||
}
|
||||
|
||||
$end = new \DateTime('today');
|
||||
$lastFetched = $this->source['last_fetched_at'] ?? null;
|
||||
if ($lastFetched) {
|
||||
$start = new \DateTime((new \DateTime($lastFetched))->format('Y-m-d'));
|
||||
$log('Letzter Abruf: ' . $start->format('d.m.Y') . ' – starte ab 00:00 Uhr dieses Tages.');
|
||||
} else {
|
||||
$start = (new \DateTime('today'))->modify('-365 days');
|
||||
$log('Kein vorheriger Abruf – hole die letzten 365 Tage.');
|
||||
}
|
||||
|
||||
$batchId = $this->createBatch();
|
||||
$log('Batch #' . $batchId . ' angelegt.');
|
||||
|
||||
$totalNew = 0;
|
||||
$totalSkip = 0;
|
||||
|
||||
foreach ($accounts as $account) {
|
||||
$iban = $account->getIban();
|
||||
$log('Konto ' . $iban . ':');
|
||||
|
||||
try {
|
||||
$action = GetStatementOfAccount::create($account, $start, $end, false, true);
|
||||
$fints->execute($action);
|
||||
if ($action->needsTan()) {
|
||||
if (!$this->handleStrongAuthentication($action, $iban)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
$soa = $action->getStatement();
|
||||
} catch (\Throwable $e) {
|
||||
$log(' → Fehler beim Abruf: ' . $e->getMessage());
|
||||
continue;
|
||||
}
|
||||
|
||||
$accountNew = 0;
|
||||
foreach ($soa->getStatements() as $statement) {
|
||||
foreach ($statement->getTransactions() as $tx) {
|
||||
$date = $tx->getValutaDate() ?: $tx->getBookingDate() ?: new \DateTime();
|
||||
$sign = $tx->getCreditDebit() === Transaction::CD_DEBIT ? -1 : 1;
|
||||
$amt = round($sign * (float)$tx->getAmount(), 2);
|
||||
|
||||
$name = trim((string)$tx->getName());
|
||||
$desc = trim((string)$tx->getMainDescription());
|
||||
$vorgang = trim((string)$tx->getBookingText()) ?: 'FinTS';
|
||||
|
||||
$extId = sha1($iban . '|' . $date->format('Y-m-d') . '|' . number_format($amt, 2, '.', '') . '|' . $desc);
|
||||
|
||||
if ($this->externalIdExists($extId)) {
|
||||
$totalSkip++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($name !== '' && $desc !== '') {
|
||||
$buchungstext = $name . ' | ' . $desc;
|
||||
} elseif ($desc !== '') {
|
||||
$buchungstext = $desc;
|
||||
} elseif ($name !== '') {
|
||||
$buchungstext = $name;
|
||||
} else {
|
||||
$buchungstext = $vorgang;
|
||||
}
|
||||
|
||||
$this->insertRow($batchId, [
|
||||
'external_id' => $extId,
|
||||
'buchungstag' => ($tx->getBookingDate() ?: $date)->format('Y-m-d'),
|
||||
'wertstellung' => $date->format('Y-m-d'),
|
||||
'vorgang' => mb_substr($vorgang, 0, 100),
|
||||
'buchungstext' => mb_substr($buchungstext, 0, 1000),
|
||||
'wert' => $amt,
|
||||
'raw_line' => json_encode([
|
||||
'iban' => $iban,
|
||||
'booking_text' => $tx->getBookingText(),
|
||||
'name' => $name,
|
||||
'desc' => $desc,
|
||||
'amount' => $amt,
|
||||
'eref' => method_exists($tx, 'getEndToEndID') ? $tx->getEndToEndID() : null,
|
||||
], JSON_UNESCAPED_UNICODE),
|
||||
]);
|
||||
$totalNew++;
|
||||
$accountNew++;
|
||||
}
|
||||
}
|
||||
$log(' → ' . $accountNew . ' neue Buchungen.');
|
||||
}
|
||||
|
||||
$this->updateBatchCount($batchId, $totalNew);
|
||||
$this->updateLastFetched();
|
||||
|
||||
$log(sprintf('Fertig. %d neu importiert, %d bereits vorhanden.', $totalNew, $totalSkip));
|
||||
}
|
||||
|
||||
/**
|
||||
* Behandelt eine TAN-Anfrage einer FinTS-Aktion.
|
||||
* Decoupled (pushTAN/App-Bestätigung) → Polling.
|
||||
* Klassisch (photoTAN/chipTAN) → Bild + Eingabefeld via SSE, Wartezeit auf Datei-Rendezvous.
|
||||
*
|
||||
* @return bool true, wenn die Aktion erfolgreich abgeschlossen wurde
|
||||
*/
|
||||
private function handleStrongAuthentication(BaseAction $action, string $contextLabel): bool
|
||||
{
|
||||
$log = $this->log;
|
||||
$send = $this->send ?: function (string $event, string $data) use ($log): void {
|
||||
// Fallback ohne SSE: nur loggen
|
||||
$log(' → [event:' . $event . '] ' . $data);
|
||||
};
|
||||
$tanMode = $this->fints->getSelectedTanMode();
|
||||
$tanRequest = $action->getTanRequest();
|
||||
|
||||
if ($tanMode->isDecoupled()) {
|
||||
$log(' → ' . $contextLabel . ': Bitte im Banking-Gerät bestätigen…');
|
||||
if ($tanRequest->getChallenge() !== null) {
|
||||
$log(' Hinweis: ' . $tanRequest->getChallenge());
|
||||
}
|
||||
if (!$tanMode->allowsAutomatedPolling()) {
|
||||
$log(' → Bank erlaubt kein automatisches Polling – aktuell nicht unterstützt.');
|
||||
return false;
|
||||
}
|
||||
sleep((int)$tanMode->getFirstDecoupledCheckDelaySeconds());
|
||||
$maxChecks = (int)$tanMode->getMaxDecoupledChecks();
|
||||
$period = max(1, (int)$tanMode->getPeriodicDecoupledCheckDelaySeconds());
|
||||
for ($attempt = 0; $attempt < $maxChecks; $attempt++) {
|
||||
try {
|
||||
$this->fints->checkDecoupledSubmission($action);
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
sleep($period);
|
||||
}
|
||||
}
|
||||
$log(' → Zeitüberschreitung bei App-Bestätigung.');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Klassisch: photoTAN/chipTAN – Bild zeigen, TAN entgegennehmen
|
||||
$token = bin2hex(random_bytes(16));
|
||||
$payload = [
|
||||
'token' => $token,
|
||||
'context' => $contextLabel,
|
||||
'challenge' => $tanRequest->getChallenge(),
|
||||
'mediumName' => $tanRequest->getTanMediumName(),
|
||||
'imageMime' => null,
|
||||
'imageData' => null,
|
||||
];
|
||||
if ($tanRequest->getChallengeHhdUc()) {
|
||||
try {
|
||||
$img = new TanRequestChallengeImage($tanRequest->getChallengeHhdUc());
|
||||
$payload['imageMime'] = $img->getMimeType();
|
||||
$payload['imageData'] = base64_encode($img->getData());
|
||||
} catch (\Throwable) {
|
||||
// war kein photoTAN-Bild (z.B. Flicker) – ohne Bild fortfahren
|
||||
}
|
||||
}
|
||||
|
||||
$log(' → ' . $contextLabel . ': TAN angefordert. Bitte im Browser eingeben…');
|
||||
($send)('tan-required', json_encode($payload, JSON_UNESCAPED_UNICODE));
|
||||
|
||||
$tan = self::awaitTan($token, 300);
|
||||
if ($tan === null) {
|
||||
$log(' → Zeitüberschreitung bei TAN-Eingabe.');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->fints->submitTan($action, $tan);
|
||||
$log(' → TAN akzeptiert.');
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
$log(' → TAN abgelehnt: ' . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Eindeutiger Identifier für ein FinTS-Konto. IBAN wenn vorhanden, sonst
|
||||
* "AN:<accountNumber>:<subAccount>". Robust gegen Depots ohne IBAN.
|
||||
*/
|
||||
private static function accountId($account): string
|
||||
{
|
||||
$iban = trim((string)$account->getIban());
|
||||
if ($iban !== '') {
|
||||
return $iban;
|
||||
}
|
||||
$sub = method_exists($account, 'getSubAccount') ? (string)$account->getSubAccount() : '';
|
||||
return 'AN:' . $account->getAccountNumber() . ':' . $sub;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wendet den gespeicherten account_filter an. Wenn kein Filter gesetzt und
|
||||
* mehr als ein Konto verfügbar ist, wird der Nutzer per SSE-Event nach einer
|
||||
* Auswahl gefragt und das Ergebnis persistiert.
|
||||
*
|
||||
* @return array<int,mixed>|null Liste der erlaubten Konten oder null bei Abbruch
|
||||
*/
|
||||
private function applyAccountFilter(array $accounts): ?array
|
||||
{
|
||||
$log = $this->log;
|
||||
$send = $this->send ?: function (string $event, string $data) use ($log): void {
|
||||
$log(' → [event:' . $event . '] ' . $data);
|
||||
};
|
||||
|
||||
$rawFilter = $this->source['account_filter'] ?? null;
|
||||
$allowedIds = null;
|
||||
if ($rawFilter !== null && $rawFilter !== '') {
|
||||
$decoded = json_decode((string)$rawFilter, true);
|
||||
if (is_array($decoded)) {
|
||||
$allowedIds = array_values(array_map('strval', $decoded));
|
||||
}
|
||||
}
|
||||
|
||||
// Erste Verwendung & mehrere Konten: Auswahl vom Nutzer einholen
|
||||
if ($allowedIds === null && count($accounts) > 1) {
|
||||
$token = bin2hex(random_bytes(16));
|
||||
$payload = [
|
||||
'token' => $token,
|
||||
'accounts' => array_map(function ($a) {
|
||||
$iban = trim((string)$a->getIban());
|
||||
$accNo = (string)$a->getAccountNumber();
|
||||
$sub = method_exists($a, 'getSubAccount') ? (string)$a->getSubAccount() : '';
|
||||
return [
|
||||
'id' => self::accountId($a),
|
||||
'iban' => $iban,
|
||||
'label' => $iban !== ''
|
||||
? $iban
|
||||
: ('Konto ' . $accNo . ($sub !== '' ? ' (' . $sub . ')' : '')),
|
||||
];
|
||||
}, $accounts),
|
||||
];
|
||||
$log('Mehrere Konten verfügbar – bitte Auswahl im Browser treffen.');
|
||||
($send)('accounts-required', json_encode($payload, JSON_UNESCAPED_UNICODE));
|
||||
|
||||
$selected = self::awaitAccountSelection($token, 300);
|
||||
if ($selected === null) {
|
||||
$log('FEHLER: Zeitüberschreitung bei Konto-Auswahl.');
|
||||
return null;
|
||||
}
|
||||
if (empty($selected)) {
|
||||
$log('FEHLER: Keine Konten ausgewählt.');
|
||||
return null;
|
||||
}
|
||||
$allowedIds = $selected;
|
||||
Database::execute(
|
||||
'UPDATE bank_sources SET account_filter = ? WHERE id = ?',
|
||||
[json_encode(array_values($allowedIds), JSON_UNESCAPED_UNICODE), $this->sourceId]
|
||||
);
|
||||
$log('Konto-Auswahl gespeichert: ' . count($allowedIds) . ' Konto(n).');
|
||||
}
|
||||
|
||||
if ($allowedIds === null) {
|
||||
return $accounts;
|
||||
}
|
||||
|
||||
$filtered = array_values(array_filter(
|
||||
$accounts,
|
||||
fn($a) => in_array(self::accountId($a), $allowedIds, true)
|
||||
));
|
||||
$log('Filter aktiv: ' . count($filtered) . ' Konto(n) verbleiben.');
|
||||
return $filtered;
|
||||
}
|
||||
|
||||
private static function tanDir(): string
|
||||
{
|
||||
$dir = sys_get_temp_dir() . '/steuerkram-tan';
|
||||
if (!is_dir($dir)) {
|
||||
@mkdir($dir, 0700, true);
|
||||
}
|
||||
return $dir;
|
||||
}
|
||||
|
||||
/** Wird vom POST-Endpoint aufgerufen, um die vom Nutzer eingegebene TAN abzulegen. */
|
||||
public static function submitPendingTan(string $token, string $tan): bool
|
||||
{
|
||||
if (!preg_match('/^[a-f0-9]{32}$/', $token)) {
|
||||
return false;
|
||||
}
|
||||
return (bool)@file_put_contents(self::tanDir() . '/' . $token . '.tan', $tan);
|
||||
}
|
||||
|
||||
/** Wird vom POST-Endpoint aufgerufen, um die Konto-Auswahl abzulegen. */
|
||||
public static function submitAccountSelection(string $token, array $ids): bool
|
||||
{
|
||||
if (!preg_match('/^[a-f0-9]{32}$/', $token)) {
|
||||
return false;
|
||||
}
|
||||
$ids = array_values(array_filter(array_map('strval', $ids), fn($s) => $s !== ''));
|
||||
return (bool)@file_put_contents(
|
||||
self::tanDir() . '/' . $token . '.accounts',
|
||||
json_encode($ids, JSON_UNESCAPED_UNICODE)
|
||||
);
|
||||
}
|
||||
|
||||
/** Polled bis zu $timeoutSec Sekunden auf die TAN-Datei. */
|
||||
private static function awaitTan(string $token, int $timeoutSec): ?string
|
||||
{
|
||||
$file = self::tanDir() . '/' . $token . '.tan';
|
||||
$start = time();
|
||||
while (time() - $start < $timeoutSec) {
|
||||
if (file_exists($file)) {
|
||||
$tan = trim((string)@file_get_contents($file));
|
||||
@unlink($file);
|
||||
return $tan !== '' ? $tan : null;
|
||||
}
|
||||
sleep(1);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Polled bis zu $timeoutSec Sekunden auf die Account-Auswahl-Datei. */
|
||||
private static function awaitAccountSelection(string $token, int $timeoutSec): ?array
|
||||
{
|
||||
$file = self::tanDir() . '/' . $token . '.accounts';
|
||||
$start = time();
|
||||
while (time() - $start < $timeoutSec) {
|
||||
if (file_exists($file)) {
|
||||
$raw = (string)@file_get_contents($file);
|
||||
@unlink($file);
|
||||
$decoded = json_decode($raw, true);
|
||||
return is_array($decoded) ? array_values(array_map('strval', $decoded)) : null;
|
||||
}
|
||||
sleep(1);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private function createBatch(): int
|
||||
{
|
||||
Database::execute(
|
||||
'INSERT INTO bank_import_batches (source_id, filename, row_count)
|
||||
VALUES (?, ?, 0)',
|
||||
[$this->sourceId, 'fints-' . date('Y-m-d')]
|
||||
);
|
||||
return (int)Database::lastInsertId();
|
||||
}
|
||||
|
||||
private function updateBatchCount(int $batchId, int $count): void
|
||||
{
|
||||
Database::execute(
|
||||
'UPDATE bank_import_batches SET row_count = ? WHERE id = ?',
|
||||
[$count, $batchId]
|
||||
);
|
||||
}
|
||||
|
||||
private function updateLastFetched(): void
|
||||
{
|
||||
Database::execute(
|
||||
'UPDATE bank_sources SET last_fetched_at = NOW() WHERE id = ?',
|
||||
[$this->sourceId]
|
||||
);
|
||||
}
|
||||
|
||||
private function externalIdExists(string $externalId): bool
|
||||
{
|
||||
$row = Database::fetchOne(
|
||||
'SELECT id FROM bank_transactions WHERE external_id = ? LIMIT 1',
|
||||
[$externalId]
|
||||
);
|
||||
return $row !== false && $row !== null;
|
||||
}
|
||||
|
||||
private function insertRow(int $batchId, array $data): void
|
||||
{
|
||||
Database::execute(
|
||||
'INSERT INTO bank_transactions
|
||||
(batch_id, buchungstag, wertstellung, vorgang, buchungstext, wert, raw_line, external_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[
|
||||
$batchId,
|
||||
$data['buchungstag'],
|
||||
$data['wertstellung'],
|
||||
$data['vorgang'],
|
||||
$data['buchungstext'],
|
||||
$data['wert'],
|
||||
$data['raw_line'] ?? null,
|
||||
$data['external_id'],
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
311
src/Services/PayPalNvpFetchService.php
Normal file
311
src/Services/PayPalNvpFetchService.php
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Steuerkram\Services;
|
||||
|
||||
use Steuerkram\Database;
|
||||
|
||||
/**
|
||||
* PayPal Classic NVP API – TransactionSearch
|
||||
* Credentials: API-Benutzer (app_name), API-Kennwort (client_id), Signature (secret)
|
||||
*/
|
||||
class PayPalNvpFetchService
|
||||
{
|
||||
private string $endpoint;
|
||||
private string $apiUser;
|
||||
private string $apiPwd;
|
||||
private string $signature;
|
||||
private int $sourceId;
|
||||
private array $source;
|
||||
|
||||
/** @var callable */
|
||||
private $log;
|
||||
|
||||
private const VERSION = '204';
|
||||
|
||||
public function __construct(array $source, callable $log)
|
||||
{
|
||||
$sandbox = (bool)($source['sandbox'] ?? false);
|
||||
$this->endpoint = $sandbox
|
||||
? 'https://api-3t.sandbox.paypal.com/nvp'
|
||||
: 'https://api-3t.paypal.com/nvp';
|
||||
$this->apiUser = $source['app_name'] ?? '';
|
||||
$this->apiPwd = $source['client_id'] ?? '';
|
||||
$this->signature = $source['secret'] ?? '';
|
||||
$this->sourceId = (int)$source['id'];
|
||||
$this->source = $source;
|
||||
$this->log = $log;
|
||||
}
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
($this->log)('Nutze PayPal Classic NVP API');
|
||||
($this->log)('Endpunkt: ' . $this->endpoint);
|
||||
|
||||
$maskedUser = $this->apiUser
|
||||
? substr($this->apiUser, 0, 6) . '…'
|
||||
: '(leer)';
|
||||
($this->log)('API-Benutzer: ' . $maskedUser . ' (' . strlen($this->apiUser) . ' Zeichen)');
|
||||
($this->log)('Kennwort: ' . ($this->apiPwd ? strlen($this->apiPwd) . ' Zeichen' : 'FEHLT'));
|
||||
($this->log)('Signature: ' . ($this->signature ? strlen($this->signature) . ' Zeichen' : 'FEHLT'));
|
||||
|
||||
if (!$this->apiUser || !$this->apiPwd || !$this->signature) {
|
||||
($this->log)('FEHLER: Credentials unvollständig.');
|
||||
return;
|
||||
}
|
||||
|
||||
($this->log)('Verbindung wird getestet…');
|
||||
if (!$this->testCredentials()) {
|
||||
return;
|
||||
}
|
||||
($this->log)('Verbindung OK.');
|
||||
|
||||
$batchId = $this->createBatch();
|
||||
($this->log)('Batch #' . $batchId . ' angelegt.');
|
||||
|
||||
$end = new \DateTimeImmutable('today');
|
||||
$lastFetched = $this->source['last_fetched_at'] ?? null;
|
||||
if ($lastFetched) {
|
||||
$start = new \DateTimeImmutable((new \DateTimeImmutable($lastFetched))->format('Y-m-d'));
|
||||
($this->log)('Letzter Abruf: ' . $start->format('d.m.Y') . ' – starte ab 00:00 Uhr dieses Tages');
|
||||
} else {
|
||||
$start = $end->modify('-365 days');
|
||||
($this->log)('Kein vorheriger Abruf – hole die letzten 365 Tage');
|
||||
}
|
||||
$chunk = new \DateInterval('P30D');
|
||||
$cursor = $start;
|
||||
|
||||
$totalNew = 0;
|
||||
$totalSkip = 0;
|
||||
|
||||
while ($cursor < $end) {
|
||||
$chunkEnd = $cursor->add($chunk);
|
||||
if ($chunkEnd > $end) {
|
||||
$chunkEnd = $end;
|
||||
}
|
||||
|
||||
$from = $cursor->format('Y-m-d') . 'T00:00:00Z';
|
||||
$to = $chunkEnd->format('Y-m-d') . 'T23:59:59Z';
|
||||
|
||||
($this->log)(sprintf('Zeitraum: %s → %s', $cursor->format('d.m.Y'), $chunkEnd->format('d.m.Y')));
|
||||
|
||||
$rows = $this->fetchTransactions($from, $to);
|
||||
($this->log)(' → ' . count($rows) . ' Transaktionen gefunden');
|
||||
|
||||
foreach ($rows as $tx) {
|
||||
$extId = $tx['external_id'];
|
||||
if ($this->externalIdExists($extId)) {
|
||||
$totalSkip++;
|
||||
continue;
|
||||
}
|
||||
$this->insertRow($batchId, $tx);
|
||||
$totalNew++;
|
||||
($this->log)(sprintf(
|
||||
' + %s %s %s €',
|
||||
$extId,
|
||||
mb_substr($tx['buchungstext'], 0, 40),
|
||||
number_format((float)$tx['wert'], 2, ',', '.')
|
||||
));
|
||||
}
|
||||
|
||||
$cursor = $chunkEnd->modify('+1 day');
|
||||
}
|
||||
|
||||
$this->updateBatchCount($batchId, $totalNew);
|
||||
$this->updateLastFetched();
|
||||
|
||||
($this->log)(sprintf('Fertig. %d neu importiert, %d bereits vorhanden.', $totalNew, $totalSkip));
|
||||
}
|
||||
|
||||
// ── NVP-API ──────────────────────────────────────────────────
|
||||
|
||||
private function nvpPost(array $params): ?array
|
||||
{
|
||||
$base = [
|
||||
'USER' => $this->apiUser,
|
||||
'PWD' => $this->apiPwd,
|
||||
'SIGNATURE' => $this->signature,
|
||||
'VERSION' => self::VERSION,
|
||||
];
|
||||
$body = http_build_query(array_merge($base, $params));
|
||||
|
||||
$ch = curl_init($this->endpoint);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $body,
|
||||
CURLOPT_HTTPHEADER => ['Content-Type: application/x-www-form-urlencoded'],
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
]);
|
||||
$response = curl_exec($ch);
|
||||
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($code !== 200 || !$response) {
|
||||
($this->log)('HTTP-Status: ' . $code);
|
||||
return null;
|
||||
}
|
||||
|
||||
parse_str((string)$response, $result);
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function testCredentials(): bool
|
||||
{
|
||||
$result = $this->nvpPost(['METHOD' => 'GetBalance']);
|
||||
if ($result === null) {
|
||||
($this->log)('FEHLER: Keine Antwort vom API-Endpunkt.');
|
||||
return false;
|
||||
}
|
||||
$ack = $result['ACK'] ?? '';
|
||||
if (!in_array($ack, ['Success', 'SuccessWithWarning'], true)) {
|
||||
$msg = $result['L_LONGMESSAGE0'] ?? $result['L_SHORTMESSAGE0'] ?? 'Unbekannt';
|
||||
($this->log)('FEHLER: ' . $msg);
|
||||
($this->log)('ACK: ' . $ack);
|
||||
if (stripos($msg, 'Security header') !== false) {
|
||||
($this->log)('');
|
||||
($this->log)('Ursache: Credentials passen nicht zum Endpunkt.');
|
||||
($this->log)(' → Live-Credentials: Sandbox-Häkchen DEAKTIVIEREN');
|
||||
($this->log)(' → Sandbox-Credentials: Sandbox-Häkchen AKTIVIEREN');
|
||||
($this->log)('');
|
||||
($this->log)('Live-API-Credentials findest du unter:');
|
||||
($this->log)(' paypal.com → Konto → Profil → Mein Verkäuferkonto → API-Zugriff');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @return array<array<string,mixed>> */
|
||||
private function fetchTransactions(string $from, string $to): array
|
||||
{
|
||||
$result = $this->nvpPost([
|
||||
'METHOD' => 'TransactionSearch',
|
||||
'STARTDATE' => $from,
|
||||
'ENDDATE' => $to,
|
||||
'STATUS' => 'All',
|
||||
]);
|
||||
|
||||
if ($result === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$ack = $result['ACK'] ?? '';
|
||||
if (!in_array($ack, ['Success', 'SuccessWithWarning'], true)) {
|
||||
($this->log)(' API-Fehler: ' . ($result['L_LONGMESSAGE0'] ?? $ack));
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
$i = 0;
|
||||
while (isset($result['L_TRANSACTIONID' . $i])) {
|
||||
$txId = $result['L_TRANSACTIONID' . $i];
|
||||
$type = $result['L_TYPE' . $i] ?? '';
|
||||
$status = $result['L_STATUS' . $i] ?? '';
|
||||
$name = trim($result['L_NAME' . $i] ?? '');
|
||||
$email = trim($result['L_EMAIL' . $i] ?? '');
|
||||
$amt = (float)($result['L_AMT' . $i] ?? 0);
|
||||
$net = isset($result['L_NETAMT' . $i])
|
||||
? (float)$result['L_NETAMT' . $i]
|
||||
: $amt;
|
||||
$dateRaw = $result['L_TIMESTAMP' . $i] ?? '';
|
||||
$date = $dateRaw
|
||||
? (new \DateTimeImmutable($dateRaw))->format('Y-m-d')
|
||||
: date('Y-m-d');
|
||||
|
||||
// 0-€-Einträge (Agreements, Subscriptions) überspringen
|
||||
if ($net == 0.0 && $amt == 0.0) {
|
||||
$i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($name && $email) {
|
||||
$buchungstext = $name . ' <' . $email . '>';
|
||||
} elseif ($email) {
|
||||
$buchungstext = $email;
|
||||
} elseif ($name) {
|
||||
$buchungstext = $name;
|
||||
} else {
|
||||
$buchungstext = 'PayPal ' . $type;
|
||||
}
|
||||
|
||||
$rows[] = [
|
||||
'external_id' => $txId,
|
||||
'buchungstag' => $date,
|
||||
'wertstellung' => $date,
|
||||
'vorgang' => $type,
|
||||
'buchungstext' => $buchungstext,
|
||||
'wert' => $net,
|
||||
'raw_line' => json_encode([
|
||||
'id' => $txId,
|
||||
'type' => $type,
|
||||
'status' => $status,
|
||||
'amt' => $amt,
|
||||
'net' => $net,
|
||||
]),
|
||||
];
|
||||
|
||||
$i++;
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
// ── Datenbankoperationen ─────────────────────────────────────
|
||||
|
||||
private function createBatch(): int
|
||||
{
|
||||
Database::execute(
|
||||
'INSERT INTO bank_import_batches (source_id, filename, row_count)
|
||||
VALUES (?, ?, 0)',
|
||||
[$this->sourceId, 'paypal-nvp-' . date('Y-m-d')]
|
||||
);
|
||||
$row = Database::fetchOne('SELECT LAST_INSERT_ID() AS id');
|
||||
return (int)$row['id'];
|
||||
}
|
||||
|
||||
private function updateBatchCount(int $batchId, int $count): void
|
||||
{
|
||||
Database::execute(
|
||||
'UPDATE bank_import_batches SET row_count = ? WHERE id = ?',
|
||||
[$count, $batchId]
|
||||
);
|
||||
}
|
||||
|
||||
private function updateLastFetched(): void
|
||||
{
|
||||
Database::execute(
|
||||
'UPDATE bank_sources SET last_fetched_at = NOW() WHERE id = ?',
|
||||
[$this->sourceId]
|
||||
);
|
||||
}
|
||||
|
||||
private function externalIdExists(string $externalId): bool
|
||||
{
|
||||
$row = Database::fetchOne(
|
||||
'SELECT id FROM bank_transactions WHERE external_id = ? LIMIT 1',
|
||||
[$externalId]
|
||||
);
|
||||
return $row !== false && $row !== null;
|
||||
}
|
||||
|
||||
private function insertRow(int $batchId, array $data): void
|
||||
{
|
||||
Database::execute(
|
||||
'INSERT INTO bank_transactions
|
||||
(batch_id, buchungstag, wertstellung, vorgang, buchungstext, wert, raw_line, external_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[
|
||||
$batchId,
|
||||
$data['buchungstag'],
|
||||
$data['wertstellung'],
|
||||
$data['vorgang'],
|
||||
$data['buchungstext'],
|
||||
$data['wert'],
|
||||
$data['raw_line'] ?? null,
|
||||
$data['external_id'],
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
683
src/Views/bank/fints.php
Normal file
683
src/Views/bank/fints.php
Normal file
|
|
@ -0,0 +1,683 @@
|
|||
<?php
|
||||
use Steuerkram\View;
|
||||
$h = fn($v) => View::h($v);
|
||||
$kTextClr = fn(string $hex): string => (0.299*hexdec(substr($hex,1,2))+0.587*hexdec(substr($hex,3,2))+0.114*hexdec(substr($hex,5,2))) > 128 ? '#1e293b' : '#ffffff';
|
||||
|
||||
// Verbindungsdaten als JSON für JS bereitstellen.
|
||||
// PINs und API-Secrets werden bewusst NICHT mitgeschickt – im Formular blanko, leer = unverändert.
|
||||
$connectionsJson = json_encode(array_map(fn($c) => [
|
||||
'id' => (int)$c['id'],
|
||||
'bezeichnung' => $c['bezeichnung'],
|
||||
'type' => $c['type'],
|
||||
'provider_id' => (int)$c['provider_id'],
|
||||
'provider_name' => $c['provider_name'],
|
||||
'verbindungstyp' => $c['verbindungstyp'],
|
||||
'kuerzel' => $c['kuerzel'] ?? '',
|
||||
'farbe' => $c['farbe'] ?? '#6366f1',
|
||||
'app_name' => $c['app_name'] ?? '',
|
||||
'sandbox' => (bool)($c['sandbox'] ?? false),
|
||||
'blz' => $c['blz'] ?? '',
|
||||
'fints_url' => $c['fints_url'] ?? '',
|
||||
'benutzerkennung' => $c['benutzerkennung'] ?? '',
|
||||
'account_filter' => $c['account_filter'] ?? null,
|
||||
], $connections));
|
||||
?>
|
||||
|
||||
<?php if (empty($connections)): ?>
|
||||
<div class="card">
|
||||
<div class="card-body" style="text-align:center;padding:3rem;color:var(--color-muted)">
|
||||
Noch keine Direktverbindungen konfiguriert. Klicke auf <strong>NEU</strong>, um eine hinzuzufügen.
|
||||
</div>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:1rem">
|
||||
<?php foreach ($connections as $conn): ?>
|
||||
<div class="card btn-run-connection" data-id="<?= (int)$conn['id'] ?>" style="margin:0;cursor:pointer" title="Abruf starten">
|
||||
<div class="card-body">
|
||||
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:.5rem">
|
||||
<div>
|
||||
<div style="display:flex;align-items:center;gap:.5rem;flex-wrap:wrap">
|
||||
<?php if (!empty($conn['kuerzel'])): ?>
|
||||
<?php $kf = $conn['farbe'] ?? '#6366f1'; ?>
|
||||
<span class="kuerzel-badge" style="background:<?= $h($kf) ?>;color:<?= $kTextClr($kf) ?>"><?= $h($conn['kuerzel']) ?></span>
|
||||
<?php endif; ?>
|
||||
<div style="font-weight:600;font-size:1rem"><?= $h($conn['bezeichnung']) ?></div>
|
||||
</div>
|
||||
<div class="text-muted text-sm" style="margin-top:.2rem"><?= $h($conn['provider_name']) ?></div>
|
||||
<span class="badge <?= $conn['verbindungstyp'] === 'API' ? 'badge-blue' : 'badge-green' ?>" style="margin-top:.5rem">
|
||||
<?= $h($conn['verbindungstyp']) ?>
|
||||
</span>
|
||||
<div class="text-muted text-sm" style="margin-top:.6rem">
|
||||
Abruf: <?= $conn['last_fetched_at'] ? $h(View::formatDate($conn['last_fetched_at'])) : '<kein>' ?>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button"
|
||||
class="btn-edit-connection"
|
||||
data-id="<?= (int)$conn['id'] ?>"
|
||||
style="background:none;border:none;cursor:pointer;color:var(--color-muted);padding:.2rem;line-height:1;flex-shrink:0"
|
||||
title="Bearbeiten">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Modal: Neue Verbindung -->
|
||||
<div id="modal-new-connection"
|
||||
style="display:none;position:fixed;inset:0;z-index:1000;background:rgba(0,0,0,.4);align-items:center;justify-content:center">
|
||||
<div style="background:#fff;border-radius:var(--radius);box-shadow:0 8px 32px rgba(0,0,0,.15);width:100%;max-width:480px;padding:1.5rem">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1.25rem">
|
||||
<h2 style="margin:0;font-size:1.125rem">Neue Direktverbindung</h2>
|
||||
<button type="button" id="btn-modal-close"
|
||||
style="background:none;border:none;font-size:1.25rem;cursor:pointer;color:var(--color-muted)">×</button>
|
||||
</div>
|
||||
|
||||
<form method="post" action="/bank/direkt">
|
||||
<div class="form-group">
|
||||
<label for="fc-provider">Bank / Anbieter</label>
|
||||
<select id="fc-provider" name="provider_id" required>
|
||||
<option value="">Bitte wählen…</option>
|
||||
<?php foreach ($providers as $p): ?>
|
||||
<option value="<?= (int)$p['id'] ?>"
|
||||
data-typ="<?= $h($p['verbindungstyp']) ?>">
|
||||
<?= $h($p['name']) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
<option value="__new__" data-typ="FinTS">+ Neue Bank…</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div id="fc-new-bank-wrap" style="display:none">
|
||||
<div class="form-group">
|
||||
<label for="fc-new-bank-name">Name der neuen Bank</label>
|
||||
<input type="text" id="fc-new-bank-name" name="new_bank_name"
|
||||
placeholder="z. B. Sparkasse Musterstadt" maxlength="100">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Verbindungstyp</label>
|
||||
<div style="display:flex;gap:1rem;padding:.25rem 0">
|
||||
<label style="display:flex;align-items:center;gap:.4rem;font-weight:normal;cursor:pointer">
|
||||
<input type="radio" name="new_bank_typ" value="FinTS" checked style="width:auto;margin:0">
|
||||
<span>FinTS</span>
|
||||
</label>
|
||||
<label style="display:flex;align-items:center;gap:.4rem;font-weight:normal;cursor:pointer">
|
||||
<input type="radio" name="new_bank_typ" value="API" style="width:auto;margin:0">
|
||||
<span>API</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="text-muted text-sm">FinTS = klassischer Bank-Abruf via PIN. API = REST/NVP-Schnittstelle (wie PayPal).</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="fc-bezeichnung">Bezeichnung</label>
|
||||
<input type="text" id="fc-bezeichnung" name="bezeichnung" required
|
||||
placeholder="z. B. Geschäftskonto PayPal">
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:.75rem;align-items:flex-end">
|
||||
<div class="form-group" style="flex:1;margin-bottom:0">
|
||||
<label for="fc-kuerzel">Kürzel (Badge)</label>
|
||||
<input type="text" id="fc-kuerzel" name="kuerzel" maxlength="10" placeholder="PP">
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom:0">
|
||||
<label for="fc-farbe">Farbe</label>
|
||||
<input type="color" id="fc-farbe" name="farbe" value="#6366f1" style="width:48px;height:36px;padding:2px;cursor:pointer">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="api-fields" style="display:none">
|
||||
<div class="form-group">
|
||||
<label for="fc-app-name">API-Benutzer</label>
|
||||
<input type="text" id="fc-app-name" name="app_name" autocomplete="off">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="fc-client-id">API-Kennwort</label>
|
||||
<input type="password" id="fc-client-id" name="client_id" autocomplete="new-password">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="fc-secret">Signature (Secret)</label>
|
||||
<input type="password" id="fc-secret" name="secret" autocomplete="new-password">
|
||||
</div>
|
||||
<div class="form-group" style="display:flex;align-items:center;gap:.5rem">
|
||||
<input type="checkbox" id="fc-sandbox" name="sandbox" value="1" style="width:auto;margin:0">
|
||||
<label for="fc-sandbox" style="margin:0;font-weight:normal;cursor:pointer">Sandbox-Modus (Testumgebung)</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="fints-fields" style="display:none">
|
||||
<div class="form-group">
|
||||
<label for="fc-blz">Bankleitzahl (BLZ)</label>
|
||||
<input type="text" id="fc-blz" name="blz" maxlength="11" placeholder="z. B. 12030000">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="fc-benutzerkennung">Benutzerkennung / Anmeldename</label>
|
||||
<input type="text" id="fc-benutzerkennung" name="benutzerkennung" autocomplete="off">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="fc-pin">PIN</label>
|
||||
<input type="password" id="fc-pin" name="pin" autocomplete="new-password">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="fc-fints-url">FinTS-Server-URL <span class="text-muted text-sm">(optional, wird sonst über BLZ ermittelt)</span></label>
|
||||
<input type="url" id="fc-fints-url" name="fints_url" placeholder="https://...">
|
||||
</div>
|
||||
<div class="text-muted text-sm">Die Konto-Auswahl wird beim ersten Abruf abgefragt.</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;justify-content:flex-end;gap:.5rem;margin-top:1rem">
|
||||
<button type="button" id="btn-modal-cancel" class="btn btn-secondary">Abbrechen</button>
|
||||
<button type="submit" class="btn btn-primary">Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lösch-Formular (außerhalb des Modals, damit keine verschachtelten Forms entstehen) -->
|
||||
<form id="form-edit-delete" method="post" action="" style="display:none"></form>
|
||||
|
||||
<!-- Modal: Verbindung bearbeiten -->
|
||||
<div id="modal-edit-connection"
|
||||
style="display:none;position:fixed;inset:0;z-index:1000;background:rgba(0,0,0,.4);align-items:center;justify-content:center">
|
||||
<div style="background:#fff;border-radius:var(--radius);box-shadow:0 8px 32px rgba(0,0,0,.15);width:100%;max-width:480px;padding:1.5rem">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1.25rem">
|
||||
<h2 style="margin:0;font-size:1.125rem">Verbindung bearbeiten</h2>
|
||||
<button type="button" id="btn-edit-close"
|
||||
style="background:none;border:none;font-size:1.25rem;cursor:pointer;color:var(--color-muted)">×</button>
|
||||
</div>
|
||||
|
||||
<form id="form-edit-connection" method="post" action="">
|
||||
<div class="form-group">
|
||||
<label>Bank / Anbieter</label>
|
||||
<div id="edit-provider-name" style="padding:.4rem 0;font-weight:500"></div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="edit-bezeichnung">Bezeichnung</label>
|
||||
<input type="text" id="edit-bezeichnung" name="bezeichnung" required>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:.75rem;align-items:flex-end">
|
||||
<div class="form-group" style="flex:1;margin-bottom:0">
|
||||
<label for="edit-kuerzel">Kürzel (Badge)</label>
|
||||
<input type="text" id="edit-kuerzel" name="kuerzel" maxlength="10" placeholder="PP">
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom:0">
|
||||
<label for="edit-farbe">Farbe</label>
|
||||
<input type="color" id="edit-farbe" name="farbe" value="#6366f1" style="width:48px;height:36px;padding:2px;cursor:pointer">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="edit-api-fields" style="display:none">
|
||||
<div class="form-group">
|
||||
<label for="edit-app-name">API-Benutzer</label>
|
||||
<input type="text" id="edit-app-name" name="app_name" autocomplete="off">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="edit-client-id">API-Kennwort</label>
|
||||
<input type="password" id="edit-client-id" name="client_id" autocomplete="new-password"
|
||||
placeholder="Leer = unverändert behalten">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="edit-secret">Signature (Secret)</label>
|
||||
<input type="password" id="edit-secret" name="secret" autocomplete="new-password"
|
||||
placeholder="Leer = unverändert behalten">
|
||||
</div>
|
||||
<div class="form-group" style="display:flex;align-items:center;gap:.5rem">
|
||||
<input type="checkbox" id="edit-sandbox" name="sandbox" value="1" style="width:auto;margin:0">
|
||||
<label for="edit-sandbox" style="margin:0;font-weight:normal;cursor:pointer">Sandbox-Modus (Testumgebung)</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="edit-fints-fields" style="display:none">
|
||||
<div class="form-group">
|
||||
<label for="edit-blz">Bankleitzahl (BLZ)</label>
|
||||
<input type="text" id="edit-blz" name="blz" maxlength="11">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="edit-benutzerkennung">Benutzerkennung / Anmeldename</label>
|
||||
<input type="text" id="edit-benutzerkennung" name="benutzerkennung" autocomplete="off">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="edit-pin">PIN</label>
|
||||
<input type="password" id="edit-pin" name="pin" autocomplete="new-password"
|
||||
placeholder="Leer = unverändert behalten">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="edit-fints-url">FinTS-Server-URL <span class="text-muted text-sm">(optional)</span></label>
|
||||
<input type="url" id="edit-fints-url" name="fints_url" placeholder="https://...">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Aktive Konten</label>
|
||||
<div id="edit-account-list" class="text-sm" style="padding:.4rem 0;color:#475569">–</div>
|
||||
<label style="display:flex;align-items:center;gap:.5rem;font-weight:normal;cursor:pointer;margin-top:.4rem">
|
||||
<input type="checkbox" id="edit-reset-accounts" name="reset_account_filter" value="1" style="width:auto;margin:0">
|
||||
<span>Auswahl zurücksetzen (beim nächsten Abruf neu fragen)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:1rem">
|
||||
<button type="button" id="btn-edit-delete" class="btn btn-danger btn-sm">Löschen</button>
|
||||
<div style="display:flex;gap:.5rem">
|
||||
<button type="button" id="btn-edit-cancel" class="btn btn-secondary">Abbrechen</button>
|
||||
<button type="submit" class="btn btn-primary">Speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal: Konto-Auswahl bei mehreren Konten -->
|
||||
<div id="modal-account-select"
|
||||
style="display:none;position:fixed;inset:0;z-index:1100;background:rgba(0,0,0,.4);align-items:center;justify-content:center">
|
||||
<div style="background:#fff;border-radius:var(--radius);box-shadow:0 8px 32px rgba(0,0,0,.15);width:100%;max-width:480px;padding:1.5rem;display:flex;flex-direction:column;gap:.75rem">
|
||||
<h2 style="margin:0;font-size:1.125rem">Konten auswählen</h2>
|
||||
<div class="text-muted text-sm">Wähle die Konten, die regelmäßig importiert werden sollen. Die Auswahl wird gespeichert und kann später im Bearbeiten-Dialog zurückgesetzt werden.</div>
|
||||
<div id="account-list" style="display:flex;flex-direction:column;gap:.4rem;max-height:280px;overflow-y:auto;padding:.25rem"></div>
|
||||
<div id="account-error" class="text-sm" style="color:#dc2626;display:none"></div>
|
||||
<div style="display:flex;justify-content:flex-end;gap:.5rem">
|
||||
<button type="button" id="btn-account-cancel" class="btn btn-secondary">Abbrechen</button>
|
||||
<button type="button" id="btn-account-submit" class="btn btn-primary">Auswahl speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal: TAN-Eingabe (photoTAN/chipTAN) -->
|
||||
<div id="modal-tan-input"
|
||||
style="display:none;position:fixed;inset:0;z-index:1100;background:rgba(0,0,0,.4);align-items:center;justify-content:center">
|
||||
<div style="background:#fff;border-radius:var(--radius);box-shadow:0 8px 32px rgba(0,0,0,.15);width:100%;max-width:480px;padding:1.5rem;display:flex;flex-direction:column;gap:.75rem">
|
||||
<h2 style="margin:0;font-size:1.125rem">TAN eingeben</h2>
|
||||
<div id="tan-context" class="text-muted text-sm"></div>
|
||||
<div id="tan-challenge" class="text-sm" style="white-space:pre-wrap"></div>
|
||||
<div id="tan-image-wrap" style="text-align:center"></div>
|
||||
<div id="tan-medium" class="text-muted text-sm"></div>
|
||||
<div class="form-group" style="margin:0">
|
||||
<label for="tan-input">TAN</label>
|
||||
<input type="text" id="tan-input" autocomplete="off" inputmode="numeric"
|
||||
style="font-size:1.25rem;letter-spacing:.25em;text-align:center">
|
||||
</div>
|
||||
<div id="tan-error" class="text-sm" style="color:#dc2626;display:none"></div>
|
||||
<div style="display:flex;justify-content:flex-end;gap:.5rem">
|
||||
<button type="button" id="btn-tan-cancel" class="btn btn-secondary">Abbrechen</button>
|
||||
<button type="button" id="btn-tan-submit" class="btn btn-primary">TAN absenden</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal: Abruf-Log -->
|
||||
<div id="modal-run-log"
|
||||
style="display:none;position:fixed;inset:0;z-index:1000;background:rgba(0,0,0,.4);align-items:center;justify-content:center">
|
||||
<div style="background:#fff;border-radius:var(--radius);box-shadow:0 8px 32px rgba(0,0,0,.15);width:100%;max-width:680px;padding:1.5rem;display:flex;flex-direction:column;gap:1rem">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||
<h2 style="margin:0;font-size:1.125rem" id="run-log-title">Abruf läuft…</h2>
|
||||
<span id="run-log-spinner" style="font-size:.8rem;color:var(--color-muted)">⏳ wird abgerufen…</span>
|
||||
</div>
|
||||
<div id="run-log-area"
|
||||
style="background:#0f172a;color:#e2e8f0;font-family:monospace;font-size:.8rem;
|
||||
padding:1rem;border-radius:var(--radius);height:340px;overflow-y:auto;white-space:pre-wrap;line-height:1.5">
|
||||
</div>
|
||||
<div style="display:flex;justify-content:flex-end">
|
||||
<button type="button" id="btn-run-close" class="btn btn-primary" disabled>Schließen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const connections = <?= $connectionsJson ?>;
|
||||
|
||||
// ── NEU-Modal ────────────────────────────────────────────────
|
||||
const modalNew = document.getElementById('modal-new-connection');
|
||||
const btnNew = document.getElementById('btn-new-connection');
|
||||
const btnClose = document.getElementById('btn-modal-close');
|
||||
const btnCancel = document.getElementById('btn-modal-cancel');
|
||||
const selectNew = document.getElementById('fc-provider');
|
||||
const apiNew = document.getElementById('api-fields');
|
||||
const fintsNew = document.getElementById('fints-fields');
|
||||
const newBankWrap = document.getElementById('fc-new-bank-wrap');
|
||||
const newBankInput = document.getElementById('fc-new-bank-name');
|
||||
|
||||
function openNew() { modalNew.style.display = 'flex'; }
|
||||
function closeNew() { modalNew.style.display = 'none'; }
|
||||
|
||||
if (btnNew) btnNew.addEventListener('click', openNew);
|
||||
if (btnClose) btnClose.addEventListener('click', closeNew);
|
||||
if (btnCancel) btnCancel.addEventListener('click', closeNew);
|
||||
modalNew.addEventListener('click', (e) => { if (e.target === modalNew) closeNew(); });
|
||||
|
||||
function syncTypFields(typ) {
|
||||
apiNew.style.display = (typ === 'API') ? 'block' : 'none';
|
||||
fintsNew.style.display = (typ === 'FinTS') ? 'block' : 'none';
|
||||
}
|
||||
|
||||
selectNew.addEventListener('change', () => {
|
||||
const opt = selectNew.options[selectNew.selectedIndex];
|
||||
const isNew = opt.value === '__new__';
|
||||
newBankWrap.style.display = isNew ? 'block' : 'none';
|
||||
newBankInput.required = isNew;
|
||||
if (isNew) {
|
||||
const selected = modalNew.querySelector('input[name="new_bank_typ"]:checked');
|
||||
syncTypFields(selected ? selected.value : 'FinTS');
|
||||
} else {
|
||||
syncTypFields(opt.dataset.typ);
|
||||
}
|
||||
});
|
||||
|
||||
modalNew.querySelectorAll('input[name="new_bank_typ"]').forEach(radio => {
|
||||
radio.addEventListener('change', () => {
|
||||
if (selectNew.value === '__new__') {
|
||||
syncTypFields(radio.value);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── Bearbeiten-Modal ─────────────────────────────────────────
|
||||
const modalEdit = document.getElementById('modal-edit-connection');
|
||||
const btnEditClose = document.getElementById('btn-edit-close');
|
||||
const btnEditCancel= document.getElementById('btn-edit-cancel');
|
||||
const formEdit = document.getElementById('form-edit-connection');
|
||||
const formDelete = document.getElementById('form-edit-delete');
|
||||
const btnEditDelete = document.getElementById('btn-edit-delete');
|
||||
const editName = document.getElementById('edit-provider-name');
|
||||
const editBez = document.getElementById('edit-bezeichnung');
|
||||
const editApiFields = document.getElementById('edit-api-fields');
|
||||
const editFintsFields = document.getElementById('edit-fints-fields');
|
||||
const editAppName = document.getElementById('edit-app-name');
|
||||
const editClientId = document.getElementById('edit-client-id');
|
||||
const editSecret = document.getElementById('edit-secret');
|
||||
const editSandbox = document.getElementById('edit-sandbox');
|
||||
const editKuerzel = document.getElementById('edit-kuerzel');
|
||||
const editFarbe = document.getElementById('edit-farbe');
|
||||
const editBlz = document.getElementById('edit-blz');
|
||||
const editBenutzer = document.getElementById('edit-benutzerkennung');
|
||||
const editPin = document.getElementById('edit-pin');
|
||||
const editFintsUrl = document.getElementById('edit-fints-url');
|
||||
const editAccountList = document.getElementById('edit-account-list');
|
||||
const editResetAcc = document.getElementById('edit-reset-accounts');
|
||||
|
||||
function closeEdit() { modalEdit.style.display = 'none'; }
|
||||
|
||||
if (btnEditClose) btnEditClose.addEventListener('click', closeEdit);
|
||||
if (btnEditCancel) btnEditCancel.addEventListener('click', closeEdit);
|
||||
modalEdit.addEventListener('click', (e) => { if (e.target === modalEdit) closeEdit(); });
|
||||
|
||||
btnEditDelete.addEventListener('click', () => {
|
||||
if (!confirm('Verbindung wirklich löschen?')) return;
|
||||
formDelete.submit();
|
||||
});
|
||||
|
||||
// ── Abruf-Modal ──────────────────────────────────────────────
|
||||
const modalRun = document.getElementById('modal-run-log');
|
||||
const runLogArea = document.getElementById('run-log-area');
|
||||
const runTitle = document.getElementById('run-log-title');
|
||||
const runSpinner = document.getElementById('run-log-spinner');
|
||||
const btnRunClose= document.getElementById('btn-run-close');
|
||||
let activeEs = null;
|
||||
|
||||
function closeRun() {
|
||||
if (activeEs) { activeEs.close(); activeEs = null; }
|
||||
modalRun.style.display = 'none';
|
||||
runLogArea.textContent = '';
|
||||
}
|
||||
|
||||
btnRunClose.addEventListener('click', closeRun);
|
||||
modalRun.addEventListener('click', (e) => { if (e.target === modalRun && !btnRunClose.disabled) closeRun(); });
|
||||
|
||||
// ── Konto-Auswahl-Modal ──────────────────────────────────────
|
||||
const modalAcc = document.getElementById('modal-account-select');
|
||||
const accList = document.getElementById('account-list');
|
||||
const accError = document.getElementById('account-error');
|
||||
const btnAccSubmit = document.getElementById('btn-account-submit');
|
||||
const btnAccCancel = document.getElementById('btn-account-cancel');
|
||||
let currentAccToken = null;
|
||||
|
||||
function openAccountModal(payload) {
|
||||
currentAccToken = payload.token;
|
||||
accList.innerHTML = '';
|
||||
accError.style.display = 'none';
|
||||
payload.accounts.forEach((acc) => {
|
||||
const row = document.createElement('label');
|
||||
row.style.display = 'flex';
|
||||
row.style.alignItems = 'center';
|
||||
row.style.gap = '.5rem';
|
||||
row.style.cursor = 'pointer';
|
||||
row.style.padding = '.35rem .5rem';
|
||||
row.style.border = '1px solid #e2e8f0';
|
||||
row.style.borderRadius = 'var(--radius)';
|
||||
const cb = document.createElement('input');
|
||||
cb.type = 'checkbox'; cb.value = acc.id; cb.checked = true;
|
||||
cb.style.margin = '0';
|
||||
const label = document.createElement('span');
|
||||
label.textContent = acc.label;
|
||||
row.appendChild(cb);
|
||||
row.appendChild(label);
|
||||
accList.appendChild(row);
|
||||
});
|
||||
modalAcc.style.display = 'flex';
|
||||
}
|
||||
function closeAccountModal() {
|
||||
modalAcc.style.display = 'none';
|
||||
currentAccToken = null;
|
||||
}
|
||||
|
||||
btnAccCancel.addEventListener('click', () => {
|
||||
closeAccountModal();
|
||||
if (activeEs) { activeEs.close(); activeEs = null; }
|
||||
runTitle.textContent = 'Abruf abgebrochen';
|
||||
runSpinner.textContent = '✗ abgebrochen';
|
||||
btnRunClose.disabled = false;
|
||||
});
|
||||
|
||||
btnAccSubmit.addEventListener('click', async () => {
|
||||
const ids = Array.from(accList.querySelectorAll('input[type=checkbox]:checked')).map(cb => cb.value);
|
||||
if (ids.length === 0) {
|
||||
accError.textContent = 'Mindestens ein Konto auswählen.';
|
||||
accError.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
btnAccSubmit.disabled = true;
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append('token', currentAccToken);
|
||||
form.append('ids', JSON.stringify(ids));
|
||||
const res = await fetch('/bank/direkt/' + currentSourceId + '/accounts', { method: 'POST', body: form });
|
||||
const data = await res.json();
|
||||
if (!res.ok || data.error) {
|
||||
accError.textContent = data.error || 'Übermittlung fehlgeschlagen.';
|
||||
accError.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
closeAccountModal();
|
||||
} catch (e) {
|
||||
accError.textContent = 'Netzwerkfehler: ' + e.message;
|
||||
accError.style.display = 'block';
|
||||
} finally {
|
||||
btnAccSubmit.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
// ── TAN-Modal ────────────────────────────────────────────────
|
||||
const modalTan = document.getElementById('modal-tan-input');
|
||||
const tanContext = document.getElementById('tan-context');
|
||||
const tanChallenge = document.getElementById('tan-challenge');
|
||||
const tanImageWrap = document.getElementById('tan-image-wrap');
|
||||
const tanMedium = document.getElementById('tan-medium');
|
||||
const tanInput = document.getElementById('tan-input');
|
||||
const tanError = document.getElementById('tan-error');
|
||||
const btnTanSubmit = document.getElementById('btn-tan-submit');
|
||||
const btnTanCancel = document.getElementById('btn-tan-cancel');
|
||||
let currentSourceId = null;
|
||||
let currentTanToken = null;
|
||||
|
||||
function openTanModal(payload) {
|
||||
currentTanToken = payload.token;
|
||||
tanContext.textContent = payload.context ? 'Konto: ' + payload.context : '';
|
||||
tanChallenge.textContent = payload.challenge || '';
|
||||
tanMedium.textContent = payload.mediumName ? 'TAN-Medium: ' + payload.mediumName : '';
|
||||
tanImageWrap.innerHTML = '';
|
||||
if (payload.imageData && payload.imageMime) {
|
||||
const img = new Image();
|
||||
img.src = 'data:' + payload.imageMime + ';base64,' + payload.imageData;
|
||||
img.style.maxWidth = '320px';
|
||||
tanImageWrap.appendChild(img);
|
||||
}
|
||||
tanInput.value = '';
|
||||
tanError.style.display = 'none';
|
||||
modalTan.style.display = 'flex';
|
||||
setTimeout(() => tanInput.focus(), 50);
|
||||
}
|
||||
function closeTanModal() {
|
||||
modalTan.style.display = 'none';
|
||||
currentTanToken = null;
|
||||
}
|
||||
|
||||
btnTanCancel.addEventListener('click', () => {
|
||||
closeTanModal();
|
||||
if (activeEs) { activeEs.close(); activeEs = null; }
|
||||
runTitle.textContent = 'Abruf abgebrochen';
|
||||
runSpinner.textContent = '✗ abgebrochen';
|
||||
btnRunClose.disabled = false;
|
||||
});
|
||||
|
||||
async function submitTan() {
|
||||
const tan = tanInput.value.trim();
|
||||
if (!tan) { tanError.textContent = 'Bitte TAN eingeben.'; tanError.style.display = 'block'; return; }
|
||||
if (!currentSourceId || !currentTanToken) return;
|
||||
btnTanSubmit.disabled = true;
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append('token', currentTanToken);
|
||||
form.append('tan', tan);
|
||||
const res = await fetch('/bank/direkt/' + currentSourceId + '/tan', { method: 'POST', body: form });
|
||||
const data = await res.json();
|
||||
if (!res.ok || data.error) {
|
||||
tanError.textContent = data.error || 'TAN-Übermittlung fehlgeschlagen.';
|
||||
tanError.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
closeTanModal();
|
||||
} catch (e) {
|
||||
tanError.textContent = 'Netzwerkfehler: ' + e.message;
|
||||
tanError.style.display = 'block';
|
||||
} finally {
|
||||
btnTanSubmit.disabled = false;
|
||||
}
|
||||
}
|
||||
btnTanSubmit.addEventListener('click', submitTan);
|
||||
tanInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') submitTan(); });
|
||||
|
||||
document.querySelectorAll('.btn-run-connection').forEach(card => {
|
||||
card.addEventListener('click', (e) => {
|
||||
if (e.target.closest('.btn-edit-connection')) return;
|
||||
|
||||
const id = card.dataset.id;
|
||||
currentSourceId = id;
|
||||
runLogArea.textContent = '';
|
||||
runTitle.textContent = 'Abruf läuft…';
|
||||
runSpinner.textContent = '⏳ wird abgerufen…';
|
||||
btnRunClose.disabled = true;
|
||||
modalRun.style.display = 'flex';
|
||||
|
||||
if (activeEs) activeEs.close();
|
||||
activeEs = new EventSource('/bank/direkt/' + id + '/run');
|
||||
|
||||
activeEs.addEventListener('log', (ev) => {
|
||||
runLogArea.textContent += ev.data + '\n';
|
||||
runLogArea.scrollTop = runLogArea.scrollHeight;
|
||||
});
|
||||
|
||||
activeEs.addEventListener('tan-required', (ev) => {
|
||||
try {
|
||||
openTanModal(JSON.parse(ev.data));
|
||||
} catch (e) {
|
||||
runLogArea.textContent += '\n[TAN-Anforderung konnte nicht geparst werden]\n';
|
||||
}
|
||||
});
|
||||
|
||||
activeEs.addEventListener('accounts-required', (ev) => {
|
||||
try {
|
||||
openAccountModal(JSON.parse(ev.data));
|
||||
} catch (e) {
|
||||
runLogArea.textContent += '\n[Konto-Auswahl konnte nicht geparst werden]\n';
|
||||
}
|
||||
});
|
||||
|
||||
activeEs.addEventListener('done', (ev) => {
|
||||
activeEs.close();
|
||||
activeEs = null;
|
||||
runTitle.textContent = ev.data === 'ok' ? 'Abruf abgeschlossen' : 'Abruf mit Fehlern beendet';
|
||||
runSpinner.textContent = ev.data === 'ok' ? '✓ fertig' : '⚠ Fehler';
|
||||
btnRunClose.disabled = false;
|
||||
});
|
||||
|
||||
activeEs.addEventListener('error', () => {
|
||||
activeEs.close();
|
||||
activeEs = null;
|
||||
runLogArea.textContent += '\n[Verbindung unterbrochen]\n';
|
||||
runLogArea.scrollTop = runLogArea.scrollHeight;
|
||||
runTitle.textContent = 'Abruf unterbrochen';
|
||||
runSpinner.textContent = '✗ unterbrochen';
|
||||
btnRunClose.disabled = false;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('.btn-edit-connection').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const id = parseInt(btn.dataset.id, 10);
|
||||
const conn = connections.find(c => c.id === id);
|
||||
if (!conn) return;
|
||||
|
||||
formEdit.action = '/bank/direkt/' + id;
|
||||
formDelete.action = '/bank/direkt/' + id + '/delete';
|
||||
|
||||
editName.textContent = conn.provider_name;
|
||||
editBez.value = conn.bezeichnung;
|
||||
editKuerzel.value = conn.kuerzel || '';
|
||||
editFarbe.value = conn.farbe || '#6366f1';
|
||||
|
||||
const isApi = conn.type === 'api';
|
||||
const isFints = conn.type === 'fints';
|
||||
|
||||
editApiFields.style.display = isApi ? 'block' : 'none';
|
||||
editFintsFields.style.display = isFints ? 'block' : 'none';
|
||||
|
||||
if (isApi) {
|
||||
editAppName.value = conn.app_name || '';
|
||||
editClientId.value = '';
|
||||
editSecret.value = '';
|
||||
editSandbox.checked = conn.sandbox === true;
|
||||
}
|
||||
if (isFints) {
|
||||
editBlz.value = conn.blz || '';
|
||||
editBenutzer.value = conn.benutzerkennung || '';
|
||||
editPin.value = '';
|
||||
editFintsUrl.value = conn.fints_url || '';
|
||||
editResetAcc.checked = false;
|
||||
let accountsText = '–';
|
||||
if (conn.account_filter) {
|
||||
try {
|
||||
const arr = JSON.parse(conn.account_filter);
|
||||
if (Array.isArray(arr) && arr.length > 0) {
|
||||
accountsText = arr.join(', ');
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
} else {
|
||||
accountsText = '(wird beim nächsten Abruf abgefragt)';
|
||||
}
|
||||
editAccountList.textContent = accountsText;
|
||||
}
|
||||
|
||||
modalEdit.style.display = 'flex';
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
|
@ -294,7 +294,7 @@
|
|||
|
||||
// "Formate bearbeiten"-Link: Suggestion in Session speichern, dann navigieren
|
||||
document.addEventListener('click', async (e) => {
|
||||
const link = e.target.closest('a[href="/settings/bankimport"]');
|
||||
const link = e.target.closest('a[href="/bank/direkt"], a[href="/settings/bankimport"]');
|
||||
if (!link || !suggestedFormat) return;
|
||||
|
||||
e.preventDefault();
|
||||
|
|
@ -305,7 +305,7 @@
|
|||
body: JSON.stringify(suggestedFormat),
|
||||
});
|
||||
} catch (_) {}
|
||||
window.location.href = '/settings/bankimport';
|
||||
window.location.href = link.getAttribute('href');
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ function navActive(string $prefix, string $current): string {
|
|||
</a>
|
||||
<a href="/settings/bankimport" class="<?= navActive('/settings/bankimport', $path) ?>" style="padding-left:2.25rem;font-size:.875rem">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/><line x1="6" y1="15" x2="10" y2="15"/></svg>
|
||||
Bankimport
|
||||
CSV Bankimport Formate
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
</nav>
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
1-260521
|
||||
1-260522
|
||||
|
|
|
|||
75
tests/36_CryptoTest.php
Normal file
75
tests/36_CryptoTest.php
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Steuerkram\Config;
|
||||
use Steuerkram\Services\Crypto;
|
||||
|
||||
/**
|
||||
* Test 36: Crypto – symmetrische Verschlüsselung für sensible Felder.
|
||||
*/
|
||||
class CryptoTest extends TestCase
|
||||
{
|
||||
protected string $testName = 'Test 36: Crypto – AES-256-GCM Roundtrip und Idempotenz';
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
// Test-Key setzen und Config neu laden, damit Config::get('app_key') greift.
|
||||
$oldKey = $_ENV['APP_KEY'] ?? null;
|
||||
$_ENV['APP_KEY'] = str_repeat('a1', 32); // 64 Hex-Zeichen
|
||||
Config::load();
|
||||
|
||||
try {
|
||||
// 1. Roundtrip
|
||||
$plain = 'streng-geheimes-passwort!';
|
||||
$cipher = Crypto::encrypt($plain);
|
||||
$this->assert($cipher !== $plain, 'Verschlüsselter Wert unterscheidet sich vom Klartext');
|
||||
$this->assert(str_starts_with($cipher, 'enc:v1:'), 'Verschlüsselter Wert hat Magic-Prefix enc:v1:');
|
||||
$this->assertEquals($plain, Crypto::decrypt($cipher), 'Entschlüsselung liefert Originaltext');
|
||||
|
||||
// 2. Leerer String
|
||||
$this->assertEquals('', Crypto::encrypt(''), 'Leerer Eingabewert wird leer zurückgegeben (encrypt)');
|
||||
$this->assertEquals('', Crypto::decrypt(''), 'Leerer Eingabewert wird leer zurückgegeben (decrypt)');
|
||||
|
||||
// 3. Idempotenz: bereits verschlüsselt → unverändert
|
||||
$cipher2 = Crypto::encrypt($cipher);
|
||||
$this->assertEquals($cipher, $cipher2, 'encrypt() auf bereits verschlüsselten Wert ist idempotent');
|
||||
|
||||
// 4. Migration: Klartext-Wert ohne Prefix → unverändert durch decrypt()
|
||||
$this->assertEquals('roher-text', Crypto::decrypt('roher-text'), 'decrypt() reicht Klartext (ohne Prefix) unverändert durch');
|
||||
|
||||
// 5. Zwei Verschlüsselungen desselben Klartextes ergeben verschiedene Cipher (zufälliges IV)
|
||||
$a = Crypto::encrypt('hallo');
|
||||
$b = Crypto::encrypt('hallo');
|
||||
$this->assert($a !== $b, 'Zwei encrypt()-Aufrufe ergeben verschiedene Cipher (IV ist zufällig)');
|
||||
$this->assertEquals('hallo', Crypto::decrypt($a), 'Cipher A entschlüsselt korrekt');
|
||||
$this->assertEquals('hallo', Crypto::decrypt($b), 'Cipher B entschlüsselt korrekt');
|
||||
|
||||
// 6. Falscher Key → Exception
|
||||
$_ENV['APP_KEY'] = str_repeat('b2', 32);
|
||||
Config::load();
|
||||
$threw = false;
|
||||
try {
|
||||
Crypto::decrypt($cipher);
|
||||
} catch (\RuntimeException) {
|
||||
$threw = true;
|
||||
}
|
||||
$this->assert($threw, 'decrypt() mit falschem Schlüssel wirft RuntimeException');
|
||||
|
||||
// 7. isEncrypted-Helper
|
||||
$_ENV['APP_KEY'] = str_repeat('a1', 32);
|
||||
Config::load();
|
||||
$this->assert(Crypto::isEncrypted($cipher), 'isEncrypted erkennt verschlüsselten Wert');
|
||||
$this->assert(!Crypto::isEncrypted('plain'), 'isEncrypted erkennt Klartext nicht als verschlüsselt');
|
||||
$this->assert(!Crypto::isEncrypted(''), 'isEncrypted ist false für leeren String');
|
||||
} finally {
|
||||
// Original-Key wiederherstellen
|
||||
if ($oldKey === null) {
|
||||
unset($_ENV['APP_KEY']);
|
||||
} else {
|
||||
$_ENV['APP_KEY'] = $oldKey;
|
||||
}
|
||||
Config::load();
|
||||
}
|
||||
}
|
||||
}
|
||||
129
tests/37_BankSourceTest.php
Normal file
129
tests/37_BankSourceTest.php
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Steuerkram\Config;
|
||||
use Steuerkram\Database;
|
||||
use Steuerkram\Models\BankSource;
|
||||
|
||||
/**
|
||||
* Test 37: BankSource – CRUD über alle drei Typen, Verschlüsselungs-Roundtrip,
|
||||
* Dedup über external_id und FK-Integrität bank_import_batches.source_id.
|
||||
*/
|
||||
class BankSourceTest extends TestCase
|
||||
{
|
||||
protected string $testName = 'Test 37: BankSource – CSV/API/FinTS Roundtrip & Verschlüsselung';
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
// Test-Key setzen (falls noch nicht aus .env)
|
||||
$oldKey = $_ENV['APP_KEY'] ?? null;
|
||||
if (empty(Config::get('app_key', ''))) {
|
||||
$_ENV['APP_KEY'] = str_repeat('c3', 32);
|
||||
Config::load();
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. CSV-Quelle anlegen und wieder lesen
|
||||
$csvId = BankSource::create([
|
||||
'type' => 'csv',
|
||||
'bezeichnung' => 'TestCSV',
|
||||
'kuerzel' => 'TC',
|
||||
'farbe' => '#abcdef',
|
||||
'skip_rows' => 3,
|
||||
'has_header_row' => 1,
|
||||
'delimiter' => ';',
|
||||
'enclosure' => '"',
|
||||
'col_buchungstag' => 0,
|
||||
'col_wertstellung' => 1,
|
||||
'col_vorgang' => 2,
|
||||
'col_buchungstext' => 3,
|
||||
'col_wert' => 4,
|
||||
'date_format' => 'd.m.Y',
|
||||
'decimal_separator' => ',',
|
||||
'thousands_separator' => '.',
|
||||
]);
|
||||
$this->assertGreaterThan(0, $csvId, 'CSV-Quelle wurde angelegt');
|
||||
|
||||
$csv = BankSource::find($csvId);
|
||||
$this->assertNotEmpty($csv, 'CSV-Quelle gefunden');
|
||||
$this->assertEquals('csv', $csv['type'], 'CSV-Quelle hat type=csv');
|
||||
$this->assertEquals('TestCSV', $csv['name'], 'BankSource liefert Alias name = bezeichnung');
|
||||
$this->assertEquals('TC', $csv['kuerzel'], 'Kürzel korrekt');
|
||||
|
||||
// 2. API-Quelle mit verschlüsselten Credentials
|
||||
$providerApi = Database::fetchOne("SELECT id FROM bank_providers WHERE verbindungstyp='API' LIMIT 1");
|
||||
if ($providerApi) {
|
||||
$apiId = BankSource::create([
|
||||
'type' => 'api',
|
||||
'provider_id' => (int)$providerApi['id'],
|
||||
'bezeichnung' => 'TestAPI',
|
||||
'app_name' => 'app-user',
|
||||
'client_id' => 'secret-key-1',
|
||||
'secret' => 'super-signature',
|
||||
'sandbox' => 0,
|
||||
]);
|
||||
$this->assertGreaterThan(0, $apiId, 'API-Quelle wurde angelegt');
|
||||
|
||||
// DB-Spalte muss verschlüsselt sein
|
||||
$raw = Database::fetchOne('SELECT client_id_enc, secret_enc FROM bank_sources WHERE id = ?', [$apiId]);
|
||||
$this->assert(str_starts_with($raw['client_id_enc'], 'enc:v1:'), 'client_id_enc ist verschlüsselt');
|
||||
$this->assert(str_starts_with($raw['secret_enc'], 'enc:v1:'), 'secret_enc ist verschlüsselt');
|
||||
|
||||
// BankSource::find liefert entschlüsselt
|
||||
$api = BankSource::find($apiId);
|
||||
$this->assertEquals('secret-key-1', $api['client_id'], 'client_id entschlüsselt korrekt');
|
||||
$this->assertEquals('super-signature', $api['secret'], 'secret entschlüsselt korrekt');
|
||||
}
|
||||
|
||||
// 3. FinTS-Quelle mit PIN
|
||||
$providerFints = Database::fetchOne("SELECT id FROM bank_providers WHERE verbindungstyp='FinTS' LIMIT 1");
|
||||
if ($providerFints) {
|
||||
$fintsId = BankSource::create([
|
||||
'type' => 'fints',
|
||||
'provider_id' => (int)$providerFints['id'],
|
||||
'bezeichnung' => 'TestFinTS',
|
||||
'blz' => '12030000',
|
||||
'benutzerkennung' => 'kundennummer',
|
||||
'pin' => 'g3h31m',
|
||||
]);
|
||||
$this->assertGreaterThan(0, $fintsId, 'FinTS-Quelle wurde angelegt');
|
||||
|
||||
$raw = Database::fetchOne('SELECT pin_enc FROM bank_sources WHERE id = ?', [$fintsId]);
|
||||
$this->assert(str_starts_with($raw['pin_enc'], 'enc:v1:'), 'pin_enc ist verschlüsselt');
|
||||
|
||||
$fints = BankSource::find($fintsId);
|
||||
$this->assertEquals('g3h31m', $fints['pin'], 'PIN entschlüsselt korrekt');
|
||||
$this->assertEquals('12030000', $fints['blz'], 'BLZ korrekt');
|
||||
|
||||
// 4. updateField mit Klartext-PIN → muss verschlüsselt landen
|
||||
BankSource::updateField($fintsId, 'pin', 'neue-pin');
|
||||
$raw2 = Database::fetchOne('SELECT pin_enc FROM bank_sources WHERE id = ?', [$fintsId]);
|
||||
$this->assert(str_starts_with($raw2['pin_enc'], 'enc:v1:'), 'updateField verschlüsselt pin');
|
||||
$fints2 = BankSource::find($fintsId);
|
||||
$this->assertEquals('neue-pin', $fints2['pin'], 'pin nach updateField wieder entschlüsselbar');
|
||||
}
|
||||
|
||||
// 5. Filter all('csv') liefert nur CSV-Einträge
|
||||
$allCsv = BankSource::all('csv');
|
||||
foreach ($allCsv as $row) {
|
||||
$this->assertEquals('csv', $row['type'], 'all(csv) liefert nur csv-Einträge');
|
||||
}
|
||||
|
||||
// 6. Batch mit source_id anlegen funktioniert (FK)
|
||||
Database::execute(
|
||||
'INSERT INTO bank_import_batches (source_id, filename, row_count) VALUES (?, ?, 0)',
|
||||
[$csvId, 'test-batch.csv']
|
||||
);
|
||||
$bid = (int)Database::lastInsertId();
|
||||
$this->assertGreaterThan(0, $bid, 'Batch mit source_id angelegt');
|
||||
} finally {
|
||||
if ($oldKey === null) {
|
||||
unset($_ENV['APP_KEY']);
|
||||
} else {
|
||||
$_ENV['APP_KEY'] = $oldKey;
|
||||
}
|
||||
Config::load();
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue