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 '| Cron Status Übersicht |
';
+
+// Enabled/Disabled
+print '| Auto-Import aktiviert | ';
+if ($cronStatus['enabled']) {
+ print 'Aktiviert';
+} else {
+ print 'Deaktiviert';
+}
+print ' |
';
+
+// Paused status
+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 ' |
';
+
+// Failure count
+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 ' |
';
+
+// Last run status
+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 ' |
';
+
+// Notification
+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 ' |
';
+
+print '
';
+
+// Dolibarr Cron Job Status
+print '
';
+print '
';
+print '| Dolibarr Cron-Job Status |
';
+
+if ($cronJob) {
+ print '| Job Status | ';
+ if ($cronJob->processing) {
+ print 'LÄUFT / HÄNGT ';
+ print 'Job zurücksetzen';
+ } elseif ($cronJob->status == 1) {
+ print 'Bereit';
+ } else {
+ print 'Deaktiviert';
+ }
+ print ' |
';
+
+ print '| Nächster geplanter Lauf | '.dol_print_date($db->jdate($cronJob->datenextrun), 'dayhour').' |
';
+ print '| Letzter Lauf (Start) | '.dol_print_date($db->jdate($cronJob->datelastrun), 'dayhour').' |
';
+ print '| Letzter Lauf (Ende) | '.dol_print_date($db->jdate($cronJob->datelastresult), 'dayhour').' |
';
+
+ print '| Letztes Ergebnis | ';
+ if ($cronJob->lastresult === '0' || $cronJob->lastresult === 0) {
+ print 'OK';
+ } elseif (!empty($cronJob->lastresult)) {
+ print 'Fehler: '.$cronJob->lastresult.'';
+ } else {
+ print '-';
+ }
+ print ' |
';
+
+ print '| Letzte Ausgabe | '.dol_escape_htmltag($cronJob->lastoutput ?: '-').' |
';
+} else {
+ print '| Cron-Job nicht gefunden |
';
+}
+print '
';
+
+// Log file viewer
+print '
';
+print '
';
+print '';
+print '| Cron Log-Datei | ';
+print '';
+print '';
+print ' | ';
+print '
';
+
+$logFile = $conf->bankimport->dir_output.'/logs/cron_bankimport.log';
+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 ' |
';
+print '
';
+
+// All cron jobs overview
+print '
';
+print '
';
+print '';
+print '| Alle Modul Cron-Jobs | ';
+print 'Status | ';
+print 'Nächster Lauf | ';
+print 'Letzter Lauf | ';
+print 'Processing | ';
+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 '| '.$obj->label.' | ';
+ print '';
+ if ($obj->status == 1) {
+ print 'Aktiv';
+ } else {
+ print 'Inaktiv';
+ }
+ print ' | ';
+ print ''.dol_print_date($db->jdate($obj->datenextrun), 'dayhour').' | ';
+ print ''.dol_print_date($db->jdate($obj->datelastrun), 'dayhour').' | ';
+ print '';
+ if ($obj->processing) {
+ print 'HÄNGT';
+ } else {
+ print 'OK';
+ }
+ print ' | ';
+ print '
';
+ }
+}
+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