739 lines
27 KiB
PHP
739 lines
27 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace Steuerkram\Controllers;
|
||
|
||
use Steuerkram\Auth;
|
||
use Steuerkram\Config;
|
||
use Steuerkram\Models\BankProvider;
|
||
use Steuerkram\Models\BankSource;
|
||
use Steuerkram\Models\BankTransaction;
|
||
use Steuerkram\Controllers\InvoiceController;
|
||
use Steuerkram\Models\Beleg;
|
||
use Steuerkram\Models\Invoice;
|
||
use Steuerkram\Services\BankImportParser;
|
||
use Steuerkram\Services\FinTsFetchService;
|
||
use Steuerkram\Services\PayPalNvpFetchService;
|
||
use Steuerkram\Services\SsrfGuard;
|
||
use Steuerkram\View;
|
||
|
||
class BankController extends BaseController
|
||
{
|
||
private const ALLOWED_EXT = ['csv', 'txt', 'xlsx'];
|
||
|
||
public function index(array $params): void
|
||
{
|
||
$filter = $_GET;
|
||
if (!isset($filter['status'])) {
|
||
$filter['status'] = 'pending';
|
||
}
|
||
|
||
$page = max(1, (int)($_GET['page'] ?? 1));
|
||
$result = BankTransaction::all($filter, $page);
|
||
$formats = BankSource::all('csv');
|
||
|
||
$view = new View();
|
||
$view->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' => '<a href="/bank/direkt" class="btn btn-primary">Importieren</a>',
|
||
]);
|
||
}
|
||
|
||
public function uploadForm(array $params): void
|
||
{
|
||
$formats = BankSource::all('csv');
|
||
$view = new View();
|
||
$view->render('layout/base.php', [
|
||
'pageTitle' => 'Bank-Buchungen importieren',
|
||
'topbarActions' => '<a href="/bank/direkt" class="btn btn-secondary btn-sm">Formate bearbeiten</a>',
|
||
'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' => '<button type="button" class="btn btn-primary" id="btn-new-connection" title="Neue Direktverbindung anlegen" style="display:inline-flex;align-items:center;justify-content:center;padding:.36rem;border-radius:50%;width:1.8rem;height:1.8rem">'
|
||
. '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">'
|
||
. '<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>'
|
||
. '</svg>'
|
||
. '</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;
|
||
}
|
||
|
||
// 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]);
|
||
}
|
||
|
||
}
|