From 07d5735de0e1b3080676ab26e872e55120fa2628 Mon Sep 17 00:00:00 2001
From: Laurent Smet <las@odoo.com>
Date: Tue, 23 Feb 2021 08:26:13 +0000
Subject: [PATCH] [FIX] account: Fix reversal of exchange diff entry when
 unlinking the full reconcile

Suppose an invoice of 1200USD = 3600EUR reconciled with a payment 1800USD = 3600EUR.

The generated exchange difference journal entry is:
600USD = 0EUR because 1800 - 1200 - 600 = 0 and 3600 - 3600 = 0
=> Everything is reconciled and all residual amounts are 0.

Remove the reconciliation. The exchange difference entry is reversed in other to cancel it.
Because the current exchange difference entry contains a line of 600USD, the reversal is creating a line of -600USD.

Before this commit:

Because both lines were sharing the same foreign currency (EUR) but have an amount_residual_currency of 0, no partial was created and then, a new exchange difference was generated in order to fix the amount_residual of 600 in USD.

After this commit:

A partial is created to handle the residual amount in USD even the residual amount in foreign currency is already zero.

Note: this issue is also there when the reconciliation is made using the company's currency with different foreign currencies.
In that case, amount_residual is zero but not amount_residual_currency.

closes odoo/odoo#66653

Opw: 2450699
Signed-off-by: oco-odoo <oco-odoo@users.noreply.github.com>
---
 addons/account/models/account_move.py         |  16 +-
 .../tests/test_account_move_reconcile.py      | 287 ++++++++++++++++++
 2 files changed, 297 insertions(+), 6 deletions(-)

