Steuerkram/src/Controllers/BankController.php

695 lines
26 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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__') {
$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.');
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';
$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;
}
$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) {
$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'] ?? '');
$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;
}
}
$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'];
}
}
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.');
}
/** 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]);
}
}