render('layout/base.php', [ 'pageTitle' => 'Bank buchen', 'content' => $this->renderView('bank/index', [ 'rows' => $result['rows'], 'total' => $result['total'], 'pages' => $result['pages'], 'page' => $page, 'filter' => $filter, 'formats' => $formats, ]), 'topbarActions' => 'Importieren', ]); } public function uploadForm(array $params): void { $formats = BankSource::all('csv'); $view = new View(); $view->render('layout/base.php', [ 'pageTitle' => 'Bank-Buchungen importieren', 'topbarActions' => 'Formate bearbeiten', 'content' => $this->renderView('bank/upload', [ 'formats' => $formats, 'errors' => [], ]), ]); } public function detectFormat(array $params): void { header('Content-Type: application/json'); $file = $_FILES['bank_file'] ?? null; if (empty($file['tmp_name']) || $file['error'] !== UPLOAD_ERR_OK) { echo json_encode(['error' => 'Upload fehlgeschlagen.']); return; } $origName = $file['name'] ?? 'upload'; $ext = strtolower(pathinfo($origName, PATHINFO_EXTENSION)); if (!in_array($ext, self::ALLOWED_EXT, true)) { echo json_encode(['error' => 'Nicht erlaubtes Dateiformat.']); return; } $formats = BankSource::all('csv'); $parser = new BankImportParser(); $formatsData = []; $matchedIds = []; foreach ($formats as $format) { $id = (int)$format['id']; $peek = $parser->peekRows($file['tmp_name'], $ext, $format); $isMatch = $parser->isFormatMatch($file['tmp_name'], $ext, $format); if ($isMatch) { $matchedIds[] = $id; } $formatsData[$id] = [ 'format' => $format, 'header_row' => $peek['header'], 'first_data' => $peek['first_data'], 'is_match' => $isMatch, ]; } $matchedFormatId = count($matchedIds) === 1 ? $matchedIds[0] : null; $suggestedFormat = null; if ($matchedFormatId === null) { $suggestedFormat = $parser->suggestFormat($file['tmp_name'], $ext); } echo json_encode([ 'matched_format_id' => $matchedFormatId, 'formats_data' => $formatsData, 'filename' => $origName, 'suggested_format' => $suggestedFormat, ], JSON_UNESCAPED_UNICODE); } public function upload(array $params): void { $formatId = (int)($_POST['format_id'] ?? 0); $format = BankSource::find($formatId); if (!$format || $format['type'] !== 'csv') { View::redirect('/bank/upload', 'Unbekanntes Import-Format.', 'error'); } $file = $_FILES['bank_file'] ?? null; if (empty($file['tmp_name']) || $file['error'] !== UPLOAD_ERR_OK) { View::redirect('/bank/upload', 'Datei-Upload fehlgeschlagen.', 'error'); } $origName = $file['name'] ?? 'upload'; $ext = strtolower(pathinfo($origName, PATHINFO_EXTENSION)); if (!in_array($ext, self::ALLOWED_EXT, true)) { View::redirect('/bank/upload', 'Nicht erlaubtes Dateiformat. Erlaubt: CSV, TXT, XLSX.', 'error'); } try { $parser = new BankImportParser(); $rows = $parser->parse($file['tmp_name'], $ext, $format); } catch (\RuntimeException $e) { View::redirect('/bank/upload', 'Fehler beim Parsen: ' . $e->getMessage(), 'error'); } if (empty($rows)) { View::redirect('/bank/upload', 'Keine Buchungszeilen in der Datei gefunden.', 'error'); } $split = BankTransaction::findDuplicates($rows); if (!empty($split['dup'])) { $_SESSION['bank_import_pending'] = [ 'format_id' => $formatId, 'filename' => $origName, 'new_rows' => $split['new'], 'dup_rows' => $split['dup'], ]; $view = new View(); $view->render('layout/base.php', [ 'pageTitle' => 'Dublettenprüfung', 'content' => $this->renderView('bank/upload_confirm', [ 'new_rows' => $split['new'], 'dup_rows' => $split['dup'], 'filename' => $origName, ]), ]); return; } $this->doImport($formatId, $origName, $rows); } public function uploadConfirm(array $params): void { $pending = $_SESSION['bank_import_pending'] ?? null; if (!$pending) { View::redirect('/bank/upload', 'Keine ausstehenden Import-Daten gefunden.', 'error'); } unset($_SESSION['bank_import_pending']); $selectedIdx = array_map('intval', (array)($_POST['dup_idx'] ?? [])); $rows = $pending['new_rows']; foreach ($selectedIdx as $idx) { if (isset($pending['dup_rows'][$idx])) { $rows[] = $pending['dup_rows'][$idx]; } } if (empty($rows)) { View::redirect('/bank', 'Import abgebrochen – keine Buchungen ausgewählt.'); } $this->doImport($pending['format_id'], $pending['filename'], $rows); } private function doImport(int $sourceId, string $filename, array $rows): void { try { $batchId = BankTransaction::importBatch($sourceId, $filename, $rows); } catch (\Throwable $e) { View::redirect('/bank/upload', 'Import fehlgeschlagen: ' . $e->getMessage(), 'error'); } $paired = \Steuerkram\Services\PayPalNvpFetchService::applyBankImportRules($batchId); $msg = count($rows) . ' Buchungen importiert.'; if ($paired > 0) { $msg .= ' ' . $paired . ' PayPal-Gegenbuchung(en) automatisch ignoriert.'; } View::redirect('/bank', $msg); } public function show(array $params): void { $id = (int)($params['id'] ?? 0); $transaction = BankTransaction::find($id); if (!$transaction) { View::redirect('/bank', 'Buchung nicht gefunden.', 'error'); } $linkedBelege = BankTransaction::getLinkedBelege($id); $remainingBetrag = BankTransaction::getRemainingBetrag($id); $offeneBelege = Beleg::getOffeneFuerBankzuordnung(); $view = new View(); $view->render('layout/base.php', [ 'pageTitle' => 'Bankbuchung zuordnen', 'content' => $this->renderView('bank/show', [ 'transaction' => $transaction, 'linkedBelege' => $linkedBelege, 'remainingBetrag' => $remainingBetrag, 'offeneBelege' => $offeneBelege, ]), ]); } public function updateStatus(array $params): void { $id = (int)($params['id'] ?? 0); $status = $_POST['status'] ?? ''; if (!in_array($status, ['offen', 'teilweise', 'gebucht', 'ignoriert'], true)) { View::redirect('/bank', 'Ungültiger Status.', 'error'); } BankTransaction::updateStatus($id, $status); $back = $_SERVER['HTTP_REFERER'] ?? '/bank'; View::redirect($back); } public function linkBeleg(array $params): void { $id = (int)($params['id'] ?? 0); $belegId = (int)($_POST['beleg_id'] ?? 0); $betrag = round((float)str_replace(',', '.', $_POST['alloc_betrag'] ?? ''), 2); if (!$belegId) { View::redirect("/bank/$id", 'Kein Beleg ausgewählt.', 'error'); } if ($betrag <= 0) { View::redirect("/bank/$id", 'Bitte einen gültigen Betrag eingeben.', 'error'); } View::redirect("/belege/$belegId?bank_tx=$id&alloc_betrag=$betrag"); } public function linkInvoice(array $params): void { $id = (int)($params['id'] ?? 0); $invoiceId = (int)($_POST['invoice_id'] ?? 0); $betrag = round((float)str_replace(',', '.', $_POST['alloc_betrag'] ?? ''), 2); if (!$invoiceId) { View::redirect("/bank/$id", 'Keine Rechnung ausgewählt.', 'error'); } $invoice = Invoice::find($invoiceId); if (!$invoice) { View::redirect("/bank/$id", 'Rechnung nicht gefunden.', 'error'); } try { InvoiceController::createBeleg($invoice); $invoice = Invoice::find($invoiceId); } catch (\Exception $e) { View::redirect("/bank/$id", 'Beleg konnte nicht erstellt werden: ' . $e->getMessage(), 'error'); } $belegId = (int)($invoice['beleg_id'] ?? 0); if (!$belegId) { View::redirect("/bank/$id", 'Beleg-Erstellung fehlgeschlagen.', 'error'); } View::redirect("/belege/$belegId?bank_tx=$id&alloc_betrag=$betrag"); } public function unlinkBeleg(array $params): void { $id = (int)($params['id'] ?? 0); $belegId = (int)($_POST['beleg_id'] ?? 0); $redirectTo = $_POST['redirect_to'] ?? ''; BankTransaction::unlinkBeleg($id, $belegId); $safe = (preg_match('#^/[a-zA-Z0-9/_-]#', $redirectTo)) ? $redirectTo : "/bank/$id"; View::redirect($safe, 'Beleg-Verknüpfung entfernt.'); } public function uploadBeleg(array $params): void { $id = (int)($params['id'] ?? 0); $transaction = BankTransaction::find($id); if (!$transaction) { View::redirect('/bank', 'Buchung nicht gefunden.', 'error'); } // Beleg über BelegController-Logik hochladen und verknüpfen $file = $_FILES['beleg_file'] ?? null; if (empty($file['tmp_name']) || $file['error'] !== UPLOAD_ERR_OK) { View::redirect("/bank/$id", 'Datei-Upload fehlgeschlagen.', 'error'); } $allowedMime = [ 'application/pdf' => 'pdf', 'image/png' => 'png', 'image/jpeg' => 'jpg', 'image/tiff' => 'tif', 'application/xml' => 'xml', 'text/xml' => 'xml', ]; $finfo = finfo_open(FILEINFO_MIME_TYPE); $mime = (string)(finfo_file($finfo, $file['tmp_name']) ?: ''); finfo_close($finfo); if (!isset($allowedMime[$mime])) { View::redirect("/bank/$id", 'Nicht erlaubtes Dateiformat.', 'error'); } $ext = $allowedMime[$mime]; $belegNr = Beleg::generateBelegNr(); $year = date('Y', strtotime($transaction['wertstellung'])); $relDir = "{$year}/{$belegNr}"; $absDir = Config::get('upload_dir') . "/{$relDir}"; if (!is_dir($absDir)) mkdir($absDir, 0755, true); $storagePath = "{$relDir}/original.{$ext}"; $absPath = Config::get('upload_dir') . "/{$storagePath}"; move_uploaded_file($file['tmp_name'], $absPath); $hash = hash_file('sha256', $absPath); $belegId = Beleg::create([ 'beleg_nr' => $belegNr, 'filename' => $file['name'], 'storage_path' => $storagePath, 'mime_type' => $mime, 'file_hash' => $hash, 'beleg_datum' => $transaction['wertstellung'], 'belegtyp' => (float)$transaction['wert'] >= 0 ? 'Ausgangsrechnung' : 'Eingangsrechnung', ]); View::redirect("/bank/$id", 'Beleg hochgeladen – bitte jetzt verknüpfen.'); } public function direkt(array $params): void { $connections = array_values(array_filter( BankSource::all(), fn($s) => in_array($s['type'], ['api', 'fints'], true) )); $providers = BankProvider::all(); $view = new View(); $view->render('layout/base.php', [ 'pageTitle' => 'Direktverbindungen', 'topbarActions' => '', 'content' => $this->renderView('bank/fints', [ 'connections' => $connections, 'providers' => $providers, ]), ]); } public function direktStore(array $params): void { $providerRaw = (string)($_POST['provider_id'] ?? ''); $bezeichnung = trim($_POST['bezeichnung'] ?? ''); if ($bezeichnung === '') { View::redirect('/bank/direkt', 'Bitte alle Pflichtfelder ausfüllen.', 'error'); return; } // Neues CSV-Importformat anlegen, wenn Dropdown auf "__csv__" steht. // Provider entfällt hier (provider_id = NULL); Spaltenzuordnung erfolgt // anschließend unter /settings/bankimport. if ($providerRaw === '__csv__') { $this->storeCsvFormat($bezeichnung); return; } $providerId = $this->resolveProviderId($providerRaw); if ($providerId === null) { return; } $provider = BankProvider::find($providerId); if (!$provider) { View::redirect('/bank/direkt', 'Unbekannter Anbieter.', 'error'); return; } $this->storeBankSource($provider, $providerId, $bezeichnung); } private function storeCsvFormat(string $bezeichnung): void { $kuerzel = trim($_POST['kuerzel'] ?? ''); if ($kuerzel === '' || mb_strlen($kuerzel) > 10) { View::redirect('/bank/direkt', 'Bitte ein Kürzel angeben (max. 10 Zeichen).', 'error'); return; } $dateFormats = ['d.m.Y', 'd.m.y', 'Y-m-d', 'd/m/Y', 'm/d/Y', 'Y.m.d', 'd-m-Y']; $delimiter = (string)($_POST['csv_delimiter'] ?? ';'); $enclosure = (string)($_POST['csv_enclosure'] ?? '"'); $dateFmt = (string)($_POST['csv_date_format'] ?? 'd.m.Y'); $decSep = (string)($_POST['csv_decimal_separator'] ?? ','); $thouSep = (string)($_POST['csv_thousands_separator'] ?? '.'); $farbe = (string)($_POST['farbe'] ?? ''); $id = BankSource::create([ 'type' => 'csv', 'bezeichnung' => $bezeichnung, 'kuerzel' => $kuerzel, 'farbe' => preg_match('/^#[0-9a-fA-F]{6}$/', $farbe) ? $farbe : '#6366f1', 'skip_rows' => max(0, min(99, (int)($_POST['csv_skip_rows'] ?? 0))), 'has_header_row' => ($_POST['csv_has_header_row'] ?? '1') === '0' ? 0 : 1, 'delimiter' => mb_strlen($delimiter) === 1 ? $delimiter : ';', 'enclosure' => mb_strlen($enclosure) === 1 ? $enclosure : '"', 'date_format' => in_array($dateFmt, $dateFormats, true) ? $dateFmt : 'd.m.Y', 'decimal_separator' => mb_strlen($decSep) === 1 ? $decSep : ',', 'thousands_separator' => mb_strlen($thouSep) <= 1 ? $thouSep : '.', 'col_buchungstag' => 0, 'col_wertstellung' => 1, 'col_vorgang' => 2, 'col_buchungstext' => 0, 'col_wert' => 4, ]); View::redirect('/settings/bankimport#format-' . $id, 'CSV-Format gespeichert. Spaltenzuordnung bei Bedarf anpassen.'); } /** Neue Bank anlegen, wenn Dropdown auf "__new__" steht, sonst vorhandenen Anbieter übernehmen. */ private function resolveProviderId(string $providerRaw): ?int { if ($providerRaw === '__new__') { $newName = trim((string)($_POST['new_bank_name'] ?? '')); $newTyp = ($_POST['new_bank_typ'] ?? 'FinTS') === 'API' ? 'API' : 'FinTS'; if ($newName === '') { View::redirect('/bank/direkt', 'Bitte den Banknamen angeben.', 'error'); return null; } $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 null; } return $providerId; } private function storeBankSource(array $provider, int $providerId, string $bezeichnung): void { $isApi = $provider['verbindungstyp'] === 'API'; $isFints = $provider['verbindungstyp'] === 'FinTS'; $fintsUrl = $isFints ? (trim($_POST['fints_url'] ?? '') ?: null) : null; if ($fintsUrl !== null) { try { SsrfGuard::assertPublicHttpsUrl($fintsUrl); } catch (\RuntimeException $e) { View::redirect('/bank/direkt', 'Ungültige FinTS-URL: ' . $e->getMessage(), 'error'); return; } } 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' => $fintsUrl, 'benutzerkennung' => $isFints ? (trim($_POST['benutzerkennung'] ?? '') ?: null) : null, 'pin' => $isFints ? (trim($_POST['pin'] ?? '') ?: null) : null, 'tan_mode_id' => $isFints ? (($_POST['tan_mode_id'] ?? '') !== '' ? (int)$_POST['tan_mode_id'] : null) : null, 'account_filter' => null, // Konten werden beim ersten Abruf ausgewählt ]); View::redirect('/bank/direkt', 'Verbindung gespeichert.'); } public function direktRun(array $params): void { Auth::validateCsrfToken((string)($_GET['csrf_token'] ?? '')); $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(); } // Session-Lock freigeben, damit parallele POSTs (TAN-/Konto-Auswahl) // während des Pollings nicht blockieren. if (session_status() === PHP_SESSION_ACTIVE) { session_write_close(); } $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; } $data = $this->buildUpdateData($source, $bezeichnung); if ($data === null) { return; } BankSource::update($id, $data); View::redirect('/bank/direkt', 'Verbindung gespeichert.'); } private function buildUpdateData(array $source, string $bezeichnung): ?array { $isApi = $source['type'] === 'api'; $isFints = $source['type'] === 'fints'; $data = [ 'bezeichnung' => $bezeichnung, 'kuerzel' => trim($_POST['kuerzel'] ?? '') ?: null, 'farbe' => trim($_POST['farbe'] ?? '') ?: '#6366f1', ]; $rangePost = (string)($_POST['fetch_range'] ?? ''); if (in_array($rangePost, BankSource::FETCH_RANGES, true)) { $data['fetch_range'] = $rangePost; } if ($isApi) { $data += $this->buildApiUpdateData($source); } elseif ($isFints) { $fintsData = $this->buildFintsUpdateData($source); if ($fintsData === null) { return null; } $data += $fintsData; } return $data; } private function buildApiUpdateData(array $source): array { $newPwd = trim($_POST['client_id'] ?? ''); $newSecret = trim($_POST['secret'] ?? ''); return [ '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'], ]; } private function buildFintsUpdateData(array $source): ?array { $newPin = trim($_POST['pin'] ?? ''); $fintsUrl = trim($_POST['fints_url'] ?? '') ?: null; if ($fintsUrl !== null) { try { SsrfGuard::assertPublicHttpsUrl($fintsUrl); } catch (\RuntimeException $e) { View::redirect('/bank/direkt', 'Ungültige FinTS-URL: ' . $e->getMessage(), 'error'); return null; } } $data = [ 'blz' => trim($_POST['blz'] ?? '') ?: null, 'fints_url' => $fintsUrl, 'benutzerkennung' => trim($_POST['benutzerkennung'] ?? '') ?: null, 'pin' => $newPin !== '' ? $newPin : $source['pin'], 'tan_mode_id' => ($_POST['tan_mode_id'] ?? '') !== '' ? (int)$_POST['tan_mode_id'] : null, ]; $filterUpdate = BankSource::computeFilterFromPost( $source['account_filter'] ?? null, $_POST ); if ($filterUpdate['update']) { $data['account_filter'] = $filterUpdate['value']; } return $data; } public function direktDelete(array $params): void { $id = (int)($params['id'] ?? 0); BankSource::delete($id); View::redirect('/bank/direkt', 'Verbindung gelöscht.'); } /** Speichert die per Drag-and-Drop geänderte Reihenfolge der Direktverbindungs-Kacheln. */ public function direktOrder(array $params): void { header('Content-Type: application/json'); $raw = $_POST['ids'] ?? '[]'; $ids = is_array($raw) ? $raw : (json_decode((string)$raw, true) ?: []); if (!is_array($ids)) { http_response_code(400); echo json_encode(['error' => 'Ungültige Reihenfolge.']); return; } BankSource::updateOrder(array_map('intval', $ids)); echo json_encode(['ok' => true]); } /** 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]); } }