Skip to content

Commit c1a2485

Browse files
committed
NEW Option MULTICURRENCY_PAYMENT_USE_REAL_AMOUNTS: enter the real amounts on multicurrency payments
On a payment for an invoice in a foreign currency, Dolibarr derives one of the two amounts (company currency / invoice currency) from the other at the invoice rate. The amount really paid in the company currency almost always differs from that derivation, so the recorded payment does not match the bank. Under this option (off by default): - both the amount in the invoice currency and the real amount in the company currency can be entered on the payment pages (customer and supplier); a read-only derived exchange rate is displayed live; - Paiement/PaiementFourn::create() keep both amounts as entered, derive the per-invoice exchange rate from them and store it on the dispatch line; a warning is raised when the derived rate is far from the invoice rate (probable swap), an error when the signs differ; - the paid status is decided on the balance in the invoice currency: the residual amount in company currency is the exchange-rate difference; - the payment list of the invoice card and the invoice/payment tooltips show the amount in the invoice currency and the rate as a sub-line; - a payment whose foreign amount is missing can be fixed a posteriori from the payment card (hidden option MULTICURRENCY_PAYMENT_ALLOW_EDIT_REAL_AMOUNT, admin only).
1 parent c680ec6 commit c1a2485

8 files changed

Lines changed: 343 additions & 21 deletions

File tree

htdocs/compta/paiement.php

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
* Copyright (C) 2023 William Mead <william.mead@manchenumerique.fr>
1515
* Copyright (C) 2024-2025 MDW <mdeweerd@users.noreply.github.com>
1616
* Copyright (C) 2025 Josep Lluís Amador <joseplluis@lliuretic.cat>
17+
* Copyright (C) 2026 José MARTINEZ <jose.martinez@pichinov.com>
1718
*
1819
* This program is free software; you can redistribute it and/or modify
1920
* it under the terms of the GNU General Public License as published by
@@ -206,8 +207,8 @@
206207
$error++;
207208
}
208209

209-
// Check if payments in both currency
210-
if ($totalpayment > 0 && $multicurrency_totalpayment > 0) {
210+
// Check if payments in both currency (allowed when entering the real amounts in both currencies, option MULTICURRENCY_PAYMENT_USE_REAL_AMOUNTS)
211+
if ($totalpayment > 0 && $multicurrency_totalpayment > 0 && !getDolGlobalInt('MULTICURRENCY_PAYMENT_USE_REAL_AMOUNTS')) {
211212
$langs->load("errors");
212213
setEventMessages($langs->transnoentities('ErrorPaymentInBothCurrency'), null, 'errors');
213214
$error++;
@@ -502,6 +503,27 @@ function callForResult(imgId, multicurrency = 0)
502503
});';
503504
print ' });'."\n";
504505

506+
// Live display of the derived exchange rate when the real amounts may be entered in both currencies (option MULTICURRENCY_PAYMENT_USE_REAL_AMOUNTS)
507+
if (getDolGlobalInt('MULTICURRENCY_PAYMENT_USE_REAL_AMOUNTS')) {
508+
print ' $(document).ready(function () {
509+
var derivedratelabel = "'.dol_escape_js($langs->trans('Rate')).'";
510+
function updateDerivedRates() {
511+
jQuery("span[id^=\'derivedrate_\']").each(function () {
512+
var facid = this.id.substring(12);
513+
var comp = parseFloat((jQuery("input[name=\'amount_" + facid + "\']").val() || "").replace(",", "."));
514+
var forc = parseFloat((jQuery("input[name=\'multicurrency_amount_" + facid + "\']").val() || "").replace(",", "."));
515+
if (!isNaN(comp) && comp != 0 && !isNaN(forc) && forc != 0) {
516+
jQuery(this).html("<br>" + derivedratelabel + " : " + (Math.abs(forc) / Math.abs(comp)).toFixed(8).replace(/0+$/, "").replace(/\.$/, ""));
517+
} else {
518+
jQuery(this).html("");
519+
}
520+
});
521+
}
522+
jQuery("#payment_form").find("input.amount, input.multicurrency_amount").on("keyup change", updateDerivedRates);
523+
updateDerivedRates();
524+
});'."\n";
525+
}
526+
505527
print ' </script>'."\n";
506528
}
507529

