* * 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/bankstatement.class.php * \ingroup bankimport * \brief Class for PDF bank statements from FinTS */ require_once DOL_DOCUMENT_ROOT.'/core/class/commonobject.class.php'; /** * Class BankImportStatement * Represents a PDF bank statement imported via FinTS */ class BankImportStatement extends CommonObject { /** * @var string ID to identify managed object */ public $element = 'bankstatement'; /** * @var string Name of table without prefix where object is stored */ public $table_element = 'bankimport_statement'; /** * @var int Entity */ public $entity; /** * @var string IBAN */ public $iban; /** * @var string Statement number */ public $statement_number; /** * @var int Statement year */ public $statement_year; /** * @var int Statement date */ public $statement_date; /** * @var int Period from */ public $date_from; /** * @var int Period to */ public $date_to; /** * @var float Opening balance */ public $opening_balance; /** * @var float Closing balance */ public $closing_balance; /** * @var string Currency */ public $currency = 'EUR'; /** * @var string Filename */ public $filename; /** * @var string Filepath */ public $filepath; /** * @var int Filesize */ public $filesize; /** * @var string Import batch key */ public $import_key; /** * @var int Creation timestamp */ public $datec; /** * @var int User who created */ public $fk_user_creat; /** * @var string Private note */ public $note_private; /** * @var string Public note */ public $note_public; /** * Constructor * * @param DoliDB $db Database handler */ public function __construct($db) { global $conf; $this->db = $db; $this->entity = $conf->entity; } /** * Create statement in database * * @param User $user User that creates * @return int <0 if KO, Id of created object if OK */ public function create($user) { global $conf; $now = dol_now(); $this->db->begin(); $sql = "INSERT INTO ".MAIN_DB_PREFIX."bankimport_statement ("; $sql .= "entity, iban, statement_number, statement_year, statement_date,"; $sql .= "date_from, date_to, opening_balance, closing_balance, currency,"; $sql .= "filename, filepath, filesize, import_key, datec, fk_user_creat"; $sql .= ") VALUES ("; $sql .= ((int) $this->entity).","; $sql .= ($this->iban ? "'".$this->db->escape($this->iban)."'" : "NULL").","; $sql .= "'".$this->db->escape($this->statement_number)."',"; $sql .= ((int) $this->statement_year).","; $sql .= ($this->statement_date ? "'".$this->db->idate($this->statement_date)."'" : "NULL").","; $sql .= ($this->date_from ? "'".$this->db->idate($this->date_from)."'" : "NULL").","; $sql .= ($this->date_to ? "'".$this->db->idate($this->date_to)."'" : "NULL").","; $sql .= ($this->opening_balance !== null ? ((float) $this->opening_balance) : "NULL").","; $sql .= ($this->closing_balance !== null ? ((float) $this->closing_balance) : "NULL").","; $sql .= "'".$this->db->escape($this->currency)."',"; $sql .= ($this->filename ? "'".$this->db->escape($this->filename)."'" : "NULL").","; $sql .= ($this->filepath ? "'".$this->db->escape($this->filepath)."'" : "NULL").","; $sql .= ($this->filesize ? ((int) $this->filesize) : "NULL").","; $sql .= ($this->import_key ? "'".$this->db->escape($this->import_key)."'" : "NULL").","; $sql .= "'".$this->db->idate($now)."',"; $sql .= ((int) $user->id); $sql .= ")"; dol_syslog(get_class($this)."::create", LOG_DEBUG); $resql = $this->db->query($sql); if ($resql) { $this->id = $this->db->last_insert_id(MAIN_DB_PREFIX."bankimport_statement"); $this->datec = $now; $this->fk_user_creat = $user->id; $this->db->commit(); return $this->id; } else { $this->error = $this->db->lasterror(); $this->db->rollback(); return -1; } } /** * Load statement from database * * @param int $id Id of statement to load * @return int <0 if KO, 0 if not found, >0 if OK */ public function fetch($id) { $sql = "SELECT t.*"; $sql .= " FROM ".MAIN_DB_PREFIX."bankimport_statement as t"; $sql .= " WHERE t.rowid = ".((int) $id); dol_syslog(get_class($this)."::fetch", LOG_DEBUG); $resql = $this->db->query($sql); if ($resql) { if ($this->db->num_rows($resql)) { $obj = $this->db->fetch_object($resql); $this->id = $obj->rowid; $this->entity = $obj->entity; $this->iban = $obj->iban; $this->statement_number = $obj->statement_number; $this->statement_year = $obj->statement_year; $this->statement_date = $this->db->jdate($obj->statement_date); $this->date_from = $this->db->jdate($obj->date_from); $this->date_to = $this->db->jdate($obj->date_to); $this->opening_balance = $obj->opening_balance; $this->closing_balance = $obj->closing_balance; $this->currency = $obj->currency; $this->filename = $obj->filename; $this->filepath = $obj->filepath; $this->filesize = $obj->filesize; $this->import_key = $obj->import_key; $this->datec = $this->db->jdate($obj->datec); $this->fk_user_creat = $obj->fk_user_creat; $this->note_private = $obj->note_private; $this->note_public = $obj->note_public; $this->db->free($resql); return 1; } else { $this->db->free($resql); return 0; } } else { $this->error = $this->db->lasterror(); return -1; } } /** * Check if statement already exists (by number, year, iban) * * @return int 0 if not exists, rowid if exists */ public function exists() { $sql = "SELECT rowid FROM ".MAIN_DB_PREFIX."bankimport_statement"; $sql .= " WHERE statement_number = '".$this->db->escape($this->statement_number)."'"; $sql .= " AND statement_year = ".((int) $this->statement_year); $sql .= " AND iban = '".$this->db->escape($this->iban)."'"; $sql .= " AND entity = ".((int) $this->entity); $resql = $this->db->query($sql); if ($resql) { if ($this->db->num_rows($resql) > 0) { $obj = $this->db->fetch_object($resql); return $obj->rowid; } } return 0; } /** * Fetch all statements with filters * * @param string $sortfield Sort field * @param string $sortorder Sort order (ASC/DESC) * @param int $limit Limit * @param int $offset Offset * @param array $filter Filters array * @param string $mode 'list' returns array, 'count' returns count * @return array|int Array of statements, count or -1 on error */ public function fetchAll($sortfield = 'statement_year,statement_number', $sortorder = 'DESC', $limit = 0, $offset = 0, $filter = array(), $mode = 'list') { $sql = "SELECT t.rowid"; $sql .= " FROM ".MAIN_DB_PREFIX."bankimport_statement as t"; $sql .= " WHERE t.entity = ".((int) $this->entity); // Apply filters if (!empty($filter['iban'])) { $sql .= " AND t.iban LIKE '%".$this->db->escape($filter['iban'])."%'"; } if (!empty($filter['year'])) { $sql .= " AND t.statement_year = ".((int) $filter['year']); } // Count mode if ($mode == 'count') { $sqlcount = preg_replace('/SELECT t\.rowid/', 'SELECT COUNT(*) as total', $sql); $resqlcount = $this->db->query($sqlcount); if ($resqlcount) { $objcount = $this->db->fetch_object($resqlcount); return (int) $objcount->total; } return 0; } // Sort and limit $sql .= $this->db->order($sortfield, $sortorder); if ($limit > 0) { $sql .= $this->db->plimit($limit, $offset); } dol_syslog(get_class($this)."::fetchAll", LOG_DEBUG); $resql = $this->db->query($sql); if ($resql) { $result = array(); while ($obj = $this->db->fetch_object($resql)) { $statement = new BankImportStatement($this->db); $statement->fetch($obj->rowid); $result[] = $statement; } $this->db->free($resql); return $result; } else { $this->error = $this->db->lasterror(); return -1; } } /** * Delete statement * * @param User $user User that deletes * @return int <0 if KO, >0 if OK */ public function delete($user) { $this->db->begin(); // Delete file if exists if ($this->filepath && file_exists($this->filepath)) { @unlink($this->filepath); } // Delete statement lines $sqlLines = "DELETE FROM ".MAIN_DB_PREFIX."bankimport_statement_line"; $sqlLines .= " WHERE fk_statement = ".((int) $this->id); $this->db->query($sqlLines); $sql = "DELETE FROM ".MAIN_DB_PREFIX."bankimport_statement"; $sql .= " WHERE rowid = ".((int) $this->id); dol_syslog(get_class($this)."::delete", LOG_DEBUG); $resql = $this->db->query($sql); if ($resql) { $this->db->commit(); return 1; } else { $this->error = $this->db->lasterror(); $this->db->rollback(); return -1; } } /** * Get full path to PDF file * * @return string Full path or empty string */ public function getFilePath() { if ($this->filepath && file_exists($this->filepath)) { return $this->filepath; } return ''; } /** * Get storage directory for statements * * @return string Directory path */ public static function getStorageDir() { global $conf; $dir = $conf->bankimport->dir_output.'/statements'; if (!is_dir($dir)) { dol_mkdir($dir); } return $dir; } /** * Save PDF content to file * * @param string $pdfContent Binary PDF content * @return int <0 if KO, >0 if OK */ public function savePDF($pdfContent) { $dir = self::getStorageDir(); // Generate filename $this->filename = sprintf('statement_%s_%d_%s.pdf', preg_replace('/[^A-Z0-9]/', '', $this->iban), $this->statement_year, $this->statement_number ); $this->filepath = $dir.'/'.$this->filename; // Write file $result = file_put_contents($this->filepath, $pdfContent); if ($result !== false) { $this->filesize = strlen($pdfContent); return 1; } $this->error = 'Failed to write PDF file'; return -1; } /** * Save uploaded PDF file * * @param array $fileInfo Element from $_FILES array * @return int <0 if KO, >0 if OK */ public function saveUploadedPDF($fileInfo) { // Validate upload if (empty($fileInfo['tmp_name']) || !is_uploaded_file($fileInfo['tmp_name'])) { $this->error = 'No file uploaded'; return -1; } // Check file size (max 10MB) if ($fileInfo['size'] > 10 * 1024 * 1024) { $this->error = 'File too large (max 10MB)'; return -1; } // Check MIME type $finfo = finfo_open(FILEINFO_MIME_TYPE); $mimeType = finfo_file($finfo, $fileInfo['tmp_name']); finfo_close($finfo); if ($mimeType !== 'application/pdf') { $this->error = 'Only PDF files are allowed'; return -1; } $dir = self::getStorageDir(); // Generate filename $ibanPart = !empty($this->iban) ? preg_replace('/[^A-Z0-9]/', '', strtoupper($this->iban)) : 'KONTO'; $this->filename = sprintf('Kontoauszug_%s_%d_%s.pdf', $ibanPart, $this->statement_year, str_pad($this->statement_number, 3, '0', STR_PAD_LEFT) ); $this->filepath = $dir.'/'.$this->filename; // Check if file already exists if (file_exists($this->filepath)) { // Add timestamp to make unique $this->filename = sprintf('Kontoauszug_%s_%d_%s_%s.pdf', $ibanPart, $this->statement_year, str_pad($this->statement_number, 3, '0', STR_PAD_LEFT), date('His') ); $this->filepath = $dir.'/'.$this->filename; } // Move uploaded file if (!move_uploaded_file($fileInfo['tmp_name'], $this->filepath)) { $this->error = 'Failed to save file'; return -1; } $this->filesize = filesize($this->filepath); return 1; } /** * Parse PDF bank statement metadata using pdfinfo and pdftotext * * Extracts: statement number, year, IBAN, date range, opening/closing balance, * account number, bank name, statement date. * * @param string $filepath Path to PDF file * @return array|false Array with extracted data or false on failure */ public static function parsePdfMetadata($filepath) { if (!file_exists($filepath)) { return false; } $result = array( 'statement_number' => '', 'statement_year' => 0, 'pdf_number' => '', // Original statement number from PDF (e.g. "1" from Nr. 1/2025) 'pdf_year' => 0, // Original year from PDF 'iban' => '', 'date_from' => null, 'date_to' => null, 'opening_balance' => null, 'closing_balance' => null, 'statement_date' => null, 'account_number' => '', 'bank_name' => '', 'author' => '', ); $escapedPath = escapeshellarg($filepath); // 1. Extract metadata via pdfinfo $pdfinfo = array(); exec("pdfinfo ".$escapedPath." 2>/dev/null", $pdfinfo); foreach ($pdfinfo as $line) { if (preg_match('/^Title:\s+(.+)$/', $line, $m)) { // Title format: "000000000000000000000013438147 001/2025" or "Kontoauszug 13438147" if (preg_match('/(\d+)\s+(\d+)\/(\d{4})/', $m[1], $tm)) { $result['account_number'] = ltrim($tm[1], '0'); $result['pdf_number'] = (string) intval($tm[2]); $result['pdf_year'] = (int) $tm[3]; } } if (preg_match('/^Author:\s+(.+)$/', $line, $m)) { $result['author'] = trim($m[1]); } } // 2. Extract text via pdftotext $text = ''; exec("pdftotext -layout ".$escapedPath." - 2>/dev/null", $textlines); $text = implode("\n", $textlines); // Statement number from text (fallback if not in metadata) if (empty($result['pdf_number']) && preg_match('/Nr\.\s+(\d+)\/(\d{4})/', $text, $m)) { $result['pdf_number'] = (string) intval($m[1]); $result['pdf_year'] = (int) $m[2]; } // IBAN if (preg_match('/IBAN:\s*([A-Z]{2}\d{2}\s*[\d\s]+)/', $text, $m)) { $result['iban'] = preg_replace('/\s+/', ' ', trim($m[1])); } // Account number (fallback) if (empty($result['account_number']) && preg_match('/Kontonummer\s+(\d+)/', $text, $m)) { $result['account_number'] = $m[1]; } // Date range from Kontoabschluss if (preg_match('/Kontoabschluss vom (\d{2}\.\d{2}\.\d{4}) bis (\d{2}\.\d{2}\.\d{4})/', $text, $m)) { $dateFrom = DateTime::createFromFormat('d.m.Y', $m[1]); $dateTo = DateTime::createFromFormat('d.m.Y', $m[2]); if ($dateFrom) { $result['date_from'] = $dateFrom->setTime(0, 0, 0)->getTimestamp(); } if ($dateTo) { $result['date_to'] = $dateTo->setTime(0, 0, 0)->getTimestamp(); } } // Statement date (erstellt am) if (preg_match('/erstellt am\s+(\d{2}\.\d{2}\.\d{4})/', $text, $m)) { $stmtDate = DateTime::createFromFormat('d.m.Y', $m[1]); if ($stmtDate) { $result['statement_date'] = $stmtDate->setTime(0, 0, 0)->getTimestamp(); } } // Opening balance: "alter Kontostand [vom DD.MM.YYYY] X.XXX,XX H/S" if (preg_match('/alter Kontostand(?:\s+vom\s+\d{2}\.\d{2}\.\d{4})?\s+([\d.,]+)\s+(H|S)/', $text, $m)) { $amount = self::parseGermanAmount($m[1]); if ($m[2] === 'S') { $amount = -$amount; } $result['opening_balance'] = $amount; } // Closing balance: "neuer Kontostand vom DD.MM.YYYY X.XXX,XX H/S" if (preg_match('/neuer Kontostand(?:\s+vom\s+\d{2}\.\d{2}\.\d{4})?\s+([\d.,]+)\s+(H|S)/', $text, $m)) { $amount = self::parseGermanAmount($m[1]); if ($m[2] === 'S') { $amount = -$amount; } $result['closing_balance'] = $amount; } // Bank name (first line that contains "Bank" or known patterns) if (preg_match('/(?:VR\s*B\s*ank|Volksbank|Raiffeisenbank|Sparkasse)[^\n]*/i', $text, $m)) { $bankName = trim($m[0]); // Fix OCR artifacts: single chars separated by spaces ("V R B a n k" → "VRBank") // Strategy: collapse all single-space gaps between word chars that look like OCR splitting $bankName = preg_replace('/\b(\w) (\w) (\w) (\w)\b/', '$1$2$3$4', $bankName); $bankName = preg_replace('/\b(\w) (\w) (\w)\b/', '$1$2$3', $bankName); $bankName = preg_replace('/\b(\w) (\w)\b/', '$1$2', $bankName); // Fix common OCR pattern "VR B ank" → "VR Bank", "S chleswig" → "Schleswig" $bankName = preg_replace('/\bB ank\b/', 'Bank', $bankName); $bankName = preg_replace('/\bS (\w)/', 'S$1', $bankName); $bankName = preg_replace('/\bW (\w)/', 'W$1', $bankName); // Clean up multiple spaces and trim address parts after comma $bankName = preg_replace('/\s{2,}/', ' ', $bankName); $bankName = preg_replace('/,.*$/', '', $bankName); $result['bank_name'] = trim($bankName); } // Derive statement_number (=month) and statement_year from end date of period if ($result['date_to']) { $result['statement_number'] = (string) intval(date('m', $result['date_to'])); $result['statement_year'] = (int) date('Y', $result['date_to']); } elseif ($result['date_from']) { $result['statement_number'] = (string) intval(date('m', $result['date_from'])); $result['statement_year'] = (int) date('Y', $result['date_from']); } elseif (!empty($result['pdf_year'])) { // Fallback to PDF metadata if no date range $result['statement_number'] = $result['pdf_number']; $result['statement_year'] = $result['pdf_year']; } // Fallback: extract data from filename if PDF tools returned nothing // Supports patterns like: 13438147_2025_Nr.001_Kontoauszug_vom_2025.07.01_timestamp.pdf if (empty($result['statement_number']) && empty($result['iban'])) { $basename = basename($filepath); if (preg_match('/(\d+)_(\d{4})_Nr\.?(\d+)/', $basename, $fm)) { $result['account_number'] = ltrim($fm[1], '0'); $result['pdf_number'] = (string) intval($fm[3]); $result['pdf_year'] = (int) $fm[2]; $result['statement_number'] = $result['pdf_number']; $result['statement_year'] = $result['pdf_year']; } if (preg_match('/vom[_\s](\d{4})\.(\d{2})\.(\d{2})/', $basename, $dm)) { $stmtDate = DateTime::createFromFormat('Y-m-d', $dm[1].'-'.$dm[2].'-'.$dm[3]); if ($stmtDate) { $result['statement_date'] = $stmtDate->setTime(0, 0, 0)->getTimestamp(); if (empty($result['statement_number'])) { $result['statement_number'] = (string) intval($dm[2]); $result['statement_year'] = (int) $dm[1]; } } } } // Validate: at least statement number or IBAN must be present if (empty($result['statement_number']) && empty($result['iban'])) { return false; } return $result; } /** * Parse a German formatted amount (e.g., "3.681,45" → 3681.45) * * @param string $amount German formatted amount string * @return float Parsed amount */ private static function parseGermanAmount($amount) { $amount = str_replace('.', '', $amount); // Remove thousands separator $amount = str_replace(',', '.', $amount); // Convert decimal separator return (float) $amount; } /** * Generate a clean filename for a PDF statement * * @param array $parsed Parsed metadata from parsePdfMetadata() * @return string Generated filename */ public static function generateFilename($parsed) { $bank = 'Bank'; if (!empty($parsed['bank_name'])) { // Shorten bank name - take first meaningful words $bank = preg_replace('/\s+(eG|AG|e\.G\.).*$/', '', $parsed['bank_name']); $bank = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß-]/', '_', $bank); $bank = preg_replace('/_+/', '_', $bank); $bank = trim($bank, '_'); } $account = !empty($parsed['account_number']) ? $parsed['account_number'] : 'Konto'; $year = !empty($parsed['statement_year']) ? $parsed['statement_year'] : date('Y'); $nr = !empty($parsed['statement_number']) ? str_pad($parsed['statement_number'], 3, '0', STR_PAD_LEFT) : '000'; return sprintf('%s_%s_%d_%s.pdf', $bank, $account, $year, $nr); } /** * Get next available statement number for a year * * @param int $year Year * @return string Next statement number */ public function getNextStatementNumber($year) { $sql = "SELECT MAX(CAST(statement_number AS UNSIGNED)) as maxnum"; $sql .= " FROM ".MAIN_DB_PREFIX."bankimport_statement"; $sql .= " WHERE statement_year = ".((int) $year); $sql .= " AND entity = ".((int) $this->entity); $resql = $this->db->query($sql); if ($resql) { $obj = $this->db->fetch_object($resql); $nextNum = ($obj->maxnum !== null) ? ((int) $obj->maxnum + 1) : 1; return (string) $nextNum; } return '1'; } /** * Get the end date (date_to) of the most recent statement * * @return int|null Timestamp of latest date_to, or null if none */ public function getLatestStatementEndDate() { $sql = "SELECT MAX(date_to) as last_date"; $sql .= " FROM ".MAIN_DB_PREFIX."bankimport_statement"; $sql .= " WHERE entity = ".((int) $this->entity); $resql = $this->db->query($sql); if ($resql) { $obj = $this->db->fetch_object($resql); if ($obj->last_date) { return $this->db->jdate($obj->last_date); } } return null; } /** * Get list of years that have stored statements * * @return array Array of years (descending) */ public function getAvailableYears() { $sql = "SELECT DISTINCT statement_year"; $sql .= " FROM ".MAIN_DB_PREFIX."bankimport_statement"; $sql .= " WHERE entity = ".((int) $this->entity); $sql .= " ORDER BY statement_year DESC"; $result = array(); $resql = $this->db->query($sql); if ($resql) { while ($obj = $this->db->fetch_object($resql)) { $result[(int) $obj->statement_year] = (string) $obj->statement_year; } $this->db->free($resql); } return $result; } /** * Reconcile bank entries using parsed statement lines. * * Strategy: Match each statement_line (parsed from PDF) to a llx_bank entry * by amount + date (with tolerance). This is authoritative because the * statement lines come directly from the bank's PDF and represent exactly * what transactions belong to this statement. * * Matching priority: * 1. Exact amount + exact date * 2. Exact amount + date within 4 days tolerance * * @param User $user User performing the reconciliation * @param int $bankAccountId Dolibarr bank account ID (llx_bank_account.rowid) * @return int Number of reconciled entries, or -1 on error */ public function reconcileBankEntries($user, $bankAccountId) { if (empty($this->id) || empty($bankAccountId)) { $this->error = 'Missing required fields for reconciliation'; return 0; } require_once DOL_DOCUMENT_ROOT.'/compta/bank/class/account.class.php'; // Format statement number: "NR/YYYY" (e.g., "3/2025") $numReleve = $this->statement_number.'/'.$this->statement_year; // Get statement lines $lines = $this->getStatementLines(); if (!is_array($lines) || empty($lines)) { // No statement lines parsed yet — nothing to reconcile $this->copyToDolibarrStatementDir($bankAccountId); return 0; } $reconciled = 0; $usedBankIds = array(); foreach ($lines as $line) { $amount = (float) $line->amount; $dateBooking = $line->date_booking; // YYYY-MM-DD string // Step 1: Try exact amount + exact date $sqlMatch = "SELECT b.rowid"; $sqlMatch .= " FROM ".MAIN_DB_PREFIX."bank as b"; $sqlMatch .= " WHERE b.fk_account = ".((int) $bankAccountId); $sqlMatch .= " AND b.rappro = 0"; $sqlMatch .= " AND b.amount = ".$amount; $sqlMatch .= " AND b.datev = '".$this->db->escape($dateBooking)."'"; if (!empty($usedBankIds)) { $sqlMatch .= " AND b.rowid NOT IN (".implode(',', $usedBankIds).")"; } $sqlMatch .= " LIMIT 1"; $resMatch = $this->db->query($sqlMatch); $matched = false; if ($resMatch && $this->db->num_rows($resMatch) > 0) { $match = $this->db->fetch_object($resMatch); $matched = $this->reconcileBankLine($user, $match->rowid, $numReleve, $line->rowid, $usedBankIds); } // Step 2: Try exact amount + date within 4 days tolerance if (!$matched) { $sqlMatch2 = "SELECT b.rowid, ABS(DATEDIFF(b.datev, '".$this->db->escape($dateBooking)."')) as date_diff"; $sqlMatch2 .= " FROM ".MAIN_DB_PREFIX."bank as b"; $sqlMatch2 .= " WHERE b.fk_account = ".((int) $bankAccountId); $sqlMatch2 .= " AND b.rappro = 0"; $sqlMatch2 .= " AND b.amount = ".$amount; $sqlMatch2 .= " AND ABS(DATEDIFF(b.datev, '".$this->db->escape($dateBooking)."')) <= 4"; if (!empty($usedBankIds)) { $sqlMatch2 .= " AND b.rowid NOT IN (".implode(',', $usedBankIds).")"; } $sqlMatch2 .= " ORDER BY date_diff ASC LIMIT 1"; $resMatch2 = $this->db->query($sqlMatch2); if ($resMatch2 && $this->db->num_rows($resMatch2) > 0) { $match2 = $this->db->fetch_object($resMatch2); $matched = $this->reconcileBankLine($user, $match2->rowid, $numReleve, $line->rowid, $usedBankIds); } } // Step 3: Match by supplier invoice numbers in description if (!$matched && !empty($line->description)) { $matched = $this->reconcileByInvoiceNumbers($user, $line, $bankAccountId, $numReleve, $usedBankIds); } if ($matched) { $reconciled++; } } // Copy PDF to Dolibarr's bank statement document directory $this->copyToDolibarrStatementDir($bankAccountId); return $reconciled; } /** * Reconcile a single bank line: set num_releve, rappro=1, link statement_line * * @param User $user User * @param int $bankRowId llx_bank.rowid * @param string $numReleve Statement number (e.g. "3/2025") * @param int $lineRowId llx_bankimport_statement_line.rowid * @param array &$usedBankIds Reference to array of already used bank IDs * @return bool True if reconciled successfully */ private function reconcileBankLine($user, $bankRowId, $numReleve, $lineRowId, &$usedBankIds) { $bankLine = new AccountLine($this->db); $bankLine->fetch($bankRowId); $bankLine->num_releve = $numReleve; $result = $bankLine->update_conciliation($user, 0, 1); if ($result >= 0) { $usedBankIds[] = (int) $bankRowId; // Link statement line to this bank entry $sql = "UPDATE ".MAIN_DB_PREFIX."bankimport_statement_line SET"; $sql .= " fk_bank = ".((int) $bankRowId); $sql .= ", match_status = 'reconciled'"; $sql .= " WHERE rowid = ".((int) $lineRowId); $this->db->query($sql); return true; } return false; } /** * Mark a statement line as pending review (matched by invoice number but amount * difference exceeds threshold). Sets fk_bank as candidate but does NOT reconcile * the bank entry (rappro stays 0). * * @param int $bankRowId llx_bank.rowid (candidate) * @param int $lineRowId llx_bankimport_statement_line.rowid * @return bool True if saved */ private function markPendingReview($bankRowId, $lineRowId) { $sql = "UPDATE ".MAIN_DB_PREFIX."bankimport_statement_line SET"; $sql .= " fk_bank = ".((int) $bankRowId); $sql .= ", match_status = 'pending_review'"; $sql .= " WHERE rowid = ".((int) $lineRowId); return (bool) $this->db->query($sql); } /** * Extract supplier invoice numbers from statement line description * and find matching llx_bank entries via the payment chain. * * Patterns recognized: * - /INV/9009414207 (Firmenlastschrift) * - /ADV/0014494147 (Avise) * - Beleg-Nr.: 9008468982 (Überweisungsauftrag) * * Chain: ref_supplier → llx_facture_fourn → llx_paiementfourn_facturefourn * → llx_paiementfourn → llx_bank_url → llx_bank * * @param User $user User * @param object $line Statement line object * @param int $bankAccountId Dolibarr bank account ID * @param string $numReleve Statement number (e.g. "3/2025") * @param array &$usedBankIds Reference to array of already used bank IDs * @return bool True if at least one bank entry was reconciled */ private function reconcileByInvoiceNumbers($user, $line, $bankAccountId, $numReleve, &$usedBankIds) { $desc = $line->description.' '.$line->name; // Extract invoice/reference numbers from description $refNumbers = array(); // Pattern 1: /INV/XXXXXXXXXX if (preg_match_all('/\/INV\/(\d+)/', $desc, $m)) { $refNumbers = array_merge($refNumbers, $m[1]); } // Pattern 2: /ADV/XXXXXXXXXX if (preg_match_all('/\/ADV\/(\d+)/', $desc, $m)) { $refNumbers = array_merge($refNumbers, $m[1]); } // Pattern 3: Beleg-Nr.: XXXXXXXXXX or Beleg-Nr. XXXXXXXXXX if (preg_match_all('/Beleg-Nr\.?\s*:?\s*(\d{5,})/', $desc, $m)) { $refNumbers = array_merge($refNumbers, $m[1]); } if (empty($refNumbers)) { return false; } $refNumbers = array_unique($refNumbers); // Build escaped list for SQL IN clause $escapedRefs = array(); foreach ($refNumbers as $ref) { $escapedRefs[] = "'".$this->db->escape($ref)."'"; } $inClause = implode(',', $escapedRefs); // Find llx_bank entries linked to supplier payments for these invoice numbers $sql = "SELECT DISTINCT b.rowid, b.amount"; $sql .= " FROM ".MAIN_DB_PREFIX."bank as b"; $sql .= " JOIN ".MAIN_DB_PREFIX."bank_url as bu ON bu.fk_bank = b.rowid AND bu.type = 'payment_supplier'"; $sql .= " JOIN ".MAIN_DB_PREFIX."paiementfourn as pf ON pf.rowid = bu.url_id"; $sql .= " JOIN ".MAIN_DB_PREFIX."paiementfourn_facturefourn as pfff ON pfff.fk_paiementfourn = pf.rowid"; $sql .= " JOIN ".MAIN_DB_PREFIX."facture_fourn as ff ON ff.rowid = pfff.fk_facturefourn"; $sql .= " WHERE b.fk_account = ".((int) $bankAccountId); $sql .= " AND b.rappro = 0"; $sql .= " AND ff.ref_supplier IN (".$inClause.")"; if (!empty($usedBankIds)) { $sql .= " AND b.rowid NOT IN (".implode(',', $usedBankIds).")"; } dol_syslog(get_class($this)."::reconcileByInvoiceNumbers refs=".implode(',', $refNumbers), LOG_DEBUG); $resql = $this->db->query($sql); if (!$resql) { return false; } $matched = false; $stmtAmount = abs((float) $line->amount); while ($obj = $this->db->fetch_object($resql)) { $bankAmount = abs((float) $obj->amount); $diff = abs($stmtAmount - $bankAmount); if ($diff > 5.0) { // Differenz > 5 EUR: nur als Kandidat markieren, nicht abgleichen $this->markPendingReview($obj->rowid, $line->rowid); dol_syslog(get_class($this)."::reconcileByInvoiceNumbers PENDING bank=".$obj->rowid." diff=".$diff, LOG_WARNING); } else { // Differenz <= 5 EUR: automatisch abgleichen if ($this->reconcileBankLine($user, $obj->rowid, $numReleve, $line->rowid, $usedBankIds)) { $matched = true; } } } $this->db->free($resql); return $matched; } /** * Copy the PDF file to Dolibarr's bank statement document directory * so it appears in the Documents tab of account_statement_document.php * * Target: $conf->bank->dir_output."/".$bankAccountId."/statement/".dol_sanitizeFileName($numReleve)."/" * * @param int $bankAccountId Dolibarr bank account ID * @return int 1 if OK, 0 if nothing to copy, -1 on error */ public function copyToDolibarrStatementDir($bankAccountId) { global $conf; if (empty($this->filepath) || !file_exists($this->filepath)) { return 0; } if (empty($bankAccountId) || empty($this->statement_number) || empty($this->statement_year)) { return 0; } $numReleve = $this->statement_number.'/'.$this->statement_year; $targetDir = $conf->bank->dir_output.'/'.((int) $bankAccountId).'/statement/'.dol_sanitizeFileName($numReleve); if (!is_dir($targetDir)) { dol_mkdir($targetDir); } $targetFile = $targetDir.'/'.$this->filename; // Don't copy if already exists with same size if (file_exists($targetFile) && filesize($targetFile) == filesize($this->filepath)) { return 1; } $result = @copy($this->filepath, $targetFile); if ($result) { @chmod($targetFile, 0664); return 1; } $this->error = 'Failed to copy PDF to '.$targetDir; return -1; } /** * Parse individual transaction lines from a PDF bank statement. * * Extracts booking date, value date, transaction type, amount, counterparty name * and description text from VR-Bank PDF statement format. * * @param string $filepath Path to PDF file (uses $this->filepath if empty) * @return array Array of transaction arrays, each with keys: * date_booking, date_value, transaction_type, amount, name, description */ public function parsePdfTransactions($filepath = '') { if (empty($filepath)) { $filepath = $this->filepath; } if (empty($filepath) || !file_exists($filepath)) { return array(); } $escapedPath = escapeshellarg($filepath); $textlines = array(); exec("pdftotext -layout ".$escapedPath." - 2>/dev/null", $textlines); // Determine statement year from metadata $stmtYear = !empty($this->statement_year) ? (int) $this->statement_year : (int) date('Y'); $transactions = array(); $currentTx = null; $inTransactionBlock = false; $skipPageBreak = false; // True between "Übertrag auf" and "Übertrag von" foreach ($textlines as $line) { // Stop parsing at "Anlage" (fee detail section) or "Der ausgewiesene Kontostand" if (preg_match('/^\s*Anlage\s+\d/', $line) || preg_match('/Der ausgewiesene Kontostand/', $line)) { if ($currentTx !== null) { $transactions[] = $currentTx; $currentTx = null; } break; } // Handle page breaks: skip everything between "Übertrag auf Blatt" and "Übertrag von Blatt" if (preg_match('/Übertrag\s+auf\s+Blatt/', $line)) { // Save current transaction before page break if ($currentTx !== null) { $transactions[] = $currentTx; $currentTx = null; } $skipPageBreak = true; continue; } if (preg_match('/Übertrag\s+von\s+Blatt/', $line)) { $skipPageBreak = false; continue; } if ($skipPageBreak) { continue; } // Skip blank lines and decorative lines if (preg_match('/^\s*$/', $line) || preg_match('/────/', $line)) { continue; } // Detect start of transaction block if (preg_match('/Bu-Tag\s+Wert\s+Vorgang/', $line)) { $inTransactionBlock = true; continue; } if (!$inTransactionBlock) { continue; } // Skip balance lines if (preg_match('/alter Kontostand/', $line) || preg_match('/neuer Kontostand/', $line)) { if ($currentTx !== null) { $transactions[] = $currentTx; $currentTx = null; } continue; } // Transaction line: "DD.MM. DD.MM. Vorgangsart ... Betrag S/H" if (preg_match('/^\s+(\d{2})\.(\d{2})\.\s+(\d{2})\.(\d{2})\.\s+(.+)/', $line, $m)) { // Save previous transaction if ($currentTx !== null) { $transactions[] = $currentTx; } $bookDay = (int) $m[1]; $bookMonth = (int) $m[2]; $valDay = (int) $m[3]; $valMonth = (int) $m[4]; $rest = $m[5]; $bookYear = $stmtYear; $valYear = $stmtYear; // Parse amount and S/H from the rest of the line $amount = 0; $txType = ''; if (preg_match('/^(.+?)\s+([\d.,]+)\s+(S|H)\s*$/', $rest, $am)) { $txType = trim($am[1]); $amount = self::parseGermanAmount($am[2]); if ($am[3] === 'S') { $amount = -$amount; } } else { $txType = trim($rest); } // Build date strings $dateBooking = sprintf('%04d-%02d-%02d', $bookYear, $bookMonth, $bookDay); $dateValue = sprintf('%04d-%02d-%02d', $valYear, $valMonth, $valDay); $currentTx = array( 'date_booking' => $dateBooking, 'date_value' => $dateValue, 'transaction_type' => $txType, 'amount' => $amount, 'name' => '', 'description' => '', ); } elseif ($currentTx !== null) { // Continuation line (counterparty name or description) $detail = trim($line); if (!empty($detail)) { if (empty($currentTx['name'])) { $currentTx['name'] = $detail; } else { $currentTx['description'] .= ($currentTx['description'] ? ' ' : '').$detail; } } } } // Don't forget the last transaction if ($currentTx !== null) { $transactions[] = $currentTx; } return $transactions; } /** * Save parsed transaction lines to the database. * Deletes existing lines for this statement first, then inserts new ones. * * @param array $transactions Array from parsePdfTransactions() * @return int Number of lines saved, or -1 on error */ public function saveStatementLines($transactions) { if (empty($this->id)) { $this->error = 'Statement not saved yet'; return -1; } $this->db->begin(); // Delete existing lines for this statement $sql = "DELETE FROM ".MAIN_DB_PREFIX."bankimport_statement_line"; $sql .= " WHERE fk_statement = ".((int) $this->id); $this->db->query($sql); $now = dol_now(); $count = 0; foreach ($transactions as $i => $tx) { $lineNum = $i + 1; $sql = "INSERT INTO ".MAIN_DB_PREFIX."bankimport_statement_line ("; $sql .= "fk_statement, entity, line_number, date_booking, date_value,"; $sql .= "transaction_type, amount, currency, name, description, datec"; $sql .= ") VALUES ("; $sql .= ((int) $this->id).","; $sql .= ((int) $this->entity).","; $sql .= ((int) $lineNum).","; $sql .= "'".$this->db->escape($tx['date_booking'])."',"; $sql .= (!empty($tx['date_value']) ? "'".$this->db->escape($tx['date_value'])."'" : "NULL").","; $sql .= (!empty($tx['transaction_type']) ? "'".$this->db->escape($tx['transaction_type'])."'" : "NULL").","; $sql .= ((float) $tx['amount']).","; $sql .= "'EUR',"; $sql .= (!empty($tx['name']) ? "'".$this->db->escape($tx['name'])."'" : "NULL").","; $sql .= (!empty($tx['description']) ? "'".$this->db->escape($tx['description'])."'" : "NULL").","; $sql .= "'".$this->db->idate($now)."'"; $sql .= ")"; $resql = $this->db->query($sql); if ($resql) { $count++; } else { $this->error = $this->db->lasterror(); $this->db->rollback(); return -1; } } $this->db->commit(); return $count; } /** * Get statement lines from database * * @return array Array of line objects, or -1 on error */ public function getStatementLines() { $sql = "SELECT rowid, fk_statement, line_number, date_booking, date_value,"; $sql .= " transaction_type, amount, currency, name, description, fk_bank, match_status"; $sql .= " FROM ".MAIN_DB_PREFIX."bankimport_statement_line"; $sql .= " WHERE fk_statement = ".((int) $this->id); $sql .= " ORDER BY line_number ASC"; $resql = $this->db->query($sql); if (!$resql) { $this->error = $this->db->lasterror(); return -1; } $lines = array(); while ($obj = $this->db->fetch_object($resql)) { $lines[] = $obj; } $this->db->free($resql); return $lines; } /** * Link transactions to this statement based on date range and IBAN * * Updates all transactions that fall within the statement's date range * and match the IBAN, setting their fk_statement to this statement's ID. * * @return int Number of linked transactions, or -1 on error */ public function linkTransactions() { if (empty($this->id) || empty($this->date_from) || empty($this->date_to)) { return 0; } $sql = "UPDATE ".MAIN_DB_PREFIX."bankimport_transaction SET"; $sql .= " fk_statement = ".((int) $this->id); $sql .= " WHERE entity = ".((int) $this->entity); $sql .= " AND date_trans >= '".$this->db->idate($this->date_from)."'"; $sql .= " AND date_trans <= '".$this->db->idate($this->date_to)."'"; $sql .= " AND fk_statement IS NULL"; // Don't overwrite existing links // Match by IBAN if available if (!empty($this->iban)) { $ibanClean = preg_replace('/\s+/', '', $this->iban); $sql .= " AND REPLACE(iban, ' ', '') = '".$this->db->escape($ibanClean)."'"; } dol_syslog(get_class($this)."::linkTransactions", LOG_DEBUG); $resql = $this->db->query($sql); if ($resql) { return $this->db->affected_rows($resql); } else { $this->error = $this->db->lasterror(); return -1; } } }