diff --git a/class/bankstatement.class.php b/class/bankstatement.class.php index affa95e..9dbe529 100755 --- a/class/bankstatement.class.php +++ b/class/bankstatement.class.php @@ -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 * diff --git a/langs/de_DE/bankimport.lang b/langs/de_DE/bankimport.lang index 553ceb8..be8e2c1 100755 --- a/langs/de_DE/bankimport.lang +++ b/langs/de_DE/bankimport.lang @@ -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 diff --git a/langs/en_US/bankimport.lang b/langs/en_US/bankimport.lang index f21a6d0..f91b265 100755 --- a/langs/en_US/bankimport.lang +++ b/langs/en_US/bankimport.lang @@ -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 diff --git a/pdfstatements.php b/pdfstatements.php index 79134a3..e20933a 100755 --- a/pdfstatements.php +++ b/pdfstatements.php @@ -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 ' '; + print ''; + print img_picto('', 'bank', 'class="pictofixedwidth"').$langs->trans("ReconcileAllStatements"); + print ''; + print ''; +} else { + print '
'; + print img_warning().' '.$langs->trans("NoBankAccountConfigured"); + print ' '.$langs->trans("GoToSetup").''; + print '
'; +} + // List of existing PDF statements print '
'; print ''; @@ -573,7 +729,7 @@ print ''; print ''; print ''; print ''; -print ''; +print ''; print ''; $filter = array(); @@ -657,21 +813,28 @@ if (is_array($records) && count($records) > 0) { print ''; // Actions - print '
'.$langs->trans("OpeningBalance").''.$langs->trans("ClosingBalance").''.$langs->trans("Size").''.$langs->trans("DateCreation").''.$langs->trans("Actions").''.$langs->trans("Actions").'
'; + print ''; if ($obj->filepath && file_exists($obj->filepath)) { // View (inline) - print 'id.'&token='.newToken().'" target="_blank" title="'.$langs->trans("View").'">'; + print 'id.'&token='.newToken().'" target="_blank" title="'.$langs->trans("View").'">'; print img_picto($langs->trans("View"), 'eye'); print ''; // Download - print 'id.'&token='.newToken().'" title="'.$langs->trans("Download").'">'; + print 'id.'&token='.newToken().'" title="'.$langs->trans("Download").'">'; print img_picto($langs->trans("Download"), 'download'); print ''; } + // Reconcile + if (!empty($reconcileBankAccountId) && $obj->date_from && $obj->date_to) { + print 'id.'&year='.$year.'&token='.newToken().'" title="'.$langs->trans("ReconcileStatement").'">'; + print img_picto($langs->trans("ReconcileStatement"), 'bank'); + print ''; + } + // Delete - print 'id.'&year='.$year.'&token='.newToken().'" title="'.$langs->trans("Delete").'">'; + print 'id.'&year='.$year.'&token='.newToken().'" title="'.$langs->trans("Delete").'">'; print img_picto($langs->trans("Delete"), 'delete'); print ''; @@ -688,6 +851,93 @@ if (is_array($records) && count($records) > 0) { print '
'; print '
'; +// 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 '
'; + print '
'; + print ''; + print ''; + print ''; + print ''; + + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + + 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 ''; + + // Statement number + print ''; + + // Booking date + print ''; + + // Name + print ''; + + // Amount from PDF statement + print ''; + + // Amount from Dolibarr bank + print ''; + + // Difference + print ''; + + // Bank entry link + print ''; + + // Action: confirm button + print ''; + + print ''; + } + + print '
'; + print img_warning().' '.$langs->trans("PendingReconciliationMatches").''; + print ' - '.$langs->trans("PendingReconciliationMatchesDesc"); + print '
'.$langs->trans("StatementNumber").''.$langs->trans("BookingDate").''.$langs->trans("Name").''.$langs->trans("AmountStatement").''.$langs->trans("AmountDolibarr").''.$langs->trans("Difference").''.$langs->trans("BankEntry").''.$langs->trans("Action").'
'.$pendObj->statement_number.'/'.$pendObj->statement_year.''.dol_print_date($db->jdate($pendObj->date_booking), 'day').''.dol_escape_htmltag($pendObj->stmt_name).''; + $stmtColor = $pendObj->stmt_amount >= 0 ? '' : 'color: red;'; + print ''.price($pendObj->stmt_amount, 0, $langs, 1, -1, 2, 'EUR').''; + print ''; + $bankColor = $pendObj->bank_amount >= 0 ? '' : 'color: red;'; + print ''.price($pendObj->bank_amount, 0, $langs, 1, -1, 2, 'EUR').''; + print ''; + print ''.price($diff, 0, $langs, 1, -1, 2, 'EUR').''; + print ''; + print '#'.$pendObj->bank_id.''; + print ''; + print 'line_id.'&bankid='.$pendObj->bank_id.'&year='.$year.'&token='.newToken().'">'; + print $langs->trans("Confirm"); + print ''; + print '
'; + print '
'; +} +$db->free($resPending); + // Statistics $totalCount = $statement->fetchAll('', '', 0, 0, array(), 'count'); $yearCount = is_array($records) ? count($records) : 0; diff --git a/sql/dolibarr_allversions.sql b/sql/dolibarr_allversions.sql index 2ca494e..e69da3d 100755 --- a/sql/dolibarr_allversions.sql +++ b/sql/dolibarr_allversions.sql @@ -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; diff --git a/sql/llx_bankimport_statement_line.key.sql b/sql/llx_bankimport_statement_line.key.sql new file mode 100644 index 0000000..aa0516e --- /dev/null +++ b/sql/llx_bankimport_statement_line.key.sql @@ -0,0 +1,15 @@ +-- Copyright (C) 2026 Eduard Wisch +-- +-- 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); diff --git a/sql/llx_bankimport_statement_line.sql b/sql/llx_bankimport_statement_line.sql new file mode 100644 index 0000000..cd49e21 --- /dev/null +++ b/sql/llx_bankimport_statement_line.sql @@ -0,0 +1,34 @@ +-- Copyright (C) 2026 Eduard Wisch +-- +-- 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;