* * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. */ /** * \file bankimport/class/fints.class.php * \ingroup bankimport * \brief Class for FinTS/HBCI bank connection */ // Load Dolibarr's PSR classes first to avoid version conflicts // Dolibarr includes psr/log 1.x which must be loaded before phpFinTS if (defined('DOL_DOCUMENT_ROOT')) { $dolibarrPsrLog = DOL_DOCUMENT_ROOT.'/includes/sabre/psr/log/Psr/Log/LoggerInterface.php'; if (file_exists($dolibarrPsrLog) && !interface_exists('Psr\Log\LoggerInterface', false)) { require_once DOL_DOCUMENT_ROOT.'/includes/sabre/psr/log/Psr/Log/LoggerInterface.php'; require_once DOL_DOCUMENT_ROOT.'/includes/sabre/psr/log/Psr/Log/LoggerTrait.php'; require_once DOL_DOCUMENT_ROOT.'/includes/sabre/psr/log/Psr/Log/AbstractLogger.php'; require_once DOL_DOCUMENT_ROOT.'/includes/sabre/psr/log/Psr/Log/NullLogger.php'; require_once DOL_DOCUMENT_ROOT.'/includes/sabre/psr/log/Psr/Log/LogLevel.php'; } } // Autoload phpFinTS library if available $composerAutoload = dirname(__DIR__).'/vendor/autoload.php'; if (file_exists($composerAutoload)) { require_once $composerAutoload; } use Fhp\FinTs; use Fhp\Options\FinTsOptions; use Fhp\Options\Credentials; use Fhp\Action\GetSEPAAccounts; use Fhp\Action\GetStatementOfAccount; use Fhp\Action\GetStatementOfAccountXML; use Fhp\Action\GetBankStatement; use Fhp\Action\GetElectronicStatement; use Fhp\Model\StatementOfAccount\Statement; use Fhp\Model\StatementOfAccount\Transaction; /** * Class BankImportFinTS * Handles FinTS/HBCI bank connections for retrieving account statements */ class BankImportFinTS { /** * @var DoliDB Database handler */ public $db; /** * @var string Error message */ public $error = ''; /** * @var array Error messages */ public $errors = array(); /** * @var string FinTS Server URL */ private $fintsUrl; /** * @var string BLZ (Bankleitzahl) */ private $blz; /** * @var string Username */ private $username; /** * @var string PIN (decrypted) */ private $pin; /** * @var string IBAN */ private $iban; /** * @var string FinTS Product ID */ private $productId; /** * @var FinTs|null FinTS instance */ private $fints = null; /** * @var array Available TAN modes */ public $tanModes = array(); /** * @var mixed Selected TAN mode */ public $selectedTanMode = null; /** * @var string TAN challenge (for display to user) */ public $tanChallenge = ''; /** * @var mixed Current action requiring TAN */ private $pendingAction = null; /** * Constructor * * @param DoliDB $db Database handler */ public function __construct($db) { global $conf; $this->db = $db; // Load configuration $this->fintsUrl = getDolGlobalString('BANKIMPORT_FINTS_URL'); $this->blz = getDolGlobalString('BANKIMPORT_FINTS_BLZ'); $this->username = getDolGlobalString('BANKIMPORT_FINTS_USERNAME'); // Decrypt PIN $encryptedPin = getDolGlobalString('BANKIMPORT_FINTS_PIN'); if (!empty($encryptedPin)) { $this->pin = dolDecrypt($encryptedPin); } $this->iban = getDolGlobalString('BANKIMPORT_FINTS_IBAN'); $this->productId = getDolGlobalString('BANKIMPORT_FINTS_PRODUCT_ID'); // Default product ID if not set // Use Hibiscus/Jameica registered product ID if (empty($this->productId)) { // Official Hibiscus product ID (registered with Deutsche Kreditwirtschaft) $this->productId = '36792786FA12F235F04647689'; } } /** * Check if configuration is complete * * @return bool True if all required fields are set */ public function isConfigured() { return !empty($this->fintsUrl) && !empty($this->blz) && !empty($this->username) && !empty($this->pin) && !empty($this->iban); } /** * Check if phpFinTS library is available * * @return bool True if library is loaded */ public function isLibraryAvailable() { return class_exists('Fhp\FinTs'); } /** * Initialize FinTS connection * * @return int 1 if success, -1 if error */ public function initConnection() { if (!$this->isConfigured()) { $this->error = 'Configuration incomplete'; return -1; } if (!$this->isLibraryAvailable()) { $this->error = 'phpFinTS library not found. Run: composer install'; return -1; } try { $options = new FinTsOptions(); $options->url = $this->fintsUrl; $options->bankCode = $this->blz; $options->productName = $this->productId; $options->productVersion = '1.0'; $credentials = Credentials::create($this->username, $this->pin); $this->fints = FinTs::new($options, $credentials); return 1; } catch (Exception $e) { $this->error = $e->getMessage(); return -1; } } /** * Test the FinTS connection * * @return int 1 if success, -1 if error */ public function testConnection() { if (!$this->isConfigured()) { $this->error = 'Configuration incomplete'; return -1; } // Check URL format if (!filter_var($this->fintsUrl, FILTER_VALIDATE_URL)) { $this->error = 'Invalid FinTS URL format'; return -1; } // Check BLZ format (8 digits) if (!preg_match('/^\d{8}$/', $this->blz)) { $this->error = 'Invalid BLZ format (must be 8 digits)'; return -1; } // Check if library is available if (!$this->isLibraryAvailable()) { $this->error = 'phpFinTS library not installed. Run: cd '.dirname(__DIR__).' && composer install'; return -1; } // Try to initialize connection $result = $this->initConnection(); if ($result < 0) { return -1; } try { // Try to get TAN modes (this tests the connection) $this->tanModes = $this->fints->getTanModes(); return 1; } catch (Exception $e) { $this->error = $e->getMessage(); return -1; } } /** * Get available TAN modes * * @return array Array of TAN modes */ public function getTanModes() { if ($this->fints === null) { $result = $this->initConnection(); if ($result < 0) { return array(); } } try { $modes = $this->fints->getTanModes(); $result = array(); foreach ($modes as $mode) { $result[] = array( 'id' => $mode->getId(), 'name' => $mode->getName(), 'isDecoupled' => $mode->isDecoupled(), ); } return $result; } catch (Exception $e) { $this->error = $e->getMessage(); return array(); } } /** * Login with selected TAN mode * * @param int $tanModeId TAN mode ID to use * @return int 1 if success, 0 if TAN required, -1 if error */ public function login($tanModeId = null) { dol_syslog("BankImport FinTS: login() gestartet, tanModeId=".($tanModeId ?: 'auto'), LOG_DEBUG); if ($this->fints === null) { dol_syslog("BankImport FinTS: Keine bestehende Verbindung, rufe initConnection() auf", LOG_DEBUG); $result = $this->initConnection(); dol_syslog("BankImport FinTS: initConnection() Ergebnis=".$result, LOG_DEBUG); if ($result < 0) { dol_syslog("BankImport FinTS: initConnection() FEHLGESCHLAGEN: ".$this->error, LOG_ERR); return -1; } } try { // Get and select TAN mode dol_syslog("BankImport FinTS: Rufe getTanModes() ab...", LOG_DEBUG); $tanModes = $this->fints->getTanModes(); dol_syslog("BankImport FinTS: ".count($tanModes)." TAN-Modi verfuegbar", LOG_DEBUG); foreach ($tanModes as $mode) { dol_syslog("BankImport FinTS: TAN-Modus: ID=".$mode->getId()." Name=".$mode->getName() ." Decoupled=".($mode->isDecoupled() ? 'JA' : 'NEIN'), LOG_DEBUG); } if ($tanModeId !== null) { foreach ($tanModes as $mode) { if ($mode->getId() == $tanModeId) { $this->selectedTanMode = $mode; break; } } } else { // Auto-select decoupled mode (SecureGo Plus) if available foreach ($tanModes as $mode) { if ($mode->isDecoupled()) { $this->selectedTanMode = $mode; break; } } // Fallback to first mode if ($this->selectedTanMode === null && !empty($tanModes)) { $this->selectedTanMode = $tanModes[0]; } } if ($this->selectedTanMode === null) { $this->error = 'No TAN mode available'; dol_syslog("BankImport FinTS: KEIN TAN-Modus ausgewaehlt", LOG_ERR); return -1; } dol_syslog("BankImport FinTS: Ausgewaehlter TAN-Modus: ID=".$this->selectedTanMode->getId() ." Name=".$this->selectedTanMode->getName() ." Decoupled=".($this->selectedTanMode->isDecoupled() ? 'JA' : 'NEIN'), LOG_DEBUG); $this->fints->selectTanMode($this->selectedTanMode); dol_syslog("BankImport FinTS: TAN-Modus gesetzt, starte fints->login()...", LOG_DEBUG); // Perform login $login = $this->fints->login(); dol_syslog("BankImport FinTS: fints->login() abgeschlossen, needsTan=".($login->needsTan() ? 'JA' : 'NEIN'), LOG_DEBUG); if ($login->needsTan()) { $this->pendingAction = $login; $tanRequest = $login->getTanRequest(); $this->tanChallenge = $tanRequest->getChallenge(); dol_syslog("BankImport FinTS: TAN benoetigt, Challenge-Laenge=".strlen($this->tanChallenge), LOG_DEBUG); return 0; // TAN required } dol_syslog("BankImport FinTS: Login erfolgreich (kein TAN benoetigt)", LOG_DEBUG); return 1; } catch (Exception $e) { $this->error = $e->getMessage(); dol_syslog("BankImport FinTS: Login EXCEPTION: ".$e->getMessage()."\n".$e->getTraceAsString(), LOG_ERR); return -1; } } /** * Get pending action for serialization * * @return mixed */ public function getPendingAction() { return $this->pendingAction; } /** * Set pending action after deserialization * * @param mixed $action The pending action * @return void */ public function setPendingAction($action) { $this->pendingAction = $action; } /** * Check if decoupled TAN (SecureGo Plus) has been confirmed * * @return int 1 if confirmed, 0 if still waiting, -1 if error */ public function checkDecoupledTan() { if ($this->fints === null) { $this->error = 'FinTS instance is null'; dol_syslog("BankImport: checkDecoupledTan - FinTS instance is null", LOG_ERR); return -1; } if ($this->pendingAction === null) { $this->error = 'Pending action is null'; dol_syslog("BankImport: checkDecoupledTan - Pending action is null", LOG_ERR); return -1; } dol_syslog("BankImport: checkDecoupledTan - Checking TAN status, action type: ".get_class($this->pendingAction), LOG_DEBUG); try { $done = $this->fints->checkDecoupledSubmission($this->pendingAction); if ($done) { dol_syslog("BankImport: checkDecoupledTan - TAN confirmed!", LOG_DEBUG); $this->pendingAction = null; return 1; } dol_syslog("BankImport: checkDecoupledTan - Still waiting for TAN", LOG_DEBUG); return 0; } catch (Exception $e) { $this->error = $e->getMessage(); dol_syslog("BankImport: checkDecoupledTan - Exception: ".$e->getMessage(), LOG_ERR); return -1; } } /** * Get bank supported parameters (for diagnostics) * * @return array Array of supported parameter segments */ public function getBankParameters() { dol_syslog("BankImport FinTS: getBankParameters() aufgerufen", LOG_DEBUG); if ($this->fints === null) { dol_syslog("BankImport FinTS: getBankParameters() - fints===null", LOG_WARNING); return array('error' => 'Not connected'); } try { // Use reflection to access protected BPD $reflection = new \ReflectionClass($this->fints); $bpdProperty = $reflection->getProperty('bpd'); $bpdProperty->setAccessible(true); $bpd = $bpdProperty->getValue($this->fints); if ($bpd === null) { dol_syslog("BankImport FinTS: getBankParameters() - BPD ist null (noch kein Login?)", LOG_WARNING); return array('error' => 'BPD not available'); } dol_syslog("BankImport FinTS: BPD-Klasse: ".get_class($bpd), LOG_DEBUG); // Get parameters property from BPD $bpdReflection = new \ReflectionClass($bpd); $paramsProperty = $bpdReflection->getProperty('parameters'); $paramsProperty->setAccessible(true); $parameters = $paramsProperty->getValue($bpd); dol_syslog("BankImport FinTS: BPD enthaelt ".count($parameters)." Segment-Typen", LOG_DEBUG); $result = array(); foreach ($parameters as $type => $versions) { $result[$type] = array_keys($versions); } dol_syslog("BankImport FinTS: BPD-Segmente: ".implode(', ', array_keys($result)), LOG_DEBUG); return $result; } catch (Exception $e) { dol_syslog("BankImport FinTS: getBankParameters() Exception: ".$e->getMessage(), LOG_ERR); return array('error' => $e->getMessage()); } } /** * Submit TAN for pending action * * @param string $tan The TAN entered by user * @return int 1 if success, -1 if error */ public function submitTan($tan) { if ($this->fints === null || $this->pendingAction === null) { $this->error = 'No pending action'; return -1; } try { $this->fints->submitTan($this->pendingAction, $tan); $this->pendingAction = null; return 1; } catch (Exception $e) { $this->error = $e->getMessage(); return -1; } } /** * Get SEPA accounts * * @return array|int Array of accounts or -1 on error */ public function getAccounts() { if ($this->fints === null) { $this->error = 'Not connected'; return -1; } try { $getAccounts = GetSEPAAccounts::create(); $this->fints->execute($getAccounts); if ($getAccounts->needsTan()) { $this->pendingAction = $getAccounts; $tanRequest = $getAccounts->getTanRequest(); $this->tanChallenge = $tanRequest->getChallenge(); return 0; // TAN required } $accounts = $getAccounts->getAccounts(); $result = array(); foreach ($accounts as $account) { $result[] = array( 'iban' => $account->getIban(), 'bic' => $account->getBic(), 'accountNumber' => $account->getAccountNumber(), 'blz' => $account->getBlz(), ); } return $result; } catch (Exception $e) { $this->error = $e->getMessage(); return -1; } } /** * Maximum days per fetch request (banks often limit this) */ const MAX_DAYS_PER_FETCH = 30; /** * Fetch account statements * * @param int $dateFrom Start date (timestamp) * @param int $dateTo End date (timestamp) * @return array|int Array of transactions or -1 on error, 0 if TAN required */ public function fetchStatements($dateFrom = 0, $dateTo = 0) { if (!$this->isConfigured()) { $this->error = 'Configuration incomplete'; return -1; } if ($this->fints === null) { $this->error = 'Not connected. Call login() first.'; return -1; } // Default: last 30 days if (empty($dateFrom)) { $dateFrom = strtotime('-30 days'); } if (empty($dateTo)) { $dateTo = time(); } // Check if date range exceeds limit - if so, fetch in chunks $daysDiff = ($dateTo - $dateFrom) / 86400; if ($daysDiff > self::MAX_DAYS_PER_FETCH) { dol_syslog("BankImport: Date range {$daysDiff} days exceeds limit, fetching in chunks", LOG_DEBUG); return $this->fetchStatementsInChunks($dateFrom, $dateTo); } return $this->fetchStatementsForPeriod($dateFrom, $dateTo); } /** * Fetch statements in chunks for long date ranges * * @param int $dateFrom Start date (timestamp) * @param int $dateTo End date (timestamp) * @return array|int Array of transactions or -1 on error, 0 if TAN required */ protected function fetchStatementsInChunks($dateFrom, $dateTo) { $allTransactions = array(); $lastBalance = array(); $chunkDays = self::MAX_DAYS_PER_FETCH; $oldestDateReached = null; // Start from the most recent date and go backwards // This ensures we get the newest transactions even if old ones aren't available $currentTo = $dateTo; while ($currentTo > $dateFrom) { $currentFrom = max($currentTo - ($chunkDays * 86400), $dateFrom); dol_syslog("BankImport: Fetching chunk from ".date('Y-m-d', $currentFrom)." to ".date('Y-m-d', $currentTo), LOG_DEBUG); $result = $this->fetchStatementsForPeriod($currentFrom, $currentTo); if ($result === 0) { // TAN required - save progress and return $_SESSION['fints_chunk_progress'] = array( 'transactions' => $allTransactions, 'currentTo' => $currentFrom, 'dateFrom' => $dateFrom ); return 0; } if ($result < 0) { // Error fetching this chunk // If we already have transactions from newer chunks, return those with a note if (!empty($allTransactions)) { dol_syslog("BankImport: Chunk failed for older dates, returning ".count($allTransactions)." transactions from recent period", LOG_WARNING); return array( 'transactions' => $allTransactions, 'balance' => $lastBalance, 'partial' => true, 'oldestDate' => $oldestDateReached, 'info' => 'Ältere Daten (vor '.date('d.m.Y', $currentTo).') sind bei der Bank nicht mehr verfügbar.' ); } return -1; } if (is_array($result)) { $newTransactions = $result['transactions'] ?? array(); $allTransactions = array_merge($allTransactions, $newTransactions); // Track oldest date we successfully fetched foreach ($newTransactions as $tx) { if ($oldestDateReached === null || $tx['date'] < $oldestDateReached) { $oldestDateReached = $tx['date']; } } if (empty($lastBalance) && !empty($result['balance'])) { $lastBalance = $result['balance']; } } $currentTo = $currentFrom - 86400; // Previous day } // Remove duplicates based on transaction ID $uniqueTransactions = array(); $seenIds = array(); foreach ($allTransactions as $tx) { if (!isset($seenIds[$tx['id']])) { $uniqueTransactions[] = $tx; $seenIds[$tx['id']] = true; } } // Sort by date descending (newest first) usort($uniqueTransactions, function($a, $b) { return $b['date'] - $a['date']; }); dol_syslog("BankImport: Total fetched: ".count($uniqueTransactions)." unique transactions", LOG_DEBUG); return array( 'transactions' => $uniqueTransactions, 'balance' => $lastBalance ); } /** * Fetch statements for a single period (internal) * * @param int $dateFrom Start date (timestamp) * @param int $dateTo End date (timestamp) * @return array|int Array of transactions or -1 on error, 0 if TAN required */ protected function fetchStatementsForPeriod($dateFrom, $dateTo) { try { // Get accounts first $getAccounts = GetSEPAAccounts::create(); $this->fints->execute($getAccounts); if ($getAccounts->needsTan()) { $this->pendingAction = $getAccounts; $tanRequest = $getAccounts->getTanRequest(); $this->tanChallenge = $tanRequest->getChallenge(); return 0; } $accounts = $getAccounts->getAccounts(); // Debug: Log all available accounts dol_syslog("BankImport: Looking for IBAN: ".$this->iban, LOG_DEBUG); foreach ($accounts as $idx => $acc) { dol_syslog("BankImport: Available account ".$idx.": IBAN=".$acc->getIban(), LOG_DEBUG); } // Find matching account by IBAN $selectedAccount = null; foreach ($accounts as $account) { if ($account->getIban() === $this->iban) { $selectedAccount = $account; dol_syslog("BankImport: Selected account by IBAN match: ".$account->getIban(), LOG_DEBUG); break; } } if ($selectedAccount === null) { // Fallback: use first account if (!empty($accounts)) { $selectedAccount = $accounts[0]; dol_syslog("BankImport: WARNING - No IBAN match found! Using first account: ".$selectedAccount->getIban(), LOG_WARNING); } else { $this->error = 'No accounts found'; return -1; } } // Fetch statements $from = new DateTime(); $from->setTimestamp($dateFrom); $to = new DateTime(); $to->setTimestamp($dateTo); $transactions = array(); // Log what we're trying dol_syslog("BankImport: Fetching statements from ".$from->format('Y-m-d')." to ".$to->format('Y-m-d'), LOG_DEBUG); // Try GetStatementOfAccount first (MT940/HKKAZ - most widely supported) $mt940Success = false; try { dol_syslog("BankImport: Trying GetStatementOfAccount (MT940/HKKAZ)", LOG_DEBUG); $getStatement = GetStatementOfAccount::create($selectedAccount, $from, $to); $this->fints->execute($getStatement); if ($getStatement->needsTan()) { $this->pendingAction = $getStatement; $tanRequest = $getStatement->getTanRequest(); $this->tanChallenge = $tanRequest->getChallenge(); return 0; } // Process MT940 results $soa = $getStatement->getStatement(); foreach ($soa->getStatements() as $statement) { foreach ($statement->getTransactions() as $tx) { $sign = ($tx->getCreditDebit() === Transaction::CD_DEBIT) ? -1 : 1; $transactions[] = array( 'id' => md5($tx->getValutaDate()->format('Y-m-d').$tx->getAmount().$tx->getName().$tx->getMainDescription()), 'date' => $tx->getValutaDate()->getTimestamp(), 'bookingDate' => $tx->getBookingDate() ? $tx->getBookingDate()->getTimestamp() : null, 'amount' => $sign * $tx->getAmount(), 'currency' => 'EUR', 'name' => $tx->getName(), 'iban' => $tx->getAccountNumber(), 'bic' => $tx->getBankCode(), 'reference' => $tx->getMainDescription(), 'bookingText' => $tx->getBookingText(), 'endToEndId' => $tx->getEndToEndID(), ); } } dol_syslog("BankImport: MT940 successful, got ".count($transactions)." transactions", LOG_DEBUG); // Return MT940 results return array( 'transactions' => $transactions, 'balance' => array() ); } catch (Exception $e) { dol_syslog("BankImport: MT940 failed: ".$e->getMessage(), LOG_DEBUG); // MT940 failed, try CAMT/XML dol_syslog("BankImport: Trying GetStatementOfAccountXML (CAMT)", LOG_DEBUG); try { $getStatementXML = GetStatementOfAccountXML::create($selectedAccount, $from, $to); $this->fints->execute($getStatementXML); if ($getStatementXML->needsTan()) { $this->pendingAction = $getStatementXML; $tanRequest = $getStatementXML->getTanRequest(); $this->tanChallenge = $tanRequest->getChallenge(); return 0; } // Process CAMT XML results $xmlStatements = $getStatementXML->getBookedXML(); // Debug: Log raw XML count dol_syslog("BankImport: Got ".count($xmlStatements)." XML documents", LOG_DEBUG); // Balance info from XML $balanceInfo = array(); foreach ($xmlStatements as $idx => $camtDoc) { // Debug: Save raw XML to temp file for analysis $debugFile = DOL_DATA_ROOT.'/bankimport_debug_'.$idx.'.xml'; file_put_contents($debugFile, $camtDoc); dol_syslog("BankImport: Saved XML to ".$debugFile, LOG_DEBUG); // Parse CAMT XML $xml = simplexml_load_string($camtDoc); if ($xml === false) { dol_syslog("BankImport: Failed to parse XML document ".$idx, LOG_ERR); continue; } // Get all namespaces used in the document $namespaces = $xml->getNamespaces(true); dol_syslog("BankImport: Namespaces: ".json_encode($namespaces), LOG_DEBUG); // Register all found namespaces foreach ($namespaces as $prefix => $uri) { if (empty($prefix)) { $xml->registerXPathNamespace('ns', $uri); } else { $xml->registerXPathNamespace($prefix, $uri); } } // Extract balance information from XML $balances = $xml->xpath('//ns:Bal') ?: $xml->xpath('//Bal') ?: $xml->xpath('//*[local-name()="Bal"]') ?: []; foreach ($balances as $bal) { $balType = (string) ($bal->Tp->CdOrPrtry->Cd ?? ''); if ($balType === 'CLBD') { // Closing balance $balAmount = (float) ($bal->Amt ?? 0); $balCcy = (string) ($bal->Amt['Ccy'] ?? 'EUR'); $balSign = ((string) ($bal->CdtDbtInd ?? 'CRDT')) === 'DBIT' ? -1 : 1; $balDate = (string) ($bal->Dt->Dt ?? date('Y-m-d')); $balanceInfo = array( 'amount' => $balSign * $balAmount, 'currency' => $balCcy, 'date' => $balDate, 'type' => 'CLBD' ); dol_syslog("BankImport: Found closing balance: ".$balanceInfo['amount']." ".$balCcy." at ".$balDate, LOG_DEBUG); } } // Try multiple XPath patterns for different CAMT versions $entries = $xml->xpath('//ns:Ntry') ?: $xml->xpath('//Ntry') ?: $xml->xpath('//*[local-name()="Ntry"]') ?: []; dol_syslog("BankImport: Found ".count($entries)." entries", LOG_DEBUG); foreach ($entries as $entry) { $amount = (float) ($entry->Amt ?? 0); $sign = ((string) ($entry->CdtDbtInd ?? 'CRDT')) === 'DBIT' ? -1 : 1; $date = (string) ($entry->BookgDt->Dt ?? $entry->ValDt->Dt ?? date('Y-m-d')); // Get counterparty name - CAMT.052.001.08 has Pty wrapper $txDtls = $entry->NtryDtls->TxDtls ?? null; $name = ''; if ($txDtls) { // For DBIT (outgoing): counterparty is Cdtr (creditor) // For CRDT (incoming): counterparty is Dbtr (debtor) if ($sign < 0) { // Outgoing payment - get creditor name $name = (string) ($txDtls->RltdPties->Cdtr->Pty->Nm ?? $txDtls->RltdPties->Cdtr->Nm ?? ''); } else { // Incoming payment - get debtor name $name = (string) ($txDtls->RltdPties->Dbtr->Pty->Nm ?? $txDtls->RltdPties->Dbtr->Nm ?? ''); } } // Get reference/description $reference = ''; if ($txDtls && isset($txDtls->RmtInf)) { $reference = (string) ($txDtls->RmtInf->Ustrd ?? ''); } // Fallback to AddtlNtryInf if (empty($reference)) { $reference = (string) ($entry->AddtlNtryInf ?? ''); } $transactions[] = array( 'id' => md5($date . $amount . $name . $reference), 'date' => strtotime($date), 'bookingDate' => strtotime($date), 'amount' => $sign * $amount, 'currency' => (string) ($entry->Amt['Ccy'] ?? 'EUR'), 'name' => $name, 'iban' => '', 'bic' => '', 'reference' => $reference, 'bookingText' => (string) ($entry->AddtlNtryInf ?? ''), 'endToEndId' => (string) ($txDtls->Refs->EndToEndId ?? ''), ); } } // Return transactions with balance info return array( 'transactions' => $transactions, 'balance' => $balanceInfo ); } catch (Exception $e2) { // CAMT also failed - both methods failed // Check if this is likely a historical data limit issue $daysAgo = (time() - $dateFrom) / 86400; $errorMsg = $e->getMessage() . ' ' . $e2->getMessage(); // Banks typically don't provide data older than 90 days // Error messages containing "HIKAZS" or "not support" often indicate data unavailability if ($daysAgo > 60 && ( stripos($errorMsg, 'HIKAZS') !== false || stripos($errorMsg, 'not support') !== false || stripos($errorMsg, 'nicht unterstützt') !== false || stripos($errorMsg, 'nicht verfügbar') !== false )) { $this->error = 'Die Bank stellt historische Kontodaten nur für ca. 2-3 Monate bereit. Bitte wählen Sie einen kürzeren Zeitraum.'; } else { $this->error = 'Abruf fehlgeschlagen: ' . $e->getMessage(); } dol_syslog("BankImport: Both MT940 and CAMT failed. MT940: ".$e->getMessage()." | CAMT: ".$e2->getMessage(), LOG_ERR); return -1; } } } catch (Exception $e) { $this->error = $e->getMessage(); return -1; } } /** * Prueft ob die Bank elektronische Kontoauszuege (PDF) unterstuetzt * * Prueft sowohl HKEKP (HIEPS) als auch HKEKA (HIEKAS). * * @return bool True wenn HIEPS oder HIEKAS in den BPD vorhanden */ public function supportsStatementPdf() { if ($this->fints === null) { return false; } try { $params = $this->getBankParameters(); return isset($params['HIEPS']) || isset($params['HIEKAS']); } catch (Exception $e) { return false; } } /** * Elektronische Kontoauszuege als PDF von der Bank abrufen (HKEKP) * * @param string|null $statementNumber Optionale Auszugsnummer * @param string|null $year Optionales Jahr (JJJJ) * @return array|int Array mit PDF-Daten oder -1 bei Fehler, 0 wenn TAN benoetigt */ public function fetchBankStatements($statementNumber = null, $year = null) { dol_syslog("BankImport HKEKP: ===== fetchBankStatements() START =====", LOG_DEBUG); dol_syslog("BankImport HKEKP: Parameter: statementNumber=".($statementNumber ?: 'null').", year=".($year ?: 'null'), LOG_DEBUG); if (!$this->isConfigured()) { $this->error = 'Konfiguration unvollstaendig'; dol_syslog("BankImport HKEKP: ABBRUCH - nicht konfiguriert", LOG_ERR); return -1; } if ($this->fints === null) { $this->error = 'Nicht verbunden. Bitte zuerst login() aufrufen.'; dol_syslog("BankImport HKEKP: ABBRUCH - fints===null, kein Login", LOG_ERR); return -1; } try { // Konten abrufen dol_syslog("BankImport HKEKP: Rufe GetSEPAAccounts ab...", LOG_DEBUG); $getAccounts = GetSEPAAccounts::create(); $this->fints->execute($getAccounts); dol_syslog("BankImport HKEKP: GetSEPAAccounts ausgefuehrt", LOG_DEBUG); if ($getAccounts->needsTan()) { dol_syslog("BankImport HKEKP: GetSEPAAccounts benoetigt TAN", LOG_DEBUG); $this->pendingAction = $getAccounts; $tanRequest = $getAccounts->getTanRequest(); $this->tanChallenge = $tanRequest->getChallenge(); return 0; } $accounts = $getAccounts->getAccounts(); dol_syslog("BankImport HKEKP: ".count($accounts)." SEPA-Konten gefunden", LOG_DEBUG); foreach ($accounts as $idx => $acc) { dol_syslog("BankImport HKEKP: Konto ".($idx+1).": IBAN=".$acc->getIban() .", BIC=".$acc->getBic() .", Ktonr=".$acc->getAccountNumber() .", BLZ=".$acc->getSubAccount(), LOG_DEBUG); } // Passendes Konto per IBAN finden $selectedAccount = null; dol_syslog("BankImport HKEKP: Suche Konto mit IBAN=".$this->iban, LOG_DEBUG); foreach ($accounts as $account) { if ($account->getIban() === $this->iban) { $selectedAccount = $account; dol_syslog("BankImport HKEKP: IBAN-Match gefunden!", LOG_DEBUG); break; } } if ($selectedAccount === null) { if (!empty($accounts)) { $selectedAccount = $accounts[0]; dol_syslog("BankImport HKEKP: KEIN IBAN-Match! Verwende erstes Konto: ".$selectedAccount->getIban(), LOG_WARNING); } else { $this->error = 'Keine Konten gefunden'; dol_syslog("BankImport HKEKP: ABBRUCH - keine Konten gefunden", LOG_ERR); return -1; } } // BPD (Bank Parameter Data) abfragen und loggen dol_syslog("BankImport HKEKP: Rufe getBankParameters() ab...", LOG_DEBUG); $params = $this->getBankParameters(); dol_syslog("BankImport HKEKP: BPD enthaelt ".count($params)." Geschaeftsvorfaelle", LOG_DEBUG); dol_syslog("BankImport HKEKP: BPD-Segmente: ".implode(', ', array_keys($params)), LOG_DEBUG); // Detailliert loggen welche Versionen unterstuetzt werden foreach ($params as $segName => $versions) { if (in_array($segName, ['HIEPS', 'HIEKAS', 'HIKEP', 'HIKAS', 'HIKAZS', 'HICAZS', 'HIPINS'])) { dol_syslog("BankImport: BPD Detail: ".$segName." Versionen=[".implode(',', $versions)."]", LOG_DEBUG); } } // Strategie: HKEKP (PDF-spezifisch) bevorzugen, Fallback auf HKEKA (generisch) $useHkekp = isset($params['HIEPS']); $useHkeka = isset($params['HIEKAS']); dol_syslog("BankImport: HIEPS (HKEKP)=".($useHkekp ? 'JA' : 'NEIN') .", HIEKAS (HKEKA)=".($useHkeka ? 'JA' : 'NEIN'), LOG_DEBUG); if (!$useHkekp && !$useHkeka) { $this->error = 'Die Bank unterstuetzt keine elektronischen Kontoauszuege (weder HKEKP/HIEPS noch HKEKA/HIEKAS in BPD)'; dol_syslog("BankImport: Weder HIEPS noch HIEKAS in BPD! Kontoauszuege nicht unterstuetzt.", LOG_WARNING); dol_syslog("BankImport: Alle verfuegbaren BPD-Parameter: ".implode(', ', array_keys($params)), LOG_WARNING); return -1; } dol_syslog("BankImport: Starte Abruf fuer ".$selectedAccount->getIban() .($statementNumber ? " Nr.".$statementNumber : " (alle)") .($year ? " Jahr ".$year : " (alle Jahre)"), LOG_DEBUG); $pdfStatements = []; if ($useHkekp) { // === HKEKP: PDF-spezifischer Kontoauszug === dol_syslog("BankImport HKEKP: HIEPS gefunden! Versionen: [".implode(',', $params['HIEPS'])."]", LOG_DEBUG); dol_syslog("BankImport HKEKP: Erstelle GetBankStatement Action...", LOG_DEBUG); $getBankStatement = GetBankStatement::create($selectedAccount, $statementNumber, $year); dol_syslog("BankImport HKEKP: Fuehre GetBankStatement aus (fints->execute)...", LOG_DEBUG); $this->fints->execute($getBankStatement); dol_syslog("BankImport HKEKP: GetBankStatement ausgefuehrt", LOG_DEBUG); if ($getBankStatement->needsTan()) { dol_syslog("BankImport HKEKP: GetBankStatement benoetigt TAN", LOG_DEBUG); $this->pendingAction = $getBankStatement; $tanRequest = $getBankStatement->getTanRequest(); $this->tanChallenge = $tanRequest->getChallenge(); return 0; } $pdfStatements = $getBankStatement->getPdfStatements(); dol_syslog("BankImport HKEKP: ".count($pdfStatements)." PDFs empfangen", LOG_DEBUG); } else { // === HKEKA: Generischer Kontoauszug (mit Format=PDF) === dol_syslog("BankImport HKEKA: HIEKAS gefunden! Versionen: [".implode(',', $params['HIEKAS'])."]", LOG_DEBUG); dol_syslog("BankImport HKEKA: Verwende HKEKA mit Format=3 (PDF)", LOG_DEBUG); dol_syslog("BankImport HKEKA: Erstelle GetElectronicStatement Action...", LOG_DEBUG); $getStatement = GetElectronicStatement::create( $selectedAccount, GetElectronicStatement::FORMAT_PDF, $statementNumber, $year ); dol_syslog("BankImport HKEKA: Fuehre GetElectronicStatement aus...", LOG_DEBUG); $this->fints->execute($getStatement); dol_syslog("BankImport HKEKA: GetElectronicStatement ausgefuehrt", LOG_DEBUG); if ($getStatement->needsTan()) { dol_syslog("BankImport HKEKA: GetElectronicStatement benoetigt TAN", LOG_DEBUG); $this->pendingAction = $getStatement; $tanRequest = $getStatement->getTanRequest(); $this->tanChallenge = $tanRequest->getChallenge(); return 0; } // Alle Statements holen, nach PDF filtern $allStatements = $getStatement->getStatements(); dol_syslog("BankImport HKEKA: ".count($allStatements)." Statements empfangen", LOG_DEBUG); foreach ($allStatements as $sIdx => $stmt) { $format = $stmt['format'] ?? 'unbekannt'; $data = $stmt['data']; dol_syslog("BankImport HKEKA: Statement ".($sIdx+1).": Format=".$format .", ".strlen($data)." Bytes, Anfang=".substr($data, 0, 10), LOG_DEBUG); } // PDFs extrahieren (getPdfStatements filtert nach Format=3 oder %PDF-) $pdfStatements = $getStatement->getPdfStatements(); dol_syslog("BankImport HKEKA: Davon ".count($pdfStatements)." PDFs", LOG_DEBUG); // Falls keine PDFs, aber andere Formate: Hinweis if (empty($pdfStatements) && !empty($allStatements)) { $formats = array_map(function($s) { return $s['format'] ?? '?'; }, $allStatements); dol_syslog("BankImport HKEKA: Keine PDFs! Empfangene Formate: ".implode(', ', $formats), LOG_WARNING); $this->error = 'Kontoauszuege empfangen, aber nicht im PDF-Format (Formate: '.implode(', ', $formats).')'; return -1; } } foreach ($pdfStatements as $pIdx => $pdf) { dol_syslog("BankImport: PDF ".($pIdx+1).": ".strlen($pdf)." Bytes, beginnt mit: ".substr($pdf, 0, 10), LOG_DEBUG); } dol_syslog("BankImport: ===== fetchBankStatements() ERFOLG - ".count($pdfStatements)." PDFs =====", LOG_DEBUG); return array( 'count' => count($pdfStatements), 'pdfs' => $pdfStatements, 'iban' => $selectedAccount->getIban(), ); } catch (\Fhp\Protocol\UnexpectedResponseException $e) { // Bank unterstuetzt den Geschaeftsvorfall nicht $this->error = 'Kontoauszug-Abruf nicht unterstuetzt: '.$e->getMessage(); dol_syslog("BankImport HKEKP: UnexpectedResponseException: ".$e->getMessage(), LOG_WARNING); dol_syslog("BankImport HKEKP: Stack-Trace: ".$e->getTraceAsString(), LOG_DEBUG); return -1; } catch (Exception $e) { $this->error = 'Fehler beim Kontoauszug-Abruf: '.$e->getMessage(); dol_syslog("BankImport HKEKP: Exception (".get_class($e)."): ".$e->getMessage(), LOG_ERR); dol_syslog("BankImport HKEKP: Stack-Trace: ".$e->getTraceAsString(), LOG_DEBUG); return -1; } } /** * Close FinTS connection * * @return void */ public function close() { if ($this->fints !== null) { try { $this->fints->close(); } catch (Exception $e) { // Ignore close errors } $this->fints = null; } } /** * Persist FinTS state (for web session handling) * * @return string|null Serialized state or null */ public function persist() { if ($this->fints === null) { return null; } try { return $this->fints->persist(); } catch (Exception $e) { return null; } } /** * Restore FinTS from persisted state * * @param string $state Persisted state * @return int 1 if success, -1 if error */ public function restore($state) { if (!$this->isConfigured()) { $this->error = 'Configuration incomplete'; return -1; } if (!$this->isLibraryAvailable()) { $this->error = 'phpFinTS library not found'; return -1; } if (empty($state)) { $this->error = 'Empty state provided'; dol_syslog("BankImport: restore - Empty state provided", LOG_ERR); return -1; } dol_syslog("BankImport: restore - Restoring FinTS state, length=".strlen($state), LOG_DEBUG); try { $options = new FinTsOptions(); $options->url = $this->fintsUrl; $options->bankCode = $this->blz; $options->productName = $this->productId; $options->productVersion = '1.0'; $credentials = Credentials::create($this->username, $this->pin); $this->fints = FinTs::new($options, $credentials, $state); dol_syslog("BankImport: restore - FinTS state restored successfully", LOG_DEBUG); return 1; } catch (Exception $e) { $this->error = $e->getMessage(); dol_syslog("BankImport: restore - Exception: ".$e->getMessage(), LOG_ERR); return -1; } } /** * Get FinTS URL * * @return string */ public function getFintsUrl() { return $this->fintsUrl; } /** * Get BLZ * * @return string */ public function getBlz() { return $this->blz; } /** * Get IBAN * * @return string */ public function getIban() { return $this->iban; } }