From a475a2721a61cf3f6e7424a66eae9e9fb24bb968 Mon Sep 17 00:00:00 2001 From: Denis Ledoux Date: Tue, 26 May 2015 18:18:11 +0200 Subject: [PATCH] [FIX] account: foreign exchanges gain/loss in reconciliation When processing the reconciliation of invoices with bank statements in foreign currencies, this is possible that there is a cent of difference, due to the fact the sum of amount exchanged could not be equal to the exchanged sum of amount received. For instance, with a company in EUR as currency, with a rate of 0.033 for USD, with an invoice of 2.00 USD (60.606060... rounded to 60.61 EUR) and a bank statement of two lines of 1.00 USD (30.30303030... rounded to 30.30 EUR) The exchanged invoice amount, 60.61 EUR, is not equal to the sum of statement lines exchanged amount (30.30 + 30.30 = 60.60 EUR). In such a case, two journal items should be created in addition: - 0.01 in the debtors account - 0.01 in the foreign exchange loss account opw-640078 --- addons/account/account_bank_statement.py | 69 +++++++++++++ addons/account/tests/test_reconciliation.py | 108 +++++++++++++++++++- 2 files changed, 172 insertions(+), 5 deletions(-) diff --git a/addons/account/account_bank_statement.py b/addons/account/account_bank_statement.py index 72a907876b9..ffcaf99106d 100644 --- a/addons/account/account_bank_statement.py +++ b/addons/account/account_bank_statement.py @@ -711,6 +711,60 @@ class account_bank_statement_line(osv.osv): 'account_id': account_id } + def _get_exchange_lines(self, cr, uid, st_line, mv_line, currency_diff, currency_id, move_id, context=None): + ''' + Prepare the two lines in company currency due to currency rate difference. + + :param line: browse record of the voucher.line for which we want to create currency rate difference accounting + entries + :param move_id: Account move wher the move lines will be. + :param currency_diff: Amount to be posted. + :param company_currency: id of currency of the company to which the voucher belong + :param current_currency: id of currency of the voucher + :return: the account move line and its counterpart to create, depicted as mapping between fieldname and value + :rtype: tuple of dict + ''' + if currency_diff > 0: + exchange_account_id = st_line.company_id.expense_currency_exchange_account_id.id + else: + exchange_account_id = st_line.company_id.income_currency_exchange_account_id.id + # Even if the amount_currency is never filled, we need to pass the foreign currency because otherwise + # the receivable/payable account may have a secondary currency, which render this field mandatory + if mv_line.account_id.currency_id: + account_currency_id = mv_line.account_id.currency_id.id + else: + account_currency_id = st_line.company_id.currency_id.id != currency_id and currency_id or False + move_line = { + 'journal_id': st_line.journal_id.id, + 'period_id': st_line.statement_id.period_id.id, + 'name': _('change') + ': ' + (st_line.name or '/'), + 'account_id': mv_line.account_id.id, + 'move_id': move_id, + 'partner_id': st_line.partner_id.id, + 'currency_id': account_currency_id, + 'amount_currency': 0.0, + 'quantity': 1, + 'credit': currency_diff > 0 and currency_diff or 0.0, + 'debit': currency_diff < 0 and -currency_diff or 0.0, + 'date': st_line.date, + 'counterpart_move_line_id': mv_line.id, + } + move_line_counterpart = { + 'journal_id': st_line.journal_id.id, + 'period_id': st_line.statement_id.period_id.id, + 'name': _('change') + ': ' + (st_line.name or '/'), + 'account_id': exchange_account_id, + 'move_id': move_id, + 'amount_currency': 0.0, + 'partner_id': st_line.partner_id.id, + 'currency_id': account_currency_id, + 'quantity': 1, + 'debit': currency_diff > 0 and currency_diff or 0.0, + 'credit': currency_diff < 0 and -currency_diff or 0.0, + 'date': st_line.date, + } + return (move_line, move_line_counterpart) + def process_reconciliations(self, cr, uid, data, context=None): for datum in data: self.process_reconciliation(cr, uid, datum[0], datum[1], context=context) @@ -809,6 +863,21 @@ class account_bank_statement_line(osv.osv): if credit_at_old_rate - credit_at_current_rate: currency_diff = credit_at_current_rate - credit_at_old_rate to_create.append(self.get_currency_rate_line(cr, uid, st_line, currency_diff, move_id, context=context)) + if mv_line.currency_id and mv_line_dict['currency_id'] == mv_line.currency_id.id: + amount_unreconciled = mv_line.amount_residual_currency + else: + amount_unreconciled = currency_obj.compute(cr, uid, company_currency.id, mv_line_dict['currency_id'] , mv_line.amount_residual, context=ctx) + if float_is_zero(mv_line_dict['amount_currency'] + amount_unreconciled, precision_rounding=mv_line.currency_id.rounding): + import pudb + pu.db + amount = mv_line_dict['debit'] or mv_line_dict['credit'] + sign = -1 if mv_line_dict['debit'] else 1 + currency_rate_difference = sign * (mv_line.amount_residual - amount) + if not company_currency.is_zero(currency_rate_difference): + exchange_lines = self._get_exchange_lines(cr, uid, st_line, mv_line, currency_rate_difference, mv_line_dict['currency_id'], move_id, context=context) + for exchange_line in exchange_lines: + to_create.append(exchange_line) + else: mv_line_dict['debit'] = debit_at_current_rate mv_line_dict['credit'] = credit_at_current_rate diff --git a/addons/account/tests/test_reconciliation.py b/addons/account/tests/test_reconciliation.py index b674350edbd..767b4f77edc 100644 --- a/addons/account/tests/test_reconciliation.py +++ b/addons/account/tests/test_reconciliation.py @@ -14,20 +14,25 @@ class TestReconciliation(TransactionCase): self.account_invoice_line_model = self.registry('account.invoice.line') self.acc_bank_stmt_model = self.registry('account.bank.statement') self.acc_bank_stmt_line_model = self.registry('account.bank.statement.line') + self.res_currency_model = self.registry('res.currency') + self.res_currency_rate_model = self.registry('res.currency.rate') self.partner_agrolait_id = self.registry("ir.model.data").get_object_reference(self.cr, self.uid, "base", "res_partner_2")[1] self.currency_swiss_id = self.registry("ir.model.data").get_object_reference(self.cr, self.uid, "base", "CHF")[1] self.currency_usd_id = self.registry("ir.model.data").get_object_reference(self.cr, self.uid, "base", "USD")[1] self.account_rcv_id = self.registry("ir.model.data").get_object_reference(self.cr, self.uid, "account", "a_recv")[1] - self.account_rsa_id = self.registry("ir.model.data").get_object_reference(self.cr, self.uid, "account", "rsa")[1] + self.account_fx_income_id = self.registry("ir.model.data").get_object_reference(self.cr, self.uid, "account", "income_fx_income")[1] + self.account_fx_expense_id = self.registry("ir.model.data").get_object_reference(self.cr, self.uid, "account", "income_fx_expense")[1] + self.product_id = self.registry("ir.model.data").get_object_reference(self.cr, self.uid, "product", "product_product_4")[1] self.bank_journal_usd_id = self.registry("ir.model.data").get_object_reference(self.cr, self.uid, "account", "bank_journal_usd")[1] self.account_usd_id = self.registry("ir.model.data").get_object_reference(self.cr, self.uid, "account", "usd_bnk")[1] self.company_id = self.registry("ir.model.data").get_object_reference(self.cr, self.uid, "base", "main_company")[1] - #set expense_currency_exchange_account_id and income_currency_exchange_account_id to a random account - self.registry("res.company").write(self.cr, self.uid, [self.company_id], {'expense_currency_exchange_account_id': self.account_rsa_id, 'income_currency_exchange_account_id':self.account_rsa_id}) + + #set expense_currency_exchange_account_id and income_currency_exchange_account_id to the according accounts + self.registry("res.company").write(self.cr, self.uid, [self.company_id], {'expense_currency_exchange_account_id': self.account_fx_expense_id, 'income_currency_exchange_account_id':self.account_fx_income_id}) def test_balanced_customer_invoice(self): cr, uid = self.cr, self.uid @@ -92,7 +97,7 @@ class TestReconciliation(TransactionCase): self.assertEquals(move_line.currency_id.id, self.currency_swiss_id) checked_line += 1 continue - if move_line.account_id.id == self.account_rsa_id: + if move_line.account_id.id == self.account_fx_expense_id: self.assertEquals(move_line.debit, 10.74) self.assertEquals(move_line.credit, 0.0) checked_line += 1 @@ -164,10 +169,103 @@ class TestReconciliation(TransactionCase): self.assertEquals(move_line.currency_id.id, self.currency_swiss_id) checked_line += 1 continue - if move_line.account_id.id == self.account_rsa_id: + if move_line.account_id.id == self.account_fx_income_id: self.assertEquals(move_line.debit, 0.0) self.assertEquals(move_line.credit, 10.74) checked_line += 1 continue self.assertEquals(checked_line, 3) + def test_balanced_exchanges_gain_loss(self): + # The point of this test is to show that we handle correctly the gain/loss exchanges during reconciliations in foreign currencies. + # For instance, with a company set in EUR, and a USD rate set to 0.033, + # the reconciliation of an invoice of 2.00 USD (60.61 EUR) and a bank statement of two lines of 1.00 USD (30.30 EUR) + # will lead to an exchange loss, that should be handled correctly within the journal items. + cr, uid = self.cr, self.uid + # We update the currency rate of the currency USD in order to force the gain/loss exchanges in next steps + self.res_currency_rate_model.create(cr, uid, { + 'name': time.strftime('%Y-%m-%d') + ' 00:00:00', + 'currency_id': self.currency_usd_id, + 'rate': 0.033, + }) + # We create a customer invoice of 2.00 USD + invoice_id = self.account_invoice_model.create(cr, uid, { + 'partner_id': self.partner_agrolait_id, + 'currency_id': self.currency_usd_id, + 'name': 'Foreign invoice with exchange gain', + 'account_id': self.account_rcv_id, + 'type': 'out_invoice', + 'date_invoice': time.strftime('%Y-%m-%d'), + 'journal_id': self.bank_journal_usd_id, + 'invoice_line': [ + (0, 0, { + 'name': 'line that will lead to an exchange gain', + 'quantity': 1, + 'price_unit': 2, + }) + ] + }) + self.registry('account.invoice').signal_workflow(cr, uid, [invoice_id], 'invoice_open') + invoice = self.account_invoice_model.browse(cr, uid, invoice_id) + # We create a bank statement with two lines of 1.00 USD each. + bank_stmt_id = self.acc_bank_stmt_model.create(cr, uid, { + 'journal_id': self.bank_journal_usd_id, + 'date': time.strftime('%Y-%m-%d'), + 'line_ids': [ + (0, 0, { + 'name': 'half payment', + 'partner_id': self.partner_agrolait_id, + 'amount': 1.0, + 'date': time.strftime('%Y-%m-%d') + }), + (0, 0, { + 'name': 'second half payment', + 'partner_id': self.partner_agrolait_id, + 'amount': 1.0, + 'date': time.strftime('%Y-%m-%d') + }) + ] + }) + + statement = self.acc_bank_stmt_model.browse(cr, uid, bank_stmt_id) + + # We process the reconciliation of the invoice line with the two bank statement lines + line_id = None + for l in invoice.move_id.line_id: + if l.account_id.id == self.account_rcv_id: + line_id = l + break + for statement_line in statement.line_ids: + self.acc_bank_stmt_line_model.process_reconciliation(cr, uid, statement_line.id, [ + {'counterpart_move_line_id': line_id.id, 'credit': 1.0, 'debit': 0.0, 'name': line_id.name} + ]) + + # The invoice should be paid, as the payments totally cover its total + self.assertEquals(invoice.state, 'paid', 'The invoice should be paid by now') + reconcile = None + for payment in invoice.payment_ids: + reconcile = payment.reconcile_id + break + # The invoice should be reconciled (entirely, not a partial reconciliation) + self.assertTrue(reconcile, 'The invoice should be totally reconciled') + result = {} + exchange_loss_line = None + for line in reconcile.line_id: + res_account = result.setdefault(line.account_id, {'debit': 0.0, 'credit': 0.0, 'count': 0}) + res_account['debit'] = res_account['debit'] + line.debit + res_account['credit'] = res_account['credit'] + line.credit + res_account['count'] += 1 + if line.credit == 0.01: + exchange_loss_line = line + # We should be able to find a move line of 0.01 EUR on the Debtors account, being the cent we lost during the currency exchange + self.assertTrue(exchange_loss_line, 'There should be one move line of 0.01 EUR in credit') + # The journal items of the reconciliation should have their debit and credit total equal + # Besides, the total debit and total credit should be 60.61 EUR (2.00 USD) + self.assertEquals(sum([res['debit'] for res in result.values()]), 60.61) + self.assertEquals(sum([res['credit'] for res in result.values()]), 60.61) + counterpart_exchange_loss_line = None + for line in exchange_loss_line.move_id.line_id: + if line.account_id.id == self.account_fx_expense_id: + counterpart_exchange_loss_line = line + # We should be able to find a move line of 0.01 EUR on the Foreign Exchange Loss account + self.assertTrue(counterpart_exchange_loss_line, 'There should be one move line of 0.01 EUR on account "Foreign Exchange Loss"')