Version 1.3: PDF-Parsing, Kontoauszugsabgleich, Rechnungsnummer-Matching

- Neue Tabelle llx_bankimport_statement_line für geparste PDF-Buchungszeilen
- parsePdfTransactions(): Extrahiert Einzelbuchungen aus VR-Bank PDF-Auszügen
- reconcileBankEntries(): 3-stufiger Abgleich (Betrag+Datum, Datumstoleranz, Rechnungsnummern)
- reconcileByInvoiceNumbers(): Matching über /INV/, /ADV/ und Beleg-Nr. im Verwendungszweck
- 5-EUR-Schwelle: Betragsabweichungen >5 EUR erfordern manuelle Bestätigung
- Pending-Review-Anzeige mit Bestätigungsbutton auf der PDF-Kontoauszüge-Seite
- match_status Spalte für Approval-Workflow (reconciled/pending_review)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Eduard Wisch 2026-02-15 10:02:01 +01:00
parent 31daf7ec94
commit be8a02e88e
7 changed files with 984 additions and 6 deletions

View file

@ -339,6 +339,11 @@ class BankImportStatement extends CommonObject
@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);
@ -755,6 +760,531 @@ class BankImportStatement extends CommonObject
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
*

View file

@ -246,3 +246,64 @@ ViewPDFStatement = PDF-Kontoauszug anzeigen
PermBankImportRead = Bankimport: Buchungen und Kontoauszüge ansehen
PermBankImportWrite = Bankimport: Kontoauszüge abrufen und PDF hochladen
PermBankImportDelete = Bankimport: Buchungen und Kontoauszüge löschen
#
# Zahlungsabgleich
#
PaymentConfirmation = Zahlungsabgleich
PaymentConfirmationDesc = Bankbuchungen mit Rechnungen abgleichen und Zahlungen in Dolibarr erstellen.
PendingPaymentMatches = %s neue Bankbuchungen warten auf Zuordnung
PendingPaymentMatchesDesc = Prüfen und bestätigen Sie die Zuordnungen, um Zahlungen in Dolibarr zu erstellen.
ReviewAndConfirm = Zuordnungen prüfen
ConfirmPayment = Zahlung bestätigen
ConfirmAllHighScore = Alle sicheren Zuordnungen bestätigen (Score >= 80%%)
PaymentCreatedSuccessfully = Zahlung erstellt: %s - %s
PaymentCreatedByBankImport = Automatisch erstellt durch Bankimport
ErrorNoBankAccountConfigured = Kein Dolibarr-Bankkonto konfiguriert. Bitte in den Einstellungen zuordnen.
NoBankAccountConfigured = Es ist noch kein Dolibarr-Bankkonto zugeordnet.
TransactionAlreadyProcessed = Buchung wurde bereits verarbeitet
PaymentsCreatedSummary = %s Zahlungen erstellt, %s fehlgeschlagen
NoNewMatchesFound = Keine neuen Zuordnungen gefunden
Alternatives = weitere Zuordnung(en)
UnmatchedTransactions = %s neue Buchungen ohne Rechnungszuordnung
InvoiceAlreadyPaid = Rechnung ist bereits vollständig bezahlt
#
# Bankkonto-Zuordnung
#
BankAccountMapping = Dolibarr-Bankkonto Zuordnung
DolibarrBankAccount = Dolibarr Bankkonto
DolibarrBankAccountHelp = Wählen Sie das Dolibarr-Bankkonto, das der konfigurierten IBAN entspricht. Zahlungen werden auf dieses Konto gebucht.
SelectBankAccount = -- Bankkonto auswählen --
#
# Kontoauszugsabgleich
#
BankEntriesReconciled = %s Bankbuchungen mit Auszug %s abgeglichen
BankEntriesReconciledTotal = %s Bankbuchungen über %s Kontoauszüge abgeglichen
ReconcileStatement = Kontoauszug abgleichen
ReconcileAllStatements = Alle Kontoauszüge abgleichen
NoBankEntriesToReconcile = Keine offenen Bankbuchungen zum Abgleichen gefunden
StatementMissingDates = Kontoauszug hat keinen Zeitraum (Von/Bis) - Abgleich nicht möglich
#
# Kontoauszugs-Positionen (Statement Lines)
#
StatementLinesExtracted = %s Buchungszeilen aus Auszug %s extrahiert
StatementLines = Buchungszeilen
BookingDate = Buchungstag
ValueDate = Wertstellung
TransactionType = Vorgangsart
NoStatementLines = Keine Buchungszeilen vorhanden
#
# Ausstehende Zuordnungen (Pending Review)
#
PendingReconciliationMatches = Zuordnungen mit Betragsabweichung
PendingReconciliationMatchesDesc = Diese Zuordnungen wurden über Rechnungsnummern erkannt, aber der Betrag weicht um mehr als 5 EUR ab. Bitte prüfen und bestätigen.
AmountStatement = Betrag (Auszug)
AmountDolibarr = Betrag (Dolibarr)
Difference = Differenz
BankEntry = Bank-Eintrag
ReconciliationConfirmed = Zuordnung bestätigt und abgeglichen
Confirm = Bestätigen

