diff --git a/CHANGELOG.md b/CHANGELOG.md old mode 100644 new mode 100755 index a904acb..f03a57e --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,32 @@ Alle wesentlichen Änderungen an diesem Projekt werden in dieser Datei dokumentiert. +## [2.7] - 2026-02-23 + +### Hinzugefügt +- **Dediziertes Cron-Logging**: Separate Log-Datei unter `/documents/bankimport/logs/cron_bankimport.log` +- **Shutdown Handler**: Fängt fatale PHP-Fehler ab und protokolliert sie +- **Pause-Mechanismus**: Cron pausiert automatisch nach 3 aufeinanderfolgenden Fehlern (60 Min) +- **Auth-Fehler-Erkennung**: Erkennt Bank-Authentifizierungsfehler und pausiert um Kontosperrung zu vermeiden +- **Cron-Monitor Admin-Seite**: Neue Seite unter Admin > BankImport > Cron-Monitor zeigt Status, Logs und ermöglicht Pause/Resume + +### Verbessert +- Robustere Fehlerbehandlung mit try/catch für alle Operationen +- Detailliertes Logging mit Zeitstempeln und Elapsed-Time +- Fehler-Zähler verhindert wiederholte fehlgeschlagene Versuche + +## [2.6] - 2026-02-20 + +### Hinzugefügt +- **Multi-Rechnungszahlungen**: Eine Bankbuchung kann jetzt mit mehreren Rechnungen verknüpft werden (Sammelzahlungen) +- **Zahlungsverknüpfung aufheben**: Falsche Zuordnungen können über "Verknüpfung aufheben" korrigiert werden +- **Detailansicht Verknüpfungen**: In der Buchungsdetailansicht werden verknüpfte Zahlungen, Rechnungen und Bank-Einträge angezeigt +- **Bezahlte Rechnungen verknüpfen**: Bereits bezahlte Rechnungen können mit Bankbuchungen verknüpft werden (für nachträgliche Bank-Zuordnung) + +### Verbessert +- Bessere Anzeige von Multi-Invoice-Matches im Zahlungsabgleich +- Flexible Rechnungsauswahl per Checkbox bei Sammelzahlungen + ## [1.7] - 2026-02-20 ### Hinzugefügt diff --git a/admin/cronmonitor.php b/admin/cronmonitor.php new file mode 100644 index 0000000..79ea7b2 --- /dev/null +++ b/admin/cronmonitor.php @@ -0,0 +1,347 @@ + + * + * 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/admin/cronmonitor.php + * \ingroup bankimport + * \brief Cron job monitoring and log viewer + */ + +// Load Dolibarr environment +$res = 0; +if (!$res && !empty($_SERVER["CONTEXT_DOCUMENT_ROOT"])) { + $res = @include $_SERVER["CONTEXT_DOCUMENT_ROOT"]."/main.inc.php"; +} +$tmp = empty($_SERVER['SCRIPT_FILENAME']) ? '' : $_SERVER['SCRIPT_FILENAME']; +$tmp2 = realpath(__FILE__); +$i = strlen($tmp) - 1; +$j = strlen($tmp2) - 1; +while ($i > 0 && $j > 0 && isset($tmp[$i]) && isset($tmp2[$j]) && $tmp[$i] == $tmp2[$j]) { + $i--; + $j--; +} +if (!$res && $i > 0 && file_exists(substr($tmp, 0, ($i + 1))."/main.inc.php")) { + $res = @include substr($tmp, 0, ($i + 1))."/main.inc.php"; +} +if (!$res && $i > 0 && file_exists(dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php")) { + $res = @include dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php"; +} +if (!$res && file_exists("../../main.inc.php")) { + $res = @include "../../main.inc.php"; +} +if (!$res && file_exists("../../../main.inc.php")) { + $res = @include "../../../main.inc.php"; +} +if (!$res) { + die("Include of main fails"); +} + +// Libraries +require_once DOL_DOCUMENT_ROOT."/core/lib/admin.lib.php"; +require_once '../lib/bankimport.lib.php'; +dol_include_once('/bankimport/class/bankimportcron.class.php'); + +/** + * @var Conf $conf + * @var DoliDB $db + * @var Translate $langs + * @var User $user + */ + +// Translations +$langs->loadLangs(array("admin", "bankimport@bankimport", "cron")); + +// Parameters +$action = GETPOST('action', 'aZ09'); +$lines = GETPOSTINT('lines') ?: 100; + +// Access control +if (!$user->admin) { + accessforbidden(); +} + +/* + * Actions + */ + +if ($action == 'unpause') { + $cron = new BankImportCron($db); + $cron->unpauseCron(); + setEventMessages("Cron-Pause aufgehoben", null, 'mesgs'); + header("Location: ".$_SERVER["PHP_SELF"]); + exit; +} + +if ($action == 'clearfailcount') { + dolibarr_del_const($db, 'BANKIMPORT_CRON_FAIL_COUNT', $conf->entity); + setEventMessages("Fehlerzähler zurückgesetzt", null, 'mesgs'); + header("Location: ".$_SERVER["PHP_SELF"]); + exit; +} + +if ($action == 'resetcronjob') { + // Reset the cron job in llx_cronjob table + $sql = "UPDATE ".MAIN_DB_PREFIX."cronjob SET processing = 0, datenextrun = NOW() WHERE label = 'BankImportAutoFetch' AND processing = 1"; + $resql = $db->query($sql); + if ($resql && $db->affected_rows($resql) > 0) { + setEventMessages("Hängenden Cron-Job zurückgesetzt", null, 'mesgs'); + } else { + setEventMessages("Kein hängender Job gefunden oder Fehler", null, 'warnings'); + } + header("Location: ".$_SERVER["PHP_SELF"]); + exit; +} + +/* + * View + */ + +$page_name = "Cron Monitor - BankImport"; +llxHeader('', $page_name, '', '', 0, 0, '', '', '', 'mod-bankimport page-admin-cronmonitor'); + +// Admin Tabs +$head = bankimportAdminPrepareHead(); +print dol_get_fiche_head($head, 'cronmonitor', 'BankImport', -1, 'bank'); + +// Get cron status +$cronStatus = BankImportCron::getCronStatus(); + +// Get Dolibarr cron job info +$sql = "SELECT rowid, label, datenextrun, datelastrun, datelastresult, processing, lastoutput, lastresult, status + FROM ".MAIN_DB_PREFIX."cronjob + WHERE label = 'BankImportAutoFetch'"; +$resql = $db->query($sql); +$cronJob = $resql ? $db->fetch_object($resql) : null; + +print '
'; + +// Status Overview +print ''; +print ''; + +// Enabled/Disabled +print ''; + +// Paused status +print ''; + +// Failure count +print ''; + +// Last run status +print ''; + +// Notification +print ''; + +print '
Cron Status Übersicht
Auto-Import aktiviert'; +if ($cronStatus['enabled']) { + print 'Aktiviert'; +} else { + print 'Deaktiviert'; +} +print '
Pause-Status'; +if ($cronStatus['paused']) { + print 'PAUSIERT '; + print 'bis '.dol_print_date($cronStatus['paused_until'], 'dayhour'); + print '
Grund: '.dol_escape_htmltag($cronStatus['pause_reason']); + print '
Pause aufheben'; +} else { + print 'Aktiv'; +} +print '
Fehlversuche in Folge'; +$failCount = $cronStatus['fail_count']; +if ($failCount >= 3) { + print ''.$failCount.' '; + print '(bei 3+ wird automatisch pausiert) '; + print 'Zurücksetzen'; +} elseif ($failCount > 0) { + print ''.$failCount.''; +} else { + print '0'; +} +print '
Letzter Lauf'; +if (!empty($cronStatus['last_run'])) { + $lastRun = $cronStatus['last_run']; + $statusClass = 'badge-status4'; + if ($lastRun['status'] == 'error') $statusClass = 'badge-status8'; + elseif ($lastRun['status'] == 'paused') $statusClass = 'badge-status1'; + elseif ($lastRun['status'] == 'running') $statusClass = 'badge-status6'; + + print ''.strtoupper($lastRun['status']).' '; + print dol_print_date($lastRun['timestamp'], 'dayhour'); + if (!empty($lastRun['duration'])) { + print ' (Dauer: '.$lastRun['duration'].'s)'; + } + if (!empty($lastRun['message'])) { + print '
'.dol_escape_htmltag($lastRun['message']).''; + } +} else { + print 'Keine Daten'; +} +print '
Aktuelle Benachrichtigung'; +if (!empty($cronStatus['notification'])) { + $notif = $cronStatus['notification']; + $notifLabels = array( + 'tan_required' => 'TAN erforderlich', + 'login_error' => 'Login-Fehler', + 'fetch_error' => 'Abruf-Fehler', + 'config_error' => 'Konfigurationsfehler', + 'session_expired' => 'Session abgelaufen', + 'error' => 'Allgemeiner Fehler', + 'paused' => 'Pausiert' + ); + $label = $notifLabels[$notif['type']] ?? $notif['type']; + print ''.$label.''; + if (!empty($notif['date'])) { + print ' seit '.dol_print_date($notif['date'], 'dayhour'); + } +} else { + print 'Keine'; +} +print '
'; + +// Dolibarr Cron Job Status +print '
'; +print ''; +print ''; + +if ($cronJob) { + print ''; + + print ''; + print ''; + print ''; + + print ''; + + print ''; +} else { + print ''; +} +print '
Dolibarr Cron-Job Status
Job Status'; + if ($cronJob->processing) { + print 'LÄUFT / HÄNGT '; + print 'Job zurücksetzen'; + } elseif ($cronJob->status == 1) { + print 'Bereit'; + } else { + print 'Deaktiviert'; + } + print '
Nächster geplanter Lauf'.dol_print_date($db->jdate($cronJob->datenextrun), 'dayhour').'
Letzter Lauf (Start)'.dol_print_date($db->jdate($cronJob->datelastrun), 'dayhour').'
Letzter Lauf (Ende)'.dol_print_date($db->jdate($cronJob->datelastresult), 'dayhour').'
Letztes Ergebnis'; + if ($cronJob->lastresult === '0' || $cronJob->lastresult === 0) { + print 'OK'; + } elseif (!empty($cronJob->lastresult)) { + print 'Fehler: '.$cronJob->lastresult.''; + } else { + print '-'; + } + print '
Letzte Ausgabe
'.dol_escape_htmltag($cronJob->lastoutput ?: '-').'
Cron-Job nicht gefunden
'; + +// Log file viewer +print '
'; +print ''; +print ''; +print ''; +print ''; +print ''; + +$logFile = $conf->bankimport->dir_output.'/logs/cron_bankimport.log'; +print ''; +print '
Cron Log-Datei'; +print '
'; +print 'Zeilen: '; +print ''; +print '
'; +print '
'; + +if (file_exists($logFile)) { + $fileSize = filesize($logFile); + print 'Datei: '.$logFile.' ('.dol_print_size($fileSize, 1).')'; + print '
';
+
+	// Read last N lines efficiently
+	$logContent = '';
+	$fp = fopen($logFile, 'r');
+	if ($fp) {
+		$buffer = array();
+		while (!feof($fp)) {
+			$line = fgets($fp);
+			if ($line !== false) {
+				$buffer[] = $line;
+				if (count($buffer) > $lines) {
+					array_shift($buffer);
+				}
+			}
+		}
+		fclose($fp);
+		$logContent = implode('', $buffer);
+	}
+
+	// Syntax highlighting for log
+	$logContent = htmlspecialchars($logContent);
+	$logContent = preg_replace('/\[ERROR\]/', '[ERROR]', $logContent);
+	$logContent = preg_replace('/\[WARNING\]/', '[WARNING]', $logContent);
+	$logContent = preg_replace('/\[INFO\]/', '[INFO]', $logContent);
+	$logContent = preg_replace('/\[DEBUG\]/', '[DEBUG]', $logContent);
+	$logContent = preg_replace('/========== CRON (START|END.*) ==========/', '========== CRON $1 ==========', $logContent);
+
+	print $logContent;
+	print '
'; +} else { + print 'Log-Datei existiert noch nicht: '.$logFile.''; +} + +print '
'; + +// All cron jobs overview +print '
'; +print ''; +print ''; +print ''; +print ''; +print ''; +print ''; +print ''; +print ''; + +$sql = "SELECT rowid, label, datenextrun, datelastrun, processing, status + FROM ".MAIN_DB_PREFIX."cronjob + WHERE label LIKE '%Import%' OR label LIKE '%Bank%' OR label LIKE '%Zugferd%' OR label LIKE '%Dump%' + ORDER BY label"; +$resql = $db->query($sql); +if ($resql) { + while ($obj = $db->fetch_object($resql)) { + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + } +} +print '
Alle Modul Cron-JobsStatusNächster LaufLetzter LaufProcessing
'.$obj->label.''; + if ($obj->status == 1) { + print 'Aktiv'; + } else { + print 'Inaktiv'; + } + print ''.dol_print_date($db->jdate($obj->datenextrun), 'dayhour').''.dol_print_date($db->jdate($obj->datelastrun), 'dayhour').''; + if ($obj->processing) { + print 'HÄNGT'; + } else { + print 'OK'; + } + print '
'; + +print dol_get_fiche_end(); + +llxFooter(); +$db->close(); diff --git a/bin/module_bankimport-1.6.zip b/bin/module_bankimport-1.6.zip new file mode 100755 index 0000000..3f9fbef Binary files /dev/null and b/bin/module_bankimport-1.6.zip differ diff --git a/bin/module_bankimport-1.7.zip b/bin/module_bankimport-1.7.zip new file mode 100755 index 0000000..cfc0f8f Binary files /dev/null and b/bin/module_bankimport-1.7.zip differ diff --git a/bin/module_bankimport-1.8.zip b/bin/module_bankimport-1.8.zip new file mode 100755 index 0000000..84cffca Binary files /dev/null and b/bin/module_bankimport-1.8.zip differ diff --git a/bin/module_bankimport-1.9.zip b/bin/module_bankimport-1.9.zip new file mode 100755 index 0000000..e87d50c Binary files /dev/null and b/bin/module_bankimport-1.9.zip differ diff --git a/bin/module_bankimport-2.0.zip b/bin/module_bankimport-2.0.zip new file mode 100755 index 0000000..ac98c3a Binary files /dev/null and b/bin/module_bankimport-2.0.zip differ diff --git a/bin/module_bankimport-2.1.zip b/bin/module_bankimport-2.1.zip new file mode 100755 index 0000000..93493e6 Binary files /dev/null and b/bin/module_bankimport-2.1.zip differ diff --git a/bin/module_bankimport-2.2.zip b/bin/module_bankimport-2.2.zip new file mode 100755 index 0000000..a46c65d Binary files /dev/null and b/bin/module_bankimport-2.2.zip differ diff --git a/bin/module_bankimport-2.3.zip b/bin/module_bankimport-2.3.zip new file mode 100755 index 0000000..5baf298 Binary files /dev/null and b/bin/module_bankimport-2.3.zip differ diff --git a/bin/module_bankimport-2.4.zip b/bin/module_bankimport-2.4.zip new file mode 100755 index 0000000..7427493 Binary files /dev/null and b/bin/module_bankimport-2.4.zip differ diff --git a/bin/module_bankimport-2.5.zip b/bin/module_bankimport-2.5.zip new file mode 100755 index 0000000..274cb70 Binary files /dev/null and b/bin/module_bankimport-2.5.zip differ diff --git a/bin/module_bankimport-2.6.zip b/bin/module_bankimport-2.6.zip new file mode 100755 index 0000000..36ce9e3 Binary files /dev/null and b/bin/module_bankimport-2.6.zip differ diff --git a/class/bankimportcron.class.php b/class/bankimportcron.class.php index 631c91a..9c1dd98 100755 --- a/class/bankimportcron.class.php +++ b/class/bankimportcron.class.php @@ -43,6 +43,16 @@ class BankImportCron */ public $output = ''; + /** + * @var string Path to cron log file + */ + private $cronLogFile = ''; + + /** + * @var float Start time of cron execution + */ + private $startTime = 0; + /** * Constructor * @@ -50,7 +60,63 @@ class BankImportCron */ 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); } /** @@ -63,33 +129,79 @@ class BankImportCron { 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'); - dol_syslog("BankImportCron::doAutoImport - Starting automatic import", LOG_INFO); + $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'); - dol_syslog("BankImportCron::doAutoImport - Auto import is disabled", LOG_INFO); + $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'); - dol_syslog("BankImportCron::doAutoImport - FinTS not configured", LOG_WARNING); + $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'); - dol_syslog("BankImportCron::doAutoImport - Library not found", LOG_ERR); + $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; @@ -97,39 +209,53 @@ class BankImportCron try { // Try to restore previous session if (!empty($storedState)) { - dol_syslog("BankImportCron::doAutoImport - Attempting to restore previous session (state length: ".strlen($storedState).")", LOG_INFO); + $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 - dol_syslog("BankImportCron::doAutoImport - Session restore failed: ".$fints->error.", trying fresh login", LOG_WARNING); + $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 { - dol_syslog("BankImportCron::doAutoImport - Session restored successfully", LOG_INFO); + $this->cronLog("Session restored successfully"); $sessionRestored = true; } } else { - dol_syslog("BankImportCron::doAutoImport - No stored session found", LOG_INFO); + $this->cronLog("No stored session found"); } // If no stored session or restore failed, try fresh login if (empty($storedState)) { - dol_syslog("BankImportCron::doAutoImport - Attempting fresh login", LOG_INFO); + $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; - dol_syslog("BankImportCron::doAutoImport - Login failed: ".$fints->error, LOG_ERR); + $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'); - dol_syslog("BankImportCron::doAutoImport - TAN required for login, cannot proceed automatically", LOG_WARNING); + $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(); @@ -137,10 +263,11 @@ class BankImportCron 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 } - dol_syslog("BankImportCron::doAutoImport - Fresh login successful", LOG_INFO); + $this->cronLog("Fresh login successful"); } // Login successful or session restored - fetch statements @@ -148,7 +275,8 @@ class BankImportCron $dateFrom = strtotime("-{$daysToFetch} days"); $dateTo = time(); - dol_syslog("BankImportCron::doAutoImport - Fetching statements from ".date('Y-m-d', $dateFrom)." to ".date('Y-m-d', $dateTo)." ({$daysToFetch} days)", LOG_INFO); + $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); @@ -157,16 +285,17 @@ class BankImportCron $txCount = count($result['transactions'] ?? array()); $hasBalance = !empty($result['balance']); $isPartial = !empty($result['partial']); - dol_syslog("BankImportCron::doAutoImport - fetchStatements returned array: transactions={$txCount}, hasBalance=".($hasBalance?'yes':'no').", partial=".($isPartial?'yes':'no'), LOG_INFO); + $this->cronLog("fetchStatements returned: transactions={$txCount}, hasBalance=".($hasBalance?'yes':'no').", partial=".($isPartial?'yes':'no')); } else { - dol_syslog("BankImportCron::doAutoImport - fetchStatements returned: ".var_export($result, true), LOG_INFO); + $this->cronLog("fetchStatements returned: ".var_export($result, true)); } if ($result === 0) { // TAN required for statements $this->output = $langs->trans('TANRequired'); - dol_syslog("BankImportCron::doAutoImport - TAN required for statements", LOG_WARNING); + $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(); @@ -174,13 +303,15 @@ class BankImportCron 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; - dol_syslog("BankImportCron::doAutoImport - Fetch failed: ".$fints->error, LOG_ERR); + $this->cronLog("Fetch failed: ".$fints->error, 'ERROR'); $this->setNotification('fetch_error'); + $this->recordCronStatus('error', 'Fetch failed: '.$fints->error); return -1; } @@ -190,12 +321,12 @@ class BankImportCron $importedCount = 0; $skippedCount = 0; - dol_syslog("BankImportCron::doAutoImport - Bank returned {$fetchedCount} transactions", LOG_INFO); + $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) { - dol_syslog("BankImportCron::doAutoImport - Restored session returned 0 transactions, clearing stale session", LOG_WARNING); + $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 @@ -203,11 +334,15 @@ class BankImportCron $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 @@ -221,7 +356,7 @@ class BankImportCron $importedCount = $importResult['imported'] ?? 0; $skippedCount = $importResult['skipped'] ?? 0; - dol_syslog("BankImportCron::doAutoImport - Import result: imported={$importedCount}, skipped={$skippedCount}", LOG_INFO); + $this->cronLog("Import result: imported={$importedCount}, skipped={$skippedCount}"); } // Update last fetch info @@ -232,6 +367,7 @@ class BankImportCron $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 @@ -251,20 +387,199 @@ class BankImportCron $this->output = $langs->trans('AutoImportNoTransactions'); } - dol_syslog("BankImportCron::doAutoImport - Completed: imported={$importedCount}, skipped={$skippedCount}", LOG_INFO); + // 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(); - dol_syslog("BankImportCron::doAutoImport - Exception: ".$e->getMessage(), LOG_ERR); + $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 @@ -275,6 +590,47 @@ class BankImportCron 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) + ); + } + } + } } /** diff --git a/core/modules/modBankImport.class.php b/core/modules/modBankImport.class.php index ecd0f1f..cdb28c2 100755 --- a/core/modules/modBankImport.class.php +++ b/core/modules/modBankImport.class.php @@ -76,7 +76,7 @@ class modBankImport extends DolibarrModules $this->editor_squarred_logo = ''; // Must be image filename into the module/img directory followed with @modulename. Example: 'myimage.png@bankimport' // Possible values for version are: 'development', 'experimental', 'dolibarr', 'dolibarr_deprecated', 'experimental_deprecated' or a version string like 'x.y.z' - $this->version = '2.6'; + $this->version = '2.7'; // Url to the file with your last numberversion of this module //$this->url_last_version = 'http://www.example.com/versionmodule.txt'; diff --git a/lib/bankimport.lib.php b/lib/bankimport.lib.php index 05cabe8..161493c 100755 --- a/lib/bankimport.lib.php +++ b/lib/bankimport.lib.php @@ -44,6 +44,17 @@ function bankimportAdminPrepareHead() $head[$h][2] = 'settings'; $h++; + $head[$h][0] = dol_buildpath("/bankimport/admin/cronmonitor.php", 1); + $head[$h][1] = $langs->trans("CronMonitor"); + // Add warning badge if there's a problem + dol_include_once('/bankimport/class/bankimportcron.class.php'); + $cronStatus = BankImportCron::getCronStatus(); + if ($cronStatus['paused'] || $cronStatus['fail_count'] >= 3 || !empty($cronStatus['notification'])) { + $head[$h][1] .= ' !'; + } + $head[$h][2] = 'cronmonitor'; + $h++; + /* $head[$h][0] = dol_buildpath("/bankimport/admin/myobject_extrafields.php", 1); $head[$h][1] = $langs->trans("ExtraFields"); diff --git a/repair.php b/repair.php old mode 100644 new mode 100755 diff --git a/sql/update_1.7.sql b/sql/update_1.7.sql old mode 100644 new mode 100755