dolibarr.bankimport/class/banktransaction.class.php
data 2dc9279143 Fix: linkExistingPayment and linkMultipleExistingPayments not updating transaction
When linking paid invoices that already have bank entries:
- Added missing commit() and return after update() in bank_line branch
- Added proper error handling for update() failures
- Fixed both single invoice (linkExistingPayment) and multi-invoice linking

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-20 09:09:32 +01:00

2337 lines
68 KiB
PHP
Executable file

<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*/
/**
* \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();
$result = $this->update($user);
if ($result <= 0) {
$this->error = 'Failed to update transaction: '.$this->error;
$this->db->rollback();
return -6;
}
$this->db->commit();
return $paiementfourn->bank_line > 0 ? $paiementfourn->bank_line : 1;
}
// 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) {
// Payment already has a bank entry in Dolibarr
// Just link this imported transaction to the existing payment WITHOUT creating new bank entry
$bankLineId = $paiementfourn->bank_line;
$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);
$result = $this->update($user);
if ($result <= 0) {
$this->error = 'Failed to update transaction: '.$this->error;
$this->db->rollback();
return -6;
}
$this->db->commit();
return $bankLineId > 0 ? $bankLineId : 1;
} 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) {
// Payment already has a bank entry in Dolibarr
// Just link this imported transaction to the existing payment WITHOUT creating new bank entry
$bankLineId = $paiement->bank_line;
$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);
$result = $this->update($user);
if ($result <= 0) {
$this->error = 'Failed to update transaction: '.$this->error;
$this->db->rollback();
return -6;
}
$this->db->commit();
return $bankLineId > 0 ? $bankLineId : 1;
} 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 > 0 ? $bankLineId : 1;
}
/**
* 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);
}
}