diff options
| author | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
|---|---|---|
| committer | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
| commit | 3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch) | |
| tree | a44932296ef4a9b71d5f010906253d8c53727726 /addons/account/tests | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/account/tests')
37 files changed, 20226 insertions, 0 deletions
diff --git a/addons/account/tests/__init__.py b/addons/account/tests/__init__.py new file mode 100644 index 00000000..ffd45bb1 --- /dev/null +++ b/addons/account/tests/__init__.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- + +from . import test_account_move_reconcile +from . import test_account_move_payments_widget +from . import test_account_move_out_invoice +from . import test_account_move_out_refund +from . import test_account_move_in_invoice +from . import test_account_move_in_refund +from . import test_account_move_entry +from . import test_invoice_tax_amount_by_group +from . import test_account_journal +from . import test_account_account +from . import test_account_tax +from . import test_account_analytic +from . import test_account_payment +from . import test_account_bank_statement +from . import test_account_move_partner_count +from . import test_account_move_rounding +from . import test_account_invoice_report +from . import test_account_journal_dashboard +from . import test_fiscal_position +from . import test_reconciliation +from . import test_sequence_mixin +from . import test_settings +from . import test_tax +from . import test_invoice_taxes +from . import test_templates_consistency +from . import test_account_all_l10n +from . import test_reconciliation_matching_rules +from . import test_account_onboarding +from . import test_portal_attachment +from . import test_tax_report +from . import test_transfer_wizard +from . import test_account_incoming_supplier_invoice +from . import test_payment_term +from . import test_account_payment_register +from . import test_tour diff --git a/addons/account/tests/common.py b/addons/account/tests/common.py new file mode 100644 index 00000000..42aba78e --- /dev/null +++ b/addons/account/tests/common.py @@ -0,0 +1,703 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from odoo import fields +from odoo.tests.common import SavepointCase, HttpSavepointCase, tagged, Form + +import time +import base64 +from lxml import etree + +@tagged('post_install', '-at_install') +class AccountTestInvoicingCommon(SavepointCase): + + @classmethod + def copy_account(cls, account): + suffix_nb = 1 + while True: + new_code = '%s (%s)' % (account.code, suffix_nb) + if account.search_count([('company_id', '=', account.company_id.id), ('code', '=', new_code)]): + suffix_nb += 1 + else: + return account.copy(default={'code': new_code}) + + @classmethod + def setUpClass(cls, chart_template_ref=None): + super(AccountTestInvoicingCommon, cls).setUpClass() + + if chart_template_ref: + chart_template = cls.env.ref(chart_template_ref) + else: + chart_template = cls.env.ref('l10n_generic_coa.configurable_chart_template', raise_if_not_found=False) + if not chart_template: + cls.tearDownClass() + # skipTest raises exception + cls.skipTest(cls, "Accounting Tests skipped because the user's company has no chart of accounts.") + + # Create user. + user = cls.env['res.users'].create({ + 'name': 'Because I am accountman!', + 'login': 'accountman', + 'password': 'accountman', + 'groups_id': [(6, 0, cls.env.user.groups_id.ids), (4, cls.env.ref('account.group_account_user').id)], + }) + user.partner_id.email = 'accountman@test.com' + + # Shadow the current environment/cursor with one having the report user. + # This is mandatory to test access rights. + cls.env = cls.env(user=user) + cls.cr = cls.env.cr + + cls.company_data_2 = cls.setup_company_data('company_2_data', chart_template=chart_template) + cls.company_data = cls.setup_company_data('company_1_data', chart_template=chart_template) + + user.write({ + 'company_ids': [(6, 0, (cls.company_data['company'] + cls.company_data_2['company']).ids)], + 'company_id': cls.company_data['company'].id, + }) + + cls.currency_data = cls.setup_multi_currency_data() + + # ==== Taxes ==== + cls.tax_sale_a = cls.company_data['default_tax_sale'] + cls.tax_sale_b = cls.company_data['default_tax_sale'].copy() + cls.tax_purchase_a = cls.company_data['default_tax_purchase'] + cls.tax_purchase_b = cls.company_data['default_tax_purchase'].copy() + cls.tax_armageddon = cls.setup_armageddon_tax('complex_tax', cls.company_data) + + # ==== Products ==== + cls.product_a = cls.env['product.product'].create({ + 'name': 'product_a', + 'uom_id': cls.env.ref('uom.product_uom_unit').id, + 'lst_price': 1000.0, + 'standard_price': 800.0, + 'property_account_income_id': cls.company_data['default_account_revenue'].id, + 'property_account_expense_id': cls.company_data['default_account_expense'].id, + 'taxes_id': [(6, 0, cls.tax_sale_a.ids)], + 'supplier_taxes_id': [(6, 0, cls.tax_purchase_a.ids)], + }) + cls.product_b = cls.env['product.product'].create({ + 'name': 'product_b', + 'uom_id': cls.env.ref('uom.product_uom_dozen').id, + 'lst_price': 200.0, + 'standard_price': 160.0, + 'property_account_income_id': cls.copy_account(cls.company_data['default_account_revenue']).id, + 'property_account_expense_id': cls.copy_account(cls.company_data['default_account_expense']).id, + 'taxes_id': [(6, 0, (cls.tax_sale_a + cls.tax_sale_b).ids)], + 'supplier_taxes_id': [(6, 0, (cls.tax_purchase_a + cls.tax_purchase_b).ids)], + }) + + # ==== Fiscal positions ==== + cls.fiscal_pos_a = cls.env['account.fiscal.position'].create({ + 'name': 'fiscal_pos_a', + 'tax_ids': [ + (0, None, { + 'tax_src_id': cls.tax_sale_a.id, + 'tax_dest_id': cls.tax_sale_b.id, + }), + (0, None, { + 'tax_src_id': cls.tax_purchase_a.id, + 'tax_dest_id': cls.tax_purchase_b.id, + }), + ], + 'account_ids': [ + (0, None, { + 'account_src_id': cls.product_a.property_account_income_id.id, + 'account_dest_id': cls.product_b.property_account_income_id.id, + }), + (0, None, { + 'account_src_id': cls.product_a.property_account_expense_id.id, + 'account_dest_id': cls.product_b.property_account_expense_id.id, + }), + ], + }) + + # ==== Payment terms ==== + cls.pay_terms_a = cls.env.ref('account.account_payment_term_immediate') + cls.pay_terms_b = cls.env['account.payment.term'].create({ + 'name': '30% Advance End of Following Month', + 'note': 'Payment terms: 30% Advance End of Following Month', + 'line_ids': [ + (0, 0, { + 'value': 'percent', + 'value_amount': 30.0, + 'sequence': 400, + 'days': 0, + 'option': 'day_after_invoice_date', + }), + (0, 0, { + 'value': 'balance', + 'value_amount': 0.0, + 'sequence': 500, + 'days': 31, + 'option': 'day_following_month', + }), + ], + }) + + # ==== Partners ==== + cls.partner_a = cls.env['res.partner'].create({ + 'name': 'partner_a', + 'property_payment_term_id': cls.pay_terms_a.id, + 'property_supplier_payment_term_id': cls.pay_terms_a.id, + 'property_account_receivable_id': cls.company_data['default_account_receivable'].id, + 'property_account_payable_id': cls.company_data['default_account_payable'].id, + 'company_id': False, + }) + cls.partner_b = cls.env['res.partner'].create({ + 'name': 'partner_b', + 'property_payment_term_id': cls.pay_terms_b.id, + 'property_supplier_payment_term_id': cls.pay_terms_b.id, + 'property_account_position_id': cls.fiscal_pos_a.id, + 'property_account_receivable_id': cls.company_data['default_account_receivable'].copy().id, + 'property_account_payable_id': cls.company_data['default_account_payable'].copy().id, + 'company_id': False, + }) + + # ==== Cash rounding ==== + cls.cash_rounding_a = cls.env['account.cash.rounding'].create({ + 'name': 'add_invoice_line', + 'rounding': 0.05, + 'strategy': 'add_invoice_line', + 'profit_account_id': cls.company_data['default_account_revenue'].copy().id, + 'loss_account_id': cls.company_data['default_account_expense'].copy().id, + 'rounding_method': 'UP', + }) + cls.cash_rounding_b = cls.env['account.cash.rounding'].create({ + 'name': 'biggest_tax', + 'rounding': 0.05, + 'strategy': 'biggest_tax', + 'rounding_method': 'DOWN', + }) + + @classmethod + def setup_company_data(cls, company_name, chart_template=None, **kwargs): + ''' Create a new company having the name passed as parameter. + A chart of accounts will be installed to this company: the same as the current company one. + The current user will get access to this company. + + :param chart_template: The chart template to be used on this new company. + :param company_name: The name of the company. + :return: A dictionary will be returned containing all relevant accounting data for testing. + ''' + def search_account(company, chart_template, field_name, domain): + template_code = chart_template[field_name].code + domain = [('company_id', '=', company.id)] + domain + + account = None + if template_code: + account = cls.env['account.account'].search(domain + [('code', '=like', template_code + '%')], limit=1) + + if not account: + account = cls.env['account.account'].search(domain, limit=1) + return account + + chart_template = chart_template or cls.env.company.chart_template_id + company = cls.env['res.company'].create({ + 'name': company_name, + **kwargs, + }) + cls.env.user.company_ids |= company + + chart_template.try_loading(company=company) + + # The currency could be different after the installation of the chart template. + if kwargs.get('currency_id'): + company.write({'currency_id': kwargs['currency_id']}) + + return { + 'company': company, + 'currency': company.currency_id, + 'default_account_revenue': cls.env['account.account'].search([ + ('company_id', '=', company.id), + ('user_type_id', '=', cls.env.ref('account.data_account_type_revenue').id) + ], limit=1), + 'default_account_expense': cls.env['account.account'].search([ + ('company_id', '=', company.id), + ('user_type_id', '=', cls.env.ref('account.data_account_type_expenses').id) + ], limit=1), + 'default_account_receivable': search_account(company, chart_template, 'property_account_receivable_id', [ + ('user_type_id.type', '=', 'receivable') + ]), + 'default_account_payable': cls.env['account.account'].search([ + ('company_id', '=', company.id), + ('user_type_id.type', '=', 'payable') + ], limit=1), + 'default_account_assets': cls.env['account.account'].search([ + ('company_id', '=', company.id), + ('user_type_id', '=', cls.env.ref('account.data_account_type_current_assets').id) + ], limit=1), + 'default_account_tax_sale': company.account_sale_tax_id.mapped('invoice_repartition_line_ids.account_id'), + 'default_account_tax_purchase': company.account_purchase_tax_id.mapped('invoice_repartition_line_ids.account_id'), + 'default_journal_misc': cls.env['account.journal'].search([ + ('company_id', '=', company.id), + ('type', '=', 'general') + ], limit=1), + 'default_journal_sale': cls.env['account.journal'].search([ + ('company_id', '=', company.id), + ('type', '=', 'sale') + ], limit=1), + 'default_journal_purchase': cls.env['account.journal'].search([ + ('company_id', '=', company.id), + ('type', '=', 'purchase') + ], limit=1), + 'default_journal_bank': cls.env['account.journal'].search([ + ('company_id', '=', company.id), + ('type', '=', 'bank') + ], limit=1), + 'default_journal_cash': cls.env['account.journal'].search([ + ('company_id', '=', company.id), + ('type', '=', 'cash') + ], limit=1), + 'default_tax_sale': company.account_sale_tax_id, + 'default_tax_purchase': company.account_purchase_tax_id, + } + + @classmethod + def setup_multi_currency_data(cls, default_values={}, rate2016=3.0, rate2017=2.0): + foreign_currency = cls.env['res.currency'].create({ + 'name': 'Gold Coin', + 'symbol': '☺', + 'rounding': 0.001, + 'position': 'after', + 'currency_unit_label': 'Gold', + 'currency_subunit_label': 'Silver', + **default_values, + }) + rate1 = cls.env['res.currency.rate'].create({ + 'name': '2016-01-01', + 'rate': rate2016, + 'currency_id': foreign_currency.id, + 'company_id': cls.env.company.id, + }) + rate2 = cls.env['res.currency.rate'].create({ + 'name': '2017-01-01', + 'rate': rate2017, + 'currency_id': foreign_currency.id, + 'company_id': cls.env.company.id, + }) + return { + 'currency': foreign_currency, + 'rates': rate1 + rate2, + } + + @classmethod + def setup_armageddon_tax(cls, tax_name, company_data): + return cls.env['account.tax'].create({ + 'name': '%s (group)' % tax_name, + 'amount_type': 'group', + 'amount': 0.0, + 'children_tax_ids': [ + (0, 0, { + 'name': '%s (child 1)' % tax_name, + 'amount_type': 'percent', + 'amount': 20.0, + 'price_include': True, + 'include_base_amount': True, + 'tax_exigibility': 'on_invoice', + 'invoice_repartition_line_ids': [ + (0, 0, { + 'factor_percent': 100, + 'repartition_type': 'base', + }), + (0, 0, { + 'factor_percent': 40, + 'repartition_type': 'tax', + 'account_id': company_data['default_account_tax_sale'].id, + }), + (0, 0, { + 'factor_percent': 60, + 'repartition_type': 'tax', + # /!\ No account set. + }), + ], + 'refund_repartition_line_ids': [ + (0, 0, { + 'factor_percent': 100, + 'repartition_type': 'base', + }), + (0, 0, { + 'factor_percent': 40, + 'repartition_type': 'tax', + 'account_id': company_data['default_account_tax_sale'].id, + }), + (0, 0, { + 'factor_percent': 60, + 'repartition_type': 'tax', + # /!\ No account set. + }), + ], + }), + (0, 0, { + 'name': '%s (child 2)' % tax_name, + 'amount_type': 'percent', + 'amount': 10.0, + 'tax_exigibility': 'on_payment', + 'cash_basis_transition_account_id': company_data['default_account_tax_sale'].copy().id, + 'invoice_repartition_line_ids': [ + (0, 0, { + 'factor_percent': 100, + 'repartition_type': 'base', + }), + (0, 0, { + 'factor_percent': 100, + 'repartition_type': 'tax', + 'account_id': company_data['default_account_tax_sale'].id, + }), + ], + 'refund_repartition_line_ids': [ + (0, 0, { + 'factor_percent': 100, + 'repartition_type': 'base', + }), + + (0, 0, { + 'factor_percent': 100, + 'repartition_type': 'tax', + 'account_id': company_data['default_account_tax_sale'].id, + }), + ], + }), + ], + }) + + @classmethod + def init_invoice(cls, move_type, partner=None, invoice_date=None, post=False, products=[], amounts=[], taxes=None): + move_form = Form(cls.env['account.move'].with_context(default_move_type=move_type, account_predictive_bills_disable_prediction=True)) + move_form.invoice_date = invoice_date or fields.Date.from_string('2019-01-01') + move_form.date = move_form.invoice_date + move_form.partner_id = partner or cls.partner_a + + for product in products: + with move_form.invoice_line_ids.new() as line_form: + line_form.product_id = product + if taxes: + line_form.tax_ids.clear() + line_form.tax_ids.add(taxes) + + for amount in amounts: + with move_form.invoice_line_ids.new() as line_form: + line_form.name = "test line" + # We use account_predictive_bills_disable_prediction context key so that + # this doesn't trigger prediction in case enterprise (hence account_predictive_bills) is installed + line_form.price_unit = amount + if taxes: + line_form.tax_ids.clear() + line_form.tax_ids.add(taxes) + + rslt = move_form.save() + + if post: + rslt.action_post() + + return rslt + + def assertInvoiceValues(self, move, expected_lines_values, expected_move_values): + def sort_lines(lines): + return lines.sorted(lambda line: (line.exclude_from_invoice_tab, not bool(line.tax_line_id), line.name or '', line.balance)) + self.assertRecordValues(sort_lines(move.line_ids.sorted()), expected_lines_values) + self.assertRecordValues(sort_lines(move.invoice_line_ids.sorted()), expected_lines_values[:len(move.invoice_line_ids)]) + self.assertRecordValues(move, [expected_move_values]) + + #################################################### + # Xml Comparison + #################################################### + + def _turn_node_as_dict_hierarchy(self, node): + ''' Turn the node as a python dictionary to be compared later with another one. + Allow to ignore the management of namespaces. + :param node: A node inside an xml tree. + :return: A python dictionary. + ''' + tag_split = node.tag.split('}') + tag_wo_ns = tag_split[-1] + attrib_wo_ns = {k: v for k, v in node.attrib.items() if '}' not in k} + return { + 'tag': tag_wo_ns, + 'namespace': None if len(tag_split) < 2 else tag_split[0], + 'text': (node.text or '').strip(), + 'attrib': attrib_wo_ns, + 'children': [self._turn_node_as_dict_hierarchy(child_node) for child_node in node.getchildren()], + } + + def assertXmlTreeEqual(self, xml_tree, expected_xml_tree): + ''' Compare two lxml.etree. + :param xml_tree: The current tree. + :param expected_xml_tree: The expected tree. + ''' + + def assertNodeDictEqual(node_dict, expected_node_dict): + ''' Compare nodes created by the `_turn_node_as_dict_hierarchy` method. + :param node_dict: The node to compare with. + :param expected_node_dict: The expected node. + ''' + # Check tag. + self.assertEqual(node_dict['tag'], expected_node_dict['tag']) + + # Check attributes. + node_dict_attrib = {k: '___ignore___' if expected_node_dict['attrib'].get(k) == '___ignore___' else v + for k, v in node_dict['attrib'].items()} + expected_node_dict_attrib = {k: v for k, v in expected_node_dict['attrib'].items() if v != '___remove___'} + self.assertDictEqual( + node_dict_attrib, + expected_node_dict_attrib, + "Element attributes are different for node %s" % node_dict['tag'], + ) + + # Check text. + if expected_node_dict['text'] != '___ignore___': + self.assertEqual( + node_dict['text'], + expected_node_dict['text'], + "Element text are different for node %s" % node_dict['tag'], + ) + + # Check children. + self.assertEqual( + [child['tag'] for child in node_dict['children']], + [child['tag'] for child in expected_node_dict['children']], + "Number of children elements for node %s is different." % node_dict['tag'], + ) + + for child_node_dict, expected_child_node_dict in zip(node_dict['children'], expected_node_dict['children']): + assertNodeDictEqual(child_node_dict, expected_child_node_dict) + + assertNodeDictEqual( + self._turn_node_as_dict_hierarchy(xml_tree), + self._turn_node_as_dict_hierarchy(expected_xml_tree), + ) + + def with_applied_xpath(self, xml_tree, xpath): + ''' Applies the xpath to the xml_tree passed as parameter. + :param xml_tree: An instance of etree. + :param xpath: The xpath to apply as a string. + :return: The resulting etree after applying the xpaths. + ''' + diff_xml_tree = etree.fromstring('<data>%s</data>' % xpath) + return self.env['ir.ui.view'].apply_inheritance_specs(xml_tree, diff_xml_tree) + + def get_xml_tree_from_attachment(self, attachment): + ''' Extract an instance of etree from an ir.attachment. + :param attachment: An ir.attachment. + :return: An instance of etree. + ''' + return etree.fromstring(base64.b64decode(attachment.with_context(bin_size=False).datas)) + + def get_xml_tree_from_string(self, xml_tree_str): + ''' Convert the string passed as parameter to an instance of etree. + :param xml_tree_str: A string representing an xml. + :return: An instance of etree. + ''' + return etree.fromstring(xml_tree_str) + + +@tagged('post_install', '-at_install') +class AccountTestInvoicingHttpCommon(AccountTestInvoicingCommon, HttpSavepointCase): + pass + + +class TestAccountReconciliationCommon(AccountTestInvoicingCommon): + + """Tests for reconciliation (account.tax) + + Test used to check that when doing a sale or purchase invoice in a different currency, + the result will be balanced. + """ + + @classmethod + def setUpClass(cls, chart_template_ref=None): + super().setUpClass(chart_template_ref=chart_template_ref) + + cls.company = cls.company_data['company'] + cls.company.currency_id = cls.env.ref('base.EUR') + + cls.partner_agrolait = cls.env['res.partner'].create({ + 'name': 'Deco Addict', + 'is_company': True, + 'country_id': cls.env.ref('base.us').id, + }) + cls.partner_agrolait_id = cls.partner_agrolait.id + cls.currency_swiss_id = cls.env.ref("base.CHF").id + cls.currency_usd_id = cls.env.ref("base.USD").id + cls.currency_euro_id = cls.env.ref("base.EUR").id + cls.account_rcv = cls.company_data['default_account_receivable'] + cls.account_rsa = cls.company_data['default_account_payable'] + cls.product = cls.env['product.product'].create({ + 'name': 'Product Product 4', + 'standard_price': 500.0, + 'list_price': 750.0, + 'type': 'consu', + 'categ_id': cls.env.ref('product.product_category_all').id, + }) + + cls.bank_journal_euro = cls.env['account.journal'].create({'name': 'Bank', 'type': 'bank', 'code': 'BNK67'}) + cls.account_euro = cls.bank_journal_euro.default_account_id + + cls.bank_journal_usd = cls.env['account.journal'].create({'name': 'Bank US', 'type': 'bank', 'code': 'BNK68', 'currency_id': cls.currency_usd_id}) + cls.account_usd = cls.bank_journal_usd.default_account_id + + cls.fx_journal = cls.company.currency_exchange_journal_id + cls.diff_income_account = cls.company.income_currency_exchange_account_id + cls.diff_expense_account = cls.company.expense_currency_exchange_account_id + + cls.inbound_payment_method = cls.env['account.payment.method'].create({ + 'name': 'inbound', + 'code': 'IN', + 'payment_type': 'inbound', + }) + + cls.expense_account = cls.company_data['default_account_expense'] + # cash basis intermediary account + cls.tax_waiting_account = cls.env['account.account'].create({ + 'name': 'TAX_WAIT', + 'code': 'TWAIT', + 'user_type_id': cls.env.ref('account.data_account_type_current_liabilities').id, + 'reconcile': True, + 'company_id': cls.company.id, + }) + # cash basis final account + cls.tax_final_account = cls.env['account.account'].create({ + 'name': 'TAX_TO_DEDUCT', + 'code': 'TDEDUCT', + 'user_type_id': cls.env.ref('account.data_account_type_current_assets').id, + 'company_id': cls.company.id, + }) + cls.tax_base_amount_account = cls.env['account.account'].create({ + 'name': 'TAX_BASE', + 'code': 'TBASE', + 'user_type_id': cls.env.ref('account.data_account_type_current_assets').id, + 'company_id': cls.company.id, + }) + cls.company.account_cash_basis_base_account_id = cls.tax_base_amount_account.id + + + # Journals + cls.purchase_journal = cls.company_data['default_journal_purchase'] + cls.cash_basis_journal = cls.env['account.journal'].create({ + 'name': 'CABA', + 'code': 'CABA', + 'type': 'general', + }) + cls.general_journal = cls.company_data['default_journal_misc'] + + # Tax Cash Basis + cls.tax_cash_basis = cls.env['account.tax'].create({ + 'name': 'cash basis 20%', + 'type_tax_use': 'purchase', + 'company_id': cls.company.id, + 'amount': 20, + 'tax_exigibility': 'on_payment', + 'cash_basis_transition_account_id': cls.tax_waiting_account.id, + 'invoice_repartition_line_ids': [ + (0,0, { + 'factor_percent': 100, + 'repartition_type': 'base', + }), + + (0,0, { + 'factor_percent': 100, + 'repartition_type': 'tax', + 'account_id': cls.tax_final_account.id, + }), + ], + 'refund_repartition_line_ids': [ + (0,0, { + 'factor_percent': 100, + 'repartition_type': 'base', + }), + + (0,0, { + 'factor_percent': 100, + 'repartition_type': 'tax', + 'account_id': cls.tax_final_account.id, + }), + ], + }) + cls.env['res.currency.rate'].create([ + { + 'currency_id': cls.env.ref('base.EUR').id, + 'name': '2010-01-02', + 'rate': 1.0, + }, { + 'currency_id': cls.env.ref('base.USD').id, + 'name': '2010-01-02', + 'rate': 1.2834, + }, { + 'currency_id': cls.env.ref('base.USD').id, + 'name': time.strftime('%Y-06-05'), + 'rate': 1.5289, + } + ]) + + def _create_invoice(self, move_type='out_invoice', invoice_amount=50, currency_id=None, partner_id=None, date_invoice=None, payment_term_id=False, auto_validate=False): + date_invoice = date_invoice or time.strftime('%Y') + '-07-01' + + invoice_vals = { + 'move_type': move_type, + 'partner_id': partner_id or self.partner_agrolait_id, + 'invoice_date': date_invoice, + 'date': date_invoice, + 'invoice_line_ids': [(0, 0, { + 'name': 'product that cost %s' % invoice_amount, + 'quantity': 1, + 'price_unit': invoice_amount, + 'tax_ids': [(6, 0, [])], + })] + } + + if payment_term_id: + invoice_vals['invoice_payment_term_id'] = payment_term_id + + if currency_id: + invoice_vals['currency_id'] = currency_id + + invoice = self.env['account.move'].with_context(default_move_type=type).create(invoice_vals) + if auto_validate: + invoice.action_post() + return invoice + + def create_invoice(self, move_type='out_invoice', invoice_amount=50, currency_id=None): + return self._create_invoice(move_type=move_type, invoice_amount=invoice_amount, currency_id=currency_id, auto_validate=True) + + def create_invoice_partner(self, move_type='out_invoice', invoice_amount=50, currency_id=None, partner_id=False, payment_term_id=False): + return self._create_invoice( + move_type=move_type, + invoice_amount=invoice_amount, + currency_id=currency_id, + partner_id=partner_id, + payment_term_id=payment_term_id, + auto_validate=True + ) + + def make_payment(self, invoice_record, bank_journal, amount=0.0, amount_currency=0.0, currency_id=None, reconcile_param=[]): + bank_stmt = self.env['account.bank.statement'].create({ + 'journal_id': bank_journal.id, + 'date': time.strftime('%Y') + '-07-15', + 'name': 'payment' + invoice_record.name, + 'line_ids': [(0, 0, { + 'payment_ref': 'payment', + 'partner_id': self.partner_agrolait_id, + 'amount': amount, + 'amount_currency': amount_currency, + 'foreign_currency_id': currency_id, + })], + }) + bank_stmt.button_post() + + bank_stmt.line_ids[0].reconcile(reconcile_param) + return bank_stmt + + def make_customer_and_supplier_flows(self, invoice_currency_id, invoice_amount, bank_journal, amount, amount_currency, transaction_currency_id): + #we create an invoice in given invoice_currency + invoice_record = self.create_invoice(move_type='out_invoice', invoice_amount=invoice_amount, currency_id=invoice_currency_id) + #we encode a payment on it, on the given bank_journal with amount, amount_currency and transaction_currency given + line = invoice_record.line_ids.filtered(lambda line: line.account_id.user_type_id.type in ('receivable', 'payable')) + bank_stmt = self.make_payment(invoice_record, bank_journal, amount=amount, amount_currency=amount_currency, currency_id=transaction_currency_id, reconcile_param=[{'id': line.id}]) + customer_move_lines = bank_stmt.line_ids.line_ids + + #we create a supplier bill in given invoice_currency + invoice_record = self.create_invoice(move_type='in_invoice', invoice_amount=invoice_amount, currency_id=invoice_currency_id) + #we encode a payment on it, on the given bank_journal with amount, amount_currency and transaction_currency given + line = invoice_record.line_ids.filtered(lambda line: line.account_id.user_type_id.type in ('receivable', 'payable')) + bank_stmt = self.make_payment(invoice_record, bank_journal, amount=-amount, amount_currency=-amount_currency, currency_id=transaction_currency_id, reconcile_param=[{'id': line.id}]) + supplier_move_lines = bank_stmt.line_ids.line_ids + return customer_move_lines, supplier_move_lines diff --git a/addons/account/tests/test_account_account.py b/addons/account/tests/test_account_account.py new file mode 100644 index 00000000..cded3517 --- /dev/null +++ b/addons/account/tests/test_account_account.py @@ -0,0 +1,137 @@ +# -*- coding: utf-8 -*- +from odoo.addons.account.tests.common import AccountTestInvoicingCommon +from odoo.tests import tagged +from odoo.exceptions import UserError, ValidationError + + +@tagged('post_install', '-at_install') +class TestAccountAccount(AccountTestInvoicingCommon): + + def test_changing_account_company(self): + ''' Ensure you can't change the company of an account.account if there are some journal entries ''' + + self.env['account.move'].create({ + 'move_type': 'entry', + 'date': '2019-01-01', + 'line_ids': [ + (0, 0, { + 'name': 'line_debit', + 'account_id': self.company_data['default_account_revenue'].id, + }), + (0, 0, { + 'name': 'line_credit', + 'account_id': self.company_data['default_account_revenue'].id, + }), + ], + }) + + with self.assertRaises(UserError), self.cr.savepoint(): + self.company_data['default_account_revenue'].company_id = self.company_data_2['company'] + + def test_toggle_reconcile(self): + ''' Test the feature when the user sets an account as reconcile/not reconcile with existing journal entries. ''' + account = self.company_data['default_account_revenue'] + + move = self.env['account.move'].create({ + 'move_type': 'entry', + 'date': '2019-01-01', + 'line_ids': [ + (0, 0, { + 'account_id': account.id, + 'currency_id': self.currency_data['currency'].id, + 'debit': 100.0, + 'credit': 0.0, + 'amount_currency': 200.0, + }), + (0, 0, { + 'account_id': account.id, + 'currency_id': self.currency_data['currency'].id, + 'debit': 0.0, + 'credit': 100.0, + 'amount_currency': -200.0, + }), + ], + }) + move.action_post() + + self.assertRecordValues(move.line_ids, [ + {'reconciled': False, 'amount_residual': 0.0, 'amount_residual_currency': 0.0}, + {'reconciled': False, 'amount_residual': 0.0, 'amount_residual_currency': 0.0}, + ]) + + # Set the account as reconcile and fully reconcile something. + account.reconcile = True + self.env['account.move.line'].invalidate_cache() + + self.assertRecordValues(move.line_ids, [ + {'reconciled': False, 'amount_residual': 100.0, 'amount_residual_currency': 200.0}, + {'reconciled': False, 'amount_residual': -100.0, 'amount_residual_currency': -200.0}, + ]) + + move.line_ids.reconcile() + self.assertRecordValues(move.line_ids, [ + {'reconciled': True, 'amount_residual': 0.0, 'amount_residual_currency': 0.0}, + {'reconciled': True, 'amount_residual': 0.0, 'amount_residual_currency': 0.0}, + ]) + + # Set back to a not reconcile account and check the journal items. + move.line_ids.remove_move_reconcile() + account.reconcile = False + self.env['account.move.line'].invalidate_cache() + + self.assertRecordValues(move.line_ids, [ + {'reconciled': False, 'amount_residual': 0.0, 'amount_residual_currency': 0.0}, + {'reconciled': False, 'amount_residual': 0.0, 'amount_residual_currency': 0.0}, + ]) + + def test_toggle_reconcile_with_partials(self): + ''' Test the feature when the user sets an account as reconcile/not reconcile with partial reconciliation. ''' + account = self.company_data['default_account_revenue'] + + move = self.env['account.move'].create({ + 'move_type': 'entry', + 'date': '2019-01-01', + 'line_ids': [ + (0, 0, { + 'account_id': account.id, + 'currency_id': self.currency_data['currency'].id, + 'debit': 100.0, + 'credit': 0.0, + 'amount_currency': 200.0, + }), + (0, 0, { + 'account_id': account.id, + 'currency_id': self.currency_data['currency'].id, + 'debit': 0.0, + 'credit': 50.0, + 'amount_currency': -100.0, + }), + (0, 0, { + 'account_id': self.company_data['default_account_expense'].id, + 'currency_id': self.currency_data['currency'].id, + 'debit': 0.0, + 'credit': 50.0, + 'amount_currency': -100.0, + }), + ], + }) + move.action_post() + + # Set the account as reconcile and partially reconcile something. + account.reconcile = True + self.env['account.move.line'].invalidate_cache() + + move.line_ids.filtered(lambda line: line.account_id == account).reconcile() + + # Try to set the account as a not-reconcile one. + with self.assertRaises(UserError), self.cr.savepoint(): + account.reconcile = False + + def test_toggle_reconcile_outstanding_account(self): + ''' Test the feature when the user sets an account as not reconcilable when a journal + is configured with this account as the payment credit or debit account. + Since such an account should be reconcilable by nature, a ValidationError is raised.''' + with self.assertRaises(ValidationError), self.cr.savepoint(): + self.company_data['default_journal_bank'].payment_debit_account_id.reconcile = False + with self.assertRaises(ValidationError), self.cr.savepoint(): + self.company_data['default_journal_bank'].payment_credit_account_id.reconcile = False diff --git a/addons/account/tests/test_account_all_l10n.py b/addons/account/tests/test_account_all_l10n.py new file mode 100644 index 00000000..fbf9887b --- /dev/null +++ b/addons/account/tests/test_account_all_l10n.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +import logging + +from odoo.tests import standalone + + +_logger = logging.getLogger(__name__) + + +@standalone('all_l10n') +def test_all_l10n(env): + """ This test will install all the l10n_* modules. + As the module install is not yet fully transactional, the modules will + remain installed after the test. + """ + l10n_mods = env['ir.module.module'].search([ + ('name', 'like', 'l10n%'), + ('state', '=', 'uninstalled'), + ]) + l10n_mods.button_immediate_install() + env.reset() # clear the set of environments + env = env() # get an environment that refers to the new registry + + coas = env['account.chart.template'].search([]) + for coa in coas: + cname = 'company_%s' % str(coa.id) + company = env['res.company'].create({'name': cname}) + env.user.company_ids += company + env.user.company_id = company + _logger.info('Testing COA: %s (company: %s)' % (coa.name, cname)) + try: + with env.cr.savepoint(): + coa.try_loading() + except Exception: + _logger.error("Error when creating COA %s", coa.name, exc_info=True) diff --git a/addons/account/tests/test_account_analytic.py b/addons/account/tests/test_account_analytic.py new file mode 100644 index 00000000..48ab4258 --- /dev/null +++ b/addons/account/tests/test_account_analytic.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8 -*- +from odoo.addons.account.tests.common import AccountTestInvoicingCommon +from odoo.tests import tagged +from odoo.exceptions import UserError + + +@tagged('post_install', '-at_install') +class TestAccountAnalyticAccount(AccountTestInvoicingCommon): + + @classmethod + def setUpClass(cls, chart_template_ref=None): + super().setUpClass(chart_template_ref=chart_template_ref) + + cls.env.user.write({ + 'groups_id': [ + (4, cls.env.ref('analytic.group_analytic_accounting').id), + (4, cls.env.ref('analytic.group_analytic_tags').id), + ], + }) + + # By default, tests are run with the current user set on the first company. + cls.env.user.company_id = cls.company_data['company'] + + cls.test_analytic_account = cls.env['account.analytic.account'].create({'name': 'test_analytic_account'}) + cls.test_analytic_tag = cls.env['account.analytic.tag'].create({'name': 'test_analytic_tag'}) + + def test_changing_analytic_company(self): + ''' Ensure you can't change the company of an account.analytic.account if there are some journal entries ''' + + self.env['account.move'].create({ + 'move_type': 'entry', + 'date': '2019-01-01', + 'line_ids': [ + (0, 0, { + 'name': 'line_debit', + 'account_id': self.company_data['default_account_revenue'].id, + 'analytic_account_id': self.test_analytic_account.id, + 'analytic_tag_ids': [(6, 0, self.test_analytic_tag.ids)], + }), + (0, 0, { + 'name': 'line_credit', + 'account_id': self.company_data['default_account_revenue'].id, + }), + ], + }) + + # Set a different company on the analytic account. + with self.assertRaises(UserError), self.cr.savepoint(): + self.test_analytic_account.company_id = self.company_data_2['company'] + + # Making the analytic account not company dependent is allowed. + self.test_analytic_account.company_id = False + + # Set a different company on the analytic tag. + with self.assertRaises(UserError), self.cr.savepoint(): + self.test_analytic_tag.company_id = self.company_data_2['company'] + + # Making the analytic tag not company dependent is allowed. + self.test_analytic_tag.company_id = False diff --git a/addons/account/tests/test_account_bank_statement.py b/addons/account/tests/test_account_bank_statement.py new file mode 100644 index 00000000..10ec0aba --- /dev/null +++ b/addons/account/tests/test_account_bank_statement.py @@ -0,0 +1,1594 @@ +# -*- coding: utf-8 -*- +from odoo.addons.account.tests.common import AccountTestInvoicingCommon +from odoo.tests import tagged +from odoo.tests.common import Form +from odoo.exceptions import ValidationError, UserError +from odoo import fields + +from unittest.mock import patch + + +class TestAccountBankStatementCommon(AccountTestInvoicingCommon): + + @classmethod + def setUpClass(cls, chart_template_ref=None): + super().setUpClass(chart_template_ref=chart_template_ref) + + # We need a third currency as you could have a company's currency != journal's currency != + cls.currency_data_2 = cls.setup_multi_currency_data(default_values={ + 'name': 'Dark Chocolate Coin', + 'symbol': '🍫', + 'currency_unit_label': 'Dark Choco', + 'currency_subunit_label': 'Dark Cacao Powder', + }, rate2016=6.0, rate2017=4.0) + cls.currency_data_3 = cls.setup_multi_currency_data(default_values={ + 'name': 'Black Chocolate Coin', + 'symbol': '🍫', + 'currency_unit_label': 'Black Choco', + 'currency_subunit_label': 'Black Cacao Powder', + }, rate2016=12.0, rate2017=8.0) + + cls.bank_journal_1 = cls.company_data['default_journal_bank'] + cls.bank_journal_2 = cls.bank_journal_1.copy() + cls.bank_journal_3 = cls.bank_journal_2.copy() + cls.currency_1 = cls.company_data['currency'] + cls.currency_2 = cls.currency_data['currency'] + cls.currency_3 = cls.currency_data_2['currency'] + cls.currency_4 = cls.currency_data_3['currency'] + + def assertBankStatementLine(self, statement_line, expected_statement_line_vals, expected_move_line_vals): + self.assertRecordValues(statement_line, [expected_statement_line_vals]) + self.assertRecordValues(statement_line.line_ids.sorted('balance'), expected_move_line_vals) + + +@tagged('post_install', '-at_install') +class TestAccountBankStatement(TestAccountBankStatementCommon): + + # ------------------------------------------------------------------------- + # TESTS about the statement model. + # ------------------------------------------------------------------------- + + def test_starting_ending_balance_chaining(self): + # Create first statement on 2019-01-02. + bnk1 = self.env['account.bank.statement'].create({ + 'name': 'BNK1', + 'date': '2019-01-02', + 'journal_id': self.company_data['default_journal_bank'].id, + 'line_ids': [(0, 0, {'payment_ref': '/', 'amount': 100.0})], + }) + self.assertRecordValues(bnk1, [{ + 'balance_start': 0.0, + 'balance_end_real': 100.0, + 'balance_end': 100.0, + 'previous_statement_id': False, + }]) + + # Create a new statement after that one. + bnk2 = self.env['account.bank.statement'].create({ + 'name': 'BNK2', + 'date': '2019-01-10', + 'journal_id': self.company_data['default_journal_bank'].id, + 'line_ids': [(0, 0, {'payment_ref': '/', 'amount': 50.0})], + }) + self.assertRecordValues(bnk2, [{ + 'balance_start': 100.0, + 'balance_end_real': 150.0, + 'balance_end': 150.0, + 'previous_statement_id': bnk1.id, + }]) + + # Create new statement with given ending balance. + bnk3 = self.env['account.bank.statement'].create({ + 'name': 'BNK3', + 'date': '2019-01-15', + 'journal_id': self.company_data['default_journal_bank'].id, + 'line_ids': [(0, 0, {'payment_ref': '/', 'amount': 25.0})], + 'balance_end_real': 200.0, + }) + self.assertRecordValues(bnk3, [{ + 'balance_start': 150.0, + 'balance_end_real': 200.0, + 'balance_end': 175.0, + 'previous_statement_id': bnk2.id, + }]) + + # Create new statement with a date right after BNK1. + bnk4 = self.env['account.bank.statement'].create({ + 'name': 'BNK4', + 'date': '2019-01-03', + 'journal_id': self.company_data['default_journal_bank'].id, + 'line_ids': [(0, 0, {'payment_ref': '/', 'amount': 100.0})], + }) + self.assertRecordValues(bnk4, [{ + 'balance_start': 100.0, + 'balance_end_real': 200.0, + 'balance_end': 200.0, + 'previous_statement_id': bnk1.id, + }]) + + # BNK2/BNK3 should have changed their previous statements. + self.assertRecordValues(bnk2, [{ + 'balance_start': 200.0, + 'balance_end_real': 250.0, + 'balance_end': 250.0, + 'previous_statement_id': bnk4.id, + }]) + self.assertRecordValues(bnk3, [{ + 'balance_start': 250.0, + 'balance_end_real': 200.0, + 'balance_end': 275.0, + 'previous_statement_id': bnk2.id, + }]) + + # Correct the ending balance of BNK3. + bnk3.balance_end_real = 275 + + # Change date of BNK4 to be the last. + bnk4.date = '2019-01-20' + self.assertRecordValues(bnk1, [{ + 'balance_start': 0.0, + 'balance_end_real': 100.0, + 'balance_end': 100.0, + 'previous_statement_id': False, + }]) + self.assertRecordValues(bnk2, [{ + 'balance_start': 100.0, + 'balance_end_real': 150.0, + 'balance_end': 150.0, + 'previous_statement_id': bnk1.id, + }]) + self.assertRecordValues(bnk3, [{ + 'balance_start': 150.0, + 'balance_end_real': 175.0, + 'balance_end': 175.0, + 'previous_statement_id': bnk2.id, + }]) + self.assertRecordValues(bnk4, [{ + 'balance_start': 175.0, + 'balance_end_real': 200.0, + 'balance_end': 275.0, + 'previous_statement_id': bnk3.id, + }]) + + # Correct the ending balance of BNK4. + bnk4.balance_end_real = 275 + + # Move BNK3 to first position. + bnk3.date = '2019-01-01' + self.assertRecordValues(bnk3, [{ + 'balance_start': 0.0, + 'balance_end_real': 25.0, + 'balance_end': 25.0, + 'previous_statement_id': False, + }]) + self.assertRecordValues(bnk1, [{ + 'balance_start': 25.0, + 'balance_end_real': 125.0, + 'balance_end': 125.0, + 'previous_statement_id': bnk3.id, + }]) + self.assertRecordValues(bnk2, [{ + 'balance_start': 125.0, + 'balance_end_real': 175.0, + 'balance_end': 175.0, + 'previous_statement_id': bnk1.id, + }]) + self.assertRecordValues(bnk4, [{ + 'balance_start': 175.0, + 'balance_end_real': 275.0, + 'balance_end': 275.0, + 'previous_statement_id': bnk2.id, + }]) + + # Move BNK1 to the third position. + bnk1.date = '2019-01-11' + self.assertRecordValues(bnk3, [{ + 'balance_start': 0.0, + 'balance_end_real': 25.0, + 'balance_end': 25.0, + 'previous_statement_id': False, + }]) + self.assertRecordValues(bnk2, [{ + 'balance_start': 25.0, + 'balance_end_real': 75.0, + 'balance_end': 75.0, + 'previous_statement_id': bnk3.id, + }]) + self.assertRecordValues(bnk1, [{ + 'balance_start': 75.0, + 'balance_end_real': 175.0, + 'balance_end': 175.0, + 'previous_statement_id': bnk2.id, + }]) + self.assertRecordValues(bnk4, [{ + 'balance_start': 175.0, + 'balance_end_real': 275.0, + 'balance_end': 275.0, + 'previous_statement_id': bnk1.id, + }]) + + # Delete BNK3 and BNK1. + (bnk3 + bnk1).unlink() + self.assertRecordValues(bnk2, [{ + 'balance_start': 0.0, + 'balance_end_real': 50.0, + 'balance_end': 50.0, + 'previous_statement_id': False, + }]) + self.assertRecordValues(bnk4, [{ + 'balance_start': 50.0, + 'balance_end_real': 275.0, + 'balance_end': 150.0, + 'previous_statement_id': bnk2.id, + }]) + + def test_statements_different_journal(self): + # Create statements in bank journal. + bnk1_1 = self.env['account.bank.statement'].create({ + 'name': 'BNK1_1', + 'date': '2019-01-01', + 'journal_id': self.company_data['default_journal_bank'].id, + 'line_ids': [(0, 0, {'payment_ref': '/', 'amount': 100.0})], + 'balance_end_real': 100.0, + }) + bnk1_2 = self.env['account.bank.statement'].create({ + 'name': 'BNK1_2', + 'date': '2019-01-10', + 'journal_id': self.company_data['default_journal_bank'].id, + 'line_ids': [(0, 0, {'payment_ref': '/', 'amount': 50.0})], + }) + + # Create statements in cash journal. + bnk2_1 = self.env['account.bank.statement'].create({ + 'name': 'BNK2_1', + 'date': '2019-01-02', + 'journal_id': self.company_data['default_journal_cash'].id, + 'line_ids': [(0, 0, {'payment_ref': '/', 'amount': 20.0})], + 'balance_end_real': 20.0, + }) + bnk2_2 = self.env['account.bank.statement'].create({ + 'name': 'BNK2_2', + 'date': '2019-01-12', + 'journal_id': self.company_data['default_journal_cash'].id, + 'line_ids': [(0, 0, {'payment_ref': '/', 'amount': 10.0})], + }) + self.assertRecordValues(bnk1_1, [{ + 'balance_start': 0.0, + 'balance_end_real': 100.0, + 'balance_end': 100.0, + 'previous_statement_id': False, + }]) + self.assertRecordValues(bnk1_2, [{ + 'balance_start': 100.0, + 'balance_end_real': 150.0, + 'balance_end': 150.0, + 'previous_statement_id': bnk1_1.id, + }]) + self.assertRecordValues(bnk2_1, [{ + 'balance_start': 0.0, + 'balance_end_real': 20.0, + 'balance_end': 20.0, + 'previous_statement_id': False, + }]) + self.assertRecordValues(bnk2_2, [{ + 'balance_start': 20.0, + 'balance_end_real': 0.0, + 'balance_end': 30.0, + 'previous_statement_id': bnk2_1.id, + }]) + + def test_cash_statement_with_difference(self): + ''' A cash statement always creates an additional line to store the cash difference towards the ending balance. + ''' + statement = self.env['account.bank.statement'].create({ + 'name': 'test_statement', + 'date': '2019-01-01', + 'journal_id': self.company_data['default_journal_cash'].id, + 'balance_end_real': 100.0, + }) + + statement.button_post() + + self.assertRecordValues(statement.line_ids, [{ + 'amount': 100.0, + 'is_reconciled': True, + }]) + + +@tagged('post_install', '-at_install') +class TestAccountBankStatementLine(TestAccountBankStatementCommon): + + @classmethod + def setUpClass(cls, chart_template_ref=None): + super().setUpClass(chart_template_ref=chart_template_ref) + + cls.statement = cls.env['account.bank.statement'].create({ + 'name': 'test_statement', + 'date': '2019-01-01', + 'journal_id': cls.bank_journal_1.id, + 'line_ids': [ + (0, 0, { + 'date': '2019-01-01', + 'payment_ref': 'line_1', + 'partner_id': cls.partner_a.id, + 'foreign_currency_id': cls.currency_2.id, + 'amount': 1250.0, + 'amount_currency': 2500.0, + }), + ], + }) + cls.statement_line = cls.statement.line_ids + + cls.expected_st_line = { + 'date': fields.Date.from_string('2019-01-01'), + 'journal_id': cls.statement.journal_id.id, + 'payment_ref': 'line_1', + 'partner_id': cls.partner_a.id, + 'currency_id': cls.currency_1.id, + 'foreign_currency_id': cls.currency_2.id, + 'amount': 1250.0, + 'amount_currency': 2500.0, + 'is_reconciled': False, + } + + cls.expected_bank_line = { + 'name': cls.statement_line.payment_ref, + 'partner_id': cls.statement_line.partner_id.id, + 'currency_id': cls.currency_2.id, + 'account_id': cls.statement.journal_id.default_account_id.id, + 'debit': 1250.0, + 'credit': 0.0, + 'amount_currency': 2500.0, + } + + cls.expected_counterpart_line = { + 'name': cls.statement_line.payment_ref, + 'partner_id': cls.statement_line.partner_id.id, + 'currency_id': cls.currency_2.id, + 'account_id': cls.statement.journal_id.suspense_account_id.id, + 'debit': 0.0, + 'credit': 1250.0, + 'amount_currency': -2500.0, + } + + # ------------------------------------------------------------------------- + # TESTS about the statement line model. + # ------------------------------------------------------------------------- + + def _test_statement_line_edition( + self, + journal, + amount, amount_currency, + journal_currency, foreign_currency, + expected_liquidity_values, expected_counterpart_values): + ''' Test the edition of a statement line from itself or from its linked journal entry. + :param journal: The account.journal record that will be set on the statement line. + :param amount: The amount in journal's currency. + :param amount_currency: The amount in the foreign currency. + :param journal_currency: The journal's currency as a res.currency record. + :param foreign_currency: The foreign currency as a res.currency record. + :param expected_liquidity_values: The expected account.move.line values for the liquidity line. + :param expected_counterpart_values: The expected account.move.line values for the counterpart line. + ''' + if journal_currency: + journal.currency_id = journal_currency.id + + statement = self.env['account.bank.statement'].create({ + 'name': 'test_statement', + 'date': '2019-01-01', + 'journal_id': journal.id, + 'line_ids': [ + (0, 0, { + 'date': '2019-01-01', + 'payment_ref': 'line_1', + 'partner_id': self.partner_a.id, + 'foreign_currency_id': foreign_currency and foreign_currency.id, + 'amount': amount, + 'amount_currency': amount_currency, + }), + ], + }) + statement_line = statement.line_ids + + # ==== Test the statement line amounts are correct ==== + # If there is a bug in the compute/inverse methods, the amount/amount_currency could be + # incorrect directly after the creation of the statement line. + + self.assertRecordValues(statement_line, [{ + 'amount': amount, + 'amount_currency': amount_currency, + }]) + self.assertRecordValues(statement_line.move_id, [{ + 'partner_id': self.partner_a.id, + 'currency_id': (statement_line.foreign_currency_id or statement_line.currency_id).id, + }]) + + # ==== Test the edition of statement line amounts ==== + # The statement line must remain consistent with its account.move. + # To test the compute/inverse methods are correctly managing all currency setup, + # we check the edition of amounts in both directions statement line <-> journal entry. + + # Check initial state of the statement line. + liquidity_lines, suspense_lines, other_lines = statement_line._seek_for_lines() + self.assertRecordValues(liquidity_lines, [expected_liquidity_values]) + self.assertRecordValues(suspense_lines, [expected_counterpart_values]) + + # Check the account.move is still correct after editing the account.bank.statement.line. + statement_line.write({ + 'amount': statement_line.amount * 2, + 'amount_currency': statement_line.amount_currency * 2, + }) + self.assertRecordValues(statement_line, [{ + 'amount': amount * 2, + 'amount_currency': amount_currency * 2, + }]) + self.assertRecordValues(liquidity_lines, [{ + **expected_liquidity_values, + 'debit': expected_liquidity_values.get('debit', 0.0) * 2, + 'credit': expected_liquidity_values.get('credit', 0.0) * 2, + 'amount_currency': expected_liquidity_values.get('amount_currency', 0.0) * 2, + }]) + self.assertRecordValues(suspense_lines, [{ + 'debit': expected_counterpart_values.get('debit', 0.0) * 2, + 'credit': expected_counterpart_values.get('credit', 0.0) * 2, + 'amount_currency': expected_counterpart_values.get('amount_currency', 0.0) * 2, + }]) + + # Check the account.bank.statement.line is still correct after editing the account.move. + statement_line.move_id.write({'line_ids': [ + (1, liquidity_lines.id, { + 'debit': expected_liquidity_values.get('debit', 0.0), + 'credit': expected_liquidity_values.get('credit', 0.0), + 'amount_currency': expected_liquidity_values.get('amount_currency', 0.0), + }), + (1, suspense_lines.id, { + 'debit': expected_counterpart_values.get('debit', 0.0), + 'credit': expected_counterpart_values.get('credit', 0.0), + 'amount_currency': expected_counterpart_values.get('amount_currency', 0.0), + }), + ]}) + self.assertRecordValues(statement_line, [{ + 'amount': amount, + 'amount_currency': amount_currency, + }]) + + def _test_edition_customer_and_supplier_flows( + self, + amount, amount_currency, + journal_currency, foreign_currency, + expected_liquidity_values, expected_counterpart_values): + ''' Test '_test_statement_line_edition' using the customer (positive amounts) + & the supplier flow (negative amounts). + :param amount: The amount in journal's currency. + :param amount_currency: The amount in the foreign currency. + :param journal_currency: The journal's currency as a res.currency record. + :param foreign_currency: The foreign currency as a res.currency record. + :param expected_liquidity_values: The expected account.move.line values for the liquidity line. + :param expected_counterpart_values: The expected account.move.line values for the counterpart line. + ''' + + # Check the full process with positive amount (customer process). + self._test_statement_line_edition( + self.bank_journal_2, + amount, amount_currency, + journal_currency, foreign_currency, + expected_liquidity_values, + expected_counterpart_values, + ) + + # Check the full process with negative amount (supplier process). + self._test_statement_line_edition( + self.bank_journal_3, + -amount, -amount_currency, + journal_currency, foreign_currency, + { + **expected_liquidity_values, + 'debit': expected_liquidity_values.get('credit', 0.0), + 'credit': expected_liquidity_values.get('debit', 0.0), + 'amount_currency': -expected_liquidity_values.get('amount_currency', 0.0), + }, + { + **expected_counterpart_values, + 'debit': expected_counterpart_values.get('credit', 0.0), + 'credit': expected_counterpart_values.get('debit', 0.0), + 'amount_currency': -expected_counterpart_values.get('amount_currency', 0.0), + }, + ) + + def test_edition_journal_curr_2_statement_curr_3(self): + self._test_edition_customer_and_supplier_flows( + 80.0, 120.0, + self.currency_2, self.currency_3, + {'debit': 40.0, 'credit': 0.0, 'amount_currency': 80.0, 'currency_id': self.currency_2.id}, + {'debit': 0.0, 'credit': 40.0, 'amount_currency': -120.0, 'currency_id': self.currency_3.id}, + ) + + def test_edition_journal_curr_2_statement_curr_1(self): + self._test_edition_customer_and_supplier_flows( + 120.0, 80.0, + self.currency_2, self.currency_1, + {'debit': 80.0, 'credit': 0.0, 'amount_currency': 120.0, 'currency_id': self.currency_2.id}, + {'debit': 0.0, 'credit': 80.0, 'amount_currency': -80.0, 'currency_id': self.currency_1.id}, + ) + + def test_edition_journal_curr_1_statement_curr_2(self): + self._test_edition_customer_and_supplier_flows( + 80.0, 120.0, + self.currency_1, self.currency_2, + {'debit': 80.0, 'credit': 0.0, 'amount_currency': 120.0, 'currency_id': self.currency_2.id}, + {'debit': 0.0, 'credit': 80.0, 'amount_currency': -120.0, 'currency_id': self.currency_2.id}, + ) + + def test_edition_journal_curr_2_statement_false(self): + self._test_edition_customer_and_supplier_flows( + 80.0, 0.0, + self.currency_2, False, + {'debit': 40.0, 'credit': 0.0, 'amount_currency': 80.0, 'currency_id': self.currency_2.id}, + {'debit': 0.0, 'credit': 40.0, 'amount_currency': -80.0, 'currency_id': self.currency_2.id}, + ) + + def test_edition_journal_curr_1_statement_false(self): + self._test_edition_customer_and_supplier_flows( + 80.0, 0.0, + self.currency_1, False, + {'debit': 80.0, 'credit': 0.0, 'amount_currency': 80.0, 'currency_id': self.currency_1.id}, + {'debit': 0.0, 'credit': 80.0, 'amount_currency': -80.0, 'currency_id': self.currency_1.id}, + ) + + def test_zero_amount_journal_curr_1_statement_curr_2(self): + self.bank_journal_2.currency_id = self.currency_1 + + statement = self.env['account.bank.statement'].create({ + 'name': 'test_statement', + 'date': '2019-01-01', + 'journal_id': self.bank_journal_2.id, + 'line_ids': [ + (0, 0, { + 'date': '2019-01-01', + 'payment_ref': 'line_1', + 'partner_id': self.partner_a.id, + 'foreign_currency_id': self.currency_2.id, + 'amount': 0.0, + 'amount_currency': 10.0, + }), + ], + }) + + self.assertRecordValues(statement.line_ids.move_id.line_ids, [ + {'debit': 0.0, 'credit': 0.0, 'amount_currency': 10.0, 'currency_id': self.currency_2.id}, + {'debit': 0.0, 'credit': 0.0, 'amount_currency': -10.0, 'currency_id': self.currency_2.id}, + ]) + + def test_zero_amount_currency_journal_curr_1_statement_curr_2(self): + self.bank_journal_2.currency_id = self.currency_1 + + statement = self.env['account.bank.statement'].create({ + 'name': 'test_statement', + 'date': '2019-01-01', + 'journal_id': self.bank_journal_2.id, + 'line_ids': [ + (0, 0, { + 'date': '2019-01-01', + 'payment_ref': 'line_1', + 'partner_id': self.partner_a.id, + 'foreign_currency_id': self.currency_2.id, + 'amount': 10.0, + 'amount_currency': 0.0, + }), + ], + }) + + self.assertRecordValues(statement.line_ids.move_id.line_ids, [ + {'debit': 10.0, 'credit': 0.0, 'amount_currency': 0.0, 'currency_id': self.currency_2.id}, + {'debit': 0.0, 'credit': 10.0, 'amount_currency': 0.0, 'currency_id': self.currency_2.id}, + ]) + + def test_zero_amount_journal_curr_2_statement_curr_1(self): + self.bank_journal_2.currency_id = self.currency_2 + + statement = self.env['account.bank.statement'].create({ + 'name': 'test_statement', + 'date': '2019-01-01', + 'journal_id': self.bank_journal_2.id, + 'line_ids': [ + (0, 0, { + 'date': '2019-01-01', + 'payment_ref': 'line_1', + 'partner_id': self.partner_a.id, + 'foreign_currency_id': self.currency_1.id, + 'amount': 0.0, + 'amount_currency': 10.0, + }), + ], + }) + + self.assertRecordValues(statement.line_ids.move_id.line_ids, [ + {'debit': 10.0, 'credit': 0.0, 'amount_currency': 0.0, 'currency_id': self.currency_2.id}, + {'debit': 0.0, 'credit': 10.0, 'amount_currency': -10.0, 'currency_id': self.currency_1.id}, + ]) + + def test_zero_amount_currency_journal_curr_2_statement_curr_1(self): + self.bank_journal_2.currency_id = self.currency_2 + + statement = self.env['account.bank.statement'].create({ + 'name': 'test_statement', + 'date': '2019-01-01', + 'journal_id': self.bank_journal_2.id, + 'line_ids': [ + (0, 0, { + 'date': '2019-01-01', + 'payment_ref': 'line_1', + 'partner_id': self.partner_a.id, + 'foreign_currency_id': self.currency_1.id, + 'amount': 10.0, + 'amount_currency': 0.0, + }), + ], + }) + + self.assertRecordValues(statement.line_ids.move_id.line_ids, [ + {'debit': 0.0, 'credit': 0.0, 'amount_currency': 10.0, 'currency_id': self.currency_2.id}, + {'debit': 0.0, 'credit': 0.0, 'amount_currency': 0.0, 'currency_id': self.currency_1.id}, + ]) + + def test_zero_amount_journal_curr_2_statement_curr_3(self): + self.bank_journal_2.currency_id = self.currency_2 + + statement = self.env['account.bank.statement'].create({ + 'name': 'test_statement', + 'date': '2019-01-01', + 'journal_id': self.bank_journal_2.id, + 'line_ids': [ + (0, 0, { + 'date': '2019-01-01', + 'payment_ref': 'line_1', + 'partner_id': self.partner_a.id, + 'foreign_currency_id': self.currency_3.id, + 'amount': 0.0, + 'amount_currency': 10.0, + }), + ], + }) + + self.assertRecordValues(statement.line_ids.move_id.line_ids, [ + {'debit': 0.0, 'credit': 0.0, 'amount_currency': 0.0, 'currency_id': self.currency_2.id}, + {'debit': 0.0, 'credit': 0.0, 'amount_currency': -10.0, 'currency_id': self.currency_3.id}, + ]) + + def test_zero_amount_currency_journal_curr_2_statement_curr_3(self): + self.bank_journal_2.currency_id = self.currency_2 + + statement = self.env['account.bank.statement'].create({ + 'name': 'test_statement', + 'date': '2019-01-01', + 'journal_id': self.bank_journal_2.id, + 'line_ids': [ + (0, 0, { + 'date': '2019-01-01', + 'payment_ref': 'line_1', + 'partner_id': self.partner_a.id, + 'foreign_currency_id': self.currency_3.id, + 'amount': 10.0, + 'amount_currency': 0.0, + }), + ], + }) + + self.assertRecordValues(statement.line_ids.move_id.line_ids, [ + {'debit': 5.0, 'credit': 0.0, 'amount_currency': 10.0, 'currency_id': self.currency_2.id}, + {'debit': 0.0, 'credit': 5.0, 'amount_currency': 0.0, 'currency_id': self.currency_3.id}, + ]) + + def test_constraints(self): + def assertStatementLineConstraint(statement_vals, statement_line_vals): + with self.assertRaises(Exception), self.cr.savepoint(): + self.env['account.bank.statement'].create({ + **statement_vals, + 'line_ids': [(0, 0, statement_line_vals)], + }) + + statement_vals = { + 'name': 'test_statement', + 'date': '2019-01-01', + 'journal_id': self.bank_journal_2.id, + } + statement_line_vals = { + 'date': '2019-01-01', + 'payment_ref': 'line_1', + 'partner_id': self.partner_a.id, + 'foreign_currency_id': False, + 'amount': 10.0, + 'amount_currency': 0.0, + } + + # ==== Test constraints at creation ==== + + # Foreign currency must not be the same as the journal one. + assertStatementLineConstraint(statement_vals, { + **statement_line_vals, + 'foreign_currency_id': self.currency_1.id, + }) + + # Can't have a stand alone amount in foreign currency without foreign currency set. + assertStatementLineConstraint(statement_vals, { + **statement_line_vals, + 'amount_currency': 10.0, + }) + + # ==== Test constraints at edition ==== + + statement = self.env['account.bank.statement'].create({ + **statement_vals, + 'line_ids': [(0, 0, statement_line_vals)], + }) + st_line = statement.line_ids + + # You can't messed up the journal entry by adding another liquidity line. + addition_lines_to_create = [ + { + 'debit': 1.0, + 'credit': 0, + 'account_id': self.bank_journal_2.default_account_id.id, + 'move_id': st_line.move_id.id, + }, + { + 'debit': 0, + 'credit': 1.0, + 'account_id': self.company_data['default_account_revenue'].id, + 'move_id': st_line.move_id.id, + }, + ] + with self.assertRaises(UserError), self.cr.savepoint(): + st_line.move_id.write({ + 'line_ids': [(0, 0, vals) for vals in addition_lines_to_create] + }) + + with self.assertRaises(UserError), self.cr.savepoint(): + st_line.line_ids.create(addition_lines_to_create) + + # You can't set the journal entry in an unconsistent state. + with self.assertRaises(UserError), self.cr.savepoint(): + st_line.move_id.action_post() + + def test_statement_line_move_onchange_1(self): + ''' Test the consistency between the account.bank.statement.line and the generated account.move.lines + using the form view emulator. + ''' + + # Check the initial state of the statement line. + self.assertBankStatementLine(self.statement_line, self.expected_st_line, [self.expected_counterpart_line, self.expected_bank_line]) + + # Inverse the amount + change them. + with Form(self.statement) as statement_form: + with statement_form.line_ids.edit(0) as st_line_form: + st_line_form.amount = -2000.0 + st_line_form.amount_currency = -4000.0 + st_line_form.foreign_currency_id = self.currency_3 + + self.assertBankStatementLine(self.statement_line, { + **self.expected_st_line, + 'amount': -2000.0, + 'amount_currency': -4000.0, + 'foreign_currency_id': self.currency_3.id, + }, [ + { + **self.expected_bank_line, + 'debit': 0.0, + 'credit': 2000.0, + 'amount_currency': -4000.0, + 'currency_id': self.currency_3.id, + }, + { + **self.expected_counterpart_line, + 'debit': 2000.0, + 'credit': 0.0, + 'amount_currency': 4000.0, + 'currency_id': self.currency_3.id, + }, + ]) + + # Check changing the label and the partner. + with Form(self.statement) as statement_form: + with statement_form.line_ids.edit(0) as st_line_form: + st_line_form.payment_ref = 'line_1 (bis)' + st_line_form.partner_id = self.partner_b + + self.assertBankStatementLine(self.statement_line, { + **self.expected_st_line, + 'payment_ref': self.statement_line.payment_ref, + 'partner_id': self.statement_line.partner_id.id, + 'amount': -2000.0, + 'amount_currency': -4000.0, + 'foreign_currency_id': self.currency_3.id, + }, [ + { + **self.expected_bank_line, + 'name': self.statement_line.payment_ref, + 'partner_id': self.statement_line.partner_id.id, + 'debit': 0.0, + 'credit': 2000.0, + 'amount_currency': -4000.0, + 'currency_id': self.currency_3.id, + }, + { + **self.expected_counterpart_line, + 'name': self.statement_line.payment_ref, + 'partner_id': self.statement_line.partner_id.id, + 'debit': 2000.0, + 'credit': 0.0, + 'amount_currency': 4000.0, + 'currency_id': self.currency_3.id, + }, + ]) + + # ------------------------------------------------------------------------- + # TESTS about reconciliation: + # - Test '_prepare_counterpart_move_line_vals': one test for each case. + # - Test 'reconcile': 3 cases: + # - Open-balance in debit. + # - Open-balance in credit. + # - No open-balance. + # - Test 'button_undo_reconciliation'. + # ------------------------------------------------------------------------- + + def _test_statement_line_reconciliation( + self, + journal, + amount, amount_currency, counterpart_amount, + journal_currency, foreign_currency, counterpart_currency, + expected_liquidity_values, expected_counterpart_values): + ''' Test the reconciliation of a statement line. + :param journal: The account.journal record that will be set on the statement line. + :param amount: The amount in journal's currency. + :param amount_currency: The amount in the foreign currency. + :param counterpart_amount: The amount of the invoice to reconcile. + :param journal_currency: The journal's currency as a res.currency record. + :param foreign_currency: The foreign currency as a res.currency record. + :param counterpart_currency: The invoice currency as a res.currency record. + :param expected_liquidity_values: The expected account.move.line values for the liquidity line. + :param expected_counterpart_values: The expected account.move.line values for the counterpart line. + ''' + if journal_currency: + journal.currency_id = journal_currency.id + + statement = self.env['account.bank.statement'].create({ + 'name': 'test_statement', + 'date': '2019-01-01', + 'journal_id': journal.id, + 'line_ids': [ + (0, 0, { + 'date': '2019-01-01', + 'payment_ref': 'line_1', + 'partner_id': self.partner_a.id, + 'foreign_currency_id': foreign_currency and foreign_currency.id, + 'amount': amount, + 'amount_currency': amount_currency, + }), + ], + }) + statement_line = statement.line_ids + + # - There is 3 flows to check: + # * The invoice will fully reconcile the statement line. + # * The invoice will partially reconcile the statement line and leads to an open balance in debit. + # * The invoice will partially reconcile the statement line and leads to an open balance in credit. + # - The dates are different to be sure the reconciliation will preserve the conversion rate bank side. + move_type = 'out_invoice' if counterpart_amount < 0.0 else 'in_invoice' + + test_invoices = self.env['account.move'].create([ + { + 'move_type': move_type, + 'invoice_date': fields.Date.from_string('2016-01-01'), + 'date': fields.Date.from_string('2016-01-01'), + 'partner_id': self.partner_a.id, + 'currency_id': counterpart_currency.id, + 'invoice_line_ids': [ + (0, None, { + 'name': 'counterpart line, same amount', + 'account_id': self.company_data['default_account_revenue'].id, + 'quantity': 1, + 'price_unit': abs(counterpart_amount), + }), + ], + }, + { + 'move_type': move_type, + 'invoice_date': fields.Date.from_string('2016-01-01'), + 'date': fields.Date.from_string('2016-01-01'), + 'partner_id': self.partner_a.id, + 'currency_id': counterpart_currency.id, + 'invoice_line_ids': [ + (0, None, { + 'name': 'counterpart line, lower amount', + 'account_id': self.company_data['default_account_revenue'].id, + 'quantity': 1, + 'price_unit': abs(counterpart_amount / 2), + }), + ], + }, + { + 'move_type': move_type, + 'invoice_date': fields.Date.from_string('2016-01-01'), + 'date': fields.Date.from_string('2016-01-01'), + 'partner_id': self.partner_a.id, + 'currency_id': counterpart_currency.id, + 'invoice_line_ids': [ + (0, None, { + 'name': 'counterpart line, bigger amount', + 'account_id': self.company_data['default_account_revenue'].id, + 'quantity': 1, + 'price_unit': abs(counterpart_amount * 2), + }), + ], + }, + ]) + test_invoices.action_post() + statement.button_post() + counterpart_lines = test_invoices.mapped('line_ids').filtered(lambda line: line.account_internal_type in ('receivable', 'payable')) + + # Check the full reconciliation. + statement_line.reconcile([{'id': counterpart_lines[0].id}]) + liquidity_lines, suspense_lines, other_lines = statement_line._seek_for_lines() + self.assertRecordValues(liquidity_lines, [expected_liquidity_values]) + self.assertRecordValues(other_lines, [expected_counterpart_values]) + + # Check the reconciliation with partial lower amount. + statement_line.button_undo_reconciliation() + statement_line.reconcile([{'id': counterpart_lines[1].id}]) + liquidity_lines, suspense_lines, other_lines = statement_line._seek_for_lines() + self.assertRecordValues(liquidity_lines, [expected_liquidity_values]) + self.assertRecordValues(other_lines.sorted('balance', reverse=amount < 0.0), [ + { + **expected_counterpart_values, + 'debit': expected_counterpart_values.get('debit', 0.0) / 2, + 'credit': expected_counterpart_values.get('credit', 0.0) / 2, + 'amount_currency': expected_counterpart_values.get('amount_currency', 0.0) / 2, + }, + { + 'debit': expected_counterpart_values.get('debit', 0.0) / 2, + 'credit': expected_counterpart_values.get('credit', 0.0) / 2, + 'amount_currency': expected_counterpart_values.get('amount_currency', 0.0) / 2, + 'currency_id': expected_counterpart_values.get('currency_id'), + }, + ]) + + # Check the reconciliation with partial higher amount. + statement_line.button_undo_reconciliation() + statement_line.reconcile([{'id': counterpart_lines[2].id}]) + liquidity_lines, suspense_lines, other_lines = statement_line._seek_for_lines() + self.assertRecordValues(liquidity_lines, [expected_liquidity_values]) + self.assertRecordValues(other_lines.sorted('balance', reverse=amount < 0.0), [ + { + **expected_counterpart_values, + 'debit': expected_counterpart_values.get('debit', 0.0) * 2, + 'credit': expected_counterpart_values.get('credit', 0.0) * 2, + 'amount_currency': expected_counterpart_values.get('amount_currency', 0.0) * 2, + }, + { + 'debit': expected_counterpart_values.get('credit', 0.0), + 'credit': expected_counterpart_values.get('debit', 0.0), + 'amount_currency': -expected_counterpart_values.get('amount_currency', 0.0), + 'currency_id': expected_counterpart_values.get('currency_id'), + }, + ]) + + # Make sure the statement line is still correct. + self.assertRecordValues(statement_line, [{ + 'amount': amount, + 'amount_currency': amount_currency, + }]) + + def _test_reconciliation_customer_and_supplier_flows( + self, + amount, amount_currency, counterpart_amount, + journal_currency, foreign_currency, counterpart_currency, + expected_liquidity_values, expected_counterpart_values): + ''' Test '_test_statement_line_reconciliation' using the customer (positive amounts) + & the supplier flow (negative amounts). + :param amount: The amount in journal's currency. + :param amount_currency: The amount in the foreign currency. + :param counterpart_amount: The amount of the invoice to reconcile. + :param journal_currency: The journal's currency as a res.currency record. + :param foreign_currency: The foreign currency as a res.currency record. + :param counterpart_currency: The invoice currency as a res.currency record. + :param expected_liquidity_values: The expected account.move.line values for the liquidity line. + :param expected_counterpart_values: The expected account.move.line values for the counterpart line. + ''' + + # Check the full process with positive amount (customer process). + self._test_statement_line_reconciliation( + self.bank_journal_2, + amount, amount_currency, counterpart_amount, + journal_currency, foreign_currency, counterpart_currency, + expected_liquidity_values, + expected_counterpart_values, + ) + + # Check the full process with negative amount (supplier process). + self._test_statement_line_reconciliation( + self.bank_journal_3, + -amount, -amount_currency, -counterpart_amount, + journal_currency, foreign_currency, counterpart_currency, + { + **expected_liquidity_values, + 'debit': expected_liquidity_values.get('credit', 0.0), + 'credit': expected_liquidity_values.get('debit', 0.0), + 'amount_currency': -expected_liquidity_values.get('amount_currency', 0.0), + }, + { + **expected_counterpart_values, + 'debit': expected_counterpart_values.get('credit', 0.0), + 'credit': expected_counterpart_values.get('debit', 0.0), + 'amount_currency': -expected_counterpart_values.get('amount_currency', 0.0), + }, + ) + + def test_reconciliation_journal_curr_2_statement_curr_3_counterpart_curr_3(self): + self._test_reconciliation_customer_and_supplier_flows( + 80.0, 120.0, -120.0, + self.currency_2, self.currency_3, self.currency_3, + {'debit': 40.0, 'credit': 0.0, 'amount_currency': 80.0, 'currency_id': self.currency_2.id}, + {'debit': 0.0, 'credit': 40.0, 'amount_currency': -120.0, 'currency_id': self.currency_3.id}, + ) + + def test_reconciliation_journal_curr_2_statement_curr_1_counterpart_curr_2(self): + self._test_reconciliation_customer_and_supplier_flows( + 120.0, 80.0, -120.0, + self.currency_2, self.currency_1, self.currency_2, + {'debit': 80.0, 'credit': 0.0, 'amount_currency': 120.0, 'currency_id': self.currency_2.id}, + {'debit': 0.0, 'credit': 80.0, 'amount_currency': -80.0, 'currency_id': self.currency_1.id}, + ) + + def test_reconciliation_journal_curr_2_statement_curr_3_counterpart_curr_2(self): + self._test_reconciliation_customer_and_supplier_flows( + 80.0, 120.0, -80.0, + self.currency_2, self.currency_3, self.currency_2, + {'debit': 40.0, 'credit': 0.0, 'amount_currency': 80.0, 'currency_id': self.currency_2.id}, + {'debit': 0.0, 'credit': 40.0, 'amount_currency': -120.0, 'currency_id': self.currency_3.id}, + ) + + def test_reconciliation_journal_curr_2_statement_curr_3_counterpart_curr_4(self): + self._test_reconciliation_customer_and_supplier_flows( + 80.0, 120.0, -480.0, + self.currency_2, self.currency_3, self.currency_4, + {'debit': 40.0, 'credit': 0.0, 'amount_currency': 80.0, 'currency_id': self.currency_2.id}, + {'debit': 0.0, 'credit': 40.0, 'amount_currency': -120.0, 'currency_id': self.currency_3.id}, + ) + + def test_reconciliation_journal_curr_1_statement_curr_2_counterpart_curr_2(self): + self._test_reconciliation_customer_and_supplier_flows( + 80.0, 120.0, -120.0, + self.currency_1, self.currency_2, self.currency_2, + {'debit': 80.0, 'credit': 0.0, 'amount_currency': 120.0, 'currency_id': self.currency_2.id}, + {'debit': 0.0, 'credit': 80.0, 'amount_currency': -120.0, 'currency_id': self.currency_2.id}, + ) + + def test_reconciliation_journal_curr_1_statement_curr_2_counterpart_curr_3(self): + self._test_reconciliation_customer_and_supplier_flows( + 80.0, 120.0, -480.0, + self.currency_1, self.currency_2, self.currency_3, + {'debit': 80.0, 'credit': 0.0, 'amount_currency': 120.0, 'currency_id': self.currency_2.id}, + {'debit': 0.0, 'credit': 80.0, 'amount_currency': -120.0, 'currency_id': self.currency_2.id}, + ) + + def test_reconciliation_journal_curr_2_statement_false_counterpart_curr_2(self): + self._test_reconciliation_customer_and_supplier_flows( + 80.0, 0.0, -80.0, + self.currency_2, False, self.currency_2, + {'debit': 40.0, 'credit': 0.0, 'amount_currency': 80.0, 'currency_id': self.currency_2.id}, + {'debit': 0.0, 'credit': 40.0, 'amount_currency': -80.0, 'currency_id': self.currency_2.id}, + ) + + def test_reconciliation_journal_curr_2_statement_false_counterpart_curr_3(self): + self._test_reconciliation_customer_and_supplier_flows( + 80.0, 0.0, -240.0, + self.currency_2, False, self.currency_3, + {'debit': 40.0, 'credit': 0.0, 'amount_currency': 80.0, 'currency_id': self.currency_2.id}, + {'debit': 0.0, 'credit': 40.0, 'amount_currency': -80.0, 'currency_id': self.currency_2.id}, + ) + + def test_reconciliation_journal_curr_1_statement_false_counterpart_curr_3(self): + self._test_reconciliation_customer_and_supplier_flows( + 80.0, 0.0, -480.0, + self.currency_1, False, self.currency_3, + {'debit': 80.0, 'credit': 0.0, 'amount_currency': 80.0, 'currency_id': self.currency_1.id}, + {'debit': 0.0, 'credit': 80.0, 'amount_currency': -80.0, 'currency_id': self.currency_1.id}, + ) + + def test_reconciliation_journal_curr_2_statement_curr_1_counterpart_curr_1(self): + self._test_reconciliation_customer_and_supplier_flows( + 120.0, 80.0, -80.0, + self.currency_2, self.currency_1, self.currency_1, + {'debit': 80.0, 'credit': 0.0, 'amount_currency': 120.0, 'currency_id': self.currency_2.id}, + {'debit': 0.0, 'credit': 80.0, 'amount_currency': -80.0, 'currency_id': self.currency_1.id}, + ) + + def test_reconciliation_journal_curr_2_statement_curr_3_counterpart_curr_1(self): + self._test_reconciliation_customer_and_supplier_flows( + 80.0, 120.0, -40.0, + self.currency_2, self.currency_3, self.currency_1, + {'debit': 40.0, 'credit': 0.0, 'amount_currency': 80.0, 'currency_id': self.currency_2.id}, + {'debit': 0.0, 'credit': 40.0, 'amount_currency': -120.0, 'currency_id': self.currency_3.id}, + ) + + def test_reconciliation_journal_curr_1_statement_curr_2_counterpart_curr_1(self): + self._test_reconciliation_customer_and_supplier_flows( + 80.0, 120.0, -80.0, + self.currency_1, self.currency_2, self.currency_1, + {'debit': 80.0, 'credit': 0.0, 'amount_currency': 120.0, 'currency_id': self.currency_2.id}, + {'debit': 0.0, 'credit': 80.0, 'amount_currency': -120.0, 'currency_id': self.currency_2.id}, + ) + + def test_reconciliation_journal_curr_2_statement_false_counterpart_curr_1(self): + self._test_reconciliation_customer_and_supplier_flows( + 80.0, 0.0, -40.0, + self.currency_2, False, self.currency_1, + {'debit': 40.0, 'credit': 0.0, 'amount_currency': 80.0, 'currency_id': self.currency_2.id}, + {'debit': 0.0, 'credit': 40.0, 'amount_currency': -80.0, 'currency_id': self.currency_2.id}, + ) + + def test_reconciliation_journal_curr_1_statement_false_counterpart_curr_1(self): + self._test_reconciliation_customer_and_supplier_flows( + 80.0, 0.0, -80.0, + self.currency_1, False, self.currency_1, + {'debit': 80.0, 'credit': 0.0, 'amount_currency': 80.0, 'currency_id': self.currency_1.id}, + {'debit': 0.0, 'credit': 80.0, 'amount_currency': -80.0, 'currency_id': self.currency_1.id}, + ) + + def test_reconciliation_statement_line_state(self): + ''' Test the reconciliation on the bank statement line with a foreign currency on the journal: + - Ensure the statement line is_reconciled field is well computed. + - Ensure the reconciliation is working well when dealing with a foreign currency at different dates. + - Ensure the reconciliation can be undo. + - Ensure the reconciliation is still possible with to_check. + ''' + self.statement.button_post() + + receivable_acc_1 = self.company_data['default_account_receivable'] + receivable_acc_2 = self.copy_account(self.company_data['default_account_receivable']) + payment_account = self.bank_journal_1.payment_debit_account_id + random_acc_1 = self.company_data['default_account_revenue'] + random_acc_2 = self.copy_account(self.company_data['default_account_revenue']) + test_move = self.env['account.move'].create({ + 'move_type': 'entry', + 'date': fields.Date.from_string('2016-01-01'), + 'line_ids': [ + (0, None, { + 'name': 'counterpart of the whole move', + 'account_id': random_acc_1.id, + 'debit': 0.0, + 'credit': 1030.0, + }), + (0, None, { + 'name': 'test line 1 - receivable account', + 'account_id': receivable_acc_1.id, + 'currency_id': self.currency_2.id, + 'debit': 500.0, + 'credit': 0.0, + 'amount_currency': 1500.0, + }), + (0, None, { + 'name': 'test line 2 - another receivable account', + 'account_id': receivable_acc_2.id, + 'currency_id': self.currency_2.id, + 'debit': 500.0, + 'credit': 0.0, + 'amount_currency': 1500.0, + }), + (0, None, { + 'name': 'test line 3 - payment transfer account', + 'account_id': payment_account.id, + 'currency_id': self.currency_2.id, + 'debit': 30.0, + 'credit': 0.0, + 'amount_currency': 90.0, + }), + ] + }) + test_move.action_post() + + test_line_1 = test_move.line_ids.filtered(lambda line: line.account_id == receivable_acc_1) + test_line_2 = test_move.line_ids.filtered(lambda line: line.account_id == receivable_acc_2) + test_line_3 = test_move.line_ids.filtered(lambda line: line.account_id == payment_account) + self.statement_line.reconcile([ + # test line 1 + # Will reconcile 300.0 in balance, 600.0 in amount_currency. + {'id': test_line_1.id, 'balance': -600.0}, + # test line 2 + # Will reconcile 250.0 in balance, 500.0 in amount_currency. + {'id': test_line_2.id, 'balance': -500.0}, + # test line 3 + # Will reconcile 30.0 in balance, 90.0 in amount_currency. + {'id': test_line_3.id}, + # test line 4 + # Will reconcile 50.0 in balance, 100.0 in amount_currency. + {'name': 'whatever', 'account_id': random_acc_1.id, 'balance': -100.0}, + ]) + + self.assertBankStatementLine(self.statement_line, { + **self.expected_st_line, + 'is_reconciled': True, + }, [ + { + 'name': '%s: Open Balance' % self.statement_line.payment_ref, + 'partner_id': self.statement_line.partner_id.id, + 'currency_id': self.currency_2.id, + 'account_id': receivable_acc_1.id, # This account is retrieved on the partner. + 'debit': 0.0, + 'credit': 605.0, + 'amount_currency': -1210.0, + 'amount_residual': -605.0, + 'amount_residual_currency': -1210.0, + }, + { + 'name': test_line_1.name, + 'partner_id': self.statement_line.partner_id.id, + 'currency_id': self.currency_2.id, + 'account_id': receivable_acc_1.id, + 'debit': 0.0, + 'credit': 300.0, + 'amount_currency': -600.0, + 'amount_residual': 0.0, + 'amount_residual_currency': 0.0, + }, + { + 'name': test_line_2.name, + 'partner_id': self.statement_line.partner_id.id, + 'currency_id': self.currency_2.id, + 'account_id': receivable_acc_2.id, + 'debit': 0.0, + 'credit': 250.0, + 'amount_currency': -500.0, + 'amount_residual': 0.0, + 'amount_residual_currency': 0.0, + }, + { + 'name': 'whatever', + 'partner_id': self.statement_line.partner_id.id, + 'currency_id': self.currency_2.id, + 'account_id': random_acc_1.id, + 'debit': 0.0, + 'credit': 50.0, + 'amount_currency': -100.0, + 'amount_residual': 0.0, + 'amount_residual_currency': 0.0, + }, + { + 'name': test_line_3.name, + 'partner_id': self.statement_line.partner_id.id, + 'currency_id': self.currency_2.id, + 'account_id': test_line_3.account_id.id, + 'debit': 0.0, + 'credit': 45.0, + 'amount_currency': -90.0, + 'amount_residual': 0.0, + 'amount_residual_currency': 0.0, + }, + { + **self.expected_bank_line, + 'amount_residual': 1250.0, + 'amount_residual_currency': 2500.0, + }, + ]) + + # Undo the reconciliation to return to the initial state. + self.statement_line.button_undo_reconciliation() + self.assertBankStatementLine(self.statement_line, self.expected_st_line, [self.expected_counterpart_line, self.expected_bank_line]) + + # Modify the counterpart line with to_check enabled. + self.statement_line.reconcile([ + {'name': 'whatever', 'account_id': random_acc_1.id, 'balance': -100.0}, + ], to_check=True) + + self.assertBankStatementLine(self.statement_line, { + **self.expected_st_line, + 'is_reconciled': True, + }, [ + { + 'name': '%s: Open Balance' % self.statement_line.payment_ref, + 'partner_id': self.statement_line.partner_id.id, + 'currency_id': self.currency_2.id, + 'account_id': receivable_acc_1.id, # This account is retrieved on the partner. + 'debit': 0.0, + 'credit': 1200.0, + 'amount_currency': -2400.0, + 'amount_residual': -1200.0, + 'amount_residual_currency': -2400.0, + }, + { + 'name': 'whatever', + 'partner_id': self.statement_line.partner_id.id, + 'currency_id': self.currency_2.id, + 'account_id': random_acc_1.id, + 'debit': 0.0, + 'credit': 50.0, + 'amount_currency': -100.0, + 'amount_residual': 0.0, + 'amount_residual_currency': 0.0, + }, + { + **self.expected_bank_line, + 'amount_residual': 1250.0, + 'amount_residual_currency': 2500.0, + }, + ]) + + # Modify the counterpart line. Should be allowed by the to_check enabled. + self.statement_line.reconcile([ + {'name': 'whatever again', 'account_id': random_acc_2.id, 'balance': -500.0}, + ]) + + self.assertBankStatementLine(self.statement_line, { + **self.expected_st_line, + 'is_reconciled': True, + }, [ + { + 'name': '%s: Open Balance' % self.statement_line.payment_ref, + 'partner_id': self.statement_line.partner_id.id, + 'currency_id': self.currency_2.id, + 'account_id': receivable_acc_1.id, # This account is retrieved on the partner. + 'debit': 0.0, + 'credit': 1000.0, + 'amount_currency': -2000.0, + 'amount_residual': -1000.0, + 'amount_residual_currency': -2000.0, + }, + { + 'name': 'whatever again', + 'partner_id': self.statement_line.partner_id.id, + 'currency_id': self.currency_2.id, + 'account_id': random_acc_2.id, + 'debit': 0.0, + 'credit': 250.0, + 'amount_currency': -500.0, + 'amount_residual': 0.0, + 'amount_residual_currency': 0.0, + }, + { + **self.expected_bank_line, + 'amount_residual': 1250.0, + 'amount_residual_currency': 2500.0, + }, + ]) + + # The statement line is no longer in the 'to_check' mode. + # Reconciling again should raise an error. + with self.assertRaises(UserError), self.cr.savepoint(): + self.statement_line.reconcile([ + {'name': 'whatever', 'account_id': random_acc_1.id, 'balance': -100.0}, + ]) + + def test_reconciliation_statement_line_with_generated_payments(self): + self.statement.button_post() + + receivable_account = self.company_data['default_account_receivable'] + payment_account = self.bank_journal_1.payment_debit_account_id + random_account = self.company_data['default_account_revenue'] + test_move = self.env['account.move'].create({ + 'move_type': 'entry', + 'date': fields.Date.from_string('2016-01-01'), + 'line_ids': [ + (0, None, { + 'name': 'counterpart of the whole move', + 'account_id': random_account.id, + 'debit': 0.0, + 'credit': 1000.0, + }), + (0, None, { + 'name': 'test line 1', + 'account_id': receivable_account.id, + 'debit': 100.0, + 'credit': 0.0, + }), + (0, None, { + 'name': 'test line 2', + 'account_id': receivable_account.id, + 'currency_id': self.currency_2.id, + 'debit': 900.0, + 'credit': 0.0, + 'amount_currency': 1500.0, + }), + ] + }) + test_move.action_post() + + test_line_1 = test_move.line_ids.filtered(lambda line: line.name == 'test line 1') + test_line_2 = test_move.line_ids.filtered(lambda line: line.name == 'test line 2') + + statement_line = self.statement_line + StatementLine_prepare_reconciliation = type(statement_line)._prepare_reconciliation + + def _prepare_reconciliation(self, lines_vals_list, create_payment_for_invoice=False): + if self == statement_line: + create_payment_for_invoice = True + return StatementLine_prepare_reconciliation(self, lines_vals_list, create_payment_for_invoice) + + with patch.object(type(statement_line), '_prepare_reconciliation', _prepare_reconciliation): + self.statement_line.reconcile([ + {'id': test_line_1.id, 'balance': -50.0}, + {'id': test_line_2.id}, + ]) + + self.assertBankStatementLine(self.statement_line, { + **self.expected_st_line, + 'is_reconciled': True, + }, [ + { + 'name': 'test line 2', + 'account_id': payment_account.id, + 'currency_id': self.currency_2.id, + 'debit': 0.0, + 'credit': 750.0, + 'amount_currency': -1500.0, + }, + { + 'name': 'line_1: Open Balance', + 'account_id': receivable_account.id, + 'currency_id': self.currency_2.id, + 'debit': 0.0, + 'credit': 450.0, + 'amount_currency': -900.0, + }, + { + 'name': 'test line 1', + 'account_id': payment_account.id, + 'currency_id': self.currency_2.id, + 'debit': 0.0, + 'credit': 50.0, + 'amount_currency': -100.0, + }, + self.expected_bank_line, + ]) + + # Check generated payments. + self.assertRecordValues(test_line_1.matched_credit_ids.credit_move_id.payment_id.line_ids.sorted('balance'), [ + { + 'partner_id': self.statement_line.partner_id.id, + 'currency_id': self.currency_2.id, + 'account_id': test_line_1.account_id.id, + 'debit': 0.0, + 'credit': 50.0, + 'amount_currency': -100.0, + 'amount_residual': 0.0, + 'amount_residual_currency': 50.0, + }, + { + 'partner_id': self.statement_line.partner_id.id, + 'currency_id': self.currency_2.id, + 'account_id': payment_account.id, + 'debit': 50.0, + 'credit': 0.0, + 'amount_currency': 100.0, + 'amount_residual': 0.0, + 'amount_residual_currency': 0.0, + }, + ]) + self.assertRecordValues(test_line_2.matched_credit_ids.credit_move_id.payment_id.line_ids.sorted('balance'), [ + { + 'partner_id': self.statement_line.partner_id.id, + 'currency_id': self.currency_2.id, + 'account_id': test_line_2.account_id.id, + 'debit': 0.0, + 'credit': 750.0, + 'amount_currency': -1500.0, + 'amount_residual': 0.0, + 'amount_residual_currency': 0.0, + }, + { + 'partner_id': self.statement_line.partner_id.id, + 'currency_id': self.currency_2.id, + 'account_id': payment_account.id, + 'debit': 750.0, + 'credit': 0.0, + 'amount_currency': 1500.0, + 'amount_residual': 0.0, + 'amount_residual_currency': 0.0, + }, + ]) + + def test_conversion_rate_rounding_issue(self): + ''' Ensure the reconciliation is well handling the rounding issue due to multiple currency conversion rates. + + In this test, the resulting journal entry after reconciliation is: + {'amount_currency': 7541.66, 'debit': 6446.97, 'credit': 0.0} + {'amount_currency': 226.04, 'debit': 193.22, 'credit': 0.0} + {'amount_currency': -7767.70, 'debit': 0.0, 'credit': 6640.19} + ... but 226.04 / 1.1698 = 193.23. In this situation, 0.01 has been removed from this write-off line in order to + avoid an unecessary open-balance line being an exchange difference issue. + ''' + self.bank_journal_2.currency_id = self.currency_2 + self.currency_data['rates'][-1].rate = 1.1698 + + statement = self.env['account.bank.statement'].create({ + 'name': 'test_statement', + 'date': '2017-01-01', + 'journal_id': self.bank_journal_2.id, + 'line_ids': [ + (0, 0, { + 'date': '2019-01-01', + 'payment_ref': 'line_1', + 'partner_id': self.partner_a.id, + 'amount': 7541.66, + }), + ], + }) + statement.button_post() + statement_line = statement.line_ids + + payment = self.env['account.payment'].create({ + 'amount': 7767.70, + 'date': '2019-01-01', + 'currency_id': self.currency_2.id, + 'payment_type': 'inbound', + 'partner_type': 'customer', + }) + payment.action_post() + liquidity_lines, counterpart_lines, writeoff_lines = payment._seek_for_lines() + self.assertRecordValues(liquidity_lines, [{'amount_currency': 7767.70}]) + + statement_line.reconcile([ + {'id': liquidity_lines.id}, + {'balance': 226.04, 'account_id': self.company_data['default_account_revenue'].id, 'name': "write-off"}, + ]) + + self.assertRecordValues(statement_line.line_ids, [ + {'amount_currency': 7541.66, 'debit': 6446.97, 'credit': 0.0}, + {'amount_currency': 226.04, 'debit': 193.22, 'credit': 0.0}, + {'amount_currency': -7767.70, 'debit': 0.0, 'credit': 6640.19}, + ]) + + def test_zero_amount_statement_line(self): + ''' Ensure the statement line is directly marked as reconciled when having an amount of zero. ''' + self.company_data['company'].account_journal_suspense_account_id.reconcile = False + + statement = self.env['account.bank.statement'].with_context(skip_check_amounts_currencies=True).create({ + 'name': 'test_statement', + 'date': '2017-01-01', + 'journal_id': self.bank_journal_2.id, + 'line_ids': [ + (0, 0, { + 'date': '2019-01-01', + 'payment_ref': "Happy new year", + 'amount': 0.0, + }), + ], + }) + statement_line = statement.line_ids + + self.assertRecordValues(statement_line, [{'is_reconciled': True, 'amount_residual': 0.0}]) + + def test_bank_statement_line_analytic(self): + ''' Ensure the analytic lines are generated during the reconciliation. ''' + analytic_account = self.env['account.analytic.account'].create({'name': 'analytic_account'}) + + statement = self.env['account.bank.statement'].with_context(skip_check_amounts_currencies=True).create({ + 'name': 'test_statement', + 'date': '2017-01-01', + 'journal_id': self.bank_journal_2.id, + 'line_ids': [ + (0, 0, { + 'date': '2019-01-01', + 'payment_ref': "line", + 'amount': 100.0, + }), + ], + }) + statement_line = statement.line_ids + + statement_line.reconcile([{ + 'balance': -100.0, + 'account_id': self.company_data['default_account_revenue'].id, + 'name': "write-off", + 'analytic_account_id': analytic_account.id, + }]) + + # Check the analytic account is there. + self.assertRecordValues(statement_line.line_ids.sorted('balance'), [ + {'balance': -100.0, 'analytic_account_id': analytic_account.id}, + {'balance': 100.0, 'analytic_account_id': False}, + ]) + + # Check the analytic lines. + self.assertRecordValues(statement_line.line_ids.analytic_line_ids, [ + {'amount': 100.0, 'account_id': analytic_account.id}, + ]) diff --git a/addons/account/tests/test_account_incoming_supplier_invoice.py b/addons/account/tests/test_account_incoming_supplier_invoice.py new file mode 100644 index 00000000..373fcbab --- /dev/null +++ b/addons/account/tests/test_account_incoming_supplier_invoice.py @@ -0,0 +1,123 @@ +# -*- coding: utf-8 -*- +from odoo.addons.account.tests.common import AccountTestInvoicingCommon +from odoo.tests import tagged + +import json + + +@tagged('post_install', '-at_install') +class TestAccountIncomingSupplierInvoice(AccountTestInvoicingCommon): + + @classmethod + def setUpClass(cls, chart_template_ref=None): + super().setUpClass(chart_template_ref=chart_template_ref) + + cls.env['ir.config_parameter'].sudo().set_param('mail.catchall.domain', 'test-company.odoo.com') + + cls.internal_user = cls.env['res.users'].create({ + 'name': 'Internal User', + 'login': 'internal.user@test.odoo.com', + 'email': 'internal.user@test.odoo.com', + }) + + cls.supplier_partner = cls.env['res.partner'].create({ + 'name': 'Your Supplier', + 'email': 'supplier@other.company.com', + 'supplier_rank': 10, + }) + + cls.journal = cls.company_data['default_journal_purchase'] + + journal_alias = cls.env['mail.alias'].create({ + 'alias_name': 'test-bill', + 'alias_model_id': cls.env.ref('account.model_account_move').id, + 'alias_defaults': json.dumps({ + 'move_type': 'in_invoice', + 'company_id': cls.env.user.company_id.id, + 'journal_id': cls.journal.id, + }), + }) + cls.journal.write({'alias_id': journal_alias.id}) + + def test_supplier_invoice_mailed_from_supplier(self): + message_parsed = { + 'message_id': 'message-id-dead-beef', + 'subject': 'Incoming bill', + 'from': '%s <%s>' % (self.supplier_partner.name, self.supplier_partner.email), + 'to': '%s@%s' % (self.journal.alias_id.alias_name, self.journal.alias_id.alias_domain), + 'body': "You know, that thing that you bought.", + 'attachments': [b'Hello, invoice'], + } + + invoice = self.env['account.move'].message_new(message_parsed, {'move_type': 'in_invoice', 'journal_id': self.journal.id}) + + message_ids = invoice.message_ids + self.assertEqual(len(message_ids), 1, 'Only one message should be posted in the chatter') + self.assertEqual(message_ids.body, '<p>Vendor Bill Created</p>', 'Only the invoice creation should be posted') + + following_partners = invoice.message_follower_ids.mapped('partner_id') + self.assertEqual(following_partners, self.env.user.partner_id) + self.assertRegex(invoice.name, 'BILL/\d{4}/\d{2}/0001') + + def test_supplier_invoice_forwarded_by_internal_user_without_supplier(self): + """ In this test, the bill was forwarded by an employee, + but no partner email address is found in the body.""" + message_parsed = { + 'message_id': 'message-id-dead-beef', + 'subject': 'Incoming bill', + 'from': '%s <%s>' % (self.internal_user.name, self.internal_user.email), + 'to': '%s@%s' % (self.journal.alias_id.alias_name, self.journal.alias_id.alias_domain), + 'body': "You know, that thing that you bought.", + 'attachments': [b'Hello, invoice'], + } + + invoice = self.env['account.move'].message_new(message_parsed, {'move_type': 'in_invoice', 'journal_id': self.journal.id}) + + message_ids = invoice.message_ids + self.assertEqual(len(message_ids), 1, 'Only one message should be posted in the chatter') + self.assertEqual(message_ids.body, '<p>Vendor Bill Created</p>', 'Only the invoice creation should be posted') + + following_partners = invoice.message_follower_ids.mapped('partner_id') + self.assertEqual(following_partners, self.env.user.partner_id | self.internal_user.partner_id) + + def test_supplier_invoice_forwarded_by_internal_with_supplier_in_body(self): + """ In this test, the bill was forwarded by an employee, + and the partner email address is found in the body.""" + message_parsed = { + 'message_id': 'message-id-dead-beef', + 'subject': 'Incoming bill', + 'from': '%s <%s>' % (self.internal_user.name, self.internal_user.email), + 'to': '%s@%s' % (self.journal.alias_id.alias_name, self.journal.alias_id.alias_domain), + 'body': "Mail sent by %s <%s>:\nYou know, that thing that you bought." % (self.supplier_partner.name, self.supplier_partner.email), + 'attachments': [b'Hello, invoice'], + } + + invoice = self.env['account.move'].message_new(message_parsed, {'move_type': 'in_invoice', 'journal_id': self.journal.id}) + + message_ids = invoice.message_ids + self.assertEqual(len(message_ids), 1, 'Only one message should be posted in the chatter') + self.assertEqual(message_ids.body, '<p>Vendor Bill Created</p>', 'Only the invoice creation should be posted') + + following_partners = invoice.message_follower_ids.mapped('partner_id') + self.assertEqual(following_partners, self.env.user.partner_id | self.internal_user.partner_id) + + def test_supplier_invoice_forwarded_by_internal_with_internal_in_body(self): + """ In this test, the bill was forwarded by an employee, + and the internal user email address is found in the body.""" + message_parsed = { + 'message_id': 'message-id-dead-beef', + 'subject': 'Incoming bill', + 'from': '%s <%s>' % (self.internal_user.name, self.internal_user.email), + 'to': '%s@%s' % (self.journal.alias_id.alias_name, self.journal.alias_id.alias_domain), + 'body': "Mail sent by %s <%s>:\nYou know, that thing that you bought." % (self.internal_user.name, self.internal_user.email), + 'attachments': [b'Hello, invoice'], + } + + invoice = self.env['account.move'].message_new(message_parsed, {'move_type': 'in_invoice', 'journal_id': self.journal.id}) + + message_ids = invoice.message_ids + self.assertEqual(len(message_ids), 1, 'Only one message should be posted in the chatter') + self.assertEqual(message_ids.body, '<p>Vendor Bill Created</p>', 'Only the invoice creation should be posted') + + following_partners = invoice.message_follower_ids.mapped('partner_id') + self.assertEqual(following_partners, self.env.user.partner_id | self.internal_user.partner_id) diff --git a/addons/account/tests/test_account_invoice_report.py b/addons/account/tests/test_account_invoice_report.py new file mode 100644 index 00000000..e136ef71 --- /dev/null +++ b/addons/account/tests/test_account_invoice_report.py @@ -0,0 +1,118 @@ +# -*- coding: utf-8 -*- +from odoo.addons.account.tests.common import AccountTestInvoicingCommon +from odoo.tests import tagged +from odoo import fields + + +@tagged('post_install', '-at_install') +class TestAccountInvoiceReport(AccountTestInvoicingCommon): + + @classmethod + def setUpClass(cls, chart_template_ref=None): + super().setUpClass(chart_template_ref=chart_template_ref) + + cls.invoices = cls.env['account.move'].create([ + { + 'move_type': 'out_invoice', + 'partner_id': cls.partner_a.id, + 'invoice_date': fields.Date.from_string('2016-01-01'), + 'currency_id': cls.currency_data['currency'].id, + 'invoice_line_ids': [ + (0, None, { + 'product_id': cls.product_a.id, + 'quantity': 3, + 'price_unit': 750, + }), + (0, None, { + 'product_id': cls.product_a.id, + 'quantity': 1, + 'price_unit': 3000, + }), + ] + }, + { + 'move_type': 'out_receipt', + 'invoice_date': fields.Date.from_string('2016-01-01'), + 'currency_id': cls.currency_data['currency'].id, + 'invoice_line_ids': [ + (0, None, { + 'product_id': cls.product_a.id, + 'quantity': 1, + 'price_unit': 6000, + }), + ] + }, + { + 'move_type': 'out_refund', + 'partner_id': cls.partner_a.id, + 'invoice_date': fields.Date.from_string('2017-01-01'), + 'currency_id': cls.currency_data['currency'].id, + 'invoice_line_ids': [ + (0, None, { + 'product_id': cls.product_a.id, + 'quantity': 1, + 'price_unit': 1200, + }), + ] + }, + { + 'move_type': 'in_invoice', + 'partner_id': cls.partner_a.id, + 'invoice_date': fields.Date.from_string('2016-01-01'), + 'currency_id': cls.currency_data['currency'].id, + 'invoice_line_ids': [ + (0, None, { + 'product_id': cls.product_a.id, + 'quantity': 1, + 'price_unit': 60, + }), + ] + }, + { + 'move_type': 'in_receipt', + 'partner_id': cls.partner_a.id, + 'invoice_date': fields.Date.from_string('2016-01-01'), + 'currency_id': cls.currency_data['currency'].id, + 'invoice_line_ids': [ + (0, None, { + 'product_id': cls.product_a.id, + 'quantity': 1, + 'price_unit': 60, + }), + ] + }, + { + 'move_type': 'in_refund', + 'partner_id': cls.partner_a.id, + 'invoice_date': fields.Date.from_string('2017-01-01'), + 'currency_id': cls.currency_data['currency'].id, + 'invoice_line_ids': [ + (0, None, { + 'product_id': cls.product_a.id, + 'quantity': 1, + 'price_unit': 12, + }), + ] + }, + ]) + + def assertInvoiceReportValues(self, expected_values_list): + reports = self.env['account.invoice.report'].search([('company_id', '=', self.company_data['company'].id)], order='price_subtotal DESC, quantity ASC') + expected_values_dict = [{ + 'price_average': vals[0], + 'price_subtotal': vals[1], + 'quantity': vals[2], + } for vals in expected_values_list] + self.assertRecordValues(reports, expected_values_dict) + + def test_invoice_report_multiple_types(self): + self.assertInvoiceReportValues([ + #price_average price_subtotal quantity + [2000, 2000, 1], + [1000, 1000, 1], + [250, 750, 3], + [6, 6, 1], + [-20, -20, -1], + [-20, -20, -1], + [-600, -600, -1], + ]) diff --git a/addons/account/tests/test_account_journal.py b/addons/account/tests/test_account_journal.py new file mode 100644 index 00000000..b8644db0 --- /dev/null +++ b/addons/account/tests/test_account_journal.py @@ -0,0 +1,84 @@ +# -*- coding: utf-8 -*- +from odoo.addons.account.tests.common import AccountTestInvoicingCommon +from odoo.tests import tagged +from odoo.exceptions import UserError, ValidationError + + +@tagged('post_install', '-at_install') +class TestAccountJournal(AccountTestInvoicingCommon): + + def test_constraint_currency_consistency_with_accounts(self): + ''' The accounts linked to a bank/cash journal must share the same foreign currency + if specified. + ''' + journal_bank = self.company_data['default_journal_bank'] + journal_bank.currency_id = self.currency_data['currency'] + + # Try to set a different currency on the 'debit' account. + with self.assertRaises(ValidationError), self.cr.savepoint(): + journal_bank.default_account_id.currency_id = self.company_data['currency'] + + def test_changing_journal_company(self): + ''' Ensure you can't change the company of an account.journal if there are some journal entries ''' + + self.env['account.move'].create({ + 'move_type': 'entry', + 'date': '2019-01-01', + 'journal_id': self.company_data['default_journal_sale'].id, + }) + + with self.assertRaises(UserError), self.cr.savepoint(): + self.company_data['default_journal_sale'].company_id = self.company_data_2['company'] + + def test_account_control_create_journal_entry(self): + move_vals = { + 'line_ids': [ + (0, 0, { + 'name': 'debit', + 'account_id': self.company_data['default_account_revenue'].id, + 'debit': 100.0, + 'credit': 0.0, + }), + (0, 0, { + 'name': 'credit', + 'account_id': self.company_data['default_account_expense'].id, + 'debit': 0.0, + 'credit': 100.0, + }), + ], + } + + # Should fail because 'default_account_expense' is not allowed. + self.company_data['default_journal_misc'].account_control_ids |= self.company_data['default_account_revenue'] + with self.assertRaises(UserError), self.cr.savepoint(): + self.env['account.move'].create(move_vals) + + # Should be allowed because both accounts are accepted. + self.company_data['default_journal_misc'].account_control_ids |= self.company_data['default_account_expense'] + self.env['account.move'].create(move_vals) + + def test_account_control_existing_journal_entry(self): + self.env['account.move'].create({ + 'line_ids': [ + (0, 0, { + 'name': 'debit', + 'account_id': self.company_data['default_account_revenue'].id, + 'debit': 100.0, + 'credit': 0.0, + }), + (0, 0, { + 'name': 'credit', + 'account_id': self.company_data['default_account_expense'].id, + 'debit': 0.0, + 'credit': 100.0, + }), + ], + }) + + # There is already an other line using the 'default_account_expense' account. + with self.assertRaises(ValidationError), self.cr.savepoint(): + self.company_data['default_journal_misc'].account_control_ids |= self.company_data['default_account_revenue'] + + # Assigning both should be allowed + self.company_data['default_journal_misc'].account_control_ids = \ + self.company_data['default_account_revenue'] + self.company_data['default_account_expense'] diff --git a/addons/account/tests/test_account_journal_dashboard.py b/addons/account/tests/test_account_journal_dashboard.py new file mode 100644 index 00000000..6bb127b6 --- /dev/null +++ b/addons/account/tests/test_account_journal_dashboard.py @@ -0,0 +1,94 @@ +# -*- coding: utf-8 -*- +from freezegun import freeze_time + +from odoo.addons.account.tests.common import AccountTestInvoicingCommon +from odoo.tests import tagged + +@tagged('post_install', '-at_install') +class TestAccountJournalDashboard(AccountTestInvoicingCommon): + + @freeze_time("2019-01-22") + def test_customer_invoice_dashboard(self): + journal = self.company_data['default_journal_sale'] + + invoice = self.env['account.move'].create({ + 'move_type': 'out_invoice', + 'journal_id': journal.id, + 'partner_id': self.partner_a.id, + 'invoice_date': '2019-01-21', + 'date': '2019-01-21', + 'invoice_line_ids': [(0, 0, { + 'product_id': self.product_a.id, + 'quantity': 40.0, + 'name': 'product test 1', + 'discount': 10.00, + 'price_unit': 2.27, + })] + }) + refund = self.env['account.move'].create({ + 'move_type': 'out_refund', + 'journal_id': journal.id, + 'partner_id': self.partner_a.id, + 'invoice_date': '2019-01-21', + 'date': '2019-01-21', + 'invoice_line_ids': [(0, 0, { + 'product_id': self.product_a.id, + 'quantity': 1.0, + 'name': 'product test 1', + 'price_unit': 13.3, + })] + }) + + # Check Draft + dashboard_data = journal.get_journal_dashboard_datas() + + self.assertEqual(dashboard_data['number_draft'], 2) + self.assertIn('68.42', dashboard_data['sum_draft']) + + self.assertEqual(dashboard_data['number_waiting'], 0) + self.assertIn('0.00', dashboard_data['sum_waiting']) + + # Check Both + invoice.action_post() + + dashboard_data = journal.get_journal_dashboard_datas() + self.assertEqual(dashboard_data['number_draft'], 1) + self.assertIn('-13.30', dashboard_data['sum_draft']) + + self.assertEqual(dashboard_data['number_waiting'], 1) + self.assertIn('81.72', dashboard_data['sum_waiting']) + + # Check waiting payment + refund.action_post() + + dashboard_data = journal.get_journal_dashboard_datas() + self.assertEqual(dashboard_data['number_draft'], 0) + self.assertIn('0.00', dashboard_data['sum_draft']) + + self.assertEqual(dashboard_data['number_waiting'], 2) + self.assertIn('68.42', dashboard_data['sum_waiting']) + + # Check partial + receivable_account = refund.line_ids.mapped('account_id').filtered(lambda a: a.internal_type == 'receivable') + payment = self.env['account.payment'].create({ + 'amount': 10.0, + 'payment_type': 'outbound', + 'partner_type': 'customer', + 'partner_id': self.partner_a.id, + }) + payment.action_post() + + (refund + payment.move_id).line_ids\ + .filtered(lambda line: line.account_internal_type == 'receivable')\ + .reconcile() + + dashboard_data = journal.get_journal_dashboard_datas() + self.assertEqual(dashboard_data['number_draft'], 0) + self.assertIn('0.00', dashboard_data['sum_draft']) + + self.assertEqual(dashboard_data['number_waiting'], 2) + self.assertIn('78.42', dashboard_data['sum_waiting']) + + dashboard_data = journal.get_journal_dashboard_datas() + self.assertEqual(dashboard_data['number_late'], 2) + self.assertIn('78.42', dashboard_data['sum_late']) diff --git a/addons/account/tests/test_account_move_entry.py b/addons/account/tests/test_account_move_entry.py new file mode 100644 index 00000000..336deab9 --- /dev/null +++ b/addons/account/tests/test_account_move_entry.py @@ -0,0 +1,490 @@ +# -*- coding: utf-8 -*- +from odoo.addons.account.tests.common import AccountTestInvoicingCommon +from odoo.tests import tagged, new_test_user +from odoo.tests.common import Form +from odoo import fields, api, SUPERUSER_ID +from odoo.exceptions import ValidationError, UserError, RedirectWarning +from odoo.tools import mute_logger + +from dateutil.relativedelta import relativedelta +from functools import reduce +import json +import psycopg2 + + +@tagged('post_install', '-at_install') +class TestAccountMove(AccountTestInvoicingCommon): + + @classmethod + def setUpClass(cls, chart_template_ref=None): + super().setUpClass(chart_template_ref=chart_template_ref) + + tax_repartition_line = cls.company_data['default_tax_sale'].refund_repartition_line_ids\ + .filtered(lambda line: line.repartition_type == 'tax') + cls.test_move = cls.env['account.move'].create({ + 'move_type': 'entry', + 'date': fields.Date.from_string('2016-01-01'), + 'line_ids': [ + (0, None, { + 'name': 'revenue line 1', + 'account_id': cls.company_data['default_account_revenue'].id, + 'debit': 500.0, + 'credit': 0.0, + }), + (0, None, { + 'name': 'revenue line 2', + 'account_id': cls.company_data['default_account_revenue'].id, + 'debit': 1000.0, + 'credit': 0.0, + 'tax_ids': [(6, 0, cls.company_data['default_tax_sale'].ids)], + }), + (0, None, { + 'name': 'tax line', + 'account_id': cls.company_data['default_account_tax_sale'].id, + 'debit': 150.0, + 'credit': 0.0, + 'tax_repartition_line_id': tax_repartition_line.id, + }), + (0, None, { + 'name': 'counterpart line', + 'account_id': cls.company_data['default_account_expense'].id, + 'debit': 0.0, + 'credit': 1650.0, + }), + ] + }) + + def test_custom_currency_on_account_1(self): + custom_account = self.company_data['default_account_revenue'].copy() + + # The currency set on the account is not the same as the one set on the company. + # It should raise an error. + custom_account.currency_id = self.currency_data['currency'] + + with self.assertRaises(UserError), self.cr.savepoint(): + self.test_move.line_ids[0].account_id = custom_account + + # The currency set on the account is the same as the one set on the company. + # It should not raise an error. + custom_account.currency_id = self.company_data['currency'] + + self.test_move.line_ids[0].account_id = custom_account + + def test_misc_fiscalyear_lock_date_1(self): + self.test_move.action_post() + + # Set the lock date after the journal entry date. + self.test_move.company_id.fiscalyear_lock_date = fields.Date.from_string('2017-01-01') + + # lines[0] = 'counterpart line' + # lines[1] = 'tax line' + # lines[2] = 'revenue line 1' + # lines[3] = 'revenue line 2' + lines = self.test_move.line_ids.sorted('debit') + + # Editing the reference should be allowed. + self.test_move.ref = 'whatever' + + # Try to edit a line into a locked fiscal year. + with self.assertRaises(UserError), self.cr.savepoint(): + self.test_move.write({ + 'line_ids': [ + (1, lines[0].id, {'credit': lines[0].credit + 100.0}), + (1, lines[2].id, {'debit': lines[2].debit + 100.0}), + ], + }) + + # Try to edit the account of a line. + with self.assertRaises(UserError), self.cr.savepoint(): + self.test_move.line_ids[0].write({'account_id': self.test_move.line_ids[0].account_id.copy().id}) + + # Try to edit a line. + with self.assertRaises(UserError), self.cr.savepoint(): + self.test_move.write({ + 'line_ids': [ + (1, lines[0].id, {'credit': lines[0].credit + 100.0}), + (1, lines[3].id, {'debit': lines[3].debit + 100.0}), + ], + }) + + # Try to add a new tax on a line. + with self.assertRaises(UserError), self.cr.savepoint(): + self.test_move.write({ + 'line_ids': [ + (1, lines[2].id, {'tax_ids': [(6, 0, self.company_data['default_tax_purchase'].ids)]}), + ], + }) + + # Try to create a new line. + with self.assertRaises(UserError), self.cr.savepoint(): + self.test_move.write({ + 'line_ids': [ + (1, lines[0].id, {'credit': lines[0].credit + 100.0}), + (0, None, { + 'name': 'revenue line 1', + 'account_id': self.company_data['default_account_revenue'].id, + 'debit': 100.0, + 'credit': 0.0, + }), + ], + }) + + # You can't remove the journal entry from a locked period. + with self.assertRaises(UserError), self.cr.savepoint(): + self.test_move.date = fields.Date.from_string('2018-01-01') + + with self.assertRaises(UserError), self.cr.savepoint(): + self.test_move.unlink() + + with self.assertRaises(UserError), self.cr.savepoint(): + self.test_move.button_draft() + + # Try to add a new journal entry prior to the lock date. + copy_move = self.test_move.copy({'date': '2017-01-01'}) + # The date has been changed to the first valid date. + self.assertEqual(copy_move.date, copy_move.company_id.fiscalyear_lock_date + relativedelta(days=1)) + + def test_misc_fiscalyear_lock_date_2(self): + self.test_move.action_post() + + # Create a bank statement to get a balance in the suspense account. + statement = self.env['account.bank.statement'].create({ + 'journal_id': self.company_data['default_journal_bank'].id, + 'date': '2016-01-01', + 'line_ids': [ + (0, 0, {'payment_ref': 'test', 'amount': 10.0}) + ], + }) + statement.button_post() + + # You can't lock the fiscal year if there is some unreconciled statement. + with self.assertRaises(RedirectWarning), self.cr.savepoint(): + self.test_move.company_id.fiscalyear_lock_date = fields.Date.from_string('2017-01-01') + + def test_misc_tax_lock_date_1(self): + self.test_move.action_post() + + # Set the tax lock date after the journal entry date. + self.test_move.company_id.tax_lock_date = fields.Date.from_string('2017-01-01') + + # lines[0] = 'counterpart line' + # lines[1] = 'tax line' + # lines[2] = 'revenue line 1' + # lines[3] = 'revenue line 2' + lines = self.test_move.line_ids.sorted('debit') + + # Try to edit a line not affecting the taxes. + self.test_move.write({ + 'line_ids': [ + (1, lines[0].id, {'credit': lines[0].credit + 100.0}), + (1, lines[2].id, {'debit': lines[2].debit + 100.0}), + ], + }) + + # Try to edit the account of a line. + self.test_move.line_ids[0].write({'account_id': self.test_move.line_ids[0].account_id.copy().id}) + + # Try to edit a line having some taxes. + with self.assertRaises(UserError), self.cr.savepoint(): + self.test_move.write({ + 'line_ids': [ + (1, lines[0].id, {'credit': lines[0].credit + 100.0}), + (1, lines[3].id, {'debit': lines[3].debit + 100.0}), + ], + }) + + # Try to add a new tax on a line. + with self.assertRaises(UserError), self.cr.savepoint(): + self.test_move.write({ + 'line_ids': [ + (1, lines[2].id, {'tax_ids': [(6, 0, self.company_data['default_tax_purchase'].ids)]}), + ], + }) + + # Try to edit a tax line. + with self.assertRaises(UserError), self.cr.savepoint(): + self.test_move.write({ + 'line_ids': [ + (1, lines[0].id, {'credit': lines[0].credit + 100.0}), + (1, lines[1].id, {'debit': lines[1].debit + 100.0}), + ], + }) + + # Try to create a line not affecting the taxes. + self.test_move.write({ + 'line_ids': [ + (1, lines[0].id, {'credit': lines[0].credit + 100.0}), + (0, None, { + 'name': 'revenue line 1', + 'account_id': self.company_data['default_account_revenue'].id, + 'debit': 100.0, + 'credit': 0.0, + }), + ], + }) + + # Try to create a line affecting the taxes. + with self.assertRaises(UserError), self.cr.savepoint(): + self.test_move.write({ + 'line_ids': [ + (1, lines[0].id, {'credit': lines[0].credit + 100.0}), + (0, None, { + 'name': 'revenue line 2', + 'account_id': self.company_data['default_account_revenue'].id, + 'debit': 1000.0, + 'credit': 0.0, + 'tax_ids': [(6, 0, self.company_data['default_tax_sale'].ids)], + }), + ], + }) + + # You can't remove the journal entry from a locked period. + with self.assertRaises(UserError), self.cr.savepoint(): + self.test_move.date = fields.Date.from_string('2018-01-01') + + with self.assertRaises(UserError), self.cr.savepoint(): + self.test_move.unlink() + + with self.assertRaises(UserError), self.cr.savepoint(): + self.test_move.button_draft() + + copy_move = self.test_move.copy({'date': self.test_move.date}) + + # /!\ The date is changed automatically to the next available one during the post. + copy_move.action_post() + + # You can't change the date to one being in a locked period. + with self.assertRaises(UserError), self.cr.savepoint(): + copy_move.date = fields.Date.from_string('2017-01-01') + + def test_misc_draft_reconciled_entries_1(self): + draft_moves = self.env['account.move'].create([ + { + 'move_type': 'entry', + 'line_ids': [ + (0, None, { + 'name': 'move 1 receivable line', + 'account_id': self.company_data['default_account_receivable'].id, + 'debit': 1000.0, + 'credit': 0.0, + }), + (0, None, { + 'name': 'move 1 counterpart line', + 'account_id': self.company_data['default_account_expense'].id, + 'debit': 0.0, + 'credit': 1000.0, + }), + ] + }, + { + 'move_type': 'entry', + 'line_ids': [ + (0, None, { + 'name': 'move 2 receivable line', + 'account_id': self.company_data['default_account_receivable'].id, + 'debit': 0.0, + 'credit': 2000.0, + }), + (0, None, { + 'name': 'move 2 counterpart line', + 'account_id': self.company_data['default_account_expense'].id, + 'debit': 2000.0, + 'credit': 0.0, + }), + ] + }, + ]) + + # lines[0] = 'move 2 receivable line' + # lines[1] = 'move 1 counterpart line' + # lines[2] = 'move 1 receivable line' + # lines[3] = 'move 2 counterpart line' + draft_moves.action_post() + lines = draft_moves.mapped('line_ids').sorted('balance') + + (lines[0] + lines[2]).reconcile() + + # You can't write something impacting the reconciliation on an already reconciled line. + with self.assertRaises(UserError), self.cr.savepoint(): + draft_moves[0].write({ + 'line_ids': [ + (1, lines[1].id, {'credit': lines[1].credit + 100.0}), + (1, lines[2].id, {'debit': lines[2].debit + 100.0}), + ] + }) + + # The write must not raise anything because the rounding of the monetary field should ignore such tiny amount. + draft_moves[0].write({ + 'line_ids': [ + (1, lines[1].id, {'credit': lines[1].credit + 0.0000001}), + (1, lines[2].id, {'debit': lines[2].debit + 0.0000001}), + ] + }) + + # You can't unlink an already reconciled line. + with self.assertRaises(UserError), self.cr.savepoint(): + draft_moves.unlink() + + def test_misc_always_balanced_move(self): + ''' Ensure there is no way to make ''' + # You can't remove a journal item making the journal entry unbalanced. + with self.assertRaises(UserError), self.cr.savepoint(): + self.test_move.line_ids[0].unlink() + + # Same check using write instead of unlink. + with self.assertRaises(UserError), self.cr.savepoint(): + balance = self.test_move.line_ids[0].balance + 5 + self.test_move.line_ids[0].write({ + 'debit': balance if balance > 0.0 else 0.0, + 'credit': -balance if balance < 0.0 else 0.0, + }) + + # You can remove journal items if the related journal entry is still balanced. + self.test_move.line_ids.unlink() + + def test_add_followers_on_post(self): + # Add some existing partners, some from another company + company = self.env['res.company'].create({'name': 'Oopo'}) + company.flush() + existing_partners = self.env['res.partner'].create([{ + 'name': 'Jean', + 'company_id': company.id, + },{ + 'name': 'Paulus', + }]) + self.test_move.message_subscribe(existing_partners.ids) + + user = new_test_user(self.env, login='jag', groups='account.group_account_invoice') + + move = self.test_move.with_user(user) + partner = self.env['res.partner'].create({'name': 'Belouga'}) + move.partner_id = partner + + move.action_post() + self.assertEqual(move.message_partner_ids, self.env.user.partner_id | existing_partners | partner) + + def test_misc_move_onchange(self): + ''' Test the behavior on onchanges for account.move having 'entry' as type. ''' + + move_form = Form(self.env['account.move']) + # Rate 1:3 + move_form.date = fields.Date.from_string('2016-01-01') + + # New line that should get 400.0 as debit. + with move_form.line_ids.new() as line_form: + line_form.name = 'debit_line' + line_form.account_id = self.company_data['default_account_revenue'] + line_form.currency_id = self.currency_data['currency'] + line_form.amount_currency = 1200.0 + + # New line that should get 400.0 as credit. + with move_form.line_ids.new() as line_form: + line_form.name = 'credit_line' + line_form.account_id = self.company_data['default_account_revenue'] + line_form.currency_id = self.currency_data['currency'] + line_form.amount_currency = -1200.0 + move = move_form.save() + + self.assertRecordValues( + move.line_ids.sorted('debit'), + [ + { + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -1200.0, + 'debit': 0.0, + 'credit': 400.0, + }, + { + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 1200.0, + 'debit': 400.0, + 'credit': 0.0, + }, + ], + ) + + # === Change the date to change the currency conversion's rate === + + with Form(move) as move_form: + move_form.date = fields.Date.from_string('2017-01-01') + + self.assertRecordValues( + move.line_ids.sorted('debit'), + [ + { + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -1200.0, + 'debit': 0.0, + 'credit': 600.0, + }, + { + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 1200.0, + 'debit': 600.0, + 'credit': 0.0, + }, + ], + ) + + def test_included_tax(self): + ''' + Test an account.move.line is created automatically when adding a tax. + This test uses the following scenario: + - Create manually a debit line of 1000 having an included tax. + - Assume a line containing the tax amount is created automatically. + - Create manually a credit line to balance the two previous lines. + - Save the move. + + included tax = 20% + + Name | Debit | Credit | Tax_ids | Tax_line_id's name + -----------------------|-----------|-----------|---------------|------------------- + debit_line_1 | 1000 | | tax | + included_tax_line | 200 | | | included_tax_line + credit_line_1 | | 1200 | | + ''' + + self.included_percent_tax = self.env['account.tax'].create({ + 'name': 'included_tax_line', + 'amount_type': 'percent', + 'amount': 20, + 'price_include': True, + 'include_base_amount': False, + }) + self.account = self.company_data['default_account_revenue'] + + move_form = Form(self.env['account.move'].with_context(default_move_type='entry')) + + # Create a new account.move.line with debit amount. + with move_form.line_ids.new() as debit_line: + debit_line.name = 'debit_line_1' + debit_line.account_id = self.account + debit_line.debit = 1000 + debit_line.tax_ids.clear() + debit_line.tax_ids.add(self.included_percent_tax) + + self.assertTrue(debit_line.recompute_tax_line) + + # Create a third account.move.line with credit amount. + with move_form.line_ids.new() as credit_line: + credit_line.name = 'credit_line_1' + credit_line.account_id = self.account + credit_line.credit = 1200 + + move = move_form.save() + + self.assertRecordValues(move.line_ids, [ + {'name': 'debit_line_1', 'debit': 1000.0, 'credit': 0.0, 'tax_ids': [self.included_percent_tax.id], 'tax_line_id': False}, + {'name': 'included_tax_line', 'debit': 200.0, 'credit': 0.0, 'tax_ids': [], 'tax_line_id': self.included_percent_tax.id}, + {'name': 'credit_line_1', 'debit': 0.0, 'credit': 1200.0, 'tax_ids': [], 'tax_line_id': False}, + ]) + + def test_misc_prevent_unlink_posted_items(self): + # You cannot remove journal items if the related journal entry is posted. + self.test_move.action_post() + with self.assertRaises(UserError), self.cr.savepoint(): + self.test_move.line_ids.unlink() + + # You can remove journal items if the related journal entry is draft. + self.test_move.button_draft() + self.test_move.line_ids.unlink() diff --git a/addons/account/tests/test_account_move_in_invoice.py b/addons/account/tests/test_account_move_in_invoice.py new file mode 100644 index 00000000..4b43a431 --- /dev/null +++ b/addons/account/tests/test_account_move_in_invoice.py @@ -0,0 +1,1774 @@ +# -*- coding: utf-8 -*- +from odoo.addons.account.tests.common import AccountTestInvoicingCommon +from odoo.tests.common import Form +from odoo.tests import tagged +from odoo import fields +from odoo.exceptions import UserError, ValidationError + + +@tagged('post_install', '-at_install') +class TestAccountMoveInInvoiceOnchanges(AccountTestInvoicingCommon): + + @classmethod + def setUpClass(cls, chart_template_ref=None): + super().setUpClass(chart_template_ref=chart_template_ref) + + cls.invoice = cls.init_invoice('in_invoice', products=cls.product_a+cls.product_b) + + cls.product_line_vals_1 = { + 'name': cls.product_a.name, + 'product_id': cls.product_a.id, + 'account_id': cls.product_a.property_account_expense_id.id, + 'partner_id': cls.partner_a.id, + 'product_uom_id': cls.product_a.uom_id.id, + 'quantity': 1.0, + 'discount': 0.0, + 'price_unit': 800.0, + 'price_subtotal': 800.0, + 'price_total': 920.0, + 'tax_ids': cls.product_a.supplier_taxes_id.ids, + 'tax_line_id': False, + 'currency_id': cls.company_data['currency'].id, + 'amount_currency': 800.0, + 'debit': 800.0, + 'credit': 0.0, + 'date_maturity': False, + 'tax_exigible': True, + } + cls.product_line_vals_2 = { + 'name': cls.product_b.name, + 'product_id': cls.product_b.id, + 'account_id': cls.product_b.property_account_expense_id.id, + 'partner_id': cls.partner_a.id, + 'product_uom_id': cls.product_b.uom_id.id, + 'quantity': 1.0, + 'discount': 0.0, + 'price_unit': 160.0, + 'price_subtotal': 160.0, + 'price_total': 208.0, + 'tax_ids': cls.product_b.supplier_taxes_id.ids, + 'tax_line_id': False, + 'currency_id': cls.company_data['currency'].id, + 'amount_currency': 160.0, + 'debit': 160.0, + 'credit': 0.0, + 'date_maturity': False, + 'tax_exigible': True, + } + cls.tax_line_vals_1 = { + 'name': cls.tax_purchase_a.name, + 'product_id': False, + 'account_id': cls.company_data['default_account_tax_purchase'].id, + 'partner_id': cls.partner_a.id, + 'product_uom_id': False, + 'quantity': 1.0, + 'discount': 0.0, + 'price_unit': 144.0, + 'price_subtotal': 144.0, + 'price_total': 144.0, + 'tax_ids': [], + 'tax_line_id': cls.tax_purchase_a.id, + 'currency_id': cls.company_data['currency'].id, + 'amount_currency': 144.0, + 'debit': 144.0, + 'credit': 0.0, + 'date_maturity': False, + 'tax_exigible': True, + } + cls.tax_line_vals_2 = { + 'name': cls.tax_purchase_b.name, + 'product_id': False, + 'account_id': cls.company_data['default_account_tax_purchase'].id, + 'partner_id': cls.partner_a.id, + 'product_uom_id': False, + 'quantity': 1.0, + 'discount': 0.0, + 'price_unit': 24.0, + 'price_subtotal': 24.0, + 'price_total': 24.0, + 'tax_ids': [], + 'tax_line_id': cls.tax_purchase_b.id, + 'currency_id': cls.company_data['currency'].id, + 'amount_currency': 24.0, + 'debit': 24.0, + 'credit': 0.0, + 'date_maturity': False, + 'tax_exigible': True, + } + cls.term_line_vals_1 = { + 'name': '', + 'product_id': False, + 'account_id': cls.company_data['default_account_payable'].id, + 'partner_id': cls.partner_a.id, + 'product_uom_id': False, + 'quantity': 1.0, + 'discount': 0.0, + 'price_unit': -1128.0, + 'price_subtotal': -1128.0, + 'price_total': -1128.0, + 'tax_ids': [], + 'tax_line_id': False, + 'currency_id': cls.company_data['currency'].id, + 'amount_currency': -1128.0, + 'debit': 0.0, + 'credit': 1128.0, + 'date_maturity': fields.Date.from_string('2019-01-01'), + 'tax_exigible': True, + } + cls.move_vals = { + 'partner_id': cls.partner_a.id, + 'currency_id': cls.company_data['currency'].id, + 'journal_id': cls.company_data['default_journal_purchase'].id, + 'date': fields.Date.from_string('2019-01-01'), + 'fiscal_position_id': False, + 'payment_reference': '', + 'invoice_payment_term_id': cls.pay_terms_a.id, + 'amount_untaxed': 960.0, + 'amount_tax': 168.0, + 'amount_total': 1128.0, + } + + def setUp(self): + super(TestAccountMoveInInvoiceOnchanges, self).setUp() + self.assertInvoiceValues(self.invoice, [ + self.product_line_vals_1, + self.product_line_vals_2, + self.tax_line_vals_1, + self.tax_line_vals_2, + self.term_line_vals_1, + ], self.move_vals) + + def test_in_invoice_onchange_invoice_date(self): + for tax_date, invoice_date, accounting_date in [ + ('2019-03-31', '2019-05-12', '2019-05-31'), + ('2019-03-31', '2019-02-10', '2019-04-30'), + ('2019-05-31', '2019-06-15', '2019-06-30'), + ]: + self.invoice.company_id.tax_lock_date = tax_date + with Form(self.invoice) as move_form: + move_form.invoice_date = invoice_date + self.assertEqual(self.invoice.date, fields.Date.to_date(accounting_date)) + + def test_in_invoice_line_onchange_product_1(self): + move_form = Form(self.invoice) + with move_form.invoice_line_ids.edit(0) as line_form: + line_form.product_id = self.product_b + move_form.save() + + self.assertInvoiceValues(self.invoice, [ + { + **self.product_line_vals_1, + 'name': self.product_b.name, + 'product_id': self.product_b.id, + 'product_uom_id': self.product_b.uom_id.id, + 'account_id': self.product_b.property_account_expense_id.id, + 'price_unit': 160.0, + 'price_subtotal': 160.0, + 'price_total': 208.0, + 'tax_ids': self.product_b.supplier_taxes_id.ids, + 'amount_currency': 160.0, + 'debit': 160.0, + }, + self.product_line_vals_2, + { + **self.tax_line_vals_1, + 'price_unit': 48.0, + 'price_subtotal': 48.0, + 'price_total': 48.0, + 'amount_currency': 48.0, + 'debit': 48.0, + }, + { + **self.tax_line_vals_2, + 'price_unit': 48.0, + 'price_subtotal': 48.0, + 'price_total': 48.0, + 'amount_currency': 48.0, + 'debit': 48.0, + }, + { + **self.term_line_vals_1, + 'price_unit': -416.0, + 'price_subtotal': -416.0, + 'price_total': -416.0, + 'amount_currency': -416.0, + 'credit': 416.0, + }, + ], { + **self.move_vals, + 'amount_untaxed': 320.0, + 'amount_tax': 96.0, + 'amount_total': 416.0, + }) + + def test_in_invoice_line_onchange_product_2_with_fiscal_pos(self): + ''' Test mapping a price-included tax (10%) with a price-excluded tax (20%) on a price_unit of 110.0. + The price_unit should be 100.0 after applying the fiscal position. + ''' + tax_price_include = self.env['account.tax'].create({ + 'name': '10% incl', + 'type_tax_use': 'purchase', + 'amount_type': 'percent', + 'amount': 10, + 'price_include': True, + 'include_base_amount': True, + }) + tax_price_exclude = self.env['account.tax'].create({ + 'name': '15% excl', + 'type_tax_use': 'purchase', + 'amount_type': 'percent', + 'amount': 15, + }) + + fiscal_position = self.env['account.fiscal.position'].create({ + 'name': 'fiscal_pos_a', + 'tax_ids': [ + (0, None, { + 'tax_src_id': tax_price_include.id, + 'tax_dest_id': tax_price_exclude.id, + }), + ], + }) + + product = self.env['product.product'].create({ + 'name': 'product', + 'uom_id': self.env.ref('uom.product_uom_unit').id, + 'standard_price': 110.0, + 'supplier_taxes_id': [(6, 0, tax_price_include.ids)], + }) + + move_form = Form(self.env['account.move'].with_context(default_move_type='in_invoice')) + move_form.partner_id = self.partner_a + move_form.invoice_date = fields.Date.from_string('2019-01-01') + move_form.currency_id = self.currency_data['currency'] + move_form.fiscal_position_id = fiscal_position + with move_form.invoice_line_ids.new() as line_form: + line_form.product_id = product + invoice = move_form.save() + + self.assertInvoiceValues(invoice, [ + { + 'product_id': product.id, + 'price_unit': 200.0, + 'price_subtotal': 200.0, + 'price_total': 230.0, + 'tax_ids': tax_price_exclude.ids, + 'tax_line_id': False, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 200.0, + 'debit': 100.0, + 'credit': 0.0, + }, + { + 'product_id': False, + 'price_unit': 30.0, + 'price_subtotal': 30.0, + 'price_total': 30.0, + 'tax_ids': [], + 'tax_line_id': tax_price_exclude.id, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 30.0, + 'debit': 15.0, + 'credit': 0.0, + }, + { + 'product_id': False, + 'price_unit': -230.0, + 'price_subtotal': -230.0, + 'price_total': -230.0, + 'tax_ids': [], + 'tax_line_id': False, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -230.0, + 'debit': 0.0, + 'credit': 115.0, + }, + ], { + 'currency_id': self.currency_data['currency'].id, + 'fiscal_position_id': fiscal_position.id, + 'amount_untaxed': 200.0, + 'amount_tax': 30.0, + 'amount_total': 230.0, + }) + + uom_dozen = self.env.ref('uom.product_uom_dozen') + with Form(invoice) as move_form: + with move_form.invoice_line_ids.edit(0) as line_form: + line_form.product_uom_id = uom_dozen + + self.assertInvoiceValues(invoice, [ + { + 'product_id': product.id, + 'product_uom_id': uom_dozen.id, + 'price_unit': 2400.0, + 'price_subtotal': 2400.0, + 'price_total': 2760.0, + 'tax_ids': tax_price_exclude.ids, + 'tax_line_id': False, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 2400.0, + 'debit': 1200.0, + 'credit': 0.0, + }, + { + 'product_id': False, + 'product_uom_id': False, + 'price_unit': 360.0, + 'price_subtotal': 360.0, + 'price_total': 360.0, + 'tax_ids': [], + 'tax_line_id': tax_price_exclude.id, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 360.0, + 'debit': 180.0, + 'credit': 0.0, + }, + { + 'product_id': False, + 'product_uom_id': False, + 'price_unit': -2760.0, + 'price_subtotal': -2760.0, + 'price_total': -2760.0, + 'tax_ids': [], + 'tax_line_id': False, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -2760.0, + 'debit': 0.0, + 'credit': 1380.0, + }, + ], { + 'currency_id': self.currency_data['currency'].id, + 'fiscal_position_id': fiscal_position.id, + 'amount_untaxed': 2400.0, + 'amount_tax': 360.0, + 'amount_total': 2760.0, + }) + + def test_in_invoice_line_onchange_product_2_with_fiscal_pos_2(self): + ''' Test mapping a price-included tax (10%) with another price-included tax (20%) on a price_unit of 110.0. + The price_unit should be 120.0 after applying the fiscal position. + ''' + tax_price_include_1 = self.env['account.tax'].create({ + 'name': '10% incl', + 'type_tax_use': 'purchase', + 'amount_type': 'percent', + 'amount': 10, + 'price_include': True, + 'include_base_amount': True, + }) + tax_price_include_2 = self.env['account.tax'].create({ + 'name': '20% incl', + 'type_tax_use': 'purchase', + 'amount_type': 'percent', + 'amount': 20, + 'price_include': True, + 'include_base_amount': True, + }) + + fiscal_position = self.env['account.fiscal.position'].create({ + 'name': 'fiscal_pos_a', + 'tax_ids': [ + (0, None, { + 'tax_src_id': tax_price_include_1.id, + 'tax_dest_id': tax_price_include_2.id, + }), + ], + }) + + product = self.env['product.product'].create({ + 'name': 'product', + 'uom_id': self.env.ref('uom.product_uom_unit').id, + 'standard_price': 110.0, + 'supplier_taxes_id': [(6, 0, tax_price_include_1.ids)], + }) + + move_form = Form(self.env['account.move'].with_context(default_move_type='in_invoice')) + move_form.partner_id = self.partner_a + move_form.invoice_date = fields.Date.from_string('2019-01-01') + move_form.currency_id = self.currency_data['currency'] + move_form.fiscal_position_id = fiscal_position + with move_form.invoice_line_ids.new() as line_form: + line_form.product_id = product + invoice = move_form.save() + + self.assertInvoiceValues(invoice, [ + { + 'product_id': product.id, + 'price_unit': 240.0, + 'price_subtotal': 200.0, + 'price_total': 240.0, + 'tax_ids': tax_price_include_2.ids, + 'tax_line_id': False, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 200.0, + 'debit': 100.0, + 'credit': 0.0, + }, + { + 'product_id': False, + 'price_unit': 40.0, + 'price_subtotal': 40.0, + 'price_total': 40.0, + 'tax_ids': [], + 'tax_line_id': tax_price_include_2.id, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 40.0, + 'debit': 20.0, + 'credit': 0.0, + }, + { + 'product_id': False, + 'price_unit': -240.0, + 'price_subtotal': -240.0, + 'price_total': -240.0, + 'tax_ids': [], + 'tax_line_id': False, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -240.0, + 'debit': 0.0, + 'credit': 120.0, + }, + ], { + 'currency_id': self.currency_data['currency'].id, + 'fiscal_position_id': fiscal_position.id, + 'amount_untaxed': 200.0, + 'amount_tax': 40.0, + 'amount_total': 240.0, + }) + + uom_dozen = self.env.ref('uom.product_uom_dozen') + with Form(invoice) as move_form: + with move_form.invoice_line_ids.edit(0) as line_form: + line_form.product_uom_id = uom_dozen + + self.assertInvoiceValues(invoice, [ + { + 'product_id': product.id, + 'product_uom_id': uom_dozen.id, + 'price_unit': 2880.0, + 'price_subtotal': 2400.0, + 'price_total': 2880.0, + 'tax_ids': tax_price_include_2.ids, + 'tax_line_id': False, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 2400.0, + 'debit': 1200.0, + 'credit': 0.0, + }, + { + 'product_id': False, + 'product_uom_id': False, + 'price_unit': 480.0, + 'price_subtotal': 480.0, + 'price_total': 480.0, + 'tax_ids': [], + 'tax_line_id': tax_price_include_2.id, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 480.0, + 'debit': 240.0, + 'credit': 0.0, + }, + { + 'product_id': False, + 'product_uom_id': False, + 'price_unit': -2880.0, + 'price_subtotal': -2880.0, + 'price_total': -2880.0, + 'tax_ids': [], + 'tax_line_id': False, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -2880.0, + 'debit': 0.0, + 'credit': 1440.0, + }, + ], { + 'currency_id': self.currency_data['currency'].id, + 'fiscal_position_id': fiscal_position.id, + 'amount_untaxed': 2400.0, + 'amount_tax': 480.0, + 'amount_total': 2880.0, + }) + + def test_in_invoice_line_onchange_business_fields_1(self): + move_form = Form(self.invoice) + with move_form.invoice_line_ids.edit(0) as line_form: + # Current price_unit is 800. + # We set quantity = 4, discount = 50%, price_unit = 400. The debit/credit fields don't change because (4 * 400) * 0.5 = 800. + line_form.quantity = 4 + line_form.discount = 50 + line_form.price_unit = 400 + move_form.save() + + self.assertInvoiceValues(self.invoice, [ + { + **self.product_line_vals_1, + 'quantity': 4, + 'discount': 50.0, + 'price_unit': 400.0, + }, + self.product_line_vals_2, + self.tax_line_vals_1, + self.tax_line_vals_2, + self.term_line_vals_1, + ], self.move_vals) + + move_form = Form(self.invoice) + with move_form.line_ids.edit(2) as line_form: + # Reset field except the discount that becomes 100%. + # /!\ The modification is made on the accounting tab. + line_form.quantity = 1 + line_form.discount = 100 + line_form.price_unit = 800 + move_form.save() + + self.assertInvoiceValues(self.invoice, [ + { + **self.product_line_vals_1, + 'discount': 100.0, + 'price_subtotal': 0.0, + 'price_total': 0.0, + 'amount_currency': 0.0, + 'debit': 0.0, + }, + self.product_line_vals_2, + { + **self.tax_line_vals_1, + 'price_unit': 24.0, + 'price_subtotal': 24.0, + 'price_total': 24.0, + 'amount_currency': 24.0, + 'debit': 24.0, + }, + self.tax_line_vals_2, + { + **self.term_line_vals_1, + 'price_unit': -208.0, + 'price_subtotal': -208.0, + 'price_total': -208.0, + 'amount_currency': -208.0, + 'credit': 208.0, + }, + ], { + **self.move_vals, + 'amount_untaxed': 160.0, + 'amount_tax': 48.0, + 'amount_total': 208.0, + }) + + def test_in_invoice_line_onchange_accounting_fields_1(self): + move_form = Form(self.invoice) + with move_form.line_ids.edit(2) as line_form: + # Custom debit on the first product line. + line_form.debit = 3000 + with move_form.line_ids.edit(3) as line_form: + # Custom credit on the second product line. Credit should be reset by onchange. + # /!\ It's a negative line. + line_form.credit = 500 + with move_form.line_ids.edit(0) as line_form: + # Custom debit on the first tax line. + line_form.debit = 800 + with move_form.line_ids.edit(4) as line_form: + # Custom debit on the second tax line. + line_form.debit = 250 + move_form.save() + + self.assertInvoiceValues(self.invoice, [ + { + **self.product_line_vals_1, + 'price_unit': 3000.0, + 'price_subtotal': 3000.0, + 'price_total': 3450.0, + 'amount_currency': 3000.0, + 'debit': 3000.0, + }, + { + **self.product_line_vals_2, + 'price_unit': -500.0, + 'price_subtotal': -500.0, + 'price_total': -650.0, + 'amount_currency': -500.0, + 'debit': 0.0, + 'credit': 500.0, + }, + { + **self.tax_line_vals_1, + 'price_unit': 800.0, + 'price_subtotal': 800.0, + 'price_total': 800.0, + 'amount_currency': 800.0, + 'debit': 800.0, + }, + { + **self.tax_line_vals_2, + 'price_unit': 250.0, + 'price_subtotal': 250.0, + 'price_total': 250.0, + 'amount_currency': 250.0, + 'debit': 250.0, + }, + { + **self.term_line_vals_1, + 'price_unit': -3550.0, + 'price_subtotal': -3550.0, + 'price_total': -3550.0, + 'amount_currency': -3550.0, + 'credit': 3550.0, + }, + ], { + **self.move_vals, + 'amount_untaxed': 2500.0, + 'amount_tax': 1050.0, + 'amount_total': 3550.0, + }) + + def test_in_invoice_line_onchange_partner_1(self): + move_form = Form(self.invoice) + move_form.partner_id = self.partner_b + move_form.payment_reference = 'turlututu' + move_form.save() + + self.assertInvoiceValues(self.invoice, [ + { + **self.product_line_vals_1, + 'partner_id': self.partner_b.id, + }, + { + **self.product_line_vals_2, + 'partner_id': self.partner_b.id, + }, + { + **self.tax_line_vals_1, + 'partner_id': self.partner_b.id, + }, + { + **self.tax_line_vals_2, + 'partner_id': self.partner_b.id, + }, + { + **self.term_line_vals_1, + 'name': 'turlututu', + 'partner_id': self.partner_b.id, + 'account_id': self.partner_b.property_account_payable_id.id, + 'price_unit': -789.6, + 'price_subtotal': -789.6, + 'price_total': -789.6, + 'amount_currency': -789.6, + 'credit': 789.6, + 'date_maturity': fields.Date.from_string('2019-02-28'), + }, + { + **self.term_line_vals_1, + 'name': 'turlututu', + 'partner_id': self.partner_b.id, + 'account_id': self.partner_b.property_account_payable_id.id, + 'price_unit': -338.4, + 'price_subtotal': -338.4, + 'price_total': -338.4, + 'amount_currency': -338.4, + 'credit': 338.4, + }, + ], { + **self.move_vals, + 'partner_id': self.partner_b.id, + 'payment_reference': 'turlututu', + 'fiscal_position_id': self.fiscal_pos_a.id, + 'invoice_payment_term_id': self.pay_terms_b.id, + 'amount_untaxed': 960.0, + 'amount_tax': 168.0, + 'amount_total': 1128.0, + }) + + # Remove lines and recreate them to apply the fiscal position. + move_form = Form(self.invoice) + move_form.invoice_line_ids.remove(0) + move_form.invoice_line_ids.remove(0) + with move_form.invoice_line_ids.new() as line_form: + line_form.product_id = self.product_a + with move_form.invoice_line_ids.new() as line_form: + line_form.product_id = self.product_b + move_form.save() + + self.assertInvoiceValues(self.invoice, [ + { + **self.product_line_vals_1, + 'account_id': self.product_b.property_account_expense_id.id, + 'partner_id': self.partner_b.id, + 'tax_ids': self.tax_purchase_b.ids, + }, + { + **self.product_line_vals_2, + 'partner_id': self.partner_b.id, + 'price_total': 184.0, + 'tax_ids': self.tax_purchase_b.ids, + }, + { + **self.tax_line_vals_1, + 'name': self.tax_purchase_b.name, + 'partner_id': self.partner_b.id, + 'tax_line_id': self.tax_purchase_b.id, + }, + { + **self.term_line_vals_1, + 'name': 'turlututu', + 'account_id': self.partner_b.property_account_payable_id.id, + 'partner_id': self.partner_b.id, + 'price_unit': -772.8, + 'price_subtotal': -772.8, + 'price_total': -772.8, + 'amount_currency': -772.8, + 'credit': 772.8, + 'date_maturity': fields.Date.from_string('2019-02-28'), + }, + { + **self.term_line_vals_1, + 'name': 'turlututu', + 'account_id': self.partner_b.property_account_payable_id.id, + 'partner_id': self.partner_b.id, + 'price_unit': -331.2, + 'price_subtotal': -331.2, + 'price_total': -331.2, + 'amount_currency': -331.2, + 'credit': 331.2, + }, + ], { + **self.move_vals, + 'partner_id': self.partner_b.id, + 'payment_reference': 'turlututu', + 'fiscal_position_id': self.fiscal_pos_a.id, + 'invoice_payment_term_id': self.pay_terms_b.id, + 'amount_untaxed': 960.0, + 'amount_tax': 144.0, + 'amount_total': 1104.0, + }) + + def test_in_invoice_line_onchange_taxes_1(self): + move_form = Form(self.invoice) + with move_form.invoice_line_ids.edit(0) as line_form: + line_form.price_unit = 960 + line_form.tax_ids.add(self.tax_armageddon) + move_form.save() + + child_tax_1 = self.tax_armageddon.children_tax_ids[0] + child_tax_2 = self.tax_armageddon.children_tax_ids[1] + + self.assertInvoiceValues(self.invoice, [ + { + **self.product_line_vals_1, + 'price_unit': 960.0, + 'price_subtotal': 800.0, + 'price_total': 1176.0, + 'tax_ids': (self.tax_purchase_a + self.tax_armageddon).ids, + 'tax_exigible': False, + }, + self.product_line_vals_2, + self.tax_line_vals_1, + self.tax_line_vals_2, + { + 'name': child_tax_1.name, + 'product_id': False, + 'account_id': self.company_data['default_account_tax_sale'].id, + 'partner_id': self.partner_a.id, + 'product_uom_id': False, + 'quantity': 1.0, + 'discount': 0.0, + 'price_unit': 64.0, + 'price_subtotal': 64.0, + 'price_total': 70.4, + 'tax_ids': child_tax_2.ids, + 'tax_line_id': child_tax_1.id, + 'currency_id': self.company_data['currency'].id, + 'amount_currency': 64.0, + 'debit': 64.0, + 'credit': 0.0, + 'date_maturity': False, + 'tax_exigible': False, + }, + { + 'name': child_tax_1.name, + 'product_id': False, + 'account_id': self.company_data['default_account_expense'].id, + 'partner_id': self.partner_a.id, + 'product_uom_id': False, + 'quantity': 1.0, + 'discount': 0.0, + 'price_unit': 96.0, + 'price_subtotal': 96.0, + 'price_total': 105.6, + 'tax_ids': child_tax_2.ids, + 'tax_line_id': child_tax_1.id, + 'currency_id': self.company_data['currency'].id, + 'amount_currency': 96.0, + 'debit': 96.0, + 'credit': 0.0, + 'date_maturity': False, + 'tax_exigible': False, + }, + { + 'name': child_tax_2.name, + 'product_id': False, + 'account_id': child_tax_2.cash_basis_transition_account_id.id, + 'partner_id': self.partner_a.id, + 'product_uom_id': False, + 'quantity': 1.0, + 'discount': 0.0, + 'price_unit': 96.0, + 'price_subtotal': 96.0, + 'price_total': 96.0, + 'tax_ids': [], + 'tax_line_id': child_tax_2.id, + 'currency_id': self.company_data['currency'].id, + 'amount_currency': 96.0, + 'debit': 96.0, + 'credit': 0.0, + 'date_maturity': False, + 'tax_exigible': False, + }, + { + **self.term_line_vals_1, + 'price_unit': -1384.0, + 'price_subtotal': -1384.0, + 'price_total': -1384.0, + 'amount_currency': -1384.0, + 'credit': 1384.0, + }, + ], { + **self.move_vals, + 'amount_untaxed': 960.0, + 'amount_tax': 424.0, + 'amount_total': 1384.0, + }) + + def test_in_invoice_line_onchange_cash_rounding_1(self): + move_form = Form(self.invoice) + # Add a cash rounding having 'add_invoice_line'. + move_form.invoice_cash_rounding_id = self.cash_rounding_a + move_form.save() + + # The cash rounding does nothing as the total is already rounded. + self.assertInvoiceValues(self.invoice, [ + self.product_line_vals_1, + self.product_line_vals_2, + self.tax_line_vals_1, + self.tax_line_vals_2, + self.term_line_vals_1, + ], self.move_vals) + + move_form = Form(self.invoice) + with move_form.invoice_line_ids.edit(0) as line_form: + line_form.price_unit = 799.99 + move_form.save() + + self.assertInvoiceValues(self.invoice, [ + { + 'name': 'add_invoice_line', + 'product_id': False, + 'account_id': self.cash_rounding_a.loss_account_id.id, + 'partner_id': self.partner_a.id, + 'product_uom_id': False, + 'quantity': 1.0, + 'discount': 0.0, + 'price_unit': 0.01, + 'price_subtotal': 0.01, + 'price_total': 0.01, + 'tax_ids': [], + 'tax_line_id': False, + 'currency_id': self.company_data['currency'].id, + 'amount_currency': 0.01, + 'debit': 0.01, + 'credit': 0.0, + 'date_maturity': False, + 'tax_exigible': True, + }, + { + **self.product_line_vals_1, + 'price_unit': 799.99, + 'price_subtotal': 799.99, + 'price_total': 919.99, + 'amount_currency': 799.99, + 'debit': 799.99, + }, + self.product_line_vals_2, + self.tax_line_vals_1, + self.tax_line_vals_2, + self.term_line_vals_1, + ], self.move_vals) + + move_form = Form(self.invoice) + # Change the cash rounding to one having 'biggest_tax'. + move_form.invoice_cash_rounding_id = self.cash_rounding_b + move_form.save() + + self.assertInvoiceValues(self.invoice, [ + { + **self.product_line_vals_1, + 'price_unit': 799.99, + 'price_subtotal': 799.99, + 'price_total': 919.99, + 'amount_currency': 799.99, + 'debit': 799.99, + }, + self.product_line_vals_2, + self.tax_line_vals_1, + self.tax_line_vals_2, + { + 'name': '%s (rounding)' % self.tax_purchase_a.name, + 'product_id': False, + 'account_id': self.company_data['default_account_tax_purchase'].id, + 'partner_id': self.partner_a.id, + 'product_uom_id': False, + 'quantity': 1.0, + 'discount': 0.0, + 'price_unit': -0.04, + 'price_subtotal': -0.04, + 'price_total': -0.04, + 'tax_ids': [], + 'tax_line_id': self.tax_purchase_a.id, + 'currency_id': self.company_data['currency'].id, + 'amount_currency': -0.04, + 'debit': 0.0, + 'credit': 0.04, + 'date_maturity': False, + 'tax_exigible': True, + }, + { + **self.term_line_vals_1, + 'price_unit': -1127.95, + 'price_subtotal': -1127.95, + 'price_total': -1127.95, + 'amount_currency': -1127.95, + 'credit': 1127.95, + }, + ], { + **self.move_vals, + 'amount_untaxed': 959.99, + 'amount_tax': 167.96, + 'amount_total': 1127.95, + }) + + def test_in_invoice_line_onchange_currency_1(self): + move_form = Form(self.invoice) + move_form.currency_id = self.currency_data['currency'] + move_form.save() + + self.assertInvoiceValues(self.invoice, [ + { + **self.product_line_vals_1, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 800.0, + 'debit': 400.0, + }, + { + **self.product_line_vals_2, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 160.0, + 'debit': 80.0, + }, + { + **self.tax_line_vals_1, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 144.0, + 'debit': 72.0, + }, + { + **self.tax_line_vals_2, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 24.0, + 'debit': 12.0, + }, + { + **self.term_line_vals_1, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -1128.0, + 'credit': 564.0, + }, + ], { + **self.move_vals, + 'currency_id': self.currency_data['currency'].id, + }) + + move_form = Form(self.invoice) + # Change the date to get another rate: 1/3 instead of 1/2. + move_form.date = fields.Date.from_string('2016-01-01') + move_form.save() + + self.assertInvoiceValues(self.invoice, [ + { + **self.product_line_vals_1, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 800.0, + 'debit': 266.67, + }, + { + **self.product_line_vals_2, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 160.0, + 'debit': 53.33, + }, + { + **self.tax_line_vals_1, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 144.0, + 'debit': 48.0, + }, + { + **self.tax_line_vals_2, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 24.0, + 'debit': 8.0, + }, + { + **self.term_line_vals_1, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -1128.0, + 'credit': 376.0, + }, + ], { + **self.move_vals, + 'currency_id': self.currency_data['currency'].id, + 'date': fields.Date.from_string('2016-01-01'), + }) + + move_form = Form(self.invoice) + with move_form.invoice_line_ids.edit(0) as line_form: + # 0.045 * 0.1 = 0.0045. As the foreign currency has a 0.001 rounding, + # the result should be 0.005 after rounding. + line_form.quantity = 0.1 + line_form.price_unit = 0.045 + move_form.save() + + self.assertInvoiceValues(self.invoice, [ + { + **self.product_line_vals_1, + 'quantity': 0.1, + 'price_unit': 0.05, + 'price_subtotal': 0.005, + 'price_total': 0.006, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 0.005, + 'debit': 0.0, + }, + { + **self.product_line_vals_2, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 160.0, + 'debit': 53.33, + }, + { + **self.tax_line_vals_1, + 'price_unit': 24.0, + 'price_subtotal': 24.001, + 'price_total': 24.001, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 24.001, + 'debit': 8.0, + }, + { + **self.tax_line_vals_2, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 24.0, + 'debit': 8.0, + }, + { + **self.term_line_vals_1, + 'currency_id': self.currency_data['currency'].id, + 'price_unit': -208.01, + 'price_subtotal': -208.006, + 'price_total': -208.006, + 'amount_currency': -208.006, + 'credit': 69.33, + }, + ], { + **self.move_vals, + 'currency_id': self.currency_data['currency'].id, + 'date': fields.Date.from_string('2016-01-01'), + 'amount_untaxed': 160.005, + 'amount_tax': 48.001, + 'amount_total': 208.006, + }) + + # Exit the multi-currencies. + move_form = Form(self.invoice) + move_form.currency_id = self.company_data['currency'] + move_form.save() + + self.assertInvoiceValues(self.invoice, [ + { + **self.product_line_vals_1, + 'quantity': 0.1, + 'price_unit': 0.05, + 'price_subtotal': 0.01, + 'price_total': 0.01, + 'amount_currency': 0.01, + 'debit': 0.01, + }, + self.product_line_vals_2, + { + **self.tax_line_vals_1, + 'price_unit': 24.0, + 'price_subtotal': 24.0, + 'price_total': 24.0, + 'amount_currency': 24.0, + 'debit': 24.0, + }, + self.tax_line_vals_2, + { + **self.term_line_vals_1, + 'price_unit': -208.01, + 'price_subtotal': -208.01, + 'price_total': -208.01, + 'amount_currency': -208.01, + 'credit': 208.01, + }, + ], { + **self.move_vals, + 'currency_id': self.company_data['currency'].id, + 'date': fields.Date.from_string('2016-01-01'), + 'amount_untaxed': 160.01, + 'amount_tax': 48.0, + 'amount_total': 208.01, + }) + + def test_in_invoice_onchange_past_invoice_1(self): + copy_invoice = self.invoice.copy() + + move_form = Form(self.invoice) + move_form.invoice_line_ids.remove(0) + move_form.invoice_line_ids.remove(0) + move_form.invoice_vendor_bill_id = copy_invoice + move_form.save() + + self.assertInvoiceValues(self.invoice, [ + self.product_line_vals_1, + self.product_line_vals_2, + self.tax_line_vals_1, + self.tax_line_vals_2, + self.term_line_vals_1, + ], self.move_vals) + + def test_in_invoice_create_refund(self): + self.invoice.action_post() + + move_reversal = self.env['account.move.reversal'].with_context(active_model="account.move", active_ids=self.invoice.ids).create({ + 'date': fields.Date.from_string('2019-02-01'), + 'reason': 'no reason', + 'refund_method': 'refund', + }) + reversal = move_reversal.reverse_moves() + reverse_move = self.env['account.move'].browse(reversal['res_id']) + + self.assertEqual(self.invoice.payment_state, 'not_paid', "Refunding with a draft credit note should keep the invoice 'not_paid'.") + self.assertInvoiceValues(reverse_move, [ + { + **self.product_line_vals_1, + 'amount_currency': -800.0, + 'debit': 0.0, + 'credit': 800.0, + }, + { + **self.product_line_vals_2, + 'amount_currency': -160.0, + 'debit': 0.0, + 'credit': 160.0, + }, + { + **self.tax_line_vals_1, + 'amount_currency': -144.0, + 'debit': 0.0, + 'credit': 144.0, + }, + { + **self.tax_line_vals_2, + 'amount_currency': -24.0, + 'debit': 0.0, + 'credit': 24.0, + }, + { + **self.term_line_vals_1, + 'name': '', + 'amount_currency': 1128.0, + 'debit': 1128.0, + 'credit': 0.0, + 'date_maturity': move_reversal.date, + }, + ], { + **self.move_vals, + 'invoice_payment_term_id': None, + 'date': move_reversal.date, + 'state': 'draft', + 'ref': 'Reversal of: %s, %s' % (self.invoice.name, move_reversal.reason), + 'payment_state': 'not_paid', + }) + + move_reversal = self.env['account.move.reversal'].with_context(active_model="account.move", active_ids=self.invoice.ids).create({ + 'date': fields.Date.from_string('2019-02-01'), + 'reason': 'no reason again', + 'refund_method': 'cancel', + }) + reversal = move_reversal.reverse_moves() + reverse_move = self.env['account.move'].browse(reversal['res_id']) + + self.assertEqual(self.invoice.payment_state, 'reversed', "After cancelling it with a reverse invoice, an invoice should be in 'reversed' state.") + self.assertInvoiceValues(reverse_move, [ + { + **self.product_line_vals_1, + 'amount_currency': -800.0, + 'debit': 0.0, + 'credit': 800.0, + }, + { + **self.product_line_vals_2, + 'amount_currency': -160.0, + 'debit': 0.0, + 'credit': 160.0, + }, + { + **self.tax_line_vals_1, + 'amount_currency': -144.0, + 'debit': 0.0, + 'credit': 144.0, + }, + { + **self.tax_line_vals_2, + 'amount_currency': -24.0, + 'debit': 0.0, + 'credit': 24.0, + }, + { + **self.term_line_vals_1, + 'name': '', + 'amount_currency': 1128.0, + 'debit': 1128.0, + 'credit': 0.0, + 'date_maturity': move_reversal.date, + }, + ], { + **self.move_vals, + 'invoice_payment_term_id': None, + 'date': move_reversal.date, + 'state': 'posted', + 'ref': 'Reversal of: %s, %s' % (self.invoice.name, move_reversal.reason), + 'payment_state': 'paid', + }) + + def test_in_invoice_create_refund_multi_currency(self): + ''' Test the account.move.reversal takes care about the currency rates when setting + a custom reversal date. + ''' + move_form = Form(self.invoice) + move_form.date = '2016-01-01' + move_form.currency_id = self.currency_data['currency'] + move_form.save() + + self.invoice.action_post() + + # The currency rate changed from 1/3 to 1/2. + move_reversal = self.env['account.move.reversal'].with_context(active_model="account.move", active_ids=self.invoice.ids).create({ + 'date': fields.Date.from_string('2017-01-01'), + 'reason': 'no reason', + 'refund_method': 'refund', + }) + reversal = move_reversal.reverse_moves() + reverse_move = self.env['account.move'].browse(reversal['res_id']) + + self.assertEqual(self.invoice.payment_state, 'not_paid', "Refunding with a draft credit note should keep the invoice 'not_paid'.") + self.assertInvoiceValues(reverse_move, [ + { + **self.product_line_vals_1, + 'amount_currency': -800.0, + 'currency_id': self.currency_data['currency'].id, + 'debit': 0.0, + 'credit': 400.0, + }, + { + **self.product_line_vals_2, + 'amount_currency': -160.0, + 'currency_id': self.currency_data['currency'].id, + 'debit': 0.0, + 'credit': 80.0, + }, + { + **self.tax_line_vals_1, + 'amount_currency': -144.0, + 'currency_id': self.currency_data['currency'].id, + 'debit': 0.0, + 'credit': 72.0, + }, + { + **self.tax_line_vals_2, + 'amount_currency': -24.0, + 'currency_id': self.currency_data['currency'].id, + 'debit': 0.0, + 'credit': 12.0, + }, + { + **self.term_line_vals_1, + 'name': '', + 'amount_currency': 1128.0, + 'currency_id': self.currency_data['currency'].id, + 'debit': 564.0, + 'credit': 0.0, + 'date_maturity': move_reversal.date, + }, + ], { + **self.move_vals, + 'invoice_payment_term_id': None, + 'currency_id': self.currency_data['currency'].id, + 'date': move_reversal.date, + 'state': 'draft', + 'ref': 'Reversal of: %s, %s' % (self.invoice.name, move_reversal.reason), + 'payment_state': 'not_paid', + }) + + move_reversal = self.env['account.move.reversal'].with_context(active_model="account.move", active_ids=self.invoice.ids).create({ + 'date': fields.Date.from_string('2017-01-01'), + 'reason': 'no reason again', + 'refund_method': 'cancel', + }) + reversal = move_reversal.reverse_moves() + reverse_move = self.env['account.move'].browse(reversal['res_id']) + + self.assertEqual(self.invoice.payment_state, 'reversed', "After cancelling it with a reverse invoice, an invoice should be in 'reversed' state.") + self.assertInvoiceValues(reverse_move, [ + { + **self.product_line_vals_1, + 'amount_currency': -800.0, + 'currency_id': self.currency_data['currency'].id, + 'debit': 0.0, + 'credit': 400.0, + }, + { + **self.product_line_vals_2, + 'amount_currency': -160.0, + 'currency_id': self.currency_data['currency'].id, + 'debit': 0.0, + 'credit': 80.0, + }, + { + **self.tax_line_vals_1, + 'amount_currency': -144.0, + 'currency_id': self.currency_data['currency'].id, + 'debit': 0.0, + 'credit': 72.0, + }, + { + **self.tax_line_vals_2, + 'amount_currency': -24.0, + 'currency_id': self.currency_data['currency'].id, + 'debit': 0.0, + 'credit': 12.0, + }, + { + **self.term_line_vals_1, + 'name': '', + 'amount_currency': 1128.0, + 'currency_id': self.currency_data['currency'].id, + 'debit': 564.0, + 'credit': 0.0, + 'date_maturity': move_reversal.date, + }, + ], { + **self.move_vals, + 'invoice_payment_term_id': None, + 'currency_id': self.currency_data['currency'].id, + 'date': move_reversal.date, + 'state': 'posted', + 'ref': 'Reversal of: %s, %s' % (self.invoice.name, move_reversal.reason), + 'payment_state': 'paid', + }) + + def test_in_invoice_create_1(self): + # Test creating an account_move with the least information. + move = self.env['account.move'].create({ + 'move_type': 'in_invoice', + 'partner_id': self.partner_a.id, + 'invoice_date': fields.Date.from_string('2019-01-01'), + 'currency_id': self.currency_data['currency'].id, + 'invoice_payment_term_id': self.pay_terms_a.id, + 'invoice_line_ids': [ + (0, None, self.product_line_vals_1), + (0, None, self.product_line_vals_2), + ] + }) + + self.assertInvoiceValues(move, [ + { + **self.product_line_vals_1, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 800.0, + 'debit': 400.0, + }, + { + **self.product_line_vals_2, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 160.0, + 'debit': 80.0, + }, + { + **self.tax_line_vals_1, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 144.0, + 'debit': 72.0, + }, + { + **self.tax_line_vals_2, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 24.0, + 'debit': 12.0, + }, + { + **self.term_line_vals_1, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -1128.0, + 'credit': 564.0, + }, + ], { + **self.move_vals, + 'currency_id': self.currency_data['currency'].id, + }) + + def test_in_invoice_write_1(self): + # Test creating an account_move with the least information. + move = self.env['account.move'].create({ + 'move_type': 'in_invoice', + 'partner_id': self.partner_a.id, + 'invoice_date': fields.Date.from_string('2019-01-01'), + 'currency_id': self.currency_data['currency'].id, + 'invoice_payment_term_id': self.pay_terms_a.id, + 'invoice_line_ids': [ + (0, None, self.product_line_vals_1), + ] + }) + move.write({ + 'invoice_line_ids': [ + (0, None, self.product_line_vals_2), + ] + }) + + self.assertInvoiceValues(move, [ + { + **self.product_line_vals_1, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 800.0, + 'debit': 400.0, + }, + { + **self.product_line_vals_2, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 160.0, + 'debit': 80.0, + }, + { + **self.tax_line_vals_1, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 144.0, + 'debit': 72.0, + }, + { + **self.tax_line_vals_2, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 24.0, + 'debit': 12.0, + }, + { + **self.term_line_vals_1, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -1128.0, + 'credit': 564.0, + }, + ], { + **self.move_vals, + 'currency_id': self.currency_data['currency'].id, + }) + + def test_in_invoice_duplicate_supplier_reference(self): + ''' Ensure two vendor bills can't share the same vendor reference. ''' + self.invoice.ref = 'a supplier reference' + invoice2 = self.invoice.copy(default={'invoice_date': self.invoice.invoice_date}) + + with self.assertRaises(ValidationError): + invoice2.ref = 'a supplier reference' + + def test_in_invoice_switch_in_refund_1(self): + # Test creating an account_move with an in_invoice_type and switch it in an in_refund. + move = self.env['account.move'].create({ + 'move_type': 'in_invoice', + 'partner_id': self.partner_a.id, + 'invoice_date': fields.Date.from_string('2019-01-01'), + 'currency_id': self.currency_data['currency'].id, + 'invoice_payment_term_id': self.pay_terms_a.id, + 'invoice_line_ids': [ + (0, None, self.product_line_vals_1), + (0, None, self.product_line_vals_2), + ] + }) + move.action_switch_invoice_into_refund_credit_note() + + self.assertRecordValues(move, [{'move_type': 'in_refund'}]) + self.assertInvoiceValues(move, [ + { + **self.product_line_vals_1, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -800.0, + 'credit': 400.0, + 'debit': 0, + }, + { + **self.product_line_vals_2, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -160.0, + 'credit': 80.0, + 'debit': 0, + }, + { + **self.tax_line_vals_1, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -144.0, + 'credit': 72.0, + 'debit': 0, + }, + { + **self.tax_line_vals_2, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -24.0, + 'credit': 12.0, + 'debit': 0, + }, + { + **self.term_line_vals_1, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 1128.0, + 'debit': 564.0, + 'credit': 0, + }, + ], { + **self.move_vals, + 'currency_id': self.currency_data['currency'].id, + }) + + def test_in_invoice_switch_in_refund_2(self): + # Test creating an account_move with an in_invoice_type and switch it in an in_refund and a negative quantity. + modified_product_line_vals_1 = self.product_line_vals_1.copy() + modified_product_line_vals_1.update({'quantity': -modified_product_line_vals_1['quantity']}) + modified_product_line_vals_2 = self.product_line_vals_2.copy() + modified_product_line_vals_2.update({'quantity': -modified_product_line_vals_2['quantity']}) + move = self.env['account.move'].create({ + 'move_type': 'in_invoice', + 'partner_id': self.partner_a.id, + 'invoice_date': fields.Date.from_string('2019-01-01'), + 'currency_id': self.currency_data['currency'].id, + 'invoice_payment_term_id': self.pay_terms_a.id, + 'invoice_line_ids': [ + (0, None, modified_product_line_vals_1), + (0, None, modified_product_line_vals_2), + ] + }) + + self.assertInvoiceValues(move, [ + { + **self.product_line_vals_1, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -800.0, + 'price_subtotal': -800.0, + 'price_total': -920.0, + 'credit': 400.0, + 'debit': 0, + 'quantity': -1.0, + }, + { + **self.product_line_vals_2, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -160.0, + 'price_subtotal': -160.0, + 'price_total': -208.0, + 'credit': 80.0, + 'debit': 0, + 'quantity': -1.0, + }, + { + **self.tax_line_vals_1, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -144.0, + 'price_subtotal': -144.0, + 'price_total': -144.0, + 'price_unit': -144.0, + 'credit': 72.0, + 'debit': 0, + }, + { + **self.tax_line_vals_2, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -24.0, + 'price_subtotal': -24.0, + 'price_total': -24.0, + 'price_unit': -24.0, + 'credit': 12.0, + 'debit': 0, + }, + { + **self.term_line_vals_1, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 1128.0, + 'price_subtotal': 1128.0, + 'price_total': 1128.0, + 'price_unit': 1128.0, + 'debit': 564.0, + 'credit': 0, + }, + ], { + **self.move_vals, + 'currency_id': self.currency_data['currency'].id, + 'amount_tax' : -self.move_vals['amount_tax'], + 'amount_total' : -self.move_vals['amount_total'], + 'amount_untaxed' : -self.move_vals['amount_untaxed'], + }) + move.action_switch_invoice_into_refund_credit_note() + + self.assertRecordValues(move, [{'move_type': 'in_refund'}]) + self.assertInvoiceValues(move, [ + { + **self.product_line_vals_1, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -800.0, + 'credit': 400.0, + 'debit': 0, + }, + { + **self.product_line_vals_2, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -160.0, + 'credit': 80.0, + 'debit': 0, + }, + { + **self.tax_line_vals_1, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -144.0, + 'credit': 72.0, + 'debit': 0, + }, + { + **self.tax_line_vals_2, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -24.0, + 'credit': 12.0, + 'debit': 0, + }, + { + **self.term_line_vals_1, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 1128.0, + 'debit': 564.0, + 'credit': 0, + }, + ], { + **self.move_vals, + 'currency_id': self.currency_data['currency'].id, + 'amount_tax' : self.move_vals['amount_tax'], + 'amount_total' : self.move_vals['amount_total'], + 'amount_untaxed' : self.move_vals['amount_untaxed'], + }) + + def test_in_invoice_change_period_accrual_1(self): + move = self.env['account.move'].create({ + 'move_type': 'in_invoice', + 'date': '2017-01-01', + 'partner_id': self.partner_a.id, + 'invoice_date': fields.Date.from_string('2017-01-01'), + 'currency_id': self.currency_data['currency'].id, + 'invoice_payment_term_id': self.pay_terms_a.id, + 'invoice_line_ids': [ + (0, None, { + 'name': self.product_line_vals_1['name'], + 'product_id': self.product_line_vals_1['product_id'], + 'product_uom_id': self.product_line_vals_1['product_uom_id'], + 'quantity': self.product_line_vals_1['quantity'], + 'price_unit': self.product_line_vals_1['price_unit'], + 'tax_ids': self.product_line_vals_1['tax_ids'], + }), + (0, None, { + 'name': self.product_line_vals_2['name'], + 'product_id': self.product_line_vals_2['product_id'], + 'product_uom_id': self.product_line_vals_2['product_uom_id'], + 'quantity': self.product_line_vals_2['quantity'], + 'price_unit': self.product_line_vals_2['price_unit'], + 'tax_ids': self.product_line_vals_2['tax_ids'], + }), + ] + }) + move.action_post() + + wizard = self.env['account.automatic.entry.wizard']\ + .with_context(active_model='account.move.line', active_ids=move.invoice_line_ids.ids).create({ + 'action': 'change_period', + 'date': '2018-01-01', + 'percentage': 60, + 'journal_id': self.company_data['default_journal_misc'].id, + 'expense_accrual_account': self.env['account.account'].create({ + 'name': 'Accrual Expense Account', + 'code': '234567', + 'user_type_id': self.env.ref('account.data_account_type_expenses').id, + 'reconcile': True, + }).id, + 'revenue_accrual_account': self.env['account.account'].create({ + 'name': 'Accrual Revenue Account', + 'code': '765432', + 'user_type_id': self.env.ref('account.data_account_type_expenses').id, + 'reconcile': True, + }).id, + }) + wizard_res = wizard.do_action() + + self.assertInvoiceValues(move, [ + { + **self.product_line_vals_1, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 800.0, + 'debit': 400.0, + 'credit': 0.0, + }, + { + **self.product_line_vals_2, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 160.0, + 'debit': 80.0, + 'credit': 0.0, + }, + { + **self.tax_line_vals_1, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 144.0, + 'debit': 72.0, + 'credit': 0.0, + }, + { + **self.tax_line_vals_2, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 24.0, + 'debit': 12.0, + 'credit': 0.0, + }, + { + **self.term_line_vals_1, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -1128.0, + 'debit': 0.0, + 'credit': 564.0, + 'date_maturity': fields.Date.from_string('2017-01-01'), + }, + ], { + **self.move_vals, + 'currency_id': self.currency_data['currency'].id, + 'date': fields.Date.from_string('2017-01-01'), + }) + + accrual_lines = self.env['account.move'].browse(wizard_res['domain'][0][2]).line_ids.sorted('date') + self.assertRecordValues(accrual_lines, [ + {'amount_currency': -480.0, 'debit': 0.0, 'credit': 240.0, 'account_id': self.product_line_vals_1['account_id'], 'reconciled': False}, + {'amount_currency': 480.0, 'debit': 240.0, 'credit': 0.0, 'account_id': wizard.expense_accrual_account.id, 'reconciled': True}, + {'amount_currency': -96.0, 'debit': 0.0, 'credit': 48.0, 'account_id': self.product_line_vals_2['account_id'], 'reconciled': False}, + {'amount_currency': 96.0, 'debit': 48.0, 'credit': 0.0, 'account_id': wizard.expense_accrual_account.id, 'reconciled': True}, + {'amount_currency': 480.0, 'debit': 240.0, 'credit': 0.0, 'account_id': self.product_line_vals_1['account_id'], 'reconciled': False}, + {'amount_currency': -480.0, 'debit': 0.0, 'credit': 240.0, 'account_id': wizard.expense_accrual_account.id, 'reconciled': True}, + {'amount_currency': 96.0, 'debit': 48.0, 'credit': 0.0, 'account_id': self.product_line_vals_2['account_id'], 'reconciled': False}, + {'amount_currency': -96.0, 'debit': 0.0, 'credit': 48.0, 'account_id': wizard.expense_accrual_account.id, 'reconciled': True}, + ]) diff --git a/addons/account/tests/test_account_move_in_refund.py b/addons/account/tests/test_account_move_in_refund.py new file mode 100644 index 00000000..593cdde3 --- /dev/null +++ b/addons/account/tests/test_account_move_in_refund.py @@ -0,0 +1,950 @@ +# -*- coding: utf-8 -*- +from odoo.addons.account.tests.common import AccountTestInvoicingCommon +from odoo.tests.common import Form +from odoo.tests import tagged +from odoo import fields + + +@tagged('post_install', '-at_install') +class TestAccountMoveInRefundOnchanges(AccountTestInvoicingCommon): + + @classmethod + def setUpClass(cls, chart_template_ref=None): + super().setUpClass(chart_template_ref=chart_template_ref) + + cls.invoice = cls.init_invoice('in_refund', products=cls.product_a+cls.product_b) + + cls.product_line_vals_1 = { + 'name': cls.product_a.name, + 'product_id': cls.product_a.id, + 'account_id': cls.product_a.property_account_expense_id.id, + 'partner_id': cls.partner_a.id, + 'product_uom_id': cls.product_a.uom_id.id, + 'quantity': 1.0, + 'discount': 0.0, + 'price_unit': 800.0, + 'price_subtotal': 800.0, + 'price_total': 920.0, + 'tax_ids': cls.product_a.supplier_taxes_id.ids, + 'tax_line_id': False, + 'currency_id': cls.company_data['currency'].id, + 'amount_currency': -800.0, + 'debit': 0.0, + 'credit': 800.0, + 'date_maturity': False, + 'tax_exigible': True, + } + cls.product_line_vals_2 = { + 'name': cls.product_b.name, + 'product_id': cls.product_b.id, + 'account_id': cls.product_b.property_account_expense_id.id, + 'partner_id': cls.partner_a.id, + 'product_uom_id': cls.product_b.uom_id.id, + 'quantity': 1.0, + 'discount': 0.0, + 'price_unit': 160.0, + 'price_subtotal': 160.0, + 'price_total': 208.0, + 'tax_ids': cls.product_b.supplier_taxes_id.ids, + 'tax_line_id': False, + 'currency_id': cls.company_data['currency'].id, + 'amount_currency': -160.0, + 'debit': 0.0, + 'credit': 160.0, + 'date_maturity': False, + 'tax_exigible': True, + } + cls.tax_line_vals_1 = { + 'name': cls.tax_purchase_a.name, + 'product_id': False, + 'account_id': cls.company_data['default_account_tax_purchase'].id, + 'partner_id': cls.partner_a.id, + 'product_uom_id': False, + 'quantity': 1.0, + 'discount': 0.0, + 'price_unit': 144.0, + 'price_subtotal': 144.0, + 'price_total': 144.0, + 'tax_ids': [], + 'tax_line_id': cls.tax_purchase_a.id, + 'currency_id': cls.company_data['currency'].id, + 'amount_currency': -144.0, + 'debit': 0.0, + 'credit': 144.0, + 'date_maturity': False, + 'tax_exigible': True, + } + cls.tax_line_vals_2 = { + 'name': cls.tax_purchase_b.name, + 'product_id': False, + 'account_id': cls.company_data['default_account_tax_purchase'].id, + 'partner_id': cls.partner_a.id, + 'product_uom_id': False, + 'quantity': 1.0, + 'discount': 0.0, + 'price_unit': 24.0, + 'price_subtotal': 24.0, + 'price_total': 24.0, + 'tax_ids': [], + 'tax_line_id': cls.tax_purchase_b.id, + 'currency_id': cls.company_data['currency'].id, + 'amount_currency': -24.0, + 'debit': 0.0, + 'credit': 24.0, + 'date_maturity': False, + 'tax_exigible': True, + } + cls.term_line_vals_1 = { + 'name': '', + 'product_id': False, + 'account_id': cls.company_data['default_account_payable'].id, + 'partner_id': cls.partner_a.id, + 'product_uom_id': False, + 'quantity': 1.0, + 'discount': 0.0, + 'price_unit': -1128.0, + 'price_subtotal': -1128.0, + 'price_total': -1128.0, + 'tax_ids': [], + 'tax_line_id': False, + 'currency_id': cls.company_data['currency'].id, + 'amount_currency': 1128.0, + 'debit': 1128.0, + 'credit': 0.0, + 'date_maturity': fields.Date.from_string('2019-01-01'), + 'tax_exigible': True, + } + cls.move_vals = { + 'partner_id': cls.partner_a.id, + 'currency_id': cls.company_data['currency'].id, + 'journal_id': cls.company_data['default_journal_purchase'].id, + 'date': fields.Date.from_string('2019-01-01'), + 'fiscal_position_id': False, + 'payment_reference': '', + 'invoice_payment_term_id': cls.pay_terms_a.id, + 'amount_untaxed': 960.0, + 'amount_tax': 168.0, + 'amount_total': 1128.0, + } + + def setUp(self): + super(TestAccountMoveInRefundOnchanges, self).setUp() + self.assertInvoiceValues(self.invoice, [ + self.product_line_vals_1, + self.product_line_vals_2, + self.tax_line_vals_1, + self.tax_line_vals_2, + self.term_line_vals_1, + ], self.move_vals) + + def test_in_refund_line_onchange_product_1(self): + move_form = Form(self.invoice) + with move_form.invoice_line_ids.edit(0) as line_form: + line_form.product_id = self.product_b + move_form.save() + + self.assertInvoiceValues(self.invoice, [ + { + **self.product_line_vals_1, + 'name': self.product_b.name, + 'product_id': self.product_b.id, + 'product_uom_id': self.product_b.uom_id.id, + 'account_id': self.product_b.property_account_expense_id.id, + 'price_unit': 160.0, + 'price_subtotal': 160.0, + 'price_total': 208.0, + 'tax_ids': self.product_b.supplier_taxes_id.ids, + 'amount_currency': -160.0, + 'credit': 160.0, + }, + self.product_line_vals_2, + { + **self.tax_line_vals_1, + 'price_unit': 48.0, + 'price_subtotal': 48.0, + 'price_total': 48.0, + 'amount_currency': -48.0, + 'credit': 48.0, + }, + { + **self.tax_line_vals_2, + 'price_unit': 48.0, + 'price_subtotal': 48.0, + 'price_total': 48.0, + 'amount_currency': -48.0, + 'credit': 48.0, + }, + { + **self.term_line_vals_1, + 'price_unit': -416.0, + 'price_subtotal': -416.0, + 'price_total': -416.0, + 'amount_currency': 416.0, + 'debit': 416.0, + }, + ], { + **self.move_vals, + 'amount_untaxed': 320.0, + 'amount_tax': 96.0, + 'amount_total': 416.0, + }) + + def test_in_refund_line_onchange_business_fields_1(self): + move_form = Form(self.invoice) + with move_form.invoice_line_ids.edit(0) as line_form: + # Current price_unit is 800. + # We set quantity = 4, discount = 50%, price_unit = 400. The debit/credit fields don't change because (4 * 400) * 0.5 = 800. + line_form.quantity = 4 + line_form.discount = 50 + line_form.price_unit = 400 + move_form.save() + + self.assertInvoiceValues(self.invoice, [ + { + **self.product_line_vals_1, + 'quantity': 4, + 'discount': 50.0, + 'price_unit': 400.0, + }, + self.product_line_vals_2, + self.tax_line_vals_1, + self.tax_line_vals_2, + self.term_line_vals_1, + ], self.move_vals) + + move_form = Form(self.invoice) + with move_form.line_ids.edit(2) as line_form: + # Reset field except the discount that becomes 100%. + # /!\ The modification is made on the accounting tab. + line_form.quantity = 1 + line_form.discount = 100 + line_form.price_unit = 800 + move_form.save() + + self.assertInvoiceValues(self.invoice, [ + { + **self.product_line_vals_1, + 'discount': 100.0, + 'price_subtotal': 0.0, + 'price_total': 0.0, + 'amount_currency': 0.0, + 'credit': 0.0, + }, + self.product_line_vals_2, + { + **self.tax_line_vals_1, + 'price_unit': 24.0, + 'price_subtotal': 24.0, + 'price_total': 24.0, + 'amount_currency': -24.0, + 'credit': 24.0, + }, + self.tax_line_vals_2, + { + **self.term_line_vals_1, + 'price_unit': -208.0, + 'price_subtotal': -208.0, + 'price_total': -208.0, + 'amount_currency': 208.0, + 'debit': 208.0, + }, + ], { + **self.move_vals, + 'amount_untaxed': 160.0, + 'amount_tax': 48.0, + 'amount_total': 208.0, + }) + + def test_in_refund_line_onchange_accounting_fields_1(self): + move_form = Form(self.invoice) + with move_form.line_ids.edit(2) as line_form: + # Custom credit on the first product line. + line_form.credit = 3000 + with move_form.line_ids.edit(3) as line_form: + # Custom debit on the second product line. Credit should be reset by onchange. + # /!\ It's a negative line. + line_form.debit = 500 + with move_form.line_ids.edit(0) as line_form: + # Custom credit on the first tax line. + line_form.credit = 800 + with move_form.line_ids.edit(4) as line_form: + # Custom credit on the second tax line. + line_form.credit = 250 + move_form.save() + + self.assertInvoiceValues(self.invoice, [ + { + **self.product_line_vals_1, + 'price_unit': 3000.0, + 'price_subtotal': 3000.0, + 'price_total': 3450.0, + 'amount_currency': -3000.0, + 'credit': 3000.0, + }, + { + **self.product_line_vals_2, + 'price_unit': -500.0, + 'price_subtotal': -500.0, + 'price_total': -650.0, + 'amount_currency': 500.0, + 'debit': 500.0, + 'credit': 0.0, + }, + { + **self.tax_line_vals_1, + 'price_unit': 800.0, + 'price_subtotal': 800.0, + 'price_total': 800.0, + 'amount_currency': -800.0, + 'credit': 800.0, + }, + { + **self.tax_line_vals_2, + 'price_unit': 250.0, + 'price_subtotal': 250.0, + 'price_total': 250.0, + 'amount_currency': -250.0, + 'credit': 250.0, + }, + { + **self.term_line_vals_1, + 'price_unit': -3550.0, + 'price_subtotal': -3550.0, + 'price_total': -3550.0, + 'amount_currency': 3550.0, + 'debit': 3550.0, + }, + ], { + **self.move_vals, + 'amount_untaxed': 2500.0, + 'amount_tax': 1050.0, + 'amount_total': 3550.0, + }) + + def test_in_refund_line_onchange_partner_1(self): + move_form = Form(self.invoice) + move_form.partner_id = self.partner_b + move_form.payment_reference = 'turlututu' + move_form.save() + + self.assertInvoiceValues(self.invoice, [ + { + **self.product_line_vals_1, + 'partner_id': self.partner_b.id, + }, + { + **self.product_line_vals_2, + 'partner_id': self.partner_b.id, + }, + { + **self.tax_line_vals_1, + 'partner_id': self.partner_b.id, + }, + { + **self.tax_line_vals_2, + 'partner_id': self.partner_b.id, + }, + { + **self.term_line_vals_1, + 'name': 'turlututu', + 'partner_id': self.partner_b.id, + 'account_id': self.partner_b.property_account_payable_id.id, + 'price_unit': -338.4, + 'price_subtotal': -338.4, + 'price_total': -338.4, + 'amount_currency': 338.4, + 'debit': 338.4, + }, + { + **self.term_line_vals_1, + 'name': 'turlututu', + 'partner_id': self.partner_b.id, + 'account_id': self.partner_b.property_account_payable_id.id, + 'price_unit': -789.6, + 'price_subtotal': -789.6, + 'price_total': -789.6, + 'amount_currency': 789.6, + 'debit': 789.6, + 'date_maturity': fields.Date.from_string('2019-02-28'), + }, + ], { + **self.move_vals, + 'partner_id': self.partner_b.id, + 'payment_reference': 'turlututu', + 'fiscal_position_id': self.fiscal_pos_a.id, + 'invoice_payment_term_id': self.pay_terms_b.id, + 'amount_untaxed': 960.0, + 'amount_tax': 168.0, + 'amount_total': 1128.0, + }) + + # Remove lines and recreate them to apply the fiscal position. + move_form = Form(self.invoice) + move_form.invoice_line_ids.remove(0) + move_form.invoice_line_ids.remove(0) + with move_form.invoice_line_ids.new() as line_form: + line_form.product_id = self.product_a + with move_form.invoice_line_ids.new() as line_form: + line_form.product_id = self.product_b + move_form.save() + + self.assertInvoiceValues(self.invoice, [ + { + **self.product_line_vals_1, + 'account_id': self.product_b.property_account_expense_id.id, + 'partner_id': self.partner_b.id, + 'tax_ids': self.tax_purchase_b.ids, + }, + { + **self.product_line_vals_2, + 'partner_id': self.partner_b.id, + 'price_total': 184.0, + 'tax_ids': self.tax_purchase_b.ids, + }, + { + **self.tax_line_vals_1, + 'name': self.tax_purchase_b.name, + 'partner_id': self.partner_b.id, + 'tax_line_id': self.tax_purchase_b.id, + }, + { + **self.term_line_vals_1, + 'name': 'turlututu', + 'account_id': self.partner_b.property_account_payable_id.id, + 'partner_id': self.partner_b.id, + 'price_unit': -331.2, + 'price_subtotal': -331.2, + 'price_total': -331.2, + 'amount_currency': 331.2, + 'debit': 331.2, + }, + { + **self.term_line_vals_1, + 'name': 'turlututu', + 'account_id': self.partner_b.property_account_payable_id.id, + 'partner_id': self.partner_b.id, + 'price_unit': -772.8, + 'price_subtotal': -772.8, + 'price_total': -772.8, + 'amount_currency': 772.8, + 'debit': 772.8, + 'date_maturity': fields.Date.from_string('2019-02-28'), + }, + ], { + **self.move_vals, + 'partner_id': self.partner_b.id, + 'payment_reference': 'turlututu', + 'fiscal_position_id': self.fiscal_pos_a.id, + 'invoice_payment_term_id': self.pay_terms_b.id, + 'amount_untaxed': 960.0, + 'amount_tax': 144.0, + 'amount_total': 1104.0, + }) + + def test_in_refund_line_onchange_taxes_1(self): + move_form = Form(self.invoice) + with move_form.invoice_line_ids.edit(0) as line_form: + line_form.price_unit = 960 + line_form.tax_ids.add(self.tax_armageddon) + move_form.save() + + child_tax_1 = self.tax_armageddon.children_tax_ids[0] + child_tax_2 = self.tax_armageddon.children_tax_ids[1] + + self.assertInvoiceValues(self.invoice, [ + { + **self.product_line_vals_1, + 'price_unit': 960.0, + 'price_subtotal': 800.0, + 'price_total': 1176.0, + 'tax_ids': (self.tax_purchase_a + self.tax_armageddon).ids, + 'tax_exigible': False, + }, + self.product_line_vals_2, + self.tax_line_vals_1, + self.tax_line_vals_2, + { + 'name': child_tax_1.name, + 'product_id': False, + 'account_id': self.company_data['default_account_expense'].id, + 'partner_id': self.partner_a.id, + 'product_uom_id': False, + 'quantity': 1.0, + 'discount': 0.0, + 'price_unit': 96.0, + 'price_subtotal': 96.0, + 'price_total': 105.6, + 'tax_ids': child_tax_2.ids, + 'tax_line_id': child_tax_1.id, + 'currency_id': self.company_data['currency'].id, + 'amount_currency': -96.0, + 'debit': 0.0, + 'credit': 96.0, + 'date_maturity': False, + 'tax_exigible': False, + }, + { + 'name': child_tax_1.name, + 'product_id': False, + 'account_id': self.company_data['default_account_tax_sale'].id, + 'partner_id': self.partner_a.id, + 'product_uom_id': False, + 'quantity': 1.0, + 'discount': 0.0, + 'price_unit': 64.0, + 'price_subtotal': 64.0, + 'price_total': 70.4, + 'tax_ids': child_tax_2.ids, + 'tax_line_id': child_tax_1.id, + 'currency_id': self.company_data['currency'].id, + 'amount_currency': -64.0, + 'debit': 0.0, + 'credit': 64.0, + 'date_maturity': False, + 'tax_exigible': False, + }, + { + 'name': child_tax_2.name, + 'product_id': False, + 'account_id': child_tax_2.cash_basis_transition_account_id.id, + 'partner_id': self.partner_a.id, + 'product_uom_id': False, + 'quantity': 1.0, + 'discount': 0.0, + 'price_unit': 96.0, + 'price_subtotal': 96.0, + 'price_total': 96.0, + 'tax_ids': [], + 'tax_line_id': child_tax_2.id, + 'currency_id': self.company_data['currency'].id, + 'amount_currency': -96.0, + 'debit': 0.0, + 'credit': 96.0, + 'date_maturity': False, + 'tax_exigible': False, + }, + { + **self.term_line_vals_1, + 'price_unit': -1384.0, + 'price_subtotal': -1384.0, + 'price_total': -1384.0, + 'amount_currency': 1384.0, + 'debit': 1384.0, + }, + ], { + **self.move_vals, + 'amount_untaxed': 960.0, + 'amount_tax': 424.0, + 'amount_total': 1384.0, + }) + + def test_in_refund_line_onchange_cash_rounding_1(self): + move_form = Form(self.invoice) + # Add a cash rounding having 'add_invoice_line'. + move_form.invoice_cash_rounding_id = self.cash_rounding_a + move_form.save() + + # The cash rounding does nothing as the total is already rounded. + self.assertInvoiceValues(self.invoice, [ + self.product_line_vals_1, + self.product_line_vals_2, + self.tax_line_vals_1, + self.tax_line_vals_2, + self.term_line_vals_1, + ], self.move_vals) + + move_form = Form(self.invoice) + with move_form.invoice_line_ids.edit(0) as line_form: + line_form.price_unit = 799.99 + move_form.save() + + self.assertInvoiceValues(self.invoice, [ + { + 'name': 'add_invoice_line', + 'product_id': False, + 'account_id': self.cash_rounding_a.profit_account_id.id, + 'partner_id': self.partner_a.id, + 'product_uom_id': False, + 'quantity': 1.0, + 'discount': 0.0, + 'price_unit': 0.01, + 'price_subtotal': 0.01, + 'price_total': 0.01, + 'tax_ids': [], + 'tax_line_id': False, + 'currency_id': self.company_data['currency'].id, + 'amount_currency': -0.01, + 'debit': 0.0, + 'credit': 0.01, + 'date_maturity': False, + 'tax_exigible': True, + }, + { + **self.product_line_vals_1, + 'price_unit': 799.99, + 'price_subtotal': 799.99, + 'price_total': 919.99, + 'amount_currency': -799.99, + 'credit': 799.99, + }, + self.product_line_vals_2, + self.tax_line_vals_1, + self.tax_line_vals_2, + self.term_line_vals_1, + ], self.move_vals) + + move_form = Form(self.invoice) + # Change the cash rounding to one having 'biggest_tax'. + move_form.invoice_cash_rounding_id = self.cash_rounding_b + move_form.save() + + self.assertInvoiceValues(self.invoice, [ + { + **self.product_line_vals_1, + 'price_unit': 799.99, + 'price_subtotal': 799.99, + 'price_total': 919.99, + 'amount_currency': -799.99, + 'credit': 799.99, + }, + self.product_line_vals_2, + self.tax_line_vals_1, + self.tax_line_vals_2, + { + 'name': '%s (rounding)' % self.tax_purchase_a.name, + 'product_id': False, + 'account_id': self.company_data['default_account_tax_purchase'].id, + 'partner_id': self.partner_a.id, + 'product_uom_id': False, + 'quantity': 1.0, + 'discount': 0.0, + 'price_unit': -0.04, + 'price_subtotal': -0.04, + 'price_total': -0.04, + 'tax_ids': [], + 'tax_line_id': self.tax_purchase_a.id, + 'currency_id': self.company_data['currency'].id, + 'amount_currency': 0.04, + 'debit': 0.04, + 'credit': 0.0, + 'date_maturity': False, + 'tax_exigible': True, + }, + { + **self.term_line_vals_1, + 'price_unit': -1127.95, + 'price_subtotal': -1127.95, + 'price_total': -1127.95, + 'amount_currency': 1127.95, + 'debit': 1127.95, + }, + ], { + **self.move_vals, + 'amount_untaxed': 959.99, + 'amount_tax': 167.96, + 'amount_total': 1127.95, + }) + + def test_in_refund_line_onchange_currency_1(self): + move_form = Form(self.invoice) + move_form.currency_id = self.currency_data['currency'] + move_form.save() + + self.assertInvoiceValues(self.invoice, [ + { + **self.product_line_vals_1, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -800.0, + 'credit': 400.0, + }, + { + **self.product_line_vals_2, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -160.0, + 'credit': 80.0, + }, + { + **self.tax_line_vals_1, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -144.0, + 'credit': 72.0, + }, + { + **self.tax_line_vals_2, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -24.0, + 'credit': 12.0, + }, + { + **self.term_line_vals_1, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 1128.0, + 'debit': 564.0, + }, + ], { + **self.move_vals, + 'currency_id': self.currency_data['currency'].id, + }) + + move_form = Form(self.invoice) + # Change the date to get another rate: 1/3 instead of 1/2. + move_form.date = fields.Date.from_string('2016-01-01') + move_form.save() + + self.assertInvoiceValues(self.invoice, [ + { + **self.product_line_vals_1, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -800.0, + 'credit': 266.67, + }, + { + **self.product_line_vals_2, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -160.0, + 'credit': 53.33, + }, + { + **self.tax_line_vals_1, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -144.0, + 'credit': 48.0, + }, + { + **self.tax_line_vals_2, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -24.0, + 'credit': 8.0, + }, + { + **self.term_line_vals_1, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 1128.0, + 'debit': 376.0, + }, + ], { + **self.move_vals, + 'currency_id': self.currency_data['currency'].id, + 'date': fields.Date.from_string('2016-01-01'), + }) + + move_form = Form(self.invoice) + with move_form.invoice_line_ids.edit(0) as line_form: + # 0.045 * 0.1 = 0.0045. As the foreign currency has a 0.001 rounding, + # the result should be 0.005 after rounding. + line_form.quantity = 0.1 + line_form.price_unit = 0.045 + move_form.save() + + self.assertInvoiceValues(self.invoice, [ + { + **self.product_line_vals_1, + 'quantity': 0.1, + 'price_unit': 0.05, + 'price_subtotal': 0.005, + 'price_total': 0.006, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -0.005, + 'credit': 0.0, + }, + { + **self.product_line_vals_2, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -160.0, + 'credit': 53.33, + }, + { + **self.tax_line_vals_1, + 'price_unit': 24.0, + 'price_subtotal': 24.001, + 'price_total': 24.001, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -24.001, + 'credit': 8.0, + }, + { + **self.tax_line_vals_2, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -24.0, + 'credit': 8.0, + }, + { + **self.term_line_vals_1, + 'currency_id': self.currency_data['currency'].id, + 'price_unit': -208.01, + 'price_subtotal': -208.006, + 'price_total': -208.006, + 'amount_currency': 208.006, + 'debit': 69.33, + }, + ], { + **self.move_vals, + 'currency_id': self.currency_data['currency'].id, + 'date': fields.Date.from_string('2016-01-01'), + 'amount_untaxed': 160.005, + 'amount_tax': 48.001, + 'amount_total': 208.006, + }) + + # Exit the multi-currencies. + move_form = Form(self.invoice) + move_form.currency_id = self.company_data['currency'] + move_form.save() + + self.assertInvoiceValues(self.invoice, [ + { + **self.product_line_vals_1, + 'quantity': 0.1, + 'price_unit': 0.05, + 'price_subtotal': 0.01, + 'price_total': 0.01, + 'amount_currency': -0.01, + 'credit': 0.01, + }, + self.product_line_vals_2, + { + **self.tax_line_vals_1, + 'price_unit': 24.0, + 'price_subtotal': 24.0, + 'price_total': 24.0, + 'amount_currency': -24.0, + 'credit': 24.0, + }, + self.tax_line_vals_2, + { + **self.term_line_vals_1, + 'price_unit': -208.01, + 'price_subtotal': -208.01, + 'price_total': -208.01, + 'amount_currency': 208.01, + 'debit': 208.01, + }, + ], { + **self.move_vals, + 'currency_id': self.company_data['currency'].id, + 'date': fields.Date.from_string('2016-01-01'), + 'amount_untaxed': 160.01, + 'amount_tax': 48.0, + 'amount_total': 208.01, + }) + + def test_in_refund_onchange_past_invoice_1(self): + copy_invoice = self.invoice.copy() + + move_form = Form(self.invoice) + move_form.invoice_line_ids.remove(0) + move_form.invoice_line_ids.remove(0) + move_form.invoice_vendor_bill_id = copy_invoice + move_form.save() + + self.assertInvoiceValues(self.invoice, [ + self.product_line_vals_1, + self.product_line_vals_2, + self.tax_line_vals_1, + self.tax_line_vals_2, + self.term_line_vals_1, + ], self.move_vals) + + def test_in_refund_create_1(self): + # Test creating an account_move with the least information. + move = self.env['account.move'].create({ + 'move_type': 'in_refund', + 'partner_id': self.partner_a.id, + 'invoice_date': fields.Date.from_string('2019-01-01'), + 'currency_id': self.currency_data['currency'].id, + 'invoice_payment_term_id': self.pay_terms_a.id, + 'invoice_line_ids': [ + (0, None, self.product_line_vals_1), + (0, None, self.product_line_vals_2), + ] + }) + + self.assertInvoiceValues(move, [ + { + **self.product_line_vals_1, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -800.0, + 'credit': 400.0, + }, + { + **self.product_line_vals_2, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -160.0, + 'credit': 80.0, + }, + { + **self.tax_line_vals_1, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -144.0, + 'credit': 72.0, + }, + { + **self.tax_line_vals_2, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -24.0, + 'credit': 12.0, + }, + { + **self.term_line_vals_1, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 1128.0, + 'debit': 564.0, + }, + ], { + **self.move_vals, + 'currency_id': self.currency_data['currency'].id, + }) + + def test_in_refund_write_1(self): + # Test creating an account_move with the least information. + move = self.env['account.move'].create({ + 'move_type': 'in_refund', + 'partner_id': self.partner_a.id, + 'invoice_date': fields.Date.from_string('2019-01-01'), + 'currency_id': self.currency_data['currency'].id, + 'invoice_payment_term_id': self.pay_terms_a.id, + 'invoice_line_ids': [ + (0, None, self.product_line_vals_1), + ] + }) + move.write({ + 'invoice_line_ids': [ + (0, None, self.product_line_vals_2), + ] + }) + + self.assertInvoiceValues(move, [ + { + **self.product_line_vals_1, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -800.0, + 'credit': 400.0, + }, + { + **self.product_line_vals_2, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -160.0, + 'credit': 80.0, + }, + { + **self.tax_line_vals_1, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -144.0, + 'credit': 72.0, + }, + { + **self.tax_line_vals_2, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -24.0, + 'credit': 12.0, + }, + { + **self.term_line_vals_1, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 1128.0, + 'debit': 564.0, + }, + ], { + **self.move_vals, + 'currency_id': self.currency_data['currency'].id, + }) diff --git a/addons/account/tests/test_account_move_out_invoice.py b/addons/account/tests/test_account_move_out_invoice.py new file mode 100644 index 00000000..ce8fe166 --- /dev/null +++ b/addons/account/tests/test_account_move_out_invoice.py @@ -0,0 +1,2933 @@ +# -*- coding: utf-8 -*- +from odoo.addons.account.tests.common import AccountTestInvoicingCommon +from odoo.tests.common import Form +from odoo.tests import tagged +from odoo import fields +from odoo.exceptions import UserError + +from unittest.mock import patch +from datetime import timedelta + + +@tagged('post_install', '-at_install') +class TestAccountMoveOutInvoiceOnchanges(AccountTestInvoicingCommon): + + @classmethod + def setUpClass(cls, chart_template_ref=None): + super().setUpClass(chart_template_ref=chart_template_ref) + + cls.invoice = cls.init_invoice('out_invoice', products=cls.product_a+cls.product_b) + + cls.product_line_vals_1 = { + 'name': cls.product_a.name, + 'product_id': cls.product_a.id, + 'account_id': cls.product_a.property_account_income_id.id, + 'partner_id': cls.partner_a.id, + 'product_uom_id': cls.product_a.uom_id.id, + 'quantity': 1.0, + 'discount': 0.0, + 'price_unit': 1000.0, + 'price_subtotal': 1000.0, + 'price_total': 1150.0, + 'tax_ids': cls.product_a.taxes_id.ids, + 'tax_line_id': False, + 'currency_id': cls.company_data['currency'].id, + 'amount_currency': -1000.0, + 'debit': 0.0, + 'credit': 1000.0, + 'date_maturity': False, + 'tax_exigible': True, + } + cls.product_line_vals_2 = { + 'name': cls.product_b.name, + 'product_id': cls.product_b.id, + 'account_id': cls.product_b.property_account_income_id.id, + 'partner_id': cls.partner_a.id, + 'product_uom_id': cls.product_b.uom_id.id, + 'quantity': 1.0, + 'discount': 0.0, + 'price_unit': 200.0, + 'price_subtotal': 200.0, + 'price_total': 260.0, + 'tax_ids': cls.product_b.taxes_id.ids, + 'tax_line_id': False, + 'currency_id': cls.company_data['currency'].id, + 'amount_currency': -200.0, + 'debit': 0.0, + 'credit': 200.0, + 'date_maturity': False, + 'tax_exigible': True, + } + cls.tax_line_vals_1 = { + 'name': cls.tax_sale_a.name, + 'product_id': False, + 'account_id': cls.company_data['default_account_tax_sale'].id, + 'partner_id': cls.partner_a.id, + 'product_uom_id': False, + 'quantity': 1.0, + 'discount': 0.0, + 'price_unit': 180.0, + 'price_subtotal': 180.0, + 'price_total': 180.0, + 'tax_ids': [], + 'tax_line_id': cls.tax_sale_a.id, + 'currency_id': cls.company_data['currency'].id, + 'amount_currency': -180.0, + 'debit': 0.0, + 'credit': 180.0, + 'date_maturity': False, + 'tax_exigible': True, + } + cls.tax_line_vals_2 = { + 'name': cls.tax_sale_b.name, + 'product_id': False, + 'account_id': cls.company_data['default_account_tax_sale'].id, + 'partner_id': cls.partner_a.id, + 'product_uom_id': False, + 'quantity': 1.0, + 'discount': 0.0, + 'price_unit': 30.0, + 'price_subtotal': 30.0, + 'price_total': 30.0, + 'tax_ids': [], + 'tax_line_id': cls.tax_sale_b.id, + 'currency_id': cls.company_data['currency'].id, + 'amount_currency': -30.0, + 'debit': 0.0, + 'credit': 30.0, + 'date_maturity': False, + 'tax_exigible': True, + } + cls.term_line_vals_1 = { + 'name': '', + 'product_id': False, + 'account_id': cls.company_data['default_account_receivable'].id, + 'partner_id': cls.partner_a.id, + 'product_uom_id': False, + 'quantity': 1.0, + 'discount': 0.0, + 'price_unit': -1410.0, + 'price_subtotal': -1410.0, + 'price_total': -1410.0, + 'tax_ids': [], + 'tax_line_id': False, + 'currency_id': cls.company_data['currency'].id, + 'amount_currency': 1410.0, + 'debit': 1410.0, + 'credit': 0.0, + 'date_maturity': fields.Date.from_string('2019-01-01'), + 'tax_exigible': True, + } + cls.move_vals = { + 'partner_id': cls.partner_a.id, + 'currency_id': cls.company_data['currency'].id, + 'journal_id': cls.company_data['default_journal_sale'].id, + 'date': fields.Date.from_string('2019-01-01'), + 'fiscal_position_id': False, + 'payment_reference': '', + 'invoice_payment_term_id': cls.pay_terms_a.id, + 'amount_untaxed': 1200.0, + 'amount_tax': 210.0, + 'amount_total': 1410.0, + } + + def setUp(self): + super(TestAccountMoveOutInvoiceOnchanges, self).setUp() + self.assertInvoiceValues(self.invoice, [ + self.product_line_vals_1, + self.product_line_vals_2, + self.tax_line_vals_1, + self.tax_line_vals_2, + self.term_line_vals_1, + ], self.move_vals) + + def test_out_invoice_onchange_invoice_date(self): + for tax_date, invoice_date, accounting_date in [ + ('2019-03-31', '2019-05-12', '2019-05-12'), + ('2019-03-31', '2019-02-10', '2019-04-1'), + ('2019-05-31', '2019-06-15', '2019-06-15'), + ]: + self.invoice.company_id.tax_lock_date = tax_date + with Form(self.invoice) as move_form: + move_form.invoice_date = invoice_date + self.assertEqual(self.invoice.date, fields.Date.to_date(accounting_date)) + + def test_out_invoice_line_onchange_product_1(self): + move_form = Form(self.invoice) + with move_form.invoice_line_ids.edit(0) as line_form: + line_form.product_id = self.product_b + move_form.save() + + self.assertInvoiceValues(self.invoice, [ + { + **self.product_line_vals_1, + 'name': self.product_b.name, + 'product_id': self.product_b.id, + 'product_uom_id': self.product_b.uom_id.id, + 'account_id': self.product_b.property_account_income_id.id, + 'price_unit': 200.0, + 'price_subtotal': 200.0, + 'price_total': 260.0, + 'tax_ids': self.product_b.taxes_id.ids, + 'amount_currency': -200.0, + 'credit': 200.0, + }, + self.product_line_vals_2, + { + **self.tax_line_vals_1, + 'price_unit': 60.0, + 'price_subtotal': 60.0, + 'price_total': 60.0, + 'amount_currency': -60.0, + 'credit': 60.0, + }, + { + **self.tax_line_vals_2, + 'price_unit': 60.0, + 'price_subtotal': 60.0, + 'price_total': 60.0, + 'amount_currency': -60.0, + 'credit': 60.0, + }, + { + **self.term_line_vals_1, + 'price_unit': -520.0, + 'price_subtotal': -520.0, + 'price_total': -520.0, + 'amount_currency': 520.0, + 'debit': 520.0, + }, + ], { + **self.move_vals, + 'amount_untaxed': 400.0, + 'amount_tax': 120.0, + 'amount_total': 520.0, + }) + + def test_out_invoice_line_onchange_product_2_with_fiscal_pos_1(self): + ''' Test mapping a price-included tax (10%) with a price-excluded tax (20%) on a price_unit of 110.0. + The price_unit should be 100.0 after applying the fiscal position. + ''' + tax_price_include = self.env['account.tax'].create({ + 'name': '10% incl', + 'type_tax_use': 'sale', + 'amount_type': 'percent', + 'amount': 10, + 'price_include': True, + 'include_base_amount': True, + }) + tax_price_exclude = self.env['account.tax'].create({ + 'name': '15% excl', + 'type_tax_use': 'sale', + 'amount_type': 'percent', + 'amount': 15, + }) + + fiscal_position = self.env['account.fiscal.position'].create({ + 'name': 'fiscal_pos_a', + 'tax_ids': [ + (0, None, { + 'tax_src_id': tax_price_include.id, + 'tax_dest_id': tax_price_exclude.id, + }), + ], + }) + + product = self.env['product.product'].create({ + 'name': 'product', + 'uom_id': self.env.ref('uom.product_uom_unit').id, + 'lst_price': 110.0, + 'taxes_id': [(6, 0, tax_price_include.ids)], + }) + + move_form = Form(self.env['account.move'].with_context(default_move_type='out_invoice')) + move_form.partner_id = self.partner_a + move_form.invoice_date = fields.Date.from_string('2019-01-01') + move_form.currency_id = self.currency_data['currency'] + move_form.fiscal_position_id = fiscal_position + with move_form.invoice_line_ids.new() as line_form: + line_form.product_id = product + invoice = move_form.save() + + self.assertInvoiceValues(invoice, [ + { + 'product_id': product.id, + 'price_unit': 200.0, + 'price_subtotal': 200.0, + 'price_total': 230.0, + 'tax_ids': tax_price_exclude.ids, + 'tax_line_id': False, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -200.0, + 'debit': 0.0, + 'credit': 100.0, + }, + { + 'product_id': False, + 'price_unit': 30.0, + 'price_subtotal': 30.0, + 'price_total': 30.0, + 'tax_ids': [], + 'tax_line_id': tax_price_exclude.id, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -30.0, + 'debit': 0.0, + 'credit': 15.0, + }, + { + 'product_id': False, + 'price_unit': -230.0, + 'price_subtotal': -230.0, + 'price_total': -230.0, + 'tax_ids': [], + 'tax_line_id': False, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 230.0, + 'debit': 115.0, + 'credit': 0.0, + }, + ], { + 'currency_id': self.currency_data['currency'].id, + 'fiscal_position_id': fiscal_position.id, + 'amount_untaxed': 200.0, + 'amount_tax': 30.0, + 'amount_total': 230.0, + }) + + uom_dozen = self.env.ref('uom.product_uom_dozen') + with Form(invoice) as move_form: + with move_form.invoice_line_ids.edit(0) as line_form: + line_form.product_uom_id = uom_dozen + + self.assertInvoiceValues(invoice, [ + { + 'product_id': product.id, + 'product_uom_id': uom_dozen.id, + 'price_unit': 2400.0, + 'price_subtotal': 2400.0, + 'price_total': 2760.0, + 'tax_ids': tax_price_exclude.ids, + 'tax_line_id': False, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -2400.0, + 'debit': 0.0, + 'credit': 1200.0, + }, + { + 'product_id': False, + 'product_uom_id': False, + 'price_unit': 360.0, + 'price_subtotal': 360.0, + 'price_total': 360.0, + 'tax_ids': [], + 'tax_line_id': tax_price_exclude.id, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -360.0, + 'debit': 0.0, + 'credit': 180.0, + }, + { + 'product_id': False, + 'product_uom_id': False, + 'price_unit': -2760.0, + 'price_subtotal': -2760.0, + 'price_total': -2760.0, + 'tax_ids': [], + 'tax_line_id': False, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 2760.0, + 'debit': 1380.0, + 'credit': 0.0, + }, + ], { + 'currency_id': self.currency_data['currency'].id, + 'fiscal_position_id': fiscal_position.id, + 'amount_untaxed': 2400.0, + 'amount_tax': 360.0, + 'amount_total': 2760.0, + }) + + def test_out_invoice_line_onchange_product_2_with_fiscal_pos_2(self): + ''' Test mapping a price-included tax (10%) with another price-included tax (20%) on a price_unit of 110.0. + The price_unit should be 120.0 after applying the fiscal position. + ''' + tax_price_include_1 = self.env['account.tax'].create({ + 'name': '10% incl', + 'type_tax_use': 'sale', + 'amount_type': 'percent', + 'amount': 10, + 'price_include': True, + 'include_base_amount': True, + }) + tax_price_include_2 = self.env['account.tax'].create({ + 'name': '20% incl', + 'type_tax_use': 'sale', + 'amount_type': 'percent', + 'amount': 20, + 'price_include': True, + 'include_base_amount': True, + }) + + fiscal_position = self.env['account.fiscal.position'].create({ + 'name': 'fiscal_pos_a', + 'tax_ids': [ + (0, None, { + 'tax_src_id': tax_price_include_1.id, + 'tax_dest_id': tax_price_include_2.id, + }), + ], + }) + + product = self.env['product.product'].create({ + 'name': 'product', + 'uom_id': self.env.ref('uom.product_uom_unit').id, + 'lst_price': 110.0, + 'taxes_id': [(6, 0, tax_price_include_1.ids)], + }) + + move_form = Form(self.env['account.move'].with_context(default_move_type='out_invoice')) + move_form.partner_id = self.partner_a + move_form.invoice_date = fields.Date.from_string('2019-01-01') + move_form.currency_id = self.currency_data['currency'] + move_form.fiscal_position_id = fiscal_position + with move_form.invoice_line_ids.new() as line_form: + line_form.product_id = product + invoice = move_form.save() + + self.assertInvoiceValues(invoice, [ + { + 'product_id': product.id, + 'price_unit': 240.0, + 'price_subtotal': 200.0, + 'price_total': 240.0, + 'tax_ids': tax_price_include_2.ids, + 'tax_line_id': False, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -200.0, + 'debit': 0.0, + 'credit': 100.0, + }, + { + 'product_id': False, + 'price_unit': 40.0, + 'price_subtotal': 40.0, + 'price_total': 40.0, + 'tax_ids': [], + 'tax_line_id': tax_price_include_2.id, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -40.0, + 'debit': 0.0, + 'credit': 20.0, + }, + { + 'product_id': False, + 'price_unit': -240.0, + 'price_subtotal': -240.0, + 'price_total': -240.0, + 'tax_ids': [], + 'tax_line_id': False, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 240.0, + 'debit': 120.0, + 'credit': 0.0, + }, + ], { + 'currency_id': self.currency_data['currency'].id, + 'fiscal_position_id': fiscal_position.id, + 'amount_untaxed': 200.0, + 'amount_tax': 40.0, + 'amount_total': 240.0, + }) + + uom_dozen = self.env.ref('uom.product_uom_dozen') + with Form(invoice) as move_form: + with move_form.invoice_line_ids.edit(0) as line_form: + line_form.product_uom_id = uom_dozen + + self.assertInvoiceValues(invoice, [ + { + 'product_id': product.id, + 'product_uom_id': uom_dozen.id, + 'price_unit': 2880.0, + 'price_subtotal': 2400.0, + 'price_total': 2880.0, + 'tax_ids': tax_price_include_2.ids, + 'tax_line_id': False, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -2400.0, + 'debit': 0.0, + 'credit': 1200.0, + }, + { + 'product_id': False, + 'product_uom_id': False, + 'price_unit': 480.0, + 'price_subtotal': 480.0, + 'price_total': 480.0, + 'tax_ids': [], + 'tax_line_id': tax_price_include_2.id, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -480.0, + 'debit': 0.0, + 'credit': 240.0, + }, + { + 'product_id': False, + 'product_uom_id': False, + 'price_unit': -2880.0, + 'price_subtotal': -2880.0, + 'price_total': -2880.0, + 'tax_ids': [], + 'tax_line_id': False, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 2880.0, + 'debit': 1440.0, + 'credit': 0.0, + }, + ], { + 'currency_id': self.currency_data['currency'].id, + 'fiscal_position_id': fiscal_position.id, + 'amount_untaxed': 2400.0, + 'amount_tax': 480.0, + 'amount_total': 2880.0, + }) + + def test_out_invoice_line_onchange_business_fields_1(self): + move_form = Form(self.invoice) + with move_form.invoice_line_ids.edit(0) as line_form: + # Current price_unit is 1000. + # We set quantity = 4, discount = 50%, price_unit = 400. The debit/credit fields don't change because (4 * 500) * 0.5 = 1000. + line_form.quantity = 4 + line_form.discount = 50 + line_form.price_unit = 500 + move_form.save() + + self.assertInvoiceValues(self.invoice, [ + { + **self.product_line_vals_1, + 'quantity': 4, + 'discount': 50.0, + 'price_unit': 500.0, + }, + self.product_line_vals_2, + self.tax_line_vals_1, + self.tax_line_vals_2, + self.term_line_vals_1, + ], self.move_vals) + + move_form = Form(self.invoice) + with move_form.line_ids.edit(2) as line_form: + # Reset field except the discount that becomes 100%. + # /!\ The modification is made on the accounting tab. + line_form.quantity = 1 + line_form.discount = 100 + line_form.price_unit = 1000 + move_form.save() + + self.assertInvoiceValues(self.invoice, [ + { + **self.product_line_vals_1, + 'discount': 100.0, + 'price_subtotal': 0.0, + 'price_total': 0.0, + 'amount_currency': 0.0, + 'credit': 0.0, + }, + self.product_line_vals_2, + { + **self.tax_line_vals_1, + 'price_unit': 30.0, + 'price_subtotal': 30.0, + 'price_total': 30.0, + 'amount_currency': -30.0, + 'credit': 30.0, + }, + self.tax_line_vals_2, + { + **self.term_line_vals_1, + 'price_unit': -260.0, + 'price_subtotal': -260.0, + 'price_total': -260.0, + 'amount_currency': 260.0, + 'debit': 260.0, + }, + ], { + **self.move_vals, + 'amount_untaxed': 200.0, + 'amount_tax': 60.0, + 'amount_total': 260.0, + }) + + def test_out_invoice_line_onchange_accounting_fields_1(self): + move_form = Form(self.invoice) + with move_form.line_ids.edit(2) as line_form: + # Custom credit on the first product line. + line_form.credit = 3000 + with move_form.line_ids.edit(3) as line_form: + # Custom debit on the second product line. Credit should be reset by onchange. + # /!\ It's a negative line. + line_form.debit = 500 + with move_form.line_ids.edit(0) as line_form: + # Custom credit on the first tax line. + line_form.credit = 800 + with move_form.line_ids.edit(4) as line_form: + # Custom credit on the second tax line. + line_form.credit = 250 + move_form.save() + + self.assertInvoiceValues(self.invoice, [ + { + **self.product_line_vals_1, + 'price_unit': 3000.0, + 'price_subtotal': 3000.0, + 'price_total': 3450.0, + 'amount_currency': -3000.0, + 'credit': 3000.0, + }, + { + **self.product_line_vals_2, + 'price_unit': -500.0, + 'price_subtotal': -500.0, + 'price_total': -650.0, + 'amount_currency': 500.0, + 'debit': 500.0, + 'credit': 0.0, + }, + { + **self.tax_line_vals_1, + 'price_unit': 800.0, + 'price_subtotal': 800.0, + 'price_total': 800.0, + 'amount_currency': -800.0, + 'credit': 800.0, + }, + { + **self.tax_line_vals_2, + 'price_unit': 250.0, + 'price_subtotal': 250.0, + 'price_total': 250.0, + 'amount_currency': -250.0, + 'credit': 250.0, + }, + { + **self.term_line_vals_1, + 'price_unit': -3550.0, + 'price_subtotal': -3550.0, + 'price_total': -3550.0, + 'amount_currency': 3550.0, + 'debit': 3550.0, + }, + ], { + **self.move_vals, + 'amount_untaxed': 2500.0, + 'amount_tax': 1050.0, + 'amount_total': 3550.0, + }) + + def test_out_invoice_line_onchange_partner_1(self): + move_form = Form(self.invoice) + move_form.partner_id = self.partner_b + move_form.payment_reference = 'turlututu' + move_form.save() + + self.assertInvoiceValues(self.invoice, [ + { + **self.product_line_vals_1, + 'partner_id': self.partner_b.id, + }, + { + **self.product_line_vals_2, + 'partner_id': self.partner_b.id, + }, + { + **self.tax_line_vals_1, + 'partner_id': self.partner_b.id, + }, + { + **self.tax_line_vals_2, + 'partner_id': self.partner_b.id, + }, + { + **self.term_line_vals_1, + 'name': 'turlututu', + 'account_id': self.partner_b.property_account_receivable_id.id, + 'partner_id': self.partner_b.id, + 'price_unit': -423.0, + 'price_subtotal': -423.0, + 'price_total': -423.0, + 'amount_currency': 423.0, + 'debit': 423.0, + }, + { + **self.term_line_vals_1, + 'name': 'turlututu', + 'account_id': self.partner_b.property_account_receivable_id.id, + 'partner_id': self.partner_b.id, + 'price_unit': -987.0, + 'price_subtotal': -987.0, + 'price_total': -987.0, + 'amount_currency': 987.0, + 'debit': 987.0, + 'date_maturity': fields.Date.from_string('2019-02-28'), + }, + ], { + **self.move_vals, + 'partner_id': self.partner_b.id, + 'payment_reference': 'turlututu', + 'fiscal_position_id': self.fiscal_pos_a.id, + 'invoice_payment_term_id': self.pay_terms_b.id, + 'amount_untaxed': 1200.0, + 'amount_tax': 210.0, + 'amount_total': 1410.0, + }) + + # Remove lines and recreate them to apply the fiscal position. + move_form = Form(self.invoice) + move_form.invoice_line_ids.remove(0) + move_form.invoice_line_ids.remove(0) + with move_form.invoice_line_ids.new() as line_form: + line_form.product_id = self.product_a + with move_form.invoice_line_ids.new() as line_form: + line_form.product_id = self.product_b + move_form.save() + + self.assertInvoiceValues(self.invoice, [ + { + **self.product_line_vals_1, + 'account_id': self.product_b.property_account_income_id.id, + 'partner_id': self.partner_b.id, + 'tax_ids': self.tax_sale_b.ids, + }, + { + **self.product_line_vals_2, + 'partner_id': self.partner_b.id, + 'price_total': 230.0, + 'tax_ids': self.tax_sale_b.ids, + }, + { + **self.tax_line_vals_1, + 'name': self.tax_sale_b.name, + 'partner_id': self.partner_b.id, + 'tax_line_id': self.tax_sale_b.id, + }, + { + **self.term_line_vals_1, + 'name': 'turlututu', + 'account_id': self.partner_b.property_account_receivable_id.id, + 'partner_id': self.partner_b.id, + 'price_unit': -414.0, + 'price_subtotal': -414.0, + 'price_total': -414.0, + 'amount_currency': 414.0, + 'debit': 414.0, + }, + { + **self.term_line_vals_1, + 'name': 'turlututu', + 'account_id': self.partner_b.property_account_receivable_id.id, + 'partner_id': self.partner_b.id, + 'price_unit': -966.0, + 'price_subtotal': -966.0, + 'price_total': -966.0, + 'amount_currency': 966.0, + 'debit': 966.0, + 'date_maturity': fields.Date.from_string('2019-02-28'), + }, + ], { + **self.move_vals, + 'partner_id': self.partner_b.id, + 'payment_reference': 'turlututu', + 'fiscal_position_id': self.fiscal_pos_a.id, + 'invoice_payment_term_id': self.pay_terms_b.id, + 'amount_untaxed': 1200.0, + 'amount_tax': 180.0, + 'amount_total': 1380.0, + }) + + def test_out_invoice_line_onchange_taxes_1(self): + move_form = Form(self.invoice) + with move_form.invoice_line_ids.edit(0) as line_form: + line_form.price_unit = 1200 + line_form.tax_ids.add(self.tax_armageddon) + move_form.save() + + child_tax_1 = self.tax_armageddon.children_tax_ids[0] + child_tax_2 = self.tax_armageddon.children_tax_ids[1] + + self.assertInvoiceValues(self.invoice, [ + { + **self.product_line_vals_1, + 'price_unit': 1200.0, + 'price_subtotal': 1000.0, + 'price_total': 1470.0, + 'tax_ids': (self.tax_sale_a + self.tax_armageddon).ids, + 'tax_exigible': False, + }, + self.product_line_vals_2, + self.tax_line_vals_1, + self.tax_line_vals_2, + { + 'name': child_tax_1.name, + 'product_id': False, + 'account_id': self.company_data['default_account_revenue'].id, + 'partner_id': self.partner_a.id, + 'product_uom_id': False, + 'quantity': 1.0, + 'discount': 0.0, + 'price_unit': 120.0, + 'price_subtotal': 120.0, + 'price_total': 132.0, + 'tax_ids': child_tax_2.ids, + 'tax_line_id': child_tax_1.id, + 'currency_id': self.company_data['currency'].id, + 'amount_currency': -120.0, + 'debit': 0.0, + 'credit': 120.0, + 'date_maturity': False, + 'tax_exigible': False, + }, + { + 'name': child_tax_1.name, + 'product_id': False, + 'account_id': self.company_data['default_account_tax_sale'].id, + 'partner_id': self.partner_a.id, + 'product_uom_id': False, + 'quantity': 1.0, + 'discount': 0.0, + 'price_unit': 80.0, + 'price_subtotal': 80.0, + 'price_total': 88.0, + 'tax_ids': child_tax_2.ids, + 'tax_line_id': child_tax_1.id, + 'currency_id': self.company_data['currency'].id, + 'amount_currency': -80.0, + 'debit': 0.0, + 'credit': 80.0, + 'date_maturity': False, + 'tax_exigible': False, + }, + { + 'name': child_tax_2.name, + 'product_id': False, + 'account_id': child_tax_2.cash_basis_transition_account_id.id, + 'partner_id': self.partner_a.id, + 'product_uom_id': False, + 'quantity': 1.0, + 'discount': 0.0, + 'price_unit': 120.0, + 'price_subtotal': 120.0, + 'price_total': 120.0, + 'tax_ids': [], + 'tax_line_id': child_tax_2.id, + 'currency_id': self.company_data['currency'].id, + 'amount_currency': -120.0, + 'debit': 0.0, + 'credit': 120.0, + 'date_maturity': False, + 'tax_exigible': False, + }, + { + **self.term_line_vals_1, + 'price_unit': -1730.0, + 'price_subtotal': -1730.0, + 'price_total': -1730.0, + 'amount_currency': 1730.0, + 'debit': 1730.0, + }, + ], { + **self.move_vals, + 'amount_untaxed': 1200.0, + 'amount_tax': 530.0, + 'amount_total': 1730.0, + }) + + def test_out_invoice_line_onchange_rounding_price_subtotal_1(self): + ''' Seek for rounding issue on the price_subtotal when dealing with a price_unit having more digits than the + foreign currency one. + ''' + decimal_precision_name = self.env['account.move.line']._fields['price_unit']._digits + decimal_precision = self.env['decimal.precision'].search([('name', '=', decimal_precision_name)]) + + self.assertTrue(decimal_precision, "Decimal precision '%s' not found" % decimal_precision_name) + + self.currency_data['currency'].rounding = 0.01 + decimal_precision.digits = 4 + + def check_invoice_values(invoice): + self.assertInvoiceValues(invoice, [ + { + 'quantity': 1.0, + 'price_unit': 0.025, + 'price_subtotal': 0.03, + 'debit': 0.0, + 'credit': 0.02, + 'currency_id': self.currency_data['currency'].id, + }, + { + 'quantity': 1.0, + 'price_unit': -0.03, + 'price_subtotal': -0.03, + 'debit': 0.02, + 'credit': 0.0, + 'currency_id': self.currency_data['currency'].id, + }, + ], { + 'amount_untaxed': 0.03, + 'amount_tax': 0.0, + 'amount_total': 0.03, + }) + + # == Test at the creation of the invoice == + + invoice_1 = self.env['account.move'].create({ + 'move_type': 'out_invoice', + 'invoice_date': '2017-01-01', + 'date': '2017-01-01', + 'partner_id': self.partner_a.id, + 'currency_id': self.currency_data['currency'].id, + 'invoice_line_ids': [(0, 0, { + 'name': 'test line', + 'price_unit': 0.025, + 'quantity': 1, + 'account_id': self.company_data['default_account_revenue'].id, + })], + }) + + check_invoice_values(invoice_1) + + # == Test when writing on the invoice == + + invoice_2 = self.env['account.move'].create({ + 'move_type': 'out_invoice', + 'invoice_date': '2017-01-01', + 'date': '2017-01-01', + 'partner_id': self.partner_a.id, + 'currency_id': self.currency_data['currency'].id, + }) + invoice_2.write({ + 'invoice_line_ids': [(0, 0, { + 'name': 'test line', + 'price_unit': 0.025, + 'quantity': 1, + 'account_id': self.company_data['default_account_revenue'].id, + })], + }) + + check_invoice_values(invoice_2) + + def test_out_invoice_line_onchange_rounding_price_subtotal_2(self): + """ Ensure the cyclic computations implemented using onchanges are not leading to rounding issues when using + price-included taxes. + For example: + 100 / 1.21 ~= 82.64 but 82.64 * 1.21 ~= 99.99 != 100.0. + """ + + def check_invoice_values(invoice): + self.assertInvoiceValues(invoice, [ + { + 'price_unit': 100.0, + 'price_subtotal': 82.64, + 'debit': 0.0, + 'credit': 82.64, + }, + { + 'price_unit': 17.36, + 'price_subtotal': 17.36, + 'debit': 0.0, + 'credit': 17.36, + }, + { + 'price_unit': -100.0, + 'price_subtotal': -100.0, + 'debit': 100.0, + 'credit': 0.0, + }, + ], { + 'amount_untaxed': 82.64, + 'amount_tax': 17.36, + 'amount_total': 100.0, + }) + + tax = self.env['account.tax'].create({ + 'name': '21%', + 'amount': 21.0, + 'price_include': True, + 'include_base_amount': True, + }) + + # == Test assigning tax directly == + + invoice_create = self.env['account.move'].create({ + 'move_type': 'out_invoice', + 'invoice_date': '2017-01-01', + 'date': '2017-01-01', + 'partner_id': self.partner_a.id, + 'invoice_line_ids': [(0, 0, { + 'name': 'test line', + 'price_unit': 100.0, + 'account_id': self.company_data['default_account_revenue'].id, + 'tax_ids': [(6, 0, tax.ids)], + })], + }) + + check_invoice_values(invoice_create) + + move_form = Form(self.env['account.move'].with_context(default_move_type='out_invoice')) + move_form.invoice_date = fields.Date.from_string('2017-01-01') + move_form.partner_id = self.partner_a + with move_form.invoice_line_ids.new() as line_form: + line_form.name = 'test line' + line_form.price_unit = 100.0 + line_form.account_id = self.company_data['default_account_revenue'] + line_form.tax_ids.clear() + line_form.tax_ids.add(tax) + invoice_onchange = move_form.save() + + check_invoice_values(invoice_onchange) + + # == Test when the tax is set on a product == + + product = self.env['product.product'].create({ + 'name': 'product', + 'lst_price': 100.0, + 'property_account_income_id': self.company_data['default_account_revenue'].id, + 'taxes_id': [(6, 0, tax.ids)], + }) + + move_form = Form(self.env['account.move'].with_context(default_move_type='out_invoice')) + move_form.invoice_date = fields.Date.from_string('2017-01-01') + move_form.partner_id = self.partner_a + with move_form.invoice_line_ids.new() as line_form: + line_form.product_id = product + invoice_onchange = move_form.save() + + check_invoice_values(invoice_onchange) + + # == Test with a fiscal position == + + fiscal_position = self.env['account.fiscal.position'].create({'name': 'fiscal_position'}) + + move_form = Form(self.env['account.move'].with_context(default_move_type='out_invoice')) + move_form.invoice_date = fields.Date.from_string('2017-01-01') + move_form.partner_id = self.partner_a + move_form.fiscal_position_id = fiscal_position + with move_form.invoice_line_ids.new() as line_form: + line_form.product_id = product + invoice_onchange = move_form.save() + + check_invoice_values(invoice_onchange) + + def test_out_invoice_line_onchange_taxes_2_price_unit_tax_included(self): + ''' Seek for rounding issue in the price unit. Suppose a price_unit of 2300 with a 5.5% price-included tax + applied on it. + + The computed balance will be computed as follow: 2300.0 / 1.055 = 2180.0948 ~ 2180.09. + Since accounting / business fields are synchronized, the inverse computation will try to recompute the + price_unit based on the balance: 2180.09 * 1.055 = 2299.99495 ~ 2299.99. + + This test ensures the price_unit is not overridden in such case. + ''' + tax_price_include = self.env['account.tax'].create({ + 'name': 'Tax 5.5% price included', + 'amount': 5.5, + 'amount_type': 'percent', + 'price_include': True, + }) + + # == Single-currency == + + # price_unit=2300 with 15% tax (excluded) + 5.5% tax (included). + move_form = Form(self.invoice) + move_form.invoice_line_ids.remove(1) + with move_form.invoice_line_ids.edit(0) as line_form: + line_form.price_unit = 2300 + line_form.tax_ids.add(tax_price_include) + move_form.save() + + self.assertInvoiceValues(self.invoice, [ + { + **self.product_line_vals_1, + 'price_unit': 2300.0, + 'price_subtotal': 2180.09, + 'price_total': 2627.01, + 'tax_ids': (self.product_a.taxes_id + tax_price_include).ids, + 'amount_currency': -2180.09, + 'credit': 2180.09, + }, + { + **self.tax_line_vals_1, + 'price_unit': 327.01, + 'price_subtotal': 327.01, + 'price_total': 327.01, + 'amount_currency': -327.01, + 'credit': 327.01, + }, + { + 'name': tax_price_include.name, + 'product_id': False, + 'account_id': self.product_line_vals_1['account_id'], + 'partner_id': self.partner_a.id, + 'product_uom_id': False, + 'quantity': 1.0, + 'discount': 0.0, + 'price_unit': 119.91, + 'price_subtotal': 119.91, + 'price_total': 119.91, + 'tax_ids': [], + 'tax_line_id': tax_price_include.id, + 'currency_id': self.company_data['currency'].id, + 'amount_currency': -119.91, + 'debit': 0.0, + 'credit': 119.91, + 'date_maturity': False, + 'tax_exigible': True, + }, + { + **self.term_line_vals_1, + 'price_unit': -2627.01, + 'price_subtotal': -2627.01, + 'price_total': -2627.01, + 'amount_currency': 2627.01, + 'debit': 2627.01, + }, + ], { + **self.move_vals, + 'amount_untaxed': 2180.09, + 'amount_tax': 446.92, + 'amount_total': 2627.01, + }) + + move_form = Form(self.invoice) + with move_form.invoice_line_ids.edit(0) as line_form: + line_form.price_unit = -2300 + move_form.save() + + self.assertInvoiceValues(self.invoice, [ + { + **self.product_line_vals_1, + 'price_unit': -2300.0, + 'price_subtotal': -2180.09, + 'price_total': -2627.01, + 'tax_ids': (self.product_a.taxes_id + tax_price_include).ids, + 'amount_currency': 2180.09, + 'debit': 2180.09, + 'credit': 0.0, + }, + { + **self.tax_line_vals_1, + 'price_unit': -327.01, + 'price_subtotal': -327.01, + 'price_total': -327.01, + 'amount_currency': 327.01, + 'debit': 327.01, + 'credit': 0.0, + }, + { + 'name': tax_price_include.name, + 'product_id': False, + 'account_id': self.product_line_vals_1['account_id'], + 'partner_id': self.partner_a.id, + 'product_uom_id': False, + 'quantity': 1.0, + 'discount': 0.0, + 'price_unit': -119.91, + 'price_subtotal': -119.91, + 'price_total': -119.91, + 'tax_ids': [], + 'tax_line_id': tax_price_include.id, + 'currency_id': self.company_data['currency'].id, + 'amount_currency': 119.91, + 'debit': 119.91, + 'credit': 0.0, + 'date_maturity': False, + 'tax_exigible': True, + }, + { + **self.term_line_vals_1, + 'price_unit': 2627.01, + 'price_subtotal': 2627.01, + 'price_total': 2627.01, + 'amount_currency': -2627.01, + 'debit': 0.0, + 'credit': 2627.01, + }, + ], { + **self.move_vals, + 'amount_untaxed': -2180.09, + 'amount_tax': -446.92, + 'amount_total': -2627.01, + }) + + # == Multi-currencies == + + move_form = Form(self.invoice) + move_form.currency_id = self.currency_data['currency'] + with move_form.invoice_line_ids.edit(0) as line_form: + line_form.price_unit = 2300 + move_form.save() + + self.assertInvoiceValues(self.invoice, [ + { + **self.product_line_vals_1, + 'price_unit': 2300.0, + 'price_subtotal': 2180.095, + 'price_total': 2627.014, + 'tax_ids': (self.product_a.taxes_id + tax_price_include).ids, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -2180.095, + 'credit': 1090.05, + }, + { + **self.tax_line_vals_1, + 'price_unit': 327.014, + 'price_subtotal': 327.014, + 'price_total': 327.014, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -327.014, + 'credit': 163.51, + }, + { + 'name': tax_price_include.name, + 'product_id': False, + 'account_id': self.product_line_vals_1['account_id'], + 'partner_id': self.partner_a.id, + 'product_uom_id': False, + 'quantity': 1.0, + 'discount': 0.0, + 'price_unit': 119.905, + 'price_subtotal': 119.905, + 'price_total': 119.905, + 'tax_ids': [], + 'tax_line_id': tax_price_include.id, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -119.905, + 'debit': 0.0, + 'credit': 59.95, + 'date_maturity': False, + 'tax_exigible': True, + }, + { + **self.term_line_vals_1, + 'price_unit': -2627.014, + 'price_subtotal': -2627.014, + 'price_total': -2627.014, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 2627.014, + 'debit': 1313.51, + }, + ], { + **self.move_vals, + 'currency_id': self.currency_data['currency'].id, + 'amount_untaxed': 2180.095, + 'amount_tax': 446.919, + 'amount_total': 2627.014, + }) + + move_form = Form(self.invoice) + with move_form.invoice_line_ids.edit(0) as line_form: + line_form.price_unit = -2300 + move_form.save() + + self.assertInvoiceValues(self.invoice, [ + { + **self.product_line_vals_1, + 'price_unit': -2300.0, + 'price_subtotal': -2180.095, + 'price_total': -2627.014, + 'tax_ids': (self.product_a.taxes_id + tax_price_include).ids, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 2180.095, + 'debit': 1090.05, + 'credit': 0.0, + }, + { + **self.tax_line_vals_1, + 'price_unit': -327.014, + 'price_subtotal': -327.014, + 'price_total': -327.014, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 327.014, + 'debit': 163.51, + 'credit': 0.0, + }, + { + 'name': tax_price_include.name, + 'product_id': False, + 'account_id': self.product_line_vals_1['account_id'], + 'partner_id': self.partner_a.id, + 'product_uom_id': False, + 'quantity': 1.0, + 'discount': 0.0, + 'price_unit': -119.905, + 'price_subtotal': -119.905, + 'price_total': -119.905, + 'tax_ids': [], + 'tax_line_id': tax_price_include.id, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 119.905, + 'debit': 59.95, + 'credit': 0.0, + 'date_maturity': False, + 'tax_exigible': True, + }, + { + **self.term_line_vals_1, + 'price_unit': 2627.014, + 'price_subtotal': 2627.014, + 'price_total': 2627.014, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -2627.014, + 'debit': 0.0, + 'credit': 1313.51, + }, + ], { + **self.move_vals, + 'currency_id': self.currency_data['currency'].id, + 'amount_untaxed': -2180.095, + 'amount_tax': -446.919, + 'amount_total': -2627.014, + }) + + def test_out_invoice_line_onchange_analytic(self): + self.env.user.groups_id += self.env.ref('analytic.group_analytic_accounting') + self.env.user.groups_id += self.env.ref('analytic.group_analytic_tags') + + analytic_tag = self.env['account.analytic.tag'].create({ + 'name': 'test_analytic_tag', + }) + + analytic_account = self.env['account.analytic.account'].create({ + 'name': 'test_analytic_account', + 'partner_id': self.invoice.partner_id.id, + 'code': 'TEST' + }) + + move_form = Form(self.invoice) + with move_form.invoice_line_ids.edit(0) as line_form: + line_form.analytic_account_id = analytic_account + line_form.analytic_tag_ids.add(analytic_tag) + move_form.save() + + # The tax is not flagged as an analytic one. It should change nothing on the taxes. + self.assertInvoiceValues(self.invoice, [ + { + **self.product_line_vals_1, + 'analytic_account_id': analytic_account.id, + 'analytic_tag_ids': analytic_tag.ids, + }, + { + **self.product_line_vals_2, + 'analytic_account_id': False, + 'analytic_tag_ids': [], + }, + { + **self.tax_line_vals_1, + 'analytic_account_id': False, + 'analytic_tag_ids': [], + }, + { + **self.tax_line_vals_2, + 'analytic_account_id': False, + 'analytic_tag_ids': [], + }, + { + **self.term_line_vals_1, + 'analytic_account_id': False, + 'analytic_tag_ids': [], + }, + ], self.move_vals) + + move_form = Form(self.invoice) + with move_form.invoice_line_ids.edit(0) as line_form: + line_form.analytic_account_id = self.env['account.analytic.account'] + line_form.analytic_tag_ids.clear() + move_form.save() + + # Enable the analytic + self.tax_sale_a.analytic = True + + move_form = Form(self.invoice) + with move_form.invoice_line_ids.edit(0) as line_form: + line_form.analytic_account_id = analytic_account + line_form.analytic_tag_ids.add(analytic_tag) + move_form.save() + + # The tax is flagged as an analytic one. + # A new tax line must be generated. + self.assertInvoiceValues(self.invoice, [ + { + **self.product_line_vals_1, + 'analytic_account_id': analytic_account.id, + 'analytic_tag_ids': analytic_tag.ids, + }, + { + **self.product_line_vals_2, + 'analytic_account_id': False, + 'analytic_tag_ids': [], + }, + { + **self.tax_line_vals_1, + 'price_unit': 150.0, + 'price_subtotal': 150.0, + 'price_total': 150.0, + 'amount_currency': -150.0, + 'credit': 150.0, + 'analytic_account_id': analytic_account.id, + 'analytic_tag_ids': analytic_tag.ids, + }, + { + **self.tax_line_vals_1, + 'price_unit': 30.0, + 'price_subtotal': 30.0, + 'price_total': 30.0, + 'amount_currency': -30.0, + 'credit': 30.0, + 'analytic_account_id': False, + 'analytic_tag_ids': [], + }, + { + **self.tax_line_vals_2, + 'analytic_account_id': False, + 'analytic_tag_ids': [], + }, + { + **self.term_line_vals_1, + 'analytic_account_id': False, + 'analytic_tag_ids': [], + }, + ], self.move_vals) + + move_form = Form(self.invoice) + with move_form.invoice_line_ids.edit(0) as line_form: + line_form.analytic_account_id = self.env['account.analytic.account'] + line_form.analytic_tag_ids.clear() + move_form.save() + + # The tax line has been removed. + self.assertInvoiceValues(self.invoice, [ + { + **self.product_line_vals_1, + 'analytic_account_id': False, + 'analytic_tag_ids': [], + }, + { + **self.product_line_vals_2, + 'analytic_account_id': False, + 'analytic_tag_ids': [], + }, + { + **self.tax_line_vals_1, + 'analytic_account_id': False, + 'analytic_tag_ids': [], + }, + { + **self.tax_line_vals_2, + 'analytic_account_id': False, + 'analytic_tag_ids': [], + }, + { + **self.term_line_vals_1, + 'analytic_account_id': False, + 'analytic_tag_ids': [], + }, + ], self.move_vals) + + def test_out_invoice_line_onchange_analytic_2(self): + self.env.user.groups_id += self.env.ref('analytic.group_analytic_accounting') + + analytic_account = self.env['account.analytic.account'].create({ + 'name': 'test_analytic_account1', + 'code': 'TEST1' + }) + + self.invoice.write({'invoice_line_ids': [(1, self.invoice.invoice_line_ids.ids[0], { + 'analytic_account_id': analytic_account.id, + })]}) + + self.assertRecordValues(self.invoice.invoice_line_ids, [ + {'analytic_account_id': analytic_account.id}, + {'analytic_account_id': False}, + ]) + + # We can remove the analytic account, it is not recomputed by an invalidation + self.invoice.write({'invoice_line_ids': [(1, self.invoice.invoice_line_ids.ids[0], { + 'analytic_account_id': False, + })]}) + + self.assertRecordValues(self.invoice.invoice_line_ids, [ + {'analytic_account_id': False}, + {'analytic_account_id': False}, + ]) + + def test_out_invoice_line_onchange_cash_rounding_1(self): + move_form = Form(self.invoice) + # Add a cash rounding having 'add_invoice_line'. + move_form.invoice_cash_rounding_id = self.cash_rounding_a + move_form.save() + + # The cash rounding does nothing as the total is already rounded. + self.assertInvoiceValues(self.invoice, [ + self.product_line_vals_1, + self.product_line_vals_2, + self.tax_line_vals_1, + self.tax_line_vals_2, + self.term_line_vals_1, + ], self.move_vals) + + move_form = Form(self.invoice) + with move_form.invoice_line_ids.edit(0) as line_form: + line_form.price_unit = 999.99 + move_form.save() + + self.assertInvoiceValues(self.invoice, [ + { + 'name': 'add_invoice_line', + 'product_id': False, + 'account_id': self.cash_rounding_a.profit_account_id.id, + 'partner_id': self.partner_a.id, + 'product_uom_id': False, + 'quantity': 1.0, + 'discount': 0.0, + 'price_unit': 0.01, + 'price_subtotal': 0.01, + 'price_total': 0.01, + 'tax_ids': [], + 'tax_line_id': False, + 'currency_id': self.company_data['currency'].id, + 'amount_currency': -0.01, + 'debit': 0.0, + 'credit': 0.01, + 'date_maturity': False, + 'tax_exigible': True, + }, + { + **self.product_line_vals_1, + 'price_unit': 999.99, + 'price_subtotal': 999.99, + 'price_total': 1149.99, + 'amount_currency': -999.99, + 'credit': 999.99, + }, + self.product_line_vals_2, + self.tax_line_vals_1, + self.tax_line_vals_2, + self.term_line_vals_1, + ], self.move_vals) + + move_form = Form(self.invoice) + # Change the cash rounding to one having 'biggest_tax'. + move_form.invoice_cash_rounding_id = self.cash_rounding_b + move_form.save() + + self.assertInvoiceValues(self.invoice, [ + { + **self.product_line_vals_1, + 'price_unit': 999.99, + 'price_subtotal': 999.99, + 'price_total': 1149.99, + 'amount_currency': -999.99, + 'credit': 999.99, + }, + self.product_line_vals_2, + self.tax_line_vals_1, + self.tax_line_vals_2, + { + 'name': '%s (rounding)' % self.tax_sale_a.name, + 'product_id': False, + 'account_id': self.company_data['default_account_tax_sale'].id, + 'partner_id': self.partner_a.id, + 'product_uom_id': False, + 'quantity': 1.0, + 'discount': 0.0, + 'price_unit': -0.04, + 'price_subtotal': -0.04, + 'price_total': -0.04, + 'tax_ids': [], + 'tax_line_id': self.tax_sale_a.id, + 'currency_id': self.company_data['currency'].id, + 'amount_currency': 0.04, + 'debit': 0.04, + 'credit': 0.0, + 'date_maturity': False, + 'tax_exigible': True, + }, + { + **self.term_line_vals_1, + 'price_unit': -1409.95, + 'price_subtotal': -1409.95, + 'price_total': -1409.95, + 'amount_currency': 1409.95, + 'debit': 1409.95, + }, + ], { + **self.move_vals, + 'amount_untaxed': 1199.99, + 'amount_tax': 209.96, + 'amount_total': 1409.95, + }) + + def test_out_invoice_line_onchange_currency_1(self): + move_form = Form(self.invoice.with_context(dudu=True)) + move_form.currency_id = self.currency_data['currency'] + move_form.save() + + self.assertInvoiceValues(self.invoice, [ + { + **self.product_line_vals_1, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -1000.0, + 'credit': 500.0, + }, + { + **self.product_line_vals_2, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -200.0, + 'credit': 100.0, + }, + { + **self.tax_line_vals_1, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -180.0, + 'credit': 90.0, + }, + { + **self.tax_line_vals_2, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -30.0, + 'credit': 15.0, + }, + { + **self.term_line_vals_1, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 1410.0, + 'debit': 705.0, + }, + ], { + **self.move_vals, + 'currency_id': self.currency_data['currency'].id, + }) + + move_form = Form(self.invoice) + # Change the date to get another rate: 1/3 instead of 1/2. + move_form.date = fields.Date.from_string('2016-01-01') + move_form.save() + + self.assertInvoiceValues(self.invoice, [ + { + **self.product_line_vals_1, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -1000.0, + 'credit': 333.33, + }, + { + **self.product_line_vals_2, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -200.0, + 'credit': 66.67, + }, + { + **self.tax_line_vals_1, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -180.0, + 'credit': 60.0, + }, + { + **self.tax_line_vals_2, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -30.0, + 'credit': 10.0, + }, + { + **self.term_line_vals_1, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 1410.0, + 'debit': 470.0, + }, + ], { + **self.move_vals, + 'currency_id': self.currency_data['currency'].id, + 'date': fields.Date.from_string('2016-01-01'), + }) + + move_form = Form(self.invoice) + with move_form.invoice_line_ids.edit(0) as line_form: + # 0.045 * 0.1 = 0.0045. As the foreign currency has a 0.001 rounding, + # the result should be 0.005 after rounding. + line_form.quantity = 0.1 + line_form.price_unit = 0.045 + move_form.save() + + self.assertInvoiceValues(self.invoice, [ + { + **self.product_line_vals_1, + 'quantity': 0.1, + 'price_unit': 0.05, + 'price_subtotal': 0.005, + 'price_total': 0.006, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -0.005, + 'credit': 0.0, + }, + { + **self.product_line_vals_2, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -200.0, + 'credit': 66.67, + }, + { + **self.tax_line_vals_1, + 'price_unit': 30.0, + 'price_subtotal': 30.001, + 'price_total': 30.001, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -30.001, + 'credit': 10.0, + }, + { + **self.tax_line_vals_2, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -30.0, + 'credit': 10.0, + }, + { + **self.term_line_vals_1, + 'currency_id': self.currency_data['currency'].id, + 'price_unit': -260.01, + 'price_subtotal': -260.006, + 'price_total': -260.006, + 'amount_currency': 260.006, + 'debit': 86.67, + }, + ], { + **self.move_vals, + 'currency_id': self.currency_data['currency'].id, + 'date': fields.Date.from_string('2016-01-01'), + 'amount_untaxed': 200.005, + 'amount_tax': 60.001, + 'amount_total': 260.006, + }) + + # Exit the multi-currencies. + move_form = Form(self.invoice) + move_form.currency_id = self.company_data['currency'] + move_form.save() + + self.assertInvoiceValues(self.invoice, [ + { + **self.product_line_vals_1, + 'quantity': 0.1, + 'price_unit': 0.05, + 'price_subtotal': 0.01, + 'price_total': 0.01, + 'amount_currency': -0.01, + 'credit': 0.01, + }, + self.product_line_vals_2, + { + **self.tax_line_vals_1, + 'price_unit': 30.0, + 'price_subtotal': 30.0, + 'price_total': 30.0, + 'amount_currency': -30.0, + 'credit': 30.0, + }, + self.tax_line_vals_2, + { + **self.term_line_vals_1, + 'price_unit': -260.01, + 'price_subtotal': -260.01, + 'price_total': -260.01, + 'amount_currency': 260.01, + 'debit': 260.01, + }, + ], { + **self.move_vals, + 'currency_id': self.company_data['currency'].id, + 'date': fields.Date.from_string('2016-01-01'), + 'amount_untaxed': 200.01, + 'amount_tax': 60.0, + 'amount_total': 260.01, + }) + + def test_out_invoice_create_refund(self): + self.invoice.action_post() + + move_reversal = self.env['account.move.reversal'].with_context(active_model="account.move", active_ids=self.invoice.ids).create({ + 'date': fields.Date.from_string('2019-02-01'), + 'reason': 'no reason', + 'refund_method': 'refund', + }) + reversal = move_reversal.reverse_moves() + reverse_move = self.env['account.move'].browse(reversal['res_id']) + + self.assertEqual(self.invoice.payment_state, 'not_paid', "Refunding with a draft credit note should keep the invoice 'not_paid'.") + self.assertInvoiceValues(reverse_move, [ + { + **self.product_line_vals_1, + 'amount_currency': 1000.0, + 'debit': 1000.0, + 'credit': 0.0, + }, + { + **self.product_line_vals_2, + 'amount_currency': 200.0, + 'debit': 200.0, + 'credit': 0.0, + }, + { + **self.tax_line_vals_1, + 'amount_currency': 180.0, + 'debit': 180.0, + 'credit': 0.0, + }, + { + **self.tax_line_vals_2, + 'amount_currency': 30.0, + 'debit': 30.0, + 'credit': 0.0, + }, + { + **self.term_line_vals_1, + 'name': '', + 'amount_currency': -1410.0, + 'debit': 0.0, + 'credit': 1410.0, + 'date_maturity': move_reversal.date, + }, + ], { + **self.move_vals, + 'invoice_payment_term_id': None, + 'name': 'RINV/2019/02/0001', + 'date': move_reversal.date, + 'state': 'draft', + 'ref': 'Reversal of: %s, %s' % (self.invoice.name, move_reversal.reason), + 'payment_state': 'not_paid', + }) + + move_reversal = self.env['account.move.reversal'].with_context(active_model="account.move", active_ids=self.invoice.ids).create({ + 'date': fields.Date.from_string('2019-02-01'), + 'reason': 'no reason', + 'refund_method': 'cancel', + }) + reversal = move_reversal.reverse_moves() + reverse_move = self.env['account.move'].browse(reversal['res_id']) + + self.assertEqual(self.invoice.payment_state, 'reversed', "After cancelling it with a reverse invoice, an invoice should be in 'reversed' state.") + self.assertInvoiceValues(reverse_move, [ + { + **self.product_line_vals_1, + 'amount_currency': 1000.0, + 'debit': 1000.0, + 'credit': 0.0, + }, + { + **self.product_line_vals_2, + 'amount_currency': 200.0, + 'debit': 200.0, + 'credit': 0.0, + }, + { + **self.tax_line_vals_1, + 'amount_currency': 180.0, + 'debit': 180.0, + 'credit': 0.0, + }, + { + **self.tax_line_vals_2, + 'amount_currency': 30.0, + 'debit': 30.0, + 'credit': 0.0, + }, + { + **self.term_line_vals_1, + 'name': '', + 'amount_currency': -1410.0, + 'debit': 0.0, + 'credit': 1410.0, + 'date_maturity': move_reversal.date, + }, + ], { + **self.move_vals, + 'invoice_payment_term_id': None, + 'date': move_reversal.date, + 'state': 'posted', + 'ref': 'Reversal of: %s, %s' % (self.invoice.name, move_reversal.reason), + 'payment_state': 'paid', + }) + + def test_out_invoice_create_refund_multi_currency(self): + ''' Test the account.move.reversal takes care about the currency rates when setting + a custom reversal date. + ''' + move_form = Form(self.invoice) + move_form.date = '2016-01-01' + move_form.currency_id = self.currency_data['currency'] + move_form.save() + + self.invoice.action_post() + + # The currency rate changed from 1/3 to 1/2. + move_reversal = self.env['account.move.reversal'].with_context(active_model="account.move", active_ids=self.invoice.ids).create({ + 'date': fields.Date.from_string('2017-01-01'), + 'reason': 'no reason', + 'refund_method': 'refund', + }) + reversal = move_reversal.reverse_moves() + reverse_move = self.env['account.move'].browse(reversal['res_id']) + + self.assertEqual(self.invoice.payment_state, 'not_paid', "Refunding with a draft credit note should keep the invoice 'not_paid'.") + self.assertInvoiceValues(reverse_move, [ + { + **self.product_line_vals_1, + 'amount_currency': 1000.0, + 'currency_id': self.currency_data['currency'].id, + 'debit': 500.0, + 'credit': 0.0, + }, + { + **self.product_line_vals_2, + 'amount_currency': 200.0, + 'currency_id': self.currency_data['currency'].id, + 'debit': 100.0, + 'credit': 0.0, + }, + { + **self.tax_line_vals_1, + 'amount_currency': 180.0, + 'currency_id': self.currency_data['currency'].id, + 'debit': 90.0, + 'credit': 0.0, + }, + { + **self.tax_line_vals_2, + 'amount_currency': 30.0, + 'currency_id': self.currency_data['currency'].id, + 'debit': 15.0, + 'credit': 0.0, + }, + { + **self.term_line_vals_1, + 'name': '', + 'amount_currency': -1410.0, + 'currency_id': self.currency_data['currency'].id, + 'debit': 0.0, + 'credit': 705.0, + 'date_maturity': move_reversal.date, + }, + ], { + **self.move_vals, + 'invoice_payment_term_id': None, + 'currency_id': self.currency_data['currency'].id, + 'date': move_reversal.date, + 'state': 'draft', + 'ref': 'Reversal of: %s, %s' % (self.invoice.name, move_reversal.reason), + 'payment_state': 'not_paid', + }) + + move_reversal = self.env['account.move.reversal'].with_context(active_model="account.move", active_ids=self.invoice.ids).create({ + 'date': fields.Date.from_string('2017-01-01'), + 'reason': 'no reason', + 'refund_method': 'cancel', + }) + reversal = move_reversal.reverse_moves() + reverse_move = self.env['account.move'].browse(reversal['res_id']) + + self.assertEqual(self.invoice.payment_state, 'reversed', "After cancelling it with a reverse invoice, an invoice should be in 'reversed' state.") + self.assertInvoiceValues(reverse_move, [ + { + **self.product_line_vals_1, + 'amount_currency': 1000.0, + 'currency_id': self.currency_data['currency'].id, + 'debit': 500.0, + 'credit': 0.0, + }, + { + **self.product_line_vals_2, + 'amount_currency': 200.0, + 'currency_id': self.currency_data['currency'].id, + 'debit': 100.0, + 'credit': 0.0, + }, + { + **self.tax_line_vals_1, + 'amount_currency': 180.0, + 'currency_id': self.currency_data['currency'].id, + 'debit': 90.0, + 'credit': 0.0, + }, + { + **self.tax_line_vals_2, + 'amount_currency': 30.0, + 'currency_id': self.currency_data['currency'].id, + 'debit': 15.0, + 'credit': 0.0, + }, + { + **self.term_line_vals_1, + 'name': '', + 'amount_currency': -1410.0, + 'currency_id': self.currency_data['currency'].id, + 'debit': 0.0, + 'credit': 705.0, + 'date_maturity': move_reversal.date, + }, + ], { + **self.move_vals, + 'invoice_payment_term_id': None, + 'currency_id': self.currency_data['currency'].id, + 'date': move_reversal.date, + 'state': 'posted', + 'ref': 'Reversal of: %s, %s' % (self.invoice.name, move_reversal.reason), + 'payment_state': 'paid', + }) + + def test_out_invoice_create_refund_auto_post(self): + self.invoice.action_post() + + move_reversal = self.env['account.move.reversal'].with_context(active_model="account.move", active_ids=self.invoice.ids).create({ + 'date': fields.Date.today() + timedelta(days=7), + 'reason': 'no reason', + 'refund_method': 'modify', + }) + move_reversal.reverse_moves() + refund = self.env['account.move'].search([('move_type', '=', 'out_refund'), ('company_id', '=', self.invoice.company_id.id)]) + + self.assertRecordValues(refund, [{ + 'state': 'draft', + 'auto_post': True, + }]) + + def test_out_invoice_create_1(self): + # Test creating an account_move with the least information. + move = self.env['account.move'].create({ + 'move_type': 'out_invoice', + 'partner_id': self.partner_a.id, + 'invoice_date': fields.Date.from_string('2019-01-01'), + 'currency_id': self.currency_data['currency'].id, + 'invoice_payment_term_id': self.pay_terms_a.id, + 'invoice_line_ids': [ + (0, None, { + 'product_id': self.product_a.id, + 'product_uom_id': self.product_a.uom_id.id, + 'quantity': 1.0, + 'price_unit': 1000.0, + 'tax_ids': [(6, 0, self.product_a.taxes_id.ids)], + }), + (0, None, { + 'product_id': self.product_b.id, + 'product_uom_id': self.product_b.uom_id.id, + 'quantity': 1.0, + 'price_unit': 200.0, + 'tax_ids': [(6, 0, self.product_b.taxes_id.ids)], + }), + ] + }) + + self.assertInvoiceValues(move, [ + { + **self.product_line_vals_1, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -1000.0, + 'credit': 500.0, + }, + { + **self.product_line_vals_2, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -200.0, + 'credit': 100.0, + }, + { + **self.tax_line_vals_1, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -180.0, + 'credit': 90.0, + }, + { + **self.tax_line_vals_2, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -30.0, + 'credit': 15.0, + }, + { + **self.term_line_vals_1, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 1410.0, + 'debit': 705.0, + }, + ], { + **self.move_vals, + 'currency_id': self.currency_data['currency'].id, + }) + + def test_out_invoice_create_child_partner(self): + # Test creating an account_move on a child partner. + # This needs to attach the lines to the parent partner id. + partner_a_child = self.env['res.partner'].create({ + 'name': 'partner_a_child', + 'parent_id': self.partner_a.id + }) + move = self.env['account.move'].create({ + 'move_type': 'out_invoice', + 'partner_id': partner_a_child.id, + 'invoice_date': fields.Date.from_string('2019-01-01'), + 'currency_id': self.currency_data['currency'].id, + 'invoice_payment_term_id': self.pay_terms_a.id, + 'invoice_line_ids': [ + (0, None, self.product_line_vals_1), + (0, None, self.product_line_vals_2), + ] + }) + + self.assertEqual(partner_a_child.id, move.partner_id.id, 'Keep child partner on the account move record') + self.assertEqual(self.partner_a.id, move.line_ids[0].partner_id.id, 'Set parent partner on the account move line records') + + def test_out_invoice_write_1(self): + # Test creating an account_move with the least information. + move = self.env['account.move'].create({ + 'move_type': 'out_invoice', + 'partner_id': self.partner_a.id, + 'invoice_date': fields.Date.from_string('2019-01-01'), + 'currency_id': self.currency_data['currency'].id, + 'invoice_payment_term_id': self.pay_terms_a.id, + 'invoice_line_ids': [ + (0, None, self.product_line_vals_1), + ] + }) + move.write({ + 'invoice_line_ids': [ + (0, None, self.product_line_vals_2), + ] + }) + + self.assertInvoiceValues(move, [ + { + **self.product_line_vals_1, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -1000.0, + 'credit': 500.0, + }, + { + **self.product_line_vals_2, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -200.0, + 'credit': 100.0, + }, + { + **self.tax_line_vals_1, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -180.0, + 'credit': 90.0, + }, + { + **self.tax_line_vals_2, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -30.0, + 'credit': 15.0, + }, + { + **self.term_line_vals_1, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 1410.0, + 'debit': 705.0, + }, + ], { + **self.move_vals, + 'currency_id': self.currency_data['currency'].id, + }) + + def test_out_invoice_write_2(self): + ''' Ensure to not messing the invoice when writing a bad account type. ''' + move = self.env['account.move'].create({ + 'move_type': 'out_invoice', + 'partner_id': self.partner_a.id, + 'invoice_line_ids': [ + (0, None, { + 'name': 'test_out_invoice_write_2', + 'quantity': 1.0, + 'price_unit': 2000, + }), + ] + }) + + receivable_lines = move.line_ids.filtered(lambda line: line.account_id.user_type_id.type == 'receivable') + not_receivable_lines = move.line_ids - receivable_lines + + # Write a receivable account on a not-receivable line. + with self.assertRaises(UserError), self.cr.savepoint(): + not_receivable_lines.write({'account_id': receivable_lines[0].account_id.copy().id}) + + # Write a not-receivable account on a receivable line. + with self.assertRaises(UserError), self.cr.savepoint(): + receivable_lines.write({'account_id': not_receivable_lines[0].account_id.copy().id}) + + # Write another receivable account on a receivable line. + receivable_lines.write({'account_id': receivable_lines[0].account_id.copy().id}) + + def test_out_invoice_post_1(self): + ''' Check the invoice_date will be set automatically at the post date. ''' + frozen_today = fields.Date.today() + with patch.object(fields.Date, 'today', lambda *args, **kwargs: frozen_today), patch.object(fields.Date, 'context_today', lambda *args, **kwargs: frozen_today): + # Create an invoice with rate 1/3. + move = self.env['account.move'].create({ + 'move_type': 'out_invoice', + 'partner_id': self.partner_a.id, + 'invoice_date': fields.Date.from_string('2016-01-01'), + 'currency_id': self.currency_data['currency'].id, + 'invoice_payment_term_id': self.pay_terms_a.id, + 'invoice_line_ids': [ + (0, None, self.product_line_vals_1), + (0, None, self.product_line_vals_2), + ] + }) + + # Remove the invoice_date to check: + # - The invoice_date must be set automatically at today during the post. + # - As the invoice_date changed, date did too so the currency rate has changed (1/3 => 1/2). + # - A different invoice_date implies also a new date_maturity. + # Add a manual edition of a tax line: + # - The modification must be preserved in the business fields. + # - The journal entry must be balanced before / after the post. + move.write({ + 'invoice_date': False, + 'line_ids': [ + (1, move.line_ids.filtered(lambda line: line.tax_line_id.id == self.tax_line_vals_1['tax_line_id']).id, { + 'amount_currency': -200.0, + }), + (1, move.line_ids.filtered(lambda line: line.date_maturity).id, { + 'amount_currency': 1430.0, + }), + ], + }) + + move.action_post() + + self.assertInvoiceValues(move, [ + { + **self.product_line_vals_1, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -1000.0, + 'credit': 500.0, + }, + { + **self.product_line_vals_2, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -200.0, + 'credit': 100.0, + }, + { + **self.tax_line_vals_1, + 'currency_id': self.currency_data['currency'].id, + 'price_unit': 200.0, + 'price_subtotal': 200.0, + 'price_total': 200.0, + 'amount_currency': -200.0, + 'credit': 100.0, + }, + { + **self.tax_line_vals_2, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -30.0, + 'credit': 15.0, + }, + { + **self.term_line_vals_1, + 'name': move.name, + 'currency_id': self.currency_data['currency'].id, + 'price_unit': -1430.0, + 'price_subtotal': -1430.0, + 'price_total': -1430.0, + 'amount_currency': 1430.0, + 'debit': 715.0, + 'date_maturity': frozen_today, + }, + ], { + **self.move_vals, + 'payment_reference': move.name, + 'currency_id': self.currency_data['currency'].id, + 'date': frozen_today, + 'invoice_date': frozen_today, + 'invoice_date_due': frozen_today, + 'amount_tax': 230.0, + 'amount_total': 1430.0, + }) + + def test_out_invoice_post_2(self): + ''' Check the date will be set automatically at the next available post date due to the tax lock date. ''' + # Create an invoice with rate 1/3. + move = self.env['account.move'].create({ + 'move_type': 'out_invoice', + 'partner_id': self.partner_a.id, + 'invoice_date': fields.Date.from_string('2016-01-01'), + 'date': fields.Date.from_string('2015-01-01'), + 'currency_id': self.currency_data['currency'].id, + 'invoice_payment_term_id': self.pay_terms_a.id, + 'invoice_line_ids': [ + (0, None, { + 'name': self.product_line_vals_1['name'], + 'product_id': self.product_line_vals_1['product_id'], + 'product_uom_id': self.product_line_vals_1['product_uom_id'], + 'quantity': self.product_line_vals_1['quantity'], + 'price_unit': self.product_line_vals_1['price_unit'], + 'tax_ids': self.product_line_vals_1['tax_ids'], + }), + (0, None, { + 'name': self.product_line_vals_2['name'], + 'product_id': self.product_line_vals_2['product_id'], + 'product_uom_id': self.product_line_vals_2['product_uom_id'], + 'quantity': self.product_line_vals_2['quantity'], + 'price_unit': self.product_line_vals_2['price_unit'], + 'tax_ids': self.product_line_vals_2['tax_ids'], + }), + ], + }) + + # Add a manual edition of a tax line: + # - The modification must be preserved in the business fields. + # - The journal entry must be balanced before / after the post. + move.write({ + 'line_ids': [ + (1, move.line_ids.filtered(lambda line: line.tax_line_id.id == self.tax_line_vals_1['tax_line_id']).id, { + 'amount_currency': -200.0, + }), + (1, move.line_ids.filtered(lambda line: line.date_maturity).id, { + 'amount_currency': 1430.0, + }), + ], + }) + + # Set the tax lock date: + # - The date must be set automatically at the date after the tax_lock_date. + # - As the date changed, the currency rate has changed (1/3 => 1/2). + move.company_id.tax_lock_date = fields.Date.from_string('2016-12-31') + + move.action_post() + + self.assertInvoiceValues(move, [ + { + **self.product_line_vals_1, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -1000.0, + 'debit': 0.0, + 'credit': 500.0, + }, + { + **self.product_line_vals_2, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -200.0, + 'debit': 0.0, + 'credit': 100.0, + }, + { + **self.tax_line_vals_1, + 'currency_id': self.currency_data['currency'].id, + 'price_unit': 200.0, + 'price_subtotal': 200.0, + 'price_total': 200.0, + 'amount_currency': -200.0, + 'debit': 0.0, + 'credit': 100.0, + }, + { + **self.tax_line_vals_2, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -30.0, + 'debit': 0.0, + 'credit': 15.0, + }, + { + **self.term_line_vals_1, + 'name': move.name, + 'currency_id': self.currency_data['currency'].id, + 'price_unit': -1430.0, + 'price_subtotal': -1430.0, + 'price_total': -1430.0, + 'amount_currency': 1430.0, + 'debit': 715.0, + 'credit': 0.0, + 'date_maturity': fields.Date.from_string('2016-01-01'), + }, + ], { + **self.move_vals, + 'payment_reference': move.name, + 'currency_id': self.currency_data['currency'].id, + 'date': fields.Date.from_string('2017-01-01'), + 'amount_untaxed': 1200.0, + 'amount_tax': 230.0, + 'amount_total': 1430.0, + }) + + def test_out_invoice_switch_out_refund_1(self): + # Test creating an account_move with an out_invoice_type and switch it in an out_refund. + move = self.env['account.move'].create({ + 'move_type': 'out_invoice', + 'partner_id': self.partner_a.id, + 'invoice_date': fields.Date.from_string('2019-01-01'), + 'currency_id': self.currency_data['currency'].id, + 'invoice_payment_term_id': self.pay_terms_a.id, + 'invoice_line_ids': [ + (0, None, self.product_line_vals_1), + (0, None, self.product_line_vals_2), + ] + }) + move.action_switch_invoice_into_refund_credit_note() + + self.assertRecordValues(move, [{'move_type': 'out_refund'}]) + self.assertInvoiceValues(move, [ + { + **self.product_line_vals_1, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 1000.0, + 'debit': 500.0, + 'credit': 0, + }, + { + **self.product_line_vals_2, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 200.0, + 'debit': 100.0, + 'credit': 0, + }, + { + **self.tax_line_vals_1, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 180.0, + 'debit': 90.0, + 'credit': 0, + }, + { + **self.tax_line_vals_2, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 30.0, + 'debit': 15.0, + 'credit': 0, + }, + { + **self.term_line_vals_1, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -1410.0, + 'credit': 705.0, + 'debit': 0, + }, + ], { + **self.move_vals, + 'currency_id': self.currency_data['currency'].id, + }) + + def test_out_invoice_switch_out_refund_2(self): + # Test creating an account_move with an out_invoice_type and switch it in an out_refund and a negative quantity. + modified_product_line_vals_1 = self.product_line_vals_1.copy() + modified_product_line_vals_1.update({'quantity': -modified_product_line_vals_1['quantity']}) + modified_product_line_vals_2 = self.product_line_vals_2.copy() + modified_product_line_vals_2.update({'quantity': -modified_product_line_vals_2['quantity']}) + move = self.env['account.move'].create({ + 'move_type': 'out_invoice', + 'partner_id': self.partner_a.id, + 'invoice_date': fields.Date.from_string('2019-01-01'), + 'currency_id': self.currency_data['currency'].id, + 'invoice_payment_term_id': self.pay_terms_a.id, + 'invoice_line_ids': [ + (0, None, modified_product_line_vals_1), + (0, None, modified_product_line_vals_2), + ] + }) + + self.assertInvoiceValues(move, [ + { + **self.product_line_vals_1, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 1000.0, + 'price_subtotal': -1000.0, + 'price_total': -1150.0, + 'debit': 500.0, + 'credit': 0, + 'quantity': -1.0, + }, + { + **self.product_line_vals_2, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 200.0, + 'price_subtotal': -200.0, + 'price_total': -260.0, + 'debit': 100.0, + 'credit': 0, + 'quantity': -1.0, + }, + { + **self.tax_line_vals_1, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 180.0, + 'price_subtotal': -180.0, + 'price_total': -180.0, + 'price_unit': -180.0, + 'debit': 90.0, + 'credit': 0, + }, + { + **self.tax_line_vals_2, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 30.0, + 'price_subtotal': -30.0, + 'price_total': -30.0, + 'price_unit': -30.0, + 'debit': 15.0, + 'credit': 0, + }, + { + **self.term_line_vals_1, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -1410.0, + 'price_subtotal': 1410.0, + 'price_total': 1410.0, + 'price_unit': 1410.0, + 'credit': 705.0, + 'debit': 0, + }, + ], { + **self.move_vals, + 'currency_id': self.currency_data['currency'].id, + 'amount_tax' : -self.move_vals['amount_tax'], + 'amount_total' : -self.move_vals['amount_total'], + 'amount_untaxed' : -self.move_vals['amount_untaxed'], + }) + + move.action_switch_invoice_into_refund_credit_note() + + self.assertRecordValues(move, [{'move_type': 'out_refund'}]) + self.assertInvoiceValues(move, [ + { + **self.product_line_vals_1, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 1000.0, + 'debit': 500.0, + 'credit': 0, + }, + { + **self.product_line_vals_2, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 200.0, + 'debit': 100.0, + 'credit': 0, + }, + { + **self.tax_line_vals_1, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 180.0, + 'debit': 90.0, + 'credit': 0, + }, + { + **self.tax_line_vals_2, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 30.0, + 'debit': 15.0, + 'credit': 0, + }, + { + **self.term_line_vals_1, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -1410.0, + 'credit': 705.0, + 'debit': 0, + }, + ], { + **self.move_vals, + 'currency_id': self.currency_data['currency'].id, + 'amount_tax' : self.move_vals['amount_tax'], + 'amount_total' : self.move_vals['amount_total'], + 'amount_untaxed' : self.move_vals['amount_untaxed'], + }) + + def test_out_invoice_reverse_move_tags(self): + country = self.env.ref('base.us') + tags = self.env['account.account.tag'].create([{ + 'name': "Test tag %s" % i, + 'applicability': 'taxes', + 'country_id': country.id, + } for i in range(8)]) + + taxes = self.env['account.tax'].create([{ + 'name': "Test tax include_base_amount = %s" % include_base_amount, + 'amount': 10.0, + 'include_base_amount': include_base_amount, + 'invoice_repartition_line_ids': [ + (0, 0, { + 'factor_percent': 100, + 'repartition_type': 'base', + 'tag_ids': [(6, 0, tags[(i * 4)].ids)], + }), + (0, 0, { + 'factor_percent': 100, + 'repartition_type': 'tax', + 'tag_ids': [(6, 0, tags[(i * 4) + 1].ids)], + }), + ], + 'refund_repartition_line_ids': [ + (0, 0, { + 'factor_percent': 100, + 'repartition_type': 'base', + 'tag_ids': [(6, 0, tags[(i * 4) + 2].ids)], + }), + (0, 0, { + 'factor_percent': 100, + 'repartition_type': 'tax', + 'tag_ids': [(6, 0, tags[(i * 4) + 3].ids)], + }), + ], + } for i, include_base_amount in enumerate((True, False))]) + + invoice = self.env['account.move'].create({ + 'move_type': 'out_invoice', + 'partner_id': self.partner_a.id, + 'invoice_date': fields.Date.from_string('2019-01-01'), + 'invoice_payment_term_id': self.pay_terms_a.id, + 'invoice_line_ids': [ + (0, 0, { + 'product_id': self.product_a.id, + 'price_unit': 1000.0, + 'tax_ids': [(6, 0, taxes.ids)], + }), + ] + }) + invoice.action_post() + + self.assertRecordValues(invoice.line_ids.sorted('tax_line_id'), [ + # Product line + {'tax_line_id': False, 'tax_ids': taxes.ids, 'tax_tag_ids': (tags[0] + tags[4]).ids}, + # Receivable line + {'tax_line_id': False, 'tax_ids': [], 'tax_tag_ids': []}, + # Tax lines + {'tax_line_id': taxes[0].id, 'tax_ids': taxes[1].ids, 'tax_tag_ids': (tags[1] + tags[4]).ids}, + {'tax_line_id': taxes[1].id, 'tax_ids': [], 'tax_tag_ids': tags[5].ids}, + ]) + + refund = invoice._reverse_moves(cancel=True) + + self.assertRecordValues(refund.line_ids.sorted('tax_line_id'), [ + # Product line + {'tax_line_id': False, 'tax_ids': taxes.ids, 'tax_tag_ids': (tags[2] + tags[6]).ids}, + # Receivable line + {'tax_line_id': False, 'tax_ids': [], 'tax_tag_ids': []}, + # Tax lines + {'tax_line_id': taxes[0].id, 'tax_ids': taxes[1].ids, 'tax_tag_ids': (tags[3] + tags[6]).ids}, + {'tax_line_id': taxes[1].id, 'tax_ids': [], 'tax_tag_ids': tags[7].ids}, + ]) + + def test_out_invoice_change_period_accrual_1(self): + move = self.env['account.move'].create({ + 'move_type': 'out_invoice', + 'date': '2017-01-01', + 'partner_id': self.partner_a.id, + 'invoice_date': fields.Date.from_string('2017-01-01'), + 'currency_id': self.currency_data['currency'].id, + 'invoice_payment_term_id': self.pay_terms_a.id, + 'invoice_line_ids': [ + (0, None, { + 'name': self.product_line_vals_1['name'], + 'product_id': self.product_line_vals_1['product_id'], + 'product_uom_id': self.product_line_vals_1['product_uom_id'], + 'quantity': self.product_line_vals_1['quantity'], + 'price_unit': self.product_line_vals_1['price_unit'], + 'tax_ids': self.product_line_vals_1['tax_ids'], + }), + (0, None, { + 'name': self.product_line_vals_2['name'], + 'product_id': self.product_line_vals_2['product_id'], + 'product_uom_id': self.product_line_vals_2['product_uom_id'], + 'quantity': self.product_line_vals_2['quantity'], + 'price_unit': self.product_line_vals_2['price_unit'], + 'tax_ids': self.product_line_vals_2['tax_ids'], + }), + ] + }) + move.action_post() + + wizard = self.env['account.automatic.entry.wizard']\ + .with_context(active_model='account.move.line', active_ids=move.invoice_line_ids.ids).create({ + 'action': 'change_period', + 'date': '2018-01-01', + 'percentage': 60, + 'journal_id': self.company_data['default_journal_misc'].id, + 'expense_accrual_account': self.env['account.account'].create({ + 'name': 'Accrual Expense Account', + 'code': '234567', + 'user_type_id': self.env.ref('account.data_account_type_expenses').id, + 'reconcile': True, + }).id, + 'revenue_accrual_account': self.env['account.account'].create({ + 'name': 'Accrual Revenue Account', + 'code': '765432', + 'user_type_id': self.env.ref('account.data_account_type_expenses').id, + 'reconcile': True, + }).id, + }) + wizard_res = wizard.do_action() + + self.assertInvoiceValues(move, [ + { + **self.product_line_vals_1, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -1000.0, + 'debit': 0.0, + 'credit': 500.0, + }, + { + **self.product_line_vals_2, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -200.0, + 'debit': 0.0, + 'credit': 100.0, + }, + { + **self.tax_line_vals_1, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -180.0, + 'debit': 0.0, + 'credit': 90.0, + }, + { + **self.tax_line_vals_2, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -30.0, + 'debit': 0.0, + 'credit': 15.0, + }, + { + **self.term_line_vals_1, + 'currency_id': self.currency_data['currency'].id, + 'name': 'INV/2017/01/0001', + 'amount_currency': 1410.0, + 'debit': 705.0, + 'credit': 0.0, + 'date_maturity': fields.Date.from_string('2017-01-01'), + }, + ], { + **self.move_vals, + 'currency_id': self.currency_data['currency'].id, + 'date': fields.Date.from_string('2017-01-01'), + 'payment_reference': 'INV/2017/01/0001', + }) + + accrual_lines = self.env['account.move'].browse(wizard_res['domain'][0][2]).line_ids.sorted('date') + self.assertRecordValues(accrual_lines, [ + {'amount_currency': 600.0, 'debit': 300.0, 'credit': 0.0, 'account_id': self.product_line_vals_1['account_id'], 'reconciled': False}, + {'amount_currency': -600.0, 'debit': 0.0, 'credit': 300.0, 'account_id': wizard.revenue_accrual_account.id, 'reconciled': True}, + {'amount_currency': 120.0, 'debit': 60.0, 'credit': 0.0, 'account_id': self.product_line_vals_2['account_id'], 'reconciled': False}, + {'amount_currency': -120.0, 'debit': 0.0, 'credit': 60.0, 'account_id': wizard.revenue_accrual_account.id, 'reconciled': True}, + {'amount_currency': -600.0, 'debit': 0.0, 'credit': 300.0, 'account_id': self.product_line_vals_1['account_id'], 'reconciled': False}, + {'amount_currency': 600.0, 'debit': 300.0, 'credit': 0.0, 'account_id': wizard.revenue_accrual_account.id, 'reconciled': True}, + {'amount_currency': -120.0, 'debit': 0.0, 'credit': 60.0, 'account_id': self.product_line_vals_2['account_id'], 'reconciled': False}, + {'amount_currency': 120.0, 'debit': 60.0, 'credit': 0.0, 'account_id': wizard.revenue_accrual_account.id, 'reconciled': True}, + ]) + + def test_out_invoice_filter_zero_balance_lines(self): + zero_balance_payment_term = self.env['account.payment.term'].create({ + 'name': 'zero_balance_payment_term', + 'line_ids': [ + (0, 0, { + 'value': 'percent', + 'value_amount': 100.0, + 'sequence': 10, + 'days': 0, + 'option': 'day_after_invoice_date', + }), + (0, 0, { + 'value': 'balance', + 'value_amount': 0.0, + 'sequence': 20, + 'days': 0, + 'option': 'day_after_invoice_date', + }), + ], + }) + + zero_balance_tax = self.env['account.tax'].create({ + 'name': 'zero_balance_tax', + 'amount_type': 'percent', + 'amount': 0.0, + }) + + invoice = self.env['account.move'].create({ + 'move_type': 'out_invoice', + 'partner_id': self.partner_a.id, + 'invoice_date': fields.Date.from_string('2019-01-01'), + 'invoice_payment_term_id': zero_balance_payment_term.id, + 'invoice_line_ids': [(0, None, { + 'name': 'whatever', + 'quantity': 1.0, + 'price_unit': 1000.0, + 'tax_ids': [(6, 0, zero_balance_tax.ids)], + })] + }) + + self.assertEqual(len(invoice.invoice_line_ids), 1) + self.assertEqual(len(invoice.line_ids), 2) + + def test_out_invoice_recomputation_receivable_lines(self): + ''' Test a tricky specific case caused by some framework limitations. Indeed, when + saving a record, some fields are written to the records even if the value is the same + as the previous one. It could lead to an unbalanced journal entry when the recomputed + line is the receivable/payable one. + + For example, the computed price_subtotal are the following: + 1471.95 / 0.14 = 10513.93 + 906468.18 / 0.14 = 6474772.71 + 1730.84 / 0.14 = 12363.14 + 17.99 / 0.14 = 128.50 + SUM = 6497778.28 + + But when recomputing the receivable line: + 909688.96 / 0.14 = 6497778.285714286 => 6497778.29 + + This recomputation was made because the framework was writing the same 'price_unit' + as the previous value leading to a recomputation of the debit/credit. + ''' + self.env['decimal.precision'].search([ + ('name', '=', self.env['account.move.line']._fields['price_unit']._digits), + ]).digits = 5 + + self.env['res.currency.rate'].create({ + 'name': '2019-01-01', + 'rate': 0.14, + 'currency_id': self.currency_data['currency'].id, + 'company_id': self.company_data['company'].id, + }) + + invoice = self.env['account.move'].create({ + 'move_type': 'out_invoice', + 'invoice_date': '2019-01-01', + 'date': '2019-01-01', + 'partner_id': self.partner_a.id, + 'currency_id': self.currency_data['currency'].id, + 'invoice_payment_term_id': self.env.ref('account.account_payment_term_immediate').id, + 'invoice_line_ids': [ + (0, 0, {'name': 'line1', 'price_unit': 38.73553, 'quantity': 38.0}), + (0, 0, {'name': 'line2', 'price_unit': 4083.19000, 'quantity': 222.0}), + (0, 0, {'name': 'line3', 'price_unit': 49.45257, 'quantity': 35.0}), + (0, 0, {'name': 'line4', 'price_unit': 17.99000, 'quantity': 1.0}), + ], + }) + + # assertNotUnbalancedEntryWhenSaving + with Form(invoice) as move_form: + move_form.invoice_payment_term_id = self.env.ref('account.account_payment_term_30days') + + def test_out_invoice_rounding_recomputation_receivable_lines(self): + ''' Test rounding error due to the fact that subtracting then rounding is different from + rounding then subtracting. + ''' + self.env['decimal.precision'].search([ + ('name', '=', self.env['account.move.line']._fields['price_unit']._digits), + ]).digits = 5 + + self.env['res.currency.rate'].search([]).unlink() + + invoice = self.env['account.move'].create({ + 'move_type': 'out_invoice', + 'invoice_date': '2019-01-01', + 'date': '2019-01-01', + 'partner_id': self.partner_a.id, + 'invoice_payment_term_id': self.env.ref('account.account_payment_term_immediate').id, + }) + + # assertNotUnbalancedEntryWhenSaving + with Form(invoice) as move_form: + with move_form.invoice_line_ids.new() as line_form: + line_form.name = 'line1' + line_form.account_id = self.company_data['default_account_revenue'] + line_form.tax_ids.clear() + line_form.price_unit = 0.89500 + move_form.save() + + def test_out_invoice_multi_company(self): + ''' Ensure the properties are found on the right company. + ''' + + product = self.env['product.product'].create({ + 'name': 'product', + 'uom_id': self.env.ref('uom.product_uom_unit').id, + 'lst_price': 1000.0, + 'standard_price': 800.0, + 'company_id': False, + }) + + partner = self.env['res.partner'].create({ + 'name': 'partner', + 'company_id': False, + }) + + journal = self.env['account.journal'].create({ + 'name': 'test_out_invoice_multi_company', + 'code': 'XXXXX', + 'type': 'sale', + 'company_id': self.company_data_2['company'].id, + }) + + product.with_company(self.company_data['company']).write({ + 'property_account_income_id': self.company_data['default_account_revenue'].id, + }) + + partner.with_company(self.company_data['company']).write({ + 'property_account_receivable_id': self.company_data['default_account_receivable'].id, + }) + + product.with_company(self.company_data_2['company']).write({ + 'property_account_income_id': self.company_data_2['default_account_revenue'].id, + }) + + partner.with_company(self.company_data_2['company']).write({ + 'property_account_receivable_id': self.company_data_2['default_account_receivable'].id, + }) + + def _check_invoice_values(invoice): + self.assertInvoiceValues(invoice, [ + { + 'product_id': product.id, + 'account_id': self.company_data_2['default_account_revenue'].id, + 'debit': 0.0, + 'credit': 1000.0, + }, + { + 'product_id': False, + 'account_id': self.company_data_2['default_account_receivable'].id, + 'debit': 1000.0, + 'credit': 0.0, + }, + ], { + 'amount_untaxed': 1000.0, + 'amount_total': 1000.0, + }) + + invoice_create = self.env['account.move'].create({ + 'move_type': 'out_invoice', + 'invoice_date': '2017-01-01', + 'date': '2017-01-01', + 'partner_id': partner.id, + 'journal_id': journal.id, + 'invoice_line_ids': [(0, 0, { + 'product_id': product.id, + 'price_unit': 1000.0, + })], + }) + + _check_invoice_values(invoice_create) + + move_form = Form(self.env['account.move'].with_context(default_move_type='out_invoice')) + move_form.journal_id = journal + move_form.partner_id = partner + move_form.invoice_date = fields.Date.from_string('2017-01-01') + with move_form.invoice_line_ids.new() as line_form: + line_form.product_id = product + line_form.tax_ids.clear() + invoice_onchange = move_form.save() + + _check_invoice_values(invoice_onchange) + + def test_out_invoice_multiple_switch_payment_terms(self): + ''' When switching immediate payment term to 30% advance then back to immediate payment term, ensure the + receivable line is back to its previous value. If some business fields are not well updated, it could lead to a + recomputation of debit/credit when writing and then, an unbalanced journal entry. + ''' + # assertNotUnbalancedEntryWhenSaving + with Form(self.invoice) as move_form: + move_form.invoice_payment_term_id = self.pay_terms_b # Switch to 30% in advance payment terms + move_form.invoice_payment_term_id = self.pay_terms_a # Back to immediate payment term diff --git a/addons/account/tests/test_account_move_out_refund.py b/addons/account/tests/test_account_move_out_refund.py new file mode 100644 index 00000000..967eb232 --- /dev/null +++ b/addons/account/tests/test_account_move_out_refund.py @@ -0,0 +1,933 @@ +# -*- coding: utf-8 -*- +from odoo.addons.account.tests.common import AccountTestInvoicingCommon +from odoo.tests.common import Form +from odoo.tests import tagged +from odoo import fields + + +@tagged('post_install', '-at_install') +class TestAccountMoveOutRefundOnchanges(AccountTestInvoicingCommon): + + @classmethod + def setUpClass(cls, chart_template_ref=None): + super().setUpClass(chart_template_ref=chart_template_ref) + + cls.invoice = cls.init_invoice('out_refund', products=cls.product_a+cls.product_b) + + cls.product_line_vals_1 = { + 'name': cls.product_a.name, + 'product_id': cls.product_a.id, + 'account_id': cls.product_a.property_account_income_id.id, + 'partner_id': cls.partner_a.id, + 'product_uom_id': cls.product_a.uom_id.id, + 'quantity': 1.0, + 'discount': 0.0, + 'price_unit': 1000.0, + 'price_subtotal': 1000.0, + 'price_total': 1150.0, + 'tax_ids': cls.product_a.taxes_id.ids, + 'tax_line_id': False, + 'currency_id': cls.company_data['currency'].id, + 'amount_currency': 1000.0, + 'debit': 1000.0, + 'credit': 0.0, + 'date_maturity': False, + 'tax_exigible': True, + } + cls.product_line_vals_2 = { + 'name': cls.product_b.name, + 'product_id': cls.product_b.id, + 'account_id': cls.product_b.property_account_income_id.id, + 'partner_id': cls.partner_a.id, + 'product_uom_id': cls.product_b.uom_id.id, + 'quantity': 1.0, + 'discount': 0.0, + 'price_unit': 200.0, + 'price_subtotal': 200.0, + 'price_total': 260.0, + 'tax_ids': cls.product_b.taxes_id.ids, + 'tax_line_id': False, + 'currency_id': cls.company_data['currency'].id, + 'amount_currency': 200.0, + 'debit': 200.0, + 'credit': 0.0, + 'date_maturity': False, + 'tax_exigible': True, + } + cls.tax_line_vals_1 = { + 'name': cls.tax_sale_a.name, + 'product_id': False, + 'account_id': cls.company_data['default_account_tax_sale'].id, + 'partner_id': cls.partner_a.id, + 'product_uom_id': False, + 'quantity': 1.0, + 'discount': 0.0, + 'price_unit': 180.0, + 'price_subtotal': 180.0, + 'price_total': 180.0, + 'tax_ids': [], + 'tax_line_id': cls.tax_sale_a.id, + 'currency_id': cls.company_data['currency'].id, + 'amount_currency': 180.0, + 'debit': 180.0, + 'credit': 0.0, + 'date_maturity': False, + 'tax_exigible': True, + } + cls.tax_line_vals_2 = { + 'name': cls.tax_sale_b.name, + 'product_id': False, + 'account_id': cls.company_data['default_account_tax_sale'].id, + 'partner_id': cls.partner_a.id, + 'product_uom_id': False, + 'quantity': 1.0, + 'discount': 0.0, + 'price_unit': 30.0, + 'price_subtotal': 30.0, + 'price_total': 30.0, + 'tax_ids': [], + 'tax_line_id': cls.tax_sale_b.id, + 'currency_id': cls.company_data['currency'].id, + 'amount_currency': 30.0, + 'debit': 30.0, + 'credit': 0.0, + 'date_maturity': False, + 'tax_exigible': True, + } + cls.term_line_vals_1 = { + 'name': '', + 'product_id': False, + 'account_id': cls.company_data['default_account_receivable'].id, + 'partner_id': cls.partner_a.id, + 'product_uom_id': False, + 'quantity': 1.0, + 'discount': 0.0, + 'price_unit': -1410.0, + 'price_subtotal': -1410.0, + 'price_total': -1410.0, + 'tax_ids': [], + 'tax_line_id': False, + 'currency_id': cls.company_data['currency'].id, + 'amount_currency': -1410.0, + 'debit': 0.0, + 'credit': 1410.0, + 'date_maturity': fields.Date.from_string('2019-01-01'), + 'tax_exigible': True, + } + cls.move_vals = { + 'partner_id': cls.partner_a.id, + 'currency_id': cls.company_data['currency'].id, + 'journal_id': cls.company_data['default_journal_sale'].id, + 'date': fields.Date.from_string('2019-01-01'), + 'fiscal_position_id': False, + 'payment_reference': '', + 'invoice_payment_term_id': cls.pay_terms_a.id, + 'amount_untaxed': 1200.0, + 'amount_tax': 210.0, + 'amount_total': 1410.0, + } + + def setUp(self): + super(TestAccountMoveOutRefundOnchanges, self).setUp() + self.assertInvoiceValues(self.invoice, [ + self.product_line_vals_1, + self.product_line_vals_2, + self.tax_line_vals_1, + self.tax_line_vals_2, + self.term_line_vals_1, + ], self.move_vals) + + def test_out_refund_line_onchange_product_1(self): + move_form = Form(self.invoice) + with move_form.invoice_line_ids.edit(0) as line_form: + line_form.product_id = self.product_b + move_form.save() + + self.assertInvoiceValues(self.invoice, [ + { + **self.product_line_vals_1, + 'name': self.product_b.name, + 'product_id': self.product_b.id, + 'product_uom_id': self.product_b.uom_id.id, + 'account_id': self.product_b.property_account_income_id.id, + 'price_unit': 200.0, + 'price_subtotal': 200.0, + 'price_total': 260.0, + 'tax_ids': self.product_b.taxes_id.ids, + 'amount_currency': 200.0, + 'debit': 200.0, + }, + self.product_line_vals_2, + { + **self.tax_line_vals_1, + 'price_unit': 60.0, + 'price_subtotal': 60.0, + 'price_total': 60.0, + 'amount_currency': 60.0, + 'debit': 60.0, + }, + { + **self.tax_line_vals_2, + 'price_unit': 60.0, + 'price_subtotal': 60.0, + 'price_total': 60.0, + 'amount_currency': 60.0, + 'debit': 60.0, + }, + { + **self.term_line_vals_1, + 'price_unit': -520.0, + 'price_subtotal': -520.0, + 'price_total': -520.0, + 'amount_currency': -520.0, + 'credit': 520.0, + }, + ], { + **self.move_vals, + 'amount_untaxed': 400.0, + 'amount_tax': 120.0, + 'amount_total': 520.0, + }) + + def test_out_refund_line_onchange_business_fields_1(self): + move_form = Form(self.invoice) + with move_form.invoice_line_ids.edit(0) as line_form: + # Current price_unit is 1000. + # We set quantity = 4, discount = 50%, price_unit = 400. The debit/credit fields don't change because (4 * 500) * 0.5 = 1000. + line_form.quantity = 4 + line_form.discount = 50 + line_form.price_unit = 500 + move_form.save() + + self.assertInvoiceValues(self.invoice, [ + { + **self.product_line_vals_1, + 'quantity': 4, + 'discount': 50.0, + 'price_unit': 500.0, + }, + self.product_line_vals_2, + self.tax_line_vals_1, + self.tax_line_vals_2, + self.term_line_vals_1, + ], self.move_vals) + + move_form = Form(self.invoice) + with move_form.line_ids.edit(2) as line_form: + # Reset field except the discount that becomes 100%. + # /!\ The modification is made on the accounting tab. + line_form.quantity = 1 + line_form.discount = 100 + line_form.price_unit = 1000 + move_form.save() + + self.assertInvoiceValues(self.invoice, [ + { + **self.product_line_vals_1, + 'discount': 100.0, + 'price_subtotal': 0.0, + 'price_total': 0.0, + 'amount_currency': 0.0, + 'debit': 0.0, + }, + self.product_line_vals_2, + { + **self.tax_line_vals_1, + 'price_unit': 30.0, + 'price_subtotal': 30.0, + 'price_total': 30.0, + 'amount_currency': 30.0, + 'debit': 30.0, + }, + self.tax_line_vals_2, + { + **self.term_line_vals_1, + 'price_unit': -260.0, + 'price_subtotal': -260.0, + 'price_total': -260.0, + 'amount_currency': -260.0, + 'credit': 260.0, + }, + ], { + **self.move_vals, + 'amount_untaxed': 200.0, + 'amount_tax': 60.0, + 'amount_total': 260.0, + }) + + def test_out_refund_line_onchange_accounting_fields_1(self): + move_form = Form(self.invoice) + with move_form.line_ids.edit(2) as line_form: + # Custom debit on the first product line. + line_form.debit = 3000 + with move_form.line_ids.edit(3) as line_form: + # Custom credit on the second product line. Credit should be reset by onchange. + # /!\ It's a negative line. + line_form.credit = 500 + with move_form.line_ids.edit(0) as line_form: + # Custom debit on the first tax line. + line_form.debit = 800 + with move_form.line_ids.edit(4) as line_form: + # Custom debit on the second tax line. + line_form.debit = 250 + move_form.save() + + self.assertInvoiceValues(self.invoice, [ + { + **self.product_line_vals_1, + 'price_unit': 3000.0, + 'price_subtotal': 3000.0, + 'price_total': 3450.0, + 'amount_currency': 3000.0, + 'debit': 3000.0, + }, + { + **self.product_line_vals_2, + 'price_unit': -500.0, + 'price_subtotal': -500.0, + 'price_total': -650.0, + 'amount_currency': -500.0, + 'debit': 0.0, + 'credit': 500.0, + }, + { + **self.tax_line_vals_1, + 'price_unit': 800.0, + 'price_subtotal': 800.0, + 'price_total': 800.0, + 'amount_currency': 800.0, + 'debit': 800.0, + }, + { + **self.tax_line_vals_2, + 'price_unit': 250.0, + 'price_subtotal': 250.0, + 'price_total': 250.0, + 'amount_currency': 250.0, + 'debit': 250.0, + }, + { + **self.term_line_vals_1, + 'price_unit': -3550.0, + 'price_subtotal': -3550.0, + 'price_total': -3550.0, + 'amount_currency': -3550.0, + 'credit': 3550.0, + }, + ], { + **self.move_vals, + 'amount_untaxed': 2500.0, + 'amount_tax': 1050.0, + 'amount_total': 3550.0, + }) + + def test_out_refund_line_onchange_partner_1(self): + move_form = Form(self.invoice) + move_form.partner_id = self.partner_b + move_form.payment_reference = 'turlututu' + move_form.save() + + self.assertInvoiceValues(self.invoice, [ + { + **self.product_line_vals_1, + 'partner_id': self.partner_b.id, + }, + { + **self.product_line_vals_2, + 'partner_id': self.partner_b.id, + }, + { + **self.tax_line_vals_1, + 'partner_id': self.partner_b.id, + }, + { + **self.tax_line_vals_2, + 'partner_id': self.partner_b.id, + }, + { + **self.term_line_vals_1, + 'name': 'turlututu', + 'partner_id': self.partner_b.id, + 'account_id': self.partner_b.property_account_receivable_id.id, + 'price_unit': -987.0, + 'price_subtotal': -987.0, + 'price_total': -987.0, + 'amount_currency': -987.0, + 'credit': 987.0, + 'date_maturity': fields.Date.from_string('2019-02-28'), + }, + { + **self.term_line_vals_1, + 'name': 'turlututu', + 'partner_id': self.partner_b.id, + 'account_id': self.partner_b.property_account_receivable_id.id, + 'price_unit': -423.0, + 'price_subtotal': -423.0, + 'price_total': -423.0, + 'amount_currency': -423.0, + 'credit': 423.0, + }, + ], { + **self.move_vals, + 'partner_id': self.partner_b.id, + 'payment_reference': 'turlututu', + 'fiscal_position_id': self.fiscal_pos_a.id, + 'invoice_payment_term_id': self.pay_terms_b.id, + 'amount_untaxed': 1200.0, + 'amount_tax': 210.0, + 'amount_total': 1410.0, + }) + + # Remove lines and recreate them to apply the fiscal position. + move_form = Form(self.invoice) + move_form.invoice_line_ids.remove(0) + move_form.invoice_line_ids.remove(0) + with move_form.invoice_line_ids.new() as line_form: + line_form.product_id = self.product_a + with move_form.invoice_line_ids.new() as line_form: + line_form.product_id = self.product_b + move_form.save() + + self.assertInvoiceValues(self.invoice, [ + { + **self.product_line_vals_1, + 'account_id': self.product_b.property_account_income_id.id, + 'partner_id': self.partner_b.id, + 'tax_ids': self.tax_sale_b.ids, + }, + { + **self.product_line_vals_2, + 'partner_id': self.partner_b.id, + 'price_total': 230.0, + 'tax_ids': self.tax_sale_b.ids, + }, + { + **self.tax_line_vals_1, + 'name': self.tax_sale_b.name, + 'partner_id': self.partner_b.id, + 'tax_line_id': self.tax_sale_b.id, + }, + { + **self.term_line_vals_1, + 'name': 'turlututu', + 'account_id': self.partner_b.property_account_receivable_id.id, + 'partner_id': self.partner_b.id, + 'price_unit': -966.0, + 'price_subtotal': -966.0, + 'price_total': -966.0, + 'amount_currency': -966.0, + 'credit': 966.0, + 'date_maturity': fields.Date.from_string('2019-02-28'), + }, + { + **self.term_line_vals_1, + 'name': 'turlututu', + 'account_id': self.partner_b.property_account_receivable_id.id, + 'partner_id': self.partner_b.id, + 'price_unit': -414.0, + 'price_subtotal': -414.0, + 'price_total': -414.0, + 'amount_currency': -414.0, + 'credit': 414.0, + }, + ], { + **self.move_vals, + 'partner_id': self.partner_b.id, + 'payment_reference': 'turlututu', + 'fiscal_position_id': self.fiscal_pos_a.id, + 'invoice_payment_term_id': self.pay_terms_b.id, + 'amount_untaxed': 1200.0, + 'amount_tax': 180.0, + 'amount_total': 1380.0, + }) + + def test_out_refund_line_onchange_taxes_1(self): + move_form = Form(self.invoice) + with move_form.invoice_line_ids.edit(0) as line_form: + line_form.price_unit = 1200 + line_form.tax_ids.add(self.tax_armageddon) + move_form.save() + + child_tax_1 = self.tax_armageddon.children_tax_ids[0] + child_tax_2 = self.tax_armageddon.children_tax_ids[1] + + self.assertInvoiceValues(self.invoice, [ + { + **self.product_line_vals_1, + 'price_unit': 1200.0, + 'price_subtotal': 1000.0, + 'price_total': 1470.0, + 'tax_ids': (self.tax_sale_a + self.tax_armageddon).ids, + 'tax_exigible': False, + }, + self.product_line_vals_2, + self.tax_line_vals_1, + self.tax_line_vals_2, + { + 'name': child_tax_1.name, + 'product_id': False, + 'account_id': self.company_data['default_account_tax_sale'].id, + 'partner_id': self.partner_a.id, + 'product_uom_id': False, + 'quantity': 1.0, + 'discount': 0.0, + 'price_unit': 80.0, + 'price_subtotal': 80.0, + 'price_total': 88.0, + 'tax_ids': child_tax_2.ids, + 'tax_line_id': child_tax_1.id, + 'currency_id': self.company_data['currency'].id, + 'amount_currency': 80.0, + 'debit': 80.0, + 'credit': 0.0, + 'date_maturity': False, + 'tax_exigible': False, + }, + { + 'name': child_tax_1.name, + 'product_id': False, + 'account_id': self.company_data['default_account_revenue'].id, + 'partner_id': self.partner_a.id, + 'product_uom_id': False, + 'quantity': 1.0, + 'discount': 0.0, + 'price_unit': 120.0, + 'price_subtotal': 120.0, + 'price_total': 132.0, + 'tax_ids': child_tax_2.ids, + 'tax_line_id': child_tax_1.id, + 'currency_id': self.company_data['currency'].id, + 'amount_currency': 120.0, + 'debit': 120.0, + 'credit': 0.0, + 'date_maturity': False, + 'tax_exigible': False, + }, + { + 'name': child_tax_2.name, + 'product_id': False, + 'account_id': child_tax_2.cash_basis_transition_account_id.id, + 'partner_id': self.partner_a.id, + 'product_uom_id': False, + 'quantity': 1.0, + 'discount': 0.0, + 'price_unit': 120.0, + 'price_subtotal': 120.0, + 'price_total': 120.0, + 'tax_ids': [], + 'tax_line_id': child_tax_2.id, + 'currency_id': self.company_data['currency'].id, + 'amount_currency': 120.0, + 'debit': 120.0, + 'credit': 0.0, + 'date_maturity': False, + 'tax_exigible': False, + }, + { + **self.term_line_vals_1, + 'price_unit': -1730.0, + 'price_subtotal': -1730.0, + 'price_total': -1730.0, + 'amount_currency': -1730.0, + 'credit': 1730.0, + }, + ], { + **self.move_vals, + 'amount_untaxed': 1200.0, + 'amount_tax': 530.0, + 'amount_total': 1730.0, + }) + + def test_out_refund_line_onchange_cash_rounding_1(self): + move_form = Form(self.invoice) + # Add a cash rounding having 'add_invoice_line'. + move_form.invoice_cash_rounding_id = self.cash_rounding_a + move_form.save() + + # The cash rounding does nothing as the total is already rounded. + self.assertInvoiceValues(self.invoice, [ + self.product_line_vals_1, + self.product_line_vals_2, + self.tax_line_vals_1, + self.tax_line_vals_2, + self.term_line_vals_1, + ], self.move_vals) + + move_form = Form(self.invoice) + with move_form.invoice_line_ids.edit(0) as line_form: + line_form.price_unit = 999.99 + move_form.save() + + self.assertInvoiceValues(self.invoice, [ + { + 'name': 'add_invoice_line', + 'product_id': False, + 'account_id': self.cash_rounding_a.loss_account_id.id, + 'partner_id': self.partner_a.id, + 'product_uom_id': False, + 'quantity': 1.0, + 'discount': 0.0, + 'price_unit': 0.01, + 'price_subtotal': 0.01, + 'price_total': 0.01, + 'tax_ids': [], + 'tax_line_id': False, + 'currency_id': self.company_data['currency'].id, + 'amount_currency': 0.01, + 'debit': 0.01, + 'credit': 0.0, + 'date_maturity': False, + 'tax_exigible': True, + }, + { + **self.product_line_vals_1, + 'price_unit': 999.99, + 'price_subtotal': 999.99, + 'price_total': 1149.99, + 'amount_currency': 999.99, + 'debit': 999.99, + }, + self.product_line_vals_2, + self.tax_line_vals_1, + self.tax_line_vals_2, + self.term_line_vals_1, + ], self.move_vals) + + move_form = Form(self.invoice) + # Change the cash rounding to one having 'biggest_tax'. + move_form.invoice_cash_rounding_id = self.cash_rounding_b + move_form.save() + + self.assertInvoiceValues(self.invoice, [ + { + **self.product_line_vals_1, + 'price_unit': 999.99, + 'price_subtotal': 999.99, + 'price_total': 1149.99, + 'amount_currency': 999.99, + 'debit': 999.99, + }, + self.product_line_vals_2, + self.tax_line_vals_1, + self.tax_line_vals_2, + { + 'name': '%s (rounding)' % self.tax_sale_a.name, + 'product_id': False, + 'account_id': self.company_data['default_account_tax_sale'].id, + 'partner_id': self.partner_a.id, + 'product_uom_id': False, + 'quantity': 1.0, + 'discount': 0.0, + 'price_unit': -0.04, + 'price_subtotal': -0.04, + 'price_total': -0.04, + 'tax_ids': [], + 'tax_line_id': self.tax_sale_a.id, + 'currency_id': self.company_data['currency'].id, + 'amount_currency': -0.04, + 'debit': 0.0, + 'credit': 0.04, + 'date_maturity': False, + 'tax_exigible': True, + }, + { + **self.term_line_vals_1, + 'price_unit': -1409.95, + 'price_subtotal': -1409.95, + 'price_total': -1409.95, + 'amount_currency': -1409.95, + 'credit': 1409.95, + }, + ], { + **self.move_vals, + 'amount_untaxed': 1199.99, + 'amount_tax': 209.96, + 'amount_total': 1409.95, + }) + + def test_out_refund_line_onchange_currency_1(self): + move_form = Form(self.invoice) + move_form.currency_id = self.currency_data['currency'] + move_form.save() + + self.assertInvoiceValues(self.invoice, [ + { + **self.product_line_vals_1, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 1000.0, + 'debit': 500.0, + }, + { + **self.product_line_vals_2, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 200.0, + 'debit': 100.0, + }, + { + **self.tax_line_vals_1, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 180.0, + 'debit': 90.0, + }, + { + **self.tax_line_vals_2, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 30.0, + 'debit': 15.0, + }, + { + **self.term_line_vals_1, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -1410.0, + 'credit': 705.0, + }, + ], { + **self.move_vals, + 'currency_id': self.currency_data['currency'].id, + }) + + move_form = Form(self.invoice) + # Change the date to get another rate: 1/3 instead of 1/2. + move_form.date = fields.Date.from_string('2016-01-01') + move_form.save() + + self.assertInvoiceValues(self.invoice, [ + { + **self.product_line_vals_1, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 1000.0, + 'debit': 333.33, + }, + { + **self.product_line_vals_2, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 200.0, + 'debit': 66.67, + }, + { + **self.tax_line_vals_1, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 180.0, + 'debit': 60.0, + }, + { + **self.tax_line_vals_2, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 30.0, + 'debit': 10.0, + }, + { + **self.term_line_vals_1, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -1410.0, + 'credit': 470.0, + }, + ], { + **self.move_vals, + 'currency_id': self.currency_data['currency'].id, + 'date': fields.Date.from_string('2016-01-01'), + }) + + move_form = Form(self.invoice) + with move_form.invoice_line_ids.edit(0) as line_form: + # 0.045 * 0.1 = 0.0045. As the foreign currency has a 0.001 rounding, + # the result should be 0.005 after rounding. + line_form.quantity = 0.1 + line_form.price_unit = 0.045 + move_form.save() + + self.assertInvoiceValues(self.invoice, [ + { + **self.product_line_vals_1, + 'quantity': 0.1, + 'price_unit': 0.05, + 'price_subtotal': 0.005, + 'price_total': 0.006, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 0.005, + 'debit': 0.0, + }, + { + **self.product_line_vals_2, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 200.0, + 'debit': 66.67, + }, + { + **self.tax_line_vals_1, + 'price_unit': 30.0, + 'price_subtotal': 30.001, + 'price_total': 30.001, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 30.001, + 'debit': 10.0, + }, + { + **self.tax_line_vals_2, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 30.0, + 'debit': 10.0, + }, + { + **self.term_line_vals_1, + 'currency_id': self.currency_data['currency'].id, + 'price_unit': -260.01, + 'price_subtotal': -260.006, + 'price_total': -260.006, + 'amount_currency': -260.006, + 'credit': 86.67, + }, + ], { + **self.move_vals, + 'currency_id': self.currency_data['currency'].id, + 'date': fields.Date.from_string('2016-01-01'), + 'amount_untaxed': 200.005, + 'amount_tax': 60.001, + 'amount_total': 260.006, + }) + + # Exit the multi-currencies. + move_form = Form(self.invoice) + move_form.currency_id = self.company_data['currency'] + move_form.save() + + self.assertInvoiceValues(self.invoice, [ + { + **self.product_line_vals_1, + 'quantity': 0.1, + 'price_unit': 0.05, + 'price_subtotal': 0.01, + 'price_total': 0.01, + 'amount_currency': 0.01, + 'debit': 0.01, + }, + self.product_line_vals_2, + { + **self.tax_line_vals_1, + 'price_unit': 30.0, + 'price_subtotal': 30.0, + 'price_total': 30.0, + 'amount_currency': 30.0, + 'debit': 30.0, + }, + self.tax_line_vals_2, + { + **self.term_line_vals_1, + 'price_unit': -260.01, + 'price_subtotal': -260.01, + 'price_total': -260.01, + 'amount_currency': -260.01, + 'credit': 260.01, + }, + ], { + **self.move_vals, + 'currency_id': self.company_data['currency'].id, + 'date': fields.Date.from_string('2016-01-01'), + 'amount_untaxed': 200.01, + 'amount_tax': 60.0, + 'amount_total': 260.01, + }) + + def test_out_refund_create_1(self): + # Test creating an account_move with the least information. + move = self.env['account.move'].create({ + 'move_type': 'out_refund', + 'partner_id': self.partner_a.id, + 'invoice_date': fields.Date.from_string('2019-01-01'), + 'currency_id': self.currency_data['currency'].id, + 'invoice_payment_term_id': self.pay_terms_a.id, + 'invoice_line_ids': [ + (0, None, self.product_line_vals_1), + (0, None, self.product_line_vals_2), + ] + }) + + self.assertInvoiceValues(move, [ + { + **self.product_line_vals_1, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 1000.0, + 'debit': 500.0, + }, + { + **self.product_line_vals_2, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 200.0, + 'debit': 100.0, + }, + { + **self.tax_line_vals_1, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 180.0, + 'debit': 90.0, + }, + { + **self.tax_line_vals_2, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 30.0, + 'debit': 15.0, + }, + { + **self.term_line_vals_1, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -1410.0, + 'credit': 705.0, + }, + ], { + **self.move_vals, + 'currency_id': self.currency_data['currency'].id, + }) + + def test_out_refund_write_1(self): + # Test creating an account_move with the least information. + move = self.env['account.move'].create({ + 'move_type': 'out_refund', + 'partner_id': self.partner_a.id, + 'invoice_date': fields.Date.from_string('2019-01-01'), + 'currency_id': self.currency_data['currency'].id, + 'invoice_payment_term_id': self.pay_terms_a.id, + 'invoice_line_ids': [ + (0, None, self.product_line_vals_1), + ] + }) + move.write({ + 'invoice_line_ids': [ + (0, None, self.product_line_vals_2), + ] + }) + + self.assertInvoiceValues(move, [ + { + **self.product_line_vals_1, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 1000.0, + 'debit': 500.0, + }, + { + **self.product_line_vals_2, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 200.0, + 'debit': 100.0, + }, + { + **self.tax_line_vals_1, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 180.0, + 'debit': 90.0, + }, + { + **self.tax_line_vals_2, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 30.0, + 'debit': 15.0, + }, + { + **self.term_line_vals_1, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -1410.0, + 'credit': 705.0, + }, + ], { + **self.move_vals, + 'currency_id': self.currency_data['currency'].id, + }) diff --git a/addons/account/tests/test_account_move_partner_count.py b/addons/account/tests/test_account_move_partner_count.py new file mode 100644 index 00000000..88eab61d --- /dev/null +++ b/addons/account/tests/test_account_move_partner_count.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +from odoo.addons.account.tests.common import AccountTestInvoicingCommon + +from odoo.tests import tagged + + +@tagged('post_install', '-at_install') +class TestAccountMovePartnerCount(AccountTestInvoicingCommon): + + def test_account_move_count(self): + self.env['account.move'].create([ + { + 'move_type': 'out_invoice', + 'date': '2017-01-01', + 'invoice_date': '2017-01-01', + 'partner_id': self.partner_a.id, + 'invoice_line_ids': [(0, 0, {'name': 'aaaa', 'price_unit': 100.0})], + }, + { + 'move_type': 'in_invoice', + 'date': '2017-01-01', + 'invoice_date': '2017-01-01', + 'partner_id': self.partner_a.id, + 'invoice_line_ids': [(0, 0, {'name': 'aaaa', 'price_unit': 100.0})], + }, + ]).action_post() + + self.assertEqual(self.partner_a.supplier_rank, 1) + self.assertEqual(self.partner_a.customer_rank, 1) diff --git a/addons/account/tests/test_account_move_payments_widget.py b/addons/account/tests/test_account_move_payments_widget.py new file mode 100644 index 00000000..b4a49515 --- /dev/null +++ b/addons/account/tests/test_account_move_payments_widget.py @@ -0,0 +1,184 @@ +# -*- coding: utf-8 -*- +from odoo.addons.account.tests.common import AccountTestInvoicingCommon +from odoo.tests import tagged + +import json + + +@tagged('post_install', '-at_install') +class TestAccountMovePaymentsWidget(AccountTestInvoicingCommon): + + @classmethod + def setUpClass(cls, chart_template_ref=None): + super().setUpClass(chart_template_ref=chart_template_ref) + + cls.receivable_account = cls.company_data['default_account_receivable'] + cls.payable_account = cls.company_data['default_account_payable'] + + cls.currency_data_2 = cls.setup_multi_currency_data(default_values={ + 'name': 'Stars', + 'symbol': '☆', + 'currency_unit_label': 'Stars', + 'currency_subunit_label': 'Little Stars', + }, rate2016=6.0, rate2017=4.0) + + cls.curr_1 = cls.company_data['currency'] + cls.curr_2 = cls.currency_data['currency'] + cls.curr_3 = cls.currency_data_2['currency'] + + cls.payment_2016_curr_1 = cls.env['account.move'].create({ + 'date': '2016-01-01', + 'line_ids': [ + (0, 0, {'debit': 0.0, 'credit': 500.0, 'amount_currency': -500.0, 'currency_id': cls.curr_1.id, 'account_id': cls.receivable_account.id, 'partner_id': cls.partner_a.id}), + (0, 0, {'debit': 500.0, 'credit': 0.0, 'amount_currency': 500.0, 'currency_id': cls.curr_1.id, 'account_id': cls.payable_account.id, 'partner_id': cls.partner_a.id}), + ], + }) + cls.payment_2016_curr_1.action_post() + + cls.payment_2016_curr_2 = cls.env['account.move'].create({ + 'date': '2016-01-01', + 'line_ids': [ + (0, 0, {'debit': 0.0, 'credit': 500.0, 'amount_currency': -1550.0, 'currency_id': cls.curr_2.id, 'account_id': cls.receivable_account.id, 'partner_id': cls.partner_a.id}), + (0, 0, {'debit': 500.0, 'credit': 0.0, 'amount_currency': 1550.0, 'currency_id': cls.curr_2.id, 'account_id': cls.payable_account.id, 'partner_id': cls.partner_a.id}), + ], + }) + cls.payment_2016_curr_2.action_post() + + cls.payment_2017_curr_2 = cls.env['account.move'].create({ + 'date': '2017-01-01', + 'line_ids': [ + (0, 0, {'debit': 0.0, 'credit': 500.0, 'amount_currency': -950.0, 'currency_id': cls.curr_2.id, 'account_id': cls.receivable_account.id, 'partner_id': cls.partner_a.id}), + (0, 0, {'debit': 500.0, 'credit': 0.0, 'amount_currency': 950.0, 'currency_id': cls.curr_2.id, 'account_id': cls.payable_account.id, 'partner_id': cls.partner_a.id}), + ], + }) + cls.payment_2017_curr_2.action_post() + + cls.payment_2016_curr_3 = cls.env['account.move'].create({ + 'date': '2016-01-01', + 'line_ids': [ + (0, 0, {'debit': 0.0, 'credit': 500.0, 'amount_currency': -3050.0, 'currency_id': cls.curr_3.id, 'account_id': cls.receivable_account.id, 'partner_id': cls.partner_a.id}), + (0, 0, {'debit': 500.0, 'credit': 0.0, 'amount_currency': 3050.0, 'currency_id': cls.curr_3.id, 'account_id': cls.payable_account.id, 'partner_id': cls.partner_a.id}), + ], + }) + cls.payment_2016_curr_3.action_post() + + cls.payment_2017_curr_3 = cls.env['account.move'].create({ + 'date': '2017-01-01', + 'line_ids': [ + (0, 0, {'debit': 0.0, 'credit': 500.0, 'amount_currency': -1950.0, 'currency_id': cls.curr_3.id, 'account_id': cls.receivable_account.id, 'partner_id': cls.partner_a.id}), + (0, 0, {'debit': 500.0, 'credit': 0.0, 'amount_currency': 1950.0, 'currency_id': cls.curr_3.id, 'account_id': cls.payable_account.id, 'partner_id': cls.partner_a.id}), + ], + }) + cls.payment_2017_curr_3.action_post() + + # ------------------------------------------------------------------------- + # HELPERS + # ------------------------------------------------------------------------- + + def _test_all_outstanding_payments(self, invoice, expected_amounts): + ''' Check the outstanding payments widget before/after the reconciliation. + :param invoice: An account.move record. + :param expected_amounts: A map <move_id> -> <amount> + ''' + + # Check suggested outstanding payments. + to_reconcile_payments_widget_vals = json.loads(invoice.invoice_outstanding_credits_debits_widget) + + self.assertTrue(to_reconcile_payments_widget_vals) + + current_amounts = {vals['move_id']: vals['amount'] for vals in to_reconcile_payments_widget_vals['content']} + self.assertDictEqual(current_amounts, expected_amounts) + + # Reconcile + pay_term_lines = invoice.line_ids\ + .filtered(lambda line: line.account_id.user_type_id.type in ('receivable', 'payable')) + to_reconcile = self.env['account.move'].browse(list(current_amounts.keys()))\ + .line_ids\ + .filtered(lambda line: line.account_id == pay_term_lines.account_id) + (pay_term_lines + to_reconcile).reconcile() + + # Check payments after reconciliation. + reconciled_payments_widget_vals = json.loads(invoice.invoice_payments_widget) + + self.assertTrue(reconciled_payments_widget_vals) + + current_amounts = {vals['move_id']: vals['amount'] for vals in reconciled_payments_widget_vals['content']} + self.assertDictEqual(current_amounts, expected_amounts) + + # ------------------------------------------------------------------------- + # TESTS + # ------------------------------------------------------------------------- + + def test_outstanding_payments_single_currency(self): + ''' Test the outstanding payments widget on invoices having the same currency + as the company one. + ''' + + # Customer invoice of 2500.0 in curr_1. + out_invoice = self.env['account.move'].create({ + 'move_type': 'out_invoice', + 'date': '2017-01-01', + 'invoice_date': '2017-01-01', + 'partner_id': self.partner_a.id, + 'currency_id': self.curr_1.id, + 'invoice_line_ids': [(0, 0, {'name': '/', 'price_unit': 2500.0})], + }) + out_invoice.action_post() + + # Vendor bill of 2500.0 in curr_1. + in_invoice = self.env['account.move'].create({ + 'move_type': 'in_invoice', + 'date': '2017-01-01', + 'invoice_date': '2017-01-01', + 'partner_id': self.partner_a.id, + 'currency_id': self.curr_1.id, + 'invoice_line_ids': [(0, 0, {'name': '/', 'price_unit': 2500.0})], + }) + in_invoice.action_post() + + expected_amounts = { + self.payment_2016_curr_1.id: 500.0, + self.payment_2016_curr_2.id: 500.0, + self.payment_2017_curr_2.id: 500.0, + self.payment_2016_curr_3.id: 500.0, + self.payment_2017_curr_3.id: 500.0, + } + + self._test_all_outstanding_payments(out_invoice, expected_amounts) + self._test_all_outstanding_payments(in_invoice, expected_amounts) + + def test_outstanding_payments_foreign_currency(self): + ''' Test the outstanding payments widget on invoices having a foreign currency. ''' + + # Customer invoice of 2500.0 in curr_1. + out_invoice = self.env['account.move'].create({ + 'move_type': 'out_invoice', + 'date': '2017-01-01', + 'invoice_date': '2017-01-01', + 'partner_id': self.partner_a.id, + 'currency_id': self.curr_2.id, + 'invoice_line_ids': [(0, 0, {'name': '/', 'price_unit': 7500.0})], + }) + out_invoice.action_post() + + # Vendor bill of 2500.0 in curr_1. + in_invoice = self.env['account.move'].create({ + 'move_type': 'in_invoice', + 'date': '2017-01-01', + 'invoice_date': '2017-01-01', + 'partner_id': self.partner_a.id, + 'currency_id': self.curr_2.id, + 'invoice_line_ids': [(0, 0, {'name': '/', 'price_unit': 7500.0})], + }) + in_invoice.action_post() + + expected_amounts = { + self.payment_2016_curr_1.id: 1500.0, + self.payment_2016_curr_2.id: 1550.0, + self.payment_2017_curr_2.id: 950.0, + self.payment_2016_curr_3.id: 1500.0, + self.payment_2017_curr_3.id: 1000.0, + } + + self._test_all_outstanding_payments(out_invoice, expected_amounts) + self._test_all_outstanding_payments(in_invoice, expected_amounts) diff --git a/addons/account/tests/test_account_move_reconcile.py b/addons/account/tests/test_account_move_reconcile.py new file mode 100644 index 00000000..140d97d0 --- /dev/null +++ b/addons/account/tests/test_account_move_reconcile.py @@ -0,0 +1,2457 @@ +# -*- coding: utf-8 -*- +from odoo.addons.account.tests.common import AccountTestInvoicingCommon +from odoo.tests import tagged +from odoo.tests.common import Form +from odoo import fields + + +@tagged('post_install', '-at_install') +class TestAccountMoveReconcile(AccountTestInvoicingCommon): + ''' Tests about the account.partial.reconcile model, not the reconciliation itself but mainly the computation of + the residual amounts on account.move.line. + ''' + + @classmethod + def setUpClass(cls, chart_template_ref=None): + super().setUpClass(chart_template_ref=chart_template_ref) + + cls.extra_receivable_account_1 = cls.copy_account(cls.company_data['default_account_receivable']) + cls.extra_receivable_account_2 = cls.copy_account(cls.company_data['default_account_receivable']) + cls.extra_payable_account_1 = cls.copy_account(cls.company_data['default_account_payable']) + cls.extra_payable_account_2 = cls.copy_account(cls.company_data['default_account_payable']) + + # ==== Multi-currency setup ==== + + cls.currency_data_2 = cls.setup_multi_currency_data(default_values={ + 'name': 'Diamond', + 'symbol': '💎', + 'currency_unit_label': 'Diamond', + 'currency_subunit_label': 'Carbon', + }, rate2016=6.0, rate2017=4.0) + + # ==== Cash Basis Taxes setup ==== + + cls.cash_basis_base_account = cls.env['account.account'].create({ + 'code': 'cash_basis_base_account', + 'name': 'cash_basis_base_account', + 'user_type_id': cls.env.ref('account.data_account_type_revenue').id, + 'company_id': cls.company_data['company'].id, + }) + cls.company_data['company'].account_cash_basis_base_account_id = cls.cash_basis_base_account + + cls.cash_basis_transfer_account = cls.env['account.account'].create({ + 'code': 'cash_basis_transfer_account', + 'name': 'cash_basis_transfer_account', + 'user_type_id': cls.env.ref('account.data_account_type_revenue').id, + 'company_id': cls.company_data['company'].id, + }) + + cls.tax_account_1 = cls.env['account.account'].create({ + 'code': 'tax_account_1', + 'name': 'tax_account_1', + 'user_type_id': cls.env.ref('account.data_account_type_revenue').id, + 'company_id': cls.company_data['company'].id, + }) + + cls.tax_account_2 = cls.env['account.account'].create({ + 'code': 'tax_account_2', + 'name': 'tax_account_2', + 'user_type_id': cls.env.ref('account.data_account_type_revenue').id, + 'company_id': cls.company_data['company'].id, + }) + + cls.fake_country = cls.env['res.country'].create({ + 'name': "The Island of the Fly", + 'code': 'YY', + }) + + cls.tax_tags = cls.env['account.account.tag'].create({ + 'name': 'tax_tag_%s' % str(i), + 'applicability': 'taxes', + 'country_id': cls.fake_country.id, + } for i in range(8)) + + cls.cash_basis_tax_a_third_amount = cls.env['account.tax'].create({ + 'name': 'tax_1', + 'amount': 33.3333, + 'company_id': cls.company_data['company'].id, + 'cash_basis_transition_account_id': cls.cash_basis_transfer_account.id, + 'tax_exigibility': 'on_payment', + 'invoice_repartition_line_ids': [ + (0, 0, { + 'factor_percent': 100, + 'repartition_type': 'base', + 'tag_ids': [(6, 0, cls.tax_tags[0].ids)], + }), + + (0, 0, { + 'factor_percent': 100, + 'repartition_type': 'tax', + 'account_id': cls.tax_account_1.id, + 'tag_ids': [(6, 0, cls.tax_tags[1].ids)], + }), + ], + 'refund_repartition_line_ids': [ + (0, 0, { + 'factor_percent': 100, + 'repartition_type': 'base', + 'tag_ids': [(6, 0, cls.tax_tags[2].ids)], + }), + + (0, 0, { + 'factor_percent': 100, + 'repartition_type': 'tax', + 'account_id': cls.tax_account_1.id, + 'tag_ids': [(6, 0, cls.tax_tags[3].ids)], + }), + ], + }) + + cls.cash_basis_tax_tiny_amount = cls.env['account.tax'].create({ + 'name': 'cash_basis_tax_tiny_amount', + 'amount': 0.0001, + 'company_id': cls.company_data['company'].id, + 'cash_basis_transition_account_id': cls.cash_basis_transfer_account.id, + 'tax_exigibility': 'on_payment', + 'invoice_repartition_line_ids': [ + (0, 0, { + 'factor_percent': 100, + 'repartition_type': 'base', + 'tag_ids': [(6, 0, cls.tax_tags[4].ids)], + }), + + (0, 0, { + 'factor_percent': 100, + 'repartition_type': 'tax', + 'account_id': cls.tax_account_2.id, + 'tag_ids': [(6, 0, cls.tax_tags[5].ids)], + }), + ], + 'refund_repartition_line_ids': [ + (0, 0, { + 'factor_percent': 100, + 'repartition_type': 'base', + 'tag_ids': [(6, 0, cls.tax_tags[6].ids)], + }), + + (0, 0, { + 'factor_percent': 100, + 'repartition_type': 'tax', + 'account_id': cls.tax_account_2.id, + 'tag_ids': [(6, 0, cls.tax_tags[7].ids)], + }), + ], + }) + + # ------------------------------------------------------------------------- + # HELPERS + # ------------------------------------------------------------------------- + + def assertFullReconcile(self, full_reconcile, lines): + exchange_difference_move = full_reconcile.exchange_move_id + partials = lines.mapped('matched_debit_ids') + lines.mapped('matched_credit_ids') + + if full_reconcile.exchange_move_id: + lines += exchange_difference_move.line_ids.filtered(lambda line: line.account_id == lines[0].account_id) + + # Use sets to not depend of the order. + self.assertEqual(set(full_reconcile.partial_reconcile_ids), set(partials)) + self.assertEqual(set(full_reconcile.reconciled_line_ids), set(lines)) + + # Ensure there is no residual amount left. + self.assertRecordValues(lines, [{ + 'amount_residual': 0.0, + 'amount_residual_currency': 0.0, + 'reconciled': bool(line.account_id.reconcile), + } for line in lines]) + + def assertPartialReconcile(self, partials, expected_vals_list): + partials = partials.sorted(lambda part: ( + part.amount, + part.debit_amount_currency, + part.credit_amount_currency, + )) + self.assertRecordValues(partials, expected_vals_list) + + def assertAmountsGroupByAccount(self, amount_per_account): + expected_values = {account.id: (account, balance, amount_currency) for account, balance, amount_currency in amount_per_account} + + if not expected_values: + return + + self.cr.execute(''' + SELECT + line.account_id, + COALESCE(SUM(line.balance), 0.0) AS total_balance, + COALESCE(SUM(line.amount_currency), 0.0) AS total_amount_currency + FROM account_move_line line + WHERE line.account_id IN %s + GROUP BY line.account_id + ''', [tuple(expected_values.keys())]) + for account_id, total_balance, total_amount_currency in self.cr.fetchall(): + account, expected_balance, expected_amount_currency = expected_values[account_id] + self.assertEqual( + total_balance, + expected_balance, + "Balance of %s is incorrect" % account.name, + ) + self.assertEqual( + total_amount_currency, + expected_amount_currency, + "Amount currency of %s is incorrect" % account.name, + ) + + def assertTaxGridAmounts(self, amount_per_tag): + expected_values = {tag.id: (tag, balance) for tag, balance in amount_per_tag} + + if not expected_values: + return + + self.cr.execute(''' + SELECT + rel.account_account_tag_id, + SUM(line.balance) + FROM account_account_tag_account_move_line_rel rel + JOIN account_move_line line ON line.id = rel.account_move_line_id + WHERE line.tax_exigible IS TRUE + AND line.company_id IN %(company_ids)s + GROUP BY rel.account_account_tag_id + ''', { + 'company_ids': tuple(self.env.companies.ids), + }) + + for tag_id, total_balance in self.cr.fetchall(): + tag, expected_balance = expected_values[tag_id] + self.assertEqual( + total_balance, + expected_balance, + "Balance of %s is incorrect" % tag.name, + ) + + # ------------------------------------------------------------------------- + # Test creation of account.partial.reconcile/account.full.reconcile + # during the reconciliation. + # ------------------------------------------------------------------------- + + def test_reconcile_single_currency(self): + account_id = self.company_data['default_account_receivable'].id + + move = self.env['account.move'].create({ + 'move_type': 'entry', + 'date': '2016-01-01', + 'line_ids': [ + (0, 0, {'debit': 1000.0, 'credit': 0.0, 'account_id': account_id}), + (0, 0, {'debit': 200.0, 'credit': 0.0, 'account_id': account_id}), + (0, 0, {'debit': 0.0, 'credit': 300.0, 'account_id': account_id}), + (0, 0, {'debit': 0.0, 'credit': 400.0, 'account_id': account_id}), + (0, 0, {'debit': 0.0, 'credit': 500.0, 'account_id': account_id}), + ] + }) + move.action_post() + + line_1 = move.line_ids.filtered(lambda line: line.debit == 1000.0) + line_2 = move.line_ids.filtered(lambda line: line.debit == 200.0) + line_3 = move.line_ids.filtered(lambda line: line.credit == 300.0) + line_4 = move.line_ids.filtered(lambda line: line.credit == 400.0) + line_5 = move.line_ids.filtered(lambda line: line.credit == 500.0) + + self.assertRecordValues(line_1 + line_2 + line_3 + line_4 + line_5, [ + {'amount_residual': 1000.0, 'amount_residual_currency': 1000.0, 'reconciled': False}, + {'amount_residual': 200.0, 'amount_residual_currency': 200.0, 'reconciled': False}, + {'amount_residual': -300.0, 'amount_residual_currency': -300.0, 'reconciled': False}, + {'amount_residual': -400.0, 'amount_residual_currency': -400.0, 'reconciled': False}, + {'amount_residual': -500.0, 'amount_residual_currency': -500.0, 'reconciled': False}, + ]) + + res = (line_1 + line_3).reconcile() + + self.assertPartialReconcile(res['partials'], [{ + 'amount': 300.0, + 'debit_amount_currency': 300.0, + 'credit_amount_currency': 300.0, + 'debit_move_id': line_1.id, + 'credit_move_id': line_3.id, + }]) + + self.assertRecordValues(line_1 + line_3, [ + {'amount_residual': 700.0, 'amount_residual_currency': 700.0, 'reconciled': False}, + {'amount_residual': 0.0, 'amount_residual_currency': 0.0, 'reconciled': True}, + ]) + + res = (line_1 + line_4).reconcile() + + self.assertPartialReconcile(res['partials'], [{ + 'amount': 400.0, + 'debit_amount_currency': 400.0, + 'credit_amount_currency': 400.0, + 'debit_move_id': line_1.id, + 'credit_move_id': line_4.id, + }]) + + self.assertRecordValues(line_1 + line_4, [ + {'amount_residual': 300.0, 'amount_residual_currency': 300.0, 'reconciled': False}, + {'amount_residual': 0.0, 'amount_residual_currency': 0.0, 'reconciled': True}, + ]) + + res = (line_1 + line_5).reconcile() + + self.assertPartialReconcile(res['partials'], [{ + 'amount': 300.0, + 'debit_amount_currency': 300.0, + 'credit_amount_currency': 300.0, + 'debit_move_id': line_1.id, + 'credit_move_id': line_5.id, + }]) + + self.assertRecordValues(line_1 + line_5, [ + {'amount_residual': 0.0, 'amount_residual_currency': 0.0, 'reconciled': True}, + {'amount_residual': -200.0, 'amount_residual_currency': -200.0, 'reconciled': False}, + ]) + + res = (line_2 + line_5).reconcile() + + self.assertPartialReconcile(res['partials'], [{ + 'amount': 200.0, + 'debit_amount_currency': 200.0, + 'credit_amount_currency': 200.0, + 'debit_move_id': line_2.id, + 'credit_move_id': line_5.id, + }]) + + self.assertRecordValues(res['full_reconcile'], [{'exchange_move_id': False}]) + self.assertFullReconcile(res['full_reconcile'], line_1 + line_2 + line_3 + line_4 + line_5) + + def test_reconcile_same_foreign_currency(self): + account_id = self.company_data['default_account_receivable'].id + + # Rate is 3.0 in 2016, 2.0 in 2017. + currency_id = self.currency_data['currency'].id + + moves = 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': account_id, 'currency_id': currency_id}), + (0, 0, {'debit': 120.0, 'credit': 0.0, 'amount_currency': 360.0, 'account_id': account_id, 'currency_id': currency_id}), + + (0, 0, {'debit': 0.0, 'credit': 1320.0, 'account_id': account_id}), + ] + }, + { + 'move_type': 'entry', + 'date': '2017-01-01', + 'line_ids': [ + (0, 0, {'debit': 0.0, 'credit': 240.0, 'amount_currency': -480.0, 'account_id': account_id, 'currency_id': currency_id}), + (0, 0, {'debit': 0.0, 'credit': 720.0, 'amount_currency': -1440.0, 'account_id': account_id, 'currency_id': currency_id}), + (0, 0, {'debit': 0.0, 'credit': 1020.0, 'amount_currency': -2040.0, 'account_id': account_id, 'currency_id': currency_id}), + + (0, 0, {'debit': 1980.0, 'credit': 0.0, 'account_id': account_id}), + ] + } + ]) + + moves.action_post() + + line_1 = moves.line_ids.filtered(lambda line: line.debit == 1200.0) + line_2 = moves.line_ids.filtered(lambda line: line.debit == 120.0) + line_3 = moves.line_ids.filtered(lambda line: line.credit == 240.0) + line_4 = moves.line_ids.filtered(lambda line: line.credit == 720.0) + line_5 = moves.line_ids.filtered(lambda line: line.credit == 1020.0) + + self.assertRecordValues(line_1 + line_2 + line_3 + line_4 + line_5, [ + {'amount_residual': 1200.0, 'amount_residual_currency': 3600.0, 'reconciled': False}, + {'amount_residual': 120.0, 'amount_residual_currency': 360.0, 'reconciled': False}, + {'amount_residual': -240.0, 'amount_residual_currency': -480.0, 'reconciled': False}, + {'amount_residual': -720.0, 'amount_residual_currency': -1440.0, 'reconciled': False}, + {'amount_residual': -1020.0, 'amount_residual_currency': -2040.0, 'reconciled': False}, + ]) + + res = (line_1 + line_3 + line_4).reconcile() + + self.assertPartialReconcile(res['partials'], [ + # Partial generated when reconciling line_1 & line_3: + { + 'amount': 240.0, + 'debit_amount_currency': 480.0, + 'credit_amount_currency': 480.0, + 'debit_move_id': line_1.id, + 'credit_move_id': line_3.id, + }, + # Partial generated when reconciling line_1 & line_4: + { + 'amount': 720.0, + 'debit_amount_currency': 1440.0, + 'credit_amount_currency': 1440.0, + 'debit_move_id': line_1.id, + 'credit_move_id': line_4.id, + }, + ]) + + self.assertRecordValues(line_1 + line_3 + line_4, [ + {'amount_residual': 240.0, 'amount_residual_currency': 1680.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}, + ]) + + res = (line_1 + line_5).reconcile() + + self.assertPartialReconcile(res['partials'], [{ + 'amount': 240.0, + 'debit_amount_currency': 1680.0, + 'credit_amount_currency': 1680.0, + 'debit_move_id': line_1.id, + 'credit_move_id': line_5.id, + }]) + + self.assertRecordValues(line_1 + line_5, [ + {'amount_residual': 0.0, 'amount_residual_currency': 0.0, 'reconciled': True}, + {'amount_residual': -780.0, 'amount_residual_currency': -360.0, 'reconciled': False}, + ]) + + res = (line_2 + line_5).reconcile() + + exchange_diff = res['full_reconcile'].exchange_move_id + exchange_diff_lines = exchange_diff.line_ids.sorted(lambda line: (line.currency_id, abs(line.amount_currency), -line.amount_currency)) + + self.assertRecordValues(exchange_diff_lines, [ + # Fix line_2: + { + 'debit': 660.0, + 'credit': 0.0, + 'amount_currency': 0.0, + 'currency_id': currency_id, + 'account_id': account_id, + }, + { + 'debit': 0.0, + 'credit': 660.0, + 'amount_currency': 0.0, + 'currency_id': currency_id, + 'account_id': exchange_diff.journal_id.company_id.income_currency_exchange_account_id.id, + }, + ]) + + self.assertPartialReconcile(res['partials'], [ + # Partial generated when reconciling line_2 & line_5: + { + 'amount': 120.0, + 'debit_amount_currency': 360.0, + 'credit_amount_currency': 360.0, + 'debit_move_id': line_2.id, + 'credit_move_id': line_5.id, + }, + # Partial fixing line_4 (exchange difference): + { + 'amount': 660.0, + 'debit_amount_currency': 0.0, + 'credit_amount_currency': 0.0, + 'debit_move_id': exchange_diff_lines[0].id, + 'credit_move_id': line_5.id, + }, + ]) + + self.assertFullReconcile(res['full_reconcile'], line_1 + line_2 + line_3 + line_4 + line_5) + + def test_reconcile_multiple_currencies(self): + account_id = self.company_data['default_account_receivable'].id + + # Rate is 3.0 in 2016, 2.0 in 2017. + currency1_id = self.currency_data['currency'].id + # Rate is 6.0 in 2016, 4.0 in 2017. + currency2_id = self.currency_data_2['currency'].id + + moves = 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': account_id, 'currency_id': currency1_id}), + (0, 0, {'debit': 780.0, 'credit': 0.0, 'amount_currency': 2340.0, 'account_id': account_id, 'currency_id': currency1_id}), + + (0, 0, {'debit': 0.0, 'credit': 1980.0, 'account_id': account_id}), + ] + }, + { + 'move_type': 'entry', + 'date': '2017-01-01', + 'line_ids': [ + (0, 0, {'debit': 0.0, 'credit': 240.0, 'amount_currency': -960.0, 'account_id': account_id, 'currency_id': currency2_id}), + (0, 0, {'debit': 0.0, 'credit': 720.0, 'amount_currency': -2880.0, 'account_id': account_id, 'currency_id': currency2_id}), + (0, 0, {'debit': 0.0, 'credit': 1020.0, 'amount_currency': -4080.0, 'account_id': account_id, 'currency_id': currency2_id}), + + (0, 0, {'debit': 1980.0, 'credit': 0.0, 'account_id': account_id}), + ] + } + ]) + + moves.action_post() + + line_1 = moves.line_ids.filtered(lambda line: line.debit == 1200.0) + line_2 = moves.line_ids.filtered(lambda line: line.debit == 780.0) + line_3 = moves.line_ids.filtered(lambda line: line.credit == 240.0) + line_4 = moves.line_ids.filtered(lambda line: line.credit == 720.0) + line_5 = moves.line_ids.filtered(lambda line: line.credit == 1020.0) + + self.assertRecordValues(line_1 + line_2 + line_3 + line_4 + line_5, [ + {'amount_residual': 1200.0, 'amount_residual_currency': 3600.0, 'reconciled': False}, + {'amount_residual': 780.0, 'amount_residual_currency': 2340.0, 'reconciled': False}, + {'amount_residual': -240.0, 'amount_residual_currency': -960.0, 'reconciled': False}, + {'amount_residual': -720.0, 'amount_residual_currency': -2880.0, 'reconciled': False}, + {'amount_residual': -1020.0, 'amount_residual_currency': -4080.0, 'reconciled': False}, + ]) + + res = (line_1 + line_3 + line_4).reconcile() + + self.assertPartialReconcile(res['partials'], [ + # Partial generated when reconciling line_1 & line_3: + { + 'amount': 240.0, + 'debit_amount_currency': 480.0, + 'credit_amount_currency': 1440.0, + 'debit_move_id': line_1.id, + 'credit_move_id': line_3.id, + }, + # Partial generated when reconciling line_1 & line_4: + { + 'amount': 720.0, + 'debit_amount_currency': 1440.0, + 'credit_amount_currency': 4320.0, + 'debit_move_id': line_1.id, + 'credit_move_id': line_4.id, + }, + ]) + + self.assertRecordValues(line_1 + line_3 + line_4, [ + {'amount_residual': 240.0, 'amount_residual_currency': 1680.0, 'reconciled': False}, + {'amount_residual': 0.0, 'amount_residual_currency': 480.0, 'reconciled': False}, + {'amount_residual': 0.0, 'amount_residual_currency': 1440.0, 'reconciled': False}, + ]) + + res = (line_1 + line_5).reconcile() + + self.assertPartialReconcile(res['partials'], [{ + 'amount': 240.0, + 'debit_amount_currency': 480.0, + 'credit_amount_currency': 1440.0, + 'debit_move_id': line_1.id, + 'credit_move_id': line_5.id, + }]) + + self.assertRecordValues(line_1 + line_5, [ + {'amount_residual': 0.0, 'amount_residual_currency': 1200.0, 'reconciled': False}, + {'amount_residual': -780.0, 'amount_residual_currency': -2640.0, 'reconciled': False}, + ]) + + res = (line_2 + line_5).reconcile() + + exchange_diff = res['full_reconcile'].exchange_move_id + exchange_diff_lines = exchange_diff.line_ids.sorted(lambda line: (line.currency_id, abs(line.amount_currency), -line.amount_currency)) + + self.assertRecordValues(exchange_diff_lines, [ + # Fix line_2: + { + 'debit': 0.0, + 'credit': 0.0, + 'amount_currency': 780.0, + 'currency_id': currency1_id, + 'account_id': exchange_diff.journal_id.company_id.expense_currency_exchange_account_id.id, + }, + { + 'debit': 0.0, + 'credit': 0.0, + 'amount_currency': -780.0, + 'currency_id': currency1_id, + 'account_id': account_id, + }, + # Fix line_3: + { + 'debit': 0.0, + 'credit': 0.0, + 'amount_currency': 480.0, + 'currency_id': currency2_id, + 'account_id': exchange_diff.journal_id.company_id.expense_currency_exchange_account_id.id, + }, + { + 'debit': 0.0, + 'credit': 0.0, + 'amount_currency': -480.0, + 'currency_id': currency2_id, + 'account_id': account_id, + }, + # Fix line_4: + { + 'debit': 0.0, + 'credit': 0.0, + 'amount_currency': 1440.0, + 'currency_id': currency2_id, + 'account_id': exchange_diff.journal_id.company_id.expense_currency_exchange_account_id.id, + }, + { + 'debit': 0.0, + 'credit': 0.0, + 'amount_currency': -1440.0, + 'currency_id': currency2_id, + 'account_id': account_id, + }, + # Fix line_5: + { + 'debit': 0.0, + 'credit': 0.0, + 'amount_currency': 2040.0, + 'currency_id': currency2_id, + 'account_id': exchange_diff.journal_id.company_id.expense_currency_exchange_account_id.id, + }, + { + 'debit': 0.0, + 'credit': 0.0, + 'amount_currency': -2040.0, + 'currency_id': currency2_id, + 'account_id': account_id, + }, + # Fix line_1: + { + 'debit': 0.0, + 'credit': 0.0, + 'amount_currency': 1200.0, + 'currency_id': currency1_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': currency1_id, + 'account_id': account_id, + }, + ]) + + self.assertPartialReconcile(res['partials'], [ + # Partial fixing line_3 (exchange difference): + { + 'amount': 0.0, + 'debit_amount_currency': 480.0, + 'credit_amount_currency': 480.0, + 'debit_move_id': line_3.id, + 'credit_move_id': exchange_diff_lines[3].id, + }, + # Partial fixing line_2 (exchange difference): + { + 'amount': 0.0, + 'debit_amount_currency': 780.0, + 'credit_amount_currency': 780.0, + 'debit_move_id': line_2.id, + 'credit_move_id': exchange_diff_lines[1].id, + }, + # Partial fixing line_1 (exchange difference): + { + 'amount': 0.0, + 'debit_amount_currency': 1200.0, + 'credit_amount_currency': 1200.0, + 'debit_move_id': line_1.id, + 'credit_move_id': exchange_diff_lines[9].id, + }, + # Partial fixing line_4 (exchange difference): + { + 'amount': 0.0, + 'debit_amount_currency': 1440.0, + 'credit_amount_currency': 1440.0, + 'debit_move_id': line_4.id, + 'credit_move_id': exchange_diff_lines[5].id, + }, + # Partial fixing line_5 (exchange difference): + { + 'amount': 0.0, + 'debit_amount_currency': 2040.0, + 'credit_amount_currency': 2040.0, + 'debit_move_id': line_5.id, + 'credit_move_id': exchange_diff_lines[7].id, + }, + # Partial generated when reconciling line_2 & line_5: + { + 'amount': 780.0, + 'debit_amount_currency': 1560.0, + 'credit_amount_currency': 4680.0, + 'debit_move_id': line_2.id, + 'credit_move_id': line_5.id, + }, + ]) + + self.assertFullReconcile(res['full_reconcile'], line_1 + line_2 + line_3 + line_4 + line_5) + + def test_reconcile_asymetric_rate_change(self): + account_id = self.company_data['default_account_receivable'].id + + # Rate is 3.0 in 2016, 2.0 in 2017. + currency1_id = self.currency_data['currency'].id + # Rate is 6.0 in 2016, 4.0 in 2017. + currency2_id = self.currency_data_2['currency'].id + + # Create rate changes for 2018: currency1 rate increases while currency2 rate decreases. + self.env['res.currency.rate'].create({ + 'name': '2018-01-01', + 'rate': 8.0, + 'currency_id': currency1_id, + 'company_id': self.company_data['company'].id, + }) + + self.env['res.currency.rate'].create({ + 'name': '2018-01-01', + 'rate': 2.0, + 'currency_id': currency2_id, + 'company_id': self.company_data['company'].id, + }) + + moves = self.env['account.move'].create([ + { + 'move_type': 'entry', + 'date': '2018-01-01', + 'line_ids': [ + (0, 0, { + 'debit': 1200.0, + 'credit': 0.0, + 'amount_currency': 9600.0, + 'account_id': account_id, + 'currency_id': currency1_id, + }), + (0, 0, { + 'debit': 960.0, + 'credit': 0.0, + 'amount_currency': 1920.0, + 'account_id': account_id, + 'currency_id': currency2_id, + }), + (0, 0, { + 'debit': 0.0, + 'credit': 2160.0, + 'account_id': account_id, + }), + ] + }, + { + 'move_type': 'entry', + 'date': '2017-01-01', + 'line_ids': [ + (0, 0, { + 'debit': 0.0, + 'credit': 1200.0, + 'amount_currency': -4800.0, + 'account_id': account_id, + 'currency_id': currency2_id, + }), + (0, 0, { + 'debit': 0.0, + 'credit': 960.0, + 'amount_currency': -1920.0, + 'account_id': account_id, + 'currency_id': currency1_id, + }), + (0, 0, { + 'debit': 2160.0, + 'credit': 0.0, + 'account_id': account_id, + }), + ] + } + ]) + + moves.action_post() + + line_1 = moves.line_ids.filtered(lambda line: line.debit == 1200.0) + line_2 = moves.line_ids.filtered(lambda line: line.debit == 960.0) + line_3 = moves.line_ids.filtered(lambda line: line.credit == 1200.0) + line_4 = moves.line_ids.filtered(lambda line: line.credit == 960.0) + + self.assertRecordValues(line_1 + line_2 + line_3 + line_4, [ + {'amount_residual': 1200.0, 'amount_residual_currency': 9600.0, 'reconciled': False}, + {'amount_residual': 960.0, 'amount_residual_currency': 1920.0, 'reconciled': False}, + {'amount_residual': -1200.0, 'amount_residual_currency': -4800.0, 'reconciled': False}, + {'amount_residual': -960.0, 'amount_residual_currency': -1920.0, 'reconciled': False}, + ]) + + # Reconcile with debit_line currency rate increased and credit_line currency rate decreased between + # credit_line.date and debit_line.date. + + res = (line_1 + line_3).reconcile() + + exchange_diff = res['full_reconcile'].exchange_move_id + exchange_diff_lines = exchange_diff.line_ids.sorted(lambda line: (line.currency_id, abs(line.amount_currency), -line.amount_currency)) + + self.assertRecordValues(exchange_diff_lines, [ + { + 'debit': 0.0, + 'credit': 0.0, + 'amount_currency': 2400.0, + 'currency_id': currency2_id, + 'account_id': account_id, + }, + { + 'debit': 0.0, + 'credit': 0.0, + 'amount_currency': -2400.0, + 'currency_id': currency2_id, + 'account_id': exchange_diff.journal_id.company_id.income_currency_exchange_account_id.id, + }, + { + 'debit': 0.0, + 'credit': 0.0, + 'amount_currency': 7200.0, + 'currency_id': currency1_id, + 'account_id': exchange_diff.journal_id.company_id.expense_currency_exchange_account_id.id, + }, + { + 'debit': 0.0, + 'credit': 0.0, + 'amount_currency': -7200.0, + 'currency_id': currency1_id, + 'account_id': account_id, + } + ]) + + self.assertPartialReconcile(res['partials'], [ + { + 'amount': 0.0, + 'debit_amount_currency': 2400.0, + 'credit_amount_currency': 2400.0, + 'debit_move_id': exchange_diff_lines[0].id, + 'credit_move_id': line_3.id, + }, + { + 'amount': 0.0, + 'debit_amount_currency': 7200.0, + 'credit_amount_currency': 7200.0, + 'debit_move_id': line_1.id, + 'credit_move_id': exchange_diff_lines[3].id, + }, + { + 'amount': 1200.0, + 'debit_amount_currency': 2400.0, + 'credit_amount_currency': 2400.0, + 'debit_move_id': line_1.id, + 'credit_move_id': line_3.id, + }, + ]) + + self.assertRecordValues(line_1 + line_3, [ + {'amount_residual': 0.0, 'amount_residual_currency': 0.0, 'reconciled': True}, + {'amount_residual': 0.0, 'amount_residual_currency': 0.0, 'reconciled': True}, + ]) + + # Reconcile with debit_line currency rate decreased and credit_line currency rate increased between + # credit_line.date and debit_line.date. + + res = (line_2 + line_4).reconcile() + + exchange_diff = res['full_reconcile'].exchange_move_id + exchange_diff_lines = exchange_diff.line_ids.sorted(lambda line: (line.currency_id, abs(line.amount_currency), -line.amount_currency)) + + self.assertRecordValues(exchange_diff_lines, [ + { + 'debit': 0.0, + 'credit': 0.0, + 'amount_currency': 5760.0, + 'currency_id': currency1_id, + 'account_id': exchange_diff.journal_id.company_id.expense_currency_exchange_account_id.id, + }, + { + 'debit': 0.0, + 'credit': 0.0, + 'amount_currency': -5760.0, + 'currency_id': currency1_id, + 'account_id': account_id, + }, + { + 'debit': 0.0, + 'credit': 0.0, + 'amount_currency': 1920.0, + 'currency_id': currency2_id, + 'account_id': account_id, + }, + { + 'debit': 0.0, + 'credit': 0.0, + 'amount_currency': -1920.0, + 'currency_id': currency2_id, + 'account_id': exchange_diff.journal_id.company_id.income_currency_exchange_account_id.id, + } + ]) + + self.assertPartialReconcile(res['partials'], [ + { + 'amount': 0.0, + 'debit_amount_currency': 1920.0, + 'credit_amount_currency': 1920.0, + 'debit_move_id': exchange_diff_lines[2].id, + 'credit_move_id': line_2.id, + }, + { + 'amount': 0.0, + 'debit_amount_currency': 5760.0, + 'credit_amount_currency': 5760.0, + 'debit_move_id': line_4.id, + 'credit_move_id': exchange_diff_lines[1].id, + }, + { + 'amount': 960.0, + 'debit_amount_currency': 3840.0, + 'credit_amount_currency': 7680.0, + 'debit_move_id': line_2.id, + 'credit_move_id': line_4.id, + }, + ]) + + self.assertRecordValues(line_2 + line_4, [ + {'amount_residual': 0.0, 'amount_residual_currency': 0.0, 'reconciled': True}, + {'amount_residual': 0.0, 'amount_residual_currency': 0.0, 'reconciled': True}, + ]) + + 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). + # ------------------------------------------------------------------------- + + def test_reconcile_cash_basis_workflow_single_currency(self): + ''' Test the generated journal entries during the reconciliation to manage the cash basis taxes. + Also, + - Test the case when there is multiple receivable/payable accounts. + - Test the reconciliation with tiny amounts. + - Check there is no rounding issue when making the percentage. + - Check there is no lost cents when the journal entry is fully reconciled. + ''' + cash_basis_move = self.env['account.move'].create({ + 'move_type': 'entry', + 'date': '2016-01-01', + 'line_ids': [ + # Base Tax line + (0, 0, { + 'debit': 0.0, + 'credit': 100.0, + 'account_id': self.company_data['default_account_revenue'].id, + 'tax_ids': [(6, 0, (self.cash_basis_tax_a_third_amount + self.cash_basis_tax_tiny_amount).ids)], + 'tax_exigible': False, + }), + + # Tax lines + (0, 0, { + 'debit': 0.0, + 'credit': 33.33, + 'account_id': self.cash_basis_transfer_account.id, + 'tax_repartition_line_id': self.cash_basis_tax_a_third_amount.invoice_repartition_line_ids.filtered(lambda line: line.repartition_type == 'tax').id, + 'tax_exigible': False, + }), + (0, 0, { + 'debit': 0.0, + 'credit': 0.01, + 'account_id': self.cash_basis_transfer_account.id, + 'tax_repartition_line_id': self.cash_basis_tax_tiny_amount.invoice_repartition_line_ids.filtered(lambda line: line.repartition_type == 'tax').id, + 'tax_exigible': False, + }), + + # Receivable lines + (0, 0, { + 'debit': 44.45, + 'credit': 0.0, + 'account_id': self.extra_receivable_account_1.id, + }), + (0, 0, { + 'debit': 44.45, + 'credit': 0.0, + 'account_id': self.extra_receivable_account_2.id, + }), + (0, 0, { + 'debit': 44.45, + 'credit': 0.0, + 'account_id': self.extra_receivable_account_2.id, + }), + (0, 0, { + 'debit': 0.0, + 'credit': 0.01, + 'account_id': self.extra_payable_account_1.id, + }), + ] + }) + + payment_move = self.env['account.move'].create({ + 'move_type': 'entry', + 'date': '2017-01-01', + 'line_ids': [ + (0, 0, {'debit': 0.0, 'credit': 33.34, 'account_id': self.extra_receivable_account_1.id}), + (0, 0, {'debit': 0.0, 'credit': 11.11, 'account_id': self.extra_receivable_account_1.id}), + (0, 0, {'debit': 0.0, 'credit': 88.89, 'account_id': self.extra_receivable_account_2.id}), + (0, 0, {'debit': 0.0, 'credit': 0.01, 'account_id': self.extra_receivable_account_2.id}), + (0, 0, {'debit': 0.01, 'credit': 0.0, 'account_id': self.extra_payable_account_1.id}), + (0, 0, {'debit': 133.34, 'credit': 0.0, 'account_id': self.company_data['default_account_revenue'].id}), + ] + }) + + (cash_basis_move + payment_move).action_post() + + # Initial amounts by accounts: + + self.assertAmountsGroupByAccount([ + # Account Balance Amount Currency + (self.cash_basis_transfer_account, -33.34, -33.34), + (self.tax_account_1, 0.0, 0.0), + (self.tax_account_2, 0.0, 0.0), + (self.cash_basis_base_account, 0.0, 0.0), + ]) + + # There is 44.45 + 44.45 + 44.45 + 0.01 = 133.36 to reconcile on 'cash_basis_move'. + # Reconciling all the amount in extra_receivable_account_1 should compute 2 percentages: + # 33.34 / 133.36 = 0.25 + # 11.11 / 133.36 = 0.083308338 + + receivable_lines_1 = (cash_basis_move + payment_move).line_ids\ + .filtered(lambda line: line.account_id == self.extra_receivable_account_1) + res = receivable_lines_1.reconcile() + + self.assertFullReconcile(res['full_reconcile'], receivable_lines_1) + self.assertEqual(len(res.get('tax_cash_basis_moves', [])), 2) + self.assertRecordValues(res['tax_cash_basis_moves'][0].line_ids, [ + # Base amount of tax_1 & tax_2: + {'debit': 25.0, 'credit': 0.0, 'account_id': self.cash_basis_base_account.id}, + {'debit': 0.0, 'credit': 25.0, 'account_id': self.cash_basis_base_account.id}, + # tax_1: + {'debit': 8.33, 'credit': 0.0, 'account_id': self.cash_basis_transfer_account.id}, + {'debit': 0.0, 'credit': 8.33, 'account_id': self.tax_account_1.id}, + # tax_2: + {'debit': 0.0, 'credit': 0.0, 'account_id': self.cash_basis_transfer_account.id}, + {'debit': 0.0, 'credit': 0.0, 'account_id': self.tax_account_2.id}, + ]) + self.assertRecordValues(res['tax_cash_basis_moves'][1].line_ids, [ + # Base amount of tax_1 & tax_2: + {'debit': 8.33, 'credit': 0.0, 'account_id': self.cash_basis_base_account.id}, + {'debit': 0.0, 'credit': 8.33, 'account_id': self.cash_basis_base_account.id}, + # tax_1: + {'debit': 2.78, 'credit': 0.0, 'account_id': self.cash_basis_transfer_account.id}, + {'debit': 0.0, 'credit': 2.78, 'account_id': self.tax_account_1.id}, + # tax_2: + {'debit': 0.0, 'credit': 0.0, 'account_id': self.cash_basis_transfer_account.id}, + {'debit': 0.0, 'credit': 0.0, 'account_id': self.tax_account_2.id}, + ]) + + self.assertAmountsGroupByAccount([ + # Account Balance Amount Currency + (self.cash_basis_transfer_account, -22.23, -22.23), + (self.tax_account_1, -11.11, -11.11), + (self.tax_account_2, 0.0, 0.0), + ]) + + # Reconciling all the amount in extra_receivable_account_2 should compute 3 percentages: + # 44.45 / 133.36 = 0.333308338 + # 44.44 / 133.36 = 0.333233353 + # 0.01 / 133.36 = 0.000074985 + + receivable_lines_2 = (cash_basis_move + payment_move).line_ids\ + .filtered(lambda line: line.account_id == self.extra_receivable_account_2) + res = receivable_lines_2.reconcile() + + self.assertFullReconcile(res['full_reconcile'], receivable_lines_2) + self.assertEqual(len(res.get('tax_cash_basis_moves', [])), 3) + self.assertRecordValues(res['tax_cash_basis_moves'][0].line_ids, [ + # Base amount of tax_1 & tax_2: + {'debit': 33.33, 'credit': 0.0, 'account_id': self.cash_basis_base_account.id}, + {'debit': 0.0, 'credit': 33.33, 'account_id': self.cash_basis_base_account.id}, + # tax_1: + {'debit': 11.11, 'credit': 0.0, 'account_id': self.cash_basis_transfer_account.id}, + {'debit': 0.0, 'credit': 11.11, 'account_id': self.tax_account_1.id}, + # tax_2: + {'debit': 0.0, 'credit': 0.0, 'account_id': self.cash_basis_transfer_account.id}, + {'debit': 0.0, 'credit': 0.0, 'account_id': self.tax_account_2.id}, + ]) + self.assertRecordValues(res['tax_cash_basis_moves'][1].line_ids, [ + # Base amount of tax_1 & tax_2: + {'debit': 33.32, 'credit': 0.0, 'account_id': self.cash_basis_base_account.id}, + {'debit': 0.0, 'credit': 33.32, 'account_id': self.cash_basis_base_account.id}, + # tax_1: + {'debit': 11.11, 'credit': 0.0, 'account_id': self.cash_basis_transfer_account.id}, + {'debit': 0.0, 'credit': 11.11, 'account_id': self.tax_account_1.id}, + # tax_2: + {'debit': 0.0, 'credit': 0.0, 'account_id': self.cash_basis_transfer_account.id}, + {'debit': 0.0, 'credit': 0.0, 'account_id': self.tax_account_2.id}, + ]) + self.assertRecordValues(res['tax_cash_basis_moves'][2].line_ids, [ + # Base amount of tax_1 & tax_2: + {'debit': 0.01, 'credit': 0.0, 'account_id': self.cash_basis_base_account.id}, + {'debit': 0.0, 'credit': 0.01, 'account_id': self.cash_basis_base_account.id}, + # tax_1: + {'debit': 0.0, 'credit': 0.0, 'account_id': self.cash_basis_transfer_account.id}, + {'debit': 0.0, 'credit': 0.0, 'account_id': self.tax_account_1.id}, + # tax_2: + {'debit': 0.0, 'credit': 0.0, 'account_id': self.cash_basis_transfer_account.id}, + {'debit': 0.0, 'credit': 0.0, 'account_id': self.tax_account_2.id}, + ]) + + self.assertAmountsGroupByAccount([ + # Account Balance Amount Currency + (self.cash_basis_transfer_account, -0.01, -0.01), + (self.tax_account_1, -33.33, -33.33), + (self.tax_account_2, 0.0, 0.0), + ]) + + # Reconciling all the amount in extra_payable_account_1 should trigger the matching number and ensure all + # the base amount has been covered without any rounding issue. + + payable_lines_1 = (cash_basis_move + payment_move).line_ids\ + .filtered(lambda line: line.account_id == self.extra_payable_account_1) + res = payable_lines_1.reconcile() + + self.assertFullReconcile(res['full_reconcile'], payable_lines_1) + self.assertEqual(len(res.get('tax_cash_basis_moves', [])), 1) + self.assertRecordValues(res['tax_cash_basis_moves'].line_ids, [ + # Base amount of tax_1 & tax_2: + {'debit': 0.01, 'credit': 0.0, 'account_id': self.cash_basis_base_account.id}, + {'debit': 0.0, 'credit': 0.01, 'account_id': self.cash_basis_base_account.id}, + # tax_1: + {'debit': 0.0, 'credit': 0.0, 'account_id': self.cash_basis_transfer_account.id}, + {'debit': 0.0, 'credit': 0.0, 'account_id': self.tax_account_1.id}, + # tax_2: + {'debit': 0.0, 'credit': 0.0, 'account_id': self.cash_basis_transfer_account.id}, + {'debit': 0.0, 'credit': 0.0, 'account_id': self.tax_account_2.id}, + ]) + + self.assertRecordValues(res['full_reconcile'].exchange_move_id.line_ids, [ + {'account_id': self.tax_account_2.id, 'debit': 0.0, 'credit': 0.01, 'tax_ids': [], 'tax_line_id': self.cash_basis_tax_tiny_amount.id}, + {'account_id': self.cash_basis_transfer_account.id, 'debit': 0.01, 'credit': 0.0, 'tax_ids': [], 'tax_line_id': False}, + ]) + + self.assertAmountsGroupByAccount([ + # Account Balance Amount Currency + (self.cash_basis_transfer_account, 0.0, 0.0), + (self.tax_account_1, -33.33, -33.33), + (self.tax_account_2, -0.01, -0.01), + ]) + + def test_reconcile_cash_basis_workflow_multi_currency(self): + ''' Same as before with a foreign currency. ''' + + currency_id = self.currency_data['currency'].id + taxes = self.cash_basis_tax_a_third_amount + self.cash_basis_tax_tiny_amount + + cash_basis_move = self.env['account.move'].create({ + 'move_type': 'entry', + 'date': '2016-01-01', + 'line_ids': [ + # Base Tax line + (0, 0, { + 'debit': 0.0, + 'credit': 33.34, + 'amount_currency': -100.0, + 'currency_id': currency_id, + 'account_id': self.company_data['default_account_revenue'].id, + 'tax_ids': [(6, 0, taxes.ids)], + 'tax_exigible': False, + }), + + # Tax lines + (0, 0, { + 'debit': 0.0, + 'credit': 11.10, + 'amount_currency': -33.33, + 'currency_id': currency_id, + 'account_id': self.cash_basis_transfer_account.id, + 'tax_repartition_line_id': self.cash_basis_tax_a_third_amount.invoice_repartition_line_ids.filtered(lambda line: line.repartition_type == 'tax').id, + 'tax_exigible': False, + }), + (0, 0, { + 'debit': 0.0, + 'credit': 0.01, + 'amount_currency': -0.01, + 'currency_id': currency_id, + 'account_id': self.cash_basis_transfer_account.id, + 'tax_repartition_line_id': self.cash_basis_tax_tiny_amount.invoice_repartition_line_ids.filtered(lambda line: line.repartition_type == 'tax').id, + 'tax_exigible': False, + }), + + # Receivable lines + (0, 0, { + 'debit': 14.82, + 'credit': 0.0, + 'amount_currency': 44.45, + 'currency_id': currency_id, + 'account_id': self.extra_receivable_account_1.id, + }), + (0, 0, { + 'debit': 14.82, + 'credit': 0.0, + 'amount_currency': 44.45, + 'currency_id': currency_id, + 'account_id': self.extra_receivable_account_2.id, + }), + (0, 0, { + 'debit': 14.82, + 'credit': 0.0, + 'amount_currency': 44.45, + 'currency_id': currency_id, + 'account_id': self.extra_receivable_account_2.id, + }), + (0, 0, { + 'debit': 0.0, + 'credit': 0.01, + 'amount_currency': -0.01, + 'currency_id': currency_id, + 'account_id': self.extra_payable_account_1.id, + }), + ] + }) + + payment_move = self.env['account.move'].create({ + 'move_type': 'entry', + 'date': '2017-01-01', + 'line_ids': [ + (0, 0, {'debit': 0.0, 'credit': 16.67, 'amount_currency': -33.34, 'currency_id': currency_id, 'account_id': self.extra_receivable_account_1.id}), + (0, 0, {'debit': 0.0, 'credit': 5.6, 'amount_currency': -11.11, 'currency_id': currency_id, 'account_id': self.extra_receivable_account_1.id}), + (0, 0, {'debit': 0.0, 'credit': 44.45, 'amount_currency': -88.89, 'currency_id': currency_id, 'account_id': self.extra_receivable_account_2.id}), + (0, 0, {'debit': 0.0, 'credit': 0.01, 'amount_currency': -0.01, 'currency_id': currency_id, 'account_id': self.extra_receivable_account_2.id}), + (0, 0, {'debit': 0.01, 'credit': 0.0, 'amount_currency': 0.01, 'currency_id': currency_id, 'account_id': self.extra_payable_account_1.id}), + (0, 0, {'debit': 66.72, 'credit': 0.0, 'account_id': self.company_data['default_account_revenue'].id}), + ] + }) + + (cash_basis_move + payment_move).action_post() + + # Initial amounts by accounts: + + self.assertAmountsGroupByAccount([ + # Account Balance Amount Currency + (self.cash_basis_transfer_account, -11.11, -33.34), + (self.tax_account_1, 0.0, 0.0), + (self.tax_account_2, 0.0, 0.0), + ]) + + # There is 44.45 + 44.45 + 44.45 + 0.01 = 133.36 to reconcile on 'cash_basis_move'. + # Reconciling all the amount in extra_receivable_account_1 should compute 2 percentages: + # 33.34 / 133.36 = 0.25 + # 11.11 / 133.36 = 0.083308338 + + receivable_lines_1 = (cash_basis_move + payment_move).line_ids\ + .filtered(lambda line: line.account_id == self.extra_receivable_account_1) + res = receivable_lines_1.reconcile() + + self.assertFullReconcile(res['full_reconcile'], receivable_lines_1) + self.assertEqual(len(res.get('tax_cash_basis_moves', [])), 2) + self.assertRecordValues(res['tax_cash_basis_moves'][0].line_ids, [ + # Base amount of tax_1 & tax_2: + {'debit': 12.5, 'credit': 0.0, 'amount_currency': 25.0, 'currency_id': currency_id, 'account_id': self.cash_basis_base_account.id}, + {'debit': 0.0, 'credit': 12.5, 'amount_currency': -25.0, 'currency_id': currency_id, 'account_id': self.cash_basis_base_account.id}, + # tax_1: + {'debit': 4.17, 'credit': 0.0, 'amount_currency': 8.333, 'currency_id': currency_id, 'account_id': self.cash_basis_transfer_account.id}, + {'debit': 0.0, 'credit': 4.17, 'amount_currency': -8.333, 'currency_id': currency_id, 'account_id': self.tax_account_1.id}, + # tax_2: + {'debit': 0.0, 'credit': 0.0, 'amount_currency': 0.003, 'currency_id': currency_id, 'account_id': self.cash_basis_transfer_account.id}, + {'debit': 0.0, 'credit': 0.0, 'amount_currency': -0.003, 'currency_id': currency_id, 'account_id': self.tax_account_2.id}, + ]) + self.assertRecordValues(res['tax_cash_basis_moves'][1].line_ids, [ + # Base amount of tax_1 & tax_2: + {'debit': 4.2, 'credit': 0.0, 'amount_currency': 8.331, 'currency_id': currency_id, 'account_id': self.cash_basis_base_account.id}, + {'debit': 0.0, 'credit': 4.2, 'amount_currency': -8.331, 'currency_id': currency_id, 'account_id': self.cash_basis_base_account.id}, + # tax_1: + {'debit': 1.4, 'credit': 0.0, 'amount_currency': 2.777, 'currency_id': currency_id, 'account_id': self.cash_basis_transfer_account.id}, + {'debit': 0.0, 'credit': 1.4, 'amount_currency': -2.777, 'currency_id': currency_id, 'account_id': self.tax_account_1.id}, + # tax_2: + {'debit': 0.0, 'credit': 0.0, 'amount_currency': 0.001, 'currency_id': currency_id, 'account_id': self.cash_basis_transfer_account.id}, + {'debit': 0.0, 'credit': 0.0, 'amount_currency': -0.001, 'currency_id': currency_id, 'account_id': self.tax_account_2.id}, + ]) + + self.assertAmountsGroupByAccount([ + # Account Balance Amount Currency + (self.cash_basis_transfer_account, -5.54, -22.226), + (self.tax_account_1, -5.57, -11.11), + (self.tax_account_2, 0.0, -0.004), + ]) + + # Reconciling all the amount in extra_receivable_account_2 should compute 3 percentages: + # 44.45 / 133.36 = 0.333308338 + # 44.44 / 133.36 = 0.333233353 + # 0.01 / 133.36 = 0.000074985 + + receivable_lines_2 = (cash_basis_move + payment_move).line_ids\ + .filtered(lambda line: line.account_id == self.extra_receivable_account_2) + res = receivable_lines_2.reconcile() + + self.assertFullReconcile(res['full_reconcile'], receivable_lines_2) + self.assertEqual(len(res.get('tax_cash_basis_moves', [])), 3) + self.assertRecordValues(res['tax_cash_basis_moves'][0].line_ids, [ + # Base amount of tax_1 & tax_2: + {'debit': 16.67, 'credit': 0.0, 'amount_currency': 33.331, 'currency_id': currency_id, 'account_id': self.cash_basis_base_account.id}, + {'debit': 0.0, 'credit': 16.67, 'amount_currency': -33.331, 'currency_id': currency_id, 'account_id': self.cash_basis_base_account.id}, + # tax_1: + {'debit': 5.56, 'credit': 0.0, 'amount_currency': 11.109, 'currency_id': currency_id, 'account_id': self.cash_basis_transfer_account.id}, + {'debit': 0.0, 'credit': 5.56, 'amount_currency': -11.109, 'currency_id': currency_id, 'account_id': self.tax_account_1.id}, + # tax_2: + {'debit': 0.0, 'credit': 0.0, 'amount_currency': 0.003, 'currency_id': currency_id, 'account_id': self.cash_basis_transfer_account.id}, + {'debit': 0.0, 'credit': 0.0, 'amount_currency': -0.003, 'currency_id': currency_id, 'account_id': self.tax_account_2.id}, + ]) + self.assertRecordValues(res['tax_cash_basis_moves'][1].line_ids, [ + # Base amount of tax_1 & tax_2: + {'debit': 16.66, 'credit': 0.0, 'amount_currency': 33.323, 'currency_id': currency_id, 'account_id': self.cash_basis_base_account.id}, + {'debit': 0.0, 'credit': 16.66, 'amount_currency': -33.323, 'currency_id': currency_id, 'account_id': self.cash_basis_base_account.id}, + # tax_1: + {'debit': 5.55, 'credit': 0.0, 'amount_currency': 11.107, 'currency_id': currency_id, 'account_id': self.cash_basis_transfer_account.id}, + {'debit': 0.0, 'credit': 5.55, 'amount_currency': -11.107, 'currency_id': currency_id, 'account_id': self.tax_account_1.id}, + # tax_2: + {'debit': 0.0, 'credit': 0.0, 'amount_currency': 0.003, 'currency_id': currency_id, 'account_id': self.cash_basis_transfer_account.id}, + {'debit': 0.0, 'credit': 0.0, 'amount_currency': -0.003, 'currency_id': currency_id, 'account_id': self.tax_account_2.id}, + ]) + self.assertRecordValues(res['tax_cash_basis_moves'][2].line_ids, [ + # Base amount of tax_1 & tax_2: + {'debit': 0.01, 'credit': 0.0, 'amount_currency': 0.007, 'currency_id': currency_id, 'account_id': self.cash_basis_base_account.id}, + {'debit': 0.0, 'credit': 0.01, 'amount_currency': -0.007, 'currency_id': currency_id, 'account_id': self.cash_basis_base_account.id}, + # tax_1: + {'debit': 0.0, 'credit': 0.0, 'amount_currency': 0.002, 'currency_id': currency_id, 'account_id': self.cash_basis_transfer_account.id}, + {'debit': 0.0, 'credit': 0.0, 'amount_currency': -0.002, 'currency_id': currency_id, 'account_id': self.tax_account_1.id}, + # tax_2: + {'debit': 0.0, 'credit': 0.0, 'amount_currency': 0.0, 'currency_id': currency_id, 'account_id': self.cash_basis_transfer_account.id}, + {'debit': 0.0, 'credit': 0.0, 'amount_currency': 0.0, 'currency_id': currency_id, 'account_id': self.tax_account_2.id}, + ]) + + self.assertAmountsGroupByAccount([ + # Account Balance Amount Currency + (self.cash_basis_transfer_account, 5.57, -0.002), + (self.tax_account_1, -16.68, -33.328), + (self.tax_account_2, 0.0, -0.01), + ]) + + # Reconciling all the amount in extra_payable_account_1 should trigger the matching number and ensure all + # the base amount has been covered without any rounding issue. + + payable_lines_1 = (cash_basis_move + payment_move).line_ids\ + .filtered(lambda line: line.account_id == self.extra_payable_account_1) + res = payable_lines_1.reconcile() + + self.assertFullReconcile(res['full_reconcile'], payable_lines_1) + self.assertEqual(len(res.get('tax_cash_basis_moves', [])), 1) + self.assertRecordValues(res['tax_cash_basis_moves'].line_ids, [ + # Base amount of tax_1 & tax_2: + {'debit': 0.01, 'credit': 0.0, 'amount_currency': 0.007, 'currency_id': currency_id, 'account_id': self.cash_basis_base_account.id}, + {'debit': 0.0, 'credit': 0.01, 'amount_currency': -0.007, 'currency_id': currency_id, 'account_id': self.cash_basis_base_account.id}, + # tax_1: + {'debit': 0.0, 'credit': 0.0, 'amount_currency': 0.002, 'currency_id': currency_id, 'account_id': self.cash_basis_transfer_account.id}, + {'debit': 0.0, 'credit': 0.0, 'amount_currency': -0.002, 'currency_id': currency_id, 'account_id': self.tax_account_1.id}, + # tax_2: + {'debit': 0.0, 'credit': 0.0, 'amount_currency': 0.0, 'currency_id': currency_id, 'account_id': self.cash_basis_transfer_account.id}, + {'debit': 0.0, 'credit': 0.0, 'amount_currency': 0.0, 'currency_id': currency_id, 'account_id': self.tax_account_2.id}, + ]) + + self.assertRecordValues(res['full_reconcile'].exchange_move_id.line_ids, [ + {'account_id': self.cash_basis_base_account.id, 'debit': 16.71, 'credit': 0.0, 'tax_ids': taxes.ids, 'tax_line_id': False}, + {'account_id': self.cash_basis_base_account.id, 'debit': 0.0, 'credit': 16.71, 'tax_ids': [], 'tax_line_id': False}, + {'account_id': self.tax_account_1.id, 'debit': 5.58, 'credit': 0.0, 'tax_ids': [], 'tax_line_id': self.cash_basis_tax_a_third_amount.id}, + {'account_id': self.cash_basis_transfer_account.id, 'debit': 0.0, 'credit': 5.58, 'tax_ids': [], 'tax_line_id': False}, + {'account_id': self.tax_account_2.id, 'debit': 0.0, 'credit': 0.01, 'tax_ids': [], 'tax_line_id': self.cash_basis_tax_tiny_amount.id}, + {'account_id': self.cash_basis_transfer_account.id, 'debit': 0.01, 'credit': 0.0, 'tax_ids': [], 'tax_line_id': False}, + ]) + + self.assertAmountsGroupByAccount([ + # Account Balance Amount Currency + (self.cash_basis_transfer_account, 0.0, 0.0), + (self.tax_account_1, -11.1, -33.33), + (self.tax_account_2, -0.01, -0.01), + ]) + + def test_reconcile_cash_basis_exchange_difference_transfer_account_check_entries_1(self): + ''' Test the generation of the exchange difference for a tax cash basis journal entry when the transfer + account is not a reconcile one. + ''' + currency_id = self.currency_data['currency'].id + + # Rate 1/3 in 2016. + cash_basis_move = self.env['account.move'].create({ + 'move_type': 'entry', + 'date': '2016-01-01', + 'line_ids': [ + # Base Tax line + (0, 0, { + 'debit': 0.0, + 'credit': 100.0, + 'amount_currency': -300.0, + 'currency_id': currency_id, + 'account_id': self.company_data['default_account_revenue'].id, + 'tax_ids': [(6, 0, self.cash_basis_tax_a_third_amount.ids)], + 'tax_exigible': False, + }), + + # Tax line + (0, 0, { + 'debit': 0.0, + 'credit': 33.33, + 'amount_currency': -100.0, + 'currency_id': currency_id, + 'account_id': self.cash_basis_transfer_account.id, + 'tax_repartition_line_id': self.cash_basis_tax_a_third_amount.invoice_repartition_line_ids.filtered(lambda line: line.repartition_type == 'tax').id, + 'tax_exigible': False, + }), + + # Receivable lines + (0, 0, { + 'debit': 133.33, + 'credit': 0.0, + 'amount_currency': 400.0, + 'currency_id': currency_id, + 'account_id': self.extra_receivable_account_1.id, + }), + ] + }) + + # Rate 1/2 in 2017. + payment_move = self.env['account.move'].create({ + 'move_type': 'entry', + 'date': '2017-01-01', + 'line_ids': [ + (0, 0, { + 'debit': 0.0, + 'credit': 201.0, + 'amount_currency': -402.0, # Don't create the full reconcile directly. + 'currency_id': currency_id, + 'account_id': self.extra_receivable_account_1.id, + }), + (0, 0, { + 'debit': 201.0, + 'credit': 0.0, + 'account_id': self.company_data['default_account_revenue'].id, + }), + ] + }) + + # Move making the payment fully paid. + end_move = self.env['account.move'].create({ + 'move_type': 'entry', + 'date': '2017-01-01', + 'line_ids': [ + (0, 0, { + 'debit': 1.0, + 'credit': 0.0, + 'amount_currency': 2.0, + 'currency_id': currency_id, + 'account_id': self.extra_receivable_account_1.id, + }), + (0, 0, { + 'debit': 0.0, + 'credit': 1.0, + 'account_id': self.company_data['default_account_revenue'].id, + }), + ] + }) + + (cash_basis_move + payment_move + end_move).action_post() + + self.assertAmountsGroupByAccount([ + # Account Balance Amount Currency + (self.cash_basis_transfer_account, -33.33, -100.0), + (self.tax_account_1, 0.0, 0.0), + ]) + + receivable_lines = (cash_basis_move + payment_move).line_ids\ + .filtered(lambda line: line.account_id == self.extra_receivable_account_1) + res = receivable_lines.reconcile() + + self.assertEqual(len(res.get('tax_cash_basis_moves', [])), 1) + self.assertRecordValues(res['tax_cash_basis_moves'].line_ids, [ + # Base amount: + {'debit': 150.0, 'credit': 0.0, 'amount_currency': 300.0, 'currency_id': currency_id, 'account_id': self.cash_basis_base_account.id}, + {'debit': 0.0, 'credit': 150.0, 'amount_currency': -300.0, 'currency_id': currency_id, 'account_id': self.cash_basis_base_account.id}, + # tax: + {'debit': 50.0, 'credit': 0.0, 'amount_currency': 100.0, 'currency_id': currency_id, 'account_id': self.cash_basis_transfer_account.id}, + {'debit': 0.0, 'credit': 50.0, 'amount_currency': -100.0, 'currency_id': currency_id, 'account_id': self.tax_account_1.id}, + ]) + + receivable_lines2 = (payment_move + end_move).line_ids\ + .filtered(lambda line: line.account_id == self.extra_receivable_account_1) + res = receivable_lines2.reconcile() + + self.assertFullReconcile(res['full_reconcile'], receivable_lines + receivable_lines2) + + exchange_diff = res['full_reconcile'].exchange_move_id + exchange_diff_lines = exchange_diff.line_ids\ + .filtered(lambda line: line.account_id == self.cash_basis_transfer_account)\ + .sorted(lambda line: (line.account_id, line.debit, line.credit)) + + self.assertRecordValues(exchange_diff_lines, [ + { + 'debit': 0.0, + 'credit': 16.67, + 'amount_currency': 0.0, + 'currency_id': currency_id, + 'account_id': self.cash_basis_transfer_account.id, + }, + ]) + + self.assertAmountsGroupByAccount([ + # Account Balance Amount Currency + (self.cash_basis_transfer_account, 0.0, 0.0), + (self.tax_account_1, -33.33, -100.0), + ]) + + def test_reconcile_cash_basis_exchange_difference_transfer_account_check_entries_2(self): + ''' Test the generation of the exchange difference for a tax cash basis journal entry when the transfer + account is not a reconcile one. + ''' + currency_id = self.setup_multi_currency_data(default_values={ + 'name': 'bitcoin', + 'symbol': 'bc', + 'currency_unit_label': 'Bitcoin', + 'currency_subunit_label': 'Tiny bitcoin', + }, rate2016=0.5, rate2017=0.66666666666666)['currency'].id + + # Rate 2/1 in 2016. + caba_inv = self.env['account.move'].create({ + 'move_type': 'entry', + 'date': '2016-01-01', + 'line_ids': [ + # Base Tax line + (0, 0, { + 'debit': 0.0, + 'credit': 200.0, + 'amount_currency': -100.0, + 'currency_id': currency_id, + 'account_id': self.company_data['default_account_revenue'].id, + 'tax_ids': [(6, 0, self.cash_basis_tax_a_third_amount.ids)], + 'tax_exigible': False, + }), + + # Tax line + (0, 0, { + 'debit': 0.0, + 'credit': 20.0, + 'amount_currency': -10.0, + 'currency_id': currency_id, + 'account_id': self.cash_basis_transfer_account.id, + 'tax_repartition_line_id': self.cash_basis_tax_a_third_amount.invoice_repartition_line_ids.filtered(lambda line: line.repartition_type == 'tax').id, + 'tax_exigible': False, + }), + + # Receivable lines + (0, 0, { + 'debit': 220.0, + 'credit': 0.0, + 'amount_currency': 110.0, + 'currency_id': currency_id, + 'account_id': self.extra_receivable_account_1.id, + }), + ] + }) + caba_inv.action_post() + + # Rate 3/2 in 2017. Full payment of 110 in foreign currency + pmt_wizard = self.env['account.payment.register'].with_context(active_model='account.move', active_ids=caba_inv.ids).create({ + 'payment_date': '2017-01-01', + 'journal_id': self.company_data['default_journal_bank'].id, + 'payment_method_id': self.env.ref('account.account_payment_method_manual_in').id, + }) + pmt_wizard._create_payments() + partial_rec = caba_inv.mapped('line_ids.matched_credit_ids') + caba_move = self.env['account.move'].search([('tax_cash_basis_rec_id', 'in', partial_rec.ids)]) + + self.assertRecordValues(caba_move.line_ids, [ + {'account_id': self.cash_basis_base_account.id, 'debit': 150.0, 'credit': 0.0, 'amount_currency': 100.0, 'tax_ids': [], 'tax_line_id': False}, + {'account_id': self.cash_basis_base_account.id, 'debit': 0.0, 'credit': 150.0, 'amount_currency': -100.0, 'tax_ids': self.cash_basis_tax_a_third_amount.ids, 'tax_line_id': False}, + {'account_id': self.cash_basis_transfer_account.id, 'debit': 15.0, 'credit': 0.0, 'amount_currency': 10.0, 'tax_ids': [], 'tax_line_id': False}, + {'account_id': self.tax_account_1.id, 'debit': 0.0, 'credit': 15.0, 'amount_currency': -10.0, 'tax_ids': [], 'tax_line_id': self.cash_basis_tax_a_third_amount.id}, + ]) + + receivable_line = caba_inv.line_ids.filtered(lambda x: x.account_id.internal_type == 'receivable') + self.assertTrue(receivable_line.full_reconcile_id, "Invoice should be fully paid") + + exchange_move = receivable_line.full_reconcile_id.exchange_move_id + self.assertTrue(exchange_move, "There should be an exchange difference move created") + self.assertRecordValues(exchange_move.line_ids, [ + {'account_id': receivable_line.account_id.id, 'debit': 0.0, 'credit': 55.0, 'amount_currency': 0.0, 'tax_ids': [], 'tax_line_id': False}, + {'account_id': caba_move.company_id.expense_currency_exchange_account_id.id, 'debit': 55.0, 'credit': 0.0, 'amount_currency': 0.0, 'tax_ids': [], 'tax_line_id': False}, + {'account_id': self.cash_basis_base_account.id, 'debit': 0.0, 'credit': 50.0, 'amount_currency': 0.0, 'tax_ids': self.cash_basis_tax_a_third_amount.ids, 'tax_line_id': False}, + {'account_id': self.cash_basis_base_account.id, 'debit': 50.0, 'credit': 0.0, 'amount_currency': 0.0, 'tax_ids': [], 'tax_line_id': False}, + {'account_id': self.tax_account_1.id, 'debit': 0.0, 'credit': 5.0, 'amount_currency': 0.0, 'tax_ids': [], 'tax_line_id': self.cash_basis_tax_a_third_amount.id}, + {'account_id': self.cash_basis_transfer_account.id, 'debit': 5.0, 'credit': 0.0, 'amount_currency': 0.0, 'tax_ids': [], 'tax_line_id': False}, + ]) + + self.assertAmountsGroupByAccount([ + # Account Balance Amount Currency + (self.cash_basis_transfer_account, 0.0, 0.0), + (self.tax_account_1, -20.0, -10.0), + ]) + + def test_reconcile_cash_basis_exchange_difference_transfer_account_check_entries_3(self): + ''' Test the generation of the exchange difference for a tax cash basis journal entry when the transfer + account is not a reconcile one. + ''' + currency_id = self.setup_multi_currency_data(default_values={ + 'name': 'bitcoin', + 'symbol': 'bc', + 'currency_unit_label': 'Bitcoin', + 'currency_subunit_label': 'Tiny bitcoin', + 'rounding': 0.01, + }, rate2016=0.5, rate2017=0.66666666666666)['currency'].id + + # Rate 2/1 in 2016. + caba_inv = self.env['account.move'].create({ + 'move_type': 'entry', + 'date': '2016-01-01', + 'line_ids': [ + # Base Tax line + (0, 0, { + 'debit': 0.0, + 'credit': 200.0, + 'amount_currency': -100.0, + 'currency_id': currency_id, + 'account_id': self.company_data['default_account_revenue'].id, + 'tax_ids': [(6, 0, self.cash_basis_tax_a_third_amount.ids)], + 'tax_exigible': False, + }), + + # Tax line + (0, 0, { + 'debit': 0.0, + 'credit': 20.0, + 'amount_currency': -10.0, + 'currency_id': currency_id, + 'account_id': self.cash_basis_transfer_account.id, + 'tax_repartition_line_id': self.cash_basis_tax_a_third_amount.invoice_repartition_line_ids.filtered(lambda line: line.repartition_type == 'tax').id, + 'tax_exigible': False, + }), + + # Receivable lines + (0, 0, { + 'debit': 220.0, + 'credit': 0.0, + 'amount_currency': 110.0, + 'currency_id': currency_id, + 'account_id': self.extra_receivable_account_1.id, + }), + ] + }) + caba_inv.action_post() + + # Rate 3/2 in 2017. Full payment of 220 in company currency + pmt_wizard = self.env['account.payment.register'].with_context(active_model='account.move', active_ids=caba_inv.ids).create({ + 'payment_date': '2017-01-01', + 'journal_id': self.company_data['default_journal_bank'].id, + 'payment_method_id': self.env.ref('account.account_payment_method_manual_in').id, + 'currency_id': self.company_data['currency'].id, + 'amount': 220.0, + }) + pmt_wizard._create_payments() + + caba_move = self.env['account.move'].search([('tax_cash_basis_rec_id', 'in', caba_inv.line_ids.matched_credit_ids.ids)]) + self.assertRecordValues(caba_move.line_ids, [ + {'account_id': self.cash_basis_base_account.id, 'debit': 200.01, 'credit': 0.0, 'amount_currency': 133.34, 'tax_ids': [], 'tax_line_id': False}, + {'account_id': self.cash_basis_base_account.id, 'debit': 0.0, 'credit': 200.01, 'amount_currency': -133.34, 'tax_ids': self.cash_basis_tax_a_third_amount.ids, 'tax_line_id': False}, + {'account_id': self.cash_basis_transfer_account.id, 'debit': 20.0, 'credit': 0.0, 'amount_currency': 13.33, 'tax_ids': [], 'tax_line_id': False}, + {'account_id': self.tax_account_1.id, 'debit': 0.0, 'credit': 20.0, 'amount_currency': -13.33, 'tax_ids': [], 'tax_line_id': self.cash_basis_tax_a_third_amount.id}, + ]) + + receivable_line = caba_inv.line_ids.filtered(lambda x: x.account_id.internal_type == 'receivable') + self.assertTrue(receivable_line.full_reconcile_id, "Invoice should be fully paid") + + exchange_move = receivable_line.full_reconcile_id.exchange_move_id + self.assertRecordValues(exchange_move.line_ids, [ + {'account_id': self.extra_receivable_account_1.id, 'debit': 0.0, 'credit': 0.0, 'amount_currency': 36.67, 'tax_ids': [], 'tax_line_id': False}, + {'account_id': caba_move.company_id.income_currency_exchange_account_id.id, 'debit': 0.0, 'credit': 0.0, 'amount_currency': -36.67, 'tax_ids': [], 'tax_line_id': False}, + {'account_id': self.cash_basis_base_account.id, 'debit': 0.01, 'credit': 0.0, 'amount_currency': 0.0, 'tax_ids': self.cash_basis_tax_a_third_amount.ids, 'tax_line_id': False}, + {'account_id': self.cash_basis_base_account.id, 'debit': 0.0, 'credit': 0.01, 'amount_currency': 0.0, 'tax_ids': [], 'tax_line_id': False}, + ]) + + self.assertAmountsGroupByAccount([ + # Account Balance Amount Currency + (self.cash_basis_transfer_account, 0.0, 3.33), + (self.tax_account_1, -20.0, -13.33), + ]) + + def test_reconcile_cash_basis_revert(self): + ''' Ensure the cash basis journal entry can be reverted. ''' + self.cash_basis_transfer_account.reconcile = True + + invoice_move = self.env['account.move'].create({ + 'move_type': 'entry', + 'date': '2016-01-01', + 'line_ids': [ + # Base Tax line + (0, 0, { + 'debit': 0.0, + 'credit': 100.0, + 'account_id': self.company_data['default_account_revenue'].id, + 'tax_ids': [(6, 0, self.cash_basis_tax_a_third_amount.ids)], + 'tax_exigible': False, + }), + + # Tax line + (0, 0, { + 'debit': 0.0, + 'credit': 33.33, + 'account_id': self.cash_basis_transfer_account.id, + 'tax_repartition_line_id': self.cash_basis_tax_a_third_amount.invoice_repartition_line_ids.filtered(lambda line: line.repartition_type == 'tax').id, + 'tax_exigible': False, + }), + + # Receivable line + (0, 0, { + 'debit': 133.33, + 'credit': 0.0, + 'account_id': self.extra_receivable_account_1.id, + }), + ] + }) + + payment_move = self.env['account.move'].create({ + 'move_type': 'entry', + 'date': '2016-01-01', + 'line_ids': [ + (0, 0, {'debit': 0.0, 'credit': 133.33, 'account_id': self.extra_receivable_account_1.id}), + (0, 0, {'debit': 133.33, 'credit': 0.0, 'account_id': self.company_data['default_account_revenue'].id}), + ] + }) + + (invoice_move + payment_move).action_post() + + receivable_lines = (invoice_move + payment_move).line_ids\ + .filtered(lambda line: line.account_id == self.extra_receivable_account_1) + res = receivable_lines.reconcile() + + # == Check reconciliation of invoice with payment == + + self.assertFullReconcile(res['full_reconcile'], receivable_lines) + self.assertEqual(len(res.get('tax_cash_basis_moves', [])), 1) + + # == Check the reconciliation of invoice with tax cash basis journal entry. + # /!\ We make the assumption the tax cash basis journal entry is well created. + + tax_cash_basis_move = res['tax_cash_basis_moves'] + + taxes_lines = (invoice_move.line_ids + tax_cash_basis_move.line_ids.filtered('debit'))\ + .filtered(lambda line: line.account_id == self.cash_basis_transfer_account) + taxes_full_reconcile = taxes_lines.matched_debit_ids.full_reconcile_id + + self.assertTrue(taxes_full_reconcile) + self.assertFullReconcile(taxes_full_reconcile, taxes_lines) + + # == Check the reconciliation after the reverse == + + tax_cash_basis_move_reverse = tax_cash_basis_move._reverse_moves(cancel=True) + + self.assertFullReconcile(res['full_reconcile'], receivable_lines) + + # == Check the reconciliation of the tax cash basis journal entry with its reverse == + + reversed_taxes_lines = (tax_cash_basis_move + tax_cash_basis_move_reverse).line_ids\ + .filtered(lambda line: line.account_id == self.cash_basis_transfer_account) + + reversed_taxes_full_reconcile = reversed_taxes_lines.matched_debit_ids.full_reconcile_id + + self.assertTrue(reversed_taxes_full_reconcile) + self.assertFullReconcile(reversed_taxes_full_reconcile, reversed_taxes_lines) + + def test_reconcile_cash_basis_tax_grid_refund(self): + invoice_move = self.env['account.move'].create({ + 'move_type': 'entry', + 'date': '2016-01-01', + 'line_ids': [ + # Base Tax line + (0, 0, { + 'debit': 0.0, + 'credit': 100.0, + 'account_id': self.company_data['default_account_revenue'].id, + 'tax_ids': [(6, 0, self.cash_basis_tax_a_third_amount.ids)], + 'tax_tag_ids': [(6, 0, self.tax_tags[0].ids)], + 'tax_exigible': False, + }), + + # Tax line + (0, 0, { + 'debit': 0.0, + 'credit': 33.33, + 'account_id': self.cash_basis_transfer_account.id, + 'tax_repartition_line_id': self.cash_basis_tax_a_third_amount.invoice_repartition_line_ids.filtered(lambda line: line.repartition_type == 'tax').id, + 'tax_tag_ids': [(6, 0, self.tax_tags[1].ids)], + 'tax_exigible': False, + }), + + # Receivable line + (0, 0, { + 'debit': 133.33, + 'credit': 0.0, + 'account_id': self.extra_receivable_account_1.id, + }), + ] + }) + + refund_move = self.env['account.move'].create({ + 'move_type': 'out_refund', + 'partner_id': self.partner_a.id, + 'invoice_date': '2016-01-01', + 'date': '2016-01-01', + 'line_ids': [ + # Base Tax line + (0, 0, { + 'debit': 100.0, + 'credit': 0.0, + 'account_id': self.company_data['default_account_revenue'].id, + 'tax_ids': [(6, 0, self.cash_basis_tax_a_third_amount.ids)], + 'tax_tag_ids': [(6, 0, self.tax_tags[2].ids)], + 'tax_exigible': False, + }), + + # Tax line + (0, 0, { + 'debit': 33.33, + 'credit': 0.0, + 'account_id': self.cash_basis_transfer_account.id, + 'tax_repartition_line_id': self.cash_basis_tax_a_third_amount.invoice_repartition_line_ids.filtered(lambda line: line.repartition_type == 'tax').id, + 'tax_tag_ids': [(6, 0, self.tax_tags[3].ids)], + 'tax_exigible': False, + }), + + # Receivable line + (0, 0, { + 'debit': 0.0, + 'credit': 133.33, + 'account_id': self.extra_receivable_account_1.id, + }), + ] + }) + + (invoice_move + refund_move).action_post() + + receivable_lines = (invoice_move + refund_move).line_ids\ + .filtered(lambda line: line.account_id == self.extra_receivable_account_1) + res = receivable_lines.reconcile() + + self.assertFullReconcile(res['full_reconcile'], receivable_lines) + self.assertEqual(len(res.get('tax_cash_basis_moves', [])), 2) + + tax_cash_basis_moves = res['tax_cash_basis_moves'].sorted(lambda move: move.tax_cash_basis_move_id.id) + + # Invoice: + cb_lines = tax_cash_basis_moves[0].line_ids.sorted(lambda line: (-abs(line.balance), -line.debit, line.account_id)) + self.assertRecordValues(cb_lines, [ + # Base amount: + {'debit': 100.0, 'credit': 0.0, 'tax_tag_ids': [], 'account_id': self.cash_basis_base_account.id}, + {'debit': 0.0, 'credit': 100.0, 'tax_tag_ids': self.tax_tags[0].ids, 'account_id': self.cash_basis_base_account.id}, + # tax: + {'debit': 33.33, 'credit': 0.0, 'tax_tag_ids': [], 'account_id': self.cash_basis_transfer_account.id}, + {'debit': 0.0, 'credit': 33.33, 'tax_tag_ids': self.tax_tags[1].ids, 'account_id': self.tax_account_1.id}, + ]) + + # Refund: + cb_lines = tax_cash_basis_moves[1].line_ids.sorted(lambda line: (-abs(line.balance), -line.debit, line.account_id)) + self.assertRecordValues(cb_lines, [ + # Base amount: + {'debit': 100.0, 'credit': 0.0, 'tax_tag_ids': self.tax_tags[2].ids, 'account_id': self.cash_basis_base_account.id}, + {'debit': 0.0, 'credit': 100.0, 'tax_tag_ids': [], 'account_id': self.cash_basis_base_account.id}, + # tax: + {'debit': 33.33, 'credit': 0.0, 'tax_tag_ids': self.tax_tags[3].ids, 'account_id': self.tax_account_1.id}, + {'debit': 0.0, 'credit': 33.33, 'tax_tag_ids': [], 'account_id': self.cash_basis_transfer_account.id}, + ]) + + self.assertTaxGridAmounts([ + # Tag Balance + (self.tax_tags[0], -100.0), + (self.tax_tags[1], -33.33), + (self.tax_tags[2], 100.0), + (self.tax_tags[3], 33.33), + ]) + + def test_reconcile_cash_basis_tax_grid_multi_taxes(self): + ''' Test the tax grid when reconciling an invoice with multiple taxes/tax repartition. ''' + base_taxes = self.cash_basis_tax_a_third_amount + self.cash_basis_tax_tiny_amount + base_tags = self.tax_tags[0] + self.tax_tags[4] + + # An invoice with 2 taxes: + invoice_move = self.env['account.move'].create({ + 'move_type': 'entry', + 'date': '2016-01-01', + 'line_ids': [ + # Base Tax line + (0, 0, { + 'debit': 0.0, + 'credit': 100.0, + 'account_id': self.company_data['default_account_revenue'].id, + 'tax_ids': [(6, 0, base_taxes.ids)], + 'tax_tag_ids': [(6, 0, base_tags.ids)], + 'tax_exigible': False, + }), + + # Tax lines + (0, 0, { + 'debit': 0.0, + 'credit': 33.33, + 'account_id': self.cash_basis_transfer_account.id, + 'tax_repartition_line_id': self.cash_basis_tax_a_third_amount.invoice_repartition_line_ids.filtered(lambda line: line.repartition_type == 'tax').id, + 'tax_tag_ids': [(6, 0, self.tax_tags[1].ids)], + 'tax_exigible': False, + }), + (0, 0, { + 'debit': 0.0, + 'credit': 0.01, + 'account_id': self.cash_basis_transfer_account.id, + 'tax_repartition_line_id': self.cash_basis_tax_tiny_amount.invoice_repartition_line_ids.filtered(lambda line: line.repartition_type == 'tax').id, + 'tax_tag_ids': [(6, 0, self.tax_tags[5].ids)], + 'tax_exigible': False, + }), + + # Receivable lines + (0, 0, { + 'debit': 133.34, + 'credit': 0.0, + 'account_id': self.extra_receivable_account_1.id, + }), + ] + }) + + # A payment paying the full invoice amount. + payment_move = self.env['account.move'].create({ + 'move_type': 'entry', + 'date': '2017-01-01', + 'line_ids': [ + (0, 0, {'debit': 0.0, 'credit': 133.34, 'account_id': self.extra_receivable_account_1.id}), + (0, 0, {'debit': 133.34, 'credit': 0.0, 'account_id': self.company_data['default_account_revenue'].id}), + ] + }) + + (invoice_move + payment_move).action_post() + + receivable_lines = (invoice_move + payment_move).line_ids\ + .filtered(lambda line: line.account_id == self.extra_receivable_account_1) + res = receivable_lines.reconcile() + + self.assertFullReconcile(res['full_reconcile'], receivable_lines) + self.assertEqual(len(res.get('tax_cash_basis_moves', [])), 1) + + self.assertRecordValues(res['tax_cash_basis_moves'].line_ids, [ + # Base amount x 2 because there is two taxes: + {'debit': 100.0, 'credit': 0.0, 'tax_ids': [], 'tax_tag_ids': [], 'account_id': self.cash_basis_base_account.id}, + {'debit': 0.0, 'credit': 100.0, 'tax_ids': base_taxes.ids, 'tax_tag_ids': base_tags.ids, 'account_id': self.cash_basis_base_account.id}, + # tax_1: + {'debit': 33.33, 'credit': 0.0, 'tax_ids': [], 'tax_tag_ids': [], 'account_id': self.cash_basis_transfer_account.id}, + {'debit': 0.0, 'credit': 33.33, 'tax_ids': [], 'tax_tag_ids': self.tax_tags[1].ids, 'account_id': self.tax_account_1.id}, + # tax_2: + {'debit': 0.01, 'credit': 0.0, 'tax_ids': [], 'tax_tag_ids': [], 'account_id': self.cash_basis_transfer_account.id}, + {'debit': 0.0, 'credit': 0.01, 'tax_ids': [], 'tax_tag_ids': self.tax_tags[5].ids, 'account_id': self.tax_account_2.id}, + ]) + + self.assertTaxGridAmounts([ + # Tag Balance + (self.tax_tags[0], -100.0), + (self.tax_tags[1], -33.33), + (self.tax_tags[4], -100.0), + (self.tax_tags[5], -0.01), + ]) + + def test_caba_mix_reconciliation(self): + """ Test the reconciliation of tax lines (when using a reconcilable tax account) + for cases mixing taxes exigible on payment and on invoices. + """ + + # Make the tax account reconcilable + self.tax_account_1.reconcile = True + + # Create a tax using the same accounts as the CABA one + non_caba_tax = self.env['account.tax'].create({ + 'name': 'tax 20%', + 'type_tax_use': 'purchase', + 'company_id': self.company_data['company'].id, + 'amount': 20, + 'tax_exigibility': 'on_invoice', + 'invoice_repartition_line_ids': [ + (0,0, { + 'factor_percent': 100, + 'repartition_type': 'base', + }), + + (0,0, { + 'factor_percent': 100, + 'repartition_type': 'tax', + 'account_id': self.tax_account_1.id, + }), + ], + 'refund_repartition_line_ids': [ + (0,0, { + 'factor_percent': 100, + 'repartition_type': 'base', + }), + + (0,0, { + 'factor_percent': 100, + 'repartition_type': 'tax', + 'account_id': self.tax_account_1.id, + }), + ], + }) + + # Create an invoice with a non-CABA tax + non_caba_inv = self.init_invoice('in_invoice', amounts=[1000], post=True, taxes=non_caba_tax) + + # Create an invoice with a CABA tax using the same tax account and pay it + caba_inv = self.init_invoice('in_invoice', amounts=[300], post=True, taxes=self.cash_basis_tax_a_third_amount) + + pmt_wizard = self.env['account.payment.register'].with_context(active_model='account.move', active_ids=caba_inv.ids).create({ + 'payment_date': caba_inv.date, + 'journal_id': self.company_data['default_journal_bank'].id, + 'payment_method_id': self.env.ref('account.account_payment_method_manual_in').id, + }) + pmt_wizard._create_payments() + + partial_rec = caba_inv.mapped('line_ids.matched_debit_ids') + caba_move = self.env['account.move'].search([('tax_cash_basis_rec_id', '=', partial_rec.id)]) + + # Create a misc operation with a line on the tax account, for full reconcile of those tax lines + misc_move = self.env['account.move'].create({ + 'name': "Misc move", + 'journal_id': self.company_data['default_journal_misc'].id, + 'line_ids': [ + (0, 0, { + 'name': 'line 1', + 'account_id': self.tax_account_1.id, + 'credit': 300, + }), + (0, 0, { + 'name': 'line 2', + 'account_id': self.company_data['default_account_expense'].id, # Whatever the account here + 'debit': 300, + }) + ], + }) + + misc_move.action_post() + + lines_to_reconcile = (misc_move + caba_move + non_caba_inv).mapped('line_ids').filtered(lambda x: x.account_id == self.tax_account_1) + lines_to_reconcile.reconcile() + + # Check full reconciliation + self.assertTrue(all(line.full_reconcile_id for line in lines_to_reconcile), "All tax lines should be fully reconciled") + + def test_caba_double_tax(self): + """ Test the CABA entries generated from an invoice with almost + equal lines, different only on analytic accounting + """ + # Make the tax account reconcilable + self.tax_account_1.reconcile = True + + # Create an invoice with a CABA tax using 'Include in analytic cost' + move_form = Form(self.env['account.move'].with_context(default_move_type='in_invoice', account_predictive_bills_disable_prediction=True)) + move_form.invoice_date = fields.Date.from_string('2019-01-01') + move_form.partner_id = self.partner_a + self.cash_basis_tax_a_third_amount.analytic = True + test_analytic_account = self.env['account.analytic.account'].create({'name': 'test_analytic_account'}) + + tax = self.cash_basis_tax_a_third_amount + + # line with analytic account, will generate 2 lines in CABA move + with move_form.invoice_line_ids.new() as line_form: + line_form.name = "test line with analytic account" + line_form.product_id = self.product_a + line_form.tax_ids.clear() + line_form.tax_ids.add(tax) + line_form.analytic_account_id = test_analytic_account + line_form.price_unit = 100 + + # line with analytic account, will generate other 2 lines in CABA move + # even if the tax is the same + with move_form.invoice_line_ids.new() as line_form: + line_form.name = "test line" + line_form.product_id = self.product_a + line_form.tax_ids.clear() + line_form.tax_ids.add(tax) + line_form.price_unit = 100 + + rslt = move_form.save() + rslt.action_post() + + pmt_wizard = self.env['account.payment.register'].with_context(active_model='account.move', active_ids=rslt.ids).create({ + 'amount': rslt.amount_total, + 'payment_date': rslt.date, + 'journal_id': self.company_data['default_journal_bank'].id, + 'payment_method_id': self.env.ref('account.account_payment_method_manual_in').id, + }) + pmt_wizard._create_payments() + + partial_rec = rslt.mapped('line_ids.matched_debit_ids') + caba_move = self.env['account.move'].search([('tax_cash_basis_rec_id', '=', partial_rec.id)]) + self.assertEqual(len(caba_move.line_ids), 4, "All lines should be there") + self.assertEqual(caba_move.line_ids.filtered(lambda x: x.tax_line_id).balance, 66.66, "Tax amount should take into account both lines") + + def test_caba_double_tax_negative_line(self): + """ Tests making a cash basis invoice with 2 lines using the same tax: a positive and a negative one. + """ + invoice = self.init_invoice('in_invoice', amounts=[300, -60], post=True, taxes=self.cash_basis_tax_a_third_amount) + + pmt_wizard = self.env['account.payment.register'].with_context(active_model='account.move', active_ids=invoice.ids).create({ + 'amount': 320, + 'payment_date': invoice.date, + 'journal_id': self.company_data['default_journal_bank'].id, + 'payment_method_id': self.env.ref('account.account_payment_method_manual_in').id, + }) + + pmt_wizard._create_payments() + + partial_rec = invoice.mapped('line_ids.matched_debit_ids') + caba_move = self.env['account.move'].search([('tax_cash_basis_rec_id', '=', partial_rec.id)]) + + self.assertRecordValues(caba_move.line_ids.sorted(lambda line: (-abs(line.balance), -line.debit, line.account_id)), [ + # Base amount: + {'debit': 240.0, 'credit': 0.0, 'tax_tag_ids': self.tax_tags[0].ids, 'account_id': self.cash_basis_base_account.id}, + {'debit': 0.0, 'credit': 240.0, 'tax_tag_ids': [], 'account_id': self.cash_basis_base_account.id}, + # tax: + {'debit': 80.0, 'credit': 0.0, 'tax_tag_ids': self.tax_tags[1].ids, 'account_id': self.tax_account_1.id}, + {'debit': 0.0, 'credit': 80.0, 'tax_tag_ids': [], 'account_id': self.cash_basis_transfer_account.id}, + ]) + + def test_caba_dest_acc_reconciliation_partial_pmt(self): + """ Test the reconciliation of tax lines (when using a reconcilable tax account) + for partially paid invoices with cash basis taxes. + This test is especially useful to check the implementation of the use case tested by + test_reconciliation_cash_basis_foreign_currency_low_values does not have unwanted side effects. + """ + + # Make the tax account reconcilable + self.tax_account_1.reconcile = True + + # Create an invoice with a CABA tax using the same tax account and pay half of it + caba_inv = self.init_invoice('in_invoice', amounts=[900], post=True, taxes=self.cash_basis_tax_a_third_amount) + + pmt_wizard = self.env['account.payment.register'].with_context(active_model='account.move', active_ids=caba_inv.ids).create({ + 'amount': 600, + 'payment_date': caba_inv.date, + 'journal_id': self.company_data['default_journal_bank'].id, + 'payment_method_id': self.env.ref('account.account_payment_method_manual_in').id, + }) + pmt_wizard._create_payments() + + partial_rec = caba_inv.mapped('line_ids.matched_debit_ids') + caba_move = self.env['account.move'].search([('tax_cash_basis_rec_id', '=', partial_rec.id)]) + + # Create a misc operation with a line on the tax account, for full reconcile with the tax line + misc_move = self.env['account.move'].create({ + 'name': "Misc move", + 'journal_id': self.company_data['default_journal_misc'].id, + 'line_ids': [ + (0, 0, { + 'name': 'line 1', + 'account_id': self.tax_account_1.id, + 'credit': 150, + }), + (0, 0, { + 'name': 'line 2', + 'account_id': self.company_data['default_account_expense'].id, # Whatever the account here + 'debit': 150, + }) + ], + }) + + misc_move.action_post() + + lines_to_reconcile = (misc_move + caba_move).mapped('line_ids').filtered(lambda x: x.account_id == self.tax_account_1) + lines_to_reconcile.reconcile() + + # Check full reconciliation + self.assertTrue(all(line.full_reconcile_id for line in lines_to_reconcile), "All tax lines should be fully reconciled") + + def test_caba_undo_reconciliation(self): + ''' Make sure there is no traceback like "Record has already been deleted" during the deletion of partials. ''' + self.cash_basis_transfer_account.reconcile = True + + bill = self.env['account.move'].create({ + 'move_type': 'in_invoice', + 'partner_id': self.partner_a.id, + 'invoice_date': '2019-01-01', + 'date': '2019-01-01', + 'invoice_line_ids': [(0, 0, { + 'name': 'line', + 'account_id': self.company_data['default_account_expense'].id, + 'price_unit': 1000.0, + 'tax_ids': [(6, 0, self.cash_basis_tax_a_third_amount.ids)], + })], + }) + bill.action_post() + + # Register a payment creating the CABA journal entry on the fly and reconcile it with the tax line. + self.env['account.payment.register']\ + .with_context(active_ids=bill.ids, active_model='account.move')\ + .create({})\ + ._create_payments() + + bill.button_draft() diff --git a/addons/account/tests/test_account_move_rounding.py b/addons/account/tests/test_account_move_rounding.py new file mode 100644 index 00000000..92f76b7c --- /dev/null +++ b/addons/account/tests/test_account_move_rounding.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- + +from odoo.addons.account.tests.common import AccountTestInvoicingCommon +from odoo.tests import tagged + + +@tagged('post_install', '-at_install') +class TestAccountMoveRounding(AccountTestInvoicingCommon): + + def test_move_line_rounding(self): + """Whatever arguments we give to the creation of an account move, + in every case the amounts should be properly rounded to the currency's precision. + In other words, we don't fall victim of the limitation introduced by 9d87d15db6dd40 + + Here the rounding should be done according to company_currency_id, which is a related + on move_id.company_id.currency_id. + In principle, it should not be necessary to add it to the create values, + since it is supposed to be computed by the ORM... + """ + move = self.env['account.move'].create({ + 'line_ids': [ + (0, 0, {'debit': 100.0 / 3, 'account_id': self.company_data['default_account_revenue'].id}), + (0, 0, {'credit': 100.0 / 3, 'account_id': self.company_data['default_account_revenue'].id}), + ], + }) + + self.assertEqual( + [(33.33, 0.0), (0.0, 33.33)], + move.line_ids.mapped(lambda x: (x.debit, x.credit)), + "Quantities should have been rounded according to the currency." + ) diff --git a/addons/account/tests/test_account_onboarding.py b/addons/account/tests/test_account_onboarding.py new file mode 100644 index 00000000..c2b9c472 --- /dev/null +++ b/addons/account/tests/test_account_onboarding.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from odoo.addons.account.tests.common import AccountTestInvoicingHttpCommon +from odoo.tests.common import tagged + + +@tagged('post_install', '-at_install') +class TestTourRenderInvoiceReport(AccountTestInvoicingHttpCommon): + + @classmethod + def setUpClass(cls, chart_template_ref=None): + super().setUpClass(chart_template_ref=chart_template_ref) + + cls.env.user.write({ + 'groups_id': [ + (6, 0, (cls.env.ref('account.group_account_invoice') + cls.env.ref('base.group_system')).ids), + ], + }) + + cls.out_invoice = cls.env['account.move'].create({ + 'move_type': 'out_invoice', + 'partner_id': cls.partner_a.id, + 'invoice_date': '2019-05-01', + 'date': '2019-05-01', + 'invoice_line_ids': [ + (0, 0, {'name': 'line1', 'price_unit': 100.0}), + ], + }) + cls.out_invoice.action_post() + + report_layout = cls.env.ref('web.report_layout_standard') + + cls.company_data['company'].write({ + 'primary_color': '#123456', + 'secondary_color': '#789101', + 'external_report_layout_id': report_layout.view_id.id, + }) + + cls.env.ref('account.account_invoices_without_payment').report_type = 'qweb-html' + + def test_render_account_document_layout(self): + self.start_tour('/web', 'account_render_report', login=self.env.user.login, timeout=200) diff --git a/addons/account/tests/test_account_payment.py b/addons/account/tests/test_account_payment.py new file mode 100644 index 00000000..b2a0df04 --- /dev/null +++ b/addons/account/tests/test_account_payment.py @@ -0,0 +1,709 @@ +# -*- coding: utf-8 -*- +from odoo.addons.account.tests.common import AccountTestInvoicingCommon +from odoo.tests import tagged, new_test_user +from odoo.tests.common import Form + + +@tagged('post_install', '-at_install') +class TestAccountPayment(AccountTestInvoicingCommon): + + @classmethod + def setUpClass(cls, chart_template_ref=None): + super().setUpClass(chart_template_ref=chart_template_ref) + + cls.payment_debit_account_id = cls.copy_account(cls.company_data['default_journal_bank'].payment_debit_account_id) + cls.payment_credit_account_id = cls.copy_account(cls.company_data['default_journal_bank'].payment_credit_account_id) + + cls.partner_bank_account = cls.env['res.partner.bank'].create({ + 'acc_number': 'BE32707171912447', + 'partner_id': cls.partner_a.id, + 'acc_type': 'bank', + }) + + cls.company_data['default_journal_bank'].write({ + 'payment_debit_account_id': cls.payment_debit_account_id.id, + 'payment_credit_account_id': cls.payment_credit_account_id.id, + 'inbound_payment_method_ids': [(6, 0, cls.env.ref('account.account_payment_method_manual_in').ids)], + 'outbound_payment_method_ids': [(6, 0, cls.env.ref('account.account_payment_method_manual_out').ids)], + }) + + cls.partner_a.write({ + 'bank_ids': [(6, 0, cls.partner_bank_account.ids)], + }) + + def test_payment_move_sync_create_write(self): + copy_receivable = self.copy_account(self.company_data['default_account_receivable']) + + payment = self.env['account.payment'].create({ + 'amount': 50.0, + 'payment_type': 'inbound', + 'partner_type': 'customer', + 'destination_account_id': copy_receivable.id, + }) + + expected_payment_values = { + 'amount': 50.0, + 'payment_type': 'inbound', + 'partner_type': 'customer', + 'payment_reference': False, + 'is_reconciled': False, + 'currency_id': self.company_data['currency'].id, + 'partner_id': False, + 'destination_account_id': copy_receivable.id, + 'payment_method_id': self.env.ref('account.account_payment_method_manual_in').id, + 'partner_bank_id': False, + } + expected_move_values = { + 'currency_id': self.company_data['currency'].id, + 'partner_id': False, + 'partner_bank_id': False, + } + expected_liquidity_line = { + 'debit': 50.0, + 'credit': 0.0, + 'amount_currency': 50.0, + 'currency_id': self.company_data['currency'].id, + 'account_id': self.payment_debit_account_id.id, + } + expected_counterpart_line = { + 'debit': 0.0, + 'credit': 50.0, + 'amount_currency': -50.0, + 'currency_id': self.company_data['currency'].id, + 'account_id': copy_receivable.id, + } + + self.assertRecordValues(payment, [expected_payment_values]) + self.assertRecordValues(payment.move_id, [expected_move_values]) + self.assertRecordValues(payment.line_ids.sorted('balance'), [ + expected_counterpart_line, + expected_liquidity_line, + ]) + + # ==== Check editing the account.payment ==== + + payment.write({ + 'partner_type': 'supplier', + 'currency_id': self.currency_data['currency'].id, + 'partner_id': self.partner_a.id, + }) + + self.assertRecordValues(payment, [{ + **expected_payment_values, + 'partner_type': 'supplier', + 'destination_account_id': self.partner_a.property_account_payable_id.id, + 'currency_id': self.currency_data['currency'].id, + 'partner_id': self.partner_a.id, + 'partner_bank_id': self.partner_bank_account.id, + }]) + self.assertRecordValues(payment.move_id, [{ + **expected_move_values, + 'currency_id': self.currency_data['currency'].id, + 'partner_id': self.partner_a.id, + 'partner_bank_id': self.partner_bank_account.id, + }]) + self.assertRecordValues(payment.line_ids.sorted('balance'), [ + { + **expected_counterpart_line, + 'debit': 0.0, + 'credit': 25.0, + 'amount_currency': -50.0, + 'currency_id': self.currency_data['currency'].id, + 'account_id': self.partner_a.property_account_payable_id.id, + }, + { + **expected_liquidity_line, + 'debit': 25.0, + 'credit': 0.0, + 'amount_currency': 50.0, + 'currency_id': self.currency_data['currency'].id, + }, + ]) + + # ==== Check editing the account.move.line ==== + + liquidity_lines, counterpart_lines, writeoff_lines = payment._seek_for_lines() + payment.move_id.write({ + 'partner_bank_id': False, + 'line_ids': [ + (1, counterpart_lines.id, { + 'debit': 0.0, + 'credit': 75.0, + 'amount_currency': -75.0, + 'currency_id': self.company_data['currency'].id, + 'account_id': copy_receivable.id, + 'partner_id': self.partner_b.id, + }), + (1, liquidity_lines.id, { + 'debit': 100.0, + 'credit': 0.0, + 'amount_currency': 100.0, + 'currency_id': self.company_data['currency'].id, + 'partner_id': self.partner_b.id, + }), + + # Additional write-off: + (0, 0, { + 'debit': 0.0, + 'credit': 25.0, + 'amount_currency': -25.0, + 'currency_id': self.company_data['currency'].id, + 'account_id': self.company_data['default_account_revenue'].id, + 'partner_id': self.partner_b.id, + }), + ] + }) + + self.assertRecordValues(payment, [{ + **expected_payment_values, + 'amount': 100.0, + 'partner_id': self.partner_b.id, + }]) + self.assertRecordValues(payment.move_id, [{ + **expected_move_values, + 'partner_id': self.partner_b.id, + }]) + self.assertRecordValues(payment.line_ids.sorted('balance'), [ + { + **expected_counterpart_line, + 'debit': 0.0, + 'credit': 75.0, + 'amount_currency': -75.0, + 'partner_id': self.partner_b.id, + }, + { + 'debit': 0.0, + 'credit': 25.0, + 'amount_currency': -25.0, + 'currency_id': self.company_data['currency'].id, + 'account_id': self.company_data['default_account_revenue'].id, + 'partner_id': self.partner_b.id, + }, + { + **expected_liquidity_line, + 'debit': 100.0, + 'credit': 0.0, + 'amount_currency': 100.0, + 'account_id': self.payment_debit_account_id.id, + 'partner_id': self.partner_b.id, + }, + ]) + + def test_payment_move_sync_onchange(self): + copy_receivable = self.copy_account(self.company_data['default_account_receivable']) + + pay_form = Form(self.env['account.payment'].with_context(default_journal_id=self.company_data['default_journal_bank'].id)) + pay_form.amount = 50.0 + pay_form.payment_type = 'inbound' + pay_form.partner_type = 'customer' + pay_form.destination_account_id = copy_receivable + payment = pay_form.save() + + expected_payment_values = { + 'amount': 50.0, + 'payment_type': 'inbound', + 'partner_type': 'customer', + 'payment_reference': False, + 'is_reconciled': False, + 'currency_id': self.company_data['currency'].id, + 'partner_id': False, + 'destination_account_id': copy_receivable.id, + 'payment_method_id': self.env.ref('account.account_payment_method_manual_in').id, + } + expected_move_values = { + 'currency_id': self.company_data['currency'].id, + 'partner_id': False, + } + expected_liquidity_line = { + 'debit': 50.0, + 'credit': 0.0, + 'amount_currency': 50.0, + 'currency_id': self.company_data['currency'].id, + 'account_id': self.payment_debit_account_id.id, + } + expected_counterpart_line = { + 'debit': 0.0, + 'credit': 50.0, + 'amount_currency': -50.0, + 'currency_id': self.company_data['currency'].id, + 'account_id': copy_receivable.id, + } + + self.assertRecordValues(payment, [expected_payment_values]) + self.assertRecordValues(payment.move_id, [expected_move_values]) + self.assertRecordValues(payment.line_ids.sorted('balance'), [ + expected_counterpart_line, + expected_liquidity_line, + ]) + + # ==== Check editing the account.payment ==== + + pay_form = Form(payment) + pay_form.partner_type = 'supplier' + pay_form.currency_id = self.currency_data['currency'] + pay_form.partner_id = self.partner_a + payment = pay_form.save() + + self.assertRecordValues(payment, [{ + **expected_payment_values, + 'partner_type': 'supplier', + 'destination_account_id': self.partner_a.property_account_payable_id.id, + 'currency_id': self.currency_data['currency'].id, + 'partner_id': self.partner_a.id, + }]) + self.assertRecordValues(payment.move_id, [{ + **expected_move_values, + 'currency_id': self.currency_data['currency'].id, + 'partner_id': self.partner_a.id, + }]) + self.assertRecordValues(payment.line_ids.sorted('balance'), [ + { + **expected_counterpart_line, + 'debit': 0.0, + 'credit': 25.0, + 'amount_currency': -50.0, + 'currency_id': self.currency_data['currency'].id, + 'account_id': self.partner_a.property_account_payable_id.id, + }, + { + **expected_liquidity_line, + 'debit': 25.0, + 'credit': 0.0, + 'amount_currency': 50.0, + 'currency_id': self.currency_data['currency'].id, + }, + ]) + + # ==== Check editing the account.move.line ==== + + move_form = Form(payment.move_id) + with move_form.line_ids.edit(0) as line_form: + line_form.currency_id = self.company_data['currency'] + line_form.amount_currency = 100.0 + line_form.partner_id = self.partner_b + with move_form.line_ids.edit(1) as line_form: + line_form.currency_id = self.company_data['currency'] + line_form.amount_currency = -75.0 + line_form.account_id = copy_receivable + line_form.partner_id = self.partner_b + with move_form.line_ids.new() as line_form: + line_form.currency_id = self.company_data['currency'] + line_form.amount_currency = -25.0 + line_form.account_id = self.company_data['default_account_revenue'] + line_form.partner_id = self.partner_b + move_form.save() + + self.assertRecordValues(payment, [{ + **expected_payment_values, + 'amount': 100.0, + 'partner_id': self.partner_b.id, + }]) + self.assertRecordValues(payment.line_ids.sorted('balance'), [ + { + **expected_counterpart_line, + 'debit': 0.0, + 'credit': 75.0, + 'amount_currency': -75.0, + 'partner_id': self.partner_b.id, + }, + { + 'debit': 0.0, + 'credit': 25.0, + 'amount_currency': -25.0, + 'currency_id': self.company_data['currency'].id, + 'account_id': self.company_data['default_account_revenue'].id, + 'partner_id': self.partner_b.id, + }, + { + **expected_liquidity_line, + 'debit': 100.0, + 'credit': 0.0, + 'amount_currency': 100.0, + 'account_id': self.payment_debit_account_id.id, + 'partner_id': self.partner_b.id, + }, + ]) + + def test_inbound_payment_sync_writeoff_debit_sign(self): + payment = self.env['account.payment'].create({ + 'amount': 100.0, + 'payment_type': 'inbound', + 'partner_type': 'customer', + }) + + # ==== Edit the account.move.line ==== + + liquidity_lines, counterpart_lines, writeoff_lines = payment._seek_for_lines() + payment.move_id.write({ + 'line_ids': [ + (1, liquidity_lines.id, {'debit': 100.0}), + (1, counterpart_lines.id, {'credit': 125.0}), + (0, 0, {'debit': 25.0, 'account_id': self.company_data['default_account_revenue'].id}), + ], + }) + + self.assertRecordValues(payment, [{ + 'payment_type': 'inbound', + 'partner_type': 'customer', + 'amount': 100.0, + }]) + + # ==== Edit the account.payment amount ==== + + payment.write({ + 'partner_type': 'supplier', + 'amount': 100.1, + 'destination_account_id': self.company_data['default_account_payable'].id, + }) + + self.assertRecordValues(payment.line_ids.sorted('balance'), [ + { + 'debit': 0.0, + 'credit': 125.1, + 'account_id': self.company_data['default_account_payable'].id, + }, + { + 'debit': 25.0, + 'credit': 0.0, + 'account_id': self.company_data['default_account_revenue'].id, + }, + { + 'debit': 100.1, + 'credit': 0.0, + 'account_id': self.payment_debit_account_id.id, + }, + ]) + + def test_inbound_payment_sync_writeoff_credit_sign(self): + payment = self.env['account.payment'].create({ + 'amount': 100.0, + 'payment_type': 'inbound', + 'partner_type': 'customer', + }) + + # ==== Edit the account.move.line ==== + + liquidity_lines, counterpart_lines, writeoff_lines = payment._seek_for_lines() + payment.move_id.write({ + 'line_ids': [ + (1, liquidity_lines.id, {'debit': 100.0}), + (1, counterpart_lines.id, {'credit': 75.0}), + (0, 0, {'credit': 25.0, 'account_id': self.company_data['default_account_revenue'].id}), + ], + }) + + self.assertRecordValues(payment, [{ + 'payment_type': 'inbound', + 'partner_type': 'customer', + 'amount': 100.0, + }]) + + # ==== Edit the account.payment amount ==== + + payment.write({ + 'partner_type': 'supplier', + 'amount': 100.1, + 'destination_account_id': self.company_data['default_account_payable'].id, + }) + + self.assertRecordValues(payment.line_ids.sorted('balance'), [ + { + 'debit': 0.0, + 'credit': 75.1, + 'account_id': self.company_data['default_account_payable'].id, + }, + { + 'debit': 0.0, + 'credit': 25.0, + 'account_id': self.company_data['default_account_revenue'].id, + }, + { + 'debit': 100.1, + 'credit': 0.0, + 'account_id': self.payment_debit_account_id.id, + }, + ]) + + def test_outbound_payment_sync_writeoff_debit_sign(self): + payment = self.env['account.payment'].create({ + 'amount': 100.0, + 'payment_type': 'outbound', + 'partner_type': 'supplier', + }) + + # ==== Edit the account.move.line ==== + + liquidity_lines, counterpart_lines, writeoff_lines = payment._seek_for_lines() + payment.move_id.write({ + 'line_ids': [ + (1, liquidity_lines.id, {'credit': 100.0}), + (1, counterpart_lines.id, {'debit': 75.0}), + (0, 0, {'debit': 25.0, 'account_id': self.company_data['default_account_revenue'].id}), + ], + }) + + self.assertRecordValues(payment, [{ + 'payment_type': 'outbound', + 'partner_type': 'supplier', + 'amount': 100.0, + }]) + + # ==== Edit the account.payment amount ==== + + payment.write({ + 'partner_type': 'customer', + 'amount': 100.1, + 'destination_account_id': self.company_data['default_account_receivable'].id, + }) + + self.assertRecordValues(payment.line_ids.sorted('balance'), [ + { + 'debit': 0.0, + 'credit': 100.1, + 'account_id': self.payment_credit_account_id.id, + }, + { + 'debit': 25.0, + 'credit': 0.0, + 'account_id': self.company_data['default_account_revenue'].id, + }, + { + 'debit': 75.1, + 'credit': 0.0, + 'account_id': self.company_data['default_account_receivable'].id, + }, + ]) + + def test_outbound_payment_sync_writeoff_credit_sign(self): + payment = self.env['account.payment'].create({ + 'amount': 100.0, + 'payment_type': 'outbound', + 'partner_type': 'supplier', + }) + + # ==== Edit the account.move.line ==== + + liquidity_lines, counterpart_lines, writeoff_lines = payment._seek_for_lines() + payment.move_id.write({ + 'line_ids': [ + (1, liquidity_lines.id, {'credit': 100.0}), + (1, counterpart_lines.id, {'debit': 125.0}), + (0, 0, {'credit': 25.0, 'account_id': self.company_data['default_account_revenue'].id}), + ], + }) + + self.assertRecordValues(payment, [{ + 'payment_type': 'outbound', + 'partner_type': 'supplier', + 'amount': 100.0, + }]) + + # ==== Edit the account.payment amount ==== + + payment.write({ + 'partner_type': 'customer', + 'amount': 100.1, + 'destination_account_id': self.company_data['default_account_receivable'].id, + }) + + self.assertRecordValues(payment.line_ids.sorted('balance'), [ + { + 'debit': 0.0, + 'credit': 100.1, + 'account_id': self.payment_credit_account_id.id, + }, + { + 'debit': 0.0, + 'credit': 25.0, + 'account_id': self.company_data['default_account_revenue'].id, + }, + { + 'debit': 125.1, + 'credit': 0.0, + 'account_id': self.company_data['default_account_receivable'].id, + }, + ]) + + def test_internal_transfer(self): + copy_receivable = self.copy_account(self.company_data['default_account_receivable']) + + payment = self.env['account.payment'].create({ + 'amount': 50.0, + 'is_internal_transfer': True, + }) + + expected_payment_values = { + 'amount': 50.0, + 'payment_type': 'inbound', + 'currency_id': self.company_data['currency'].id, + 'partner_id': self.company_data['company'].partner_id.id, + 'destination_account_id': self.company_data['company'].transfer_account_id.id, + } + expected_move_values = { + 'currency_id': self.company_data['currency'].id, + 'partner_id': self.company_data['company'].partner_id.id, + } + expected_liquidity_line = { + 'debit': 50.0, + 'credit': 0.0, + 'amount_currency': 50.0, + 'currency_id': self.company_data['currency'].id, + 'account_id': self.payment_debit_account_id.id, + } + expected_counterpart_line = { + 'debit': 0.0, + 'credit': 50.0, + 'amount_currency': -50.0, + 'currency_id': self.company_data['currency'].id, + 'account_id': self.company_data['company'].transfer_account_id.id, + } + + self.assertRecordValues(payment, [expected_payment_values]) + self.assertRecordValues(payment.move_id, [expected_move_values]) + self.assertRecordValues(payment.line_ids.sorted('balance'), [ + expected_counterpart_line, + expected_liquidity_line, + ]) + + # ==== Check editing the account.payment ==== + + payment.write({ + 'partner_type': 'customer', + 'partner_id': self.partner_a.id, + 'destination_account_id': copy_receivable.id, + }) + + self.assertRecordValues(payment, [{ + **expected_payment_values, + 'partner_type': 'customer', + 'destination_account_id': copy_receivable.id, + 'partner_id': self.partner_a.id, + 'is_internal_transfer': False, + }]) + self.assertRecordValues(payment.move_id, [{ + **expected_move_values, + 'partner_id': self.partner_a.id, + }]) + self.assertRecordValues(payment.line_ids.sorted('balance'), [ + { + **expected_counterpart_line, + 'account_id': copy_receivable.id, + }, + expected_liquidity_line, + ]) + + # ==== Check editing the account.move.line ==== + + liquidity_lines, counterpart_lines, writeoff_lines = payment._seek_for_lines() + payment.move_id.write({ + 'line_ids': [ + (1, counterpart_lines.id, { + 'account_id': self.company_data['company'].transfer_account_id.id, + 'partner_id': self.company_data['company'].partner_id.id, + }), + (1, liquidity_lines.id, { + 'partner_id': self.company_data['company'].partner_id.id, + }), + ] + }) + + self.assertRecordValues(payment, [expected_payment_values]) + self.assertRecordValues(payment.move_id, [expected_move_values]) + self.assertRecordValues(payment.line_ids.sorted('balance'), [ + expected_counterpart_line, + expected_liquidity_line, + ]) + + def test_compute_currency_id(self): + ''' When creating a new account.payment without specifying a currency, the default currency should be the one + set on the journal. + ''' + self.company_data['default_journal_bank'].write({ + 'currency_id': self.currency_data['currency'].id, + }) + + payment = self.env['account.payment'].create({ + 'amount': 50.0, + 'payment_type': 'inbound', + 'partner_type': 'customer', + 'partner_id': self.partner_a.id, + 'journal_id': self.company_data['default_journal_bank'].id, + }) + + self.assertRecordValues(payment, [{ + 'currency_id': self.currency_data['currency'].id, + }]) + self.assertRecordValues(payment.move_id, [{ + 'currency_id': self.currency_data['currency'].id, + }]) + self.assertRecordValues(payment.line_ids.sorted('balance'), [ + { + 'debit': 0.0, + 'credit': 25.0, + 'amount_currency': -50.0, + 'currency_id': self.currency_data['currency'].id, + }, + { + 'debit': 25.0, + 'credit': 0.0, + 'amount_currency': 50.0, + 'currency_id': self.currency_data['currency'].id, + }, + ]) + + def test_reconciliation_payment_states(self): + payment = self.env['account.payment'].create({ + 'amount': 50.0, + 'payment_type': 'inbound', + 'partner_type': 'customer', + 'destination_account_id': self.company_data['default_account_receivable'].id, + }) + liquidity_lines, counterpart_lines, writeoff_lines = payment._seek_for_lines() + + self.assertRecordValues(payment, [{ + 'is_reconciled': False, + 'is_matched': False, + }]) + + invoice = self.env['account.move'].create({ + 'move_type': 'out_invoice', + 'partner_id': self.partner_a.id, + 'invoice_line_ids': [(0, 0, { + 'name': '50 to pay', + 'price_unit': 50.0, + 'quantity': 1, + 'account_id': self.company_data['default_account_revenue'].id, + })], + }) + + payment.action_post() + invoice.action_post() + + (counterpart_lines + invoice.line_ids.filtered(lambda line: line.account_internal_type == 'receivable'))\ + .reconcile() + + self.assertRecordValues(payment, [{ + 'is_reconciled': True, + 'is_matched': False, + }]) + + statement = self.env['account.bank.statement'].create({ + 'name': 'test_statement', + 'journal_id': self.company_data['default_journal_bank'].id, + 'line_ids': [ + (0, 0, { + 'payment_ref': '50 to pay', + 'partner_id': self.partner_a.id, + 'amount': 50.0, + }), + ], + }) + statement.button_post() + statement_line = statement.line_ids + + statement_line.reconcile([{'id': liquidity_lines.id}]) + + self.assertRecordValues(payment, [{ + 'is_reconciled': True, + 'is_matched': True, + }]) diff --git a/addons/account/tests/test_account_payment_register.py b/addons/account/tests/test_account_payment_register.py new file mode 100644 index 00000000..1515ca48 --- /dev/null +++ b/addons/account/tests/test_account_payment_register.py @@ -0,0 +1,800 @@ +# -*- coding: utf-8 -*- +from odoo.addons.account.tests.common import AccountTestInvoicingCommon +from odoo.exceptions import UserError +from odoo.tests import tagged + + +@tagged('post_install', '-at_install') +class TestAccountPaymentRegister(AccountTestInvoicingCommon): + + @classmethod + def setUpClass(cls, chart_template_ref=None): + super().setUpClass(chart_template_ref=chart_template_ref) + + cls.currency_data_3 = cls.setup_multi_currency_data({ + 'name': "Umbrella", + 'symbol': '☂', + 'currency_unit_label': "Umbrella", + 'currency_subunit_label': "Broken Umbrella", + }, rate2017=0.01) + + cls.payment_debit_account_id = cls.company_data['default_journal_bank'].payment_debit_account_id.copy() + cls.payment_credit_account_id = cls.company_data['default_journal_bank'].payment_credit_account_id.copy() + + cls.custom_payment_method_in = cls.env['account.payment.method'].create({ + 'name': 'custom_payment_method_in', + 'code': 'CUSTOMIN', + 'payment_type': 'inbound', + }) + cls.manual_payment_method_in = cls.env.ref('account.account_payment_method_manual_in') + + cls.custom_payment_method_out = cls.env['account.payment.method'].create({ + 'name': 'custom_payment_method_out', + 'code': 'CUSTOMOUT', + 'payment_type': 'outbound', + }) + cls.manual_payment_method_out = cls.env.ref('account.account_payment_method_manual_out') + + cls.company_data['default_journal_bank'].write({ + 'payment_debit_account_id': cls.payment_debit_account_id.id, + 'payment_credit_account_id': cls.payment_credit_account_id.id, + 'inbound_payment_method_ids': [(6, 0, ( + cls.manual_payment_method_in.id, + cls.custom_payment_method_in.id, + ))], + 'outbound_payment_method_ids': [(6, 0, ( + cls.env.ref('account.account_payment_method_manual_out').id, + cls.custom_payment_method_out.id, + cls.manual_payment_method_out.id, + ))], + }) + + # Customer invoices sharing the same batch. + cls.out_invoice_1 = cls.env['account.move'].create({ + 'move_type': 'out_invoice', + 'date': '2017-01-01', + 'invoice_date': '2017-01-01', + 'partner_id': cls.partner_a.id, + 'currency_id': cls.currency_data['currency'].id, + 'invoice_line_ids': [(0, 0, {'product_id': cls.product_a.id, 'price_unit': 1000.0})], + }) + cls.out_invoice_2 = cls.env['account.move'].create({ + 'move_type': 'out_invoice', + 'date': '2017-01-01', + 'invoice_date': '2017-01-01', + 'partner_id': cls.partner_a.id, + 'currency_id': cls.currency_data['currency'].id, + 'invoice_line_ids': [(0, 0, {'product_id': cls.product_a.id, 'price_unit': 2000.0})], + }) + cls.out_invoice_3 = cls.env['account.move'].create({ + 'move_type': 'out_invoice', + 'date': '2017-01-01', + 'invoice_date': '2017-01-01', + 'partner_id': cls.partner_a.id, + 'invoice_line_ids': [(0, 0, {'product_id': cls.product_a.id, 'price_unit': 12.01})], + }) + cls.out_invoice_4 = cls.env['account.move'].create({ + 'move_type': 'out_invoice', + 'date': '2017-01-01', + 'invoice_date': '2017-01-01', + 'partner_id': cls.partner_a.id, + 'invoice_line_ids': [(0, 0, {'product_id': cls.product_a.id, 'price_unit': 11.99})], + }) + (cls.out_invoice_1 + cls.out_invoice_2 + cls.out_invoice_3 + cls.out_invoice_4).action_post() + + # Vendor bills, in_invoice_1 + in_invoice_2 are sharing the same batch but not in_invoice_3. + cls.in_invoice_1 = cls.env['account.move'].create({ + 'move_type': 'in_invoice', + 'date': '2017-01-01', + 'invoice_date': '2017-01-01', + 'partner_id': cls.partner_a.id, + 'invoice_line_ids': [(0, 0, {'product_id': cls.product_a.id, 'price_unit': 1000.0})], + }) + cls.in_invoice_2 = cls.env['account.move'].create({ + 'move_type': 'in_invoice', + 'date': '2017-01-01', + 'invoice_date': '2017-01-01', + 'partner_id': cls.partner_a.id, + 'invoice_line_ids': [(0, 0, {'product_id': cls.product_a.id, 'price_unit': 2000.0})], + }) + cls.in_invoice_3 = cls.env['account.move'].create({ + 'move_type': 'in_invoice', + 'date': '2017-01-01', + 'invoice_date': '2017-01-01', + 'partner_id': cls.partner_b.id, + 'currency_id': cls.currency_data['currency'].id, + 'invoice_line_ids': [(0, 0, {'product_id': cls.product_a.id, 'price_unit': 3000.0})], + }) + (cls.in_invoice_1 + cls.in_invoice_2 + cls.in_invoice_3).action_post() + + def test_register_payment_single_batch_grouped_keep_open_lower_amount(self): + ''' Pay 800.0 with 'open' as payment difference handling on two customer invoices (1000 + 2000). ''' + active_ids = (self.out_invoice_1 + self.out_invoice_2).ids + payments = self.env['account.payment.register'].with_context(active_model='account.move', active_ids=active_ids).create({ + 'amount': 800.0, + 'group_payment': True, + 'payment_difference_handling': 'open', + 'currency_id': self.currency_data['currency'].id, + 'payment_method_id': self.custom_payment_method_in.id, + })._create_payments() + + self.assertRecordValues(payments, [{ + 'ref': 'INV/2017/01/0001 INV/2017/01/0002', + 'payment_method_id': self.custom_payment_method_in.id, + }]) + self.assertRecordValues(payments.line_ids.sorted('balance'), [ + # Receivable line: + { + 'debit': 0.0, + 'credit': 400.0, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -800.0, + 'reconciled': True, + }, + # Liquidity line: + { + 'debit': 400.0, + 'credit': 0.0, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 800.0, + 'reconciled': False, + }, + ]) + + def test_register_payment_single_batch_grouped_keep_open_higher_amount(self): + ''' Pay 3100.0 with 'open' as payment difference handling on two customer invoices (1000 + 2000). ''' + active_ids = (self.out_invoice_1 + self.out_invoice_2).ids + payments = self.env['account.payment.register'].with_context(active_model='account.move', active_ids=active_ids).create({ + 'amount': 3100.0, + 'group_payment': True, + 'payment_difference_handling': 'open', + 'currency_id': self.currency_data['currency'].id, + 'payment_method_id': self.custom_payment_method_in.id, + })._create_payments() + + self.assertRecordValues(payments, [{ + 'ref': 'INV/2017/01/0001 INV/2017/01/0002', + 'payment_method_id': self.custom_payment_method_in.id, + }]) + self.assertRecordValues(payments.line_ids.sorted('balance'), [ + # Receivable line: + { + 'debit': 0.0, + 'credit': 1550.0, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -3100.0, + 'reconciled': False, + }, + # Liquidity line: + { + 'debit': 1550.0, + 'credit': 0.0, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 3100.0, + 'reconciled': False, + }, + ]) + + def test_register_payment_single_batch_grouped_writeoff_lower_amount_debit(self): + ''' Pay 800.0 with 'reconcile' as payment difference handling on two customer invoices (1000 + 2000). ''' + active_ids = (self.out_invoice_1 + self.out_invoice_2).ids + payments = self.env['account.payment.register'].with_context(active_model='account.move', active_ids=active_ids).create({ + 'amount': 800.0, + 'group_payment': True, + 'payment_difference_handling': 'reconcile', + 'writeoff_account_id': self.company_data['default_account_revenue'].id, + 'writeoff_label': 'writeoff', + 'payment_method_id': self.custom_payment_method_in.id, + })._create_payments() + + self.assertRecordValues(payments, [{ + 'ref': 'INV/2017/01/0001 INV/2017/01/0002', + 'payment_method_id': self.custom_payment_method_in.id, + }]) + self.assertRecordValues(payments.line_ids.sorted('balance'), [ + # Receivable line: + { + 'debit': 0.0, + 'credit': 1500.0, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -3000.0, + 'reconciled': True, + }, + # Liquidity line: + { + 'debit': 400.0, + 'credit': 0.0, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 800.0, + 'reconciled': False, + }, + # Writeoff line: + { + 'debit': 1100.0, + 'credit': 0.0, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 2200.0, + 'reconciled': False, + }, + ]) + + def test_register_payment_single_batch_grouped_writeoff_higher_amount_debit(self): + ''' Pay 3100.0 with 'reconcile' as payment difference handling on two customer invoices (1000 + 2000). ''' + active_ids = (self.out_invoice_1 + self.out_invoice_2).ids + payments = self.env['account.payment.register'].with_context(active_model='account.move', active_ids=active_ids).create({ + 'amount': 3100.0, + 'group_payment': True, + 'payment_difference_handling': 'reconcile', + 'writeoff_account_id': self.company_data['default_account_revenue'].id, + 'writeoff_label': 'writeoff', + 'payment_method_id': self.custom_payment_method_in.id, + })._create_payments() + + self.assertRecordValues(payments, [{ + 'ref': 'INV/2017/01/0001 INV/2017/01/0002', + 'payment_method_id': self.custom_payment_method_in.id, + }]) + self.assertRecordValues(payments.line_ids.sorted('balance'), [ + # Receivable line: + { + 'debit': 0.0, + 'credit': 1500.0, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -3000.0, + 'reconciled': True, + }, + # Writeoff line: + { + 'debit': 0.0, + 'credit': 50.0, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -100.0, + 'reconciled': False, + }, + # Liquidity line: + { + 'debit': 1550.0, + 'credit': 0.0, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 3100.0, + 'reconciled': False, + }, + ]) + + def test_register_payment_single_batch_grouped_writeoff_lower_amount_credit(self): + ''' Pay 800.0 with 'reconcile' as payment difference handling on two vendor billes (1000 + 2000). ''' + active_ids = (self.in_invoice_1 + self.in_invoice_2).ids + payments = self.env['account.payment.register'].with_context(active_model='account.move', active_ids=active_ids).create({ + 'amount': 800.0, + 'group_payment': True, + 'payment_difference_handling': 'reconcile', + 'writeoff_account_id': self.company_data['default_account_revenue'].id, + 'writeoff_label': 'writeoff', + 'payment_method_id': self.custom_payment_method_in.id, + })._create_payments() + + self.assertRecordValues(payments, [{ + 'ref': 'BILL/2017/01/0001 BILL/2017/01/0002', + 'payment_method_id': self.custom_payment_method_in.id, + }]) + self.assertRecordValues(payments.line_ids.sorted('balance'), [ + # Writeoff line: + { + 'debit': 0.0, + 'credit': 2200.0, + 'currency_id': self.company_data['currency'].id, + 'amount_currency': -2200.0, + 'reconciled': False, + }, + # Liquidity line: + { + 'debit': 0.0, + 'credit': 800.0, + 'currency_id': self.company_data['currency'].id, + 'amount_currency': -800.0, + 'reconciled': False, + }, + # Payable line: + { + 'debit': 3000.0, + 'credit': 0.0, + 'currency_id': self.company_data['currency'].id, + 'amount_currency': 3000.0, + 'reconciled': True, + }, + ]) + + def test_register_payment_single_batch_grouped_writeoff_higher_amount_credit(self): + ''' Pay 3100.0 with 'reconcile' as payment difference handling on two vendor billes (1000 + 2000). ''' + active_ids = (self.in_invoice_1 + self.in_invoice_2).ids + payments = self.env['account.payment.register'].with_context(active_model='account.move', active_ids=active_ids).create({ + 'amount': 3100.0, + 'group_payment': True, + 'payment_difference_handling': 'reconcile', + 'writeoff_account_id': self.company_data['default_account_revenue'].id, + 'writeoff_label': 'writeoff', + 'payment_method_id': self.custom_payment_method_in.id, + })._create_payments() + + self.assertRecordValues(payments, [{ + 'ref': 'BILL/2017/01/0001 BILL/2017/01/0002', + 'payment_method_id': self.custom_payment_method_in.id, + }]) + self.assertRecordValues(payments.line_ids.sorted('balance'), [ + # Liquidity line: + { + 'debit': 0.0, + 'credit': 3100.0, + 'currency_id': self.company_data['currency'].id, + 'amount_currency': -3100.0, + 'reconciled': False, + }, + # Writeoff line: + { + 'debit': 100.0, + 'credit': 0.0, + 'currency_id': self.company_data['currency'].id, + 'amount_currency': 100.0, + 'reconciled': False, + }, + # Payable line: + { + 'debit': 3000.0, + 'credit': 0.0, + 'currency_id': self.company_data['currency'].id, + 'amount_currency': 3000.0, + 'reconciled': True, + }, + ]) + + def test_register_payment_single_batch_not_grouped(self): + ''' Choose to pay two customer invoices with separated payments (1000 + 2000). ''' + active_ids = (self.out_invoice_1 + self.out_invoice_2).ids + payments = self.env['account.payment.register'].with_context(active_model='account.move', active_ids=active_ids).create({ + 'group_payment': False, + })._create_payments() + + self.assertRecordValues(payments, [ + { + 'ref': 'INV/2017/01/0001', + 'payment_method_id': self.manual_payment_method_in.id, + }, + { + 'ref': 'INV/2017/01/0002', + 'payment_method_id': self.manual_payment_method_in.id, + }, + ]) + self.assertRecordValues(payments[0].line_ids.sorted('balance') + payments[1].line_ids.sorted('balance'), [ + # == Payment 1: to pay out_invoice_1 == + # Receivable line: + { + 'debit': 0.0, + 'credit': 500.0, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -1000.0, + 'reconciled': True, + }, + # Liquidity line: + { + 'debit': 500.0, + 'credit': 0.0, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 1000.0, + 'reconciled': False, + }, + # == Payment 2: to pay out_invoice_2 == + # Receivable line: + { + 'debit': 0.0, + 'credit': 1000.0, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -2000.0, + 'reconciled': True, + }, + # Liquidity line: + { + 'debit': 1000.0, + 'credit': 0.0, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 2000.0, + 'reconciled': False, + }, + ]) + + def test_register_payment_multi_batches_grouped(self): + ''' Choose to pay multiple batches, one with two customer invoices (1000 + 2000) + and one with a vendor bill of 600, by grouping payments. + ''' + active_ids = (self.in_invoice_1 + self.in_invoice_2 + self.in_invoice_3).ids + payments = self.env['account.payment.register'].with_context(active_model='account.move', active_ids=active_ids).create({ + 'group_payment': True, + })._create_payments() + + self.assertRecordValues(payments, [ + { + 'ref': 'BILL/2017/01/0001 BILL/2017/01/0002', + 'payment_method_id': self.manual_payment_method_out.id, + }, + { + 'ref': 'BILL/2017/01/0003', + 'payment_method_id': self.manual_payment_method_out.id, + }, + ]) + self.assertRecordValues(payments[0].line_ids.sorted('balance') + payments[1].line_ids.sorted('balance'), [ + # == Payment 1: to pay in_invoice_1 & in_invoice_2 == + # Liquidity line: + { + 'debit': 0.0, + 'credit': 3000.0, + 'currency_id': self.company_data['currency'].id, + 'amount_currency': -3000.0, + 'reconciled': False, + }, + # Payable line: + { + 'debit': 3000.0, + 'credit': 0.0, + 'currency_id': self.company_data['currency'].id, + 'amount_currency': 3000.0, + 'reconciled': True, + }, + # == Payment 2: to pay in_invoice_3 == + # Liquidity line: + { + 'debit': 0.0, + 'credit': 1500.0, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -3000.0, + 'reconciled': False, + }, + # Payable line: + { + 'debit': 1500.0, + 'credit': 0.0, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 3000.0, + 'reconciled': True, + }, + ]) + + def test_register_payment_multi_batches_not_grouped(self): + ''' Choose to pay multiple batches, one with two customer invoices (1000 + 2000) + and one with a vendor bill of 600, by splitting payments. + ''' + active_ids = (self.in_invoice_1 + self.in_invoice_2 + self.in_invoice_3).ids + payments = self.env['account.payment.register'].with_context(active_model='account.move', active_ids=active_ids).create({ + 'group_payment': False, + })._create_payments() + + self.assertRecordValues(payments, [ + { + 'ref': 'BILL/2017/01/0001', + 'payment_method_id': self.manual_payment_method_out.id, + }, + { + 'ref': 'BILL/2017/01/0002', + 'payment_method_id': self.manual_payment_method_out.id, + }, + { + 'ref': 'BILL/2017/01/0003', + 'payment_method_id': self.manual_payment_method_out.id, + }, + ]) + self.assertRecordValues(payments[0].line_ids.sorted('balance') + payments[1].line_ids.sorted('balance') + payments[2].line_ids.sorted('balance'), [ + # == Payment 1: to pay in_invoice_1 == + # Liquidity line: + { + 'debit': 0.0, + 'credit': 1000.0, + 'currency_id': self.company_data['currency'].id, + 'amount_currency': -1000.0, + 'reconciled': False, + }, + # Payable line: + { + 'debit': 1000.0, + 'credit': 0.0, + 'currency_id': self.company_data['currency'].id, + 'amount_currency': 1000.0, + 'reconciled': True, + }, + # == Payment 2: to pay in_invoice_2 == + # Liquidity line: + { + 'debit': 0.0, + 'credit': 2000.0, + 'currency_id': self.company_data['currency'].id, + 'amount_currency': -2000.0, + 'reconciled': False, + }, + # Payable line: + { + 'debit': 2000.0, + 'credit': 0.0, + 'currency_id': self.company_data['currency'].id, + 'amount_currency': 2000.0, + 'reconciled': True, + }, + # == Payment 3: to pay in_invoice_3 == + # Liquidity line: + { + 'debit': 0.0, + 'credit': 1500.0, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': -3000.0, + 'reconciled': False, + }, + # Payable line: + { + 'debit': 1500.0, + 'credit': 0.0, + 'currency_id': self.currency_data['currency'].id, + 'amount_currency': 3000.0, + 'reconciled': True, + }, + ]) + + def test_register_payment_constraints(self): + # Test to register a payment for a draft journal entry. + self.out_invoice_1.button_draft() + with self.assertRaises(UserError), self.cr.savepoint(): + self.env['account.payment.register']\ + .with_context(active_model='account.move', active_ids=self.out_invoice_1.ids)\ + .create({}) + + # Test to register a payment for an already fully reconciled journal entry. + self.env['account.payment.register']\ + .with_context(active_model='account.move', active_ids=self.out_invoice_2.ids)\ + .create({})\ + ._create_payments() + with self.assertRaises(UserError), self.cr.savepoint(): + self.env['account.payment.register']\ + .with_context(active_model='account.move', active_ids=self.out_invoice_2.ids)\ + .create({}) + + def test_register_payment_multi_currency_rounding_issue_positive_delta(self): + ''' When registering a payment using a different currency than the invoice one, the invoice must be fully paid + at the end whatever the currency rate. + ''' + payment = self.env['account.payment.register']\ + .with_context(active_model='account.move', active_ids=self.out_invoice_3.ids)\ + .create({ + 'currency_id': self.currency_data_3['currency'].id, + 'amount': 0.12, + })\ + ._create_payments() + + self.assertRecordValues(payment.line_ids.sorted('balance'), [ + # Receivable line: + { + 'debit': 0.0, + 'credit': 12.01, + 'currency_id': self.currency_data_3['currency'].id, + 'amount_currency': -0.12, + 'reconciled': True, + }, + # Liquidity line: + { + 'debit': 12.01, + 'credit': 0.0, + 'currency_id': self.currency_data_3['currency'].id, + 'amount_currency': 0.12, + 'reconciled': False, + }, + ]) + + def test_register_payment_multi_currency_rounding_issue_negative_delta(self): + ''' When registering a payment using a different currency than the invoice one, the invoice must be fully paid + at the end whatever the currency rate. + ''' + payment = self.env['account.payment.register']\ + .with_context(active_model='account.move', active_ids=self.out_invoice_4.ids)\ + .create({ + 'currency_id': self.currency_data_3['currency'].id, + 'amount': 0.12, + })\ + ._create_payments() + + self.assertRecordValues(payment.line_ids.sorted('balance'), [ + # Receivable line: + { + 'debit': 0.0, + 'credit': 11.99, + 'currency_id': self.currency_data_3['currency'].id, + 'amount_currency': -0.12, + 'reconciled': True, + }, + # Liquidity line: + { + 'debit': 11.99, + 'credit': 0.0, + 'currency_id': self.currency_data_3['currency'].id, + 'amount_currency': 0.12, + 'reconciled': False, + }, + ]) + + def test_register_payment_multi_currency_rounding_issue_writeoff_lower_amount_keep_open(self): + payment = self.env['account.payment.register']\ + .with_context(active_model='account.move', active_ids=self.out_invoice_3.ids)\ + .create({ + 'currency_id': self.currency_data_3['currency'].id, + 'amount': 0.08, + 'payment_difference_handling': 'open', + })\ + ._create_payments() + + self.assertRecordValues(payment.line_ids.sorted('balance'), [ + # Receivable line: + { + 'debit': 0.0, + 'credit': 8.0, + 'currency_id': self.currency_data_3['currency'].id, + 'amount_currency': -0.08, + 'reconciled': True, + }, + # Liquidity line: + { + 'debit': 8.0, + 'credit': 0.0, + 'currency_id': self.currency_data_3['currency'].id, + 'amount_currency': 0.08, + 'reconciled': False, + }, + ]) + + def test_register_payment_multi_currency_rounding_issue_writeoff_lower_amount_reconcile_positive_delta(self): + payment = self.env['account.payment.register']\ + .with_context(active_model='account.move', active_ids=self.out_invoice_3.ids)\ + .create({ + 'currency_id': self.currency_data_3['currency'].id, + 'amount': 0.08, + 'payment_difference_handling': 'reconcile', + 'writeoff_account_id': self.company_data['default_account_revenue'].id, + 'writeoff_label': 'writeoff', + })\ + ._create_payments() + + self.assertRecordValues(payment.line_ids.sorted('balance'), [ + # Receivable line: + { + 'debit': 0.0, + 'credit': 12.01, + 'currency_id': self.currency_data_3['currency'].id, + 'amount_currency': -0.12, + 'reconciled': True, + }, + # Write-off line: + { + 'debit': 4.0, + 'credit': 0.0, + 'currency_id': self.currency_data_3['currency'].id, + 'amount_currency': 0.04, + 'reconciled': False, + }, + # Liquidity line: + { + 'debit': 8.01, + 'credit': 0.0, + 'currency_id': self.currency_data_3['currency'].id, + 'amount_currency': 0.08, + 'reconciled': False, + }, + ]) + + def test_register_payment_multi_currency_rounding_issue_writeoff_lower_amount_reconcile_negative_delta(self): + payment = self.env['account.payment.register']\ + .with_context(active_model='account.move', active_ids=self.out_invoice_4.ids)\ + .create({ + 'currency_id': self.currency_data_3['currency'].id, + 'amount': 0.08, + 'payment_difference_handling': 'reconcile', + 'writeoff_account_id': self.company_data['default_account_revenue'].id, + 'writeoff_label': 'writeoff', + })\ + ._create_payments() + + self.assertRecordValues(payment.line_ids.sorted('balance'), [ + # Receivable line: + { + 'debit': 0.0, + 'credit': 11.99, + 'currency_id': self.currency_data_3['currency'].id, + 'amount_currency': -0.12, + 'reconciled': True, + }, + # Write-off line: + { + 'debit': 4.0, + 'credit': 0.0, + 'currency_id': self.currency_data_3['currency'].id, + 'amount_currency': 0.04, + 'reconciled': False, + }, + # Liquidity line: + { + 'debit': 7.99, + 'credit': 0.0, + 'currency_id': self.currency_data_3['currency'].id, + 'amount_currency': 0.08, + 'reconciled': False, + }, + ]) + + def test_register_payment_multi_currency_rounding_issue_writeoff_higher_amount_reconcile_positive_delta(self): + payment = self.env['account.payment.register']\ + .with_context(active_model='account.move', active_ids=self.out_invoice_3.ids)\ + .create({ + 'currency_id': self.currency_data_3['currency'].id, + 'amount': 0.16, + 'payment_difference_handling': 'reconcile', + 'writeoff_account_id': self.company_data['default_account_revenue'].id, + 'writeoff_label': 'writeoff', + })\ + ._create_payments() + + self.assertRecordValues(payment.line_ids.sorted('balance'), [ + # Receivable line: + { + 'debit': 0.0, + 'credit': 12.01, + 'currency_id': self.currency_data_3['currency'].id, + 'amount_currency': -0.12, + 'reconciled': True, + }, + # Write-off line: + { + 'debit': 0.0, + 'credit': 4.0, + 'currency_id': self.currency_data_3['currency'].id, + 'amount_currency': -0.04, + 'reconciled': False, + }, + # Liquidity line: + { + 'debit': 16.01, + 'credit': 0.0, + 'currency_id': self.currency_data_3['currency'].id, + 'amount_currency': 0.16, + 'reconciled': False, + }, + ]) + + def test_register_payment_multi_currency_rounding_issue_writeoff_higher_amount_reconcile_negative_delta(self): + payment = self.env['account.payment.register']\ + .with_context(active_model='account.move', active_ids=self.out_invoice_4.ids)\ + .create({ + 'currency_id': self.currency_data_3['currency'].id, + 'amount': 0.16, + 'payment_difference_handling': 'reconcile', + 'writeoff_account_id': self.company_data['default_account_revenue'].id, + 'writeoff_label': 'writeoff', + })\ + ._create_payments() + + self.assertRecordValues(payment.line_ids.sorted('balance'), [ + # Receivable line: + { + 'debit': 0.0, + 'credit': 11.99, + 'currency_id': self.currency_data_3['currency'].id, + 'amount_currency': -0.12, + 'reconciled': True, + }, + # Write-off line: + { + 'debit': 0.0, + 'credit': 4.0, + 'currency_id': self.currency_data_3['currency'].id, + 'amount_currency': -0.04, + 'reconciled': False, + }, + # Liquidity line: + { + 'debit': 15.99, + 'credit': 0.0, + 'currency_id': self.currency_data_3['currency'].id, + 'amount_currency': 0.16, + 'reconciled': False, + }, + ]) diff --git a/addons/account/tests/test_account_tax.py b/addons/account/tests/test_account_tax.py new file mode 100644 index 00000000..94bd2a16 --- /dev/null +++ b/addons/account/tests/test_account_tax.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +from odoo.addons.account.tests.common import AccountTestInvoicingCommon +from odoo.tests import tagged +from odoo.exceptions import UserError + + +@tagged('post_install', '-at_install') +class TestAccountTax(AccountTestInvoicingCommon): + + def test_changing_tax_company(self): + ''' Ensure you can't change the company of an account.tax if there are some journal entries ''' + + # Avoid duplicate key value violates unique constraint "account_tax_name_company_uniq". + self.company_data['default_tax_sale'].name = 'test_changing_account_company' + + self.env['account.move'].create({ + 'move_type': 'out_invoice', + 'date': '2019-01-01', + 'invoice_line_ids': [ + (0, 0, { + 'name': 'invoice_line', + 'quantity': 1.0, + 'price_unit': 100.0, + 'tax_ids': [(6, 0, self.company_data['default_tax_sale'].ids)], + }), + ], + }) + + with self.assertRaises(UserError), self.cr.savepoint(): + self.company_data['default_tax_sale'].company_id = self.company_data_2['company'] diff --git a/addons/account/tests/test_fiscal_position.py b/addons/account/tests/test_fiscal_position.py new file mode 100644 index 00000000..96032e75 --- /dev/null +++ b/addons/account/tests/test_fiscal_position.py @@ -0,0 +1,168 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.tests import common + + +class TestFiscalPosition(common.SavepointCase): + """Tests for fiscal positions in auto apply (account.fiscal.position). + If a partner has a vat number, the fiscal positions with "vat_required=True" + are preferred. + """ + + @classmethod + def setUpClass(cls): + super(TestFiscalPosition, cls).setUpClass() + cls.fp = cls.env['account.fiscal.position'] + + # reset any existing FP + cls.fp.search([]).write({'auto_apply': False}) + + cls.res_partner = cls.env['res.partner'] + cls.be = be = cls.env.ref('base.be') + cls.fr = fr = cls.env.ref('base.fr') + cls.mx = mx = cls.env.ref('base.mx') + cls.eu = eu = cls.env.ref('base.europe') + cls.state_fr = cls.env['res.country.state'].create(dict( + name="State", + code="ST", + country_id=fr.id)) + cls.jc = cls.res_partner.create(dict( + name="JCVD", + vat="BE0477472701", + country_id=be.id)) + cls.ben = cls.res_partner.create(dict( + name="BP", + country_id=be.id)) + cls.george = cls.res_partner.create(dict( + name="George", + vat="BE0477472701", + country_id=fr.id)) + cls.alberto = cls.res_partner.create(dict( + name="Alberto", + vat="BE0477472701", + country_id=mx.id)) + cls.be_nat = cls.fp.create(dict( + name="BE-NAT", + auto_apply=True, + country_id=be.id, + vat_required=False, + sequence=10)) + cls.fr_b2c = cls.fp.create(dict( + name="EU-VAT-FR-B2C", + auto_apply=True, + country_id=fr.id, + vat_required=False, + sequence=40)) + cls.fr_b2b = cls.fp.create(dict( + name="EU-VAT-FR-B2B", + auto_apply=True, + country_id=fr.id, + vat_required=True, + sequence=50)) + + def test_10_fp_country(self): + def assert_fp(partner, expected_pos, message): + self.assertEqual( + self.fp.get_fiscal_position(partner.id).id, + expected_pos.id, + message) + + george, jc, ben, alberto = self.george, self.jc, self.ben, self.alberto + + # B2B has precedence over B2C for same country even when sequence gives lower precedence + self.assertGreater(self.fr_b2b.sequence, self.fr_b2c.sequence) + assert_fp(george, self.fr_b2b, "FR-B2B should have precedence over FR-B2C") + self.fr_b2b.auto_apply = False + assert_fp(george, self.fr_b2c, "FR-B2C should match now") + self.fr_b2b.auto_apply = True + + # Create positions matching on Country Group and on NO country at all + self.eu_intra_b2b = self.fp.create(dict( + name="EU-INTRA B2B", + auto_apply=True, + country_group_id=self.eu.id, + vat_required=True, + sequence=20)) + self.world = self.fp.create(dict( + name="WORLD-EXTRA", + auto_apply=True, + vat_required=False, + sequence=30)) + + # Country match has higher precedence than group match or sequence + self.assertGreater(self.fr_b2b.sequence, self.eu_intra_b2b.sequence) + assert_fp(george, self.fr_b2b, "FR-B2B should have precedence over EU-INTRA B2B") + + # B2B has precedence regardless of country or group match + self.assertGreater(self.eu_intra_b2b.sequence, self.be_nat.sequence) + assert_fp(jc, self.eu_intra_b2b, "EU-INTRA B2B should match before BE-NAT") + + # Lower sequence = higher precedence if country/group and VAT matches + self.assertFalse(ben.vat) # No VAT set + assert_fp(ben, self.be_nat, "BE-NAT should match before EU-INTRA due to lower sequence") + + # Remove BE from EU group, now BE-NAT should be the fallback match before the wildcard WORLD + self.be.write({'country_group_ids': [(3, self.eu.id)]}) + self.assertTrue(jc.vat) # VAT set + assert_fp(jc, self.be_nat, "BE-NAT should match as fallback even w/o VAT match") + + # No country = wildcard match only if nothing else matches + self.assertTrue(alberto.vat) # with VAT + assert_fp(alberto, self.world, "WORLD-EXTRA should match anything else (1)") + alberto.vat = False # or without + assert_fp(alberto, self.world, "WORLD-EXTRA should match anything else (2)") + + # Zip range + self.fr_b2b_zip100 = self.fr_b2b.copy(dict(zip_from=0, zip_to=5000, sequence=60)) + george.zip = 6000 + assert_fp(george, self.fr_b2b, "FR-B2B with wrong zip range should not match") + george.zip = 3000 + assert_fp(george, self.fr_b2b_zip100, "FR-B2B with zip range should have precedence") + + # States + self.fr_b2b_state = self.fr_b2b.copy(dict(state_ids=[(4, self.state_fr.id)], sequence=70)) + george.state_id = self.state_fr + assert_fp(george, self.fr_b2b_zip100, "FR-B2B with zip should have precedence over states") + george.zip = False + assert_fp(george, self.fr_b2b_state, "FR-B2B with states should have precedence") + + # Dedicated position has max precedence + george.property_account_position_id = self.be_nat + assert_fp(george, self.be_nat, "Forced position has max precedence") + + + def test_20_fp_one_tax_2m(self): + + self.src_tax = self.env['account.tax'].create({'name': "SRC", 'amount': 0.0}) + self.dst1_tax = self.env['account.tax'].create({'name': "DST1", 'amount': 0.0}) + self.dst2_tax = self.env['account.tax'].create({'name': "DST2", 'amount': 0.0}) + + self.fp2m = self.fp.create({ + 'name': "FP-TAX2TAXES", + 'tax_ids': [ + (0,0,{ + 'tax_src_id': self.src_tax.id, + 'tax_dest_id': self.dst1_tax.id + }), + (0,0,{ + 'tax_src_id': self.src_tax.id, + 'tax_dest_id': self.dst2_tax.id + }) + ] + }) + mapped_taxes = self.fp2m.map_tax(self.src_tax) + + self.assertEqual(mapped_taxes, self.dst1_tax | self.dst2_tax) + + def test_30_fp_country_delivery(self): + """ + Customer is in Belgium + Delivery is in France + Check if fiscal position is France + """ + self.george.vat = False + self.assertEqual( + self.fp.get_fiscal_position(self.ben.id, self.george.id).id, + self.fr_b2c.id, + "FR B2C should be set") diff --git a/addons/account/tests/test_invoice_tax_amount_by_group.py b/addons/account/tests/test_invoice_tax_amount_by_group.py new file mode 100644 index 00000000..e4f76f14 --- /dev/null +++ b/addons/account/tests/test_invoice_tax_amount_by_group.py @@ -0,0 +1,209 @@ +# -*- coding: utf-8 -*- +from odoo.addons.account.tests.common import AccountTestInvoicingCommon +from odoo.tests import tagged + + +@tagged('post_install', '-at_install') +class TestInvoiceTaxAmountByGroup(AccountTestInvoicingCommon): + + @classmethod + def setUpClass(cls, chart_template_ref=None): + super().setUpClass(chart_template_ref=chart_template_ref) + + cls.tax_group1 = cls.env['account.tax.group'].create({'name': '1'}) + cls.tax_group2 = cls.env['account.tax.group'].create({'name': '2'}) + + def assertAmountByTaxGroup(self, invoice, expected_values): + current_values = [(x[6], x[2], x[1]) for x in invoice.amount_by_group] + self.assertEqual(current_values, expected_values) + + def test_multiple_tax_lines(self): + tax_10 = self.env['account.tax'].create({ + 'name': "tax_10", + 'amount_type': 'percent', + 'amount': 10.0, + 'tax_group_id': self.tax_group1.id, + }) + tax_20 = self.env['account.tax'].create({ + 'name': "tax_20", + 'amount_type': 'percent', + 'amount': 20.0, + 'tax_group_id': self.tax_group2.id, + }) + + invoice = self.env['account.move'].create({ + 'move_type': 'out_invoice', + 'partner_id': self.partner_a.id, + 'invoice_date': '2019-01-01', + 'invoice_line_ids': [ + (0, 0, { + 'name': 'line', + 'account_id': self.company_data['default_account_revenue'].id, + 'price_unit': 1000.0, + 'tax_ids': [(6, 0, (tax_10 + tax_20).ids)], + }), + (0, 0, { + 'name': 'line', + 'account_id': self.company_data['default_account_revenue'].id, + 'price_unit': 1000.0, + 'tax_ids': [(6, 0, tax_10.ids)], + }), + (0, 0, { + 'name': 'line', + 'account_id': self.company_data['default_account_revenue'].id, + 'price_unit': 1000.0, + 'tax_ids': [(6, 0, tax_20.ids)], + }), + ] + }) + + self.assertAmountByTaxGroup(invoice, [ + (self.tax_group1.id, 2000.0, 200.0), + (self.tax_group2.id, 2000.0, 400.0), + ]) + + # Same but both are sharing the same tax group. + + tax_20.tax_group_id = self.tax_group1 + invoice.invalidate_cache(['amount_by_group']) + + self.assertAmountByTaxGroup(invoice, [ + (self.tax_group1.id, 3000.0, 600.0), + ]) + + def test_zero_tax_lines(self): + tax_0 = self.env['account.tax'].create({ + 'name': "tax_0", + 'amount_type': 'percent', + 'amount': 0.0, + }) + + invoice = self.env['account.move'].create({ + 'move_type': 'out_invoice', + 'partner_id': self.partner_a.id, + 'invoice_date': '2019-01-01', + 'invoice_line_ids': [ + (0, 0, { + 'name': 'line', + 'account_id': self.company_data['default_account_revenue'].id, + 'price_unit': 1000.0, + 'tax_ids': [(6, 0, tax_0.ids)], + }), + ] + }) + + self.assertAmountByTaxGroup(invoice, [ + (tax_0.tax_group_id.id, 1000.0, 0.0), + ]) + + def test_tax_affect_base_1(self): + tax_10 = self.env['account.tax'].create({ + 'name': "tax_10", + 'amount_type': 'percent', + 'amount': 10.0, + 'tax_group_id': self.tax_group1.id, + 'price_include': True, + 'include_base_amount': True, + }) + tax_20 = self.env['account.tax'].create({ + 'name': "tax_20", + 'amount_type': 'percent', + 'amount': 20.0, + 'tax_group_id': self.tax_group2.id, + }) + + invoice = self.env['account.move'].create({ + 'move_type': 'out_invoice', + 'partner_id': self.partner_a.id, + 'invoice_date': '2019-01-01', + 'invoice_line_ids': [ + (0, 0, { + 'name': 'line', + 'account_id': self.company_data['default_account_revenue'].id, + 'price_unit': 1100.0, + 'tax_ids': [(6, 0, (tax_10 + tax_20).ids)], + }), + (0, 0, { + 'name': 'line', + 'account_id': self.company_data['default_account_revenue'].id, + 'price_unit': 1100.0, + 'tax_ids': [(6, 0, tax_10.ids)], + }), + (0, 0, { + 'name': 'line', + 'account_id': self.company_data['default_account_revenue'].id, + 'price_unit': 1000.0, + 'tax_ids': [(6, 0, tax_20.ids)], + }), + ] + }) + + self.assertAmountByTaxGroup(invoice, [ + (self.tax_group1.id, 2000.0, 200.0), + (self.tax_group2.id, 2100.0, 420.0), + ]) + + # Same but both are sharing the same tax group. + + tax_20.tax_group_id = self.tax_group1 + invoice.invalidate_cache(['amount_by_group']) + + self.assertAmountByTaxGroup(invoice, [ + (self.tax_group1.id, 3000.0, 620.0), + ]) + + def test_tax_affect_base_2(self): + tax_10 = self.env['account.tax'].create({ + 'name': "tax_10", + 'amount_type': 'percent', + 'amount': 10.0, + 'tax_group_id': self.tax_group1.id, + 'include_base_amount': True, + }) + tax_20 = self.env['account.tax'].create({ + 'name': "tax_20", + 'amount_type': 'percent', + 'amount': 20.0, + 'tax_group_id': self.tax_group1.id, + }) + tax_30 = self.env['account.tax'].create({ + 'name': "tax_30", + 'amount_type': 'percent', + 'amount': 30.0, + 'tax_group_id': self.tax_group2.id, + 'include_base_amount': True, + }) + + invoice = self.env['account.move'].create({ + 'move_type': 'out_invoice', + 'partner_id': self.partner_a.id, + 'invoice_date': '2019-01-01', + 'invoice_line_ids': [ + (0, 0, { + 'name': 'line', + 'account_id': self.company_data['default_account_revenue'].id, + 'price_unit': 1000.0, + 'tax_ids': [(6, 0, (tax_10 + tax_20).ids)], + }), + (0, 0, { + 'name': 'line', + 'account_id': self.company_data['default_account_revenue'].id, + 'price_unit': 1000.0, + 'tax_ids': [(6, 0, (tax_30 + tax_10).ids)], + }), + ] + }) + + self.assertAmountByTaxGroup(invoice, [ + (self.tax_group1.id, 2300.0, 450.0), + (self.tax_group2.id, 1000.0, 300.0), + ]) + + # Same but both are sharing the same tax group. + + tax_30.tax_group_id = self.tax_group1 + invoice.invalidate_cache(['amount_by_group']) + + self.assertAmountByTaxGroup(invoice, [ + (self.tax_group1.id, 2000.0, 750.0), + ]) diff --git a/addons/account/tests/test_invoice_taxes.py b/addons/account/tests/test_invoice_taxes.py new file mode 100644 index 00000000..83b9faa2 --- /dev/null +++ b/addons/account/tests/test_invoice_taxes.py @@ -0,0 +1,669 @@ +# -*- coding: utf-8 -*- +from odoo.addons.account.tests.common import AccountTestInvoicingCommon +from odoo.tests import tagged, Form + + +@tagged('post_install', '-at_install') +class TestInvoiceTaxes(AccountTestInvoicingCommon): + + @classmethod + def setUpClass(cls, chart_template_ref=None): + super().setUpClass(chart_template_ref=chart_template_ref) + + cls.company_data['company'].country_id = cls.env.ref('base.us') + + cls.percent_tax_1 = cls.env['account.tax'].create({ + 'name': '21%', + 'amount_type': 'percent', + 'amount': 21, + 'sequence': 10, + }) + cls.percent_tax_1_incl = cls.env['account.tax'].create({ + 'name': '21% incl', + 'amount_type': 'percent', + 'amount': 21, + 'price_include': True, + 'include_base_amount': True, + 'sequence': 20, + }) + cls.percent_tax_2 = cls.env['account.tax'].create({ + 'name': '12%', + 'amount_type': 'percent', + 'amount': 12, + 'sequence': 30, + }) + cls.percent_tax_3_incl = cls.env['account.tax'].create({ + 'name': '5% incl', + 'amount_type': 'percent', + 'amount': 5, + 'price_include': True, + 'include_base_amount': True, + 'sequence': 40, + }) + cls.group_tax = cls.env['account.tax'].create({ + 'name': 'group 12% + 21%', + 'amount_type': 'group', + 'amount': 21, + 'children_tax_ids': [ + (4, cls.percent_tax_1_incl.id), + (4, cls.percent_tax_2.id) + ], + 'sequence': 40, + }) + + cls.tax_report = cls.env['account.tax.report'].create({ + 'name': "Tax report", + 'country_id': cls.company_data['company'].country_id.id, + }) + + cls.tax_report_line = cls.env['account.tax.report.line'].create({ + 'name': 'test_tax_report_line', + 'tag_name': 'test_tax_report_line', + 'report_id': cls.tax_report.id, + 'sequence': 10, + }) + cls.tax_tag_pos = cls.tax_report_line.tag_ids.filtered(lambda x: not x.tax_negate) + cls.tax_tag_neg = cls.tax_report_line.tag_ids.filtered(lambda x: x.tax_negate) + cls.base_tax_report_line = cls.env['account.tax.report.line'].create({ + 'name': 'base_test_tax_report_line', + 'tag_name': 'base_test_tax_report_line', + 'report_id': cls.tax_report.id, + 'sequence': 10, + }) + cls.base_tag_pos = cls.base_tax_report_line.tag_ids.filtered(lambda x: not x.tax_negate) + cls.base_tag_neg = cls.base_tax_report_line.tag_ids.filtered(lambda x: x.tax_negate) + + def _create_invoice(self, taxes_per_line, inv_type='out_invoice', currency_id=False, invoice_payment_term_id=False): + ''' Create an invoice on the fly. + + :param taxes_per_line: A list of tuple (price_unit, account.tax recordset) + ''' + vals = { + 'move_type': inv_type, + 'partner_id': self.partner_a.id, + 'invoice_line_ids': [(0, 0, { + 'name': 'xxxx', + 'quantity': 1, + 'price_unit': amount, + 'tax_ids': [(6, 0, taxes.ids)], + }) for amount, taxes in taxes_per_line], + } + if currency_id: + vals['currency_id'] = currency_id.id + if invoice_payment_term_id: + vals['invoice_payment_term_id'] = invoice_payment_term_id.id + return self.env['account.move'].create(vals) + + def test_one_tax_per_line(self): + ''' Test: + price_unit | Taxes + ------------------ + 100 | 21% + 121 | 21% incl + 100 | 12% + + Expected: + Tax | Taxes | Base | Amount + -------------------------------------------- + 21% | / | 100 | 21 + 21% incl | / | 100 | 21 + 12% | / | 100 | 12 + ''' + invoice = self._create_invoice([ + (100, self.percent_tax_1), + (121, self.percent_tax_1_incl), + (100, self.percent_tax_2), + ]) + invoice.action_post() + self.assertRecordValues(invoice.line_ids.filtered('tax_line_id'), [ + {'name': self.percent_tax_1.name, 'tax_base_amount': 100, 'price_unit': 21, 'tax_ids': []}, + {'name': self.percent_tax_1_incl.name, 'tax_base_amount': 100, 'price_unit': 21, 'tax_ids': []}, + {'name': self.percent_tax_2.name, 'tax_base_amount': 100, 'price_unit': 12, 'tax_ids': []}, + ]) + + def test_affecting_base_amount(self): + ''' Test: + price_unit | Taxes + ------------------ + 121 | 21% incl, 12% + 100 | 12% + + Expected: + Tax | Taxes | Base | Amount + -------------------------------------------- + 21% incl | 12% | 100 | 21 + 12% | / | 121 | 14.52 + 12% | / | 100 | 12 + ''' + invoice = self._create_invoice([ + (121, self.percent_tax_1_incl + self.percent_tax_2), + (100, self.percent_tax_2), + ]) + invoice.action_post() + self.assertRecordValues(invoice.line_ids.filtered('tax_line_id').sorted(lambda x: x.price_unit), [ + {'name': self.percent_tax_1_incl.name, 'tax_base_amount': 100, 'price_unit': 21, 'tax_ids': [self.percent_tax_2.id]}, + {'name': self.percent_tax_2.name, 'tax_base_amount': 221, 'price_unit': 26.52, 'tax_ids': []}, + ]) + + def test_group_of_taxes(self): + ''' Test: + price_unit | Taxes + ------------------ + 121 | 21% incl + 12% + 100 | 12% + + Expected: + Tax | Taxes | Base | Amount + -------------------------------------------- + 21% incl | / | 100 | 21 + 12% | 21% incl | 121 | 14.52 + 12% | / | 100 | 12 + ''' + invoice = self._create_invoice([ + (121, self.group_tax), + (100, self.percent_tax_2), + ]) + invoice.action_post() + self.assertRecordValues(invoice.line_ids.filtered('tax_line_id').sorted(lambda x: x.price_unit), [ + {'name': self.percent_tax_1_incl.name, 'tax_base_amount': 100, 'price_unit': 21, 'tax_ids': [self.percent_tax_2.id]}, + {'name': self.percent_tax_2.name, 'tax_base_amount': 221, 'price_unit': 26.52, 'tax_ids': []}, + ]) + + def _create_tax_tag(self, tag_name): + return self.env['account.account.tag'].create({ + 'name': tag_name, + 'applicability': 'taxes', + 'country_id': self.env.company.country_id.id, + }) + + def test_tax_repartition(self): + inv_base_tag = self._create_tax_tag('invoice_base') + inv_tax_tag_10 = self._create_tax_tag('invoice_tax_10') + inv_tax_tag_90 = self._create_tax_tag('invoice_tax_90') + ref_base_tag = self._create_tax_tag('refund_base') + ref_tax_tag = self._create_tax_tag('refund_tax') + + user_type = self.env.ref('account.data_account_type_current_assets') + account_1 = self.env['account.account'].create({'name': 'test1', 'code': 'test1', 'user_type_id': user_type.id}) + account_2 = self.env['account.account'].create({'name': 'test2', 'code': 'test2', 'user_type_id': user_type.id}) + + tax = self.env['account.tax'].create({ + 'name': "Tax with account", + 'amount_type': 'fixed', + 'type_tax_use': 'sale', + 'amount': 42, + 'invoice_repartition_line_ids': [ + (0,0, { + 'factor_percent': 100, + 'repartition_type': 'base', + 'tag_ids': [(4, inv_base_tag.id, 0)], + }), + + (0,0, { + 'factor_percent': 10, + 'repartition_type': 'tax', + 'account_id': account_1.id, + 'tag_ids': [(4, inv_tax_tag_10.id, 0)], + }), + + (0,0, { + 'factor_percent': 90, + 'repartition_type': 'tax', + 'account_id': account_2.id, + 'tag_ids': [(4, inv_tax_tag_90.id, 0)], + }), + ], + 'refund_repartition_line_ids': [ + (0,0, { + 'factor_percent': 100, + 'repartition_type': 'base', + 'tag_ids': [(4, ref_base_tag.id, 0)], + }), + + (0,0, { + 'factor_percent': 10, + 'repartition_type': 'tax', + 'tag_ids': [(4, ref_tax_tag.id, 0)], + }), + + (0,0, { + 'factor_percent': 90, + 'repartition_type': 'tax', + 'account_id': account_1.id, + 'tag_ids': [(4, ref_tax_tag.id, 0)], + }), + ], + }) + + # Test invoice repartition + invoice = self._create_invoice([(100, tax)], inv_type='out_invoice') + invoice.action_post() + + self.assertEqual(len(invoice.line_ids), 4, "There should be 4 account move lines created for the invoice: payable, base and 2 tax lines") + inv_base_line = invoice.line_ids.filtered(lambda x: not x.tax_repartition_line_id and x.account_id.user_type_id.type != 'receivable') + self.assertEqual(len(inv_base_line), 1, "There should be only one base line generated") + self.assertEqual(abs(inv_base_line.balance), 100, "Base amount should be 100") + self.assertEqual(inv_base_line.tax_tag_ids, inv_base_tag, "Base line should have received base tag") + inv_tax_lines = invoice.line_ids.filtered(lambda x: x.tax_repartition_line_id.repartition_type == 'tax') + self.assertEqual(len(inv_tax_lines), 2, "There should be two tax lines, one for each repartition line.") + self.assertEqual(abs(inv_tax_lines.filtered(lambda x: x.account_id == account_1).balance), 4.2, "Tax line on account 1 should amount to 4.2 (10% of 42)") + self.assertEqual(inv_tax_lines.filtered(lambda x: x.account_id == account_1).tax_tag_ids, inv_tax_tag_10, "Tax line on account 1 should have 10% tag") + self.assertAlmostEqual(abs(inv_tax_lines.filtered(lambda x: x.account_id == account_2).balance), 37.8, 2, "Tax line on account 2 should amount to 37.8 (90% of 42)") + self.assertEqual(inv_tax_lines.filtered(lambda x: x.account_id == account_2).tax_tag_ids, inv_tax_tag_90, "Tax line on account 2 should have 90% tag") + + # Test refund repartition + refund = self._create_invoice([(100, tax)], inv_type='out_refund') + refund.action_post() + + self.assertEqual(len(refund.line_ids), 4, "There should be 4 account move lines created for the refund: payable, base and 2 tax lines") + ref_base_line = refund.line_ids.filtered(lambda x: not x.tax_repartition_line_id and x.account_id.user_type_id.type != 'receivable') + self.assertEqual(len(ref_base_line), 1, "There should be only one base line generated") + self.assertEqual(abs(ref_base_line.balance), 100, "Base amount should be 100") + self.assertEqual(ref_base_line.tax_tag_ids, ref_base_tag, "Base line should have received base tag") + ref_tax_lines = refund.line_ids.filtered(lambda x: x.tax_repartition_line_id.repartition_type == 'tax') + self.assertEqual(len(ref_tax_lines), 2, "There should be two refund tax lines") + self.assertEqual(abs(ref_tax_lines.filtered(lambda x: x.account_id == ref_base_line.account_id).balance), 4.2, "Refund tax line on base account should amount to 4.2 (10% of 42)") + self.assertAlmostEqual(abs(ref_tax_lines.filtered(lambda x: x.account_id == account_1).balance), 37.8, 2, "Refund tax line on account 1 should amount to 37.8 (90% of 42)") + self.assertEqual(ref_tax_lines.mapped('tax_tag_ids'), ref_tax_tag, "Refund tax lines should have the right tag") + + def test_division_tax(self): + ''' + Test that when using division tax, with percentage amount + 100% any change on price unit is correctly reflected on + the whole move. + + Complete scenario: + - Create a division tax, 100% amount, included in price. + - Create an invoice, with only the mentioned tax + - Change price_unit of the aml + - Total price of the move should change as well + ''' + + sale_tax = self.env['account.tax'].create({ + 'name': 'tax', + 'type_tax_use': 'sale', + 'amount_type': 'division', + 'amount': 100, + 'price_include': True, + 'include_base_amount': True, + }) + invoice = self._create_invoice([(100, sale_tax)]) + self.assertRecordValues(invoice.line_ids.filtered('tax_line_id'), [{ + 'name': sale_tax.name, + 'tax_base_amount': 0.0, + 'balance': -100, + }]) + # change price unit, everything should change as well + with Form(invoice) as invoice_form: + with invoice_form.line_ids.edit(0) as line_edit: + line_edit.price_unit = 200 + + self.assertRecordValues(invoice.line_ids.filtered('tax_line_id'), [{ + 'name': sale_tax.name, + 'tax_base_amount': 0.0, + 'balance': -200, + }]) + + def test_misc_journal_entry_tax_tags_sale(self): + sale_tax = self.env['account.tax'].create({ + 'name': 'tax', + 'type_tax_use': 'sale', + 'amount_type': 'percent', + 'amount': 10, + 'invoice_repartition_line_ids': [ + (0, 0, { + 'repartition_type': 'base', + 'factor_percent': 100.0, + 'tag_ids': [(6, 0, self.base_tag_pos.ids)], + }), + (0, 0, { + 'repartition_type': 'tax', + 'factor_percent': 100.0, + 'tag_ids': [(6, 0, self.tax_tag_pos.ids)], + }), + ], + 'refund_repartition_line_ids': [ + (0, 0, { + 'repartition_type': 'base', + 'factor_percent': 100.0, + 'tag_ids': [(6, 0, self.base_tag_neg.ids)], + }), + (0, 0, { + 'repartition_type': 'tax', + 'factor_percent': 100.0, + 'tag_ids': [(6, 0, self.tax_tag_neg.ids)], + }), + ], + }) + + inv_tax_rep_ln = sale_tax.invoice_repartition_line_ids.filtered(lambda x: x.repartition_type == 'tax') + ref_tax_rep_ln = sale_tax.refund_repartition_line_ids.filtered(lambda x: x.repartition_type == 'tax') + + # === Tax in debit === + + move_form = Form(self.env['account.move'], view='account.view_move_form') + move_form.ref = 'azerty' + + # Debit base tax line. + with move_form.line_ids.new() as credit_line: + credit_line.name = 'debit_line_1' + credit_line.account_id = self.company_data['default_account_revenue'] + credit_line.debit = 1000.0 + credit_line.tax_ids.clear() + credit_line.tax_ids.add(sale_tax) + + self.assertTrue(credit_line.recompute_tax_line) + + # Balance the journal entry. + with move_form.line_ids.new() as credit_line: + credit_line.name = 'balance' + credit_line.account_id = self.company_data['default_account_revenue'] + credit_line.credit = 1100.0 + + move = move_form.save() + + self.assertRecordValues(move.line_ids.sorted('balance'), [ + {'balance': -1100.0, 'tax_ids': [], 'tax_tag_ids': [], 'tax_base_amount': 0, 'tax_repartition_line_id': False}, + {'balance': 100.0, 'tax_ids': [], 'tax_tag_ids': self.tax_tag_neg.ids, 'tax_base_amount': 1000, 'tax_repartition_line_id': ref_tax_rep_ln.id}, + {'balance': 1000.0, 'tax_ids': sale_tax.ids, 'tax_tag_ids': self.base_tag_neg.ids, 'tax_base_amount': 0, 'tax_repartition_line_id': False}, + ]) + + # === Tax in credit === + + move_form = Form(self.env['account.move'], view='account.view_move_form') + move_form.ref = 'azerty' + + # Debit base tax line. + with move_form.line_ids.new() as credit_line: + credit_line.name = 'debit_line_1' + credit_line.account_id = self.company_data['default_account_revenue'] + credit_line.credit = 1000.0 + credit_line.tax_ids.clear() + credit_line.tax_ids.add(sale_tax) + + self.assertTrue(credit_line.recompute_tax_line) + + # Balance the journal entry. + with move_form.line_ids.new() as debit_line: + debit_line.name = 'balance' + debit_line.account_id = self.company_data['default_account_revenue'] + debit_line.debit = 1100.0 + + move = move_form.save() + + self.assertRecordValues(move.line_ids.sorted('balance'), [ + {'balance': -1000.0, 'tax_ids': sale_tax.ids, 'tax_tag_ids': self.base_tag_neg.ids, 'tax_base_amount': 0, 'tax_repartition_line_id': False}, + {'balance': -100.0, 'tax_ids': [], 'tax_tag_ids': self.tax_tag_neg.ids, 'tax_base_amount': 1000, 'tax_repartition_line_id': inv_tax_rep_ln.id}, + {'balance': 1100.0, 'tax_ids': [], 'tax_tag_ids': [], 'tax_base_amount': 0, 'tax_repartition_line_id': False}, + ]) + + def test_misc_journal_entry_tax_tags_purchase(self): + purch_tax = self.env['account.tax'].create({ + 'name': 'tax', + 'type_tax_use': 'purchase', + 'amount_type': 'percent', + 'amount': 10, + 'invoice_repartition_line_ids': [ + (0, 0, { + 'repartition_type': 'base', + 'factor_percent': 100.0, + 'tag_ids': [(6, 0, self.base_tag_pos.ids)], + }), + (0, 0, { + 'repartition_type': 'tax', + 'factor_percent': 100.0, + 'tag_ids': [(6, 0, self.tax_tag_pos.ids)], + }), + ], + 'refund_repartition_line_ids': [ + (0, 0, { + 'repartition_type': 'base', + 'factor_percent': 100.0, + 'tag_ids': [(6, 0, self.base_tag_neg.ids)], + }), + (0, 0, { + 'repartition_type': 'tax', + 'factor_percent': 100.0, + 'tag_ids': [(6, 0, self.tax_tag_neg.ids)], + }), + ], + }) + + inv_tax_rep_ln = purch_tax.invoice_repartition_line_ids.filtered(lambda x: x.repartition_type == 'tax') + ref_tax_rep_ln = purch_tax.refund_repartition_line_ids.filtered(lambda x: x.repartition_type == 'tax') + + # === Tax in debit === + + move_form = Form(self.env['account.move']) + move_form.ref = 'azerty' + + # Debit base tax line. + with move_form.line_ids.new() as credit_line: + credit_line.name = 'debit_line_1' + credit_line.account_id = self.company_data['default_account_revenue'] + credit_line.debit = 1000.0 + credit_line.tax_ids.clear() + credit_line.tax_ids.add(purch_tax) + + self.assertTrue(credit_line.recompute_tax_line) + + # Balance the journal entry. + with move_form.line_ids.new() as credit_line: + credit_line.name = 'balance' + credit_line.account_id = self.company_data['default_account_revenue'] + credit_line.credit = 1100.0 + + move = move_form.save() + + self.assertRecordValues(move.line_ids.sorted('balance'), [ + {'balance': -1100.0, 'tax_ids': [], 'tax_tag_ids': [], 'tax_base_amount': 0, 'tax_repartition_line_id': False}, + {'balance': 100.0, 'tax_ids': [], 'tax_tag_ids': self.tax_tag_pos.ids, 'tax_base_amount': 1000, 'tax_repartition_line_id': inv_tax_rep_ln.id}, + {'balance': 1000.0, 'tax_ids': purch_tax.ids, 'tax_tag_ids': self.base_tag_pos.ids, 'tax_base_amount': 0, 'tax_repartition_line_id': False}, + ]) + + # === Tax in credit === + + move_form = Form(self.env['account.move']) + move_form.ref = 'azerty' + + # Debit base tax line. + with move_form.line_ids.new() as credit_line: + credit_line.name = 'debit_line_1' + credit_line.account_id = self.company_data['default_account_revenue'] + credit_line.credit = 1000.0 + credit_line.tax_ids.clear() + credit_line.tax_ids.add(purch_tax) + + self.assertTrue(credit_line.recompute_tax_line) + + # Balance the journal entry. + with move_form.line_ids.new() as debit_line: + debit_line.name = 'balance' + debit_line.account_id = self.company_data['default_account_revenue'] + debit_line.debit = 1100.0 + + move = move_form.save() + + self.assertRecordValues(move.line_ids.sorted('balance'), [ + {'balance': -1000.0, 'tax_ids': purch_tax.ids, 'tax_tag_ids': self.base_tag_pos.ids, 'tax_base_amount': 0, 'tax_repartition_line_id': False}, + {'balance': -100.0, 'tax_ids': [], 'tax_tag_ids': self.tax_tag_pos.ids, 'tax_base_amount': 1000, 'tax_repartition_line_id': ref_tax_rep_ln.id}, + {'balance': 1100.0, 'tax_ids': [], 'tax_tag_ids': [], 'tax_base_amount': 0, 'tax_repartition_line_id': False}, + ]) + + def test_misc_entry_tax_group_signs(self): + """ Tests sign inversion of the tags on misc operations made with tax + groups. + """ + def _create_group_of_taxes(tax_type): + # We use asymmetric tags between the child taxes to avoid shadowing errors + child1_sale_tax = self.env['account.tax'].create({ + 'sequence': 1, + 'name': 'child1_%s' % tax_type, + 'type_tax_use': 'none', + 'amount_type': 'percent', + 'amount': 5, + 'invoice_repartition_line_ids': [ + (0, 0, { + 'repartition_type': 'base', + 'factor_percent': 100.0, + 'tag_ids': [(6, 0, self.base_tag_pos.ids)], + }), + (0, 0, { + 'repartition_type': 'tax', + 'factor_percent': 100.0, + 'tag_ids': [(6, 0, self.tax_tag_pos.ids)], + }), + ], + 'refund_repartition_line_ids': [ + (0, 0, { + 'repartition_type': 'base', + 'factor_percent': 100.0, + }), + (0, 0, { + 'repartition_type': 'tax', + 'factor_percent': 100.0, + }), + ], + }) + child2_sale_tax = self.env['account.tax'].create({ + 'sequence': 2, + 'name': 'child2_%s' % tax_type, + 'type_tax_use': 'none', + 'amount_type': 'percent', + 'amount': 10, + 'invoice_repartition_line_ids': [ + (0, 0, { + 'repartition_type': 'base', + 'factor_percent': 100.0, + }), + (0, 0, { + 'repartition_type': 'tax', + 'factor_percent': 100.0, + }), + ], + 'refund_repartition_line_ids': [ + (0, 0, { + 'repartition_type': 'base', + 'factor_percent': 100.0, + 'tag_ids': [(6, 0, self.base_tag_neg.ids)], + }), + (0, 0, { + 'repartition_type': 'tax', + 'factor_percent': 100.0, + 'tag_ids': [(6, 0, self.tax_tag_neg.ids)], + }), + ], + }) + return self.env['account.tax'].create({ + 'name': 'group_%s' % tax_type, + 'type_tax_use': tax_type, + 'amount_type': 'group', + 'amount': 10, + 'children_tax_ids':[(6,0,[child1_sale_tax.id, child2_sale_tax.id])] + }) + + def _create_misc_operation(tax, tax_field): + with Form(self.env['account.move'], view='account.view_move_form') as move_form: + for line_field in ('debit', 'credit'): + line_amount = tax_field == line_field and 1000 or 1150 + with move_form.line_ids.new() as line_form: + line_form.name = '%s_line' % line_field + line_form.account_id = self.company_data['default_account_revenue'] + line_form.debit = line_field == 'debit' and line_amount or 0 + line_form.credit = line_field == 'credit' and line_amount or 0 + + if tax_field == line_field: + line_form.tax_ids.clear() + line_form.tax_ids.add(tax) + + return move_form.save() + + sale_group = _create_group_of_taxes('sale') + purchase_group = _create_group_of_taxes('purchase') + + # Sale tax on debit: use refund repartition + debit_sale_move = _create_misc_operation(sale_group, 'debit') + self.assertRecordValues(debit_sale_move.line_ids.sorted('balance'), [ + {'balance': -1150.0, 'tax_ids': [], 'tax_tag_ids': [], 'tax_base_amount': 0}, + {'balance': 50.0, 'tax_ids': [], 'tax_tag_ids': [], 'tax_base_amount': 1000}, + {'balance': 100.0, 'tax_ids': [], 'tax_tag_ids': self.tax_tag_neg.ids, 'tax_base_amount': 1000}, + {'balance': 1000.0, 'tax_ids': sale_group.ids, 'tax_tag_ids': self.base_tag_neg.ids, 'tax_base_amount': 0}, + ]) + + # Sale tax on credit: use invoice repartition and invert tags + credit_sale_move = _create_misc_operation(sale_group, 'credit') + self.assertRecordValues(credit_sale_move.line_ids.sorted('balance'), [ + {'balance': -1000.0, 'tax_ids': sale_group.ids, 'tax_tag_ids': self.base_tag_neg.ids, 'tax_base_amount': 0}, + {'balance': -100.0, 'tax_ids': [], 'tax_tag_ids': [], 'tax_base_amount': 1000}, + {'balance': -50.0, 'tax_ids': [], 'tax_tag_ids': self.tax_tag_neg.ids, 'tax_base_amount': 1000}, + {'balance': 1150.0, 'tax_ids': [], 'tax_tag_ids': [], 'tax_base_amount': 0}, + ]) + + # Purchase tax on debit: use invoice repartition + debit_purchase_move = _create_misc_operation(purchase_group, 'debit') + self.assertRecordValues(debit_purchase_move.line_ids.sorted('balance'), [ + {'balance': -1150.0, 'tax_ids': [], 'tax_tag_ids': [], 'tax_base_amount': 0}, + {'balance': 50.0, 'tax_ids': [], 'tax_tag_ids': self.tax_tag_pos.ids, 'tax_base_amount': 1000}, + {'balance': 100.0, 'tax_ids': [], 'tax_tag_ids': [], 'tax_base_amount': 1000}, + {'balance': 1000.0, 'tax_ids': purchase_group.ids, 'tax_tag_ids': self.base_tag_pos.ids, 'tax_base_amount': 0}, + ]) + + # Purchase tax on credit: use refund repartition and invert tags + credit_purchase_move = _create_misc_operation(purchase_group, 'credit') + self.assertRecordValues(credit_purchase_move.line_ids.sorted('balance'), [ + {'balance': -1000.0, 'tax_ids': purchase_group.ids, 'tax_tag_ids': self.base_tag_pos.ids, 'tax_base_amount': 0}, + {'balance': -100.0, 'tax_ids': [], 'tax_tag_ids': self.tax_tag_pos.ids, 'tax_base_amount': 1000}, + {'balance': -50.0, 'tax_ids': [], 'tax_tag_ids': [], 'tax_base_amount': 1000}, + {'balance': 1150.0, 'tax_ids': [], 'tax_tag_ids': [], 'tax_base_amount': 0}, + ]) + + def test_tax_calculation_foreign_currency_large_quantity(self): + ''' Test: + Foreign currency with rate of 1.1726 and tax of 21% + price_unit | Quantity | Taxes + ------------------ + 2.82 | 20000 | 21% not incl + ''' + self.env['res.currency.rate'].create({ + 'name': '2018-01-01', + 'rate': 1.1726, + 'currency_id': self.currency_data['currency'].id, + 'company_id': self.env.company.id, + }) + self.currency_data['currency'].rounding = 0.05 + + invoice = self.env['account.move'].create({ + 'move_type': 'out_invoice', + 'partner_id': self.partner_a.id, + 'currency_id': self.currency_data['currency'].id, + 'invoice_date': '2018-01-01', + 'date': '2018-01-01', + 'invoice_line_ids': [(0, 0, { + 'name': 'xxxx', + 'quantity': 20000, + 'price_unit': 2.82, + 'tax_ids': [(6, 0, self.percent_tax_1.ids)], + })] + }) + + self.assertRecordValues(invoice.line_ids.filtered('tax_line_id'), [{ + 'tax_base_amount': 48098.24, # 20000 * 2.82 / 1.1726 + 'credit': 10100.63, # tax_base_amount * 0.21 + }]) + + def test_ensure_no_unbalanced_entry(self): + ''' Ensure to not create an unbalanced journal entry when saving. ''' + self.env['res.currency.rate'].create({ + 'name': '2018-01-01', + 'rate': 0.654065014, + 'currency_id': self.currency_data['currency'].id, + 'company_id': self.env.company.id, + }) + self.currency_data['currency'].rounding = 0.05 + + invoice = self._create_invoice([ + (5, self.percent_tax_3_incl), + (10, self.percent_tax_3_incl), + (50, self.percent_tax_3_incl), + ], currency_id=self.currency_data['currency'], invoice_payment_term_id=self.pay_terms_a) + invoice.action_post() diff --git a/addons/account/tests/test_payment_term.py b/addons/account/tests/test_payment_term.py new file mode 100644 index 00000000..a3133247 --- /dev/null +++ b/addons/account/tests/test_payment_term.py @@ -0,0 +1,98 @@ +# -*- coding: utf-8 -*- + +from odoo.addons.account.tests.common import AccountTestInvoicingCommon +from odoo.tests import tagged +from odoo import fields +from odoo.tests.common import Form + + +@tagged('post_install', '-at_install') +class TestAccountInvoiceRounding(AccountTestInvoicingCommon): + @classmethod + def setUpClass(cls, chart_template_ref=None): + super().setUpClass(chart_template_ref=chart_template_ref) + cls.pay_term_today = cls.env['account.payment.term'].create({ + 'name': 'Today', + 'line_ids': [ + (0, 0, { + 'value': 'balance', + 'days': 0, + 'option': 'day_after_invoice_date', + }), + ], + }) + + cls.pay_term_min_31days_15th = cls.env['account.payment.term'].create({ + 'name': 'the 15th of the month, min 31 days from now', + 'line_ids': [ + (0, 0, { + 'value': 'balance', + 'days': 31, + 'day_of_the_month': 15, + 'option': 'day_after_invoice_date', + }), + ], + }) + + cls.pay_term_45_end_month = cls.env['account.payment.term'].create({ + 'name': '45 Days from End of Month', + 'line_ids': [ + (0, 0, { + 'value': 'balance', + 'days': 45, + 'option': 'after_invoice_month', + }), + ], + }) + + cls.pay_term_last_day_of_month = cls.env['account.payment.term'].create({ + 'name': 'Last Day of month', + 'line_ids': [ + (0, 0, { + 'value': 'balance', + 'days': 31, + 'option': 'day_current_month', + }), + ], + }) + + cls.pay_term_first_day_next_month = cls.env['account.payment.term'].create({ + 'name': 'First day next month', + 'line_ids': [ + (0, 0, { + 'value': 'balance', + 'days': 1, + 'option': 'day_following_month', + }), + ], + }) + + cls.invoice = cls.init_invoice('out_refund', products=cls.product_a+cls.product_b) + + def assertPaymentTerm(self, pay_term, invoice_date, dates): + with Form(self.invoice) as move_form: + move_form.invoice_payment_term_id = pay_term + move_form.invoice_date = invoice_date + self.assertEqual( + self.invoice.line_ids.filtered( + lambda l: l.account_id == self.company_data['default_account_receivable'] + ).mapped('date_maturity'), + [fields.Date.from_string(date) for date in dates], + ) + + def test_payment_term(self): + self.assertPaymentTerm(self.pay_term_today, '2019-01-01', ['2019-01-01']) + self.assertPaymentTerm(self.pay_term_today, '2019-01-15', ['2019-01-15']) + self.assertPaymentTerm(self.pay_term_today, '2019-01-31', ['2019-01-31']) + self.assertPaymentTerm(self.pay_term_45_end_month, '2019-01-01', ['2019-03-17']) + self.assertPaymentTerm(self.pay_term_45_end_month, '2019-01-15', ['2019-03-17']) + self.assertPaymentTerm(self.pay_term_45_end_month, '2019-01-31', ['2019-03-17']) + self.assertPaymentTerm(self.pay_term_min_31days_15th, '2019-01-01', ['2019-02-15']) + self.assertPaymentTerm(self.pay_term_min_31days_15th, '2019-01-15', ['2019-02-15']) + self.assertPaymentTerm(self.pay_term_min_31days_15th, '2019-01-31', ['2019-03-15']) + self.assertPaymentTerm(self.pay_term_last_day_of_month, '2019-01-01', ['2019-01-31']) + self.assertPaymentTerm(self.pay_term_last_day_of_month, '2019-01-15', ['2019-01-31']) + self.assertPaymentTerm(self.pay_term_last_day_of_month, '2019-01-31', ['2019-01-31']) + self.assertPaymentTerm(self.pay_term_first_day_next_month, '2019-01-01', ['2019-02-01']) + self.assertPaymentTerm(self.pay_term_first_day_next_month, '2019-01-15', ['2019-02-01']) + self.assertPaymentTerm(self.pay_term_first_day_next_month, '2019-01-31', ['2019-02-01']) diff --git a/addons/account/tests/test_portal_attachment.py b/addons/account/tests/test_portal_attachment.py new file mode 100644 index 00000000..2340fc75 --- /dev/null +++ b/addons/account/tests/test_portal_attachment.py @@ -0,0 +1,272 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from odoo.addons.account.tests.common import AccountTestInvoicingHttpCommon +from odoo.tests.common import tagged + +import json + +from odoo import http +from odoo.tools import mute_logger + + +@tagged('post_install', '-at_install') +class TestPortalAttachment(AccountTestInvoicingHttpCommon): + + @classmethod + def setUpClass(cls, chart_template_ref=None): + super().setUpClass(chart_template_ref=chart_template_ref) + + cls.out_invoice = cls.env['account.move'].with_context(tracking_disable=True).create({ + 'move_type': 'out_invoice', + 'partner_id': cls.partner_a.id, + 'invoice_date': '2019-05-01', + 'date': '2019-05-01', + 'invoice_line_ids': [ + (0, 0, {'name': 'line1', 'price_unit': 100.0}), + ], + }) + + cls.base_url = cls.env['ir.config_parameter'].sudo().get_param('web.base.url') + + @mute_logger('odoo.addons.http_routing.models.ir_http', 'odoo.http') + def test_01_portal_attachment(self): + """Test the portal chatter attachment route.""" + + self.authenticate(None, None) + + # Test public user can't create attachment without token of document + res = self.url_open( + url='%s/portal/attachment/add' % self.base_url, + data={ + 'name': "new attachment", + 'res_model': self.out_invoice._name, + 'res_id': self.out_invoice.id, + 'csrf_token': http.WebRequest.csrf_token(self), + }, + files=[('file', ('test.txt', b'test', 'plain/text'))], + ) + self.assertEqual(res.status_code, 400) + self.assertIn("you do not have the rights", res.text) + + # Test public user can create attachment with token + res = self.url_open( + url='%s/portal/attachment/add' % self.base_url, + data={ + 'name': "new attachment", + 'res_model': self.out_invoice._name, + 'res_id': self.out_invoice.id, + 'csrf_token': http.WebRequest.csrf_token(self), + 'access_token': self.out_invoice._portal_ensure_token(), + }, + files=[('file', ('test.txt', b'test', 'plain/text'))], + ) + self.assertEqual(res.status_code, 200) + create_res = json.loads(res.content.decode('utf-8')) + self.assertTrue(self.env['ir.attachment'].sudo().search([('id', '=', create_res['id'])])) + + # Test created attachment is private + res_binary = self.url_open('/web/content/%d' % create_res['id']) + self.assertEqual(res_binary.status_code, 404) + + # Test created access_token is working + res_binary = self.url_open('/web/content/%d?access_token=%s' % (create_res['id'], create_res['access_token'])) + self.assertEqual(res_binary.status_code, 200) + + # Test mimetype is neutered as non-admin + res = self.url_open( + url='%s/portal/attachment/add' % self.base_url, + data={ + 'name': "new attachment", + 'res_model': self.out_invoice._name, + 'res_id': self.out_invoice.id, + 'csrf_token': http.WebRequest.csrf_token(self), + 'access_token': self.out_invoice._portal_ensure_token(), + }, + files=[('file', ('test.svg', b'<svg></svg>', 'image/svg+xml'))], + ) + self.assertEqual(res.status_code, 200) + create_res = json.loads(res.content.decode('utf-8')) + self.assertEqual(create_res['mimetype'], 'text/plain') + + res_binary = self.url_open('/web/content/%d?access_token=%s' % (create_res['id'], create_res['access_token'])) + self.assertEqual(res_binary.headers['Content-Type'], 'text/plain') + self.assertEqual(res_binary.content, b'<svg></svg>') + + res_image = self.url_open('/web/image/%d?access_token=%s' % (create_res['id'], create_res['access_token'])) + self.assertEqual(res_image.headers['Content-Type'], 'text/plain') + self.assertEqual(res_image.content, b'<svg></svg>') + + # Test attachment can't be removed without valid token + res = self.opener.post( + url='%s/portal/attachment/remove' % self.base_url, + json={ + 'params': { + 'attachment_id': create_res['id'], + 'access_token': "wrong", + }, + }, + ) + self.assertEqual(res.status_code, 200) + self.assertTrue(self.env['ir.attachment'].sudo().search([('id', '=', create_res['id'])])) + self.assertIn("you do not have the rights", res.text) + + # Test attachment can be removed with token if "pending" state + res = self.opener.post( + url='%s/portal/attachment/remove' % self.base_url, + json={ + 'params': { + 'attachment_id': create_res['id'], + 'access_token': create_res['access_token'], + }, + }, + ) + self.assertEqual(res.status_code, 200) + remove_res = json.loads(res.content.decode('utf-8'))['result'] + self.assertFalse(self.env['ir.attachment'].sudo().search([('id', '=', create_res['id'])])) + self.assertTrue(remove_res is True) + + # Test attachment can't be removed if not "pending" state + attachment = self.env['ir.attachment'].create({ + 'name': 'an attachment', + 'access_token': self.env['ir.attachment']._generate_access_token(), + }) + res = self.opener.post( + url='%s/portal/attachment/remove' % self.base_url, + json={ + 'params': { + 'attachment_id': attachment.id, + 'access_token': attachment.access_token, + }, + }, + ) + self.assertEqual(res.status_code, 200) + self.assertTrue(self.env['ir.attachment'].sudo().search([('id', '=', attachment.id)])) + self.assertIn("not in a pending state", res.text) + + # Test attachment can't be removed if attached to a message + attachment.write({ + 'res_model': 'mail.compose.message', + 'res_id': 0, + }) + attachment.flush() + message = self.env['mail.message'].create({ + 'attachment_ids': [(6, 0, attachment.ids)], + }) + res = self.opener.post( + url='%s/portal/attachment/remove' % self.base_url, + json={ + 'params': { + 'attachment_id': attachment.id, + 'access_token': attachment.access_token, + }, + }, + ) + self.assertEqual(res.status_code, 200) + self.assertTrue(attachment.exists()) + self.assertIn("it is linked to a message", res.text) + message.sudo().unlink() + + # Test attachment can't be associated if no attachment token. + res = self.url_open( + url='%s/mail/chatter_post' % self.base_url, + data={ + 'res_model': self.out_invoice._name, + 'res_id': self.out_invoice.id, + 'message': "test message 1", + 'attachment_ids': attachment.id, + 'attachment_tokens': 'false', + 'csrf_token': http.WebRequest.csrf_token(self), + }, + ) + self.assertEqual(res.status_code, 400) + self.assertIn("The attachment %s does not exist or you do not have the rights to access it." % attachment.id, res.text) + + # Test attachment can't be associated if no main document token + res = self.url_open( + url='%s/mail/chatter_post' % self.base_url, + data={ + 'res_model': self.out_invoice._name, + 'res_id': self.out_invoice.id, + 'message': "test message 1", + 'attachment_ids': attachment.id, + 'attachment_tokens': attachment.access_token, + 'csrf_token': http.WebRequest.csrf_token(self), + }, + ) + self.assertEqual(res.status_code, 403) + self.assertIn("You are not allowed to access 'Journal Entry' (account.move) records.", res.text) + + # Test attachment can't be associated if not "pending" state + self.assertFalse(self.out_invoice.message_ids) + attachment.write({'res_model': 'model'}) + res = self.url_open( + url='%s/mail/chatter_post' % self.base_url, + data={ + 'res_model': self.out_invoice._name, + 'res_id': self.out_invoice.id, + 'message': "test message 1", + 'attachment_ids': attachment.id, + 'attachment_tokens': attachment.access_token, + 'csrf_token': http.WebRequest.csrf_token(self), + 'token': self.out_invoice._portal_ensure_token(), + }, + ) + self.assertEqual(res.status_code, 200) + self.out_invoice.invalidate_cache(fnames=['message_ids'], ids=self.out_invoice.ids) + self.assertEqual(len(self.out_invoice.message_ids), 1) + self.assertEqual(self.out_invoice.message_ids.body, "<p>test message 1</p>") + self.assertFalse(self.out_invoice.message_ids.attachment_ids) + + # Test attachment can't be associated if not correct user + attachment.write({'res_model': 'mail.compose.message'}) + res = self.url_open( + url='%s/mail/chatter_post' % self.base_url, + data={ + 'res_model': self.out_invoice._name, + 'res_id': self.out_invoice.id, + 'message': "test message 2", + 'attachment_ids': attachment.id, + 'attachment_tokens': attachment.access_token, + 'csrf_token': http.WebRequest.csrf_token(self), + 'token': self.out_invoice._portal_ensure_token(), + }, + ) + self.assertEqual(res.status_code, 200) + self.out_invoice.invalidate_cache(fnames=['message_ids'], ids=self.out_invoice.ids) + self.assertEqual(len(self.out_invoice.message_ids), 2) + self.assertEqual(self.out_invoice.message_ids[0].body, "<p>test message 2</p>") + self.assertFalse(self.out_invoice.message_ids.attachment_ids) + + # Test attachment can be associated if all good (complete flow) + res = self.url_open( + url='%s/portal/attachment/add' % self.base_url, + data={ + 'name': "final attachment", + 'res_model': self.out_invoice._name, + 'res_id': self.out_invoice.id, + 'csrf_token': http.WebRequest.csrf_token(self), + 'access_token': self.out_invoice._portal_ensure_token(), + }, + files=[('file', ('test.txt', b'test', 'plain/text'))], + ) + self.assertEqual(res.status_code, 200) + create_res = json.loads(res.content.decode('utf-8')) + self.assertEqual(create_res['name'], "final attachment") + + res = self.url_open( + url='%s/mail/chatter_post' % self.base_url, + data={ + 'res_model': self.out_invoice._name, + 'res_id': self.out_invoice.id, + 'message': "test message 3", + 'attachment_ids': create_res['id'], + 'attachment_tokens': create_res['access_token'], + 'csrf_token': http.WebRequest.csrf_token(self), + 'token': self.out_invoice._portal_ensure_token(), + }, + ) + self.assertEqual(res.status_code, 200) + self.out_invoice.invalidate_cache(fnames=['message_ids'], ids=self.out_invoice.ids) + self.assertEqual(len(self.out_invoice.message_ids), 3) + self.assertEqual(self.out_invoice.message_ids[0].body, "<p>test message 3</p>") + self.assertEqual(len(self.out_invoice.message_ids[0].attachment_ids), 1) diff --git a/addons/account/tests/test_reconciliation.py b/addons/account/tests/test_reconciliation.py new file mode 100644 index 00000000..98559eab --- /dev/null +++ b/addons/account/tests/test_reconciliation.py @@ -0,0 +1,1208 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import time +import unittest +from datetime import timedelta + +from odoo import api, fields +from odoo.addons.account.tests.common import TestAccountReconciliationCommon +from odoo.tests import Form, tagged + + +@tagged('post_install', '-at_install') +class TestReconciliationExec(TestAccountReconciliationCommon): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env['res.currency.rate'].search([]).unlink() + + def test_statement_euro_invoice_usd_transaction_euro_full(self): + self.env['res.currency.rate'].create({ + 'name': '%s-07-01' % time.strftime('%Y'), + 'rate': 1.5289, + 'currency_id': self.currency_usd_id, + }) + # Create a customer invoice of 50 USD. + partner = self.env['res.partner'].create({'name': 'test'}) + move = self.env['account.move'].with_context(default_move_type='out_invoice').create({ + 'move_type': 'out_invoice', + 'partner_id': partner.id, + 'invoice_date': '%s-07-01' % time.strftime('%Y'), + 'date': '%s-07-01' % time.strftime('%Y'), + 'currency_id': self.currency_usd_id, + 'invoice_line_ids': [ + (0, 0, {'quantity': 1, 'price_unit': 50.0, 'name': 'test'}) + ], + }) + move.action_post() + + # Create a bank statement of 40 EURO. + bank_stmt = self.env['account.bank.statement'].create({ + 'journal_id': self.bank_journal_euro.id, + 'date': '%s-01-01' % time.strftime('%Y'), + 'line_ids': [ + (0, 0, { + 'payment_ref': 'test', + 'partner_id': partner.id, + 'amount': 40.0, + 'date': '%s-01-01' % time.strftime('%Y') + }) + ], + }) + + # Reconcile the bank statement with the invoice. + receivable_line = move.line_ids.filtered(lambda line: line.account_id.user_type_id.type in ('receivable', 'payable')) + bank_stmt.button_post() + bank_stmt.line_ids[0].reconcile([ + {'id': receivable_line.id}, + {'name': 'exchange difference', 'balance': -7.3, 'account_id': self.diff_income_account.id}, + ]) + + self.assertRecordValues(bank_stmt.line_ids.line_ids, [ + {'debit': 40.0, 'credit': 0.0, 'amount_currency': 40.0, 'currency_id': self.currency_euro_id}, + {'debit': 0.0, 'credit': 7.3, 'amount_currency': -7.3, 'currency_id': self.currency_euro_id}, + {'debit': 0.0, 'credit': 32.7, 'amount_currency': -32.7, 'currency_id': self.currency_euro_id}, + ]) + + # The invoice should be paid, as the payments totally cover its total + self.assertEqual(move.payment_state, 'paid', 'The invoice should be paid by now') + self.assertTrue(receivable_line.reconciled, 'The invoice should be totally reconciled') + self.assertTrue(receivable_line.full_reconcile_id, 'The invoice should have a full reconcile number') + self.assertEqual(receivable_line.amount_residual, 0, 'The invoice should be totally reconciled') + self.assertEqual(receivable_line.amount_residual_currency, 0, 'The invoice should be totally reconciled') + + @unittest.skip('adapt to new accounting') + 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. + env = api.Environment(self.cr, self.uid, {}) + # We update the currency rate of the currency USD in order to force the gain/loss exchanges in next steps + rateUSDbis = env.ref("base.rateUSDbis") + rateUSDbis.write({ + 'name': time.strftime('%Y-%m-%d') + ' 00:00:00', + 'rate': 0.033, + }) + # We create a customer invoice of 2.00 USD + invoice = self.account_invoice_model.create({ + 'partner_id': self.partner_agrolait_id, + 'currency_id': self.currency_usd_id, + 'name': 'Foreign invoice with exchange gain', + 'account_id': self.account_rcv_id, + 'move_type': 'out_invoice', + 'invoice_date': time.strftime('%Y-%m-%d'), + 'date': 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, + }) + ] + }) + invoice.action_post() + # We create a bank statement with two lines of 1.00 USD each. + statement = self.env['account.bank.statement'].create({ + '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') + }) + ] + }) + + # We process the reconciliation of the invoice line with the two bank statement lines + line_id = None + for l in invoice.line_id: + if l.account_id.id == self.account_rcv_id: + line_id = l + break + for statement_line in statement.line_ids: + statement_line.reconcile([{'id': line_id.id}]) + + # The invoice should be paid, as the payments totally cover its total + self.assertEqual(invoice.state, 'paid', 'The invoice should be paid by now') + reconcile = None + for payment in invoice.payment_ids: + reconcile = payment.reconcile_model_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.assertEqual(sum(res['debit'] for res in result.values()), 60.61) + self.assertEqual(sum(res['credit'] for res in result.items()), 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"') + + def test_manual_reconcile_wizard_opw678153(self): + + def create_move(name, amount, amount_currency, currency_id): + debit_line_vals = { + 'name': name, + 'debit': amount > 0 and amount or 0.0, + 'credit': amount < 0 and -amount or 0.0, + 'account_id': self.account_rcv.id, + 'amount_currency': amount_currency, + 'currency_id': currency_id, + } + credit_line_vals = debit_line_vals.copy() + credit_line_vals['debit'] = debit_line_vals['credit'] + credit_line_vals['credit'] = debit_line_vals['debit'] + credit_line_vals['account_id'] = self.account_rsa.id + credit_line_vals['amount_currency'] = -debit_line_vals['amount_currency'] + vals = { + 'journal_id': self.bank_journal_euro.id, + 'line_ids': [(0,0, debit_line_vals), (0, 0, credit_line_vals)] + } + move = self.env['account.move'].create(vals) + move.action_post() + return move.id + move_list_vals = [ + ('1', -1.83, 0, self.currency_swiss_id), + ('2', 728.35, 795.05, self.currency_swiss_id), + ('3', -4.46, 0, self.currency_swiss_id), + ('4', 0.32, 0, self.currency_swiss_id), + ('5', 14.72, 16.20, self.currency_swiss_id), + ('6', -737.10, -811.25, self.currency_swiss_id), + ] + move_ids = [] + for name, amount, amount_currency, currency_id in move_list_vals: + move_ids.append(create_move(name, amount, amount_currency, currency_id)) + aml_recs = self.env['account.move.line'].search([('move_id', 'in', move_ids), ('account_id', '=', self.account_rcv.id), ('reconciled', '=', False)]) + aml_recs.reconcile() + for aml in aml_recs: + self.assertTrue(aml.reconciled, 'The journal item should be totally reconciled') + self.assertEqual(aml.amount_residual, 0, 'The journal item should be totally reconciled') + self.assertEqual(aml.amount_residual_currency, 0, 'The journal item should be totally reconciled') + + def test_partial_reconcile_currencies_01(self): + # client Account (payable, rsa) + # Debit Credit + # -------------------------------------------------------- + # Pay a : 25/0.5 = 50 | Inv a : 50/0.5 = 100 + # Pay b: 50/0.75 = 66.66 | Inv b : 50/0.75 = 66.66 + # Pay c: 25/0.8 = 31.25 | + # + # Debit_currency = 100 | Credit currency = 100 + # Debit = 147.91 | Credit = 166.66 + # Balance Debit = 18.75 + # Counterpart Credit goes in Exchange diff + + dest_journal_id = self.env['account.journal'].create({ + 'name': 'dest_journal_id', + 'type': 'bank', + }) + + # Setting up rates for USD (main_company is in EUR) + self.env['res.currency.rate'].create({'name': time.strftime('%Y') + '-' + '07' + '-01', + 'rate': 0.5, + 'currency_id': self.currency_usd_id, + 'company_id': self.company.id}) + + self.env['res.currency.rate'].create({'name': time.strftime('%Y') + '-' + '08' + '-01', + 'rate': 0.75, + 'currency_id': self.currency_usd_id, + 'company_id': self.company.id}) + + self.env['res.currency.rate'].create({'name': time.strftime('%Y') + '-' + '09' + '-01', + 'rate': 0.80, + 'currency_id': self.currency_usd_id, + 'company_id': self.company.id}) + + # Preparing Invoices (from vendor) + invoice_a = self.env['account.move'].with_context(default_move_type='in_invoice').create({ + 'move_type': 'in_invoice', + 'partner_id': self.partner_agrolait_id, + 'currency_id': self.currency_usd_id, + 'invoice_date': '%s-07-01' % time.strftime('%Y'), + 'date': '%s-07-01' % time.strftime('%Y'), + 'invoice_line_ids': [ + (0, 0, {'product_id': self.product.id, 'quantity': 1, 'price_unit': 50.0}) + ], + }) + invoice_b = self.env['account.move'].with_context(default_move_type='in_invoice').create({ + 'move_type': 'in_invoice', + 'partner_id': self.partner_agrolait_id, + 'currency_id': self.currency_usd_id, + 'invoice_date': '%s-08-01' % time.strftime('%Y'), + 'date': '%s-08-01' % time.strftime('%Y'), + 'invoice_line_ids': [ + (0, 0, {'product_id': self.product.id, 'quantity': 1, 'price_unit': 50.0}) + ], + }) + (invoice_a + invoice_b).action_post() + + # Preparing Payments + # One partial for invoice_a (fully assigned to it) + payment_a = self.env['account.payment'].create({'payment_type': 'outbound', + 'amount': 25, + 'currency_id': self.currency_usd_id, + 'journal_id': self.bank_journal_euro.id, + 'company_id': self.company.id, + 'date': time.strftime('%Y') + '-' + '07' + '-01', + 'partner_id': self.partner_agrolait_id, + 'payment_method_id': self.env.ref('account.account_payment_method_manual_out').id, + 'partner_type': 'supplier'}) + + # One that will complete the payment of a, the rest goes to b + payment_b = self.env['account.payment'].create({'payment_type': 'outbound', + 'amount': 50, + 'currency_id': self.currency_usd_id, + 'journal_id': self.bank_journal_euro.id, + 'company_id': self.company.id, + 'date': time.strftime('%Y') + '-' + '08' + '-01', + 'partner_id': self.partner_agrolait_id, + 'payment_method_id': self.env.ref('account.account_payment_method_manual_out').id, + 'partner_type': 'supplier'}) + + # The last one will complete the payment of b + payment_c = self.env['account.payment'].create({'payment_type': 'outbound', + 'amount': 25, + 'currency_id': self.currency_usd_id, + 'journal_id': self.bank_journal_euro.id, + 'company_id': self.company.id, + 'date': time.strftime('%Y') + '-' + '09' + '-01', + 'partner_id': self.partner_agrolait_id, + 'payment_method_id': self.env.ref('account.account_payment_method_manual_out').id, + 'partner_type': 'supplier'}) + + payment_a.action_post() + payment_b.action_post() + payment_c.action_post() + + # Assigning payments to invoices + debit_line_a = payment_a.line_ids.filtered(lambda l: l.debit and l.account_id == self.account_rsa) + debit_line_b = payment_b.line_ids.filtered(lambda l: l.debit and l.account_id == self.account_rsa) + debit_line_c = payment_c.line_ids.filtered(lambda l: l.debit and l.account_id == self.account_rsa) + + invoice_a.js_assign_outstanding_line(debit_line_a.id) + invoice_a.js_assign_outstanding_line(debit_line_b.id) + invoice_b.js_assign_outstanding_line(debit_line_b.id) + invoice_b.js_assign_outstanding_line(debit_line_c.id) + + # Asserting correctness (only in the payable account) + full_reconcile = False + reconciled_amls = (debit_line_a + debit_line_b + debit_line_c + (invoice_a + invoice_b).mapped('line_ids'))\ + .filtered(lambda l: l.account_id == self.account_rsa) + for aml in reconciled_amls: + self.assertEqual(aml.amount_residual, 0.0) + self.assertEqual(aml.amount_residual_currency, 0.0) + self.assertTrue(aml.reconciled) + if not full_reconcile: + full_reconcile = aml.full_reconcile_id + else: + self.assertTrue(aml.full_reconcile_id == full_reconcile) + + full_rec_move = full_reconcile.exchange_move_id + # Globally check whether the amount is correct + self.assertEqual(sum(full_rec_move.mapped('line_ids.debit')), 18.75) + + # Checking if the direction of the move is correct + full_rec_payable = full_rec_move.line_ids.filtered(lambda l: l.account_id == self.account_rsa) + self.assertEqual(full_rec_payable.balance, 18.75) + + def test_unreconcile(self): + # Use case: + # 2 invoices paid with a single payment. Unreconcile the payment with one invoice, the + # other invoice should remain reconciled. + inv1 = self.create_invoice(invoice_amount=10, currency_id=self.currency_usd_id) + inv2 = self.create_invoice(invoice_amount=20, currency_id=self.currency_usd_id) + payment = self.env['account.payment'].create({ + 'payment_type': 'inbound', + 'payment_method_id': self.env.ref('account.account_payment_method_manual_in').id, + 'partner_type': 'customer', + 'partner_id': self.partner_agrolait_id, + 'amount': 100, + 'currency_id': self.currency_usd_id, + 'journal_id': self.bank_journal_usd.id, + }) + payment.action_post() + credit_aml = payment.line_ids.filtered('credit') + + # Check residual before assignation + self.assertAlmostEqual(inv1.amount_residual, 10) + self.assertAlmostEqual(inv2.amount_residual, 20) + + # Assign credit and residual + inv1.js_assign_outstanding_line(credit_aml.id) + inv2.js_assign_outstanding_line(credit_aml.id) + self.assertAlmostEqual(inv1.amount_residual, 0) + self.assertAlmostEqual(inv2.amount_residual, 0) + + # Unreconcile one invoice at a time and check residual + credit_aml.remove_move_reconcile() + self.assertAlmostEqual(inv1.amount_residual, 10) + self.assertAlmostEqual(inv2.amount_residual, 20) + + def test_unreconcile_exchange(self): + # Use case: + # - Company currency in EUR + # - Create 2 rates for USD: + # 1.0 on 2018-01-01 + # 0.5 on 2018-02-01 + # - Create an invoice on 2018-01-02 of 111 USD + # - Register a payment on 2018-02-02 of 111 USD + # - Unreconcile the payment + + self.env['res.currency.rate'].create({ + 'name': time.strftime('%Y') + '-07-01', + 'rate': 1.0, + 'currency_id': self.currency_usd_id, + 'company_id': self.company.id + }) + self.env['res.currency.rate'].create({ + 'name': time.strftime('%Y') + '-08-01', + 'rate': 0.5, + 'currency_id': self.currency_usd_id, + 'company_id': self.company.id + }) + inv = self.create_invoice(invoice_amount=111, currency_id=self.currency_usd_id) + payment = self.env['account.payment'].create({ + 'payment_type': 'inbound', + 'payment_method_id': self.env.ref('account.account_payment_method_manual_in').id, + 'partner_type': 'customer', + 'partner_id': self.partner_agrolait_id, + 'amount': 111, + 'currency_id': self.currency_usd_id, + 'journal_id': self.bank_journal_usd.id, + 'date': time.strftime('%Y') + '-08-01', + }) + payment.action_post() + credit_aml = payment.line_ids.filtered('credit') + + # Check residual before assignation + self.assertAlmostEqual(inv.amount_residual, 111) + + # Assign credit, check exchange move and residual + inv.js_assign_outstanding_line(credit_aml.id) + self.assertEqual(len(payment.line_ids.mapped('full_reconcile_id').exchange_move_id), 1) + self.assertAlmostEqual(inv.amount_residual, 0) + + # Unreconcile invoice and check residual + credit_aml.remove_move_reconcile() + self.assertAlmostEqual(inv.amount_residual, 111) + + def test_revert_payment_and_reconcile(self): + payment = self.env['account.payment'].create({ + 'payment_method_id': self.inbound_payment_method.id, + 'payment_type': 'inbound', + 'partner_type': 'customer', + 'partner_id': self.partner_agrolait_id, + 'journal_id': self.bank_journal_usd.id, + 'date': '2018-06-04', + 'amount': 666, + }) + payment.action_post() + + self.assertEqual(len(payment.line_ids), 2) + + bank_line = payment.line_ids.filtered(lambda l: l.account_id.id == self.bank_journal_usd.payment_debit_account_id.id) + customer_line = payment.line_ids - bank_line + + self.assertEqual(len(bank_line), 1) + self.assertEqual(len(customer_line), 1) + self.assertNotEqual(bank_line.id, customer_line.id) + + self.assertEqual(bank_line.move_id.id, customer_line.move_id.id) + move = bank_line.move_id + + # Reversing the payment's move + reversed_move = move._reverse_moves([{'date': '2018-06-04'}]) + self.assertEqual(len(reversed_move), 1) + + self.assertEqual(len(reversed_move.line_ids), 2) + + # Testing the reconciliation matching between the move lines and their reversed counterparts + reversed_bank_line = reversed_move.line_ids.filtered(lambda l: l.account_id.id == self.bank_journal_usd.payment_debit_account_id.id) + reversed_customer_line = reversed_move.line_ids - reversed_bank_line + + self.assertEqual(len(reversed_bank_line), 1) + self.assertEqual(len(reversed_customer_line), 1) + self.assertNotEqual(reversed_bank_line.id, reversed_customer_line.id) + self.assertEqual(reversed_bank_line.move_id.id, reversed_customer_line.move_id.id) + + self.assertEqual(reversed_bank_line.full_reconcile_id.id, bank_line.full_reconcile_id.id) + self.assertEqual(reversed_customer_line.full_reconcile_id.id, customer_line.full_reconcile_id.id) + + + def test_revert_payment_and_reconcile_exchange(self): + + # A reversal of a reconciled payment which created a currency exchange entry, should create reversal moves + # which move lines should be reconciled two by two with the original move's lines + + def _determine_debit_credit_line(move): + line_ids_reconciliable = move.line_ids.filtered(lambda l: l.account_id.reconcile or l.account_id.internal_type == 'liquidity') + return line_ids_reconciliable.filtered(lambda l: l.debit), line_ids_reconciliable.filtered(lambda l: l.credit) + + def _move_revert_test_pair(move, revert): + self.assertTrue(move.line_ids) + self.assertTrue(revert.line_ids) + + move_lines = _determine_debit_credit_line(move) + revert_lines = _determine_debit_credit_line(revert) + + # in the case of the exchange entry, only one pair of lines will be found + if move_lines[0] and revert_lines[1]: + self.assertTrue(move_lines[0].full_reconcile_id.exists()) + self.assertEqual(move_lines[0].full_reconcile_id.id, revert_lines[1].full_reconcile_id.id) + + if move_lines[1] and revert_lines[0]: + self.assertTrue(move_lines[1].full_reconcile_id.exists()) + self.assertEqual(move_lines[1].full_reconcile_id.id, revert_lines[0].full_reconcile_id.id) + + self.env['res.currency.rate'].create({ + 'name': time.strftime('%Y') + '-07-01', + 'rate': 1.0, + 'currency_id': self.currency_usd_id, + 'company_id': self.company.id + }) + self.env['res.currency.rate'].create({ + 'name': time.strftime('%Y') + '-08-01', + 'rate': 0.5, + 'currency_id': self.currency_usd_id, + 'company_id': self.company.id + }) + inv = self.create_invoice(invoice_amount=111, currency_id=self.currency_usd_id) + payment = self.env['account.payment'].create({ + 'payment_type': 'inbound', + 'payment_method_id': self.env.ref('account.account_payment_method_manual_in').id, + 'partner_type': 'customer', + 'partner_id': self.partner_agrolait_id, + 'amount': 111, + 'currency_id': self.currency_usd_id, + 'journal_id': self.bank_journal_usd.id, + 'date': time.strftime('%Y') + '-08-01', + }) + payment.action_post() + + credit_aml = payment.line_ids.filtered('credit') + inv.js_assign_outstanding_line(credit_aml.id) + self.assertTrue(inv.payment_state in ('in_payment', 'paid'), "Invoice should be paid") + + exchange_reconcile = payment.line_ids.mapped('full_reconcile_id') + exchange_move = exchange_reconcile.exchange_move_id + payment_move = payment.line_ids[0].move_id + + reverted_payment_move = payment_move._reverse_moves([{'date': time.strftime('%Y') + '-08-01'}], cancel=True) + + # After reversal of payment, the invoice should be open + self.assertTrue(inv.state == 'posted', 'The invoice should be open again') + self.assertFalse(exchange_reconcile.exists()) + + reverted_exchange_move = self.env['account.move'].search([('journal_id', '=', exchange_move.journal_id.id), ('ref', 'ilike', exchange_move.name)], limit=1) + _move_revert_test_pair(payment_move, reverted_payment_move) + _move_revert_test_pair(exchange_move, reverted_exchange_move) + + def test_partial_reconcile_currencies_02(self): + #### + # Day 1: Invoice Cust/001 to customer (expressed in USD) + # Market value of USD (day 1): 1 USD = 0.5 EUR + # * Dr. 100 USD / 50 EUR - Accounts receivable + # * Cr. 100 USD / 50 EUR - Revenue + #### + dest_journal_id = self.env['account.journal'].create({ + 'name': 'turlututu', + 'type': 'bank', + 'company_id': self.env.company.id, + }) + + self.env['res.currency.rate'].create({ + 'currency_id': self.currency_usd_id, + 'name': time.strftime('%Y') + '-01-01', + 'rate': 2, + }) + + invoice_cust_1 = self.env['account.move'].with_context(default_move_type='out_invoice').create({ + 'move_type': 'out_invoice', + 'partner_id': self.partner_agrolait_id, + 'invoice_date': '%s-01-01' % time.strftime('%Y'), + 'date': '%s-01-01' % time.strftime('%Y'), + 'currency_id': self.currency_usd_id, + 'invoice_line_ids': [ + (0, 0, {'quantity': 1, 'price_unit': 100.0, 'name': 'product that cost 100'}) + ], + }) + invoice_cust_1.action_post() + aml = invoice_cust_1.invoice_line_ids[0] + self.assertEqual(aml.credit, 50.0) + ##### + # Day 2: Receive payment for half invoice Cust/1 (in USD) + # ------------------------------------------------------- + # Market value of USD (day 2): 1 USD = 1 EUR + + # Payment transaction: + # * Dr. 50 USD / 50 EUR - EUR Bank (valued at market price + # at the time of receiving the money) + # * Cr. 50 USD / 50 EUR - Accounts Receivable + ##### + self.env['res.currency.rate'].create({ + 'currency_id': self.currency_usd_id, + 'name': time.strftime('%Y') + '-01-02', + 'rate': 1, + }) + + payment = self.env['account.payment.register']\ + .with_context(active_model='account.move', active_ids=invoice_cust_1.ids)\ + .create({ + 'payment_date': time.strftime('%Y') + '-01-02', + 'amount': 50, + 'journal_id': dest_journal_id.id, + 'currency_id': self.currency_usd_id, + })\ + ._create_payments() + + # We expect at this point that the invoice should still be open, in 'partial' state, + # because they owe us still 50 CC. + self.assertEqual(invoice_cust_1.payment_state, 'partial', 'Invoice is in status %s' % invoice_cust_1.state) + + def test_multiple_term_reconciliation_opw_1906665(self): + '''Test that when registering a payment to an invoice with multiple + payment term lines the reconciliation happens against the line + with the earliest date_maturity + ''' + + payment_term = self.env['account.payment.term'].create({ + 'name': 'Pay in 2 installments', + 'line_ids': [ + # Pay 50% immediately + (0, 0, { + 'value': 'percent', + 'value_amount': 50, + }), + # Pay the rest after 14 days + (0, 0, { + 'value': 'balance', + 'days': 14, + }) + ], + }) + + # can't use self.create_invoice because it validates and we need to set payment_term_id + invoice = self.create_invoice_partner( + partner_id=self.partner_agrolait_id, + payment_term_id=payment_term.id, + currency_id=self.currency_usd_id, + ) + + payment = self.env['account.payment'].create({ + 'date': time.strftime('%Y') + '-07-15', + 'payment_type': 'inbound', + 'payment_method_id': self.env.ref('account.account_payment_method_manual_in').id, + 'partner_type': 'customer', + 'partner_id': self.partner_agrolait_id, + 'amount': 25, + 'currency_id': self.currency_usd_id, + 'journal_id': self.bank_journal_usd.id, + }) + payment.action_post() + + receivable_line = payment.line_ids.filtered('credit') + invoice.js_assign_outstanding_line(receivable_line.id) + + self.assertTrue(receivable_line.matched_debit_ids) + + def test_reconciliation_with_currency(self): + #reconciliation on an account having a foreign currency being + #the same as the company one + account_rcv = self.account_rcv + account_rcv.currency_id = self.currency_euro_id + aml_obj = self.env['account.move.line'].with_context( + check_move_validity=False) + general_move1 = self.env['account.move'].create({ + 'name': 'general1', + 'journal_id': self.general_journal.id, + }) + aml_obj.create({ + 'name': 'debit1', + 'account_id': account_rcv.id, + 'debit': 11, + 'move_id': general_move1.id, + }) + aml_obj.create({ + 'name': 'credit1', + 'account_id': self.account_rsa.id, + 'credit': 11, + 'move_id': general_move1.id, + }) + general_move1.action_post() + general_move2 = self.env['account.move'].create({ + 'name': 'general2', + 'journal_id': self.general_journal.id, + }) + aml_obj.create({ + 'name': 'credit2', + 'account_id': account_rcv.id, + 'credit': 10, + 'move_id': general_move2.id, + }) + aml_obj.create({ + 'name': 'debit2', + 'account_id': self.account_rsa.id, + 'debit': 10, + 'move_id': general_move2.id, + }) + general_move2.action_post() + general_move3 = self.env['account.move'].create({ + 'name': 'general3', + 'journal_id': self.general_journal.id, + }) + aml_obj.create({ + 'name': 'credit3', + 'account_id': account_rcv.id, + 'credit': 1, + 'move_id': general_move3.id, + }) + aml_obj.create({ + 'name': 'debit3', + 'account_id': self.account_rsa.id, + 'debit': 1, + 'move_id': general_move3.id, + }) + general_move3.action_post() + to_reconcile = ((general_move1 + general_move2 + general_move3) + .mapped('line_ids') + .filtered(lambda l: l.account_id.id == account_rcv.id)) + to_reconcile.reconcile() + for aml in to_reconcile: + self.assertEqual(aml.amount_residual, 0.0) + + def test_inv_refund_foreign_payment_writeoff_domestic2(self): + company = self.company + self.env['res.currency.rate'].create({ + 'name': time.strftime('%Y') + '-07-01', + 'rate': 1.0, + 'currency_id': self.currency_euro_id, + 'company_id': company.id + }) + self.env['res.currency.rate'].create({ + 'name': time.strftime('%Y') + '-07-01', + 'rate': 1.110600, # Don't change this ! + 'currency_id': self.currency_usd_id, + 'company_id': self.company.id + }) + inv1 = self.create_invoice(invoice_amount=800, currency_id=self.currency_usd_id) + inv2 = self.create_invoice(move_type="out_refund", invoice_amount=400, currency_id=self.currency_usd_id) + + payment = self.env['account.payment'].create({ + 'date': time.strftime('%Y') + '-07-15', + 'payment_method_id': self.inbound_payment_method.id, + 'payment_type': 'inbound', + 'partner_type': 'customer', + 'partner_id': inv1.partner_id.id, + 'amount': 200.00, + 'journal_id': self.bank_journal_euro.id, + 'company_id': company.id, + }) + payment.action_post() + + inv1_receivable = inv1.line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable') + inv2_receivable = inv2.line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable') + pay_receivable = payment.line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable') + + move_balance = self.env['account.move'].create({ + 'partner_id': inv1.partner_id.id, + 'date': time.strftime('%Y') + '-07-01', + 'journal_id': self.bank_journal_euro.id, + 'line_ids': [ + (0, False, {'credit': 160.16, 'account_id': inv1_receivable.account_id.id, 'name': 'Balance WriteOff'}), + (0, False, {'debit': 160.16, 'account_id': self.diff_expense_account.id, 'name': 'Balance WriteOff'}), + ] + }) + + move_balance.action_post() + move_balance_receiv = move_balance.line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable') + + (inv1_receivable + inv2_receivable + pay_receivable + move_balance_receiv).reconcile() + + self.assertTrue(inv1_receivable.full_reconcile_id.exists()) + self.assertEqual(inv1_receivable.full_reconcile_id, inv2_receivable.full_reconcile_id) + self.assertEqual(inv1_receivable.full_reconcile_id, pay_receivable.full_reconcile_id) + self.assertEqual(inv1_receivable.full_reconcile_id, move_balance_receiv.full_reconcile_id) + + self.assertTrue(inv1.payment_state in ('in_payment', 'paid'), "Invoice should be paid") + self.assertEqual(inv2.payment_state, 'paid') + + def test_inv_refund_foreign_payment_writeoff_domestic3(self): + """ + Receivable + Domestic (Foreign) + 592.47 (658.00) | INV 1 > Done in foreign + | 202.59 (225.00) INV 2 > Done in foreign + | 372.10 (413.25) PAYMENT > Done in domestic (the 413.25 is virtual, non stored) + | 17.78 (19.75) WriteOff > Done in domestic (the 19.75 is virtual, non stored) + Reconciliation should be full + Invoices should be marked as paid + """ + company = self.company + self.env['res.currency.rate'].create({ + 'name': time.strftime('%Y') + '-07-01', + 'rate': 1.0, + 'currency_id': self.currency_euro_id, + 'company_id': company.id + }) + self.env['res.currency.rate'].create({ + 'name': time.strftime('%Y') + '-07-01', + 'rate': 1.110600, # Don't change this ! + 'currency_id': self.currency_usd_id, + 'company_id': company.id + }) + inv1 = self.create_invoice(invoice_amount=658, currency_id=self.currency_usd_id) + inv2 = self.create_invoice(move_type="out_refund", invoice_amount=225, currency_id=self.currency_usd_id) + + payment = self.env['account.payment'].create({ + 'payment_method_id': self.inbound_payment_method.id, + 'payment_type': 'inbound', + 'partner_type': 'customer', + 'partner_id': inv1.partner_id.id, + 'amount': 372.10, + 'date': time.strftime('%Y') + '-07-01', + 'journal_id': self.bank_journal_euro.id, + 'company_id': company.id, + }) + payment.action_post() + + inv1_receivable = inv1.line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable') + inv2_receivable = inv2.line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable') + pay_receivable = payment.line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable') + + move_balance = self.env['account.move'].create({ + 'partner_id': inv1.partner_id.id, + 'date': time.strftime('%Y') + '-07-01', + 'journal_id': self.bank_journal_euro.id, + 'line_ids': [ + (0, False, {'credit': 17.78, 'account_id': inv1_receivable.account_id.id, 'name': 'Balance WriteOff'}), + (0, False, {'debit': 17.78, 'account_id': self.diff_expense_account.id, 'name': 'Balance WriteOff'}), + ] + }) + + move_balance.action_post() + move_balance_receiv = move_balance.line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable') + + (inv1_receivable + inv2_receivable + pay_receivable + move_balance_receiv).reconcile() + + self.assertTrue(inv1_receivable.full_reconcile_id.exists()) + self.assertEqual(inv1_receivable.full_reconcile_id, inv2_receivable.full_reconcile_id) + self.assertEqual(inv1_receivable.full_reconcile_id, pay_receivable.full_reconcile_id) + self.assertEqual(inv1_receivable.full_reconcile_id, move_balance_receiv.full_reconcile_id) + + self.assertFalse(inv1_receivable.full_reconcile_id.exchange_move_id) + + self.assertTrue(inv1.payment_state in ('in_payment', 'paid'), "Invoice should be paid") + self.assertEqual(inv2.payment_state, 'paid') + + def test_inv_refund_foreign_payment_writeoff_domestic4(self): + """ + Receivable + Domestic (Foreign) + 658.00 (658.00) | INV 1 > Done in foreign + | 202.59 (225.00) INV 2 > Done in foreign + | 372.10 (413.25) PAYMENT > Done in domestic (the 413.25 is virtual, non stored) + | 83.31 (92.52) WriteOff > Done in domestic (the 92.52 is virtual, non stored) + Reconciliation should be full + Invoices should be marked as paid + """ + company = self.company + self.env['res.currency.rate'].create({ + 'name': time.strftime('%Y') + '-07-01', + 'rate': 1.0, + 'currency_id': self.currency_euro_id, + 'company_id': company.id + }) + self.env['res.currency.rate'].create({ + 'name': time.strftime('%Y') + '-07-01', + 'rate': 1.0, # Don't change this ! + 'currency_id': self.currency_usd_id, + 'company_id': company.id + }) + self.env['res.currency.rate'].create({ + 'name': time.strftime('%Y') + '-07-15', + 'rate': 1.110600, # Don't change this ! + 'currency_id': self.currency_usd_id, + 'company_id': company.id + }) + inv1 = self._create_invoice(invoice_amount=658, currency_id=self.currency_usd_id, date_invoice=time.strftime('%Y') + '-07-01', auto_validate=True) + inv2 = self._create_invoice(move_type="out_refund", invoice_amount=225, currency_id=self.currency_usd_id, date_invoice=time.strftime('%Y') + '-07-15', auto_validate=True) + + payment = self.env['account.payment'].create({ + 'date': time.strftime('%Y') + '-07-15', + 'payment_method_id': self.inbound_payment_method.id, + 'payment_type': 'inbound', + 'partner_type': 'customer', + 'partner_id': inv1.partner_id.id, + 'amount': 372.10, + 'journal_id': self.bank_journal_euro.id, + 'company_id': company.id, + 'currency_id': self.currency_euro_id, + }) + payment.action_post() + + inv1_receivable = inv1.line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable') + inv2_receivable = inv2.line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable') + pay_receivable = payment.line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable') + + self.assertEqual(inv1_receivable.balance, 658) + self.assertEqual(inv2_receivable.balance, -202.59) + self.assertEqual(pay_receivable.balance, -372.1) + + move_balance = self.env['account.move'].create({ + 'partner_id': inv1.partner_id.id, + 'date': time.strftime('%Y') + '-07-15', + 'journal_id': self.bank_journal_usd.id, + 'line_ids': [ + (0, False, {'credit': 83.31, 'account_id': inv1_receivable.account_id.id, 'name': 'Balance WriteOff'}), + (0, False, {'debit': 83.31, 'account_id': self.diff_expense_account.id, 'name': 'Balance WriteOff'}), + ] + }) + + move_balance.action_post() + move_balance_receiv = move_balance.line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable') + + (inv1_receivable + inv2_receivable + pay_receivable + move_balance_receiv).reconcile() + + self.assertTrue(inv1_receivable.full_reconcile_id.exists()) + self.assertEqual(inv1_receivable.full_reconcile_id, inv2_receivable.full_reconcile_id) + self.assertEqual(inv1_receivable.full_reconcile_id, pay_receivable.full_reconcile_id) + self.assertEqual(inv1_receivable.full_reconcile_id, move_balance_receiv.full_reconcile_id) + + self.assertTrue(inv1.payment_state in ('in_payment', 'paid'), "Invoice should be paid") + self.assertEqual(inv2.payment_state, 'paid') + + def test_inv_refund_foreign_payment_writeoff_domestic5(self): + """ + Receivable + Domestic (Foreign) + 600.00 (600.00) | INV 1 > Done in foreign + | 250.00 (250.00) INV 2 > Done in foreign + | 314.07 (314.07) PAYMENT > Done in domestic (foreign non stored) + | 35.93 (60.93) WriteOff > Done in domestic (foreign non stored). WriteOff is included in payment + Reconciliation should be full, without exchange difference + Invoices should be marked as paid + """ + company = self.company + self.env['res.currency.rate'].create({ + 'name': time.strftime('%Y') + '-07-01', + 'rate': 1.0, + 'currency_id': self.currency_euro_id, + 'company_id': company.id + }) + self.env['res.currency.rate'].create({ + 'name': time.strftime('%Y') + '-07-01', + 'rate': 1.0, # Don't change this ! + 'currency_id': self.currency_usd_id, + 'company_id': company.id + }) + + inv1 = self._create_invoice(invoice_amount=600, currency_id=self.currency_usd_id, date_invoice=time.strftime('%Y') + '-07-15', auto_validate=True) + inv2 = self._create_invoice(move_type="out_refund", invoice_amount=250, currency_id=self.currency_usd_id, date_invoice=time.strftime('%Y') + '-07-15', auto_validate=True) + + inv1_receivable = inv1.line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable') + inv2_receivable = inv2.line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable') + + self.assertEqual(inv1_receivable.balance, 600.00) + self.assertEqual(inv2_receivable.balance, -250) + + # partially pay the invoice with the refund + inv1.js_assign_outstanding_line(inv2_receivable.id) + self.assertEqual(inv1.amount_residual, 350) + + payment = self.env['account.payment.register']\ + .with_context(active_model='account.move', active_ids=inv1.ids)\ + .create({ + 'payment_date': time.strftime('%Y') + '-07-15', + 'amount': 314.07, + 'journal_id': self.bank_journal_euro.id, + 'currency_id': self.currency_euro_id, + 'payment_difference_handling': 'reconcile', + 'writeoff_account_id': self.diff_income_account.id, + })\ + ._create_payments() + + payment_receivable = payment.line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable') + self.assertEqual(payment_receivable.balance, -350) + + self.assertTrue(inv1_receivable.full_reconcile_id.exists()) + self.assertEqual(inv1_receivable.full_reconcile_id, inv2_receivable.full_reconcile_id) + self.assertEqual(inv1_receivable.full_reconcile_id, payment_receivable.full_reconcile_id) + + self.assertFalse(inv1_receivable.full_reconcile_id.exchange_move_id) + + self.assertTrue(inv1.payment_state in ('in_payment', 'paid'), "Invoice should be paid") + self.assertEqual(inv2.payment_state, 'paid') + + def test_inv_refund_foreign_payment_writeoff_domestic6(self): + """ + Receivable + Domestic (Foreign) + 540.25 (600.00) | INV 1 > Done in foreign + | 225.10 (250.00) INV 2 > Done in foreign + | 315.15 (350.00) PAYMENT > Done in domestic (the 350.00 is virtual, non stored) + """ + company = self.company + self.env['res.currency.rate'].create({ + 'name': time.strftime('%Y') + '-07-01', + 'rate': 1.0, + 'currency_id': self.currency_euro_id, + 'company_id': company.id + }) + self.env['res.currency.rate'].create({ + 'name': time.strftime('%Y') + '-07-01', + 'rate': 1.1106, # Don't change this ! + 'currency_id': self.currency_usd_id, + 'company_id': company.id + }) + inv1 = self._create_invoice(invoice_amount=600, currency_id=self.currency_usd_id, date_invoice=time.strftime('%Y') + '-07-15', auto_validate=True) + inv2 = self._create_invoice(move_type="out_refund", invoice_amount=250, currency_id=self.currency_usd_id, date_invoice=time.strftime('%Y') + '-07-15', auto_validate=True) + + inv1_receivable = inv1.line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable') + inv2_receivable = inv2.line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable') + + self.assertEqual(inv1_receivable.balance, 540.25) + self.assertEqual(inv2_receivable.balance, -225.10) + + # partially pay the invoice with the refund + inv1.js_assign_outstanding_line(inv2_receivable.id) + self.assertAlmostEqual(inv1.amount_residual, 350) + self.assertAlmostEqual(inv1_receivable.amount_residual, 315.15) + + payment = self.env['account.payment.register']\ + .with_context(active_model='account.move', active_ids=inv1.ids)\ + .create({ + 'payment_date': time.strftime('%Y') + '-07-15', + 'amount': 314.07, + 'journal_id': self.bank_journal_euro.id, + 'currency_id': self.currency_euro_id, + 'payment_difference_handling': 'reconcile', + 'writeoff_account_id': self.diff_income_account.id, + })\ + ._create_payments() + + payment_receivable = payment.line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable') + + self.assertTrue(inv1_receivable.full_reconcile_id.exists()) + self.assertEqual(inv1_receivable.full_reconcile_id, inv2_receivable.full_reconcile_id) + self.assertEqual(inv1_receivable.full_reconcile_id, payment_receivable.full_reconcile_id) + + exchange_rcv = inv1_receivable.full_reconcile_id.exchange_move_id.line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable') + self.assertEqual(exchange_rcv.amount_currency, 0.01) + + self.assertTrue(inv1.payment_state in ('in_payment', 'paid'), "Invoice should be paid") + self.assertEqual(inv2.payment_state, 'paid') + + def test_inv_refund_foreign_payment_writeoff_domestic6bis(self): + """ + Same as domestic6, but only in foreign currencies + Obviously, it should lead to the same kind of results + Here there is no exchange difference entry though + """ + foreign_0 = self.env['res.currency'].create({ + 'name': 'foreign0', + 'symbol': 'F0' + }) + foreign_1 = self.env['res.currency'].browse(self.currency_usd_id) + + company = self.company + self.env['res.currency.rate'].create({ + 'name': time.strftime('%Y') + '-07-01', + 'rate': 1.0, + 'currency_id': self.currency_euro_id, + 'company_id': company.id + }) + + self.env['res.currency.rate'].create({ + 'name': time.strftime('%Y') + '-07-01', + 'rate': 1.0, + 'currency_id': foreign_0.id, + 'company_id': company.id + }) + self.env['res.currency.rate'].create({ + 'name': time.strftime('%Y') + '-07-01', + 'rate': 1.1106, # Don't change this ! + 'currency_id': foreign_1.id, + 'company_id': company.id + }) + inv1 = self._create_invoice(invoice_amount=600, currency_id=foreign_1.id, date_invoice=time.strftime('%Y') + '-07-15', auto_validate=True) + inv2 = self._create_invoice(move_type="out_refund", invoice_amount=250, currency_id=foreign_1.id, date_invoice=time.strftime('%Y') + '-07-15', auto_validate=True) + + inv1_receivable = inv1.line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable') + inv2_receivable = inv2.line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable') + + self.assertEqual(inv1_receivable.balance, 540.25) + self.assertEqual(inv2_receivable.balance, -225.10) + + # partially pay the invoice with the refund + inv1.js_assign_outstanding_line(inv2_receivable.id) + self.assertAlmostEqual(inv1.amount_residual, 350) + self.assertAlmostEqual(inv1_receivable.amount_residual, 315.15) + + payment = self.env['account.payment.register']\ + .with_context(active_model='account.move', active_ids=inv1.ids)\ + .create({ + 'payment_date': time.strftime('%Y') + '-07-15', + 'amount': 314.07, + 'journal_id': self.bank_journal_euro.id, + 'currency_id': foreign_0.id, + 'payment_difference_handling': 'reconcile', + 'writeoff_account_id': self.diff_income_account.id, + })\ + ._create_payments() + + payment_receivable = payment.line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable') + + self.assertTrue(inv1_receivable.full_reconcile_id.exists()) + self.assertEqual(inv1_receivable.full_reconcile_id, inv2_receivable.full_reconcile_id) + self.assertEqual(inv1_receivable.full_reconcile_id, payment_receivable.full_reconcile_id) + + # Before saas-13.4, there was no exchange difference entry generated because the amount was + # wrongly converted in the _amount_residual method at the invoice date like this: + # 315.15 * (600.0 / 540.25) = 515.15 * 1.110596946 = 350.004627487 ~= 350.0 + # Now, the conversion is made using the payment rate using the _convert method and the + # encoded currency rate: + # 315.15 * 1.1106 = 350.00559 ~= 350.01 + self.assertTrue(inv1_receivable.full_reconcile_id.exchange_move_id) + + self.assertTrue(inv1.payment_state in ('in_payment', 'paid'), "Invoice should be paid") + self.assertEqual(inv2.payment_state, 'paid') + + def test_inv_refund_foreign_payment_writeoff_domestic7(self): + """ + Receivable + Domestic (Foreign) + 5384.48 (5980.00) | INV 1 > Done in foreign + | 5384.43 (5979.95) PAYMENT > Done in domestic (foreign non stored) + | 0.05 (0.00) WriteOff > Done in domestic (foreign non stored). WriteOff is included in payment, + so, the amount in currency is irrelevant + Reconciliation should be full, without exchange difference + Invoices should be marked as paid + """ + company = self.company + self.env['res.currency.rate'].create({ + 'name': time.strftime('%Y') + '-07-01', + 'rate': 1.0, + 'currency_id': self.currency_euro_id, + 'company_id': company.id + }) + self.env['res.currency.rate'].create({ + 'name': time.strftime('%Y') + '-07-01', + 'rate': 1.1106, # Don't change this ! + 'currency_id': self.currency_usd_id, + 'company_id': company.id + }) + inv1 = self._create_invoice(invoice_amount=5980, currency_id=self.currency_usd_id, date_invoice=time.strftime('%Y') + '-07-15', auto_validate=True) + + inv1_receivable = inv1.line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable') + + self.assertAlmostEqual(inv1_receivable.balance, 5384.48) + + payment = self.env['account.payment.register']\ + .with_context(active_model='account.move', active_ids=inv1.ids)\ + .create({ + 'payment_date': time.strftime('%Y') + '-07-15', + 'amount': 5384.43, + 'journal_id': self.bank_journal_euro.id, + 'currency_id': self.currency_euro_id, + 'payment_difference_handling': 'reconcile', + 'writeoff_account_id': self.diff_income_account.id, + })\ + ._create_payments() + + payment_receivable = payment.line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable') + + self.assertTrue(inv1_receivable.full_reconcile_id.exists()) + self.assertEqual(inv1_receivable.full_reconcile_id, payment_receivable.full_reconcile_id) + + self.assertFalse(inv1_receivable.full_reconcile_id.exchange_move_id) + + self.assertTrue(inv1.payment_state in ('in_payment', 'paid'), "Invoice should be paid") + + def test_inv_refund_foreign_payment_writeoff_domestic8(self): + """ + Roughly the same as *_domestic7 + Though it simulates going through the reconciliation widget + Because the WriteOff is on a different line than the payment + """ + company = self.company + self.env['res.currency.rate'].create({ + 'name': time.strftime('%Y') + '-07-01', + 'rate': 1.0, + 'currency_id': self.currency_euro_id, + 'company_id': company.id + }) + self.env['res.currency.rate'].create({ + 'name': time.strftime('%Y') + '-07-01', + 'rate': 1.1106, # Don't change this ! + 'currency_id': self.currency_usd_id, + 'company_id': company.id + }) + inv1 = self._create_invoice(invoice_amount=5980, currency_id=self.currency_usd_id, date_invoice=time.strftime('%Y') + '-07-15', auto_validate=True) + + inv1_receivable = inv1.line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable') + + self.assertAlmostEqual(inv1_receivable.balance, 5384.48) + + Payment = self.env['account.payment'] + payment = Payment.create({ + 'date': time.strftime('%Y') + '-07-15', + 'payment_method_id': self.inbound_payment_method.id, + 'payment_type': 'inbound', + 'partner_type': 'customer', + 'partner_id': inv1.partner_id.id, + 'amount': 5384.43, + 'journal_id': self.bank_journal_euro.id, + 'company_id': company.id, + 'currency_id': self.currency_euro_id, + }) + payment.action_post() + payment_receivable = payment.line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable') + + move_balance = self.env['account.move'].create({ + 'partner_id': inv1.partner_id.id, + 'date': time.strftime('%Y') + '-07-15', + 'journal_id': self.bank_journal_usd.id, + 'line_ids': [ + (0, False, {'credit': 0.05, 'account_id': inv1_receivable.account_id.id, 'name': 'Balance WriteOff'}), + (0, False, {'debit': 0.05, 'account_id': self.diff_expense_account.id, 'name': 'Balance WriteOff'}), + ] + }) + move_balance.action_post() + move_balance_receiv = move_balance.line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable') + + (inv1_receivable + payment_receivable + move_balance_receiv).reconcile() + + self.assertTrue(inv1_receivable.full_reconcile_id.exists()) + self.assertEqual(inv1_receivable.full_reconcile_id, payment_receivable.full_reconcile_id) + self.assertEqual(move_balance_receiv.full_reconcile_id, inv1_receivable.full_reconcile_id) + + exchange_rcv = inv1_receivable.full_reconcile_id.exchange_move_id.line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable') + self.assertEqual(exchange_rcv.amount_currency, 0.01) + + self.assertTrue(inv1.payment_state in ('in_payment', 'paid'), "Invoice should be paid") diff --git a/addons/account/tests/test_reconciliation_matching_rules.py b/addons/account/tests/test_reconciliation_matching_rules.py new file mode 100644 index 00000000..ffe18d63 --- /dev/null +++ b/addons/account/tests/test_reconciliation_matching_rules.py @@ -0,0 +1,1061 @@ +# -*- coding: utf-8 -*- +from freezegun import freeze_time + +from odoo.addons.account.tests.common import AccountTestInvoicingCommon +from odoo.tests.common import Form +from odoo.tests import tagged + + +@tagged('post_install', '-at_install') +class TestReconciliationMatchingRules(AccountTestInvoicingCommon): + + @classmethod + def setUpClass(cls, chart_template_ref=None): + super().setUpClass(chart_template_ref=chart_template_ref) + + ################# + # Company setup # + ################# + cls.currency_data_2 = cls.setup_multi_currency_data({ + 'name': 'Dark Chocolate Coin', + 'symbol': '🍫', + 'currency_unit_label': 'Dark Choco', + 'currency_subunit_label': 'Dark Cacao Powder', + }, rate2016=10.0, rate2017=20.0) + + cls.company = cls.company_data['company'] + + cls.account_pay = cls.company_data['default_account_payable'] + cls.current_assets_account = cls.env['account.account'].search([ + ('user_type_id', '=', cls.env.ref('account.data_account_type_current_assets').id), + ('company_id', '=', cls.company.id)], limit=1) + + cls.bank_journal = cls.env['account.journal'].search([('type', '=', 'bank'), ('company_id', '=', cls.company.id)], limit=1) + cls.cash_journal = cls.env['account.journal'].search([('type', '=', 'cash'), ('company_id', '=', cls.company.id)], limit=1) + + cls.tax21 = cls.env['account.tax'].create({ + 'name': '21%', + 'type_tax_use': 'purchase', + 'amount': 21, + }) + + cls.tax12 = cls.env['account.tax'].create({ + 'name': '12%', + 'type_tax_use': 'purchase', + 'amount': 12, + }) + + cls.partner_1 = cls.env['res.partner'].create({'name': 'partner_1', 'company_id': cls.company.id}) + cls.partner_2 = cls.env['res.partner'].create({'name': 'partner_2', 'company_id': cls.company.id}) + cls.partner_3 = cls.env['res.partner'].create({'name': 'partner_3', 'company_id': cls.company.id}) + + ############### + # Rules setup # + ############### + cls.rule_1 = cls.env['account.reconcile.model'].create({ + 'name': 'Invoices Matching Rule', + 'sequence': '1', + 'rule_type': 'invoice_matching', + 'auto_reconcile': False, + 'match_nature': 'both', + 'match_same_currency': True, + 'match_total_amount': True, + 'match_total_amount_param': 100, + 'match_partner': True, + 'match_partner_ids': [(6, 0, (cls.partner_1 + cls.partner_2 + cls.partner_3).ids)], + 'company_id': cls.company.id, + 'line_ids': [(0, 0, {'account_id': cls.current_assets_account.id})], + }) + cls.rule_2 = cls.env['account.reconcile.model'].create({ + 'name': 'write-off model', + 'rule_type': 'writeoff_suggestion', + 'match_partner': True, + 'match_partner_ids': [], + 'line_ids': [(0, 0, {'account_id': cls.current_assets_account.id})], + }) + + ################## + # Invoices setup # + ################## + cls.invoice_line_1 = cls._create_invoice_line(100, cls.partner_1, 'out_invoice') + cls.invoice_line_2 = cls._create_invoice_line(200, cls.partner_1, 'out_invoice') + cls.invoice_line_3 = cls._create_invoice_line(300, cls.partner_1, 'in_refund', name="RBILL/2019/09/0013") + cls.invoice_line_4 = cls._create_invoice_line(1000, cls.partner_2, 'in_invoice') + cls.invoice_line_5 = cls._create_invoice_line(600, cls.partner_3, 'out_invoice') + cls.invoice_line_6 = cls._create_invoice_line(600, cls.partner_3, 'out_invoice', ref="RF12 3456") + cls.invoice_line_7 = cls._create_invoice_line(200, cls.partner_3, 'out_invoice', pay_reference="RF12 3456") + + #################### + # Statements setup # + #################### + # TODO : account_number, partner_name, transaction_type, narration + invoice_number = cls.invoice_line_1.move_id.name + cls.bank_st, cls.bank_st_2, cls.cash_st = cls.env['account.bank.statement'].create([ + { + 'name': 'test bank journal', + 'journal_id': cls.bank_journal.id, + 'line_ids': [ + (0, 0, { + 'date': '2020-01-01', + 'payment_ref': 'invoice %s-%s-%s' % tuple(invoice_number.split('/')[1:]), + 'partner_id': cls.partner_1.id, + 'amount': 100, + 'sequence': 1, + }), + (0, 0, { + 'date': '2020-01-01', + 'payment_ref': 'xxxxx', + 'partner_id': cls.partner_1.id, + 'amount': 600, + 'sequence': 2, + }), + ], + }, { + 'name': 'second test bank journal', + 'journal_id': cls.bank_journal.id, + 'line_ids': [ + (0, 0, { + 'date': '2020-01-01', + 'payment_ref': 'nawak', + 'narration': 'Communication: RF12 3456', + 'partner_id': cls.partner_3.id, + 'amount': 600, + 'sequence': 1, + }), + (0, 0, { + 'date': '2020-01-01', + 'payment_ref': 'RF12 3456', + 'partner_id': cls.partner_3.id, + 'amount': 600, + 'sequence': 2, + }), + (0, 0, { + 'date': '2020-01-01', + 'payment_ref': 'baaaaah', + 'ref': 'RF12 3456', + 'partner_id': cls.partner_3.id, + 'amount': 600, + 'sequence': 2, + }), + ], + }, { + 'name': 'test cash journal', + 'journal_id': cls.cash_journal.id, + 'line_ids': [ + (0, 0, { + 'date': '2020-01-01', + 'payment_ref': 'yyyyy', + 'partner_id': cls.partner_2.id, + 'amount': -1000, + 'sequence': 1, + }), + ], + } + ]) + + cls.bank_line_1, cls.bank_line_2 = cls.bank_st.line_ids + cls.bank_line_3, cls.bank_line_4, cls.bank_line_5 = cls.bank_st_2.line_ids + cls.cash_line_1 = cls.cash_st.line_ids + cls._post_statements(cls) + + @classmethod + def _create_invoice_line(cls, amount, partner, type, currency=None, pay_reference=None, ref=None, name=None): + ''' Create an invoice on the fly.''' + invoice_form = Form(cls.env['account.move'].with_context(default_move_type=type, default_invoice_date='2019-09-01', default_date='2019-09-01')) + invoice_form.partner_id = partner + if currency: + invoice_form.currency_id = currency + if pay_reference: + invoice_form.payment_reference = pay_reference + if ref: + invoice_form.ref = ref + if name: + invoice_form.name = name + with invoice_form.invoice_line_ids.new() as invoice_line_form: + invoice_line_form.name = 'xxxx' + invoice_line_form.quantity = 1 + invoice_line_form.price_unit = amount + invoice_line_form.tax_ids.clear() + invoice = invoice_form.save() + invoice.action_post() + lines = invoice.line_ids + return lines.filtered(lambda l: l.account_id.user_type_id.type in ('receivable', 'payable')) + + def _post_statements(self): + self.bank_st.balance_end_real = self.bank_st.balance_end + self.bank_st_2.balance_end_real = self.bank_st_2.balance_end + self.cash_st.balance_end_real = self.cash_st.balance_end + (self.bank_st + self.bank_st_2 + self.cash_st).button_post() + + @freeze_time('2020-01-01') + def _check_statement_matching(self, rules, expected_values, statements=None): + if statements is None: + statements = self.bank_st + self.cash_st + statement_lines = statements.mapped('line_ids').sorted() + matching_values = rules._apply_rules(statement_lines, None) + + for st_line_id, values in matching_values.items(): + values.pop('reconciled_lines', None) + values.pop('write_off_vals', None) + self.assertDictEqual(values, expected_values[st_line_id]) + + def test_matching_fields(self): + # Check without restriction. + self._check_statement_matching(self.rule_1, { + self.bank_line_1.id: {'aml_ids': [self.invoice_line_1.id], 'model': self.rule_1, 'partner': self.bank_line_1.partner_id}, + self.bank_line_2.id: {'aml_ids': [ + self.invoice_line_2.id, + self.invoice_line_3.id, + self.invoice_line_1.id, + ], 'model': self.rule_1, + 'partner': self.bank_line_2.partner_id}, + self.cash_line_1.id: {'aml_ids': [self.invoice_line_4.id], 'model': self.rule_1, 'partner': self.cash_line_1.partner_id}, + }) + + def test_matching_fields_match_text_location(self): + self.rule_1.match_text_location_label = True + self.rule_1.match_text_location_reference = False + self.rule_1.match_text_location_note = False + self._check_statement_matching(self.rule_1, { + self.bank_line_3.id: {'aml_ids': [self.invoice_line_5.id], 'model': self.rule_1, 'partner': self.bank_line_3.partner_id}, + self.bank_line_4.id: {'aml_ids': [self.invoice_line_7.id], 'model': self.rule_1, 'partner': self.bank_line_4.partner_id}, + self.bank_line_5.id: {'aml_ids': [self.invoice_line_6.id], 'model': self.rule_1, 'partner': self.bank_line_5.partner_id}, + }, statements=self.bank_st_2) + + self.rule_1.match_text_location_label = True + self.rule_1.match_text_location_reference = False + self.rule_1.match_text_location_note = True + self._check_statement_matching(self.rule_1, { + self.bank_line_3.id: {'aml_ids': [self.invoice_line_6.id], 'model': self.rule_1, 'partner': self.bank_line_3.partner_id}, + self.bank_line_4.id: {'aml_ids': [self.invoice_line_7.id], 'model': self.rule_1, 'partner': self.bank_line_4.partner_id}, + self.bank_line_5.id: {'aml_ids': [self.invoice_line_5.id], 'model': self.rule_1, 'partner': self.bank_line_5.partner_id}, + }, statements=self.bank_st_2) + + self.rule_1.match_text_location_label = True + self.rule_1.match_text_location_reference = True + self.rule_1.match_text_location_note = False + self._check_statement_matching(self.rule_1, { + self.bank_line_3.id: {'aml_ids': [self.invoice_line_5.id], 'model': self.rule_1, 'partner': self.bank_line_3.partner_id}, + self.bank_line_4.id: {'aml_ids': [self.invoice_line_7.id], 'model': self.rule_1, 'partner': self.bank_line_4.partner_id}, + self.bank_line_5.id: {'aml_ids': [self.invoice_line_7.id], 'model': self.rule_1, 'partner': self.bank_line_5.partner_id}, + }, statements=self.bank_st_2) + + self.rule_1.match_text_location_label = True + self.rule_1.match_text_location_reference = True + self.rule_1.match_text_location_note = True + self._check_statement_matching(self.rule_1, { + self.bank_line_3.id: {'aml_ids': [self.invoice_line_6.id], 'model': self.rule_1, 'partner': self.bank_line_3.partner_id}, + self.bank_line_4.id: {'aml_ids': [self.invoice_line_7.id], 'model': self.rule_1, 'partner': self.bank_line_4.partner_id}, + self.bank_line_5.id: {'aml_ids': [self.invoice_line_7.id], 'model': self.rule_1, 'partner': self.bank_line_5.partner_id}, + }, statements=self.bank_st_2) + + self.rule_1.match_text_location_label = False + self.rule_1.match_text_location_reference = False + self.rule_1.match_text_location_note = False + self._check_statement_matching(self.rule_1, { + self.bank_line_3.id: {'aml_ids': [self.invoice_line_5.id], 'model': self.rule_1, 'partner': self.bank_line_3.partner_id}, + self.bank_line_4.id: {'aml_ids': [self.invoice_line_5.id], 'model': self.rule_1, 'partner': self.bank_line_4.partner_id}, + self.bank_line_5.id: {'aml_ids': [self.invoice_line_6.id], 'model': self.rule_1, 'partner': self.bank_line_5.partner_id}, + }, statements=self.bank_st_2) + + def test_matching_fields_match_journal_ids(self): + self.rule_1.match_journal_ids |= self.cash_st.journal_id + self._check_statement_matching(self.rule_1, { + self.bank_line_1.id: {'aml_ids': []}, + self.bank_line_2.id: {'aml_ids': []}, + self.cash_line_1.id: {'aml_ids': [self.invoice_line_4.id], 'model': self.rule_1, 'partner': self.cash_line_1.partner_id}, + }) + self.rule_1.match_journal_ids |= self.bank_st.journal_id + self.cash_st.journal_id + + def test_matching_fields_match_nature(self): + self.rule_1.match_nature = 'amount_received' + self._check_statement_matching(self.rule_1, { + self.bank_line_1.id: {'aml_ids': [self.invoice_line_1.id], 'model': self.rule_1, 'partner': self.bank_line_1.partner_id}, + self.bank_line_2.id: {'aml_ids': [ + self.invoice_line_2.id, + self.invoice_line_3.id, + self.invoice_line_1.id, + ], 'model': self.rule_1, 'partner': self.bank_line_2.partner_id}, + self.cash_line_1.id: {'aml_ids': []}, + }) + self.rule_1.match_nature = 'amount_paid' + self._check_statement_matching(self.rule_1, { + self.bank_line_1.id: {'aml_ids': []}, + self.bank_line_2.id: {'aml_ids': []}, + self.cash_line_1.id: {'aml_ids': [self.invoice_line_4.id], 'model': self.rule_1, 'partner': self.cash_line_1.partner_id}, + }) + self.rule_1.match_nature = 'both' + + def test_matching_fields_match_amount(self): + self.rule_1.match_amount = 'lower' + self.rule_1.match_amount_max = 150 + self._check_statement_matching(self.rule_1, { + self.bank_line_1.id: {'aml_ids': [self.invoice_line_1.id], 'model': self.rule_1, 'partner': self.bank_line_1.partner_id}, + self.bank_line_2.id: {'aml_ids': []}, + self.cash_line_1.id: {'aml_ids': []}, + }) + self.rule_1.match_amount = 'greater' + self.rule_1.match_amount_min = 200 + self._check_statement_matching(self.rule_1, { + self.bank_line_1.id: {'aml_ids': []}, + self.bank_line_2.id: {'aml_ids': [ + self.invoice_line_1.id, + self.invoice_line_2.id, + self.invoice_line_3.id, + ], 'model': self.rule_1, 'partner': self.bank_line_2.partner_id}, + self.cash_line_1.id: {'aml_ids': [self.invoice_line_4.id], 'model': self.rule_1, 'partner': self.cash_line_1.partner_id}, + }) + self.rule_1.match_amount = 'between' + self.rule_1.match_amount_min = 200 + self.rule_1.match_amount_max = 800 + self._check_statement_matching(self.rule_1, { + self.bank_line_1.id: {'aml_ids': []}, + self.bank_line_2.id: {'aml_ids': [ + self.invoice_line_1.id, + self.invoice_line_2.id, + self.invoice_line_3.id, + ], 'model': self.rule_1, 'partner': self.bank_line_2.partner_id}, + self.cash_line_1.id: {'aml_ids': []}, + }) + self.rule_1.match_amount = False + + def test_matching_fields_match_label(self): + self.rule_1.match_label = 'contains' + self.rule_1.match_label_param = 'yyyyy' + self._check_statement_matching(self.rule_1, { + self.bank_line_1.id: {'aml_ids': []}, + self.bank_line_2.id: {'aml_ids': []}, + self.cash_line_1.id: {'aml_ids': [self.invoice_line_4.id], 'model': self.rule_1, 'partner': self.cash_line_1.partner_id}, + }) + self.rule_1.match_label = 'not_contains' + self.rule_1.match_label_param = 'xxxxx' + self._check_statement_matching(self.rule_1, { + self.bank_line_1.id: {'aml_ids': [self.invoice_line_1.id], 'model': self.rule_1, 'partner': self.bank_line_1.partner_id}, + self.bank_line_2.id: {'aml_ids': []}, + self.cash_line_1.id: {'aml_ids': [self.invoice_line_4.id], 'model': self.rule_1, 'partner': self.cash_line_1.partner_id}, + }) + self.rule_1.match_label = 'match_regex' + self.rule_1.match_label_param = 'xxxxx|yyyyy' + self._check_statement_matching(self.rule_1, { + self.bank_line_1.id: {'aml_ids': []}, + self.bank_line_2.id: {'aml_ids': [ + self.invoice_line_1.id, + self.invoice_line_2.id, + self.invoice_line_3.id, + ], 'model': self.rule_1, 'partner': self.bank_line_2.partner_id}, + self.cash_line_1.id: {'aml_ids': [self.invoice_line_4.id], 'model': self.rule_1, 'partner': self.cash_line_1.partner_id}, + }) + self.rule_1.match_label = False + + def test_matching_fields_match_total_amount(self): + # Check match_total_amount: line amount >= total residual amount. + self.rule_1.match_total_amount_param = 90.0 + self.bank_line_1.amount += 10 + self._check_statement_matching(self.rule_1, { + self.bank_line_1.id: {'aml_ids': [self.invoice_line_1.id], 'model': self.rule_1, 'status': 'write_off', 'partner': self.bank_line_1.partner_id}, + self.bank_line_2.id: {'aml_ids': [ + self.invoice_line_2.id, + self.invoice_line_3.id, + self.invoice_line_1.id, + ], 'model': self.rule_1, 'partner': self.bank_line_2.partner_id}, + self.cash_line_1.id: {'aml_ids': [self.invoice_line_4.id], 'model': self.rule_1, 'partner': self.cash_line_1.partner_id}, + }) + self.rule_1.match_total_amount_param = 100.0 + self.bank_line_1.amount -= 10 + + # Check match_total_amount: line amount <= total residual amount. + self.rule_1.match_total_amount_param = 90.0 + self.bank_line_1.amount -= 10 + self._check_statement_matching(self.rule_1, { + self.bank_line_1.id: {'aml_ids': [self.invoice_line_1.id], 'model': self.rule_1, 'status': 'write_off', 'partner': self.bank_line_1.partner_id}, + self.bank_line_2.id: {'aml_ids': [ + self.invoice_line_2.id, + self.invoice_line_3.id, + self.invoice_line_1.id, + ], 'model': self.rule_1, 'partner': self.bank_line_2.partner_id}, + self.cash_line_1.id: {'aml_ids': [self.invoice_line_4.id], 'model': self.rule_1, 'partner': self.cash_line_1.partner_id}, + }) + self.rule_1.match_total_amount_param = 100.0 + self.bank_line_1.amount += 10 + + # Check match_total_amount: line amount >= total residual amount, match_total_amount_param just not matched. + self.rule_1.match_total_amount_param = 90.0 + self.bank_line_1.amount += 10.01 + self._check_statement_matching(self.rule_1, { + self.bank_line_1.id: {'aml_ids': []}, + self.bank_line_2.id: {'aml_ids': [ + self.invoice_line_1.id, + self.invoice_line_2.id, + self.invoice_line_3.id, + ], 'model': self.rule_1, 'partner': self.bank_line_2.partner_id}, + self.cash_line_1.id: {'aml_ids': [self.invoice_line_4.id], 'model': self.rule_1, 'partner': self.cash_line_1.partner_id}, + }) + self.rule_1.match_total_amount_param = 100.0 + self.bank_line_1.amount -= 10.01 + + # Check match_total_amount: line amount <= total residual amount, match_total_amount_param just not matched. + self.rule_1.match_total_amount_param = 90.0 + self.bank_line_1.amount -= 10.01 + self._check_statement_matching(self.rule_1, { + self.bank_line_1.id: {'aml_ids': []}, + self.bank_line_2.id: {'aml_ids': [ + self.invoice_line_1.id, + self.invoice_line_2.id, + self.invoice_line_3.id, + ], 'model': self.rule_1, 'partner': self.bank_line_2.partner_id}, + self.cash_line_1.id: {'aml_ids': [self.invoice_line_4.id], 'model': self.rule_1, 'partner': self.cash_line_1.partner_id}, + }) + self.rule_1.match_total_amount_param = 100.0 + self.bank_line_1.amount += 10.01 + + def test_matching_fields_match_partner_category_ids(self): + test_category = self.env['res.partner.category'].create({'name': 'Consulting Services'}) + self.partner_2.category_id = test_category + self.rule_1.match_partner_category_ids |= test_category + self._check_statement_matching(self.rule_1, { + self.bank_line_1.id: {'aml_ids': []}, + self.bank_line_2.id: {'aml_ids': []}, + self.cash_line_1.id: {'aml_ids': [self.invoice_line_4.id], 'model': self.rule_1, 'partner': self.cash_line_1.partner_id}, + }) + self.rule_1.match_partner_category_ids = False + + def test_mixin_rules(self): + ''' Test usage of rules together.''' + # rule_1 is used before rule_2. + self.rule_1.sequence = 1 + self.rule_2.sequence = 2 + + self._check_statement_matching(self.rule_1 + self.rule_2, { + self.bank_line_1.id: {'aml_ids': [self.invoice_line_1.id], 'model': self.rule_1, 'partner': self.bank_line_1.partner_id}, + self.bank_line_2.id: {'aml_ids': [ + self.invoice_line_2.id, + self.invoice_line_3.id, + self.invoice_line_1.id, + ], 'model': self.rule_1, 'partner': self.bank_line_2.partner_id}, + self.cash_line_1.id: {'aml_ids': [self.invoice_line_4.id], 'model': self.rule_1, 'partner': self.cash_line_1.partner_id}, + }) + + # rule_2 is used before rule_1. + self.rule_1.sequence = 2 + self.rule_2.sequence = 1 + + self._check_statement_matching(self.rule_1 + self.rule_2, { + self.bank_line_1.id: {'aml_ids': [], 'model': self.rule_2, 'status': 'write_off', 'partner': self.bank_line_1.partner_id}, + self.bank_line_2.id: {'aml_ids': [], 'model': self.rule_2, 'status': 'write_off', 'partner': self.bank_line_2.partner_id}, + self.cash_line_1.id: {'aml_ids': [], 'model': self.rule_2, 'status': 'write_off', 'partner': self.cash_line_1.partner_id}, + }) + + # rule_2 is used before rule_1 but only on partner_1. + self.rule_2.match_partner_ids |= self.partner_1 + + self._check_statement_matching(self.rule_1 + self.rule_2, { + self.bank_line_1.id: {'aml_ids': [], 'model': self.rule_2, 'status': 'write_off', 'partner': self.bank_line_1.partner_id}, + self.bank_line_2.id: {'aml_ids': [], 'model': self.rule_2, 'status': 'write_off', 'partner': self.bank_line_2.partner_id}, + self.cash_line_1.id: {'aml_ids': [self.invoice_line_4.id], 'model': self.rule_1, 'partner': self.cash_line_1.partner_id}, + }) + + def test_auto_reconcile(self): + ''' Test auto reconciliation.''' + self.bank_line_1.amount += 5 + + self.rule_1.sequence = 2 + self.rule_1.auto_reconcile = True + self.rule_1.match_total_amount_param = 90 + self.rule_2.sequence = 1 + self.rule_2.match_partner_ids |= self.partner_2 + self.rule_2.auto_reconcile = True + + self._check_statement_matching(self.rule_1 + self.rule_2, { + self.bank_line_1.id: {'aml_ids': [self.invoice_line_1.id], 'model': self.rule_1, 'status': 'reconciled', 'partner': self.bank_line_1.partner_id}, + self.bank_line_2.id: {'aml_ids': []}, + self.cash_line_1.id: {'aml_ids': [], 'model': self.rule_2, 'status': 'reconciled', 'partner': self.cash_line_1.partner_id}, + }) + + # Check first line has been well reconciled. + self.assertRecordValues(self.bank_line_1.line_ids, [ + {'partner_id': self.partner_1.id, 'debit': 105.0, 'credit': 0.0}, + {'partner_id': self.partner_1.id, 'debit': 0.0, 'credit': 5.0}, + {'partner_id': self.partner_1.id, 'debit': 0.0, 'credit': 100.0}, + ]) + + # Check second line has been well reconciled. + self.assertRecordValues(self.cash_line_1.line_ids, [ + {'partner_id': self.partner_2.id, 'debit': 0.0, 'credit': 1000.0}, + {'partner_id': self.partner_2.id, 'debit': 1000.0, 'credit': 0.0}, + ]) + + def test_larger_invoice_auto_reconcile(self): + ''' Test auto reconciliation with an invoice with larger amount than the + statement line's, for rules without write-offs.''' + self.bank_line_1.amount = 40 + self.invoice_line_1.move_id.payment_reference = self.bank_line_1.payment_ref + + self.rule_1.sequence = 2 + self.rule_1.auto_reconcile = True + self.rule_1.line_ids = [(5, 0, 0)] + + self._check_statement_matching(self.rule_1, { + self.bank_line_1.id: {'aml_ids': [self.invoice_line_1.id], 'model': self.rule_1, 'status': 'reconciled', 'partner': self.bank_line_1.partner_id}, + self.bank_line_2.id: {'aml_ids': []}, + }, statements=self.bank_st) + + # Check first line has been well reconciled. + self.assertRecordValues(self.bank_line_1.line_ids, [ + {'partner_id': self.partner_1.id, 'debit': 40.0, 'credit': 0.0}, + {'partner_id': self.partner_1.id, 'debit': 0.0, 'credit': 40.0}, + ]) + + self.assertEqual(self.invoice_line_1.amount_residual, 60.0, "The invoice should have been partially reconciled") + + def test_auto_reconcile_with_duplicate_match(self): + """ If multiple bank statement lines match with the same invoice, ensure the + correct line is auto-validated and no crashing happens. + """ + + # Only the invoice defined in this test should have this partner + partner = self.env['res.partner'].create({'name': "The Only One"}) + invoice_line = self._create_invoice_line( + 2000, partner, 'out_invoice', ref="REF 7788") + + # Enable auto-validation and don't restrict the partners that can be matched on + # so our newly created partner can be matched. + self.rule_1.write({ + 'auto_reconcile': True, + 'match_partner_ids': [(5, 0, 0)], + }) + self.rule_1.match_partner_ids = [] + + # This line has a matching payment reference and the exact amount of the + # invoice. As a result it should auto-validate. + self.bank_line_1.amount = 2000 + self.bank_line_1.partner_id = partner + self.bank_line_1.payment_ref = "REF 7788" + + # This line doesn't have a matching amount or reference, but it does have a + # matching partner. + self.bank_line_2.amount = 1800 + self.bank_line_2.partner_id = partner + self.bank_line_2.payment_ref = "something" + + # Verify the auto-validation happens with the first line, and no exceptions. + self._check_statement_matching(self.rule_1, { + self.bank_line_1.id: { + 'aml_ids': [invoice_line.id], + 'model': self.rule_1, + 'status': 'reconciled', + 'partner': self.bank_line_1.partner_id + }, + self.bank_line_2.id: { + 'aml_ids': [] + }, + }, statements=self.bank_st) + + def test_auto_reconcile_with_tax(self): + ''' Test auto reconciliation with a tax amount included in the bank statement line''' + self.rule_1.write({ + 'auto_reconcile': True, + 'rule_type': 'writeoff_suggestion', + 'line_ids': [(1, self.rule_1.line_ids.id, { + 'amount': 50, + 'force_tax_included': True, + 'tax_ids': [(6, 0, self.tax21.ids)], + }), (0, 0, { + 'amount': 100, + 'force_tax_included': False, + 'tax_ids': [(6, 0, self.tax12.ids)], + 'account_id': self.current_assets_account.id, + })] + }) + + self.bank_line_1.amount = -121 + + self._check_statement_matching(self.rule_1, { + self.bank_line_1.id: {'aml_ids': [], 'model': self.rule_1, 'status': 'reconciled', 'partner': self.bank_line_1.partner_id}, + self.bank_line_2.id: {'aml_ids': [], 'model': self.rule_1, 'status': 'reconciled', 'partner': self.bank_line_2.partner_id}, + }, statements=self.bank_st) + + # Check first line has been well reconciled. + self.assertRecordValues(self.bank_line_1.line_ids, [ + {'partner_id': self.partner_1.id, 'debit': 0.0, 'credit': 121.0, 'tax_ids': [], 'tax_line_id': False}, + {'partner_id': self.partner_1.id, 'debit': 0.0, 'credit': 7.26, 'tax_ids': [], 'tax_line_id': False}, + {'partner_id': self.partner_1.id, 'debit': 50.0, 'credit': 0.0, 'tax_ids': [self.tax21.id], 'tax_line_id': False}, + {'partner_id': self.partner_1.id, 'debit': 10.5, 'credit': 0.0, 'tax_ids': [], 'tax_line_id': self.tax21.id}, + {'partner_id': self.partner_1.id, 'debit': 60.5, 'credit': 0.0, 'tax_ids': [self.tax12.id], 'tax_line_id': False}, + {'partner_id': self.partner_1.id, 'debit': 7.26, 'credit': 0.0, 'tax_ids': [], 'tax_line_id': self.tax12.id}, + ]) + + def test_reverted_move_matching(self): + partner = self.partner_1 + AccountMove = self.env['account.move'] + move = AccountMove.create({ + 'journal_id': self.bank_journal.id, + 'line_ids': [ + (0, 0, { + 'account_id': self.account_pay.id, + 'partner_id': partner.id, + 'name': 'One of these days', + 'debit': 10, + }), + (0, 0, { + 'account_id': self.bank_journal.payment_credit_account_id.id, + 'partner_id': partner.id, + 'name': 'I\'m gonna cut you into little pieces', + 'credit': 10, + }) + ], + }) + + payment_bnk_line = move.line_ids.filtered(lambda l: l.account_id == self.bank_journal.payment_credit_account_id) + + move.action_post() + move_reversed = move._reverse_moves() + self.assertTrue(move_reversed.exists()) + + self.bank_line_1.write({ + 'payment_ref': '8', + 'partner_id': partner.id, + 'amount': -10, + }) + self._check_statement_matching(self.rule_1, { + self.bank_line_1.id: {'aml_ids': [payment_bnk_line.id], 'model': self.rule_1, 'partner': self.bank_line_1.partner_id}, + self.bank_line_2.id: {'aml_ids': []}, + }, statements=self.bank_st) + + def test_match_different_currencies(self): + partner = self.env['res.partner'].create({'name': 'Bernard Gagnant'}) + self.rule_1.write({'match_partner_ids': [(6, 0, partner.ids)], 'match_same_currency': False}) + + currency_inv = self.env.ref('base.EUR') + currency_statement = self.env.ref('base.JPY') + + currency_statement.active = True + + invoice_line = self._create_invoice_line(100, partner, 'out_invoice', currency=currency_inv) + + self.bank_line_1.write({'partner_id': partner.id, 'foreign_currency_id': currency_statement.id, 'amount_currency': 100, 'payment_ref': 'test'}) + self._check_statement_matching(self.rule_1, { + self.bank_line_1.id: {'aml_ids': invoice_line.ids, 'model': self.rule_1, 'partner': self.bank_line_1.partner_id}, + self.bank_line_2.id: {'aml_ids': []}, + }, statements=self.bank_st) + + def test_invoice_matching_rule_no_partner(self): + """ Tests that a statement line without any partner can be matched to the + right invoice if they have the same payment reference. + """ + self.invoice_line_1.move_id.write({'payment_reference': 'Tournicoti66'}) + + self.bank_line_1.write({ + 'payment_ref': 'Tournicoti66', + 'partner_id': None, + 'amount': 95, + }) + + self.rule_1.write({ + 'line_ids': [(5, 0, 0)], + 'match_partner': False, + 'match_label': 'contains', + 'match_label_param': 'Tournicoti', # So that we only match what we want to test + }) + + self._check_statement_matching(self.rule_1, { + self.bank_line_1.id: {'aml_ids': [self.invoice_line_1.id], 'model': self.rule_1, 'partner': self.bank_line_1.partner_id}, + self.bank_line_2.id: {'aml_ids': []}, + }, self.bank_st) + + def test_inv_matching_rule_auto_rec_no_partner_with_writeoff(self): + self.invoice_line_1.move_id.write({'payment_reference': 'doudlidou355'}) + + self.bank_line_1.write({ + 'payment_ref': 'doudlidou355', + 'partner_id': None, + 'amount': 95, + }) + + self.rule_1.write({ + 'match_partner': False, + 'match_label': 'contains', + 'match_label_param': 'doudlidou', # So that we only match what we want to test + 'match_total_amount_param': 90, + 'auto_reconcile': True, + }) + + # Check bank reconciliation + + self._check_statement_matching(self.rule_1, { + self.bank_line_1.id: {'aml_ids': [self.invoice_line_1.id], 'model': self.rule_1, 'partner': self.bank_line_1.partner_id, 'status': 'reconciled'}, + self.bank_line_2.id: {'aml_ids': []}, + }, self.bank_st) + + # Check invoice line has been fully reconciled, with a write-off. + self.assertRecordValues(self.bank_line_1.line_ids, [ + {'partner_id': self.partner_1.id, 'debit': 95.0, 'credit': 0.0, 'account_id': self.bank_journal.default_account_id.id, 'reconciled': False}, + {'partner_id': self.partner_1.id, 'debit': 5.0, 'credit': 0.0, 'account_id': self.current_assets_account.id, 'reconciled': False}, + {'partner_id': self.partner_1.id, 'debit': 0.0, 'credit': 100.0, 'account_id': self.invoice_line_1.account_id.id, 'reconciled': True}, + ]) + + self.assertEqual(self.invoice_line_1.amount_residual, 0.0, "The invoice should have been fully reconciled") + + def test_partner_mapping_rule(self): + self.bank_line_1.write({'partner_id': None, 'payment_ref': 'toto42', 'narration': None}) + self.bank_line_2.write({'partner_id': None}) + + # Do the test for both rule 1 and 2, so that we check invoice matching and write-off rules + for rule in (self.rule_1 + self.rule_2): + + # To cope for minor differences in rule results + matching_amls = rule.rule_type == 'invoice_matching' and self.invoice_line_1.ids or [] + result_status = rule.rule_type == 'writeoff_suggestion' and {'status': 'write_off'} or {} + + match_result = {**result_status, 'aml_ids': matching_amls, 'model': rule, 'partner': self.partner_1} + no_match_result = {'aml_ids': []} + + # Without mapping, there should be no match + self._check_statement_matching(rule, { + self.bank_line_1.id: no_match_result, + self.bank_line_2.id: no_match_result, + }, self.bank_st) + + # We add some mapping for payment reference to rule_1 + rule.write({ + 'partner_mapping_line_ids': [(0, 0, { + 'partner_id': self.partner_1.id, + 'payment_ref_regex': 'toto.*', + })] + }) + + # bank_line_1 should now match + self._check_statement_matching(rule, { + self.bank_line_1.id: match_result, + self.bank_line_2.id: no_match_result, + }, self.bank_st) + + # If we now add a narration regex to the same mapping line, nothing should match + rule.partner_mapping_line_ids.write({'narration_regex': ".*coincoin"}) + self.bank_line_1.write({'narration': None}) # Reset from possible previous iteration + + self._check_statement_matching(rule, { + self.bank_line_1.id: no_match_result, + self.bank_line_2.id: no_match_result, + }, self.bank_st) + + # If we set the narration so that it matches the new mapping criterium, line_1 matches + self.bank_line_1.write({'narration': "42coincoin"}) + + self._check_statement_matching(rule, { + self.bank_line_1.id: match_result, + self.bank_line_2.id: no_match_result, + }, self.bank_st) + + def test_partner_name_in_communication(self): + self.invoice_line_1.partner_id.write({'name': "Archibald Haddock"}) + self.bank_line_1.write({'partner_id': None, 'payment_ref': '1234//HADDOCK-Archibald'}) + self.bank_line_2.write({'partner_id': None}) + self.rule_1.write({'match_partner': False}) + + # bank_line_1 should match, as its communication contains the invoice's partner name + self._check_statement_matching(self.rule_1, { + self.bank_line_1.id: {'aml_ids': [self.invoice_line_1.id], 'model': self.rule_1, 'partner': self.bank_line_1.partner_id}, + self.bank_line_2.id: {'aml_ids': []}, + }, self.bank_st) + + def test_partner_name_with_regexp_chars(self): + self.invoice_line_1.partner_id.write({'name': "Archibald + Haddock"}) + self.bank_line_1.write({'partner_id': None, 'payment_ref': '1234//HADDOCK+Archibald'}) + self.bank_line_2.write({'partner_id': None}) + self.rule_1.write({'match_partner': False}) + + # The query should still work + self._check_statement_matching(self.rule_1, { + self.bank_line_1.id: {'aml_ids': [self.invoice_line_1.id], 'model': self.rule_1, 'partner': self.bank_line_1.partner_id}, + self.bank_line_2.id: {'aml_ids': []}, + }, self.bank_st) + + def test_match_multi_currencies(self): + ''' Ensure the matching of candidates is made using the right statement line currency. + + In this test, the value of the statement line is 100 USD = 300 GOL = 900 DAR and we want to match two journal + items of: + - 100 USD = 200 GOL (= 600 DAR from the statement line point of view) + - 14 USD = 280 DAR + + Both journal items should be suggested to the user because they represents 98% of the statement line amount + (DAR). + ''' + partner = self.env['res.partner'].create({'name': 'Bernard Perdant'}) + + journal = self.env['account.journal'].create({ + 'name': 'test_match_multi_currencies', + 'code': 'xxxx', + 'type': 'bank', + 'currency_id': self.currency_data['currency'].id, + }) + + matching_rule = self.env['account.reconcile.model'].create({ + 'name': 'test_match_multi_currencies', + 'rule_type': 'invoice_matching', + 'match_partner': True, + 'match_partner_ids': [(6, 0, partner.ids)], + 'match_total_amount': True, + 'match_total_amount_param': 95.0, + 'match_same_currency': False, + 'company_id': self.company_data['company'].id, + 'past_months_limit': False, + }) + + statement = self.env['account.bank.statement'].create({ + 'name': 'test_match_multi_currencies', + 'journal_id': journal.id, + 'line_ids': [ + (0, 0, { + 'journal_id': journal.id, + 'date': '2016-01-01', + 'payment_ref': 'line', + 'partner_id': partner.id, + 'foreign_currency_id': self.currency_data_2['currency'].id, + 'amount': 300.0, # Rate is 3 GOL = 1 USD in 2016. + 'amount_currency': 900.0, # Rate is 10 DAR = 1 USD in 2016 but the rate used by the bank is 9:1. + }), + ], + }) + statement_line = statement.line_ids + + statement.button_post() + + move = self.env['account.move'].create({ + 'move_type': 'entry', + 'date': '2017-01-01', + 'journal_id': self.company_data['default_journal_misc'].id, + 'line_ids': [ + # Rate is 2 GOL = 1 USD in 2017. + # The statement line will consider this line equivalent to 600 DAR. + (0, 0, { + 'account_id': self.company_data['default_account_receivable'].id, + 'partner_id': partner.id, + 'currency_id': self.currency_data['currency'].id, + 'debit': 100.0, + 'credit': 0.0, + 'amount_currency': 200.0, + }), + # Rate is 20 GOL = 1 USD in 2017. + (0, 0, { + 'account_id': self.company_data['default_account_receivable'].id, + 'partner_id': partner.id, + 'currency_id': self.currency_data_2['currency'].id, + 'debit': 14.0, + 'credit': 0.0, + 'amount_currency': 280.0, + }), + # Line to balance the journal entry: + (0, 0, { + 'account_id': self.company_data['default_account_revenue'].id, + 'debit': 0.0, + 'credit': 114.0, + }), + ], + }) + move.action_post() + + move_line_1 = move.line_ids.filtered(lambda line: line.debit == 100.0) + move_line_2 = move.line_ids.filtered(lambda line: line.debit == 14.0) + + self._check_statement_matching(matching_rule, { + statement_line.id: {'aml_ids': (move_line_1 + move_line_2).ids, 'model': matching_rule, 'partner': statement_line.partner_id} + }, statements=statement) + + @freeze_time('2020-01-01') + def test_inv_matching_with_write_off(self): + self.rule_1.match_total_amount_param = 90 + self.bank_st.line_ids[1].unlink() # We don't need this one here + statement_line = self.bank_st.line_ids[0] + statement_line.write({ + 'payment_ref': self.invoice_line_1.move_id.payment_reference, + 'amount': 90, + }) + + # Test the invoice-matching part + self._check_statement_matching(self.rule_1, { + statement_line.id: {'aml_ids': self.invoice_line_1.ids, 'model': self.rule_1, 'partner': self.invoice_line_1.partner_id, 'status': 'write_off'}, + }, self.bank_st) + + # Test the write-off part + expected_write_off = { + 'balance': 10, + 'currency_id': False, + 'reconcile_model_id': self.rule_1.id, + 'account_id': self.current_assets_account.id, + } + + matching_result = self.rule_1._apply_rules(statement_line) + + self.assertEqual(len(matching_result[statement_line.id].get('write_off_vals', [])), 1, "Exactly one write-off line should be proposed.") + + full_write_off_dict = matching_result[statement_line.id]['write_off_vals'][0] + to_compare = { + key: full_write_off_dict[key] + for key in expected_write_off.keys() + } + + self.assertDictEqual(expected_write_off, to_compare) + + def test_inv_matching_with_write_off_autoreconcile(self): + self.bank_line_1.amount = 95 + + self.rule_1.sequence = 2 + self.rule_1.auto_reconcile = True + self.rule_1.match_total_amount_param = 90 + + self._check_statement_matching(self.rule_1, { + self.bank_line_1.id: {'aml_ids': [self.invoice_line_1.id], 'model': self.rule_1, 'status': 'reconciled', 'partner': self.bank_line_1.partner_id}, + self.bank_line_2.id: {'aml_ids': []}, + }, statements=self.bank_st) + + # Check first line has been properly reconciled. + self.assertRecordValues(self.bank_line_1.line_ids, [ + {'partner_id': self.partner_1.id, 'debit': 95.0, 'credit': 0.0, 'account_id': self.bank_journal.default_account_id.id, 'reconciled': False}, + {'partner_id': self.partner_1.id, 'debit': 5.0, 'credit': 0.0, 'account_id': self.current_assets_account.id, 'reconciled': False}, + {'partner_id': self.partner_1.id, 'debit': 0.0, 'credit': 100.0, 'account_id': self.invoice_line_1.account_id.id, 'reconciled': True}, + ]) + + self.assertEqual(self.invoice_line_1.amount_residual, 0.0, "The invoice should have been fully reconciled") + + def test_avoid_amount_matching_bypass(self): + """ By the default, if the label of statement lines exactly matches a payment reference, it bypasses any kind of amount verification. + This is annoying in some setups, so a config parameter was introduced to handle that. + """ + self.env['ir.config_parameter'].set_param('account.disable_rec_models_bypass', '1') + self.rule_1.match_total_amount_param = 90 + second_inv_matching_rule = self.env['account.reconcile.model'].create({ + 'name': 'Invoices Matching Rule', + 'sequence': 2, + 'rule_type': 'invoice_matching', + 'auto_reconcile': False, + 'match_nature': 'both', + 'match_same_currency': False, + 'match_total_amount': False, + 'match_partner': True, + 'company_id': self.company.id, + }) + + self.bank_line_1.write({ + 'payment_ref': self.invoice_line_1.move_id.payment_reference, + 'amount': 99, + }) + self.bank_line_2.write({ + 'payment_ref': self.invoice_line_2.move_id.payment_reference, + 'amount': 1, + }) + + self._check_statement_matching(self.rule_1 + second_inv_matching_rule, { + self.bank_line_1.id: {'aml_ids': [self.invoice_line_1.id], 'model': self.rule_1, 'status': 'write_off', 'partner': self.bank_line_1.partner_id}, + self.bank_line_2.id: {'aml_ids': [self.invoice_line_2.id], 'model': second_inv_matching_rule, 'partner': self.bank_line_2.partner_id} + }, statements=self.bank_st) + + def test_payment_similar_communications(self): + def create_payment_line(amount, memo, partner): + payment = self.env['account.payment'].create({ + 'amount': amount, + 'payment_type': 'inbound', + 'partner_type': 'customer', + 'partner_id': partner.id, + 'ref': memo, + 'destination_account_id': self.company_data['default_account_receivable'].id, + }) + payment.action_post() + + return payment.line_ids.filtered(lambda x: x.account_id.user_type_id.type not in {'receivable', 'payable'}) + + payment_partner = self.env['res.partner'].create({ + 'name': "Bernard Gagnant", + }) + + self.rule_1.match_partner_ids = [(6, 0, payment_partner.ids)] + + pmt_line_1 = create_payment_line(500, 'a1b2c3', payment_partner) + pmt_line_2 = create_payment_line(500, 'a1b2c3', payment_partner) + pmt_line_3 = create_payment_line(500, 'd1e2f3', payment_partner) + + self.bank_line_1.write({ + 'amount': 1000, + 'payment_ref': 'a1b2c3', + 'partner_id': payment_partner.id, + }) + self.bank_line_2.unlink() + self.rule_1.match_total_amount = False + + self._check_statement_matching(self.rule_1, { + self.bank_line_1.id: {'aml_ids': (pmt_line_1 + pmt_line_2).ids, 'model': self.rule_1, 'partner': payment_partner}, + }, statements=self.bank_line_1.statement_id) + + def test_tax_tags_inversion_with_reconciliation_model(self): + country = self.env.ref('base.us') + tax_report = self.env['account.tax.report'].create({ + 'name': "Tax report", + 'country_id': country.id, + }) + tax_report_line = self.env['account.tax.report.line'].create({ + 'name': 'test_tax_report_line', + 'tag_name': 'test_tax_report_line', + 'report_id': tax_report.id, + 'sequence': 10, + }) + tax_tag_pos = tax_report_line.tag_ids.filtered(lambda x: not x.tax_negate) + tax_tag_neg = tax_report_line.tag_ids.filtered(lambda x: x.tax_negate) + tax = self.env['account.tax'].create({ + 'name': 'Test Tax', + 'amount': 10, + 'invoice_repartition_line_ids': [ + (0, 0, { + 'factor_percent': 100, + 'repartition_type': 'base', + 'tag_ids': [(6, 0, tax_tag_pos.ids)], + }), + (0, 0, { + 'factor_percent': 100, + 'repartition_type': 'tax', + 'tag_ids': [(6, 0, tax_tag_neg.ids)], + }), + ], + 'refund_repartition_line_ids': [ + (0, 0, { + 'factor_percent': 100, + 'repartition_type': 'base', + 'tag_ids': [(6, 0, tax_tag_neg.ids)], + }), + (0, 0, { + 'factor_percent': 100, + 'repartition_type': 'tax', + 'tag_ids': [(6, 0, tax_tag_pos.ids)], + }), + ], + }) + reconciliation_model = self.env['account.reconcile.model'].create({ + 'name': 'Charge with Tax', + 'line_ids': [(0, 0, { + 'account_id': self.company_data['default_account_expense'].id, + 'amount_type': 'percentage', + 'amount_string': '100', + 'tax_ids': [(6, 0, tax.ids)] + })] + }) + bank_stmt = self.env['account.bank.statement'].create({ + 'journal_id': self.bank_journal.id, + 'date': '2020-07-15', + 'name': 'test', + 'line_ids': [(0, 0, { + 'payment_ref': 'testLine', + 'amount': 5, + 'date': '2020-07-15', + })] + }) + res = reconciliation_model._get_write_off_move_lines_dict(bank_stmt.line_ids, 5) + self.assertEqual(len(res), 2) + self.assertEqual( + res[0]['tax_tag_ids'], + [(6, 0, tax.refund_repartition_line_ids[0].tag_ids.ids)], + 'The tags of the first repartition line are not inverted' + ) + self.assertEqual( + res[1]['tax_tag_ids'], + [(6, 0, tax.refund_repartition_line_ids[1].tag_ids.ids)], + 'The tags of the second repartition line are not inverted' + ) diff --git a/addons/account/tests/test_sequence_mixin.py b/addons/account/tests/test_sequence_mixin.py new file mode 100644 index 00000000..6280533b --- /dev/null +++ b/addons/account/tests/test_sequence_mixin.py @@ -0,0 +1,396 @@ +# -*- coding: utf-8 -*- +from odoo.addons.account.tests.common import AccountTestInvoicingCommon +from odoo.tests import tagged +from odoo.tests.common import Form +from odoo import fields, api, SUPERUSER_ID +from odoo.exceptions import ValidationError +from odoo.tools import mute_logger + +from dateutil.relativedelta import relativedelta +from functools import reduce +import json +import psycopg2 + + +@tagged('post_install', '-at_install') +class TestSequenceMixin(AccountTestInvoicingCommon): + + @classmethod + def setUpClass(cls, chart_template_ref=None): + super().setUpClass(chart_template_ref=chart_template_ref) + cls.test_move = cls.create_move() + + @classmethod + def create_move(cls, move_type=None, date=None, journal=None, name=None, post=False): + move = cls.env['account.move'].create({ + 'move_type': move_type or 'entry', + 'date': date or '2016-01-01', + 'line_ids': [ + (0, None, { + 'name': 'line', + 'account_id': cls.company_data['default_account_revenue'].id, + }), + ] + }) + if journal: + move.name = False + move.journal_id = journal + if name: + move.name = name + if post: + move.action_post() + return move + + def test_sequence_change_date(self): + """Change the sequence when we change the date iff it has never been posted.""" + # Check setup + self.assertEqual(self.test_move.state, 'draft') + self.assertEqual(self.test_move.name, 'MISC/2016/01/0001') + self.assertEqual(fields.Date.to_string(self.test_move.date), '2016-01-01') + + # Never posetd, the number must change if we change the date + self.test_move.date = '2020-02-02' + self.assertEqual(self.test_move.name, 'MISC/2020/02/0001') + + # We don't recompute user's input when posting + self.test_move.name = 'MyMISC/2020/0000001' + self.test_move.action_post() + self.assertEqual(self.test_move.name, 'MyMISC/2020/0000001') + + # Has been posted, and it doesn't change anymore + self.test_move.button_draft() + self.test_move.date = '2020-01-02' + self.test_move.action_post() + self.assertEqual(self.test_move.name, 'MyMISC/2020/0000001') + + def test_journal_sequence(self): + self.assertEqual(self.test_move.name, 'MISC/2016/01/0001') + self.test_move.action_post() + self.assertEqual(self.test_move.name, 'MISC/2016/01/0001') + + copy1 = self.create_move(date=self.test_move.date) + self.assertEqual(copy1.name, '/') + copy1.action_post() + self.assertEqual(copy1.name, 'MISC/2016/01/0002') + + copy2 = self.create_move(date=self.test_move.date) + new_journal = self.test_move.journal_id.copy() + new_journal.code = "MISC2" + copy2.journal_id = new_journal + self.assertEqual(copy2.name, 'MISC2/2016/01/0001') + with Form(copy2) as move_form: # It is editable in the form + with mute_logger('odoo.tests.common.onchange'): + move_form.name = 'MyMISC/2016/0001' + self.assertIn( + 'The sequence will restart at 1 at the start of every year', + move_form._perform_onchange(['name'])['warning']['message'], + ) + move_form.journal_id = self.test_move.journal_id + self.assertEqual(move_form.name, '/') + move_form.journal_id = new_journal + self.assertEqual(move_form.name, 'MISC2/2016/01/0001') + with mute_logger('odoo.tests.common.onchange'): + move_form.name = 'MyMISC/2016/0001' + self.assertIn( + 'The sequence will restart at 1 at the start of every year', + move_form._perform_onchange(['name'])['warning']['message'], + ) + copy2.action_post() + self.assertEqual(copy2.name, 'MyMISC/2016/0001') + + copy3 = self.create_move(date=copy2.date, journal=new_journal) + self.assertEqual(copy3.name, '/') + with self.assertRaises(AssertionError): + with Form(copy2) as move_form: # It is not editable in the form + move_form.name = 'MyMISC/2016/0002' + copy3.action_post() + self.assertEqual(copy3.name, 'MyMISC/2016/0002') + copy3.name = 'MISC2/2016/00002' + + copy4 = self.create_move(date=copy2.date, journal=new_journal) + copy4.action_post() + self.assertEqual(copy4.name, 'MISC2/2016/00003') + + copy5 = self.create_move(date=copy2.date, journal=new_journal) + copy5.date = '2021-02-02' + copy5.action_post() + self.assertEqual(copy5.name, 'MISC2/2021/00001') + copy5.name = 'N\'importe quoi?' + + copy6 = self.create_move(date=copy5.date, journal=new_journal) + copy6.action_post() + self.assertEqual(copy6.name, 'N\'importe quoi?1') + + def test_journal_sequence_format(self): + """Test different format of sequences and what it becomes on another period""" + sequences = [ + ('JRNL/2016/00001', 'JRNL/2016/00002', 'JRNL/2016/00003', 'JRNL/2017/00001'), + ('1234567', '1234568', '1234569', '1234570'), + ('20190910', '20190911', '20190912', '20190913'), + ('2016-0910', '2016-0911', '2016-0912', '2017-0001'), + ('201603-10', '201603-11', '201604-01', '201703-01'), + ('16-03-10', '16-03-11', '16-04-01', '17-03-01'), + ('2016-10', '2016-11', '2016-12', '2017-01'), + ('045-001-000002', '045-001-000003', '045-001-000004', '045-001-000005'), + ('JRNL/2016/00001suffix', 'JRNL/2016/00002suffix', 'JRNL/2016/00003suffix', 'JRNL/2017/00001suffix'), + ] + + init_move = self.create_move(date='2016-03-12') + next_move = self.create_move(date='2016-03-12') + next_move_month = self.create_move(date='2016-04-12') + next_move_year = self.create_move(date='2017-03-12') + next_moves = (next_move + next_move_month + next_move_year) + next_moves.action_post() + + for sequence_init, sequence_next, sequence_next_month, sequence_next_year in sequences: + init_move.name = sequence_init + next_moves.name = False + next_moves._compute_name() + self.assertEqual( + [next_move.name, next_move_month.name, next_move_year.name], + [sequence_next, sequence_next_month, sequence_next_year], + ) + + def test_journal_next_sequence(self): + """Sequences behave correctly even when there is not enough padding.""" + prefix = "TEST_ORDER/2016/" + self.test_move.name = f"{prefix}1" + for c in range(2, 25): + copy = self.create_move(date=self.test_move.date) + copy.name = "/" + copy.action_post() + self.assertEqual(copy.name, f"{prefix}{c}") + + def test_journal_sequence_multiple_type(self): + """Domain is computed accordingly to different types.""" + entry, entry2, invoice, invoice2, refund, refund2 = ( + self.create_move(date='2016-01-01') + for i in range(6) + ) + (invoice + invoice2 + refund + refund2).write({ + 'journal_id': self.company_data['default_journal_sale'], + 'partner_id': 1, + 'invoice_date': '2016-01-01', + }) + (invoice + invoice2).move_type = 'out_invoice' + (refund + refund2).move_type = 'out_refund' + all = (entry + entry2 + invoice + invoice2 + refund + refund2) + all.name = False + all.action_post() + self.assertEqual(entry.name, 'MISC/2016/01/0002') + self.assertEqual(entry2.name, 'MISC/2016/01/0003') + self.assertEqual(invoice.name, 'INV/2016/01/0001') + self.assertEqual(invoice2.name, 'INV/2016/01/0002') + self.assertEqual(refund.name, 'RINV/2016/01/0001') + self.assertEqual(refund2.name, 'RINV/2016/01/0002') + + def test_journal_sequence_groupby_compute(self): + """The grouping optimization is correctly done.""" + # Setup two journals with a sequence that resets yearly + journals = self.env['account.journal'].create([{ + 'name': f'Journal{i}', + 'code': f'J{i}', + 'type': 'general', + } for i in range(2)]) + account = self.env['account.account'].search([], limit=1) + moves = self.env['account.move'].create([{ + 'journal_id': journals[i].id, + 'line_ids': [(0, 0, {'account_id': account.id, 'name': 'line'})], + 'date': '2010-01-01', + } for i in range(2)])._post() + for i in range(2): + moves[i].name = f'J{i}/2010/00001' + + # Check that the moves are correctly batched + moves = self.env['account.move'].create([{ + 'journal_id': journals[journal_index].id, + 'line_ids': [(0, 0, {'account_id': account.id, 'name': 'line'})], + 'date': f'2010-{month}-01', + } for journal_index, month in [(1, 1), (0, 1), (1, 2), (1, 1)]])._post() + self.assertEqual( + moves.mapped('name'), + ['J1/2010/00002', 'J0/2010/00002', 'J1/2010/00004', 'J1/2010/00003'], + ) + + journals[0].code = 'OLD' + journals.flush() + journal_same_code = self.env['account.journal'].create([{ + 'name': 'Journal0', + 'code': 'J0', + 'type': 'general', + }]) + moves = ( + self.create_move(date='2010-01-01', journal=journal_same_code, name='J0/2010/00001') + + self.create_move(date='2010-01-01', journal=journal_same_code) + + self.create_move(date='2010-01-01', journal=journal_same_code) + + self.create_move(date='2010-01-01', journal=journals[0]) + )._post() + self.assertEqual( + moves.mapped('name'), + ['J0/2010/00001', 'J0/2010/00002', 'J0/2010/00003', 'J0/2010/00003'], + ) + + def test_journal_override_sequence_regex(self): + """There is a possibility to override the regex and change the order of the paramters.""" + self.create_move(date='2020-01-01', name='00000876-G 0002/2020') + next = self.create_move(date='2020-01-01') + next.action_post() + self.assertEqual(next.name, '00000876-G 0002/2021') # Wait, I didn't want this! + + next.button_draft() + next.name = False + next.journal_id.sequence_override_regex = r'^(?P<seq>\d*)(?P<suffix1>.*?)(?P<year>(\d{4})?)(?P<suffix2>)$' + next.action_post() + self.assertEqual(next.name, '00000877-G 0002/2020') # Pfew, better! + next = self.create_move(date='2020-01-01') + next.action_post() + self.assertEqual(next.name, '00000878-G 0002/2020') + + next = self.create_move(date='2017-05-02') + next.action_post() + self.assertEqual(next.name, '00000001-G 0002/2017') + + def test_journal_sequence_ordering(self): + """Entries are correctly sorted when posting multiple at once.""" + self.test_move.name = 'XMISC/2016/00001' + copies = reduce((lambda x, y: x+y), [ + self.create_move(date=self.test_move.date) + for i in range(6) + ]) + + copies[0].date = '2019-03-05' + copies[1].date = '2019-03-06' + copies[2].date = '2019-03-07' + copies[3].date = '2019-03-04' + copies[4].date = '2019-03-05' + copies[5].date = '2019-03-05' + # that entry is actualy the first one of the period, so it already has a name + # set it to '/' so that it is recomputed at post to be ordered correctly. + copies[0].name = '/' + copies.action_post() + + # Ordered by date + self.assertEqual(copies[0].name, 'XMISC/2019/00002') + self.assertEqual(copies[1].name, 'XMISC/2019/00005') + self.assertEqual(copies[2].name, 'XMISC/2019/00006') + self.assertEqual(copies[3].name, 'XMISC/2019/00001') + self.assertEqual(copies[4].name, 'XMISC/2019/00003') + self.assertEqual(copies[5].name, 'XMISC/2019/00004') + + # Can't have twice the same name + with self.assertRaises(ValidationError): + copies[0].name = 'XMISC/2019/00001' + + # Lets remove the order by date + copies[0].name = 'XMISC/2019/10001' + copies[1].name = 'XMISC/2019/10002' + copies[2].name = 'XMISC/2019/10003' + copies[3].name = 'XMISC/2019/10004' + copies[4].name = 'XMISC/2019/10005' + copies[5].name = 'XMISC/2019/10006' + + copies[4].button_draft() + copies[4].with_context(force_delete=True).unlink() + copies[5].button_draft() + + wizard = Form(self.env['account.resequence.wizard'].with_context( + active_ids=set(copies.ids) - set(copies[4].ids), + active_model='account.move'), + ) + + new_values = json.loads(wizard.new_values) + self.assertEqual(new_values[str(copies[0].id)]['new_by_date'], 'XMISC/2019/10002') + self.assertEqual(new_values[str(copies[0].id)]['new_by_name'], 'XMISC/2019/10001') + + self.assertEqual(new_values[str(copies[1].id)]['new_by_date'], 'XMISC/2019/10004') + self.assertEqual(new_values[str(copies[1].id)]['new_by_name'], 'XMISC/2019/10002') + + self.assertEqual(new_values[str(copies[2].id)]['new_by_date'], 'XMISC/2019/10005') + self.assertEqual(new_values[str(copies[2].id)]['new_by_name'], 'XMISC/2019/10003') + + self.assertEqual(new_values[str(copies[3].id)]['new_by_date'], 'XMISC/2019/10001') + self.assertEqual(new_values[str(copies[3].id)]['new_by_name'], 'XMISC/2019/10004') + + self.assertEqual(new_values[str(copies[5].id)]['new_by_date'], 'XMISC/2019/10003') + self.assertEqual(new_values[str(copies[5].id)]['new_by_name'], 'XMISC/2019/10005') + + wizard.save().resequence() + + self.assertEqual(copies[3].state, 'posted') + self.assertEqual(copies[5].name, 'XMISC/2019/10005') + self.assertEqual(copies[5].state, 'draft') + + def test_sequence_get_more_specific(self): + """There is the ability to change the format (i.e. from yearly to montlhy).""" + def test_date(date, name): + test = self.create_move(date=date) + test.action_post() + self.assertEqual(test.name, name) + + def set_sequence(date, name): + return self.create_move(date=date, name=name)._post() + + # Start with a continuous sequence + self.test_move.name = 'MISC/00001' + + # Change the prefix to reset every year starting in 2017 + new_year = set_sequence(self.test_move.date + relativedelta(years=1), 'MISC/2017/00001') + + # Change the prefix to reset every month starting in February 2017 + new_month = set_sequence(new_year.date + relativedelta(months=1), 'MISC/2017/02/00001') + + test_date(self.test_move.date, 'MISC/00002') # Keep the old prefix in 2016 + test_date(new_year.date, 'MISC/2017/00002') # Keep the new prefix in 2017 + test_date(new_month.date, 'MISC/2017/02/00002') # Keep the new prefix in February 2017 + + # Change the prefix to never reset (again) year starting in 2018 (Please don't do that) + reset_never = set_sequence(self.test_move.date + relativedelta(years=2), 'MISC/00100') + test_date(reset_never.date, 'MISC/00101') # Keep the new prefix in 2018 + + def test_sequence_concurency(self): + """Computing the same name in concurent transactions is not allowed.""" + with self.env.registry.cursor() as cr0,\ + self.env.registry.cursor() as cr1,\ + self.env.registry.cursor() as cr2: + env0 = api.Environment(cr0, SUPERUSER_ID, {}) + env1 = api.Environment(cr1, SUPERUSER_ID, {}) + env2 = api.Environment(cr2, SUPERUSER_ID, {}) + + journal = env0['account.journal'].create({ + 'name': 'concurency_test', + 'code': 'CT', + 'type': 'general', + }) + account = env0['account.account'].create({ + 'code': 'CT', + 'name': 'CT', + 'user_type_id': env0.ref('account.data_account_type_fixed_assets').id, + }) + moves = env0['account.move'].create([{ + 'journal_id': journal.id, + 'date': fields.Date.from_string('2016-01-01'), + 'line_ids': [(0, 0, {'name': 'name', 'account_id': account.id})] + }] * 3) + moves.name = '/' + moves[0].action_post() + self.assertEqual(moves.mapped('name'), ['CT/2016/01/0001', '/', '/']) + env0.cr.commit() + + # start the transactions here on cr2 to simulate concurency with cr1 + env2.cr.execute('SELECT 1') + + move = env1['account.move'].browse(moves[1].id) + move.action_post() + env1.cr.commit() + + move = env2['account.move'].browse(moves[2].id) + with self.assertRaises(psycopg2.OperationalError), env2.cr.savepoint(), mute_logger('odoo.sql_db'): + move.action_post() + + self.assertEqual(moves.mapped('name'), ['CT/2016/01/0001', 'CT/2016/01/0002', '/']) + moves.button_draft() + moves.posted_before = False + moves.unlink() + journal.unlink() + account.unlink() + env0.cr.commit() diff --git a/addons/account/tests/test_settings.py b/addons/account/tests/test_settings.py new file mode 100644 index 00000000..2aabe334 --- /dev/null +++ b/addons/account/tests/test_settings.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- +from odoo.addons.account.tests.common import AccountTestInvoicingCommon +from odoo.tests import tagged + + +@tagged('post_install', '-at_install') +class TestSettings(AccountTestInvoicingCommon): + + def test_switch_taxB2B_taxB2C(self): + """ + Since having users both in the tax B2B and tax B2C groups raise, + modifications of the settings must be done in the right order; + otherwise it is impossible to change the settings. + """ + # at each setting change, all users should be removed from one group and added to the other + # so picking an arbitrary witness should be equivalent to checking that everything worked. + config = self.env['res.config.settings'].create({}) + self.switch_tax_settings(config) + + def switch_tax_settings(self, config): + config.show_line_subtotals_tax_selection = "tax_excluded" + config._onchange_sale_tax() + config.flush() + config.execute() + self.assertEqual(self.env.user.has_group('account.group_show_line_subtotals_tax_excluded'), True) + self.assertEqual(self.env.user.has_group('account.group_show_line_subtotals_tax_included'), False) + + config.show_line_subtotals_tax_selection = "tax_included" + config._onchange_sale_tax() + config.flush() + config.execute() + self.assertEqual(self.env.user.has_group('account.group_show_line_subtotals_tax_excluded'), False) + self.assertEqual(self.env.user.has_group('account.group_show_line_subtotals_tax_included'), True) + + config.show_line_subtotals_tax_selection = "tax_excluded" + config._onchange_sale_tax() + config.flush() + config.execute() + self.assertEqual(self.env.user.has_group('account.group_show_line_subtotals_tax_excluded'), True) + self.assertEqual(self.env.user.has_group('account.group_show_line_subtotals_tax_included'), False) + + def test_switch_taxB2B_taxB2C_multicompany(self): + """ + Contrarily to the (apparently reasonable) assumption that adding users + to group and removing them was symmetrical, it may not be the case + if one is done in SQL and the other via the ORM. + Because the latter automatically takes into account record rules that + might make some users invisible. + + This one is identical to the previous, except that we do the actions + with a non-superuser user, and in a new company with one user in common + with another company which has a different taxB2X setting. + """ + user = self.env.ref('base.user_admin') + company = self.env['res.company'].create({'name': 'oobO'}) + user.write({'company_ids': [(4, company.id)], 'company_id': company.id}) + Settings = self.env['res.config.settings'].with_user(user.id) + config = Settings.create({}) + + self.switch_tax_settings(config) diff --git a/addons/account/tests/test_tax.py b/addons/account/tests/test_tax.py new file mode 100644 index 00000000..068031ce --- /dev/null +++ b/addons/account/tests/test_tax.py @@ -0,0 +1,1075 @@ +# -*- coding: utf-8 -*- +from odoo.addons.account.tests.common import AccountTestInvoicingCommon +from odoo.tests import tagged + + +@tagged('post_install', '-at_install') +class TestTaxCommon(AccountTestInvoicingCommon): + + @classmethod + def setUpClass(cls, chart_template_ref=None): + super().setUpClass(chart_template_ref=chart_template_ref) + + # Setup another company having a rounding of 1.0. + cls.currency_data['currency'].rounding = 1.0 + cls.currency_no_decimal = cls.currency_data['currency'] + cls.company_data_2 = cls.setup_company_data('company_2', currency_id=cls.currency_no_decimal.id) + cls.env.user.company_id = cls.company_data['company'] + + cls.fixed_tax = cls.env['account.tax'].create({ + 'name': "Fixed tax", + 'amount_type': 'fixed', + 'amount': 10, + 'sequence': 1, + }) + cls.fixed_tax_bis = cls.env['account.tax'].create({ + 'name': "Fixed tax bis", + 'amount_type': 'fixed', + 'amount': 15, + 'sequence': 2, + }) + cls.percent_tax = cls.env['account.tax'].create({ + 'name': "Percent tax", + 'amount_type': 'percent', + 'amount': 10, + 'sequence': 3, + }) + cls.percent_tax_bis = cls.env['account.tax'].create({ + 'name': "Percent tax bis", + 'amount_type': 'percent', + 'amount': 10, + 'sequence': 4, + }) + cls.division_tax = cls.env['account.tax'].create({ + 'name': "Division tax", + 'amount_type': 'division', + 'amount': 10, + 'sequence': 4, + }) + cls.group_tax = cls.env['account.tax'].create({ + 'name': "Group tax", + 'amount_type': 'group', + 'amount': 0, + 'sequence': 5, + 'children_tax_ids': [ + (4, cls.fixed_tax.id, 0), + (4, cls.percent_tax.id, 0) + ] + }) + cls.group_tax_bis = cls.env['account.tax'].create({ + 'name': "Group tax bis", + 'amount_type': 'group', + 'amount': 0, + 'sequence': 6, + 'children_tax_ids': [ + (4, cls.fixed_tax.id, 0), + (4, cls.percent_tax.id, 0) + ] + }) + cls.group_tax_percent = cls.env['account.tax'].create({ + 'name': "Group tax percent", + 'amount_type': 'group', + 'amount': 0, + 'sequence': 6, + 'children_tax_ids': [ + (4, cls.percent_tax.id, 0), + (4, cls.percent_tax_bis.id, 0) + ] + }) + cls.group_of_group_tax = cls.env['account.tax'].create({ + 'name': "Group of group tax", + 'amount_type': 'group', + 'amount': 0, + 'sequence': 7, + 'children_tax_ids': [ + (4, cls.group_tax.id, 0), + (4, cls.group_tax_bis.id, 0) + ] + }) + cls.tax_with_no_account = cls.env['account.tax'].create({ + 'name': "Tax with no account", + 'amount_type': 'fixed', + 'amount': 0, + 'sequence': 8, + }) + some_account = cls.env['account.account'].search([], limit=1) + cls.tax_with_account = cls.env['account.tax'].create({ + 'name': "Tax with account", + 'amount_type': 'fixed', + 'amount': 0, + 'sequence': 8, + 'invoice_repartition_line_ids': [ + (0,0, { + 'factor_percent': 100, + 'repartition_type': 'base', + }), + + (0,0, { + 'factor_percent': 100, + 'repartition_type': 'tax', + 'account_id': some_account.id, + }), + ], + 'refund_repartition_line_ids': [ + (0,0, { + 'factor_percent': 100, + 'repartition_type': 'base', + }), + + (0,0, { + 'factor_percent': 100, + 'repartition_type': 'tax', + 'account_id': some_account.id, + }), + ], + }) + + cls.tax_0_percent = cls.env['account.tax'].with_company(cls.company_data['company']).create({ + 'name': "test_0_percent", + 'amount_type': 'percent', + 'amount': 0, + }) + + cls.tax_5_percent = cls.env['account.tax'].with_company(cls.company_data['company']).create({ + 'name': "test_5_percent", + 'amount_type': 'percent', + 'amount': 5, + }) + + cls.tax_8_percent = cls.env['account.tax'].with_company(cls.company_data['company']).create({ + 'name': "test_8_percent", + 'amount_type': 'percent', + 'amount': 8, + }) + cls.tax_12_percent = cls.env['account.tax'].with_company(cls.company_data['company']).create({ + 'name': "test_12_percent", + 'amount_type': 'percent', + 'amount': 12, + }) + + cls.tax_19_percent = cls.env['account.tax'].with_company(cls.company_data_2['company']).create({ + 'name': "test_19_percent", + 'amount_type': 'percent', + 'amount': 19, + }) + + cls.tax_21_percent = cls.env['account.tax'].with_company(cls.company_data['company']).create({ + 'name': "test_21_percent", + 'amount_type': 'percent', + 'amount': 19, + }) + + cls.tax_21_percent = cls.env['account.tax'].with_company(cls.company_data['company']).create({ + 'name': "test_rounding_methods_2", + 'amount_type': 'percent', + 'amount': 21, + }) + + cls.bank_journal = cls.company_data['default_journal_bank'] + cls.bank_account = cls.bank_journal.default_account_id + cls.expense_account = cls.company_data['default_account_expense'] + + def _check_compute_all_results(self, total_included, total_excluded, taxes, res): + self.assertAlmostEqual(res['total_included'], total_included) + self.assertAlmostEqual(res['total_excluded'], total_excluded) + for i in range(0, len(taxes)): + self.assertAlmostEqual(res['taxes'][i]['base'], taxes[i][0]) + self.assertAlmostEqual(res['taxes'][i]['amount'], taxes[i][1]) + + +@tagged('post_install', '-at_install') +class TestTax(TestTaxCommon): + + @classmethod + def setUpClass(cls): + super(TestTax, cls).setUpClass() + + def test_tax_group_of_group_tax(self): + self.fixed_tax.include_base_amount = True + res = self.group_of_group_tax.compute_all(200.0) + self._check_compute_all_results( + 263, # 'total_included' + 200, # 'total_excluded' + [ + # base , amount | seq | amount | incl | incl_base + # --------------------------------------------------- + (200.0, 10.0), # | 1 | 10 | | t + (210.0, 21.0), # | 3 | 10% | | + (210.0, 10.0), # | 1 | 10 | | t + (220.0, 22.0), # | 3 | 10% | | + # --------------------------------------------------- + ], + res + ) + + def test_tax_group(self): + res = self.group_tax.compute_all(200.0) + self._check_compute_all_results( + 230, # 'total_included' + 200, # 'total_excluded' + [ + # base , amount | seq | amount | incl | incl_base + # --------------------------------------------------- + (200.0, 10.0), # | 1 | 10 | | + (200.0, 20.0), # | 3 | 10% | | + # --------------------------------------------------- + ], + res + ) + + def test_tax_group_percent(self): + res = self.group_tax_percent.with_context({'force_price_include':True}).compute_all(100.0) + self._check_compute_all_results( + 100, # 'total_included' + 83.33, # 'total_excluded' + [ + # base , amount | seq | amount | incl | incl_base + # --------------------------------------------------- + (83.33, 8.33), # | 1 | 10% | | + (83.33, 8.34), # | 2 | 10% | | + # --------------------------------------------------- + ], + res + ) + + def test_tax_percent_division(self): + self.division_tax.price_include = True + self.division_tax.include_base_amount = True + res_division = self.division_tax.compute_all(200.0) + self._check_compute_all_results( + 200, # 'total_included' + 180, # 'total_excluded' + [ + # base , amount | seq | amount | incl | incl_base + # --------------------------------------------------- + (180.0, 20.0), # | 4 | 10/ | t | t + # --------------------------------------------------- + ], + res_division + ) + self.percent_tax.price_include = False + self.percent_tax.include_base_amount = False + res_percent = self.percent_tax.compute_all(100.0) + self._check_compute_all_results( + 110, # 'total_included' + 100, # 'total_excluded' + [ + # base , amount | seq | amount | incl | incl_base + # --------------------------------------------------- + (100.0, 10.0), # | 3 | 10% | | + # --------------------------------------------------- + ], + res_percent + ) + self.division_tax.price_include = False + self.division_tax.include_base_amount = False + res_division = self.division_tax.compute_all(180.0) + self._check_compute_all_results( + 200, # 'total_included' + 180, # 'total_excluded' + [ + # base, amount | seq | amount | incl | incl_base + # --------------------------------------------------- + (180.0, 20.0), # | 4 | 10/ | | + # --------------------------------------------------- + ], + res_division + ) + self.percent_tax.price_include = True + self.percent_tax.include_base_amount = True + res_percent = self.percent_tax.compute_all(110.0) + self._check_compute_all_results( + 110, # 'total_included' + 100, # 'total_excluded' + [ + # base, amount | seq | amount | incl | incl_base + # --------------------------------------------------- + (100.0, 10.0), # | 3 | 10% | t | t + # --------------------------------------------------- + ], + res_percent + ) + self.percent_tax_bis.price_include = True + self.percent_tax_bis.include_base_amount = True + self.percent_tax_bis.amount = 21 + res_percent = self.percent_tax_bis.compute_all(7.0) + self._check_compute_all_results( + 7.0, # 'total_included' + 5.79, # 'total_excluded' + [ + # base , amount | seq | amount | incl | incl_base + # --------------------------------------------------- + (5.79, 1.21), # | 3 | 21% | t | t + # --------------------------------------------------- + ], + res_percent + ) + + def test_tax_sequence_normalized_set(self): + self.division_tax.sequence = 1 + self.fixed_tax.sequence = 2 + self.percent_tax.sequence = 3 + taxes_set = (self.group_tax | self.division_tax) + res = taxes_set.compute_all(200.0) + self._check_compute_all_results( + 252.22, # 'total_included' + 200, # 'total_excluded' + [ + # base , amount | seq | amount | incl | incl_base + # --------------------------------------------------- + (200.0, 22.22), # | 1 | 10/ | | + (200.0, 10.0), # | 2 | 10 | | + (200.0, 20.0), # | 3 | 10% | | + # --------------------------------------------------- + ], + res + ) + + def test_fixed_tax_include_base_amount(self): + self.fixed_tax.include_base_amount = True + res = self.group_tax.compute_all(200.0) + self._check_compute_all_results( + 231, # 'total_included' + 200, # 'total_excluded' + [ + # base , amount | seq | amount | incl | incl_base + # --------------------------------------------------- + (200.0, 10.0), # | 1 | 10 | | t + (210.0, 21.0), # | 3 | 10% | | + # --------------------------------------------------- + ], + res + ) + + self.fixed_tax.price_include = True + self.fixed_tax.include_base_amount = False + res = self.fixed_tax.compute_all(100.0, quantity=2.0) + self._check_compute_all_results( + 200, # 'total_included' + 180, # 'total_excluded' + [ + # base , amount | seq | amount | incl | incl_base + # --------------------------------------------------- + (180.0, 20.0), # | 1 | 20 | | t + # --------------------------------------------------- + ], + res + ) + + def test_percent_tax_include_base_amount(self): + self.percent_tax.price_include = True + self.percent_tax.amount = 21.0 + res = self.percent_tax.compute_all(7.0) + self._check_compute_all_results( + 7.0, # 'total_included' + 5.79, # 'total_excluded' + [ + # base , amount | seq | amount | incl | incl_base + # --------------------------------------------------- + (5.79, 1.21), # | 3 | 21% | t | + # --------------------------------------------------- + ], + res + ) + + self.percent_tax.price_include = True + self.percent_tax.amount = 20.0 + res = self.percent_tax.compute_all(399.99) + self._check_compute_all_results( + 399.99, # 'total_included' + 333.33, # 'total_excluded' + [ + # base , amount | seq | amount | incl | incl_base + # --------------------------------------------------- + (333.33, 66.66), # | 3 | 20% | t | + # --------------------------------------------------- + ], + res + ) + + def test_tax_decimals(self): + """Test the rounding of taxes up to 6 decimals (maximum decimals places allowed for currencies)""" + self.env.user.company_id.currency_id.rounding = 0.000001 + + self.percent_tax.price_include = True + self.percent_tax.amount = 21.0 + res = self.percent_tax.compute_all(7.0) + self._check_compute_all_results( + 7.0, # 'total_included' + 5.785124, # 'total_excluded' + [ + # base , amount | seq | amount | incl | incl_base + # -------------------------------------------------------- + (5.785124, 1.214876), # | 3 | 21% | t | + # -------------------------------------------------------- + ], + res + ) + + self.percent_tax.price_include = True + self.percent_tax.amount = 20.0 + res = self.percent_tax.compute_all(399.999999) + self._check_compute_all_results( + 399.999999, # 'total_included' + 333.333333, # 'total_excluded' + [ + # base , amount | seq | amount | incl | incl_base + # ----------------------------------------------------------- + (333.333333, 66.666666), # | 3 | 20% | t | + # ----------------------------------------------------------- + ], + res + ) + + def test_advanced_taxes_computation_0(self): + '''Test more advanced taxes computation (see issue 34471).''' + tax_1 = self.env['account.tax'].create({ + 'name': 'test_advanced_taxes_computation_0_1', + 'amount_type': 'percent', + 'amount': 10, + 'price_include': True, + 'include_base_amount': True, + 'sequence': 1, + 'invoice_repartition_line_ids': [ + (0, 0, {'repartition_type': 'base', 'factor_percent': 100.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + ], + 'refund_repartition_line_ids': [ + (0, 0, {'repartition_type': 'base', 'factor_percent': 100.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + ], + }) + tax_2 = self.env['account.tax'].create({ + 'name': 'test_advanced_taxes_computation_0_2', + 'amount_type': 'percent', + 'amount': 10, + 'sequence': 2, + 'invoice_repartition_line_ids': [ + (0, 0, {'repartition_type': 'base', 'factor_percent': 100.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + ], + 'refund_repartition_line_ids': [ + (0, 0, {'repartition_type': 'base', 'factor_percent': 100.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + ], + }) + tax_3 = self.env['account.tax'].create({ + 'name': 'test_advanced_taxes_computation_0_3', + 'amount_type': 'percent', + 'amount': 10, + 'price_include': True, + 'sequence': 3, + 'invoice_repartition_line_ids': [ + (0, 0, {'repartition_type': 'base', 'factor_percent': 100.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + ], + 'refund_repartition_line_ids': [ + (0, 0, {'repartition_type': 'base', 'factor_percent': 100.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + ], + }) + tax_4 = self.env['account.tax'].create({ + 'name': 'test_advanced_taxes_computation_0_4', + 'amount_type': 'percent', + 'amount': 10, + 'sequence': 4, + 'invoice_repartition_line_ids': [ + (0, 0, {'repartition_type': 'base', 'factor_percent': 100.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + ], + 'refund_repartition_line_ids': [ + (0, 0, {'repartition_type': 'base', 'factor_percent': 100.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + ], + }) + tax_5 = self.env['account.tax'].create({ + 'name': 'test_advanced_taxes_computation_0_5', + 'amount_type': 'percent', + 'amount': 10, + 'price_include': True, + 'sequence': 5, + 'invoice_repartition_line_ids': [ + (0, 0, {'repartition_type': 'base', 'factor_percent': 100.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + ], + 'refund_repartition_line_ids': [ + (0, 0, {'repartition_type': 'base', 'factor_percent': 100.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + ], + }) + taxes = tax_1 + tax_2 + tax_3 + tax_4 + tax_5 + + # Test with positive amount. + self._check_compute_all_results( + 154, # 'total_included' + 100, # 'total_excluded' + [ + # base , amount | seq | amount | incl | incl_base + # --------------------------------------------------- + (100.0, 5.0), # | 1 | 10% | t | t + (100.0, 5.0), # | 1 | 10% | t | t + (110.0, 5.5), # | 2 | 10% | | + (110.0, 5.5), # | 2 | 10% | | + (110.0, 5.5), # | 3 | 10% | t | + (110.0, 5.5), # | 3 | 10% | t | + (110.0, 5.5), # | 4 | 10% | | + (110.0, 5.5), # | 4 | 10% | | + (110.0, 5.5), # | 5 | 10% | t | + (110.0, 5.5), # | 5 | 10% | t | + # --------------------------------------------------- + ], + taxes.compute_all(132.0) + ) + + # Test with negative amount. + self._check_compute_all_results( + -154, # 'total_included' + -100, # 'total_excluded' + [ + # base , amount | seq | amount | incl | incl_base + # --------------------------------------------------- + (-100.0, -5.0), # | 1 | 10% | t | t + (-100.0, -5.0), # | 1 | 10% | t | t + (-110.0, -5.5), # | 2 | 10% | | + (-110.0, -5.5), # | 2 | 10% | | + (-110.0, -5.5), # | 3 | 10% | t | + (-110.0, -5.5), # | 3 | 10% | t | + (-110.0, -5.5), # | 4 | 10% | | + (-110.0, -5.5), # | 4 | 10% | | + (-110.0, -5.5), # | 5 | 10% | t | + (-110.0, -5.5), # | 5 | 10% | t | + # --------------------------------------------------- + ], + taxes.compute_all(-132.0) + ) + + def test_intracomm_taxes_computation_0(self): + ''' Test usage of intracomm taxes having e.g.+100%, -100% as repartition lines. ''' + intracomm_tax = self.env['account.tax'].create({ + 'name': 'test_intracomm_taxes_computation_0_1', + 'amount_type': 'percent', + 'amount': 21, + 'invoice_repartition_line_ids': [ + (0, 0, {'repartition_type': 'base', 'factor_percent': 100.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 100.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': -100.0}), + ], + 'refund_repartition_line_ids': [ + (0, 0, {'repartition_type': 'base', 'factor_percent': 100.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 100.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': -100.0}), + ], + }) + + # Test with positive amount. + self._check_compute_all_results( + 100, # 'total_included' + 100, # 'total_excluded' + [ + # base , amount + # --------------- + (100.0, 21.0), + (100.0, -21.0), + # --------------- + ], + intracomm_tax.compute_all(100.0) + ) + + # Test with negative amount. + self._check_compute_all_results( + -100, # 'total_included' + -100, # 'total_excluded' + [ + # base , amount + # --------------- + (-100.0, -21.0), + (-100.0, 21.0), + # --------------- + ], + intracomm_tax.compute_all(-100.0) + ) + + def test_rounding_issues_0(self): + ''' Test taxes having a complex setup of repartition lines. ''' + tax = self.env['account.tax'].create({ + 'name': 'test_rounding_issues_0', + 'amount_type': 'percent', + 'amount': 3, + 'invoice_repartition_line_ids': [ + (0, 0, {'repartition_type': 'base', 'factor_percent': 100.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + ], + 'refund_repartition_line_ids': [ + (0, 0, {'repartition_type': 'base', 'factor_percent': 100.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + ], + }) + + # Test with positive amount. + self._check_compute_all_results( + 1.09, # 'total_included' + 1, # 'total_excluded' + [ + # base , amount + # --------------- + (1.0, 0.01), + (1.0, 0.01), + (1.0, 0.01), + (1.0, 0.02), + (1.0, 0.02), + (1.0, 0.02), + # --------------- + ], + tax.compute_all(1.0) + ) + + # Test with negative amount. + self._check_compute_all_results( + -1.09, # 'total_included' + -1, # 'total_excluded' + [ + # base , amount + # --------------- + (-1.0, -0.01), + (-1.0, -0.01), + (-1.0, -0.01), + (-1.0, -0.02), + (-1.0, -0.02), + (-1.0, -0.02), + # --------------- + ], + tax.compute_all(-1.0) + ) + + def test_rounding_issues_1(self): + ''' Test taxes having a complex setup of repartition lines. ''' + tax = self.env['account.tax'].create({ + 'name': 'test_advanced_taxes_repartition_lines_computation_1', + 'amount_type': 'percent', + 'amount': 3, + 'invoice_repartition_line_ids': [ + (0, 0, {'repartition_type': 'base', 'factor_percent': 100.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': -50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 25.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 25.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': -25.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': -25.0}), + ], + 'refund_repartition_line_ids': [ + (0, 0, {'repartition_type': 'base', 'factor_percent': 100.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': -50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 25.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 25.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': -25.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': -25.0}), + ], + }) + + # Test with positive amount. + self._check_compute_all_results( + 1, # 'total_included' + 1, # 'total_excluded' + [ + # base , amount + # --------------- + (1.0, 0.02), + (1.0, -0.02), + (1.0, 0.01), + (1.0, 0.01), + (1.0, -0.01), + (1.0, -0.01), + # --------------- + ], + tax.compute_all(1.0) + ) + + # Test with negative amount. + self._check_compute_all_results( + -1, # 'total_included' + -1, # 'total_excluded' + [ + # base , amount + # --------------- + (-1.0, -0.02), + (-1.0, 0.02), + (-1.0, -0.01), + (-1.0, -0.01), + (-1.0, 0.01), + (-1.0, 0.01), + # --------------- + ], + tax.compute_all(-1.0) + ) + + def test_rounding_tax_excluded_round_per_line_01(self): + ''' Test the rounding of a 19% price excluded tax in an invoice having 22689 and 9176 as lines. + The decimal precision is set to zero. + The computation must be similar to round(22689 * 0.19) + round(9176 * 0.19). + ''' + self.tax_19_percent.company_id.currency_id.rounding = 1.0 + self.tax_19_percent.company_id.tax_calculation_rounding_method = 'round_per_line' + + res1 = self.tax_19_percent.compute_all(22689) + self._check_compute_all_results( + 27000, # 'total_included' + 22689, # 'total_excluded' + [ + # base, amount + # --------------- + (22689, 4311), + # --------------- + ], + res1 + ) + + res2 = self.tax_19_percent.compute_all(9176) + self._check_compute_all_results( + 10919, # 'total_included' + 9176, # 'total_excluded' + [ + # base , amount + # --------------- + (9176, 1743), + # --------------- + ], + res2 + ) + + def test_rounding_tax_excluded_round_globally(self): + ''' Test the rounding of a 19% price excluded tax in an invoice having 22689 and 9176 as lines. + The decimal precision is set to zero. + The computation must be similar to round((22689 + 9176) * 0.19). + ''' + self.tax_19_percent.company_id.tax_calculation_rounding_method = 'round_globally' + + res1 = self.tax_19_percent.compute_all(22689) + self._check_compute_all_results( + 27000, # 'total_included' + 22689, # 'total_excluded' + [ + # base, amount + # --------------- + (22689, 4310.91), + # --------------- + ], + res1 + ) + + res2 = self.tax_19_percent.compute_all(9176) + self._check_compute_all_results( + 10919, # 'total_included' + 9176, # 'total_excluded' + [ + # base , amount + # --------------- + (9176, 1743.44), + # --------------- + ], + res2 + ) + + def test_rounding_tax_included_round_per_line_01(self): + ''' Test the rounding of a 19% price included tax in an invoice having 27000 and 10920 as lines. + The decimal precision is set to zero. + The computation must be similar to round(27000 / 1.19) + round(10920 / 1.19). + ''' + self.tax_19_percent.price_include = True + self.tax_19_percent.company_id.currency_id.rounding = 1.0 + self.tax_19_percent.company_id.tax_calculation_rounding_method = 'round_per_line' + + res1 = self.tax_19_percent.compute_all(27000) + self._check_compute_all_results( + 27000, # 'total_included' + 22689, # 'total_excluded' + [ + # base , amount + # --------------- + (22689, 4311), + # --------------- + ], + res1 + ) + + res2 = self.tax_19_percent.compute_all(10920) + self._check_compute_all_results( + 10920, # 'total_included' + 9176, # 'total_excluded' + [ + # base , amount + # --------------- + (9176, 1744), + # --------------- + ], + res2 + ) + + def test_rounding_tax_included_round_per_line_02(self): + ''' Test the rounding of a 12% price included tax in an invoice having 52.50 as line. + The decimal precision is set to 2. + ''' + self.tax_12_percent.price_include = True + self.tax_12_percent.company_id.currency_id.rounding = 0.01 + + res1 = self.tax_12_percent.compute_all(52.50) + self._check_compute_all_results( + 52.50, # 'total_included' + 46.88, # 'total_excluded' + [ + # base , amount + # ------------- + (46.88, 5.62), + # ------------- + ], + res1 + ) + + def test_rounding_tax_included_round_per_line_03(self): + ''' Test the rounding of a 8% and 0% price included tax in an invoice having 8 * 15.55 as line. + The decimal precision is set to 2. + ''' + self.tax_0_percent.company_id.currency_id.rounding = 0.01 + self.tax_0_percent.price_include = True + self.tax_8_percent.price_include = True + + self.group_tax.children_tax_ids = [(6, 0, self.tax_0_percent.ids)] + self.group_tax_bis.children_tax_ids = [(6, 0, self.tax_8_percent.ids)] + + res1 = (self.tax_8_percent | self.tax_0_percent).compute_all(15.55, quantity=8.0) + self._check_compute_all_results( + 124.40, # 'total_included' + 115.19, # 'total_excluded' + [ + # base , amount + # ------------- + (115.19, 9.21), + (115.19, 0.00), + # ------------- + ], + res1 + ) + + res2 = (self.tax_0_percent | self.tax_8_percent).compute_all(15.55, quantity=8.0) + self._check_compute_all_results( + 124.40, # 'total_included' + 115.19, # 'total_excluded' + [ + # base , amount + # ------------- + (115.19, 0.00), + (115.19, 9.21), + # ------------- + ], + res2 + ) + + def test_rounding_tax_included_round_per_line_04(self): + ''' Test the rounding of a 5% price included tax. + The decimal precision is set to 0.05. + ''' + self.tax_5_percent.price_include = True + self.tax_5_percent.company_id.currency_id.rounding = 0.05 + self.tax_5_percent.company_id.tax_calculation_rounding_method = 'round_per_line' + + res1 = self.tax_5_percent.compute_all(5) + self._check_compute_all_results( + 5, # 'total_included' + 4.75, # 'total_excluded' + [ + # base , amount + # --------------- + (4.75, 0.25), + # --------------- + ], + res1 + ) + + res2 = self.tax_5_percent.compute_all(10) + self._check_compute_all_results( + 10, # 'total_included' + 9.5, # 'total_excluded' + [ + # base , amount + # --------------- + (9.5, 0.5), + # --------------- + ], + res2 + ) + + res3 = self.tax_5_percent.compute_all(50) + self._check_compute_all_results( + 50, # 'total_included' + 47.6, # 'total_excluded' + [ + # base , amount + # --------------- + (47.6, 2.4), + # --------------- + ], + res3 + ) + + def test_rounding_tax_included_round_globally_01(self): + ''' Test the rounding of a 19% price included tax in an invoice having 27000 and 10920 as lines. + The decimal precision is set to zero. + The computation must be similar to round((27000 + 10920) / 1.19). + ''' + self.tax_19_percent.price_include = True + self.tax_19_percent.company_id.tax_calculation_rounding_method = 'round_globally' + + res1 = self.tax_19_percent.compute_all(27000) + self._check_compute_all_results( + 27000, # 'total_included' + 22689, # 'total_excluded' + [ + # base , amount + # --------------- + (22689, 4311), + # --------------- + ], + res1 + ) + + res2 = self.tax_19_percent.compute_all(10920) + self._check_compute_all_results( + 10920, # 'total_included' + 9176, # 'total_excluded' + [ + # base , amount + # --------------- + (9176, 1744), + # --------------- + ], + res2 + ) + + def test_rounding_tax_included_round_globally_02(self): + ''' Test the rounding of a 21% price included tax in an invoice having 11.90 and 2.80 as lines. + The decimal precision is set to 2. + ''' + self.tax_21_percent.price_include = True + self.tax_21_percent.company_id.currency_id.rounding = 0.01 + self.tax_21_percent.company_id.tax_calculation_rounding_method = 'round_globally' + + res1 = self.tax_21_percent.compute_all(11.90) + self._check_compute_all_results( + 11.90, # 'total_included' + 9.83, # 'total_excluded' + [ + # base , amount + # --------------- + (9.83, 2.07), + # --------------- + ], + res1 + ) + + res2 = self.tax_21_percent.compute_all(2.80) + self._check_compute_all_results( + 2.80, # 'total_included' + 2.31, # 'total_excluded' + [ + # base , amount + # --------------- + (2.31, 0.49), + # --------------- + ], + res2 + ) + + def test_rounding_tax_included_round_globally_03(self): + ''' Test the rounding of a 5% price included tax. + The decimal precision is set to 0.05. + ''' + self.tax_5_percent.price_include = True + self.tax_5_percent.company_id.currency_id.rounding = 0.05 + self.tax_5_percent.company_id.tax_calculation_rounding_method = 'round_globally' + + res1 = self.tax_5_percent.compute_all(5) + self._check_compute_all_results( + 5, # 'total_included' + 4.75, # 'total_excluded' + [ + # base , amount + # --------------- + (4.75, 0.25), + # --------------- + ], + res1 + ) + + res2 = self.tax_5_percent.compute_all(10) + self._check_compute_all_results( + 10, # 'total_included' + 9.5, # 'total_excluded' + [ + # base , amount + # --------------- + (9.50, 0.50), + # --------------- + ], + res2 + ) + + res3 = self.tax_5_percent.compute_all(50) + self._check_compute_all_results( + 50, # 'total_included' + 47.6, # 'total_excluded' + [ + # base , amount + # --------------- + (47.60, 2.40), + # --------------- + ], + res3 + ) + + def test_mixing_price_included_excluded_with_affect_base(self): + tax_10_fix = self.env['account.tax'].create({ + 'name': "tax_10_fix", + 'amount_type': 'fixed', + 'amount': 10.0, + 'include_base_amount': True, + }) + tax_21 = self.env['account.tax'].create({ + 'name': "tax_21", + 'amount_type': 'percent', + 'amount': 21.0, + 'price_include': True, + 'include_base_amount': True, + }) + + self._check_compute_all_results( + 1222.1, # 'total_included' + 1000.0, # 'total_excluded' + [ + # base , amount + # --------------- + (1000.0, 10.0), + (1010.0, 212.1), + # --------------- + ], + (tax_10_fix + tax_21).compute_all(1210), + ) diff --git a/addons/account/tests/test_tax_report.py b/addons/account/tests/test_tax_report.py new file mode 100644 index 00000000..23014084 --- /dev/null +++ b/addons/account/tests/test_tax_report.py @@ -0,0 +1,285 @@ +# -*- coding: utf-8 -*- +from odoo.addons.account.tests.common import AccountTestInvoicingCommon +from odoo.tests import tagged + + +@tagged('post_install', '-at_install') +class TaxReportTest(AccountTestInvoicingCommon): + + @classmethod + def setUpClass(cls, chart_template_ref=None): + super().setUpClass(chart_template_ref=chart_template_ref) + cls.test_country_1 = cls.env['res.country'].create({ + 'name': "The Old World", + 'code': 'YY', + }) + + cls.test_country_2 = cls.env['res.country'].create({ + 'name': "The Principality of Zeon", + 'code': 'ZZ', + }) + cls.test_country_3 = cls.env['res.country'].create({ + 'name': "Alagaësia", + 'code': 'QQ', + }) + + cls.tax_report_1 = cls.env['account.tax.report'].create({ + 'name': "Tax report 1", + 'country_id': cls.test_country_1.id, + }) + + cls.tax_report_line_1_1 = cls.env['account.tax.report.line'].create({ + 'name': "[01] Line 01", + 'tag_name': '01', + 'report_id': cls.tax_report_1.id, + 'sequence': 2, + }) + + cls.tax_report_line_1_2 = cls.env['account.tax.report.line'].create({ + 'name': "[01] Line 02", + 'tag_name': '02', + 'report_id': cls.tax_report_1.id, + 'sequence': 3, + }) + + cls.tax_report_line_1_3 = cls.env['account.tax.report.line'].create({ + 'name': "[03] Line 03", + 'tag_name': '03', + 'report_id': cls.tax_report_1.id, + 'sequence': 4, + }) + + cls.tax_report_line_1_4 = cls.env['account.tax.report.line'].create({ + 'name': "[04] Line 04", + 'report_id': cls.tax_report_1.id, + 'sequence': 5, + }) + + cls.tax_report_line_1_5 = cls.env['account.tax.report.line'].create({ + 'name': "[05] Line 05", + 'report_id': cls.tax_report_1.id, + 'sequence': 6, + }) + + cls.tax_report_line_1_55 = cls.env['account.tax.report.line'].create({ + 'name': "[55] Line 55", + 'tag_name': '55', + 'report_id': cls.tax_report_1.id, + 'sequence': 7, + }) + + cls.tax_report_line_1_6 = cls.env['account.tax.report.line'].create({ + 'name': "[100] Line 100", + 'tag_name': '100', + 'report_id': cls.tax_report_1.id, + 'sequence': 8, + }) + + cls.tax_report_2 = cls.env['account.tax.report'].create({ + 'name': "Tax report 2", + 'country_id': cls.test_country_1.id, + }) + + cls.tax_report_line_2_1 = cls.env['account.tax.report.line'].create({ + 'name': "[01] Line 01, but in report 2", + 'tag_name': '01', + 'report_id': cls.tax_report_2.id, + 'sequence': 1, + }) + + cls.tax_report_line_2_2 = cls.env['account.tax.report.line'].create({ + 'name': "[02] Line 02, but in report 2", + 'report_id': cls.tax_report_2.id, + 'sequence': 2, + }) + + cls.tax_report_line_2_42 = cls.env['account.tax.report.line'].create({ + 'name': "[42] Line 42", + 'tag_name': '42', + 'report_id': cls.tax_report_2.id, + 'sequence': 3, + }) + + cls.tax_report_line_2_6 = cls.env['account.tax.report.line'].create({ + 'name': "[100] Line 100", + 'tag_name': '100', + 'report_id': cls.tax_report_2.id, + 'sequence': 4, + }) + + def _get_tax_tags(self, tag_name=None): + domain = [('country_id', '=', self.test_country_1.id), ('applicability', '=', 'taxes')] + if tag_name: + domain.append(('name', 'like', '_' + tag_name )) + return self.env['account.account.tag'].search(domain) + + def test_write_add_tagname(self): + """ Adding a tag_name to a line without any should create new tags. + """ + tags_before = self._get_tax_tags() + self.tax_report_line_2_2.tag_name = 'tournicoti' + tags_after = self._get_tax_tags() + + self.assertEqual(len(tags_after), len(tags_before) + 2, "Two tags should have been created, +tournicoti and -tournicoti.") + + def test_write_single_line_tagname(self): + """ Writing on the tag_name of a line with a non-null tag_name used in + no other line should overwrite the name of the existing tags. + """ + start_tags = self._get_tax_tags() + original_tag_name = self.tax_report_line_1_55.tag_name + original_tags = self.tax_report_line_1_55.tag_ids + self.tax_report_line_1_55.tag_name = 'Mille sabords !' + + self.assertEqual(len(self._get_tax_tags(original_tag_name)), 0, "The original tag name of the line should not correspond to any tag anymore.") + self.assertEqual(original_tags, self.tax_report_line_1_55.tag_ids, "The tax report line should still be linked to the same tags.") + self.assertEqual(len(self._get_tax_tags()), len(start_tags), "No new tag should have been created.") + + def test_write_single_line_remove_tagname(self): + """ Setting None as the tag_name of a line with a non-null tag_name used + in a unique line should delete the tags, also removing all the references to it + from tax repartition lines and account move lines + """ + + test_tax = self.env['account.tax'].create({ + 'name': "Test tax", + 'amount_type': 'percent', + 'amount': 25, + 'type_tax_use': 'sale', + 'invoice_repartition_line_ids': [ + (0,0, { + 'factor_percent': 100, + 'repartition_type': 'base', + }), + + (0,0, { + 'factor_percent': 100, + 'repartition_type': 'tax', + 'tag_ids': [(6, 0, self.tax_report_line_1_55.tag_ids[0].ids)], + }), + ], + 'refund_repartition_line_ids': [ + (0,0, { + 'factor_percent': 100, + 'repartition_type': 'base', + }), + + (0,0, { + 'factor_percent': 100, + 'repartition_type': 'tax', + }), + ], + }) + + test_invoice = self.env['account.move'].create({ + 'move_type': 'out_invoice', + 'partner_id': self.partner_a.id, + 'date': '1992-12-22', + 'invoice_line_ids': [ + (0, 0, {'quantity': 1, 'price_unit': 42, 'tax_ids': [(6, 0, test_tax.ids)]}), + ], + }) + test_invoice.action_post() + + self.assertTrue(any(line.tax_tag_ids == self.tax_report_line_1_55.tag_ids[0] for line in test_invoice.line_ids), "The test invoice should contain a tax line with tag 55") + tag_name_before = self.tax_report_line_1_55.tag_name + tag_nber_before = len(self._get_tax_tags()) + self.tax_report_line_1_55.tag_name = None + self.assertFalse(self.tax_report_line_1_55.tag_name, "The tag name for line 55 should now be None") + self.assertEqual(len(self._get_tax_tags(tag_name_before)), 0, "None of the original tags for this line should be left after setting tag_name to None if no other line was using this tag_name.") + self.assertEqual(len(self._get_tax_tags()), tag_nber_before - 2, "No new tag should have been created, and the two that were assigned to the report line should have been removed.") + self.assertFalse(test_tax.mapped('invoice_repartition_line_ids.tag_ids'), "There should be no tag left on test tax's repartition lines after the removal of tag 55.") + self.assertFalse(test_invoice.mapped('line_ids.tax_tag_ids'), "The link between test invoice and tag 55 should have been broken. There should be no tag left on the invoice's lines.") + + def test_write_multi_no_change(self): + """ Writing the same tag_name as they already use on a set of tax report + lines with the same tag_name should not do anything. + """ + tags_before = self._get_tax_tags().ids + (self.tax_report_line_1_1 + self.tax_report_line_2_1).write({'tag_name': '01'}) + tags_after = self._get_tax_tags().ids + self.assertEqual(tags_before, tags_after, "Re-assigning the same tag_name should keep the same tags.") + + def test_edit_line_shared_tags(self): + """ Setting the tag_name of a tax report line sharing its tags with another line + should edit the tags' name and the tag_name of this other report line, to + keep consistency. + """ + original_tag_name = self.tax_report_line_1_1.tag_name + self.tax_report_line_1_1.tag_name = 'Groucha' + self.assertEqual(self.tax_report_line_2_1.tag_name, self.tax_report_line_1_1.tag_name, "Modifying the tag name of a tax report line sharing it with another one should also modify the other's.") + + def test_edit_multi_line_tagname_all_different_new(self): + """ Writing a tag_name on multiple lines with distinct tag_names should + delete all the former tags and replace them by new ones (also on lines + sharing tags with them). + """ + lines = self.tax_report_line_1_1 + self.tax_report_line_2_2 + self.tax_report_line_2_42 + previous_tag_ids = lines.mapped('tag_ids.id') + lines.write({'tag_name': 'crabe'}) + new_tags = lines.mapped('tag_ids') + + self.assertNotEqual(new_tags.ids, previous_tag_ids, "All the tags should have changed") + self.assertEqual(len(new_tags), 2, "Only two distinct tags should be assigned to all the lines after writing tag_name on them all") + surviving_tags = self.env['account.account.tag'].search([('id', 'in', previous_tag_ids)]) + self.assertEqual(len(surviving_tags), 0, "All former tags should have been deleted") + self.assertEqual(self.tax_report_line_1_1.tag_ids, self.tax_report_line_2_1.tag_ids, "The report lines initially sharing their tag_name with the written-on lines should also have been impacted") + + def test_remove_line_dependency(self): + """ Setting to None the tag_name of a report line sharing its tags with + other lines should only impact this line ; the other ones should keep their + link to the initial tags (their tag_name will hence differ in the end). + """ + tags_before = self.tax_report_line_1_1.tag_ids + self.tax_report_line_1_1.tag_name = None + self.assertEqual(len(self.tax_report_line_1_1.tag_ids), 0, "Setting the tag_name to None should have removed the tags.") + self.assertEqual(self.tax_report_line_2_1.tag_ids, tags_before, "Setting tag_name to None on a line linked to another one via tag_name should break this link.") + + def test_tax_report_change_country(self): + """ Tests that duplicating and modifying the country of a tax report works + as intended (countries wanting to use the tax report of another + country need that). + """ + # Copy our first report + tags_before = self._get_tax_tags().ids + copied_report_1 = self.tax_report_1.copy() + copied_report_2 = self.tax_report_1.copy() + tags_after = self._get_tax_tags().ids + self.assertEqual(tags_before, tags_after, "Report duplication should not create or remove any tag") + + for original, copy in zip(self.tax_report_1.get_lines_in_hierarchy(), copied_report_1.get_lines_in_hierarchy()): + self.assertEqual(original.tag_ids, copy.tag_ids, "Copying the lines of a tax report should keep the same tags on lines") + + # Assign another country to one of the copies + copied_report_1.country_id = self.test_country_2 + for original, copy in zip(self.tax_report_1.get_lines_in_hierarchy(), copied_report_1.get_lines_in_hierarchy()): + if original.tag_ids or copy.tag_ids: + self.assertNotEqual(original.tag_ids, copy.tag_ids, "Changing the country of a copied report should create brand new tags for all of its lines") + + for original, copy in zip(self.tax_report_1.get_lines_in_hierarchy(), copied_report_2.get_lines_in_hierarchy()): + self.assertEqual(original.tag_ids, copy.tag_ids, "Changing the country of a copied report should not impact the other copies or the original report") + + + # Direclty change the country of a report without copying it first (some of its tags are shared, but not all) + original_report_2_tags = {line.id: line.tag_ids.ids for line in self.tax_report_2.get_lines_in_hierarchy()} + self.tax_report_2.country_id = self.test_country_2 + for line in self.tax_report_2.get_lines_in_hierarchy(): + if line == self.tax_report_line_2_42: + # This line is the only one of the report not sharing its tags + self.assertEqual(line.tag_ids.ids, original_report_2_tags[line.id], "The tax report lines not sharing their tags with any other report should keep the same tags when the country of their report is changed") + elif line.tag_ids or original_report_2_tags[line.id]: + self.assertNotEqual(line.tag_ids.ids, original_report_2_tags[line.id], "The tax report lines sharing their tags with other report should receive new tags when the country of their report is changed") + + def test_unlink_report_line_tags(self): + """ Under certain circumstances, unlinking a tax report line should also unlink + the tags that are linked to it. We test those cases here. + """ + def check_tags_unlink(tag_name, report_lines, unlinked, error_message): + report_lines.unlink() + surviving_tags = self._get_tax_tags(tag_name) + required_len = 0 if unlinked else 2 # 2 for + and - tag + self.assertEqual(len(surviving_tags), required_len, error_message) + + check_tags_unlink('42', self.tax_report_line_2_42, True, "Unlinking one line not sharing its tags should also unlink them") + check_tags_unlink('01', self.tax_report_line_1_1, False, "Unlinking one line sharing its tags with others should keep the tags") + check_tags_unlink('100', self.tax_report_line_1_6 + self.tax_report_line_2_6, True, "Unlinkink all the lines sharing the same tags should also unlink them") diff --git a/addons/account/tests/test_templates_consistency.py b/addons/account/tests/test_templates_consistency.py new file mode 100644 index 00000000..ba6ec81d --- /dev/null +++ b/addons/account/tests/test_templates_consistency.py @@ -0,0 +1,73 @@ +# -*- coding: utf-8 -*- +from odoo.tests.common import TransactionCase + + +class AccountingTestTemplConsistency(TransactionCase): + '''Test the templates consistency between some objects like account.account when account.account.template. + ''' + + def check_fields_consistency(self, model_from, model_to, exceptions=[]): + '''Check the consistency of fields from one model to another by comparing if all fields + in the model_from are present in the model_to. + :param model_from: The model to compare. + :param model_to: The compared model. + :param exceptions: Not copied model's fields. + ''' + + def get_fields(model, extra_domain=None): + # Retrieve fields to compare + domain = [('model', '=', model), ('state', '=', 'base'), ('related', '=', False), + ('compute', '=', False), ('store', '=', True)] + if extra_domain: + domain += extra_domain + return self.env['ir.model.fields'].search(domain) + + from_fields = get_fields(model_from, extra_domain=[('name', 'not in', exceptions)]) + to_fields_set = set([f.name for f in get_fields(model_to)]) + for field in from_fields: + assert field.name in to_fields_set,\ + 'Missing field "%s" from "%s" in model "%s".' % (field.name, model_from, model_to) + + def test_account_account_fields(self): + '''Test fields consistency for ('account.account', 'account.account.template') + ''' + self.check_fields_consistency( + 'account.account.template', 'account.account', exceptions=['chart_template_id', 'nocreate']) + self.check_fields_consistency( + 'account.account', 'account.account.template', exceptions=['company_id', 'deprecated', 'opening_debit', 'opening_credit', 'allowed_journal_ids', 'group_id', 'root_id', 'is_off_balance']) + + def test_account_tax_fields(self): + '''Test fields consistency for ('account.tax', 'account.tax.template') + ''' + self.check_fields_consistency('account.tax.template', 'account.tax', exceptions=['chart_template_id']) + self.check_fields_consistency('account.tax', 'account.tax.template', exceptions=['company_id']) + self.check_fields_consistency('account.tax.repartition.line.template', 'account.tax.repartition.line', exceptions=['plus_report_line_ids', 'minus_report_line_ids']) + self.check_fields_consistency('account.tax.repartition.line', 'account.tax.repartition.line.template', exceptions=['tag_ids', 'country_id', 'company_id', 'sequence']) + + def test_fiscal_position_fields(self): + '''Test fields consistency for ('account.fiscal.position', 'account.fiscal.position.template') + ''' + #main + self.check_fields_consistency('account.fiscal.position.template', 'account.fiscal.position', exceptions=['chart_template_id']) + self.check_fields_consistency('account.fiscal.position', 'account.fiscal.position.template', exceptions=['active', 'company_id', 'states_count']) + #taxes + self.check_fields_consistency('account.fiscal.position.tax.template', 'account.fiscal.position.tax') + self.check_fields_consistency('account.fiscal.position.tax', 'account.fiscal.position.tax.template') + #accounts + self.check_fields_consistency('account.fiscal.position.account.template', 'account.fiscal.position.account') + self.check_fields_consistency('account.fiscal.position.account', 'account.fiscal.position.account.template') + + def test_reconcile_model_fields(self): + '''Test fields consistency for ('account.reconcile.model', 'account.reconcile.model.template') + ''' + self.check_fields_consistency('account.reconcile.model.template', 'account.reconcile.model', exceptions=['chart_template_id']) + self.check_fields_consistency('account.reconcile.model', 'account.reconcile.model.template', exceptions=['active', 'company_id', 'past_months_limit', 'partner_mapping_line_ids']) + # lines + self.check_fields_consistency('account.reconcile.model.line.template', 'account.reconcile.model.line', exceptions=['chart_template_id']) + self.check_fields_consistency('account.reconcile.model.line', 'account.reconcile.model.line.template', exceptions=['company_id', 'journal_id', 'analytic_account_id', 'analytic_tag_ids', 'amount']) + + def test_account_group_fields(self): + '''Test fields consistency for ('account.group', 'account.group.template') + ''' + self.check_fields_consistency('account.group', 'account.group.template', exceptions=['company_id', 'parent_path']) + self.check_fields_consistency('account.group.template', 'account.group', exceptions=['chart_template_id']) diff --git a/addons/account/tests/test_tour.py b/addons/account/tests/test_tour.py new file mode 100644 index 00000000..8dc70195 --- /dev/null +++ b/addons/account/tests/test_tour.py @@ -0,0 +1,15 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import odoo.tests + + +@odoo.tests.tagged('post_install', '-at_install') +class TestUi(odoo.tests.HttpCase): + + def test_01_account_tour(self): + # This tour doesn't work with demo data on runbot + all_moves = self.env['account.move'].search([('move_type', '!=', 'entry')]) + all_moves.button_draft() + all_moves.posted_before = False + all_moves.unlink() + self.start_tour("/web", 'account_tour', login="admin") diff --git a/addons/account/tests/test_transfer_wizard.py b/addons/account/tests/test_transfer_wizard.py new file mode 100644 index 00000000..eb78a3da --- /dev/null +++ b/addons/account/tests/test_transfer_wizard.py @@ -0,0 +1,291 @@ +# -*- coding: utf-8 -*- +from odoo.addons.account.tests.common import AccountTestInvoicingCommon +from odoo.tests import tagged, Form +import time + +@tagged('post_install', '-at_install') +class TestTransferWizard(AccountTestInvoicingCommon): + + @classmethod + def setUpClass(cls, chart_template_ref=None): + super().setUpClass(chart_template_ref=chart_template_ref) + + cls.company = cls.company_data['company'] + cls.receivable_account = cls.company_data['default_account_receivable'] + cls.payable_account = cls.company_data['default_account_payable'] + cls.accounts = cls.env['account.account'].search([('reconcile', '=', False), ('company_id', '=', cls.company.id)], limit=5) + cls.journal = cls.company_data['default_journal_misc'] + + # Set rate for base currency to 1 + cls.env['res.currency.rate'].search([('company_id', '=', cls.company.id), ('currency_id', '=', cls.company.currency_id.id)]).write({'rate': 1}) + + # Create test currencies + cls.test_currency_1 = cls.env['res.currency'].create({ + 'name': "PMK", + 'symbol':'P', + }) + + cls.test_currency_2 = cls.env['res.currency'].create({ + 'name': "toto", + 'symbol':'To', + }) + + cls.test_currency_3 = cls.env['res.currency'].create({ + 'name': "titi", + 'symbol':'Ti', + }) + + # Create test rates + cls.env['res.currency.rate'].create({ + 'name': time.strftime('%Y') + '-' + '01' + '-01', + 'rate': 0.5, + 'currency_id': cls.test_currency_1.id, + 'company_id': cls.company.id + }) + + cls.env['res.currency.rate'].create({ + 'name': time.strftime('%Y') + '-' + '01' + '-01', + 'rate': 2, + 'currency_id': cls.test_currency_2.id, + 'company_id': cls.company.id + }) + + cls.env['res.currency.rate'].create({ + 'name': time.strftime('%Y') + '-' + '01' + '-01', + 'rate': 10, + 'currency_id': cls.test_currency_3.id, + 'company_id': cls.company.id + }) + + # Create an account using a foreign currency + cls.test_currency_account = cls.env['account.account'].create({ + 'name': 'test destination account', + 'code': 'test_dest_acc', + 'user_type_id': cls.env['ir.model.data'].xmlid_to_res_id('account.data_account_type_current_assets'), + 'currency_id': cls.test_currency_3.id, + }) + + # Create test account.move + cls.move_1 = cls.env['account.move'].create({ + 'journal_id': cls.journal.id, + 'line_ids': [ + (0, 0, { + 'name': "test1_1", + 'account_id': cls.receivable_account.id, + 'debit': 500, + }), + (0, 0, { + 'name': "test1_2", + 'account_id': cls.accounts[0].id, + 'credit': 500, + }), + (0, 0, { + 'name': "test1_3", + 'account_id': cls.accounts[0].id, + 'debit': 800, + 'partner_id': cls.partner_a.id, + }), + (0, 0, { + 'name': "test1_4", + 'account_id': cls.accounts[1].id, + 'credit': 500, + }), + (0, 0, { + 'name': "test1_5", + 'account_id': cls.accounts[2].id, + 'credit': 300, + 'partner_id': cls.partner_a.id, + }), + (0, 0, { + 'name': "test1_6", + 'account_id': cls.accounts[0].id, + 'debit': 270, + 'currency_id': cls.test_currency_1.id, + 'amount_currency': 540, + }), + (0, 0, { + 'name': "test1_7", + 'account_id': cls.accounts[1].id, + 'credit': 140, + }), + (0, 0, { + 'name': "test1_8", + 'account_id': cls.accounts[2].id, + 'credit': 160, + }), + (0, 0, { + 'name': "test1_9", + 'account_id': cls.accounts[2].id, + 'debit': 30, + 'currency_id': cls.test_currency_2.id, + 'amount_currency': 15, + }), + ] + }) + cls.move_1.action_post() + + cls.move_2 = cls.env['account.move'].create({ + 'journal_id': cls.journal.id, + 'line_ids': [ + (0, 0, { + 'name': "test2_1", + 'account_id': cls.accounts[1].id, + 'debit': 400, + }), + (0, 0, { + 'name': "test2_2", + 'account_id': cls.payable_account.id, + 'credit': 400, + }), + (0, 0, { + 'name': "test2_3", + 'account_id': cls.accounts[3].id, + 'debit': 250, + 'partner_id': cls.partner_a.id, + }), + (0, 0, { + 'name': "test2_4", + 'account_id': cls.accounts[1].id, + 'debit': 480, + 'partner_id': cls.partner_b.id, + }), + (0, 0, { + 'name': "test2_5", + 'account_id': cls.accounts[2].id, + 'credit': 730, + 'partner_id': cls.partner_a.id, + }), + (0, 0, { + 'name': "test2_6", + 'account_id': cls.accounts[2].id, + 'credit': 412, + 'partner_id': cls.partner_a.id, + 'currency_id': cls.test_currency_2.id, + 'amount_currency': -633, + }), + (0, 0, { + 'name': "test2_7", + 'account_id': cls.accounts[1].id, + 'debit': 572, + }), + (0, 0, { + 'name': "test2_8", + 'account_id': cls.accounts[2].id, + 'credit': 100, + 'partner_id': cls.partner_a.id, + 'currency_id': cls.test_currency_2.id, + 'amount_currency': -123, + }), + (0, 0, { + 'name': "test2_9", + 'account_id': cls.accounts[2].id, + 'credit': 60, + 'partner_id': cls.partner_a.id, + 'currency_id': cls.test_currency_1.id, + 'amount_currency': -10, + }), + ] + }) + cls.move_2.action_post() + + + def test_transfer_wizard_reconcile(self): + """ Tests reconciliation when doing a transfer with the wizard + """ + active_move_lines = (self.move_1 + self.move_2).mapped('line_ids').filtered(lambda x: x.account_id.user_type_id.type in ('receivable', 'payable')) + + # We use a form to pass the context properly to the depends_context move_line_ids field + context = {'active_model': 'account.move.line', 'active_ids': active_move_lines.ids} + with Form(self.env['account.automatic.entry.wizard'].with_context(context)) as wizard_form: + wizard_form.action = 'change_account' + wizard_form.destination_account_id = self.receivable_account + wizard_form.journal_id = self.journal + wizard = wizard_form.save() + + transfer_move_id = wizard.do_action()['res_id'] + transfer_move = self.env['account.move'].browse(transfer_move_id) + + payable_transfer = transfer_move.line_ids.filtered(lambda x: x.account_id == self.payable_account) + receivable_transfer = transfer_move.line_ids.filtered(lambda x: x.account_id == self.receivable_account) + + self.assertTrue(payable_transfer.reconciled, "Payable line of the transfer move should be fully reconciled") + self.assertAlmostEqual(self.move_1.line_ids.filtered(lambda x: x.account_id == self.receivable_account).amount_residual, 100, self.company.currency_id.decimal_places, "Receivable line of the original move should be partially reconciled, and still have a residual amount of 100 (500 - 400 from payable account)") + self.assertTrue(self.move_2.line_ids.filtered(lambda x: x.account_id == self.payable_account).reconciled, "Payable line of the original move should be fully reconciled") + self.assertAlmostEqual(receivable_transfer.amount_residual, 0, self.company.currency_id.decimal_places, "Receivable line from the transfer move should have nothing left to reconcile") + self.assertAlmostEqual(payable_transfer.debit, 400, self.company.currency_id.decimal_places, "400 should have been debited from payable account to apply the transfer") + self.assertAlmostEqual(receivable_transfer.credit, 400, self.company.currency_id.decimal_places, "400 should have been credited to receivable account to apply the transfer") + + def test_transfer_wizard_grouping(self): + """ Tests grouping (by account and partner) when doing a transfer with the wizard + """ + active_move_lines = (self.move_1 + self.move_2).mapped('line_ids').filtered(lambda x: x.name in ('test1_3', 'test1_4', 'test1_5', 'test2_3', 'test2_4', 'test2_5', 'test2_6', 'test2_8')) + + # We use a form to pass the context properly to the depends_context move_line_ids field + context = {'active_model': 'account.move.line', 'active_ids': active_move_lines.ids} + with Form(self.env['account.automatic.entry.wizard'].with_context(context)) as wizard_form: + wizard_form.action = 'change_account' + wizard_form.destination_account_id = self.accounts[4] + wizard_form.journal_id = self.journal + wizard = wizard_form.save() + + transfer_move_id = wizard.do_action()['res_id'] + transfer_move = self.env['account.move'].browse(transfer_move_id) + + groups = {} + for line in transfer_move.line_ids: + key = (line.account_id, line.partner_id or None, line.currency_id) + self.assertFalse(groups.get(key), "There should be only one line per (account, partner, currency) group in the transfer move.") + groups[key] = line + + self.assertAlmostEqual(groups[(self.accounts[0], self.partner_a, self.company_data['currency'])].balance, -800, self.company.currency_id.decimal_places) + self.assertAlmostEqual(groups[(self.accounts[1], None, self.company_data['currency'])].balance, 500, self.company.currency_id.decimal_places) + self.assertAlmostEqual(groups[(self.accounts[1], self.partner_b, self.company_data['currency'])].balance, -480, self.company.currency_id.decimal_places) + self.assertAlmostEqual(groups[(self.accounts[2], self.partner_a, self.company_data['currency'])].balance, 1030, self.company.currency_id.decimal_places) + self.assertAlmostEqual(groups[(self.accounts[2], self.partner_a, self.test_currency_2)].balance, 512, self.company.currency_id.decimal_places) + self.assertAlmostEqual(groups[(self.accounts[3], self.partner_a, self.company_data['currency'])].balance, -250, self.company.currency_id.decimal_places) + + + def test_transfer_wizard_currency_conversion(self): + """ Tests multi currency use of the transfer wizard, checking the conversion + is propperly done when using a destination account with a currency_id set. + """ + active_move_lines = self.move_1.mapped('line_ids').filtered(lambda x: x.name in ('test1_6', 'test1_9')) + + # We use a form to pass the context properly to the depends_context move_line_ids field + context = {'active_model': 'account.move.line', 'active_ids': active_move_lines.ids} + with Form(self.env['account.automatic.entry.wizard'].with_context(context)) as wizard_form: + wizard_form.action = 'change_account' + wizard_form.destination_account_id = self.test_currency_account + wizard_form.journal_id = self.journal + wizard = wizard_form.save() + + transfer_move_id = wizard.do_action()['res_id'] + transfer_move = self.env['account.move'].browse(transfer_move_id) + + destination_line = transfer_move.line_ids.filtered(lambda x: x.account_id == self.test_currency_account) + self.assertEqual(destination_line.currency_id, self.test_currency_3, "Transferring to an account with a currency set should keep this currency on the transfer line.") + self.assertAlmostEqual(destination_line.amount_currency, 3000, self.company.currency_id.decimal_places, "Transferring two lines with different currencies (and the same partner) on an account with a currency set should convert the balance of these lines into this account's currency (here (270 + 30) * 10 = 3000)") + + + def test_transfer_wizard_no_currency_conversion(self): + """ Tests multi currency use of the transfer wizard, verifying that + currency amounts are kept on distinct lines when transferring to an + account without any currency specified. + """ + active_move_lines = self.move_2.mapped('line_ids').filtered(lambda x: x.name in ('test2_9', 'test2_6', 'test2_8')) + + # We use a form to pass the context properly to the depends_context move_line_ids field + context = {'active_model': 'account.move.line', 'active_ids': active_move_lines.ids} + with Form(self.env['account.automatic.entry.wizard'].with_context(context)) as wizard_form: + wizard_form.action = 'change_account' + wizard_form.destination_account_id = self.receivable_account + wizard_form.journal_id = self.journal + wizard = wizard_form.save() + + transfer_move_id = wizard.do_action()['res_id'] + transfer_move = self.env['account.move'].browse(transfer_move_id) + + destination_lines = transfer_move.line_ids.filtered(lambda x: x.account_id == self.receivable_account) + self.assertEqual(len(destination_lines), 2, "Two lines should have been created on destination account: one for each currency (the lines with same partner and currency should have been aggregated)") + self.assertAlmostEqual(destination_lines.filtered(lambda x: x.currency_id == self.test_currency_1).amount_currency, -10, self.test_currency_1.decimal_places) + self.assertAlmostEqual(destination_lines.filtered(lambda x: x.currency_id == self.test_currency_2).amount_currency, -756, self.test_currency_2.decimal_places) |