diff --git a/addons/account/models/account_move.py b/addons/account/models/account_move.py
index c4251614fbb9..184da93d9480 100644
--- a/addons/account/models/account_move.py
+++ b/addons/account/models/account_move.py
@@ -4101,8 +4101,8 @@ class AccountMoveLine(models.Model):
 
         :return: A recordset of account.partial.reconcile.
         '''
-        debit_lines = iter(self.filtered('debit'))
-        credit_lines = iter(self.filtered('credit'))
+        debit_lines = iter(self.filtered(lambda line: line.balance > 0.0 or line.amount_currency > 0.0))
+        credit_lines = iter(self.filtered(lambda line: line.balance < 0.0 or line.amount_currency < 0.0))
         debit_line = None
         credit_line = None
 
@@ -4146,17 +4146,21 @@ class AccountMoveLine(models.Model):
                     credit_line_currency = credit_line.company_currency_id
 
             min_amount_residual = min(debit_amount_residual, -credit_amount_residual)
+            has_debit_residual_left = not debit_line.company_currency_id.is_zero(debit_amount_residual) and debit_amount_residual > 0.0
+            has_credit_residual_left = not credit_line.company_currency_id.is_zero(credit_amount_residual) and credit_amount_residual < 0.0
+            has_debit_residual_curr_left = not debit_line_currency.is_zero(debit_amount_residual_currency) and debit_amount_residual_currency > 0.0
+            has_credit_residual_curr_left = not credit_line_currency.is_zero(credit_amount_residual_currency) and credit_amount_residual_currency < 0.0
 
             if debit_line_currency == credit_line_currency:
                 # Reconcile on the same currency.
 
                 # The debit line is now fully reconciled.
-                if debit_line_currency.is_zero(debit_amount_residual_currency) or debit_amount_residual_currency < 0.0:
+                if not has_debit_residual_curr_left and (has_credit_residual_curr_left or not has_debit_residual_left):
                     debit_line = None
                     continue
 
                 # The credit line is now fully reconciled.
-                if credit_line_currency.is_zero(credit_amount_residual_currency) or credit_amount_residual_currency > 0.0:
+                if not has_credit_residual_curr_left and (has_debit_residual_curr_left or not has_credit_residual_left):
                     credit_line = None
                     continue
 
@@ -4168,12 +4172,12 @@ class AccountMoveLine(models.Model):
                 # Reconcile on the company's currency.
 
                 # The debit line is now fully reconciled.
-                if debit_line.company_currency_id.is_zero(debit_amount_residual) or debit_amount_residual < 0.0:
+                if not has_debit_residual_left and (has_credit_residual_left or not has_debit_residual_curr_left):
                     debit_line = None
                     continue
 
                 # The credit line is now fully reconciled.
-                if credit_line.company_currency_id.is_zero(credit_amount_residual) or credit_amount_residual > 0.0:
+                if not has_credit_residual_left and (has_debit_residual_left or not has_credit_residual_curr_left):
                     credit_line = None
                     continue
 
diff --git a/addons/account/tests/test_account_move_reconcile.py b/addons/account/tests/test_account_move_reconcile.py
index 4c17a415c428..1f4a687f886e 100644
--- a/addons/account/tests/test_account_move_reconcile.py
+++ b/addons/account/tests/test_account_move_reconcile.py
@@ -674,6 +674,293 @@ class TestAccountMoveReconcile(AccountTestInvoicingCommon):
 
         self.assertFullReconcile(res['full_reconcile'], line_1 + line_2 + line_3 + line_4 + line_5)
 
+    def test_reverse_exchange_difference_same_foreign_currency(self):
+        move_2016 = self.env['account.move'].create({
+            'move_type': 'entry',
+            'date': '2016-01-01',
+            'line_ids': [
+                (0, 0, {
+                    'debit': 1200.0,
+                    'credit': 0.0,
+                    'amount_currency': 3600.0,
+                    'account_id': self.company_data['default_account_receivable'].id,
+                    'currency_id': self.currency_data['currency'].id,
+                }),
+                (0, 0, {
+                    'debit': 0.0,
+                    'credit': 1200.0,
+                    'account_id': self.company_data['default_account_revenue'].id,
+                }),
+            ],
+        })
+        move_2017 = self.env['account.move'].create({
+            'move_type': 'entry',
+            'date': '2017-01-01',
+            'line_ids': [
+                (0, 0, {
+                    'debit': 0.0,
+                    'credit': 1800.0,
+                    'amount_currency': -3600.0,
+                    'account_id': self.company_data['default_account_receivable'].id,
+                    'currency_id': self.currency_data['currency'].id,
+                }),
+                (0, 0, {
+                    'debit': 1800.0,
+                    'credit': 0.0,
+                    'account_id': self.company_data['default_account_revenue'].id,
+                }),
+            ],
+        })
+        (move_2016 + move_2017).action_post()
+
+        rec_line_2016 = move_2016.line_ids.filtered(lambda line: line.account_id.internal_type == 'receivable')
+        rec_line_2017 = move_2017.line_ids.filtered(lambda line: line.account_id.internal_type == 'receivable')
+
+        self.assertRecordValues(rec_line_2016 + rec_line_2017, [
+            {'amount_residual': 1200.0,     'amount_residual_currency': 3600.0,     'reconciled': False},
+            {'amount_residual': -1800.0,    'amount_residual_currency': -3600.0,    'reconciled': False},
+        ])
+
+        # Reconcile.
+
+        res = (rec_line_2016 + rec_line_2017).reconcile()
+
+        self.assertRecordValues(rec_line_2016 + rec_line_2017, [
+            {'amount_residual': 0.0,        'amount_residual_currency': 0.0,        'reconciled': True},
+            {'amount_residual': 0.0,        'amount_residual_currency': 0.0,        'reconciled': True},
+        ])
+
+        exchange_diff = res['full_reconcile'].exchange_move_id
+        exchange_diff_lines = exchange_diff.line_ids.sorted('balance')
+
+        self.assertRecordValues(exchange_diff_lines, [
+            {
+                'debit': 0.0,
+                'credit': 600.0,
+                'amount_currency': 0.0,
+                'currency_id': self.currency_data['currency'].id,
+                'account_id': exchange_diff.journal_id.company_id.income_currency_exchange_account_id.id,
+            },
+            {
+                'debit': 600.0,
+                'credit': 0.0,
+                'amount_currency': 0.0,
+                'currency_id': self.currency_data['currency'].id,
+                'account_id': self.company_data['default_account_receivable'].id,
+            },
+        ])
+
+        self.assertRecordValues(exchange_diff_lines, [
+            {'amount_residual': 0.0,        'amount_residual_currency': 0.0,        'reconciled': False},
+            {'amount_residual': 0.0,        'amount_residual_currency': 0.0,        'reconciled': True},
+        ])
+
+        # Unreconcile.
+        # A reversal is created to cancel the exchange difference journal entry.
+
+        (rec_line_2016 + rec_line_2017).remove_move_reconcile()
+
+        reverse_exchange_diff = exchange_diff_lines[1].matched_credit_ids.credit_move_id.move_id
+        reverse_exchange_diff_lines = reverse_exchange_diff.line_ids.sorted('balance')
+
+        self.assertRecordValues(reverse_exchange_diff_lines, [
+            {
+                'debit': 0.0,
+                'credit': 600.0,
+                'amount_currency': 0.0,
+                'currency_id': self.currency_data['currency'].id,
+                'account_id': self.company_data['default_account_receivable'].id,
+            },
+            {
+                'debit': 600.0,
+                'credit': 0.0,
+                'amount_currency': 0.0,
+                'currency_id': self.currency_data['currency'].id,
+                'account_id': exchange_diff.journal_id.company_id.income_currency_exchange_account_id.id,
+            },
+        ])
+
+        self.assertRecordValues(exchange_diff_lines + reverse_exchange_diff_lines, [
+            {'amount_residual': 0.0,        'amount_residual_currency': 0.0,        'reconciled': False},
+            {'amount_residual': 0.0,        'amount_residual_currency': 0.0,        'reconciled': True},
+            {'amount_residual': 0.0,        'amount_residual_currency': 0.0,        'reconciled': True},
+            {'amount_residual': 0.0,        'amount_residual_currency': 0.0,        'reconciled': False},
+        ])
+
+        partials = reverse_exchange_diff_lines.matched_debit_ids + reverse_exchange_diff_lines.matched_credit_ids
+        self.assertPartialReconcile(partials, [{
+            'amount': 600.0,
+            'debit_amount_currency': 0.0,
+            'credit_amount_currency': 0.0,
+            'debit_move_id': exchange_diff_lines[1].id,
+            'credit_move_id': reverse_exchange_diff_lines[0].id,
+        }])
+
+    def test_reverse_exchange_multiple_foreign_currencies(self):
+        move_2016 = self.env['account.move'].create({
+            'move_type': 'entry',
+            'date': '2016-01-01',
+            'line_ids': [
+                (0, 0, {
+                    'debit': 1200.0,
+                    'credit': 0.0,
+                    'amount_currency': 7200.0,
+                    'account_id': self.company_data['default_account_receivable'].id,
+                    'currency_id': self.currency_data_2['currency'].id,
+                }),
+                (0, 0, {
+                    'debit': 0.0,
+                    'credit': 1200.0,
+                    'account_id': self.company_data['default_account_revenue'].id,
+                }),
+            ],
+        })
+        move_2017 = self.env['account.move'].create({
+            'move_type': 'entry',
+            'date': '2017-01-01',
+            'line_ids': [
+                (0, 0, {
+                    'debit': 0.0,
+                    'credit': 1200.0,
+                    'amount_currency': -2400.0,
+                    'account_id': self.company_data['default_account_receivable'].id,
+                    'currency_id': self.currency_data['currency'].id,
+                }),
+                (0, 0, {
+                    'debit': 1200.0,
+                    'credit': 0.0,
+                    'account_id': self.company_data['default_account_revenue'].id,
+                }),
+            ],
+        })
+        (move_2016 + move_2017).action_post()
+
+        rec_line_2016 = move_2016.line_ids.filtered(lambda line: line.account_id.internal_type == 'receivable')
+        rec_line_2017 = move_2017.line_ids.filtered(lambda line: line.account_id.internal_type == 'receivable')
+
+        self.assertRecordValues(rec_line_2016 + rec_line_2017, [
+            {'amount_residual': 1200.0,     'amount_residual_currency': 7200.0,     'reconciled': False},
+            {'amount_residual': -1200.0,    'amount_residual_currency': -2400.0,    'reconciled': False},
+        ])
+
+        # Reconcile.
+
+        res = (rec_line_2016 + rec_line_2017).reconcile()
+
+        self.assertRecordValues(rec_line_2016 + rec_line_2017, [
+            {'amount_residual': 0.0,        'amount_residual_currency': 0.0,        'reconciled': True},
+            {'amount_residual': 0.0,        'amount_residual_currency': 0.0,        'reconciled': True},
+        ])
+
+        exchange_diff = res['full_reconcile'].exchange_move_id
+        exchange_diff_lines = exchange_diff.line_ids.sorted('amount_currency')
+
+        self.assertRecordValues(exchange_diff_lines, [
+            {
+                'debit': 0.0,
+                'credit': 0.0,
+                'amount_currency': -2400.0,
+                'currency_id': self.currency_data_2['currency'].id,
+                'account_id': self.company_data['default_account_receivable'].id,
+            },
+            {
+                'debit': 0.0,
+                'credit': 0.0,
+                'amount_currency': -1200.0,
+                'currency_id': self.currency_data['currency'].id,
+                'account_id': self.company_data['default_account_receivable'].id,
+            },
+            {
+                'debit': 0.0,
+                'credit': 0.0,
+                'amount_currency': 1200.0,
+                'currency_id': self.currency_data['currency'].id,
+                'account_id': exchange_diff.journal_id.company_id.expense_currency_exchange_account_id.id,
+            },
+            {
+                'debit': 0.0,
+                'credit': 0.0,
+                'amount_currency': 2400.0,
+                'currency_id': self.currency_data_2['currency'].id,
+                'account_id': exchange_diff.journal_id.company_id.expense_currency_exchange_account_id.id,
+            },
+        ])
+
+        self.assertRecordValues(exchange_diff_lines, [
+            {'amount_residual': 0.0,        'amount_residual_currency': 0.0,        'reconciled': True},
+            {'amount_residual': 0.0,        'amount_residual_currency': 0.0,        'reconciled': True},
+            {'amount_residual': 0.0,        'amount_residual_currency': 0.0,        'reconciled': False},
+            {'amount_residual': 0.0,        'amount_residual_currency': 0.0,        'reconciled': False},
+        ])
+
+        # Unreconcile.
+        # A reversal is created to cancel the exchange difference journal entry.
+
+        (rec_line_2016 + rec_line_2017).remove_move_reconcile()
+
+        reverse_exchange_diff = exchange_diff_lines[1].matched_debit_ids.debit_move_id.move_id
+        reverse_exchange_diff_lines = reverse_exchange_diff.line_ids.sorted('amount_currency')
+
+        self.assertRecordValues(reverse_exchange_diff_lines, [
+            {
+                'debit': 0.0,
+                'credit': 0.0,
+                'amount_currency': -2400.0,
+                'currency_id': self.currency_data_2['currency'].id,
+                'account_id': exchange_diff.journal_id.company_id.expense_currency_exchange_account_id.id,
+            },
+            {
+                'debit': 0.0,
+                'credit': 0.0,
+                'amount_currency': -1200.0,
+                'currency_id': self.currency_data['currency'].id,
+                'account_id': exchange_diff.journal_id.company_id.expense_currency_exchange_account_id.id,
+            },
+            {
+                'debit': 0.0,
+                'credit': 0.0,
+                'amount_currency': 1200.0,
+                'currency_id': self.currency_data['currency'].id,
+                'account_id': self.company_data['default_account_receivable'].id,
+            },
+            {
+                'debit': 0.0,
+                'credit': 0.0,
+                'amount_currency': 2400.0,
+                'currency_id': self.currency_data_2['currency'].id,
+                'account_id': self.company_data['default_account_receivable'].id,
+            },
+        ])
+
+        self.assertRecordValues(exchange_diff_lines + reverse_exchange_diff_lines, [
+            {'amount_residual': 0.0,        'amount_residual_currency': 0.0,        'reconciled': True},
+            {'amount_residual': 0.0,        'amount_residual_currency': 0.0,        'reconciled': True},
+            {'amount_residual': 0.0,        'amount_residual_currency': 0.0,        'reconciled': False},
+            {'amount_residual': 0.0,        'amount_residual_currency': 0.0,        'reconciled': False},
+            {'amount_residual': 0.0,        'amount_residual_currency': 0.0,        'reconciled': False},
+            {'amount_residual': 0.0,        'amount_residual_currency': 0.0,        'reconciled': False},
+            {'amount_residual': 0.0,        'amount_residual_currency': 0.0,        'reconciled': True},
+            {'amount_residual': 0.0,        'amount_residual_currency': 0.0,        'reconciled': True},
+        ])
+
+        partials = reverse_exchange_diff_lines.matched_debit_ids + reverse_exchange_diff_lines.matched_credit_ids
+        self.assertPartialReconcile(partials, [
+            {
+                'amount': 0.0,
+                'debit_amount_currency': 1200.0,
+                'credit_amount_currency': 1200.0,
+                'debit_move_id': reverse_exchange_diff_lines[2].id,
+                'credit_move_id': exchange_diff_lines[1].id,
+            },
+            {
+                'amount': 0.0,
+                'debit_amount_currency': 2400.0,
+                'credit_amount_currency': 2400.0,
+                'debit_move_id': reverse_exchange_diff_lines[3].id,
+                'credit_move_id': exchange_diff_lines[0].id,
+            },
+        ])
+
     # -------------------------------------------------------------------------
     # Test creation of extra journal entries during the reconciliation to
     # deal with taxes that are exigible on payment (cash basis).
-- 
GitLab