dolibarr.bankimport/class/fints.class.php
data fc380892f0 feat: PDF-Kontoauszüge per FinTS (HKEKP) abrufen
- Neue php-fints Segmente: HKEKPv2, HIEKPv2, HIEKPSv2
- Action-Klasse GetStatementPDF mit Pagination-Support
- Integration in pdfstatements.php (2-Spalten-Layout)
- Cronjob doAutoFetchPdf für automatischen Abruf
- Bank-Support-Prüfung via BPD (HIEKPS Parameter)

Hinweis: Nicht alle Banken unterstützen HKEKP

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-05 14:26:35 +01:00

1142 lines
30 KiB
PHP
Executable file

<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
*
* 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\GetStatementPDF;
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)
{
if ($this->fints === null) {
$result = $this->initConnection();
if ($result < 0) {
return -1;
}
}
try {
// Get and select TAN mode
$tanModes = $this->fints->getTanModes();
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';
return -1;
}
$this->fints->selectTanMode($this->selectedTanMode);
// Perform login
$login = $this->fints->login();
if ($login->needsTan()) {
$this->pendingAction = $login;
$tanRequest = $login->getTanRequest();
$this->tanChallenge = $tanRequest->getChallenge();
return 0; // TAN required
}
return 1;
} catch (Exception $e) {
$this->error = $e->getMessage();
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()
{
if ($this->fints === null) {
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) {
return array('error' => 'BPD not available');
}
// Get parameters property from BPD
$bpdReflection = new \ReflectionClass($bpd);
$paramsProperty = $bpdReflection->getProperty('parameters');
$paramsProperty->setAccessible(true);
$parameters = $paramsProperty->getValue($bpd);
$result = array();
foreach ($parameters as $type => $versions) {
$result[$type] = array_keys($versions);
}
return $result;
} catch (Exception $e) {
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;
}
}
/**
* 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;
}
/**
* Get PDF bank statement via HKEKP
*
* @param int $accountIndex Index of account to use (default 0)
* @param int|null $statementNumber Optional: specific statement number
* @param int|null $statementYear Optional: statement year
* @return array|int Array with 'pdfData' and 'info', or 0 if TAN required, or -1 on error
*/
public function getStatementPDF($accountIndex = 0, $statementNumber = null, $statementYear = null)
{
global $conf;
$this->error = '';
if (!$this->fints) {
$this->error = 'Not connected';
return -1;
}
try {
// Get accounts if not cached
if (empty($this->accounts)) {
$getAccounts = GetSEPAAccounts::create();
$this->fints->execute($getAccounts);
if ($getAccounts->needsTan()) {
$this->pendingAction = $getAccounts;
$tanRequest = $getAccounts->getTanRequest();
$this->tanChallenge = $tanRequest->getChallenge();
return 0;
}
$this->accounts = $getAccounts->getAccounts();
}
if (empty($this->accounts) || !isset($this->accounts[$accountIndex])) {
$this->error = 'No accounts available or invalid account index';
return -1;
}
$selectedAccount = $this->accounts[$accountIndex];
dol_syslog("BankImport: Fetching PDF statement via HKEKP", LOG_DEBUG);
$getPdf = GetStatementPDF::create(
$selectedAccount,
$statementNumber,
$statementYear
);
$this->fints->execute($getPdf);
if ($getPdf->needsTan()) {
$this->pendingAction = $getPdf;
$tanRequest = $getPdf->getTanRequest();
$this->tanChallenge = $tanRequest->getChallenge();
return 0;
}
$pdfData = $getPdf->getPdfData();
$info = $getPdf->getStatementInfo();
if (empty($pdfData)) {
dol_syslog("BankImport: No PDF data received (no new statements available)", LOG_DEBUG);
return array(
'pdfData' => '',
'info' => array(),
'message' => 'No new statements available'
);
}
dol_syslog("BankImport: Received PDF statement #".$info['statementNumber'].'/'.$info['statementYear'], LOG_DEBUG);
return array(
'pdfData' => $pdfData,
'info' => $info
);
} catch (Exception $e) {
$this->error = $e->getMessage();
dol_syslog("BankImport: HKEKP failed: ".$this->error, LOG_ERR);
// Check if bank doesn't support HKEKP
if (stripos($this->error, 'HKEKP') !== false || stripos($this->error, 'not support') !== false) {
$this->error = 'Bank does not support PDF statements (HKEKP)';
}
return -1;
}
}
/**
* Check if bank supports PDF statements (HKEKP)
*
* @return bool
*/
public function supportsPdfStatements()
{
if (!$this->fints) {
return false;
}
try {
// Try to get BPD and check for HIEKPS
$reflection = new ReflectionClass($this->fints);
$bpdProperty = $reflection->getProperty('bpd');
$bpdProperty->setAccessible(true);
$bpd = $bpdProperty->getValue($this->fints);
if ($bpd === null) {
return false;
}
$hiekps = $bpd->getLatestSupportedParameters('HIEKPS');
return $hiekps !== null;
} catch (Exception $e) {
return false;
}
}
}