diff --git a/admin/setup.php b/admin/setup.php index f68b2e9..e2ca39e 100755 --- a/admin/setup.php +++ b/admin/setup.php @@ -199,6 +199,12 @@ if ($action == 'update') { $error++; } + // Dolibarr Bank Account mapping + $res = dolibarr_set_const($db, "BANKIMPORT_BANK_ACCOUNT_ID", GETPOSTINT('BANKIMPORT_BANK_ACCOUNT_ID'), 'chaine', 0, '', $conf->entity); + if (!($res > 0)) { + $error++; + } + // Reminder setting $res = dolibarr_set_const($db, "BANKIMPORT_REMINDER_ENABLED", GETPOSTINT('BANKIMPORT_REMINDER_ENABLED'), 'chaine', 0, '', $conf->entity); if (!($res > 0)) { @@ -433,6 +439,41 @@ print ''; print ''; +// Bank Account Mapping Section +print '
'; +print ''; + +print ''; +print ''; +print ''; + +// Bank Account Dropdown +$bankAccountId = getDolGlobalInt('BANKIMPORT_BANK_ACCOUNT_ID'); +print ''; +print ''; +print ''; +print ''; + +print '
'.$langs->trans("BankAccountMapping").'
'.$langs->trans("DolibarrBankAccount").''; + +// Build select from llx_bank_account +$sql_ba = "SELECT rowid, label, iban_prefix FROM ".MAIN_DB_PREFIX."bank_account"; +$sql_ba .= " WHERE entity = ".((int) $conf->entity); +$sql_ba .= " AND clos = 0"; +$sql_ba .= " ORDER BY label ASC"; + +$bankAccounts = array('' => $langs->trans("SelectBankAccount")); +$resql_ba = $db->query($sql_ba); +if ($resql_ba) { + while ($obj_ba = $db->fetch_object($resql_ba)) { + $ibanDisplay = $obj_ba->iban_prefix ? ' ('.dol_trunc($obj_ba->iban_prefix, 20).')' : ''; + $bankAccounts[$obj_ba->rowid] = $obj_ba->label.$ibanDisplay; + } +} +print $form->selectarray('BANKIMPORT_BANK_ACCOUNT_ID', $bankAccounts, $bankAccountId, 0, 0, 0, '', 0, 0, 0, '', 'minwidth300'); +print '
'.$langs->trans("DolibarrBankAccountHelp").''; +print '
'; + // Automatic Import Section print '
'; print ''; diff --git a/bankimportindex.php b/bankimportindex.php index f4c8a96..efd7a9d 100755 --- a/bankimportindex.php +++ b/bankimportindex.php @@ -113,6 +113,36 @@ if ($reminderEnabled) { } } +// Payment matching notification +$bankAccountId = getDolGlobalInt('BANKIMPORT_BANK_ACCOUNT_ID'); +if (!empty($bankAccountId)) { + $sqlNewCount = "SELECT COUNT(*) as cnt FROM ".MAIN_DB_PREFIX."bankimport_transaction"; + $sqlNewCount .= " WHERE entity IN (".getEntity('banktransaction').")"; + $sqlNewCount .= " AND status = 0"; + $resNewCount = $db->query($sqlNewCount); + $newCount = 0; + if ($resNewCount) { + $objNewCount = $db->fetch_object($resNewCount); + $newCount = (int) $objNewCount->cnt; + } + + if ($newCount > 0) { + print '
'; + print img_picto('', 'payment', 'class="pictofixedwidth"'); + print ''.$langs->trans("PendingPaymentMatches", $newCount).''; + print '
'.$langs->trans("PendingPaymentMatchesDesc"); + print ' '; + print $langs->trans("ReviewAndConfirm"); + print ''; + print '
'; + } +} else { + print '
'; + print img_warning().' '.$langs->trans("NoBankAccountConfigured"); + print ' '.$langs->trans("GoToSetup").''; + print '
'; +} + print '
'; // ----------------------------------------------- diff --git a/class/banktransaction.class.php b/class/banktransaction.class.php index 4c63631..4648030 100755 --- a/class/banktransaction.class.php +++ b/class/banktransaction.class.php @@ -1019,6 +1019,193 @@ class BankImportTransaction extends CommonObject 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; + } + /** * Link transaction to a Dolibarr object * diff --git a/confirm.php b/confirm.php new file mode 100644 index 0000000..f42a8b8 --- /dev/null +++ b/confirm.php @@ -0,0 +1,350 @@ + + * + * 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 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]; + $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 '
'; + print img_warning().' '.$langs->trans("ErrorNoBankAccountConfigured"); + print ' '.$langs->trans("GoToSetup").''; + print '
'; + llxFooter(); + $db->close(); + exit; +} + +// Description +print '
'.$langs->trans("PaymentConfirmationDesc").'
'; + +// 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 ''; +} + +// 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") +); + +if (!empty($pendingMatches)) { + print '
'; + print '
'; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + + foreach ($pendingMatches as $pm) { + $trans = $pm['transaction']; + $bestMatch = $pm['matches'][0]; + + // Score color + $scoreColor = $bestMatch['match_score'] >= 80 ? '#4caf50' : ($bestMatch['match_score'] >= 60 ? '#ff9800' : '#9e9e9e'); + + print ''; + + // Transaction date + print ''; + + // Counterparty name + description + print ''; + + // Transaction amount + print ''; + + // Arrow + print ''; + + // Invoice reference + print ''; + + // Third party + print ''; + + // Invoice amount + print ''; + + // Score + print ''; + + // Match reasons + print ''; + + // Actions + print ''; + + print ''; + } + + print '
'.$langs->trans("Date").''.$langs->trans("Counterparty").''.$langs->trans("Amount").' ('.$langs->trans("Transaction").')'.$langs->trans("Invoice").''.$langs->trans("ThirdParty").''.$langs->trans("Amount").' ('.$langs->trans("Invoice").')'.$langs->trans("Score").''.$langs->trans("MatchReason").''.$langs->trans("Action").'
'.dol_print_date($trans->date_trans, 'day').''; + print ''.dol_escape_htmltag(dol_trunc($trans->name, 30)).''; + if ($trans->description) { + print '
'.dol_escape_htmltag(dol_trunc($trans->description, 50)).''; + } + print '
'; + if ($trans->amount >= 0) { + print '+'.price($trans->amount, 0, $langs, 1, -1, 2, $trans->currency).''; + } else { + print ''.price($trans->amount, 0, $langs, 1, -1, 2, $trans->currency).''; + } + print ''; + if ($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 ''; + 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 ''.price($bestMatch['amount'], 0, $langs, 1, -1, 2, 'EUR').''.$bestMatch['match_score'].'%'; + if (!empty($bestMatch['match_reasons'])) { + foreach ($bestMatch['match_reasons'] as $reason) { + $label = $reasonLabels[$reason] ?? $reason; + print ''.$label.' '; + } + } + print ''; + // Confirm payment button + print 'id.'&matchtype='.urlencode($bestMatch['type']).'&matchid='.$bestMatch['id'].'&token='.newToken().'">'; + print $langs->trans("ConfirmPayment"); + print ''; + print '
'; + // Ignore button + print 'id.'&token='.newToken().'">'; + print $langs->trans("SetAsIgnored"); + print ''; + + // Show alternatives if multiple matches + if (count($pm['matches']) > 1) { + print '
'; + print '+'.($count = count($pm['matches']) - 1).' '.$langs->trans("Alternatives"); + print ''; + } + print '
'; + print ''; +} else { + print '
'; + print $langs->trans("NoNewMatchesFound"); + print '
'; +} + +// Show unmatched transactions count +if (!empty($noMatches)) { + print '
'; + print '
'; + print img_picto('', 'info', 'class="pictofixedwidth"'); + print $langs->trans("UnmatchedTransactions", count($noMatches)); + print ' '.$langs->trans("ShowAll").''; + print '
'; +} + +llxFooter(); +$db->close(); diff --git a/core/modules/modBankImport.class.php b/core/modules/modBankImport.class.php index d264f82..f7fb10a 100755 --- a/core/modules/modBankImport.class.php +++ b/core/modules/modBankImport.class.php @@ -76,7 +76,7 @@ class modBankImport extends DolibarrModules $this->editor_squarred_logo = ''; // Must be image filename into the module/img directory followed with @modulename. Example: 'myimage.png@bankimport' // Possible values for version are: 'development', 'experimental', 'dolibarr', 'dolibarr_deprecated', 'experimental_deprecated' or a version string like 'x.y.z' - $this->version = '1.2'; + $this->version = '1.4'; // Url to the file with your last numberversion of this module //$this->url_last_version = 'http://www.example.com/versionmodule.txt'; @@ -369,6 +369,21 @@ class modBankImport extends DolibarrModules 'target' => '', 'user' => 2, ); + $this->menu[$r++] = array( + 'fk_menu' => 'fk_mainmenu=bank,fk_leftmenu=bankimport', + 'type' => 'left', + 'titre' => 'PaymentConfirmation', + 'prefix' => img_picto('', 'payment', 'class="pictofixedwidth valignmiddle paddingright"'), + 'mainmenu' => 'bank', + 'leftmenu' => 'bankimport_confirm', + 'url' => '/bankimport/confirm.php?mainmenu=bank&leftmenu=bankimport', + 'langs' => 'bankimport@bankimport', + 'position' => 203, + 'enabled' => 'isModEnabled("bankimport")', + 'perms' => '$user->hasRight("bankimport", "write")', + 'target' => '', + 'user' => 2, + ); $this->menu[$r++] = array( 'fk_menu' => 'fk_mainmenu=bank,fk_leftmenu=bankimport', 'type' => 'left', @@ -378,7 +393,7 @@ class modBankImport extends DolibarrModules 'leftmenu' => 'bankimport_pdfstatements', 'url' => '/bankimport/pdfstatements.php?mainmenu=bank&leftmenu=bankimport', 'langs' => 'bankimport@bankimport', - 'position' => 203, + 'position' => 204, 'enabled' => 'isModEnabled("bankimport")', 'perms' => '$user->hasRight("bankimport", "read")', 'target' => '',