View file

@ -142,3 +142,64 @@ BankImportAboutPage = BankImport about page
# Home
#
BankImportArea = BankImport Home
#
# Payment Confirmation
#
PaymentConfirmation = Payment Matching
PaymentConfirmationDesc = Match bank transactions to invoices and create payments in Dolibarr.
PendingPaymentMatches = %s new bank transactions waiting for matching
PendingPaymentMatchesDesc = Review and confirm matches to create payments in Dolibarr.
ReviewAndConfirm = Review matches
ConfirmPayment = Confirm payment
ConfirmAllHighScore = Confirm all high-score matches (score >= 80%%)
PaymentCreatedSuccessfully = Payment created: %s - %s
PaymentCreatedByBankImport = Automatically created by BankImport
ErrorNoBankAccountConfigured = No Dolibarr bank account configured. Please configure in settings.
NoBankAccountConfigured = No Dolibarr bank account mapped yet.
TransactionAlreadyProcessed = Transaction already processed
PaymentsCreatedSummary = %s payments created, %s failed
NoNewMatchesFound = No new matches found
Alternatives = more match(es)
UnmatchedTransactions = %s new transactions without invoice match
InvoiceAlreadyPaid = Invoice is already fully paid
#
# Bank Account Mapping
#
BankAccountMapping = Dolibarr Bank Account Mapping
DolibarrBankAccount = Dolibarr Bank Account
DolibarrBankAccountHelp = Select the Dolibarr bank account corresponding to the configured IBAN. Payments will be booked to this account.
SelectBankAccount = -- Select bank account --
#
# Statement Reconciliation
#
BankEntriesReconciled = %s bank entries reconciled with statement %s
BankEntriesReconciledTotal = %s bank entries reconciled across %s statements
ReconcileStatement = Reconcile statement
ReconcileAllStatements = Reconcile all statements
NoBankEntriesToReconcile = No open bank entries found to reconcile
StatementMissingDates = Statement has no date range (from/to) - reconciliation not possible
#
# Statement Lines
#
StatementLinesExtracted = %s transaction lines extracted from statement %s
StatementLines = Transaction Lines
BookingDate = Booking Date
ValueDate = Value Date
TransactionType = Transaction Type
NoStatementLines = No transaction lines available
#
# Pending Review Matches
#
PendingReconciliationMatches = Matches with Amount Deviation
PendingReconciliationMatchesDesc = These matches were found via invoice numbers but the amount differs by more than 5 EUR. Please review and confirm.
AmountStatement = Amount (Statement)
AmountDolibarr = Amount (Dolibarr)
Difference = Difference
BankEntry = Bank Entry
ReconciliationConfirmed = Match confirmed and reconciled
Confirm = Confirm