@@ -932,6 +954,10 @@ function callForResult(imgId, multicurrency = 0)
932954
print '<input type="text" class="maxwidth75" name="'.$namef.'_disabled" value="'.dol_escape_htmltag(GETPOST($namef)).'" disabled>';
933955
print '<input type="hidden" name="'.$namef.'" value="'.dol_escape_htmltag(GETPOST($namef)).'">';
934956
}
957+
// Read-only derived exchange rate, shown when the real amounts may be entered in both currencies (option MULTICURRENCY_PAYMENT_USE_REAL_AMOUNTS)
958+
if (getDolGlobalInt('MULTICURRENCY_PAYMENT_USE_REAL_AMOUNTS') && isModEnabled('multicurrency') && !empty($objp->multicurrency_code) && $objp->multicurrency_code != $conf->currency) {
959+
print '<span class="opacitymedium small" id="derivedrate_'.$objp->facid.'"></span>';
960+
}
935961
print "</td>";
936962

937963
$parameters = array();

htdocs/compta/paiement/class/paiement.class.php

Lines changed: 88 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
* Copyright (C) 2023 Joachim Kueter <git-jk@bloxera.com>
1515
* Copyright (C) 2023 Sylvain Legrand <technique@infras.fr>
1616
* Copyright (C) 2024-2026 MDW <mdeweerd@users.noreply.github.com>
17+
* Copyright (C) 2026 José MARTINEZ <jose.martinez@pichinov.com>
1718
*
1819
* This program is free software; you can redistribute it and/or modify
1920
* it under the terms of the GNU General Public License as published by
@@ -341,8 +342,40 @@ public function create($user, $closepaidinvoices = 0, $thirdparty = null)
341342
$invoice_multicurrency_tx = $tmparray['invoice_multicurrency_tx'];
342343
$invoice_multicurrency_code = $tmparray['invoice_multicurrency_code'];
343344

