* * 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/banktransaction.class.php * \ingroup bankimport * \brief Class for bank transactions from FinTS import */ require_once DOL_DOCUMENT_ROOT.'/core/class/commonobject.class.php'; /** * Class BankImportTransaction * Represents a bank transaction imported via FinTS */ class BankImportTransaction extends CommonObject { /** * @var string ID to identify managed object */ public $element = 'banktransaction'; /** * @var string Name of table without prefix where object is stored */ public $table_element = 'bankimport_transaction'; /** * @var string Primary key name */ public $pk = 'rowid'; /** * @var int Entity */ public $entity; /** * @var string Unique reference (hash) */ public $ref; /** * @var string IBAN */ public $iban; /** * @var string BIC */ public $bic; /** * @var int Creation timestamp */ public $datec; /** * @var int Transaction date */ public $date_trans; /** * @var int Value date */ public $date_value; /** * @var string Counterparty name */ public $name; /** * @var string Counterparty IBAN */ public $counterparty_iban; /** * @var string Counterparty BIC */ public $counterparty_bic; /** * @var float Amount */ public $amount; /** * @var string Currency */ public $currency = 'EUR'; /** * @var string Short label */ public $label; /** * @var string Full description */ public $description; /** * @var string End-to-end ID */ public $end_to_end_id; /** * @var string Mandate ID */ public $mandate_id; /** * @var int Link to llx_bank */ public $fk_bank; /** * @var int Link to llx_facture */ public $fk_facture; /** * @var int Link to llx_facture_fourn */ public $fk_facture_fourn; /** * @var int Link to llx_paiement */ public $fk_paiement; /** * @var int Link to llx_paiementfourn */ public $fk_paiementfourn; /** * @var int Link to llx_salary */ public $fk_salary; /** * @var int Link to llx_don */ public $fk_don; /** * @var int Link to llx_loan */ public $fk_loan; /** * @var int Link to llx_societe */ public $fk_societe; /** * @var int Link to llx_bankimport_statement */ public $fk_statement; /** * @var int Status (0=new, 1=matched, 2=reconciled, 9=ignored) */ public $status = 0; /** * @var string Import batch key */ public $import_key; /** * @var int User who created */ public $fk_user_creat; /** * @var int User who modified */ public $fk_user_modif; /** * @var int User who matched */ public $fk_user_match; /** * @var int Match date */ public $date_match; /** * @var string Private note */ public $note_private; /** * @var string Public note */ public $note_public; // Status constants const STATUS_NEW = 0; const STATUS_MATCHED = 1; const STATUS_RECONCILED = 2; const STATUS_IGNORED = 9; /** * Constructor * * @param DoliDB $db Database handler */ public function __construct($db) { global $conf; $this->db = $db; $this->entity = $conf->entity; } /** * Create transaction in database * * @param User $user User that creates * @param bool $notrigger Disable triggers * @return int <0 if KO, Id of created object if OK */ public function create($user, $notrigger = false) { global $conf; $error = 0; $now = dol_now(); // Generate reference hash if not set if (empty($this->ref)) { $this->ref = $this->generateRef(); } $this->db->begin(); $sql = "INSERT INTO ".MAIN_DB_PREFIX."bankimport_transaction ("; $sql .= "ref, entity, iban, bic, datec, date_trans, date_value,"; $sql .= "name, counterparty_iban, counterparty_bic,"; $sql .= "amount, currency, label, description, end_to_end_id, mandate_id,"; $sql .= "status, import_key, fk_user_creat"; $sql .= ") VALUES ("; $sql .= "'".$this->db->escape($this->ref)."',"; $sql .= ((int) $this->entity).","; $sql .= ($this->iban ? "'".$this->db->escape($this->iban)."'" : "NULL").","; $sql .= ($this->bic ? "'".$this->db->escape($this->bic)."'" : "NULL").","; $sql .= "'".$this->db->idate($now)."',"; $sql .= "'".$this->db->idate($this->date_trans)."',"; $sql .= ($this->date_value ? "'".$this->db->idate($this->date_value)."'" : "NULL").","; $sql .= ($this->name ? "'".$this->db->escape($this->name)."'" : "NULL").","; $sql .= ($this->counterparty_iban ? "'".$this->db->escape($this->counterparty_iban)."'" : "NULL").","; $sql .= ($this->counterparty_bic ? "'".$this->db->escape($this->counterparty_bic)."'" : "NULL").","; $sql .= ((float) $this->amount).","; $sql .= "'".$this->db->escape($this->currency)."',"; $sql .= ($this->label ? "'".$this->db->escape($this->label)."'" : "NULL").","; $sql .= ($this->description ? "'".$this->db->escape($this->description)."'" : "NULL").","; $sql .= ($this->end_to_end_id ? "'".$this->db->escape($this->end_to_end_id)."'" : "NULL").","; $sql .= ($this->mandate_id ? "'".$this->db->escape($this->mandate_id)."'" : "NULL").","; $sql .= ((int) $this->status).","; $sql .= ($this->import_key ? "'".$this->db->escape($this->import_key)."'" : "NULL").","; $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_transaction"); $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 transaction from database * * @param int $id Id of transaction to load * @param string $ref Reference * @return int <0 if KO, 0 if not found, >0 if OK */ public function fetch($id, $ref = '') { $sql = "SELECT t.*"; $sql .= " FROM ".MAIN_DB_PREFIX."bankimport_transaction as t"; if ($id > 0) { $sql .= " WHERE t.rowid = ".((int) $id); } else { $sql .= " WHERE t.ref = '".$this->db->escape($ref)."' AND t.entity = ".((int) $this->entity); } 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->ref = $obj->ref; $this->entity = $obj->entity; $this->iban = $obj->iban; $this->bic = $obj->bic; $this->datec = $this->db->jdate($obj->datec); $this->date_trans = $this->db->jdate($obj->date_trans); $this->date_value = $this->db->jdate($obj->date_value); $this->name = $obj->name; $this->counterparty_iban = $obj->counterparty_iban; $this->counterparty_bic = $obj->counterparty_bic; $this->amount = $obj->amount; $this->currency = $obj->currency; $this->label = $obj->label; $this->description = $obj->description; $this->end_to_end_id = $obj->end_to_end_id; $this->mandate_id = $obj->mandate_id; $this->fk_bank = $obj->fk_bank; $this->fk_facture = $obj->fk_facture; $this->fk_facture_fourn = $obj->fk_facture_fourn; $this->fk_paiement = $obj->fk_paiement; $this->fk_paiementfourn = $obj->fk_paiementfourn; $this->fk_salary = $obj->fk_salary; $this->fk_don = $obj->fk_don; $this->fk_loan = $obj->fk_loan; $this->fk_societe = $obj->fk_societe; $this->fk_statement = $obj->fk_statement; $this->status = $obj->status; $this->import_key = $obj->import_key; $this->fk_user_creat = $obj->fk_user_creat; $this->fk_user_modif = $obj->fk_user_modif; $this->fk_user_match = $obj->fk_user_match; $this->date_match = $this->db->jdate($obj->date_match); $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; } } /** * Update transaction in database * * @param User $user User that modifies * @param bool $notrigger Disable triggers * @return int <0 if KO, >0 if OK */ public function update($user, $notrigger = false) { $error = 0; $this->db->begin(); $sql = "UPDATE ".MAIN_DB_PREFIX."bankimport_transaction SET"; $sql .= " iban = ".($this->iban ? "'".$this->db->escape($this->iban)."'" : "NULL").","; $sql .= " bic = ".($this->bic ? "'".$this->db->escape($this->bic)."'" : "NULL").","; $sql .= " date_trans = '".$this->db->idate($this->date_trans)."',"; $sql .= " date_value = ".($this->date_value ? "'".$this->db->idate($this->date_value)."'" : "NULL").","; $sql .= " name = ".($this->name ? "'".$this->db->escape($this->name)."'" : "NULL").","; $sql .= " counterparty_iban = ".($this->counterparty_iban ? "'".$this->db->escape($this->counterparty_iban)."'" : "NULL").","; $sql .= " counterparty_bic = ".($this->counterparty_bic ? "'".$this->db->escape($this->counterparty_bic)."'" : "NULL").","; $sql .= " amount = ".((float) $this->amount).","; $sql .= " currency = '".$this->db->escape($this->currency)."',"; $sql .= " label = ".($this->label ? "'".$this->db->escape($this->label)."'" : "NULL").","; $sql .= " description = ".($this->description ? "'".$this->db->escape($this->description)."'" : "NULL").","; $sql .= " end_to_end_id = ".($this->end_to_end_id ? "'".$this->db->escape($this->end_to_end_id)."'" : "NULL").","; $sql .= " mandate_id = ".($this->mandate_id ? "'".$this->db->escape($this->mandate_id)."'" : "NULL").","; $sql .= " fk_bank = ".($this->fk_bank > 0 ? ((int) $this->fk_bank) : "NULL").","; $sql .= " fk_facture = ".($this->fk_facture > 0 ? ((int) $this->fk_facture) : "NULL").","; $sql .= " fk_facture_fourn = ".($this->fk_facture_fourn > 0 ? ((int) $this->fk_facture_fourn) : "NULL").","; $sql .= " fk_paiement = ".($this->fk_paiement > 0 ? ((int) $this->fk_paiement) : "NULL").","; $sql .= " fk_paiementfourn = ".($this->fk_paiementfourn > 0 ? ((int) $this->fk_paiementfourn) : "NULL").","; $sql .= " fk_salary = ".($this->fk_salary > 0 ? ((int) $this->fk_salary) : "NULL").","; $sql .= " fk_don = ".($this->fk_don > 0 ? ((int) $this->fk_don) : "NULL").","; $sql .= " fk_loan = ".($this->fk_loan > 0 ? ((int) $this->fk_loan) : "NULL").","; $sql .= " fk_societe = ".($this->fk_societe > 0 ? ((int) $this->fk_societe) : "NULL").","; $sql .= " fk_statement = ".($this->fk_statement > 0 ? ((int) $this->fk_statement) : "NULL").","; $sql .= " status = ".((int) $this->status).","; $sql .= " fk_user_modif = ".((int) $user->id).","; $sql .= " note_private = ".($this->note_private ? "'".$this->db->escape($this->note_private)."'" : "NULL").","; $sql .= " note_public = ".($this->note_public ? "'".$this->db->escape($this->note_public)."'" : "NULL"); $sql .= " WHERE rowid = ".((int) $this->id); dol_syslog(get_class($this)."::update", LOG_DEBUG); $resql = $this->db->query($sql); if ($resql) { $this->fk_user_modif = $user->id; $this->db->commit(); return 1; } else { $this->error = $this->db->lasterror(); $this->db->rollback(); return -1; } } /** * Delete transaction from database * * @param User $user User that deletes * @param bool $notrigger Disable triggers * @return int <0 if KO, >0 if OK */ public function delete($user, $notrigger = false) { $this->db->begin(); $sql = "DELETE FROM ".MAIN_DB_PREFIX."bankimport_transaction"; $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; } } /** * Generate unique reference hash * * @return string Reference hash */ public function generateRef() { // Create hash from: date + amount + name + description $data = $this->date_trans . '|' . $this->amount . '|' . $this->name . '|' . $this->description; return substr(md5($data), 0, 32); } /** * Check if transaction already exists (duplicate detection) * * @return int 0 if not exists, rowid if exists */ public function exists() { $ref = $this->generateRef(); $sql = "SELECT rowid FROM ".MAIN_DB_PREFIX."bankimport_transaction"; $sql .= " WHERE ref = '".$this->db->escape($ref)."'"; $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; } /** * Import transactions from FinTS fetch result * * @param array $transactions Array of transaction data from FinTS * @param string $iban IBAN of the account * @param User $user User doing the import * @return array Array with 'imported' and 'skipped' counts */ public function importFromFinTS($transactions, $iban, $user) { $imported = 0; $skipped = 0; $errors = array(); $importKey = date('YmdHis') . '_' . $user->id; foreach ($transactions as $tx) { $trans = new BankImportTransaction($this->db); $trans->iban = $iban; $trans->date_trans = $tx['date']; $trans->date_value = $tx['bookingDate'] ?? $tx['date']; $trans->name = $tx['name'] ?? ''; $trans->counterparty_iban = $tx['iban'] ?? ''; $trans->counterparty_bic = $tx['bic'] ?? ''; $trans->amount = $tx['amount']; $trans->currency = $tx['currency'] ?? 'EUR'; $trans->label = $tx['bookingText'] ?? ''; $trans->description = $tx['reference'] ?? ''; $trans->end_to_end_id = $tx['endToEndId'] ?? ''; $trans->import_key = $importKey; $trans->status = self::STATUS_NEW; // Check for duplicate if ($trans->exists()) { $skipped++; continue; } // Create transaction $result = $trans->create($user); if ($result > 0) { $imported++; } else { $errors[] = $trans->error; } } return array( 'imported' => $imported, 'skipped' => $skipped, 'errors' => $errors, 'import_key' => $importKey ); } /** * Fetch list of transactions 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 of objects, 'count' returns total count * @return array|int Array of transactions, count or -1 on error */ public function fetchAll($sortfield = 'date_trans', $sortorder = 'DESC', $limit = 0, $offset = 0, $filter = array(), $mode = 'list') { $sql = "SELECT t.rowid"; $sql .= " FROM ".MAIN_DB_PREFIX."bankimport_transaction as t"; $sql .= " WHERE t.entity = ".((int) $this->entity); // Apply filters if (!empty($filter['ref'])) { $sql .= " AND t.ref LIKE '%".$this->db->escape($filter['ref'])."%'"; } if (!empty($filter['name'])) { $sql .= " AND t.name LIKE '%".$this->db->escape($filter['name'])."%'"; } if (!empty($filter['description'])) { $sql .= " AND (t.description LIKE '%".$this->db->escape($filter['description'])."%'"; $sql .= " OR t.label LIKE '%".$this->db->escape($filter['description'])."%')"; } if (!empty($filter['amount_min'])) { $sql .= " AND t.amount >= ".((float) $filter['amount_min']); } if (!empty($filter['amount_max'])) { $sql .= " AND t.amount <= ".((float) $filter['amount_max']); } if (!empty($filter['date_from'])) { $sql .= " AND t.date_trans >= '".$this->db->idate($filter['date_from'])."'"; } if (!empty($filter['date_to'])) { $sql .= " AND t.date_trans <= '".$this->db->idate($filter['date_to'])."'"; } if (isset($filter['status']) && $filter['status'] !== '' && $filter['status'] >= 0) { $sql .= " AND t.status = ".((int) $filter['status']); } if (!empty($filter['iban'])) { $sql .= " AND t.iban LIKE '%".$this->db->escape($filter['iban'])."%'"; } // Count mode - just return total count 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)) { $trans = new BankImportTransaction($this->db); $trans->fetch($obj->rowid); $result[] = $trans; } $this->db->free($resql); return $result; } else { $this->error = $this->db->lasterror(); return -1; } } /** * Get status label * * @param int $mode 0=long label, 1=short label, 2=Picto + short label, 3=Picto, 4=Picto + long label, 5=Short label + Picto, 6=Long label + Picto * @return string Label */ public function getLibStatut($mode = 0) { return $this->LibStatut($this->status, $mode); } /** * Get status label for a given status * * @param int $status Status value * @param int $mode Mode * @return string Label */ public function LibStatut($status, $mode = 0) { global $langs; $statusLabels = array( self::STATUS_NEW => array('short' => 'New', 'long' => 'NewTransaction', 'picto' => 'status0'), self::STATUS_MATCHED => array('short' => 'Matched', 'long' => 'TransactionMatched', 'picto' => 'status4'), self::STATUS_RECONCILED => array('short' => 'Reconciled', 'long' => 'TransactionReconciled', 'picto' => 'status6'), self::STATUS_IGNORED => array('short' => 'Ignored', 'long' => 'TransactionIgnored', 'picto' => 'status5'), ); $statusInfo = $statusLabels[$status] ?? array('short' => 'Unknown', 'long' => 'Unknown', 'picto' => 'status0'); if ($mode == 0) { return $langs->trans($statusInfo['long']); } elseif ($mode == 1) { return $langs->trans($statusInfo['short']); } elseif ($mode == 2) { return img_picto($langs->trans($statusInfo['long']), $statusInfo['picto']).' '.$langs->trans($statusInfo['short']); } elseif ($mode == 3) { return img_picto($langs->trans($statusInfo['long']), $statusInfo['picto']); } elseif ($mode == 4) { return img_picto($langs->trans($statusInfo['long']), $statusInfo['picto']).' '.$langs->trans($statusInfo['long']); } elseif ($mode == 5) { return $langs->trans($statusInfo['short']).' '.img_picto($langs->trans($statusInfo['long']), $statusInfo['picto']); } elseif ($mode == 6) { return $langs->trans($statusInfo['long']).' '.img_picto($langs->trans($statusInfo['long']), $statusInfo['picto']); } return ''; } /** * Set status of transaction * * @param int $status New status * @param User $user User making the change * @return int <0 if KO, >0 if OK */ public function setStatus($status, $user) { $this->status = $status; if ($status == self::STATUS_MATCHED || $status == self::STATUS_RECONCILED) { $this->fk_user_match = $user->id; $this->date_match = dol_now(); } return $this->update($user); } /** * Try to auto-match transaction with Dolibarr invoices * Uses multiple criteria: amount, name, invoice reference, IBAN * * @return array Array of potential matches with scores */ public function findMatches() { $matches = array(); $seenIds = array(); // Skip if already matched if ($this->status == self::STATUS_MATCHED || $this->status == self::STATUS_RECONCILED) { return $matches; } // Build search text from description and end-to-end-id $searchText = strtoupper($this->description . ' ' . $this->end_to_end_id . ' ' . $this->label); // For incoming payments (positive amount), search customer invoices if ($this->amount > 0) { $matches = array_merge($matches, $this->findCustomerInvoiceMatches($searchText, $seenIds)); } // For outgoing payments (negative amount), search supplier invoices if ($this->amount < 0) { $matches = array_merge($matches, $this->findSupplierInvoiceMatches($searchText, $seenIds)); // FALLBACK: If best match has amount difference > 5 EUR, try multi-invoice matching $absAmount = abs($this->amount); $needsMultiMatch = true; if (!empty($matches)) { $bestMatch = $matches[0]; // If best single match is close enough in amount, no need for multi-match if (abs($bestMatch['amount'] - $absAmount) <= 5.00) { $needsMultiMatch = false; } } if ($needsMultiMatch) { // Try to find supplier by IBAN or name $socid = $this->findSupplierForMultiMatch($searchText); if ($socid > 0) { $multiMatch = $this->findMultipleSupplierInvoiceMatches($searchText, $socid, $absAmount, 5.00); if ($multiMatch && count($multiMatch['invoices']) > 1) { // Add as a special "multi" match $matches[] = array( 'type' => 'multi_facture_fourn', 'id' => 0, // Special marker 'ref' => count($multiMatch['invoices']).' '.$this->getLangsTransNoLoad("Invoices"), 'amount' => $multiMatch['total'], 'socname' => $multiMatch['invoices'][0]['socname'], 'socid' => $socid, 'match_score' => $multiMatch['match_score'], 'match_reasons' => array('multi_invoice', 'ref_supplier'), 'invoices' => $multiMatch['invoices'], 'difference' => $multiMatch['difference'] ); } } } } // Sort by score descending usort($matches, function($a, $b) { return $b['match_score'] - $a['match_score']; }); return $matches; } /** * Find supplier ID for multi-invoice matching * Uses IBAN or name matching * * @param string $searchText Search text * @return int Societe ID or 0 */ protected function findSupplierForMultiMatch($searchText) { // First try by IBAN if (!empty($this->counterparty_iban)) { $socid = $this->findSocieteByIban($this->counterparty_iban); if ($socid > 0) { return $socid; } } // Then try by name similarity if (!empty($this->name)) { $sql = "SELECT s.rowid, s.nom"; $sql .= " FROM ".MAIN_DB_PREFIX."societe as s"; $sql .= " WHERE s.entity = ".((int) $this->entity); $sql .= " AND s.fournisseur = 1"; // Must be a supplier $resql = $this->db->query($sql); if ($resql) { $bestSocid = 0; $bestSimilarity = 0; while ($obj = $this->db->fetch_object($resql)) { $similarity = $this->calculateNameSimilarity($this->name, $obj->nom); if ($similarity > 70 && $similarity > $bestSimilarity) { $bestSimilarity = $similarity; $bestSocid = $obj->rowid; } } if ($bestSocid > 0) { return $bestSocid; } } } return 0; } /** * Get translation without loading langs (fallback) * * @param string $key Translation key * @return string */ protected function getLangsTransNoLoad($key) { global $langs; if (is_object($langs)) { return $langs->trans($key); } return $key; } /** * Find matching customer invoices * * @param string $searchText Text to search in * @param array &$seenIds Already seen invoice IDs * @return array Matches */ protected function findCustomerInvoiceMatches($searchText, &$seenIds) { $matches = array(); // 1. Search by invoice reference in description (highest priority) $sql = "SELECT f.rowid, f.ref, f.ref_client, f.total_ttc, f.date_lim_reglement,"; $sql .= " s.nom as socname, s.rowid as socid"; $sql .= " FROM ".MAIN_DB_PREFIX."facture as f"; $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."societe as s ON f.fk_soc = s.rowid"; $sql .= " WHERE f.entity = ".((int) $this->entity); $sql .= " AND f.fk_statut = 1"; // Unpaid $resql = $this->db->query($sql); if ($resql) { while ($obj = $this->db->fetch_object($resql)) { $score = 0; $matchReasons = array(); // Check if invoice reference appears in description if (stripos($searchText, $obj->ref) !== false) { $score += 80; $matchReasons[] = 'ref'; } // Check client reference if (!empty($obj->ref_client) && stripos($searchText, $obj->ref_client) !== false) { $score += 70; $matchReasons[] = 'ref_client'; } // Check exact amount match if (abs($obj->total_ttc - $this->amount) < 0.01) { $score += 50; $matchReasons[] = 'amount'; } elseif (abs($obj->total_ttc - $this->amount) < 1.00) { // Close amount (within 1 EUR) $score += 20; $matchReasons[] = 'amount_close'; } // Check name similarity if (!empty($this->name) && !empty($obj->socname)) { $nameSimilarity = $this->calculateNameSimilarity($this->name, $obj->socname); if ($nameSimilarity > 80) { $score += 40; $matchReasons[] = 'name_exact'; } elseif ($nameSimilarity > 50) { $score += 20; $matchReasons[] = 'name_similar'; } } // Check IBAN match if (!empty($this->counterparty_iban)) { $ibanMatch = $this->checkSocieteIban($obj->socid, $this->counterparty_iban); if ($ibanMatch) { $score += 60; $matchReasons[] = 'iban'; } } // Only include if score is significant if ($score >= 50 && !isset($seenIds['facture_'.$obj->rowid])) { $seenIds['facture_'.$obj->rowid] = true; $matches[] = array( 'type' => 'facture', 'id' => $obj->rowid, 'ref' => $obj->ref, 'amount' => $obj->total_ttc, 'socname' => $obj->socname, 'socid' => $obj->socid, 'match_score' => min($score, 100), 'match_reasons' => $matchReasons, 'date_due' => $obj->date_lim_reglement ); } } } return $matches; } /** * Find matching supplier invoices * * @param string $searchText Text to search in * @param array &$seenIds Already seen invoice IDs * @return array Matches */ protected function findSupplierInvoiceMatches($searchText, &$seenIds) { $matches = array(); $absAmount = abs($this->amount); $sql = "SELECT f.rowid, f.ref, f.ref_supplier, f.total_ttc, f.date_lim_reglement,"; $sql .= " s.nom as socname, s.rowid as socid"; $sql .= " FROM ".MAIN_DB_PREFIX."facture_fourn as f"; $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."societe as s ON f.fk_soc = s.rowid"; $sql .= " WHERE f.entity = ".((int) $this->entity); $sql .= " AND f.fk_statut = 1"; // Unpaid $resql = $this->db->query($sql); if ($resql) { while ($obj = $this->db->fetch_object($resql)) { $score = 0; $matchReasons = array(); // Check if invoice reference appears in description if (stripos($searchText, $obj->ref) !== false) { $score += 80; $matchReasons[] = 'ref'; } // Check supplier reference if (!empty($obj->ref_supplier) && stripos($searchText, $obj->ref_supplier) !== false) { $score += 70; $matchReasons[] = 'ref_supplier'; } // Check exact amount match if (abs($obj->total_ttc - $absAmount) < 0.01) { $score += 50; $matchReasons[] = 'amount'; } elseif (abs($obj->total_ttc - $absAmount) < 1.00) { $score += 20; $matchReasons[] = 'amount_close'; } // Check name similarity if (!empty($this->name) && !empty($obj->socname)) { $nameSimilarity = $this->calculateNameSimilarity($this->name, $obj->socname); if ($nameSimilarity > 80) { $score += 40; $matchReasons[] = 'name_exact'; } elseif ($nameSimilarity > 50) { $score += 20; $matchReasons[] = 'name_similar'; } } // Check IBAN match if (!empty($this->counterparty_iban)) { $ibanMatch = $this->checkSocieteIban($obj->socid, $this->counterparty_iban); if ($ibanMatch) { $score += 60; $matchReasons[] = 'iban'; } } // Only include if score is significant if ($score >= 50 && !isset($seenIds['facture_fourn_'.$obj->rowid])) { $seenIds['facture_fourn_'.$obj->rowid] = true; $matches[] = array( 'type' => 'facture_fourn', 'id' => $obj->rowid, 'ref' => $obj->ref, 'amount' => $obj->total_ttc, 'socname' => $obj->socname, 'socid' => $obj->socid, 'match_score' => min($score, 100), 'match_reasons' => $matchReasons, 'date_due' => $obj->date_lim_reglement ); } } } return $matches; } /** * Calculate name similarity percentage * * @param string $name1 First name * @param string $name2 Second name * @return int Similarity percentage (0-100) */ protected function calculateNameSimilarity($name1, $name2) { // Normalize names $name1 = strtolower(trim($name1)); $name2 = strtolower(trim($name2)); // Remove common suffixes $suffixes = array(' gmbh', ' ag', ' kg', ' ohg', ' e.k.', ' ug', ' gbr', ' se', ' co. kg', ' gmbh & co. kg'); foreach ($suffixes as $suffix) { $name1 = str_replace($suffix, '', $name1); $name2 = str_replace($suffix, '', $name2); } $name1 = trim($name1); $name2 = trim($name2); // Check if one contains the other if (strpos($name1, $name2) !== false || strpos($name2, $name1) !== false) { return 90; } // Use similar_text for fuzzy matching $similarity = 0; similar_text($name1, $name2, $similarity); return (int) $similarity; } /** * Find multiple supplier invoices that together match the transaction amount * Uses the logic: oldest invoices first, check if ref_supplier appears in description * * @param string $searchText Text to search in (description) * @param int $socid Societe ID (supplier) * @param float $targetAmount Target amount (absolute value of transaction) * @param float $tolerance Tolerance for amount matching (default 5.00) * @return array|null Array with 'invoices' and 'total' or null if no match */ protected function findMultipleSupplierInvoiceMatches($searchText, $socid, $targetAmount, $tolerance = 5.00) { $matchedInvoices = array(); $runningTotal = 0.0; // Get all unpaid supplier invoices for this supplier, ordered by date (oldest first) $sql = "SELECT f.rowid, f.ref, f.ref_supplier, f.total_ttc, f.datef, f.date_lim_reglement,"; $sql .= " s.nom as socname, s.rowid as socid"; $sql .= " FROM ".MAIN_DB_PREFIX."facture_fourn as f"; $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."societe as s ON f.fk_soc = s.rowid"; $sql .= " WHERE f.entity = ".((int) $this->entity); $sql .= " AND f.fk_statut = 1"; // Unpaid $sql .= " AND f.fk_soc = ".((int) $socid); $sql .= " ORDER BY f.datef ASC, f.rowid ASC"; // Oldest first $resql = $this->db->query($sql); if (!$resql) { return null; } while ($obj = $this->db->fetch_object($resql)) { // Check if this invoice's ref_supplier appears in the search text $refFound = false; if (!empty($obj->ref_supplier)) { // Try different formats: with and without leading zeros, etc. $refClean = preg_replace('/^0+/', '', $obj->ref_supplier); // Remove leading zeros if (stripos($searchText, $obj->ref_supplier) !== false || stripos($searchText, $refClean) !== false) { $refFound = true; } } // Also check the internal ref if (!$refFound && stripos($searchText, $obj->ref) !== false) { $refFound = true; } if ($refFound) { // Calculate remaining amount for this invoice $remainToPay = $this->getInvoiceRemainToPay('facture_fourn', $obj->rowid, $obj->total_ttc); if ($remainToPay > 0) { $matchedInvoices[] = array( 'type' => 'facture_fourn', 'id' => $obj->rowid, 'ref' => $obj->ref, 'ref_supplier' => $obj->ref_supplier, 'amount' => $remainToPay, 'total_ttc' => $obj->total_ttc, 'socname' => $obj->socname, 'socid' => $obj->socid, 'datef' => $obj->datef, 'date_due' => $obj->date_lim_reglement ); $runningTotal += $remainToPay; } } // Stop if we've reached or exceeded the target if ($runningTotal >= $targetAmount - $tolerance) { break; } } $this->db->free($resql); // Check if total matches within tolerance if (!empty($matchedInvoices) && abs($runningTotal - $targetAmount) <= $tolerance) { return array( 'invoices' => $matchedInvoices, 'total' => $runningTotal, 'difference' => $targetAmount - $runningTotal, 'match_score' => abs($runningTotal - $targetAmount) < 0.01 ? 100 : 90 ); } return null; } /** * Get remaining amount to pay for an invoice * * @param string $type 'facture' or 'facture_fourn' * @param int $invoiceId Invoice ID * @param float $totalTtc Total TTC of invoice * @return float Remaining amount */ protected function getInvoiceRemainToPay($type, $invoiceId, $totalTtc) { if ($type == 'facture_fourn') { require_once DOL_DOCUMENT_ROOT.'/fourn/class/fournisseur.facture.class.php'; $invoice = new FactureFournisseur($this->db); if ($invoice->fetch($invoiceId) > 0) { $alreadyPaid = $invoice->getSommePaiement(); $creditnotes = $invoice->getSumCreditNotesUsed(); $deposits = $invoice->getSumDepositsUsed(); return price2num($invoice->total_ttc - $alreadyPaid - $creditnotes - $deposits, 'MT'); } } else { require_once DOL_DOCUMENT_ROOT.'/compta/facture/class/facture.class.php'; $invoice = new Facture($this->db); if ($invoice->fetch($invoiceId) > 0) { $alreadyPaid = $invoice->getSommePaiement(); $creditnotes = $invoice->getSumCreditNotesUsed(); $deposits = $invoice->getSumDepositsUsed(); return price2num($invoice->total_ttc - $alreadyPaid - $creditnotes - $deposits, 'MT'); } } return $totalTtc; } /** * Check if IBAN matches any bank account of a societe * * @param int $socid Societe ID * @param string $iban IBAN to check * @return bool True if match found */ protected function checkSocieteIban($socid, $iban) { if (empty($socid) || empty($iban)) { return false; } // Normalize IBAN (remove spaces) $iban = str_replace(' ', '', strtoupper($iban)); $sql = "SELECT iban_prefix FROM ".MAIN_DB_PREFIX."societe_rib"; $sql .= " WHERE fk_soc = ".((int) $socid); $sql .= " AND status = 1"; // Active $resql = $this->db->query($sql); if ($resql) { while ($obj = $this->db->fetch_object($resql)) { $storedIban = str_replace(' ', '', strtoupper($obj->iban_prefix)); if ($storedIban === $iban) { return true; } } } return false; } /** * Find third party by IBAN * * @param string $iban IBAN to search * @return int|null Societe ID or null */ public function findSocieteByIban($iban) { if (empty($iban)) { return null; } $iban = str_replace(' ', '', strtoupper($iban)); $sql = "SELECT fk_soc FROM ".MAIN_DB_PREFIX."societe_rib"; $sql .= " WHERE REPLACE(UPPER(iban_prefix), ' ', '') = '".$this->db->escape($iban)."'"; $sql .= " AND status = 1"; $sql .= " LIMIT 1"; $resql = $this->db->query($sql); if ($resql && $this->db->num_rows($resql) > 0) { $obj = $this->db->fetch_object($resql); return (int) $obj->fk_soc; } return null; } /** * Auto-detect and link third party by IBAN * * @param User $user User doing the action * @return int Societe ID if found and linked, 0 if not found */ public function autoLinkSociete($user) { if (!empty($this->fk_societe)) { return $this->fk_societe; // Already linked } $socid = $this->findSocieteByIban($this->counterparty_iban); if ($socid > 0) { $this->fk_societe = $socid; $this->update($user); return $socid; } return 0; } /** * Confirm payment: create Paiement or PaiementFourn in Dolibarr * Links the bankimport transaction to the created payment and bank line. * * @param User $user User performing the action * @param string $type 'facture' or 'facture_fourn' * @param int $invoiceId Invoice ID * @param int $bankAccountId Dolibarr bank account ID (llx_bank_account.rowid) * @return int >0 if OK (payment ID), <0 if error */ public function confirmPayment($user, $type, $invoiceId, $bankAccountId) { global $conf, $langs; $error = 0; $this->db->begin(); // Look up payment type ID for 'VIR' (bank transfer) $paiementTypeId = 0; $sql = "SELECT id FROM ".MAIN_DB_PREFIX."c_paiement WHERE code = 'VIR' AND active = 1"; $resql = $this->db->query($sql); if ($resql && $this->db->num_rows($resql) > 0) { $obj = $this->db->fetch_object($resql); $paiementTypeId = (int) $obj->id; } if (empty($paiementTypeId)) { $this->error = 'Payment type VIR not found in c_paiement'; $this->db->rollback(); return -1; } if ($type == 'facture') { // Customer invoice payment require_once DOL_DOCUMENT_ROOT.'/compta/facture/class/facture.class.php'; require_once DOL_DOCUMENT_ROOT.'/compta/paiement/class/paiement.class.php'; $invoice = new Facture($this->db); if ($invoice->fetch($invoiceId) <= 0) { $this->error = 'Invoice not found: '.$invoiceId; $this->db->rollback(); return -2; } // Calculate remaining amount $alreadyPaid = $invoice->getSommePaiement(); $creditnotes = $invoice->getSumCreditNotesUsed(); $deposits = $invoice->getSumDepositsUsed(); $remaintopay = price2num($invoice->total_ttc - $alreadyPaid - $creditnotes - $deposits, 'MT'); if ($remaintopay <= 0) { $this->error = $langs->trans("InvoiceAlreadyPaid"); $this->db->rollback(); return -5; } // Use the lesser of: transaction amount or remain to pay $payAmount = min(abs($this->amount), $remaintopay); $paiement = new Paiement($this->db); $paiement->datepaye = $this->date_trans; $paiement->amounts = array($invoiceId => $payAmount); $paiement->multicurrency_amounts = array($invoiceId => $payAmount); $paiement->paiementid = $paiementTypeId; $paiement->paiementcode = 'VIR'; $paiement->num_payment = $this->end_to_end_id ?: $this->ref; $paiement->note_private = $langs->trans("PaymentCreatedByBankImport").' - '.$this->name.' - '.dol_trunc($this->description, 100); $paymentId = $paiement->create($user, 1); if ($paymentId < 0) { $this->error = $paiement->error; $this->errors = $paiement->errors; $error++; } if (!$error) { $bankLineId = $paiement->addPaymentToBank( $user, 'payment', '(CustomerInvoicePayment)', $bankAccountId, $this->name, '' ); if ($bankLineId > 0) { $this->fk_paiement = $paymentId; $this->fk_bank = $bankLineId; $this->fk_facture = $invoiceId; $this->fk_societe = $invoice->socid; $this->status = self::STATUS_MATCHED; $this->fk_user_match = $user->id; $this->date_match = dol_now(); $this->update($user); } else { $this->error = 'Failed to add payment to bank'; $error++; } } if ($error) { $this->db->rollback(); return -3; } $this->db->commit(); return $paymentId; } elseif ($type == 'facture_fourn') { // Supplier invoice payment require_once DOL_DOCUMENT_ROOT.'/fourn/class/fournisseur.facture.class.php'; require_once DOL_DOCUMENT_ROOT.'/fourn/class/paiementfourn.class.php'; $invoice = new FactureFournisseur($this->db); if ($invoice->fetch($invoiceId) <= 0) { $this->error = 'Supplier invoice not found: '.$invoiceId; $this->db->rollback(); return -2; } $alreadyPaid = $invoice->getSommePaiement(); $creditnotes = $invoice->getSumCreditNotesUsed(); $deposits = $invoice->getSumDepositsUsed(); $remaintopay = price2num($invoice->total_ttc - $alreadyPaid - $creditnotes - $deposits, 'MT'); if ($remaintopay <= 0) { $this->error = $langs->trans("InvoiceAlreadyPaid"); $this->db->rollback(); return -5; } $payAmount = min(abs($this->amount), $remaintopay); $paiementfourn = new PaiementFourn($this->db); $paiementfourn->datepaye = $this->date_trans; $paiementfourn->amounts = array($invoiceId => $payAmount); $paiementfourn->multicurrency_amounts = array($invoiceId => $payAmount); $paiementfourn->paiementid = $paiementTypeId; $paiementfourn->paiementcode = 'VIR'; $paiementfourn->num_payment = $this->end_to_end_id ?: $this->ref; $paiementfourn->note_private = $langs->trans("PaymentCreatedByBankImport").' - '.$this->name.' - '.dol_trunc($this->description, 100); $paymentId = $paiementfourn->create($user, 1); if ($paymentId < 0) { $this->error = $paiementfourn->error; $this->errors = $paiementfourn->errors; $error++; } if (!$error) { $bankLineId = $paiementfourn->addPaymentToBank( $user, 'payment_supplier', '(SupplierInvoicePayment)', $bankAccountId, $this->name, '' ); if ($bankLineId > 0) { $this->fk_paiementfourn = $paymentId; $this->fk_bank = $bankLineId; $this->fk_facture_fourn = $invoiceId; $this->fk_societe = $invoice->socid; $this->status = self::STATUS_MATCHED; $this->fk_user_match = $user->id; $this->date_match = dol_now(); $this->update($user); } else { $this->error = 'Failed to add payment to bank'; $error++; } } if ($error) { $this->db->rollback(); return -3; } $this->db->commit(); return $paymentId; } $this->error = 'Unknown type: '.$type; $this->db->rollback(); return -4; } /** * Confirm payment for multiple invoices (batch payment) * Creates a single payment that covers multiple invoices * * @param User $user User performing the action * @param array $invoices Array of invoice data: [['type' => 'facture_fourn', 'id' => X, 'amount' => Y], ...] * @param int $bankAccountId Dolibarr bank account ID * @return int >0 if OK (payment ID), <0 if error */ public function confirmMultiplePayment($user, $invoices, $bankAccountId) { global $conf, $langs; if (empty($invoices)) { $this->error = 'No invoices provided'; return -1; } $error = 0; $this->db->begin(); // Look up payment type ID for 'VIR' (bank transfer) $paiementTypeId = 0; $sql = "SELECT id FROM ".MAIN_DB_PREFIX."c_paiement WHERE code = 'VIR' AND active = 1"; $resql = $this->db->query($sql); if ($resql && $this->db->num_rows($resql) > 0) { $obj = $this->db->fetch_object($resql); $paiementTypeId = (int) $obj->id; } if (empty($paiementTypeId)) { $this->error = 'Payment type VIR not found in c_paiement'; $this->db->rollback(); return -1; } // Determine if these are customer or supplier invoices $firstType = $invoices[0]['type']; $isSupplier = ($firstType == 'facture_fourn'); // Build amounts array for payment $amounts = array(); $multicurrency_amounts = array(); $totalPayment = 0; $socid = 0; foreach ($invoices as $inv) { $invoiceId = (int) $inv['id']; $payAmount = (float) $inv['amount']; if ($payAmount <= 0) { continue; } $amounts[$invoiceId] = $payAmount; $multicurrency_amounts[$invoiceId] = $payAmount; $totalPayment += $payAmount; // Get socid from first invoice if ($socid == 0) { if ($isSupplier) { require_once DOL_DOCUMENT_ROOT.'/fourn/class/fournisseur.facture.class.php'; $invoice = new FactureFournisseur($this->db); if ($invoice->fetch($invoiceId) > 0) { $socid = $invoice->socid; } } else { require_once DOL_DOCUMENT_ROOT.'/compta/facture/class/facture.class.php'; $invoice = new Facture($this->db); if ($invoice->fetch($invoiceId) > 0) { $socid = $invoice->socid; } } } } if (empty($amounts)) { $this->error = 'No valid invoice amounts'; $this->db->rollback(); return -2; } if ($isSupplier) { // Supplier invoice payment require_once DOL_DOCUMENT_ROOT.'/fourn/class/fournisseur.facture.class.php'; require_once DOL_DOCUMENT_ROOT.'/fourn/class/paiementfourn.class.php'; $paiementfourn = new PaiementFourn($this->db); $paiementfourn->datepaye = $this->date_trans; $paiementfourn->amounts = $amounts; $paiementfourn->multicurrency_amounts = $multicurrency_amounts; $paiementfourn->paiementid = $paiementTypeId; $paiementfourn->paiementcode = 'VIR'; $paiementfourn->num_payment = $this->end_to_end_id ?: $this->ref; $paiementfourn->note_private = $langs->trans("PaymentCreatedByBankImport").' - '.$this->name.' - '.count($invoices).' '.$langs->trans("Invoices"); $paymentId = $paiementfourn->create($user, 1); if ($paymentId < 0) { $this->error = $paiementfourn->error; $this->errors = $paiementfourn->errors; $error++; } if (!$error) { $bankLineId = $paiementfourn->addPaymentToBank( $user, 'payment_supplier', '(SupplierInvoicePayment)', $bankAccountId, $this->name, '' ); if ($bankLineId > 0) { $this->fk_paiementfourn = $paymentId; $this->fk_bank = $bankLineId; $this->fk_societe = $socid; $this->status = self::STATUS_MATCHED; $this->fk_user_match = $user->id; $this->date_match = dol_now(); // Store first invoice as reference (or could store all in note) $this->fk_facture_fourn = $invoices[0]['id']; $this->note_private = ($this->note_private ? $this->note_private."\n" : '').'Multi-invoice payment: '.implode(', ', array_column($invoices, 'ref')); $this->update($user); } else { $this->error = 'Failed to add payment to bank'; $error++; } } } else { // Customer invoice payment require_once DOL_DOCUMENT_ROOT.'/compta/facture/class/facture.class.php'; require_once DOL_DOCUMENT_ROOT.'/compta/paiement/class/paiement.class.php'; $paiement = new Paiement($this->db); $paiement->datepaye = $this->date_trans; $paiement->amounts = $amounts; $paiement->multicurrency_amounts = $multicurrency_amounts; $paiement->paiementid = $paiementTypeId; $paiement->paiementcode = 'VIR'; $paiement->num_payment = $this->end_to_end_id ?: $this->ref; $paiement->note_private = $langs->trans("PaymentCreatedByBankImport").' - '.$this->name.' - '.count($invoices).' '.$langs->trans("Invoices"); $paymentId = $paiement->create($user, 1); if ($paymentId < 0) { $this->error = $paiement->error; $this->errors = $paiement->errors; $error++; } if (!$error) { $bankLineId = $paiement->addPaymentToBank( $user, 'payment', '(CustomerInvoicePayment)', $bankAccountId, $this->name, '' ); if ($bankLineId > 0) { $this->fk_paiement = $paymentId; $this->fk_bank = $bankLineId; $this->fk_societe = $socid; $this->status = self::STATUS_MATCHED; $this->fk_user_match = $user->id; $this->date_match = dol_now(); $this->fk_facture = $invoices[0]['id']; $this->note_private = ($this->note_private ? $this->note_private."\n" : '').'Multi-invoice payment: '.implode(', ', array_column($invoices, 'ref')); $this->update($user); } else { $this->error = 'Failed to add payment to bank'; $error++; } } } if ($error) { $this->db->rollback(); return -3; } $this->db->commit(); return $paymentId; } /** * Link transaction to an existing payment (for already paid invoices) * Finds the existing payment for the invoice and creates the bank entry. * If no payment exists (invoice marked paid without payment record), creates both payment and bank entry. * * @param User $user User performing the action * @param string $invoiceType Invoice type ('facture' or 'facture_fourn') * @param int $invoiceId Invoice ID * @param int $bankAccountId Dolibarr bank account ID * @return int >0 if OK (bank line ID), <0 if error */ public function linkExistingPayment($user, $invoiceType, $invoiceId, $bankAccountId) { global $conf, $langs; $error = 0; $bankLineId = 0; $this->db->begin(); // Look up payment type ID for 'VIR' (bank transfer) $paiementTypeId = 0; $sql = "SELECT id FROM ".MAIN_DB_PREFIX."c_paiement WHERE code = 'VIR' AND active = 1"; $resql = $this->db->query($sql); if ($resql && $this->db->num_rows($resql) > 0) { $obj = $this->db->fetch_object($resql); $paiementTypeId = (int) $obj->id; } if (empty($paiementTypeId)) { $this->error = 'Payment type VIR not found'; $this->db->rollback(); return -1; } if ($invoiceType == 'facture_fourn') { // Supplier invoice require_once DOL_DOCUMENT_ROOT.'/fourn/class/fournisseur.facture.class.php'; require_once DOL_DOCUMENT_ROOT.'/fourn/class/paiementfourn.class.php'; $invoice = new FactureFournisseur($this->db); if ($invoice->fetch($invoiceId) <= 0) { $this->error = 'Invoice not found'; $this->db->rollback(); return -1; } // Find the payment(s) for this invoice $sql = "SELECT pfp.fk_paiement_fourn as payment_id, pfp.amount"; $sql .= " FROM ".MAIN_DB_PREFIX."paiementfourn_facturefourn as pfp"; $sql .= " WHERE pfp.fk_facturefourn = ".((int) $invoiceId); $sql .= " ORDER BY pfp.rowid DESC"; $sql .= " LIMIT 1"; $resql = $this->db->query($sql); $paymentExists = ($resql && $this->db->num_rows($resql) > 0); if ($paymentExists) { // Payment exists - link bank entry to it $obj = $this->db->fetch_object($resql); $paymentId = (int) $obj->payment_id; $paiementfourn = new PaiementFourn($this->db); if ($paiementfourn->fetch($paymentId) <= 0) { $this->error = 'Payment not found'; $this->db->rollback(); return -3; } // Check if payment already has a bank entry if (!empty($paiementfourn->bank_line) && $paiementfourn->bank_line > 0) { // Just link transaction to existing bank entry $this->fk_paiementfourn = $paymentId; $this->fk_bank = $paiementfourn->bank_line; $this->fk_facture_fourn = $invoiceId; $this->fk_societe = $invoice->socid; $this->status = self::STATUS_MATCHED; $this->fk_user_match = $user->id; $this->date_match = dol_now(); $this->update($user); $this->db->commit(); return $paiementfourn->bank_line; } // Create bank entry for existing payment $bankLineId = $paiementfourn->addPaymentToBank( $user, 'payment_supplier', '(SupplierInvoicePayment)', $bankAccountId, $this->name, '' ); if ($bankLineId > 0) { $this->fk_paiementfourn = $paymentId; $this->fk_bank = $bankLineId; $this->fk_facture_fourn = $invoiceId; $this->fk_societe = $invoice->socid; $this->status = self::STATUS_MATCHED; $this->fk_user_match = $user->id; $this->date_match = dol_now(); $this->update($user); } else { $this->error = 'Failed to add payment to bank'; $error++; } } else { // No payment exists - create new payment AND bank entry // This handles invoices marked as paid without actual payment record $amounts = array($invoiceId => $invoice->total_ttc); $multicurrency_amounts = array($invoiceId => $invoice->total_ttc); $paiementfourn = new PaiementFourn($this->db); $paiementfourn->datepaye = $this->date_trans; $paiementfourn->amounts = $amounts; $paiementfourn->multicurrency_amounts = $multicurrency_amounts; $paiementfourn->paiementid = $paiementTypeId; $paiementfourn->paiementcode = 'VIR'; $paiementfourn->num_payment = $this->end_to_end_id ?: $this->ref; $paiementfourn->note_private = $langs->trans("PaymentCreatedByBankImport").' - '.$this->name; $paymentId = $paiementfourn->create($user, 1); if ($paymentId < 0) { $this->error = $paiementfourn->error; $this->errors = $paiementfourn->errors; $error++; } if (!$error) { $bankLineId = $paiementfourn->addPaymentToBank( $user, 'payment_supplier', '(SupplierInvoicePayment)', $bankAccountId, $this->name, '' ); if ($bankLineId > 0) { $this->fk_paiementfourn = $paymentId; $this->fk_bank = $bankLineId; $this->fk_facture_fourn = $invoiceId; $this->fk_societe = $invoice->socid; $this->status = self::STATUS_MATCHED; $this->fk_user_match = $user->id; $this->date_match = dol_now(); $this->update($user); } else { $this->error = 'Failed to add payment to bank'; $error++; } } } } elseif ($invoiceType == 'facture') { // Customer invoice require_once DOL_DOCUMENT_ROOT.'/compta/facture/class/facture.class.php'; require_once DOL_DOCUMENT_ROOT.'/compta/paiement/class/paiement.class.php'; $invoice = new Facture($this->db); if ($invoice->fetch($invoiceId) <= 0) { $this->error = 'Invoice not found'; $this->db->rollback(); return -1; } // Find the payment(s) for this invoice $sql = "SELECT pfp.fk_paiement as payment_id, pfp.amount"; $sql .= " FROM ".MAIN_DB_PREFIX."paiement_facture as pfp"; $sql .= " WHERE pfp.fk_facture = ".((int) $invoiceId); $sql .= " ORDER BY pfp.rowid DESC"; $sql .= " LIMIT 1"; $resql = $this->db->query($sql); $paymentExists = ($resql && $this->db->num_rows($resql) > 0); if ($paymentExists) { // Payment exists - link bank entry to it $obj = $this->db->fetch_object($resql); $paymentId = (int) $obj->payment_id; $paiement = new Paiement($this->db); if ($paiement->fetch($paymentId) <= 0) { $this->error = 'Payment not found'; $this->db->rollback(); return -3; } // Check if payment already has a bank entry if (!empty($paiement->bank_line) && $paiement->bank_line > 0) { // Just link transaction to existing bank entry $this->fk_paiement = $paymentId; $this->fk_bank = $paiement->bank_line; $this->fk_facture = $invoiceId; $this->fk_societe = $invoice->socid; $this->status = self::STATUS_MATCHED; $this->fk_user_match = $user->id; $this->date_match = dol_now(); $this->update($user); $this->db->commit(); return $paiement->bank_line; } // Create bank entry for existing payment $bankLineId = $paiement->addPaymentToBank( $user, 'payment', '(CustomerInvoicePayment)', $bankAccountId, $this->name, '' ); if ($bankLineId > 0) { $this->fk_paiement = $paymentId; $this->fk_bank = $bankLineId; $this->fk_facture = $invoiceId; $this->fk_societe = $invoice->socid; $this->status = self::STATUS_MATCHED; $this->fk_user_match = $user->id; $this->date_match = dol_now(); $this->update($user); } else { $this->error = 'Failed to add payment to bank'; $error++; } } else { // No payment exists - create new payment AND bank entry $amounts = array($invoiceId => $invoice->total_ttc); $multicurrency_amounts = array($invoiceId => $invoice->total_ttc); $paiement = new Paiement($this->db); $paiement->datepaye = $this->date_trans; $paiement->amounts = $amounts; $paiement->multicurrency_amounts = $multicurrency_amounts; $paiement->paiementid = $paiementTypeId; $paiement->paiementcode = 'VIR'; $paiement->num_payment = $this->end_to_end_id ?: $this->ref; $paiement->note_private = $langs->trans("PaymentCreatedByBankImport").' - '.$this->name; $paymentId = $paiement->create($user, 1); if ($paymentId < 0) { $this->error = $paiement->error; $this->errors = $paiement->errors; $error++; } if (!$error) { $bankLineId = $paiement->addPaymentToBank( $user, 'payment', '(CustomerInvoicePayment)', $bankAccountId, $this->name, '' ); if ($bankLineId > 0) { $this->fk_paiement = $paymentId; $this->fk_bank = $bankLineId; $this->fk_facture = $invoiceId; $this->fk_societe = $invoice->socid; $this->status = self::STATUS_MATCHED; $this->fk_user_match = $user->id; $this->date_match = dol_now(); $this->update($user); } else { $this->error = 'Failed to add payment to bank'; $error++; } } } } else { $this->error = 'Unknown invoice type: '.$invoiceType; $this->db->rollback(); return -4; } if ($error) { $this->db->rollback(); return -5; } $this->db->commit(); return $bankLineId; } /** * Link multiple already paid invoices to this bank transaction * Finds existing payments for these invoices and links them to a single bank entry * * @param User $user User making the link * @param array $invoices Array of ['type' => 'facture'|'facture_fourn', 'id' => int] * @param int $bankAccountId Dolibarr bank account ID * @return int <0 if KO, >0 bank line ID if OK */ public function linkMultipleExistingPayments($user, $invoices, $bankAccountId) { global $conf, $langs; if (empty($invoices)) { $this->error = 'No invoices provided'; return -1; } $error = 0; $bankLineId = 0; $this->db->begin(); // Determine invoice type (all must be same type) $firstType = $invoices[0]['type']; $isSupplier = ($firstType == 'facture_fourn'); // Collect all payment IDs and check they exist $paymentIds = array(); $socid = 0; $invoiceRefs = array(); foreach ($invoices as $inv) { $invoiceId = (int) $inv['id']; if ($isSupplier) { require_once DOL_DOCUMENT_ROOT.'/fourn/class/fournisseur.facture.class.php'; $invoice = new FactureFournisseur($this->db); if ($invoice->fetch($invoiceId) <= 0) { $this->error = 'Invoice not found: '.$invoiceId; $this->db->rollback(); return -2; } $invoiceRefs[] = $invoice->ref; if ($socid == 0) { $socid = $invoice->socid; } // Find payment for this invoice $sql = "SELECT pfp.fk_paiement_fourn as payment_id"; $sql .= " FROM ".MAIN_DB_PREFIX."paiementfourn_facturefourn as pfp"; $sql .= " WHERE pfp.fk_facturefourn = ".((int) $invoiceId); $sql .= " ORDER BY pfp.rowid DESC LIMIT 1"; $resql = $this->db->query($sql); if ($resql && $this->db->num_rows($resql) > 0) { $obj = $this->db->fetch_object($resql); $paymentIds[$obj->payment_id] = $obj->payment_id; } } else { require_once DOL_DOCUMENT_ROOT.'/compta/facture/class/facture.class.php'; $invoice = new Facture($this->db); if ($invoice->fetch($invoiceId) <= 0) { $this->error = 'Invoice not found: '.$invoiceId; $this->db->rollback(); return -2; } $invoiceRefs[] = $invoice->ref; if ($socid == 0) { $socid = $invoice->socid; } // Find payment for this invoice $sql = "SELECT pfp.fk_paiement as payment_id"; $sql .= " FROM ".MAIN_DB_PREFIX."paiement_facture as pfp"; $sql .= " WHERE pfp.fk_facture = ".((int) $invoiceId); $sql .= " ORDER BY pfp.rowid DESC LIMIT 1"; $resql = $this->db->query($sql); if ($resql && $this->db->num_rows($resql) > 0) { $obj = $this->db->fetch_object($resql); $paymentIds[$obj->payment_id] = $obj->payment_id; } } } // Look up payment type ID for 'VIR' (bank transfer) - needed if we create new payment $paiementTypeId = 0; $sql = "SELECT id FROM ".MAIN_DB_PREFIX."c_paiement WHERE code = 'VIR' AND active = 1"; $resql = $this->db->query($sql); if ($resql && $this->db->num_rows($resql) > 0) { $obj = $this->db->fetch_object($resql); $paiementTypeId = (int) $obj->id; } // If no payments found, we need to create new payment(s) if (empty($paymentIds)) { // No existing payment - create one for all invoices if ($isSupplier) { require_once DOL_DOCUMENT_ROOT.'/fourn/class/paiementfourn.class.php'; // Build amounts array from invoices $amounts = array(); $multicurrency_amounts = array(); foreach ($invoices as $inv) { $invoice = new FactureFournisseur($this->db); if ($invoice->fetch($inv['id']) > 0) { $amounts[$inv['id']] = $invoice->total_ttc; $multicurrency_amounts[$inv['id']] = $invoice->total_ttc; } } $paiementfourn = new PaiementFourn($this->db); $paiementfourn->datepaye = $this->date_trans; $paiementfourn->amounts = $amounts; $paiementfourn->multicurrency_amounts = $multicurrency_amounts; $paiementfourn->paiementid = $paiementTypeId; $paiementfourn->paiementcode = 'VIR'; $paiementfourn->num_payment = $this->end_to_end_id ?: $this->ref; $paiementfourn->note_private = $langs->trans("PaymentCreatedByBankImport").' - '.$this->name; $paymentId = $paiementfourn->create($user, 1); if ($paymentId < 0) { $this->error = $paiementfourn->error; $this->errors = $paiementfourn->errors; $this->db->rollback(); return -3; } $bankLineId = $paiementfourn->addPaymentToBank( $user, 'payment_supplier', '(SupplierInvoicePayment)', $bankAccountId, $this->name, '' ); if ($bankLineId > 0) { $this->fk_paiementfourn = $paymentId; $this->fk_bank = $bankLineId; $this->fk_facture_fourn = $invoices[0]['id']; $this->fk_societe = $socid; $this->status = self::STATUS_MATCHED; $this->fk_user_match = $user->id; $this->date_match = dol_now(); $this->note_private = ($this->note_private ? $this->note_private."\n" : '').'Multi-invoice link: '.implode(', ', $invoiceRefs); $this->update($user); $this->db->commit(); return $bankLineId; } else { $this->error = 'Failed to add payment to bank'; $this->db->rollback(); return -5; } } else { require_once DOL_DOCUMENT_ROOT.'/compta/paiement/class/paiement.class.php'; // Build amounts array from invoices $amounts = array(); $multicurrency_amounts = array(); foreach ($invoices as $inv) { $invoice = new Facture($this->db); if ($invoice->fetch($inv['id']) > 0) { $amounts[$inv['id']] = $invoice->total_ttc; $multicurrency_amounts[$inv['id']] = $invoice->total_ttc; } } $paiement = new Paiement($this->db); $paiement->datepaye = $this->date_trans; $paiement->amounts = $amounts; $paiement->multicurrency_amounts = $multicurrency_amounts; $paiement->paiementid = $paiementTypeId; $paiement->paiementcode = 'VIR'; $paiement->num_payment = $this->end_to_end_id ?: $this->ref; $paiement->note_private = $langs->trans("PaymentCreatedByBankImport").' - '.$this->name; $paymentId = $paiement->create($user, 1); if ($paymentId < 0) { $this->error = $paiement->error; $this->errors = $paiement->errors; $this->db->rollback(); return -3; } $bankLineId = $paiement->addPaymentToBank( $user, 'payment', '(CustomerInvoicePayment)', $bankAccountId, $this->name, '' ); if ($bankLineId > 0) { $this->fk_paiement = $paymentId; $this->fk_bank = $bankLineId; $this->fk_facture = $invoices[0]['id']; $this->fk_societe = $socid; $this->status = self::STATUS_MATCHED; $this->fk_user_match = $user->id; $this->date_match = dol_now(); $this->note_private = ($this->note_private ? $this->note_private."\n" : '').'Multi-invoice link: '.implode(', ', $invoiceRefs); $this->update($user); $this->db->commit(); return $bankLineId; } else { $this->error = 'Failed to add payment to bank'; $this->db->rollback(); return -5; } } } // Use the first payment to link to bank $firstPaymentId = reset($paymentIds); if ($isSupplier) { require_once DOL_DOCUMENT_ROOT.'/fourn/class/paiementfourn.class.php'; $paiementfourn = new PaiementFourn($this->db); if ($paiementfourn->fetch($firstPaymentId) <= 0) { $this->error = 'Payment not found'; $this->db->rollback(); return -4; } // Check if payment already has a bank entry if (!empty($paiementfourn->bank_line) && $paiementfourn->bank_line > 0) { $bankLineId = $paiementfourn->bank_line; } else { // Create bank entry for existing payment $bankLineId = $paiementfourn->addPaymentToBank( $user, 'payment_supplier', '(SupplierInvoicePayment)', $bankAccountId, $this->name, '' ); } if ($bankLineId > 0) { $this->fk_paiementfourn = $firstPaymentId; $this->fk_bank = $bankLineId; $this->fk_facture_fourn = $invoices[0]['id']; $this->fk_societe = $socid; $this->status = self::STATUS_MATCHED; $this->fk_user_match = $user->id; $this->date_match = dol_now(); $this->note_private = ($this->note_private ? $this->note_private."\n" : '').'Multi-invoice link: '.implode(', ', $invoiceRefs); $this->update($user); } else { $this->error = 'Failed to add payment to bank'; $error++; } } else { require_once DOL_DOCUMENT_ROOT.'/compta/paiement/class/paiement.class.php'; $paiement = new Paiement($this->db); if ($paiement->fetch($firstPaymentId) <= 0) { $this->error = 'Payment not found'; $this->db->rollback(); return -4; } // Check if payment already has a bank entry if (!empty($paiement->bank_line) && $paiement->bank_line > 0) { $bankLineId = $paiement->bank_line; } else { // Create bank entry for existing payment $bankLineId = $paiement->addPaymentToBank( $user, 'payment', '(CustomerInvoicePayment)', $bankAccountId, $this->name, '' ); } if ($bankLineId > 0) { $this->fk_paiement = $firstPaymentId; $this->fk_bank = $bankLineId; $this->fk_facture = $invoices[0]['id']; $this->fk_societe = $socid; $this->status = self::STATUS_MATCHED; $this->fk_user_match = $user->id; $this->date_match = dol_now(); $this->note_private = ($this->note_private ? $this->note_private."\n" : '').'Multi-invoice link: '.implode(', ', $invoiceRefs); $this->update($user); } else { $this->error = 'Failed to add payment to bank'; $error++; } } if ($error) { $this->db->rollback(); return -5; } $this->db->commit(); return $bankLineId; } /** * Unlink payment from this transaction * Resets all links and sets status back to NEW * Note: This does NOT delete the payment in Dolibarr, just removes the link * * @param User $user User making the change * @return int <0 if KO, >0 if OK */ public function unlinkPayment($user) { $this->db->begin(); // Reset all links $this->fk_bank = null; $this->fk_facture = null; $this->fk_facture_fourn = null; $this->fk_paiement = null; $this->fk_paiementfourn = null; $this->fk_societe = null; $this->fk_salary = null; $this->fk_don = null; $this->fk_loan = null; // Reset status to NEW $this->status = self::STATUS_NEW; $this->fk_user_match = null; $this->date_match = null; $result = $this->update($user); if ($result > 0) { $this->db->commit(); return 1; } else { $this->db->rollback(); return -1; } } /** * Link transaction to a Dolibarr object * * @param string $type Object type (facture, facture_fourn, societe, etc.) * @param int $id Object ID * @param User $user User making the link * @return int <0 if KO, >0 if OK */ public function linkTo($type, $id, $user) { switch ($type) { case 'facture': $this->fk_facture = $id; break; case 'facture_fourn': $this->fk_facture_fourn = $id; break; case 'societe': $this->fk_societe = $id; break; case 'bank': $this->fk_bank = $id; break; case 'paiement': $this->fk_paiement = $id; break; case 'paiementfourn': $this->fk_paiementfourn = $id; break; case 'salary': $this->fk_salary = $id; break; case 'don': $this->fk_don = $id; break; case 'loan': $this->fk_loan = $id; break; default: $this->error = 'Unknown link type: '.$type; return -1; } $this->status = self::STATUS_MATCHED; return $this->update($user); } }