* * 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/bankimportcron.class.php * \ingroup bankimport * \brief Cron job class for automatic bank statement import */ require_once DOL_DOCUMENT_ROOT.'/core/class/commonobject.class.php'; dol_include_once('/bankimport/class/fints.class.php'); dol_include_once('/bankimport/class/banktransaction.class.php'); /** * Class BankImportCron * Handles automatic bank statement import via scheduled task */ class BankImportCron { /** * @var DoliDB Database handler */ public $db; /** * @var string Error message */ public $error = ''; /** * @var array Error messages */ public $errors = array(); /** * @var string Output message for cron log */ public $output = ''; /** * @var string Path to cron log file */ private $cronLogFile = ''; /** * @var float Start time of cron execution */ private $startTime = 0; /** * Constructor * * @param DoliDB $db Database handler */ public function __construct($db) { global $conf; $this->db = $db; // Set up dedicated log file for cron jobs $logDir = $conf->bankimport->dir_output.'/logs'; if (!is_dir($logDir)) { dol_mkdir($logDir); } $this->cronLogFile = $logDir.'/cron_bankimport.log'; } /** * Write to dedicated cron log file * * @param string $message Log message * @param string $level Log level (INFO, WARNING, ERROR, DEBUG) * @return void */ private function cronLog($message, $level = 'INFO') { $timestamp = date('Y-m-d H:i:s'); $elapsed = $this->startTime > 0 ? round(microtime(true) - $this->startTime, 2).'s' : '0s'; $logLine = "[{$timestamp}] [{$level}] [{$elapsed}] {$message}\n"; // Write to dedicated log file @file_put_contents($this->cronLogFile, $logLine, FILE_APPEND | LOCK_EX); // Also log to Dolibarr system log $dolLevel = LOG_INFO; if ($level === 'ERROR') $dolLevel = LOG_ERR; elseif ($level === 'WARNING') $dolLevel = LOG_WARNING; elseif ($level === 'DEBUG') $dolLevel = LOG_DEBUG; dol_syslog("BankImportCron: ".$message, $dolLevel); } /** * Record cron execution status to database for monitoring * * @param string $status Status (started, running, completed, error) * @param string $message Status message * @return void */ private function recordCronStatus($status, $message = '') { global $conf; $statusData = array( 'status' => $status, 'message' => $message, 'timestamp' => time(), 'duration' => $this->startTime > 0 ? round(microtime(true) - $this->startTime, 2) : 0, 'memory' => memory_get_usage(true), 'peak_memory' => memory_get_peak_usage(true) ); dolibarr_set_const($this->db, 'BANKIMPORT_CRON_STATUS', json_encode($statusData), 'chaine', 0, '', $conf->entity); } /** * Execute the automatic import cron job * Called by Dolibarr's scheduled task system * * @return int 0 if OK, < 0 if error */ public function doAutoImport() { global $conf, $langs, $user; // Initialize timing $this->startTime = microtime(true); // Register shutdown function to catch fatal errors register_shutdown_function(array($this, 'handleShutdown')); $langs->load('bankimport@bankimport'); $this->cronLog("========== CRON START =========="); $this->cronLog("PHP Version: ".PHP_VERSION.", Memory Limit: ".ini_get('memory_limit').", Max Execution Time: ".ini_get('max_execution_time')); $this->recordCronStatus('started', 'Cron job started'); // Check if automatic import is enabled if (!getDolGlobalInt('BANKIMPORT_AUTO_ENABLED')) { $this->output = $langs->trans('AutoImportDisabled'); $this->cronLog("Auto import is disabled - exiting"); $this->recordCronStatus('completed', 'Auto import disabled'); return 0; } // CHECK: Is cron paused due to errors? (to prevent bank account lockout) $pausedUntil = getDolGlobalInt('BANKIMPORT_CRON_PAUSED_UNTIL'); if ($pausedUntil > 0 && $pausedUntil > time()) { $pauseReason = getDolGlobalString('BANKIMPORT_CRON_PAUSE_REASON'); $remainingMinutes = ceil(($pausedUntil - time()) / 60); $this->output = "Cron pausiert für {$remainingMinutes} Minuten: {$pauseReason}"; $this->cronLog("Cron is PAUSED until ".date('Y-m-d H:i:s', $pausedUntil)." - Reason: {$pauseReason}", 'WARNING'); $this->recordCronStatus('paused', "Paused: {$pauseReason}"); return 0; } // Clear pause if expired if ($pausedUntil > 0 && $pausedUntil <= time()) { $this->cronLog("Pause expired - resuming normal operation"); dolibarr_del_const($this->db, 'BANKIMPORT_CRON_PAUSED_UNTIL', $conf->entity); dolibarr_del_const($this->db, 'BANKIMPORT_CRON_PAUSE_REASON', $conf->entity); dolibarr_del_const($this->db, 'BANKIMPORT_CRON_FAIL_COUNT', $conf->entity); } // Check consecutive failure count $failCount = getDolGlobalInt('BANKIMPORT_CRON_FAIL_COUNT'); if ($failCount >= 3) { // After 3 consecutive failures, pause for increasing time $pauseMinutes = min(60 * 24, 15 * pow(2, $failCount - 3)); // 15min, 30min, 1h, 2h, ... max 24h $this->pauseCron("Zu viele Fehlversuche ({$failCount}x) - Bitte manuell prüfen", $pauseMinutes); $this->output = "Automatisch pausiert nach {$failCount} Fehlversuchen"; $this->cronLog("Auto-paused after {$failCount} failures for {$pauseMinutes} minutes", 'WARNING'); return 0; } $this->cronLog("Initializing FinTS library"); $this->recordCronStatus('running', 'Initializing FinTS'); // Initialize FinTS $fints = new BankImportFinTS($this->db); if (!$fints->isConfigured()) { $this->error = $langs->trans('AutoImportNotConfigured'); $this->cronLog("FinTS not configured", 'WARNING'); $this->setNotification('config_error'); $this->recordCronStatus('error', 'FinTS not configured'); return -1; } if (!$fints->isLibraryAvailable()) { $this->error = $langs->trans('FinTSLibraryNotFound'); $this->cronLog("FinTS library not found", 'ERROR'); $this->recordCronStatus('error', 'FinTS library not found'); return -1; } $this->cronLog("FinTS library loaded successfully"); // Check for stored session state (from previous successful TAN) $storedState = getDolGlobalString('BANKIMPORT_CRON_STATE'); $sessionRestored = false; try { // Try to restore previous session if (!empty($storedState)) { $this->cronLog("Attempting to restore previous session (state length: ".strlen($storedState).")"); $this->recordCronStatus('running', 'Restoring session'); $restoreResult = $fints->restore($storedState); if ($restoreResult < 0) { // Session expired, need fresh login $this->cronLog("Session restore failed: ".$fints->error.", trying fresh login", 'WARNING'); $storedState = ''; // Clear stale session dolibarr_del_const($this->db, 'BANKIMPORT_CRON_STATE', $conf->entity); } else { $this->cronLog("Session restored successfully"); $sessionRestored = true; } } else { $this->cronLog("No stored session found"); } // If no stored session or restore failed, try fresh login if (empty($storedState)) { $this->cronLog("Attempting fresh login to bank"); $this->recordCronStatus('running', 'Logging in to bank'); $loginResult = $fints->login(); if ($loginResult < 0) { $this->error = $langs->trans('LoginFailed').': '.$fints->error; $this->cronLog("Login failed: ".$fints->error, 'ERROR'); $this->setNotification('login_error'); $this->recordCronStatus('error', 'Login failed: '.$fints->error); // Increment failure count and potentially pause to prevent lockout $this->incrementFailCount(); // Check if this is a critical auth error that should pause immediately if ($this->isAuthError($fints->error)) { $this->pauseCron("Bank-Login fehlgeschlagen - Zugangsdaten prüfen!", 60); // Pause 1 hour $this->cronLog("CRITICAL: Auth error detected - pausing cron for 1 hour to prevent lockout", 'ERROR'); } return -1; } if ($loginResult == 0) { // TAN required - can't proceed automatically $this->output = $langs->trans('TANRequired'); $this->cronLog("TAN required for login - cannot proceed automatically", 'WARNING'); $this->setNotification('tan_required'); $this->recordCronStatus('completed', 'TAN required - waiting for user'); // Store the state so user can complete TAN manually $state = $fints->persist(); if (!empty($state)) { dolibarr_set_const($this->db, 'BANKIMPORT_CRON_PENDING_STATE', $state, 'chaine', 0, '', $conf->entity); dolibarr_set_const($this->db, 'BANKIMPORT_CRON_PENDING_ACTION', serialize($fints->getPendingAction()), 'chaine', 0, '', $conf->entity); } $this->cronLog("========== CRON END (TAN required) =========="); return 0; // Not an error, just can't proceed } $this->cronLog("Fresh login successful"); } // Login successful or session restored - fetch statements $daysToFetch = getDolGlobalInt('BANKIMPORT_AUTO_DAYS') ?: 30; $dateFrom = strtotime("-{$daysToFetch} days"); $dateTo = time(); $this->cronLog("Fetching statements from ".date('Y-m-d', $dateFrom)." to ".date('Y-m-d', $dateTo)." ({$daysToFetch} days)"); $this->recordCronStatus('running', 'Fetching bank statements'); $result = $fints->fetchStatements($dateFrom, $dateTo); // Log what we got back if (is_array($result)) { $txCount = count($result['transactions'] ?? array()); $hasBalance = !empty($result['balance']); $isPartial = !empty($result['partial']); $this->cronLog("fetchStatements returned: transactions={$txCount}, hasBalance=".($hasBalance?'yes':'no').", partial=".($isPartial?'yes':'no')); } else { $this->cronLog("fetchStatements returned: ".var_export($result, true)); } if ($result === 0) { // TAN required for statements $this->output = $langs->trans('TANRequired'); $this->cronLog("TAN required for statements", 'WARNING'); $this->setNotification('tan_required'); $this->recordCronStatus('completed', 'TAN required for statements'); // Store state for manual completion $state = $fints->persist(); if (!empty($state)) { dolibarr_set_const($this->db, 'BANKIMPORT_CRON_PENDING_STATE', $state, 'chaine', 0, '', $conf->entity); dolibarr_set_const($this->db, 'BANKIMPORT_CRON_PENDING_ACTION', serialize($fints->getPendingAction()), 'chaine', 0, '', $conf->entity); } $this->cronLog("========== CRON END (TAN required) =========="); return 0; } if ($result < 0) { $this->error = $langs->trans('FetchFailed').': '.$fints->error; $this->cronLog("Fetch failed: ".$fints->error, 'ERROR'); $this->setNotification('fetch_error'); $this->recordCronStatus('error', 'Fetch failed: '.$fints->error); return -1; } // Success - import transactions $transactions = $result['transactions'] ?? array(); $fetchedCount = count($transactions); $importedCount = 0; $skippedCount = 0; $this->cronLog("Bank returned {$fetchedCount} transactions"); // If restored session returned 0 transactions, the session might be stale // Try fresh login as fallback if ($fetchedCount == 0 && $sessionRestored) { $this->cronLog("Restored session returned 0 transactions - session might be stale", 'WARNING'); dolibarr_del_const($this->db, 'BANKIMPORT_CRON_STATE', $conf->entity); // Note: We don't retry here because a fresh login would require TAN // Just mark that the session was stale $this->output = $langs->trans('AutoImportNoTransactions').' (Session abgelaufen - nächster Lauf erfordert TAN)'; $this->setNotification('session_expired'); $this->recordCronStatus('completed', 'Session expired - no transactions'); $this->cronLog("========== CRON END (session expired) =========="); return 0; } if (!empty($transactions)) { $this->cronLog("Starting import of {$fetchedCount} transactions"); $this->recordCronStatus('running', "Importing {$fetchedCount} transactions"); // Get a system user for the import $importUser = new User($this->db); $importUser->fetch(1); // Admin user $iban = $fints->getIban(); // Use the importFromFinTS method for correct field mapping $transImporter = new BankImportTransaction($this->db); $importResult = $transImporter->importFromFinTS($transactions, $iban, $importUser); $importedCount = $importResult['imported'] ?? 0; $skippedCount = $importResult['skipped'] ?? 0; $this->cronLog("Import result: imported={$importedCount}, skipped={$skippedCount}"); } // Update last fetch info dolibarr_set_const($this->db, 'BANKIMPORT_LAST_FETCH', time(), 'chaine', 0, '', $conf->entity); dolibarr_set_const($this->db, 'BANKIMPORT_LAST_FETCH_COUNT', $importedCount, 'chaine', 0, '', $conf->entity); // Store session for next run (might avoid TAN) $state = $fints->persist(); if (!empty($state)) { dolibarr_set_const($this->db, 'BANKIMPORT_CRON_STATE', $state, 'chaine', 0, '', $conf->entity); $this->cronLog("Session state saved for next run"); } // Clear any pending state dolibarr_del_const($this->db, 'BANKIMPORT_CRON_PENDING_STATE', $conf->entity); dolibarr_del_const($this->db, 'BANKIMPORT_CRON_PENDING_ACTION', $conf->entity); // Clear notification flag on success $this->clearNotification(); $fints->close(); if ($importedCount > 0) { $this->output = $langs->trans('AutoImportSuccess', $importedCount); } elseif ($skippedCount > 0) { $this->output = $langs->trans('AutoImportNoTransactions').' ('.$skippedCount.' bereits vorhanden)'; } else { $this->output = $langs->trans('AutoImportNoTransactions'); } // Reset failure count on success $this->resetFailCount(); $duration = round(microtime(true) - $this->startTime, 2); $this->cronLog("Completed successfully: imported={$importedCount}, skipped={$skippedCount}, duration={$duration}s"); $this->recordCronStatus('completed', "Success: imported={$importedCount}, skipped={$skippedCount}"); $this->cronLog("========== CRON END (success) =========="); return 0; } catch (Exception $e) { $this->error = 'Exception: '.$e->getMessage(); $this->cronLog("EXCEPTION: ".$e->getMessage()."\nStack trace:\n".$e->getTraceAsString(), 'ERROR'); $this->setNotification('error'); $this->recordCronStatus('error', 'Exception: '.$e->getMessage()); $this->incrementFailCount(); $this->cronLog("========== CRON END (exception) =========="); return -1; } catch (Throwable $t) { $this->error = 'Fatal error: '.$t->getMessage(); $this->cronLog("FATAL ERROR: ".$t->getMessage()."\nStack trace:\n".$t->getTraceAsString(), 'ERROR'); $this->setNotification('error'); $this->recordCronStatus('error', 'Fatal: '.$t->getMessage()); $this->incrementFailCount(); $this->cronLog("========== CRON END (fatal error) =========="); return -1; } } /** * Pause cron execution for specified minutes * * @param string $reason Reason for pausing * @param int $minutes Minutes to pause * @return void */ private function pauseCron($reason, $minutes = 60) { global $conf; $pauseUntil = time() + ($minutes * 60); dolibarr_set_const($this->db, 'BANKIMPORT_CRON_PAUSED_UNTIL', $pauseUntil, 'chaine', 0, '', $conf->entity); dolibarr_set_const($this->db, 'BANKIMPORT_CRON_PAUSE_REASON', $reason, 'chaine', 0, '', $conf->entity); $this->cronLog("CRON PAUSED until ".date('Y-m-d H:i:s', $pauseUntil)." - Reason: {$reason}", 'WARNING'); $this->setNotification('paused'); } /** * Increment consecutive failure count * * @return int New failure count */ private function incrementFailCount() { global $conf; $count = getDolGlobalInt('BANKIMPORT_CRON_FAIL_COUNT') + 1; dolibarr_set_const($this->db, 'BANKIMPORT_CRON_FAIL_COUNT', $count, 'chaine', 0, '', $conf->entity); $this->cronLog("Failure count incremented to {$count}"); return $count; } /** * Reset failure count (on success) * * @return void */ private function resetFailCount() { global $conf; $oldCount = getDolGlobalInt('BANKIMPORT_CRON_FAIL_COUNT'); if ($oldCount > 0) { dolibarr_del_const($this->db, 'BANKIMPORT_CRON_FAIL_COUNT', $conf->entity); $this->cronLog("Failure count reset (was {$oldCount})"); } } /** * Check if error message indicates an authentication error * These errors should pause immediately to prevent account lockout * * @param string $error Error message * @return bool True if this is an auth-related error */ private function isAuthError($error) { $authKeywords = array( 'authentication', 'authentifizierung', 'passwort', 'password', 'pin', 'credentials', 'zugangsdaten', 'gesperrt', 'locked', 'blocked', 'invalid user', 'ungültiger benutzer', 'zugang verweigert', 'access denied', 'not authorized', 'nicht autorisiert' ); $errorLower = strtolower($error); foreach ($authKeywords as $keyword) { if (strpos($errorLower, $keyword) !== false) { return true; } } return false; } /** * Manually unpause the cron (called from admin interface) * * @return bool Success */ public function unpauseCron() { global $conf; dolibarr_del_const($this->db, 'BANKIMPORT_CRON_PAUSED_UNTIL', $conf->entity); dolibarr_del_const($this->db, 'BANKIMPORT_CRON_PAUSE_REASON', $conf->entity); dolibarr_del_const($this->db, 'BANKIMPORT_CRON_FAIL_COUNT', $conf->entity); $this->clearNotification(); $this->cronLog("Cron manually unpaused by admin"); return true; } /** * Get current cron status for display * * @return array Status information */ public static function getCronStatus() { global $conf; $status = array( 'enabled' => getDolGlobalInt('BANKIMPORT_AUTO_ENABLED') > 0, 'paused' => false, 'paused_until' => 0, 'pause_reason' => '', 'fail_count' => getDolGlobalInt('BANKIMPORT_CRON_FAIL_COUNT'), 'last_run' => null, 'notification' => null ); // Check if paused $pausedUntil = getDolGlobalInt('BANKIMPORT_CRON_PAUSED_UNTIL'); if ($pausedUntil > 0 && $pausedUntil > time()) { $status['paused'] = true; $status['paused_until'] = $pausedUntil; $status['pause_reason'] = getDolGlobalString('BANKIMPORT_CRON_PAUSE_REASON'); } // Get last run status $lastStatus = getDolGlobalString('BANKIMPORT_CRON_STATUS'); if (!empty($lastStatus)) { $status['last_run'] = json_decode($lastStatus, true); } // Get notification $status['notification'] = self::getNotification(); return $status; } /** * Shutdown handler to catch fatal errors * Called automatically by PHP when script ends * * @return void */ public function handleShutdown() { $error = error_get_last(); if ($error !== null && in_array($error['type'], array(E_ERROR, E_CORE_ERROR, E_COMPILE_ERROR, E_PARSE))) { $message = "FATAL SHUTDOWN: {$error['message']} in {$error['file']}:{$error['line']}"; $this->cronLog($message, 'ERROR'); $this->recordCronStatus('error', $message); $this->cronLog("========== CRON END (fatal shutdown) =========="); } } /** * Set notification flag for admin users * Also sends to GlobalNotify if available * * @param string $type Notification type (tan_required, error, etc.) * @return void */ private function setNotification($type) { global $conf; dolibarr_set_const($this->db, 'BANKIMPORT_NOTIFICATION', $type, 'chaine', 0, '', $conf->entity); dolibarr_set_const($this->db, 'BANKIMPORT_NOTIFICATION_DATE', time(), 'chaine', 0, '', $conf->entity); // Send to GlobalNotify if module is enabled if (isModEnabled('globalnotify')) { dol_include_once('/globalnotify/class/globalnotify.class.php'); if (class_exists('GlobalNotify')) { $messages = array( 'tan_required' => array('TAN erforderlich', 'Bank-Login erfordert TAN-Bestätigung', 'action'), 'login_error' => array('Login-Fehler', 'Bank-Login fehlgeschlagen - Zugangsdaten prüfen', 'error'), 'fetch_error' => array('Abruf-Fehler', 'Kontoauszüge konnten nicht abgerufen werden', 'error'), 'config_error' => array('Konfigurationsfehler', 'FinTS ist nicht korrekt konfiguriert', 'error'), 'session_expired' => array('Session abgelaufen', 'Bank-Session ist abgelaufen, neuer Login erforderlich', 'warning'), 'paused' => array('Cron pausiert', 'BankImport Cron wurde automatisch pausiert', 'warning'), 'error' => array('Allgemeiner Fehler', 'Ein Fehler ist aufgetreten', 'error'), ); if (isset($messages[$type])) { $msg = $messages[$type]; $actionUrl = dol_buildpath('/bankimport/admin/cronmonitor.php', 1); $actionLabel = 'Details anzeigen'; if ($type == 'tan_required') { $actionUrl = dol_buildpath('/bankimport/fetch.php', 1); $actionLabel = 'TAN eingeben'; } $notify = new GlobalNotify($this->db); $notifyType = $msg[2] == 'action' ? GlobalNotify::TYPE_ACTION : ($msg[2] == 'error' ? GlobalNotify::TYPE_ERROR : GlobalNotify::TYPE_WARNING); $notify->addNotification( 'bankimport', $notifyType, $msg[0], $msg[1], $actionUrl, $actionLabel, $msg[2] == 'error' ? 10 : ($msg[2] == 'action' ? 9 : 7) ); } } } } /** * Clear notification flag * * @return void */ private function clearNotification() { global $conf; dolibarr_del_const($this->db, 'BANKIMPORT_NOTIFICATION', $conf->entity); dolibarr_del_const($this->db, 'BANKIMPORT_NOTIFICATION_DATE', $conf->entity); } /** * Check if there's a pending notification * * @return array|null Notification info or null */ public static function getNotification() { $type = getDolGlobalString('BANKIMPORT_NOTIFICATION'); $date = getDolGlobalInt('BANKIMPORT_NOTIFICATION_DATE'); if (empty($type)) { return null; } return array( 'type' => $type, 'date' => $date ); } /** * Resume a pending TAN action (called from web interface) * * @return int 1 if TAN confirmed and import done, 0 if still waiting, -1 if error */ public function resumePendingAction() { global $conf, $langs, $user; $pendingState = getDolGlobalString('BANKIMPORT_CRON_PENDING_STATE'); $pendingAction = getDolGlobalString('BANKIMPORT_CRON_PENDING_ACTION'); if (empty($pendingState) || empty($pendingAction)) { $this->error = 'No pending action'; return -1; } $fints = new BankImportFinTS($this->db); $restoreResult = $fints->restore($pendingState); if ($restoreResult < 0) { $this->error = $fints->error; // Clear expired state dolibarr_del_const($this->db, 'BANKIMPORT_CRON_PENDING_STATE', $conf->entity); dolibarr_del_const($this->db, 'BANKIMPORT_CRON_PENDING_ACTION', $conf->entity); return -1; } $action = @unserialize($pendingAction); if ($action === false) { $this->error = 'Could not restore pending action'; return -1; } $fints->setPendingAction($action); // Check if TAN was confirmed $checkResult = $fints->checkDecoupledTan(); if ($checkResult == 0) { // Still waiting // Update state $newState = $fints->persist(); if (!empty($newState)) { dolibarr_set_const($this->db, 'BANKIMPORT_CRON_PENDING_STATE', $newState, 'chaine', 0, '', $conf->entity); } return 0; } if ($checkResult < 0) { $this->error = $fints->error; return -1; } // TAN confirmed - now run the import dolibarr_del_const($this->db, 'BANKIMPORT_CRON_PENDING_STATE', $conf->entity); dolibarr_del_const($this->db, 'BANKIMPORT_CRON_PENDING_ACTION', $conf->entity); // Store the confirmed session and run import $state = $fints->persist(); if (!empty($state)) { dolibarr_set_const($this->db, 'BANKIMPORT_CRON_STATE', $state, 'chaine', 0, '', $conf->entity); } return $this->doAutoImport(); } }