View file

@ -249,9 +249,33 @@ if ($action == 'upload' && !empty($_FILES['pdffile'])) {
$result = $stmt->create($user);
if ($result > 0) {
// Link matching transactions to this statement
// Link matching FinTS transactions to this statement
$linked = $stmt->linkTransactions();
$totalLinked += max(0, $linked);
// Parse PDF transaction lines and save to database
$pdfLines = $stmt->parsePdfTransactions();
if (!empty($pdfLines)) {
$linesSaved = $stmt->saveStatementLines($pdfLines);
if ($linesSaved > 0) {
setEventMessages($langs->trans("StatementLinesExtracted", $linesSaved, $stmt->statement_number.'/'.$stmt->statement_year), null, 'mesgs');
}
}
// Copy PDF to Dolibarr's bank statement document directory
$uploadBankAccountId = getDolGlobalInt('BANKIMPORT_BANK_ACCOUNT_ID');
if ($uploadBankAccountId > 0) {
$stmt->copyToDolibarrStatementDir($uploadBankAccountId);
}
// Reconcile bank entries if bank account is configured
if ($uploadBankAccountId > 0) {
$reconciledCount = $stmt->reconcileBankEntries($user, $uploadBankAccountId);
if ($reconciledCount > 0) {
setEventMessages($langs->trans("BankEntriesReconciled", $reconciledCount, $stmt->statement_number.'/'.$stmt->statement_year), null, 'mesgs');
}
}
$uploadedCount++;
$lastYear = $stmt->statement_year;
} else {
@ -329,6 +353,123 @@ if ($action == 'view') {
}
}
// Reconcile single statement
if ($action == 'reconcile') {
if (!$user->hasRight('bankimport', 'write')) {
accessforbidden();
}
$id = GETPOSTINT('id');
$reconcileBankAccountId = getDolGlobalInt('BANKIMPORT_BANK_ACCOUNT_ID');
if (empty($reconcileBankAccountId)) {
setEventMessages($langs->trans("ErrorNoBankAccountConfigured"), null, 'errors');
} elseif ($statement->fetch($id) > 0) {
// Parse statement lines if not yet done
$existingLines = $statement->getStatementLines();
if (is_array($existingLines) && empty($existingLines)) {
$pdfLines = $statement->parsePdfTransactions();
if (!empty($pdfLines)) {
$statement->saveStatementLines($pdfLines);
}
}
$reconciledCount = $statement->reconcileBankEntries($user, $reconcileBankAccountId);
if ($reconciledCount > 0) {
setEventMessages($langs->trans("BankEntriesReconciled", $reconciledCount, $statement->statement_number.'/'.$statement->statement_year), null, 'mesgs');
} else {
setEventMessages($langs->trans("NoBankEntriesToReconcile"), null, 'warnings');
}
}
$action = '';
}
// Reconcile all statements
if ($action == 'reconcileall') {
if (!$user->hasRight('bankimport', 'write')) {
accessforbidden();
}
$reconcileBankAccountId = getDolGlobalInt('BANKIMPORT_BANK_ACCOUNT_ID');
if (empty($reconcileBankAccountId)) {
setEventMessages($langs->trans("ErrorNoBankAccountConfigured"), null, 'errors');
} else {
$allStatements = $statement->fetchAll('statement_year,statement_number', 'ASC', 0, 0, array());
$totalReconciled = 0;
$stmtCount = 0;
if (is_array($allStatements)) {
foreach ($allStatements as $stmt) {
// Parse statement lines if not yet done
$existingLines = $stmt->getStatementLines();
if (is_array($existingLines) && empty($existingLines)) {
$pdfLines = $stmt->parsePdfTransactions();
if (!empty($pdfLines)) {
$stmt->saveStatementLines($pdfLines);
}
}
$count = $stmt->reconcileBankEntries($user, $reconcileBankAccountId);
if ($count > 0) {
$totalReconciled += $count;
$stmtCount++;
}
}
}
if ($totalReconciled > 0) {
setEventMessages($langs->trans("BankEntriesReconciledTotal", $totalReconciled, $stmtCount), null, 'mesgs');
} else {
setEventMessages($langs->trans("NoBankEntriesToReconcile"), null, 'warnings');
}
}
$action = '';
}
// Confirm a pending reconciliation match
if ($action == 'confirmreconcile') {
if (!$user->hasRight('bankimport', 'write')) {
accessforbidden();
}
$lineId = GETPOSTINT('lineid');
$bankId = GETPOSTINT('bankid');
$reconcileBankAccountId = getDolGlobalInt('BANKIMPORT_BANK_ACCOUNT_ID');
if ($lineId > 0 && $bankId > 0 && $reconcileBankAccountId > 0) {
require_once DOL_DOCUMENT_ROOT.'/compta/bank/class/account.class.php';
// Get statement info from line
$sqlLine = "SELECT sl.fk_statement, s.statement_number, s.statement_year";
$sqlLine .= " FROM ".MAIN_DB_PREFIX."bankimport_statement_line sl";
$sqlLine .= " JOIN ".MAIN_DB_PREFIX."bankimport_statement s ON s.rowid = sl.fk_statement";
$sqlLine .= " WHERE sl.rowid = ".((int) $lineId);
$resLine = $db->query($sqlLine);
if ($resLine && $db->num_rows($resLine) > 0) {
$lineObj = $db->fetch_object($resLine);
$numReleve = $lineObj->statement_number.'/'.$lineObj->statement_year;
// Reconcile the bank entry
$bankLine = new AccountLine($db);
$bankLine->fetch($bankId);
$bankLine->num_releve = $numReleve;
$result = $bankLine->update_conciliation($user, 0, 1);
if ($result >= 0) {
// Update statement line status
$sqlUpd = "UPDATE ".MAIN_DB_PREFIX."bankimport_statement_line SET";
$sqlUpd .= " match_status = 'reconciled'";
$sqlUpd .= " WHERE rowid = ".((int) $lineId);
$db->query($sqlUpd);
setEventMessages($langs->trans("ReconciliationConfirmed"), null, 'mesgs');
} else {
setEventMessages($langs->trans("Error"), null, 'errors');
}
}
}
$action = '';
}
// Delete confirmation
if ($action == 'delete' && $confirm == 'yes') {
$id = GETPOSTINT('id');
@ -561,6 +702,21 @@ print ' <input type="submit" class="button smallpaddingimp" value="'.$langs->tra
print '</div>';
print '</form>';
// Reconcile All button
$reconcileBankAccountId = getDolGlobalInt('BANKIMPORT_BANK_ACCOUNT_ID');
if (!empty($reconcileBankAccountId)) {
print '<div class="right" style="margin-bottom: 10px;">';
print '<a class="butAction" href="'.$_SERVER["PHP_SELF"].'?action=reconcileall&year='.$year.'&token='.newToken().'">';
print img_picto('', 'bank', 'class="pictofixedwidth"').$langs->trans("ReconcileAllStatements");
print '</a>';
print '</div>';
} else {
print '<div class="warning" style="margin-bottom: 10px;">';
print img_warning().' '.$langs->trans("NoBankAccountConfigured");
print ' <a href="'.dol_buildpath('/bankimport/admin/setup.php', 1).'">'.$langs->trans("GoToSetup").'</a>';
print '</div>';
}
// List of existing PDF statements
print '<div class="div-table-responsive">';
print '<table class="noborder centpercent">';
@ -573,7 +729,7 @@ print '<th class="right">'.$langs->trans("OpeningBalance").'</th>';
print '<th class="right">'.$langs->trans("ClosingBalance").'</th>';
print '<th class="right">'.$langs->trans("Size").'</th>';
print '<th class="center">'.$langs->trans("DateCreation").'</th>';
print '<th class="center" width="150">'.$langs->trans("Actions").'</th>';
print '<th class="center" width="200">'.$langs->trans("Actions").'</th>';
print '</tr>';
$filter = array();
@ -657,21 +813,28 @@ if (is_array($records) && count($records) > 0) {
print '</td>';
// Actions
print '<td class="center nowraponall">';
print '<td class="center nowraponall" style="white-space: nowrap;">';
if ($obj->filepath && file_exists($obj->filepath)) {
// View (inline)
print '<a class="paddingright" href="'.$_SERVER["PHP_SELF"].'?action=view&id='.$obj->id.'&token='.newToken().'" target="_blank" title="'.$langs->trans("View").'">';
print '<a style="margin: 0 6px;" href="'.$_SERVER["PHP_SELF"].'?action=view&id='.$obj->id.'&token='.newToken().'" target="_blank" title="'.$langs->trans("View").'">';
print img_picto($langs->trans("View"), 'eye');
print '</a>';
// Download
print '<a class="paddingright" href="'.$_SERVER["PHP_SELF"].'?action=download&id='.$obj->id.'&token='.newToken().'" title="'.$langs->trans("Download").'">';
print '<a style="margin: 0 6px;" href="'.$_SERVER["PHP_SELF"].'?action=download&id='.$obj->id.'&token='.newToken().'" title="'.$langs->trans("Download").'">';
print img_picto($langs->trans("Download"), 'download');
print '</a>';
}
// Reconcile
if (!empty($reconcileBankAccountId) && $obj->date_from && $obj->date_to) {
print '<a style="margin: 0 6px;" href="'.$_SERVER["PHP_SELF"].'?action=reconcile&id='.$obj->id.'&year='.$year.'&token='.newToken().'" title="'.$langs->trans("ReconcileStatement").'">';
print img_picto($langs->trans("ReconcileStatement"), 'bank');
print '</a>';
}
// Delete
print '<a class="paddingright" href="'.$_SERVER["PHP_SELF"].'?action=delete&id='.$obj->id.'&year='.$year.'&token='.newToken().'" title="'.$langs->trans("Delete").'">';
print '<a style="margin: 0 6px;" href="'.$_SERVER["PHP_SELF"].'?action=delete&id='.$obj->id.'&year='.$year.'&token='.newToken().'" title="'.$langs->trans("Delete").'">';
print img_picto($langs->trans("Delete"), 'delete');
print '</a>';
@ -688,6 +851,93 @@ if (is_array($records) && count($records) > 0) {
print '</table>';
print '</div>';
// Pending review matches
$sqlPending = "SELECT sl.rowid as line_id, sl.fk_statement, sl.line_number, sl.date_booking, sl.amount as stmt_amount,";
$sqlPending .= " sl.name as stmt_name, sl.fk_bank,";
$sqlPending .= " b.rowid as bank_id, b.datev, b.amount as bank_amount, b.label as bank_label,";
$sqlPending .= " s.statement_number, s.statement_year";
$sqlPending .= " FROM ".MAIN_DB_PREFIX."bankimport_statement_line sl";
$sqlPending .= " JOIN ".MAIN_DB_PREFIX."bankimport_statement s ON s.rowid = sl.fk_statement";
$sqlPending .= " JOIN ".MAIN_DB_PREFIX."bank b ON b.rowid = sl.fk_bank";
$sqlPending .= " WHERE sl.match_status = 'pending_review'";
$sqlPending .= " AND sl.entity = ".((int) $conf->entity);
$sqlPending .= " ORDER BY s.statement_year, s.statement_number, sl.line_number";
$resPending = $db->query($sqlPending);
if ($resPending && $db->num_rows($resPending) > 0) {
print '<br>';
print '<div class="div-table-responsive">';
print '<table class="noborder centpercent">';
print '<tr class="liste_titre">';
print '<td colspan="8">';
print img_warning().' <strong>'.$langs->trans("PendingReconciliationMatches").'</strong>';
print ' - '.$langs->trans("PendingReconciliationMatchesDesc");
print '</td>';
print '</tr>';
print '<tr class="liste_titre">';
print '<th class="center">'.$langs->trans("StatementNumber").'</th>';
print '<th class="center">'.$langs->trans("BookingDate").'</th>';
print '<th>'.$langs->trans("Name").'</th>';
print '<th class="right">'.$langs->trans("AmountStatement").'</th>';
print '<th class="right">'.$langs->trans("AmountDolibarr").'</th>';
print '<th class="right">'.$langs->trans("Difference").'</th>';
print '<th class="center">'.$langs->trans("BankEntry").'</th>';
print '<th class="center">'.$langs->trans("Action").'</th>';
print '</tr>';
while ($pendObj = $db->fetch_object($resPending)) {
$diff = abs(abs((float) $pendObj->stmt_amount) - abs((float) $pendObj->bank_amount));
$diffColor = ($diff > 10) ? 'color: red; font-weight: bold;' : 'color: #e68a00;';
print '<tr class="oddeven">';
// Statement number
print '<td class="center nowraponall">'.$pendObj->statement_number.'/'.$pendObj->statement_year.'</td>';
// Booking date
print '<td class="center">'.dol_print_date($db->jdate($pendObj->date_booking), 'day').'</td>';
// Name
print '<td>'.dol_escape_htmltag($pendObj->stmt_name).'</td>';
// Amount from PDF statement
print '<td class="right nowraponall">';
$stmtColor = $pendObj->stmt_amount >= 0 ? '' : 'color: red;';
print '<span style="'.$stmtColor.'">'.price($pendObj->stmt_amount, 0, $langs, 1, -1, 2, 'EUR').'</span>';
print '</td>';
// Amount from Dolibarr bank
print '<td class="right nowraponall">';
$bankColor = $pendObj->bank_amount >= 0 ? '' : 'color: red;';
print '<span style="'.$bankColor.'">'.price($pendObj->bank_amount, 0, $langs, 1, -1, 2, 'EUR').'</span>';
print '</td>';
// Difference
print '<td class="right nowraponall">';
print '<span style="'.$diffColor.'">'.price($diff, 0, $langs, 1, -1, 2, 'EUR').'</span>';
print '</td>';
// Bank entry link
print '<td class="center">';
print '<a href="'.DOL_URL_ROOT.'/compta/bank/line.php?rowid='.$pendObj->bank_id.'" target="_blank">#'.$pendObj->bank_id.'</a>';
print '</td>';
// Action: confirm button
print '<td class="center nowraponall">';
print '<a class="butAction butActionSmall" href="'.$_SERVER["PHP_SELF"].'?action=confirmreconcile&lineid='.$pendObj->line_id.'&bankid='.$pendObj->bank_id.'&year='.$year.'&token='.newToken().'">';
print $langs->trans("Confirm");
print '</a>';
print '</td>';
print '</tr>';
}
print '</table>';
print '</div>';
}
$db->free($resPending);
// Statistics
$totalCount = $statement->fetchAll('', '', 0, 0, array(), 'count');
$yearCount = is_array($records) ? count($records) : 0;

View file

@ -5,3 +5,30 @@
-- v1.1: Add fk_statement to transaction table
ALTER TABLE llx_bankimport_transaction ADD COLUMN fk_statement INTEGER AFTER fk_societe;
ALTER TABLE llx_bankimport_transaction ADD INDEX idx_bankimport_transaction_fk_statement (fk_statement);
-- v1.3: Add statement_line table for parsed PDF transactions
CREATE TABLE IF NOT EXISTS llx_bankimport_statement_line (
rowid INTEGER AUTO_INCREMENT PRIMARY KEY,
fk_statement INTEGER NOT NULL,
entity INTEGER DEFAULT 1 NOT NULL,
line_number INTEGER DEFAULT 0,
date_booking DATE NOT NULL,
date_value DATE,
transaction_type VARCHAR(100),
amount DOUBLE(24,8) NOT NULL,
currency VARCHAR(3) DEFAULT 'EUR',
name VARCHAR(255),
description TEXT,
fk_bank INTEGER,
datec DATETIME,
tms TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB;
ALTER TABLE llx_bankimport_statement_line ADD INDEX idx_bankimport_stmtline_fk_statement (fk_statement);
ALTER TABLE llx_bankimport_statement_line ADD INDEX idx_bankimport_stmtline_entity (entity);
ALTER TABLE llx_bankimport_statement_line ADD INDEX idx_bankimport_stmtline_date_booking (date_booking);
ALTER TABLE llx_bankimport_statement_line ADD INDEX idx_bankimport_stmtline_amount (amount);
ALTER TABLE llx_bankimport_statement_line ADD INDEX idx_bankimport_stmtline_fk_bank (fk_bank);
ALTER TABLE llx_bankimport_statement_line ADD UNIQUE INDEX uk_bankimport_stmtline (fk_statement, line_number, entity);
-- v1.4: Add match_status to statement_line for approval workflow
ALTER TABLE llx_bankimport_statement_line ADD COLUMN match_status VARCHAR(20) DEFAULT NULL AFTER fk_bank;

View file

@ -0,0 +1,15 @@
-- Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
--
-- This program is free software: you can redistribute it and/or modify
-- it under the terms of the GNU General Public License as published by
-- the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
-- Indexes for llx_bankimport_statement_line
ALTER TABLE llx_bankimport_statement_line ADD INDEX idx_bankimport_stmtline_fk_statement (fk_statement);
ALTER TABLE llx_bankimport_statement_line ADD INDEX idx_bankimport_stmtline_entity (entity);
ALTER TABLE llx_bankimport_statement_line ADD INDEX idx_bankimport_stmtline_date_booking (date_booking);
ALTER TABLE llx_bankimport_statement_line ADD INDEX idx_bankimport_stmtline_amount (amount);
ALTER TABLE llx_bankimport_statement_line ADD INDEX idx_bankimport_stmtline_fk_bank (fk_bank);
ALTER TABLE llx_bankimport_statement_line ADD UNIQUE INDEX uk_bankimport_stmtline (fk_statement, line_number, entity);

View file

@ -0,0 +1,34 @@
-- Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
--
-- This program is free software: you can redistribute it and/or modify
-- it under the terms of the GNU General Public License as published by
-- the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
-- Table for individual transaction lines parsed from PDF bank statements
CREATE TABLE llx_bankimport_statement_line (
rowid INTEGER AUTO_INCREMENT PRIMARY KEY,
fk_statement INTEGER NOT NULL, -- Link to llx_bankimport_statement
entity INTEGER DEFAULT 1 NOT NULL,
line_number INTEGER DEFAULT 0, -- Position within statement (1, 2, 3...)
-- Transaction data from PDF
date_booking DATE NOT NULL, -- Buchungstag (Bu-Tag)
date_value DATE, -- Wertstellungstag (Wert)
transaction_type VARCHAR(100), -- Vorgangsart (e.g. Überweisungsgutschr., Basislastschrift)
amount DOUBLE(24,8) NOT NULL, -- Amount (positive = credit, negative = debit)
currency VARCHAR(3) DEFAULT 'EUR',
-- Counterparty and description
name VARCHAR(255), -- Counterparty name (first detail line)
description TEXT, -- Full description (all detail lines)
-- Matching
fk_bank INTEGER, -- Link to llx_bank when reconciled
match_status VARCHAR(20) DEFAULT NULL, -- NULL=unmatched, reconciled=auto, pending_review=needs approval
-- Timestamps
datec DATETIME,
tms TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB;