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]);
}
}