dolibarr.bankimport/confirm.php
data 94efa59df3 v1.7: Multi-invoice payments and payment unlinking
- Add multi-invoice payment support (link one bank transaction to multiple invoices)
- Add payment unlinking feature to correct wrong matches
- Show linked payments, invoices and bank entries in transaction detail view
- Allow linking already paid invoices to bank transactions
- Update README with new features
- Add CHANGELOG.md

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

458 lines
16 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/confirm.php
* \ingroup bankimport
* \brief Payment confirmation page - match bank transactions to invoices
*/
// Load Dolibarr environment
$res = 0;
if (!$res && !empty($_SERVER["CONTEXT_DOCUMENT_ROOT"])) {
$res = @include $_SERVER["CONTEXT_DOCUMENT_ROOT"]."/main.inc.php";
}
$tmp = empty($_SERVER['SCRIPT_FILENAME']) ? '' : $_SERVER['SCRIPT_FILENAME'];
$tmp2 = realpath(__FILE__);
$i = strlen($tmp) - 1;
$j = strlen($tmp2) - 1;
while ($i > 0 && $j > 0 && isset($tmp[$i]) && isset($tmp2[$j]) && $tmp[$i] == $tmp2[$j]) {
$i--;
$j--;
}
if (!$res && $i > 0 && file_exists(substr($tmp, 0, ($i + 1))."/main.inc.php")) {
$res = @include substr($tmp, 0, ($i + 1))."/main.inc.php";
}
if (!$res && $i > 0 && file_exists(dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php")) {
$res = @include dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php";
}
if (!$res && file_exists("../main.inc.php")) {
$res = @include "../main.inc.php";
}
if (!$res && file_exists("../../main.inc.php")) {
$res = @include "../../main.inc.php";
}
if (!$res) {
die("Include of main fails");
}
require_once DOL_DOCUMENT_ROOT.'/core/lib/date.lib.php';
require_once DOL_DOCUMENT_ROOT.'/core/class/html.formother.class.php';
dol_include_once('/bankimport/class/banktransaction.class.php');
dol_include_once('/bankimport/lib/bankimport.lib.php');
/**
* @var Conf $conf
* @var DoliDB $db
* @var Translate $langs
* @var User $user
*/
$langs->loadLangs(array("bankimport@bankimport", "banks", "bills"));
$action = GETPOST('action', 'aZ09');
// Security check
if (!$user->hasRight('bankimport', 'write')) {
accessforbidden();
}
$bankAccountId = getDolGlobalInt('BANKIMPORT_BANK_ACCOUNT_ID');
/*
* Actions
*/
// Confirm single payment
if ($action == 'confirmpayment' && !empty($bankAccountId)) {
$transid = GETPOSTINT('transid');
$matchtype = GETPOST('matchtype', 'alpha');
$matchid = GETPOSTINT('matchid');
if ($transid > 0 && !empty($matchtype) && $matchid > 0) {
$trans = new BankImportTransaction($db);
if ($trans->fetch($transid) > 0) {
if ($trans->status != BankImportTransaction::STATUS_NEW) {
setEventMessages($langs->trans("TransactionAlreadyProcessed"), null, 'warnings');
} else {
$result = $trans->confirmPayment($user, $matchtype, $matchid, $bankAccountId);
if ($result > 0) {
setEventMessages($langs->trans("PaymentCreatedSuccessfully", dol_escape_htmltag($trans->name), price(abs($trans->amount))), null, 'mesgs');
} else {
setEventMessages($trans->error, $trans->errors, 'errors');
}
}
}
}
header("Location: ".$_SERVER["PHP_SELF"]."?token=".newToken());
exit;
}
// Confirm multiple invoices payment
if ($action == 'confirmmulti' && !empty($bankAccountId)) {
$transid = GETPOSTINT('transid');
$invoiceIds = GETPOST('invoices', 'array');
if ($transid > 0 && !empty($invoiceIds)) {
$trans = new BankImportTransaction($db);
if ($trans->fetch($transid) > 0) {
if ($trans->status != BankImportTransaction::STATUS_NEW) {
setEventMessages($langs->trans("TransactionAlreadyProcessed"), null, 'warnings');
} else {
// Build invoices array with amounts
require_once DOL_DOCUMENT_ROOT.'/fourn/class/fournisseur.facture.class.php';
$invoices = array();
foreach ($invoiceIds as $invId) {
$invId = (int) $invId;
if ($invId > 0) {
$invoice = new FactureFournisseur($db);
if ($invoice->fetch($invId) > 0) {
$alreadyPaid = $invoice->getSommePaiement();
$creditnotes = $invoice->getSumCreditNotesUsed();
$deposits = $invoice->getSumDepositsUsed();
$remainToPay = price2num($invoice->total_ttc - $alreadyPaid - $creditnotes - $deposits, 'MT');
if ($remainToPay > 0) {
$invoices[] = array(
'type' => 'facture_fourn',
'id' => $invId,
'ref' => $invoice->ref,
'amount' => $remainToPay
);
}
}
}
}
if (!empty($invoices)) {
$result = $trans->confirmMultiplePayment($user, $invoices, $bankAccountId);
if ($result > 0) {
setEventMessages($langs->trans("PaymentCreatedSuccessfully", dol_escape_htmltag($trans->name), price(abs($trans->amount))).' ('.count($invoices).' '.$langs->trans("Invoices").')', null, 'mesgs');
} else {
setEventMessages($trans->error, $trans->errors, 'errors');
}
} else {
setEventMessages($langs->trans("NoInvoicesSelected"), null, 'errors');
}
}
}
}
header("Location: ".$_SERVER["PHP_SELF"]."?token=".newToken());
exit;
}
// Confirm all high-score matches
if ($action == 'confirmall' && !empty($bankAccountId)) {
$transaction = new BankImportTransaction($db);
$transactions = $transaction->fetchAll('date_trans', 'DESC', 0, 0, array('status' => BankImportTransaction::STATUS_NEW));
$created = 0;
$failed = 0;
if (is_array($transactions)) {
foreach ($transactions as $trans) {
$matches = $trans->findMatches();
if (!empty($matches) && $matches[0]['match_score'] >= 80) {
$bestMatch = $matches[0];
// Handle multi-invoice matches
if ($bestMatch['type'] == 'multi_facture_fourn' && !empty($bestMatch['invoices'])) {
$invoices = array();
foreach ($bestMatch['invoices'] as $inv) {
$invoices[] = array(
'type' => 'facture_fourn',
'id' => $inv['id'],
'ref' => $inv['ref'],
'amount' => $inv['amount']
);
}
$result = $trans->confirmMultiplePayment($user, $invoices, $bankAccountId);
} else {
$result = $trans->confirmPayment($user, $bestMatch['type'], $bestMatch['id'], $bankAccountId);
}
if ($result > 0) {
$created++;
} else {
$failed++;
}
}
}
}
if ($created > 0 || $failed > 0) {
setEventMessages($langs->trans("PaymentsCreatedSummary", $created, $failed), null, $failed > 0 ? 'warnings' : 'mesgs');
} else {
setEventMessages($langs->trans("NoNewMatchesFound"), null, 'warnings');
}
header("Location: ".$_SERVER["PHP_SELF"]."?token=".newToken());
exit;
}
// Ignore transaction
if ($action == 'ignore') {
$transid = GETPOSTINT('transid');
if ($transid > 0) {
$trans = new BankImportTransaction($db);
if ($trans->fetch($transid) > 0 && $trans->status == BankImportTransaction::STATUS_NEW) {
$trans->setStatus(BankImportTransaction::STATUS_IGNORED, $user);
setEventMessages($langs->trans("StatusUpdated"), null, 'mesgs');
}
}
header("Location: ".$_SERVER["PHP_SELF"]."?token=".newToken());
exit;
}
/*
* View
*/
$form = new Form($db);
$title = $langs->trans("PaymentConfirmation");
llxHeader('', $title, '', '', 0, 0, '', '', '', 'mod-bankimport page-confirm');
print load_fiche_titre($title, '', 'bank');
// Check if bank account is configured
if (empty($bankAccountId)) {
print '<div class="warning">';
print img_warning().' '.$langs->trans("ErrorNoBankAccountConfigured");
print ' <a href="'.dol_buildpath('/bankimport/admin/setup.php', 1).'">'.$langs->trans("GoToSetup").'</a>';
print '</div>';
llxFooter();
$db->close();
exit;
}
// Description
print '<div class="opacitymedium" style="margin-bottom: 15px;">'.$langs->trans("PaymentConfirmationDesc").'</div>';
// Fetch all new transactions and find matches
$transaction = new BankImportTransaction($db);
$transactions = $transaction->fetchAll('date_trans', 'DESC', 0, 0, array('status' => BankImportTransaction::STATUS_NEW));
$pendingMatches = array(); // transactions with matches
$noMatches = array(); // transactions without matches
if (is_array($transactions)) {
foreach ($transactions as $trans) {
$matches = $trans->findMatches();
if (!empty($matches)) {
$pendingMatches[] = array('transaction' => $trans, 'matches' => $matches);
} else {
$noMatches[] = $trans;
}
}
}
// Confirm all button (if high-score matches exist)
$highScoreCount = 0;
foreach ($pendingMatches as $pm) {
if ($pm['matches'][0]['match_score'] >= 80) {
$highScoreCount++;
}
}
if ($highScoreCount > 0) {
print '<div class="tabsAction">';
print '<a class="butAction" href="'.$_SERVER["PHP_SELF"].'?action=confirmall&token='.newToken().'">';
print $langs->trans("ConfirmAllHighScore").' ('.$highScoreCount.')';
print '</a>';
print '</div>';
}
// Match reasons translation
$reasonLabels = array(
'ref' => $langs->trans("MatchByRef"),
'ref_client' => $langs->trans("MatchByClientRef"),
'ref_supplier' => $langs->trans("MatchBySupplierRef"),
'amount' => $langs->trans("MatchByAmount"),
'amount_close' => $langs->trans("MatchByAmountClose"),
'name_exact' => $langs->trans("MatchByNameExact"),
'name_similar' => $langs->trans("MatchByNameSimilar"),
'iban' => $langs->trans("MatchByIBAN"),
'multi_invoice' => $langs->trans("MatchByMultiInvoice")
);
if (!empty($pendingMatches)) {
print '<div class="div-table-responsive">';
print '<table class="noborder centpercent">';
print '<tr class="liste_titre">';
print '<th>'.$langs->trans("Date").'</th>';
print '<th>'.$langs->trans("Counterparty").'</th>';
print '<th class="right">'.$langs->trans("Amount").' ('.$langs->trans("Transaction").')</th>';
print '<th style="text-align: center; width: 30px;"></th>';
print '<th>'.$langs->trans("Invoice").'</th>';
print '<th>'.$langs->trans("ThirdParty").'</th>';
print '<th class="right">'.$langs->trans("Amount").' ('.$langs->trans("Invoice").')</th>';
print '<th class="center">'.$langs->trans("Score").'</th>';
print '<th>'.$langs->trans("MatchReason").'</th>';
print '<th class="center">'.$langs->trans("Action").'</th>';
print '</tr>';
foreach ($pendingMatches as $pm) {
$trans = $pm['transaction'];
$bestMatch = $pm['matches'][0];
$isMultiInvoice = ($bestMatch['type'] == 'multi_facture_fourn');
// Score color
$scoreColor = $bestMatch['match_score'] >= 80 ? '#4caf50' : ($bestMatch['match_score'] >= 60 ? '#ff9800' : '#9e9e9e');
print '<tr class="oddeven">';
// Transaction date
print '<td class="nowraponall">'.dol_print_date($trans->date_trans, 'day').'</td>';
// Counterparty name + description
print '<td class="tdoverflowmax200">';
print '<a href="'.dol_buildpath('/bankimport/card.php', 1).'?id='.$trans->id.'">'.dol_escape_htmltag(dol_trunc($trans->name, 30)).'</a>';
if ($trans->description) {
print '<br><span class="opacitymedium small">'.dol_escape_htmltag(dol_trunc($trans->description, 50)).'</span>';
}
print '</td>';
// Transaction amount
print '<td class="right nowraponall">';
if ($trans->amount >= 0) {
print '<span style="color: green; font-weight: bold;">+'.price($trans->amount, 0, $langs, 1, -1, 2, $trans->currency).'</span>';
} else {
print '<span style="color: red; font-weight: bold;">'.price($trans->amount, 0, $langs, 1, -1, 2, $trans->currency).'</span>';
}
print '</td>';
// Arrow
print '<td class="center" style="font-size: 1.3em;">&harr;</td>';
// Invoice reference(s)
print '<td class="nowraponall">';
if ($isMultiInvoice && !empty($bestMatch['invoices'])) {
// Multi-invoice display
require_once DOL_DOCUMENT_ROOT.'/fourn/class/fournisseur.facture.class.php';
print '<strong>'.count($bestMatch['invoices']).' '.$langs->trans("Invoices").':</strong><br>';
foreach ($bestMatch['invoices'] as $invData) {
$inv = new FactureFournisseur($db);
$inv->fetch($invData['id']);
print $inv->getNomUrl(1).' <span class="opacitymedium small">('.price($invData['amount'], 0, $langs, 1, -1, 2, 'EUR').')</span><br>';
}
} elseif ($bestMatch['type'] == 'facture') {
require_once DOL_DOCUMENT_ROOT.'/compta/facture/class/facture.class.php';
$inv = new Facture($db);
$inv->fetch($bestMatch['id']);
print $inv->getNomUrl(1);
} else {
require_once DOL_DOCUMENT_ROOT.'/fourn/class/fournisseur.facture.class.php';
$inv = new FactureFournisseur($db);
$inv->fetch($bestMatch['id']);
print $inv->getNomUrl(1);
}
print '</td>';
// Third party
print '<td class="tdoverflowmax150">';
if ($bestMatch['socid'] > 0) {
require_once DOL_DOCUMENT_ROOT.'/societe/class/societe.class.php';
$soc = new Societe($db);
$soc->fetch($bestMatch['socid']);
print $soc->getNomUrl(1);
} else {
print dol_escape_htmltag($bestMatch['socname']);
}
print '</td>';
// Invoice amount
print '<td class="right nowraponall">';
print price($bestMatch['amount'], 0, $langs, 1, -1, 2, 'EUR');
if ($isMultiInvoice && isset($bestMatch['difference']) && abs($bestMatch['difference']) > 0.01) {
$diffColor = $bestMatch['difference'] > 0 ? 'orange' : 'green';
print '<br><span class="small" style="color: '.$diffColor.';">'.($bestMatch['difference'] > 0 ? '+' : '').price($bestMatch['difference'], 0, $langs, 1, -1, 2, 'EUR').'</span>';
}
print '</td>';
// Score
print '<td class="center"><span style="color: '.$scoreColor.'; font-weight: bold; font-size: 1.1em;">'.$bestMatch['match_score'].'%</span></td>';
// Match reasons
print '<td>';
if (!empty($bestMatch['match_reasons'])) {
foreach ($bestMatch['match_reasons'] as $reason) {
$label = $reasonLabels[$reason] ?? $reason;
print '<span class="badge badge-secondary">'.$label.'</span> ';
}
}
print '</td>';
// Actions
print '<td class="center nowraponall">';
if ($isMultiInvoice && !empty($bestMatch['invoices'])) {
// Multi-invoice: Form with checkboxes for selection
print '<form method="POST" action="'.$_SERVER["PHP_SELF"].'" style="display: inline;">';
print '<input type="hidden" name="token" value="'.newToken().'">';
print '<input type="hidden" name="action" value="confirmmulti">';
print '<input type="hidden" name="transid" value="'.$trans->id.'">';
print '<div style="text-align: left; margin-bottom: 5px;">';
foreach ($bestMatch['invoices'] as $invData) {
print '<label style="display: block; margin: 2px 0;">';
print '<input type="checkbox" name="invoices[]" value="'.$invData['id'].'" checked> ';
print dol_escape_htmltag($invData['ref_supplier'] ?: $invData['ref']).' ('.price($invData['amount'], 0, $langs, 1, -1, 2, 'EUR').')';
print '</label>';
}
print '</div>';
print '<button type="submit" class="butActionSmall">'.$langs->trans("ConfirmPayment").'</button>';
print '</form>';
} else {
// Single invoice: Confirm payment button
print '<a class="butActionSmall" href="'.$_SERVER["PHP_SELF"].'?action=confirmpayment&transid='.$trans->id.'&matchtype='.urlencode($bestMatch['type']).'&matchid='.$bestMatch['id'].'&token='.newToken().'">';
print $langs->trans("ConfirmPayment");
print '</a>';
}
print '<br>';
// Ignore button
print '<a class="butActionSmall button-cancel" style="margin-top: 3px;" href="'.$_SERVER["PHP_SELF"].'?action=ignore&transid='.$trans->id.'&token='.newToken().'">';
print $langs->trans("SetAsIgnored");
print '</a>';
// Show alternatives if multiple matches
if (count($pm['matches']) > 1) {
print '<br><a class="small opacitymedium" style="margin-top: 3px;" href="'.dol_buildpath('/bankimport/card.php', 1).'?id='.$trans->id.'&action=findmatches&token='.newToken().'">';
print '+'.($count = count($pm['matches']) - 1).' '.$langs->trans("Alternatives");
print '</a>';
}
print '</td>';
print '</tr>';
}
print '</table>';
print '</div>';
} else {
print '<div class="opacitymedium" style="padding: 20px; text-align: center;">';
print $langs->trans("NoNewMatchesFound");
print '</div>';
}
// Show unmatched transactions count
if (!empty($noMatches)) {
print '<br>';
print '<div class="info">';
print img_picto('', 'info', 'class="pictofixedwidth"');
print $langs->trans("UnmatchedTransactions", count($noMatches));
print ' <a href="'.dol_buildpath('/bankimport/list.php', 1).'?search_status=0">'.$langs->trans("ShowAll").'</a>';
print '</div>';
}
llxFooter();
$db->close();