345+
// Option MULTICURRENCY_PAYMENT_USE_REAL_AMOUNTS: if both the amount in the invoice currency and the real
346+
// amount in the company currency have been provided for this invoice, we keep both amounts as entered (no
347+
// derivation at the invoice rate) and we derive the exchange rate from them.
348+
$userealamounts = false;
349+
if (getDolGlobalInt('MULTICURRENCY_PAYMENT_USE_REAL_AMOUNTS')
350+
&& (float) price2num(isset($this->amounts[$key]) ? $this->amounts[$key] : 0) != 0
351+
&& (float) price2num(isset($this->multicurrency_amounts[$key]) ? $this->multicurrency_amounts[$key] : 0) != 0) {
352+
$userealamounts = true;
353+
}
354+
$invoiceidfortrans = $key; // intermediate name: phan flags a variable named key passed to trans()
355+
344356
// $key is id of invoice, $value is amount, $way is 'dolibarr' if amount is in main currency, 'customer' if in foreign currency
345-
if ($invoice_multicurrency_tx) {
357+
if ($userealamounts) {
358+
$realcompanyamount = (float) price2num($this->amounts[$key], 'MU');
359+
$realforeignamount = (float) price2num($this->multicurrency_amounts[$key], 'MU');
360+
// Both real amounts must share the same sign
361+
if (($realcompanyamount > 0) != ($realforeignamount > 0)) {
362+
$this->error = $langs->trans('ErrorPaymentRealAmountsMustHaveSameSign', (string) $invoiceidfortrans);
363+
return -1;
364+
}
365+
// The counterpart amount is the real amount entered by the user, not a value derived at the invoice rate
366+
$value_converted = ($way == 'dolibarr') ? $realforeignamount : $realcompanyamount;
367+
// Derived exchange rate, Dolibarr convention: multicurrency_tx = amount in invoice currency / amount in company currency
368+
$derived_tx = (float) price2num(abs($realforeignamount) / abs($realcompanyamount), 'MU');
369+
if ($derived_tx <= 0) {
370+
$this->error = $langs->trans('FailedToFoundTheConversionRateForInvoice');
371+
return -1;
372+
}
373+
$this->multicurrency_tx[$key] = $derived_tx;
374+
// Non-blocking warning if the derived rate is far (> 50%) from the invoice rate (probable swap of the two amounts)
375+
if ($invoice_multicurrency_tx && abs($derived_tx - $invoice_multicurrency_tx) > (0.5 * $invoice_multicurrency_tx)) {
376+
setEventMessages($langs->trans('WarningPaymentDerivedRateFarFromInvoiceRate', (string) $invoiceidfortrans, price2num($derived_tx, 'MU'), price2num($invoice_multicurrency_tx, 'MU')), null, 'warnings');
377+
}
378+
} elseif ($invoice_multicurrency_tx) {
346379
if ($way == 'dolibarr') {
347380
$value_converted = (float) price2num($value * $invoice_multicurrency_tx, 'MU');
348381
} else {
@@ -472,8 +505,13 @@ public function create($user, $closepaidinvoices = 0, $thirdparty = null)
472505
$facid = $key;
473506
if (is_numeric($amount) && $amount != 0) {
474507
$amount = price2num($amount);
508+
// Under MULTICURRENCY_PAYMENT_USE_REAL_AMOUNTS, store the per-invoice exchange rate derived from the real amounts instead of the single payment rate
509+
$multicurrencytxtostore = $currencytxofpayment;
510+
if (getDolGlobalInt('MULTICURRENCY_PAYMENT_USE_REAL_AMOUNTS') && !empty($this->multicurrency_tx[$key])) {
511+
$multicurrencytxtostore = $this->multicurrency_tx[$key];
512+
}
475513
$sql = "INSERT INTO ".MAIN_DB_PREFIX."paiement_facture (fk_facture, fk_paiement, amount, multicurrency_amount, multicurrency_code, multicurrency_tx)";
476-
$sql .= " VALUES (".((int) $facid).", ".((int) $this->id).", ".((float) $amount).", ".((float) $this->multicurrency_amounts[$key]).", ".($currencyofpayment ? "'".$this->db->escape($currencyofpayment)."'" : 'NULL').", ".(!empty($this->multicurrency_tx) ? (float) $currencytxofpayment : 1).")";
514+
$sql .= " VALUES (".((int) $facid).", ".((int) $this->id).", ".((float) $amount).", ".((float) $this->multicurrency_amounts[$key]).", ".($currencyofpayment ? "'".$this->db->escape($currencyofpayment)."'" : 'NULL').", ".(!empty($multicurrencytxtostore) ? (float) $multicurrencytxtostore : 1).")";
477515

478516
dol_syslog(get_class($this).'::create Amount line '.$key.' insert paiement_facture', LOG_DEBUG);
479517
$resql = $this->db->query($sql);
@@ -488,6 +526,17 @@ public function create($user, $closepaidinvoices = 0, $thirdparty = null)
488526
$deposits = $invoice->getSumDepositsUsed();
489527
$alreadypayed = price2num($paiement + $creditnotes + $deposits, 'MT');
490528
$remaintopay = price2num($invoice->total_ttc - $paiement - $creditnotes - $deposits, 'MT');
529+
// Under MULTICURRENCY_PAYMENT_USE_REAL_AMOUNTS, a multicurrency invoice paid at the real amounts keeps a
530+
// residual amount in company currency (the exchange-rate difference), so the "paid" status must be decided
531+
// on the balance in the invoice currency and not on the balance in the company currency.
532+
$remaintopayforclosure = $remaintopay;
533+
if (getDolGlobalInt('MULTICURRENCY_PAYMENT_USE_REAL_AMOUNTS') && isModEnabled('multicurrency')
534+
&& !empty($invoice->multicurrency_code) && $invoice->multicurrency_code != $conf->currency) {
535+
$multicurrency_paiement = $invoice->getSommePaiement(1);
536+
$multicurrency_creditnotes = $invoice->getSumCreditNotesUsed(1);
537+
$multicurrency_deposits = $invoice->getSumDepositsUsed(1);
538+
$remaintopayforclosure = price2num($invoice->multicurrency_total_ttc - $multicurrency_paiement - $multicurrency_creditnotes - $multicurrency_deposits, 'MT');
539+
}
491540

492541
//var_dump($invoice->total_ttc.' - '.$paiement.' -'.$creditnotes.' - '.$deposits.' - '.$remaintopay);exit;
493542

@@ -502,7 +551,7 @@ public function create($user, $closepaidinvoices = 0, $thirdparty = null)
502551

503552
if (!in_array($invoice->type, $affected_types)) {
504553
dol_syslog("Invoice ".$facid." is not a standard, nor replacement invoice, nor credit note, nor deposit invoice, nor situation invoice. We do nothing more.");
505-
} elseif ($remaintopay) {
554+
} elseif ($remaintopayforclosure) {
506555
// hook to have an option to automatically close a closable invoice with less payment than the total amount (e.g. agreed cash discount terms)
507556
global $hookmanager;
508557
$hookmanager->initHooks(array('paymentdao'));
@@ -566,22 +615,46 @@ public function create($user, $closepaidinvoices = 0, $thirdparty = null)
566615
}
567616
}
568617

618+
// Option MULTICURRENCY_PAYMENT_USE_REAL_AMOUNTS: carry the invoice currency and the real exchange
619+
// rate onto the discount, and scale its company-currency value to the real amount paid, so the real
620+
// cost propagates when the deposit is later consumed in a final invoice. Without the option, nothing changes.
621+
$discountmccode = '';
622+
$discountmctx = null;
623+
$discountrealratio = 1;
624+
if (getDolGlobalInt('MULTICURRENCY_PAYMENT_USE_REAL_AMOUNTS') && isModEnabled('multicurrency')
625+
&& !empty($invoice->multicurrency_code) && $invoice->multicurrency_code != $conf->currency) {
626+
$discountmccode = $invoice->multicurrency_code;
627+
$discountmctx = $invoice->multicurrency_tx;
628+
$realeurpaid = (float) price2num($invoice->getSommePaiement(0), 'MT');
629+
$realfxpaid = (float) price2num($invoice->getSommePaiement(1), 'MT');
630+
if ($realeurpaid != 0 && $realfxpaid != 0) {
631+
$discountmctx = (float) price2num($realfxpaid / $realeurpaid, 'MU');
632+
if ((float) $invoice->total_ttc != 0) {
633+
$discountrealratio = $realeurpaid / (float) $invoice->total_ttc;
634+
}
635+
}
636+
}
637+
569638
foreach ($amount_ht as $keyfordiscount => $xxx) {
570639
$parts = explode('|', (string) $keyfordiscount, 2);
571640
$tva_tx = $parts[0];
572641
$vat_src_code = isset($parts[1]) ? $parts[1] : '';
573-
$discount->amount_ht = abs($amount_ht[$keyfordiscount]);
574-
$discount->total_ht = abs($amount_ht[$keyfordiscount]);
575-
$discount->amount_tva = abs($amount_tva[$keyfordiscount]);
576-
$discount->total_tva = abs($amount_tva[$keyfordiscount]);
577-
$discount->amount_ttc = abs($amount_ttc[$keyfordiscount]);
578-
$discount->total_ttc = abs($amount_ttc[$keyfordiscount]);
642+
$discount->amount_ht = abs($amount_ht[$keyfordiscount] * $discountrealratio);
643+
$discount->total_ht = abs($amount_ht[$keyfordiscount] * $discountrealratio);
644+
$discount->amount_tva = abs($amount_tva[$keyfordiscount] * $discountrealratio);
645+
$discount->total_tva = abs($amount_tva[$keyfordiscount] * $discountrealratio);
646+
$discount->amount_ttc = abs($amount_ttc[$keyfordiscount] * $discountrealratio);
647+
$discount->total_ttc = abs($amount_ttc[$keyfordiscount] * $discountrealratio);
579648
$discount->multicurrency_amount_ht = abs($multicurrency_amount_ht[$keyfordiscount]);
580649
$discount->multicurrency_total_ht = abs($multicurrency_amount_ht[$keyfordiscount]);
581650
$discount->multicurrency_amount_tva = abs($multicurrency_amount_tva[$keyfordiscount]);
582651
$discount->multicurrency_total_tva = abs($multicurrency_amount_tva[$keyfordiscount]);
583652
$discount->multicurrency_amount_ttc = abs($multicurrency_amount_ttc[$keyfordiscount]);
584653
$discount->multicurrency_total_ttc = abs($multicurrency_amount_ttc[$keyfordiscount]);
654+
if ($discountmccode !== '') {
655+
$discount->multicurrency_code = $discountmccode;
656+
$discount->multicurrency_tx = $discountmctx;
657+
}
585658
$discount->tva_tx = abs((float) $tva_tx);
586659
$discount->vat_src_code = $vat_src_code;
587660

@@ -1421,6 +1494,12 @@ public function getNomUrl($withpicto = 0, $option = '', $mode = 'withlistofinvoi
14211494
if ($this->amount) {
14221495
$label .= '<br><strong>'.$langs->trans("Amount").':</strong> '.price($this->amount, 0, $langs, 1, -1, -1, $conf->currency);
14231496
}
1497+
// Amount in the invoice currency and derived rate (option MULTICURRENCY_PAYMENT_USE_REAL_AMOUNTS)
1498+
if (getDolGlobalInt('MULTICURRENCY_PAYMENT_USE_REAL_AMOUNTS') && isModEnabled('multicurrency') && !empty($this->multicurrency_amount) && (float) $this->amount != 0 && abs((float) $this->multicurrency_amount - (float) $this->amount) > 0.0001) {
1499+
$langs->load("multicurrency");
1500+
$label .= '<br><strong>'.$langs->trans("MulticurrencyPaymentAmount").':</strong> '.price($this->multicurrency_amount, 0, $langs, 1);
1501+
$label .= '<br><strong>'.$langs->trans("Rate").':</strong> '.price2num((float) $this->multicurrency_amount / (float) $this->amount, 'MU');
1502+
}
14241503
if ($mode == 'withlistofinvoices') {
14251504
$arraybill = $this->getBillsArray();
14261505
if (is_array($arraybill) && count($arraybill) > 0) {

htdocs/fourn/class/fournisseur.facture.class.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
* Copyright (C) 2024-2026 MDW <mdeweerd@users.noreply.github.com>
1919
* Copyright (C) 2026 Vincent de Grandpré <vincent@de-grandpre.quebec>
2020
* Copyright (C) 2026 Jose Martinez <jose.martinez@pichinov.com>
21+
* Copyright (C) 2026 José MARTINEZ <jose.martinez@pichinov.com>
2122
*
2223
* This program is free software; you can redistribute it and/or modify
2324
* it under the terms of the GNU General Public License as published by
@@ -2907,6 +2908,17 @@ public function getTooltipContentArray($params)
29072908
if (!empty($this->total_ttc)) {
29082909
$datas['totalttc'] = '<br><b>'.$langs->trans('AmountTTC').':</b> '.price($this->total_ttc, 0, $langs, 0, -1, -1, $conf->currency);
29092910
}
2911+
// Multicurrency rate and total in the invoice currency (option MULTICURRENCY_PAYMENT_USE_REAL_AMOUNTS)
2912+
if (getDolGlobalInt('MULTICURRENCY_PAYMENT_USE_REAL_AMOUNTS') && isModEnabled('multicurrency') && !empty($this->multicurrency_code) && $this->multicurrency_code != $conf->currency) {
2913+
$datas['multicurrencyrate'] = '<br><b>'.$langs->trans('CurrencyRate').':</b> '.price2num($this->multicurrency_tx, 'MU');
2914+
if (!empty($this->multicurrency_total_ht)) {
2915+
$datas['multicurrencytotalht'] = '<br><b>'.$langs->trans('MulticurrencyAmountHT').':</b> '.price($this->multicurrency_total_ht, 0, $langs, 0, -1, -1, $this->multicurrency_code);
2916+
}
2917+
if (!empty($this->multicurrency_total_tva)) {
2918+
$datas['multicurrencytotaltva'] = '<br><b>'.$langs->trans('MulticurrencyAmountVAT').':</b> '.price($this->multicurrency_total_tva, 0, $langs, 0, -1, -1, $this->multicurrency_code);
2919+
}
2920+
$datas['multicurrencytotalttc'] = '<br><b>'.$langs->trans('MulticurrencyAmountTTC').':</b> '.price($this->multicurrency_total_ttc, 0, $langs, 0, -1, -1, $this->multicurrency_code);
2921+
}
29102922
return $datas;
29112923
}
29122924

0 commit comments

Comments
 (0)