diff --git a/account_reconcile_model_oca/README.rst b/account_reconcile_model_oca/README.rst new file mode 100644 index 0000000000..f6976d4726 --- /dev/null +++ b/account_reconcile_model_oca/README.rst @@ -0,0 +1,90 @@ +=========================== +Account Reconcile Model Oca +=========================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:cead010f67ccc5d4b9700540c9d8d0b4fb20d497a38d36cea80cf6f78563a756 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-LGPL--3-blue.png + :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html + :alt: License: LGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Faccount--reconcile-lightgray.png?logo=github + :target: https://github.com/OCA/account-reconcile/tree/18.0/account_reconcile_model_oca + :alt: OCA/account-reconcile +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/account-reconcile-18-0/account-reconcile-18-0-account_reconcile_model_oca + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/account-reconcile&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module restores account reconciliation models functions moved from +Odoo community to enterpise in V. 17.0 + +**Table of contents** + +.. contents:: + :local: + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Dixmit +* Odoo + +Contributors +------------ + +- Dixmit + + - Enric Tobella + +- Trobz + + - Do Anh Duy + +Other credits +------------- + +The migration of this module from 17.0 to 18.0 was financially supported +by Camptocamp. + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/account-reconcile `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/account_reconcile_model_oca/__init__.py b/account_reconcile_model_oca/__init__.py new file mode 100644 index 0000000000..0650744f6b --- /dev/null +++ b/account_reconcile_model_oca/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/account_reconcile_model_oca/__manifest__.py b/account_reconcile_model_oca/__manifest__.py new file mode 100644 index 0000000000..7e5f4ac9d5 --- /dev/null +++ b/account_reconcile_model_oca/__manifest__.py @@ -0,0 +1,16 @@ +# Copyright 2024 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "Account Reconcile Model Oca", + "summary": """ + This includes the logic moved from Odoo Community to Odoo Enterprise""", + "version": "18.0.1.0.0", + "license": "LGPL-3", + "author": "Dixmit,Odoo,Odoo Community Association (OCA)", + "website": "https://github.com/OCA/account-reconcile", + "depends": ["account"], + "excludes": ["account_accountant"], + "data": [], + "demo": [], +} diff --git a/account_reconcile_model_oca/i18n/account_reconcile_model_oca.pot b/account_reconcile_model_oca/i18n/account_reconcile_model_oca.pot new file mode 100644 index 0000000000..a75b7011f3 --- /dev/null +++ b/account_reconcile_model_oca/i18n/account_reconcile_model_oca.pot @@ -0,0 +1,25 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * account_reconcile_model_oca +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 17.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: account_reconcile_model_oca +#: model:ir.model,name:account_reconcile_model_oca.model_account_bank_statement_line +msgid "Bank Statement Line" +msgstr "" + +#. module: account_reconcile_model_oca +#: model:ir.model,name:account_reconcile_model_oca.model_account_reconcile_model +msgid "" +"Preset to create journal entries during a invoices and payments matching" +msgstr "" diff --git a/account_reconcile_model_oca/i18n/it.po b/account_reconcile_model_oca/i18n/it.po new file mode 100644 index 0000000000..b4901c9a1d --- /dev/null +++ b/account_reconcile_model_oca/i18n/it.po @@ -0,0 +1,30 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * account_reconcile_model_oca +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 17.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2024-04-29 08:38+0000\n" +"Last-Translator: mymage \n" +"Language-Team: none\n" +"Language: it\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 4.17\n" + +#. module: account_reconcile_model_oca +#: model:ir.model,name:account_reconcile_model_oca.model_account_bank_statement_line +msgid "Bank Statement Line" +msgstr "Riga estratto conto" + +#. module: account_reconcile_model_oca +#: model:ir.model,name:account_reconcile_model_oca.model_account_reconcile_model +msgid "" +"Preset to create journal entries during a invoices and payments matching" +msgstr "" +"Preimpostazione per creare registrazioni contabili durante la corrispondenza " +"tra fatture e pagamenti" diff --git a/account_reconcile_model_oca/i18n/zh_CN.po b/account_reconcile_model_oca/i18n/zh_CN.po new file mode 100644 index 0000000000..b9db9d8245 --- /dev/null +++ b/account_reconcile_model_oca/i18n/zh_CN.po @@ -0,0 +1,28 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * account_reconcile_model_oca +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 17.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2024-06-12 03:25+0000\n" +"Last-Translator: xtanuiha \n" +"Language-Team: none\n" +"Language: zh_CN\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=1; plural=0;\n" +"X-Generator: Weblate 4.17\n" + +#. module: account_reconcile_model_oca +#: model:ir.model,name:account_reconcile_model_oca.model_account_bank_statement_line +msgid "Bank Statement Line" +msgstr "银行对账单明细" + +#. module: account_reconcile_model_oca +#: model:ir.model,name:account_reconcile_model_oca.model_account_reconcile_model +msgid "" +"Preset to create journal entries during a invoices and payments matching" +msgstr "在发票和付款匹配期间创建会计分录的预置信息" diff --git a/account_reconcile_model_oca/models/__init__.py b/account_reconcile_model_oca/models/__init__.py new file mode 100644 index 0000000000..cbaab70a65 --- /dev/null +++ b/account_reconcile_model_oca/models/__init__.py @@ -0,0 +1,2 @@ +from . import account_reconcile_model +from . import account_bank_statement_line diff --git a/account_reconcile_model_oca/models/account_bank_statement_line.py b/account_reconcile_model_oca/models/account_bank_statement_line.py new file mode 100644 index 0000000000..b807e3f6ee --- /dev/null +++ b/account_reconcile_model_oca/models/account_bank_statement_line.py @@ -0,0 +1,140 @@ +# Copyright 2023 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import models +from odoo.tools import SQL, html2plaintext + +from odoo.addons.base.models.res_bank import sanitize_account_number + + +class AccountBankStatementLine(models.Model): + _inherit = "account.bank.statement.line" + + def _retrieve_partner(self): + self.ensure_one() + + # Retrieve the partner from the statement line. + if self.partner_id: + return self.partner_id + + # Retrieve the partner from the bank account. + if self.account_number: + account_number_nums = sanitize_account_number(self.account_number) + if account_number_nums: + domain = [("sanitized_acc_number", "ilike", account_number_nums)] + for extra_domain in ([("company_id", "=", self.company_id.id)], []): + bank_accounts = self.env["res.partner.bank"].search( + extra_domain + domain + ) + if len(bank_accounts.partner_id) == 1: + return bank_accounts.partner_id + + # Retrieve the partner from the partner name. + if self.partner_name: + domain = [ + ("parent_id", "=", False), + ("name", "ilike", self.partner_name), + ] + for extra_domain in ([("company_id", "=", self.company_id.id)], []): + partner = self.env["res.partner"].search(extra_domain + domain, limit=1) + if partner: + return partner + + # Retrieve the partner from the 'reconcile models'. + rec_models = self.env["account.reconcile.model"].search( + [ + ("rule_type", "!=", "writeoff_button"), + ("company_id", "=", self.company_id.id), + ] + ) + for rec_model in rec_models: + partner = rec_model._get_partner_from_mapping(self) + if partner and rec_model._is_applicable_for(self, partner): + return partner + + # Retrieve the partner from statement line text values. + st_line_text_values = self._get_st_line_strings_for_matching() + unaccent = self.env.registry.unaccent + sub_queries = [] + params = [] + for text_value in st_line_text_values: + if not text_value: + continue + + # Find a partner having a name contained inside the statement line values. + # Take care a partner could contain some special characters in its name that + # needs to be escaped. + sub_queries.append( + SQL( + rf""" + {unaccent("%s")} ~* ('^' || ( + SELECT STRING_AGG(CONCAT('(?=.*\m', chunk[1], '\M)'), '') + FROM regexp_matches({unaccent('partner.name')}, '\w{{3,}}', 'g') + AS chunk + )) + """, + text_value, + ) + ) + params.append(text_value) + + if sub_queries: + self.env["res.partner"].flush_model(["company_id", "name"]) + self.env["account.move.line"].flush_model(["partner_id", "company_id"]) + query = SQL(""" + SELECT aml.partner_id + FROM account_move_line aml + JOIN res_partner partner ON + aml.partner_id = partner.id + AND partner.name IS NOT NULL + AND partner.active + AND ( + """) + query_parts = SQL(") OR (").join(sub_queries) + final_query = SQL( + """ + %s + %s + ) + WHERE aml.company_id = %s + LIMIT 1 + """, + query, + query_parts, + self.company_id.id, + ) + self._cr.execute(final_query) + row = self._cr.fetchone() + if row: + return self.env["res.partner"].browse(row[0]) + + return self.env["res.partner"] + + def _get_st_line_strings_for_matching(self, allowed_fields=None): + """Collect the strings that could be used on the statement line to perform some + matching. + :param allowed_fields: A explicit list of fields to consider. + :return: A list of strings. + """ + self.ensure_one() + + def _get_text_value(field_name): + if self._fields[field_name].type == "html": + return self[field_name] and html2plaintext(self[field_name]) + else: + return self[field_name] + + st_line_text_values = [] + if allowed_fields is None or "payment_ref" in allowed_fields: + value = _get_text_value("payment_ref") + if value: + st_line_text_values.append(value) + if allowed_fields is None or "narration" in allowed_fields: + value = _get_text_value("narration") + if value: + st_line_text_values.append(value) + if allowed_fields is None or "ref" in allowed_fields: + value = _get_text_value("ref") + if value: + st_line_text_values.append(value) + return st_line_text_values diff --git a/account_reconcile_model_oca/models/account_reconcile_model.py b/account_reconcile_model_oca/models/account_reconcile_model.py new file mode 100644 index 0000000000..1fe0f39225 --- /dev/null +++ b/account_reconcile_model_oca/models/account_reconcile_model.py @@ -0,0 +1,703 @@ +import re +from collections import defaultdict + +from dateutil.relativedelta import relativedelta + +from odoo import Command, fields, models, tools + + +class AccountReconcileModel(models.Model): + _inherit = "account.reconcile.model" + + #################################################### + # RECONCILIATION PROCESS + #################################################### + + def _apply_lines_for_bank_widget(self, residual_amount_currency, partner, st_line): + """Apply the reconciliation model lines to the statement line passed as + parameter. + :param residual_amount_currency: The open balance of the statement line in the + bank reconciliation widget expressed in the statement line currency. + :param partner: The partner set on the wizard. + :param st_line: The statement line processed by the bank reconciliation widget. + :return: A list of python dictionaries (one per reconcile model line) + representing the journal items to be created by the current reconcile model. + """ + self.ensure_one() + currency = ( + st_line.foreign_currency_id + or st_line.journal_id.currency_id + or st_line.company_currency_id + ) + if currency.is_zero(residual_amount_currency): + return [] + + vals_list = [] + for line in self.line_ids: + vals = line._apply_in_bank_widget( + residual_amount_currency, partner, st_line + ) + amount_currency = vals["amount_currency"] + + if currency.is_zero(amount_currency): + continue + + vals_list.append(vals) + residual_amount_currency -= amount_currency + + return vals_list + + def _get_taxes_move_lines_dict(self, tax, base_line_dict): + """Get move.lines dict (to be passed to the create()) corresponding to a tax. + :param tax: An account.tax record. + :param base_line_dict: A dict representing the move.line containing the base + amount. + :return: A list of dict representing move.lines to be created corresponding to + the tax. + """ + self.ensure_one() + balance = base_line_dict["balance"] + + tax_type = tax.type_tax_use + is_refund = (tax_type == "sale" and balance < 0) or ( + tax_type == "purchase" and balance > 0 + ) + + res = tax.compute_all(balance, is_refund=is_refund) + + new_aml_dicts = [] + for tax_res in res["taxes"]: + tax = self.env["account.tax"].browse(tax_res["id"]) + balance = tax_res["amount"] + name = " ".join( + [x for x in [base_line_dict.get("name", ""), tax_res["name"]] if x] + ) + new_aml_dicts.append( + { + "account_id": tax_res["account_id"] or base_line_dict["account_id"], + "journal_id": base_line_dict.get("journal_id", False), + "name": name, + "partner_id": base_line_dict.get("partner_id"), + "balance": balance, + "debit": balance > 0 and balance or 0, + "credit": balance < 0 and -balance or 0, + "analytic_distribution": tax.analytic + and base_line_dict["analytic_distribution"], + "tax_repartition_line_id": tax_res["tax_repartition_line_id"], + "tax_ids": [(6, 0, tax_res["tax_ids"])], + "tax_tag_ids": [(6, 0, tax_res["tag_ids"])], + "group_tax_id": tax_res["group"].id if tax_res["group"] else False, + "currency_id": False, + "reconcile_model_id": self.id, + } + ) + + # Handle price included taxes. + base_balance = tax_res["base"] + base_line_dict.update( + { + "balance": base_balance, + "debit": base_balance > 0 and base_balance or 0, + "credit": base_balance < 0 and -base_balance or 0, + } + ) + + base_line_dict["tax_tag_ids"] = [(6, 0, res["base_tags"])] + return new_aml_dicts + + def _get_write_off_move_lines_dict(self, residual_balance, partner_id): + """Get move.lines dict corresponding to the reconciliation model's write-off + lines. + :param residual_balance: The residual balance of the account on the manual + reconciliation widget. + :return: A list of dict representing move.lines to be created corresponding to + the write-off lines. + """ + self.ensure_one() + + if self.rule_type == "invoice_matching" and ( + not self.allow_payment_tolerance or self.payment_tolerance_param == 0 + ): + return [] + + currency = self.company_id.currency_id + + lines_vals_list = [] + for line in self.line_ids: + balance = 0 + if line.amount_type == "percentage": + balance = currency.round(residual_balance * (line.amount / 100.0)) + elif line.amount_type == "fixed": + balance = currency.round( + line.amount * (1 if residual_balance > 0.0 else -1) + ) + + if currency.is_zero(balance): + continue + + writeoff_line = line._get_write_off_move_line_dict(balance, currency) + lines_vals_list.append(writeoff_line) + + residual_balance -= balance + + if line.tax_ids: + taxes = line.tax_ids + detected_fiscal_position = self.env[ + "account.fiscal.position" + ]._get_fiscal_position(self.env["res.partner"].browse(partner_id)) + if detected_fiscal_position: + taxes = detected_fiscal_position.map_tax(taxes) + writeoff_line["tax_ids"] += [Command.set(taxes.ids)] + # Multiple taxes with force_tax_included results in wrong computation, + # so we only allow to set the force_tax_included field if we have one + # tax selected + if line.force_tax_included: + taxes = taxes[0].with_context(force_price_include=True) + tax_vals_list = self._get_taxes_move_lines_dict(taxes, writeoff_line) + lines_vals_list += tax_vals_list + if not line.force_tax_included: + for tax_line in tax_vals_list: + residual_balance -= tax_line["balance"] + + return lines_vals_list + + #################################################### + # RECONCILIATION CRITERIA + #################################################### + + def _apply_rules(self, st_line, partner): + """Apply criteria to get candidates for all reconciliation models. + This function is called in enterprise by the reconciliation widget to match + the statement line with the available candidates (using the reconciliation + models). + :param st_line: The statement line to match. + :param partner: The partner to consider. + :return: A dict mapping each statement line id with: + * aml_ids: A list of account.move.line ids. + * model: An account.reconcile.model record (optional). + * status: 'reconciled' if the lines has been already reconciled, 'write_off' + if the write-off must be applied on the statement line. + * auto_reconcile: A flag indicating if the match is enough significant to + auto reconcile the candidates. + """ + available_models = self.filtered( + lambda m: m.rule_type != "writeoff_button" + ).sorted() + + for rec_model in available_models: + if not rec_model._is_applicable_for(st_line, partner): + continue + + if rec_model.rule_type == "invoice_matching": + rules_map = rec_model._get_invoice_matching_rules_map() + for rule_index in sorted(rules_map.keys()): + for rule_method in rules_map[rule_index]: + candidate_vals = rule_method(st_line, partner) + if not candidate_vals: + continue + + if candidate_vals.get("amls"): + res = rec_model._get_invoice_matching_amls_result( + st_line, partner, candidate_vals + ) + if res: + return { + **res, + "model": rec_model, + } + else: + return { + **candidate_vals, + "model": rec_model, + } + + elif rec_model.rule_type == "writeoff_suggestion": + return { + "model": rec_model, + "status": "write_off", + "auto_reconcile": rec_model.auto_reconcile, + } + return {} + + def _is_applicable_for(self, st_line, partner): + """Returns true iff this reconciliation model can be used to search for matches + for the provided statement line and partner. + """ + self.ensure_one() + + # Filter on journals, amount nature, amount and partners + # All the conditions defined in this block are non-match conditions. + if ( + ( + self.match_journal_ids + and st_line.move_id.journal_id not in self.match_journal_ids + ) + or (self.match_nature == "amount_received" and st_line.amount < 0) + or (self.match_nature == "amount_paid" and st_line.amount > 0) + or ( + self.match_amount == "lower" + and abs(st_line.amount) >= self.match_amount_max + ) + or ( + self.match_amount == "greater" + and abs(st_line.amount) <= self.match_amount_min + ) + or ( + self.match_amount == "between" + and ( + abs(st_line.amount) > self.match_amount_max + or abs(st_line.amount) < self.match_amount_min + ) + ) + or (self.match_partner and not partner) + or ( + self.match_partner + and self.match_partner_ids + and partner not in self.match_partner_ids + ) + or ( + self.match_partner + and self.match_partner_category_ids + and partner.category_id not in self.match_partner_category_ids + ) + ): + return False + + # Filter on label, note and transaction_type + for record, rule_field, record_field in [ + (st_line, "label", "payment_ref"), + (st_line.move_id, "note", "narration"), + (st_line, "transaction_type", "transaction_type"), + ]: + rule_term = (self["match_" + rule_field + "_param"] or "").lower() + record_term = (record[record_field] or "").lower() + + # This defines non-match conditions + if ( + ( + self["match_" + rule_field] == "contains" + and rule_term not in record_term + ) + or ( + self["match_" + rule_field] == "not_contains" + and rule_term in record_term + ) + or ( + self["match_" + rule_field] == "match_regex" + and not re.match(rule_term, record_term) + ) + ): + return False + + return True + + def _get_invoice_matching_amls_domain(self, st_line, partner): + aml_domain = st_line._get_default_amls_matching_domain() + + if st_line.amount > 0.0: + aml_domain.append(("balance", ">", 0.0)) + else: + aml_domain.append(("balance", "<", 0.0)) + + currency = st_line.foreign_currency_id or st_line.currency_id + if self.match_same_currency: + aml_domain.append(("currency_id", "=", currency.id)) + + if partner: + aml_domain.append(("partner_id", "=", partner.id)) + + if self.past_months_limit: + date_limit = fields.Date.context_today(self) - relativedelta( + months=self.past_months_limit + ) + aml_domain.append(("date", ">=", fields.Date.to_string(date_limit))) + + return aml_domain + + def _get_invoice_matching_st_line_tokens(self, st_line): + """Parse the textual information from the statement line passed as parameter + in order to extract from it the meaningful information in order to perform the + matching. + :param st_line: A statement line. + :return: A list of tokens, each one being a string. + """ + st_line_text_values = st_line._get_st_line_strings_for_matching( + allowed_fields=( + "payment_ref" if self.match_text_location_label else None, + "narration" if self.match_text_location_note else None, + "ref" if self.match_text_location_reference else None, + ) + ) + significant_token_size = 4 + tokens = [] + for text_value in st_line_text_values: + for token in (text_value or "").split(): + # The token is too short to be significant. + if len(token) < significant_token_size: + continue + + formatted_token = "".join(x for x in token if x.isdecimal()) + + # The token is too short after formatting to be significant. + if len(formatted_token) < significant_token_size: + continue + + tokens.append(formatted_token) + return tokens + + def _get_invoice_matching_amls_candidates(self, st_line, partner): + """Returns the match candidates for the 'invoice_matching' rule, with respect to + the provided parameters. + :param st_line: A statement line. + :param partner: The partner associated to the statement line. + """ + assert self.rule_type == "invoice_matching" + self.env["account.move"].flush_model() + self.env["account.move.line"].flush_model() + + if self.matching_order == "new_first": + order_by = "sub.date_maturity DESC, sub.date DESC, sub.id DESC" + else: + order_by = "sub.date_maturity ASC, sub.date ASC, sub.id ASC" + + aml_domain = self._get_invoice_matching_amls_domain(st_line, partner) + query = self.env["account.move.line"]._where_calc(aml_domain) + from_string, from_params = query.from_clause + where_string, where_params = query.where_clause + from_clause = from_string + where_clause = where_string + query_params = from_params + where_params + + tokens = self._get_invoice_matching_st_line_tokens(st_line) + if tokens: + search_fields = [ + ("account_move_line", "name"), + ("account_move_line__move_id", "name"), + ("account_move_line__move_id", "ref"), + ] + sub_queries = [] + for table_alias, field in search_fields: + sub_queries.append( + rf""" + SELECT + account_move_line.id, + account_move_line.date, + account_move_line.date_maturity, + UNNEST( + REGEXP_SPLIT_TO_ARRAY( + SUBSTRING( + REGEXP_REPLACE( + {table_alias}.{field}, '[^0-9\s]', '', 'g' + ), + '\S(?:.*\S)*' + ), + '\s+' + ) + ) AS token + FROM {from_clause} + JOIN account_move account_move_line__move_id + ON account_move_line__move_id.id = account_move_line.move_id + WHERE {where_clause} AND {table_alias}.{field} IS NOT NULL + """ + ) + + self._cr.execute( + """ + SELECT + sub.id, + COUNT(*) AS nb_match + FROM (""" + + " UNION ALL ".join(sub_queries) + + """) AS sub + WHERE sub.token IN %s + GROUP BY sub.date_maturity, sub.date, sub.id + HAVING COUNT(*) > 0 + ORDER BY nb_match DESC, """ + + order_by + + """ + """, + (query_params * 3) + [tuple(tokens)], + ) + candidate_ids = [r[0] for r in self._cr.fetchall()] + if candidate_ids: + return { + "allow_auto_reconcile": True, + "amls": self.env["account.move.line"].browse(candidate_ids), + } + + # Search without any matching based on textual information. + if partner: + if self.matching_order == "new_first": + order = "date_maturity DESC, date DESC, id DESC" + else: + order = "date_maturity ASC, date ASC, id ASC" + + amls = self.env["account.move.line"].search(aml_domain, order=order) + if amls: + return { + "allow_auto_reconcile": False, + "amls": amls, + } + + def _get_invoice_matching_rules_map(self): + """Get a mapping that could be overridden in others + modules. + :return: a mapping where: + * priority_order: Defines in which order the rules will be evaluated, the + lowest comes first. This is extremely important since the algorithm stops + when a rule returns some candidates. + * rule: Method taking as parameters and returning the + candidates journal items found. + """ + rules_map = defaultdict(list) + rules_map[10].append(self._get_invoice_matching_amls_candidates) + return rules_map + + def _get_partner_from_mapping(self, st_line): + """Find partner with mapping defined on model. + For invoice matching rules, matches the statement line against each + regex defined in partner mapping, and returns the partner corresponding + to the first one matching. + :param st_line (Model): + The statement line that needs a partner to be found + :return Model: + The partner found from the mapping. Can be empty an empty recordset + if there was nothing found from the mapping or if the function is + not applicable. + """ + self.ensure_one() + + if self.rule_type not in ("invoice_matching", "writeoff_suggestion"): + return self.env["res.partner"] + + for partner_mapping in self.partner_mapping_line_ids: + match_payment_ref = ( + re.match(partner_mapping.payment_ref_regex, st_line.payment_ref) + if partner_mapping.payment_ref_regex + else True + ) + match_narration = ( + re.match( + partner_mapping.narration_regex, + tools.html2plaintext(st_line.narration or "").rstrip(), + ) + if partner_mapping.narration_regex + else True + ) + + if match_payment_ref and match_narration: + return partner_mapping.partner_id + return self.env["res.partner"] + + def _get_invoice_matching_amls_result(self, st_line, partner, candidate_vals): # noqa: C901 + def _create_result_dict(amls_values_list, status): + if "rejected" in status: + return + + result = {"amls": self.env["account.move.line"]} + for aml_values in amls_values_list: + result["amls"] |= aml_values["aml"] + + if "allow_write_off" in status and self.line_ids: + result["status"] = "write_off" + + if ( + "allow_auto_reconcile" in status + and candidate_vals["allow_auto_reconcile"] + and self.auto_reconcile + ): + result["auto_reconcile"] = True + + return result + + st_line_currency = st_line.foreign_currency_id or st_line.currency_id + st_line_amount = st_line._prepare_move_line_default_vals()[1]["amount_currency"] + sign = 1 if st_line_amount > 0.0 else -1 + + amls = candidate_vals["amls"] + amls_values_list = [] + amls_with_epd_values_list = [] + same_currency_mode = amls.currency_id == st_line_currency + for aml in amls: + aml_values = { + "aml": aml, + "amount_residual": aml.amount_residual, + "amount_residual_currency": aml.amount_residual_currency, + } + + amls_values_list.append(aml_values) + + # Manage the early payment discount. + if ( + same_currency_mode + and aml.move_id.move_type + in ("out_invoice", "out_receipt", "in_invoice", "in_receipt") + and not aml.matched_debit_ids + and not aml.matched_credit_ids + and aml.discount_date + and st_line.date <= aml.discount_date + ): + rate = ( + abs(aml.amount_currency) / abs(aml.balance) if aml.balance else 1.0 + ) + amls_with_epd_values_list.append( + { + **aml_values, + "amount_residual": st_line.company_currency_id.round( + aml.discount_amount_currency / rate + ), + "amount_residual_currency": aml.discount_amount_currency, + } + ) + else: + amls_with_epd_values_list.append(aml_values) + + def match_batch_amls(amls_values_list): + if not same_currency_mode: + return None, [] + + kepts_amls_values_list = [] + sum_amount_residual_currency = 0.0 + for aml_values in amls_values_list: + if ( + st_line_currency.compare_amounts( + st_line_amount, -aml_values["amount_residual_currency"] + ) + == 0 + ): + # Special case: the amounts are the same, submit the line directly. + return "perfect", [aml_values] + + if ( + st_line_currency.compare_amounts( + sign * (st_line_amount + sum_amount_residual_currency), 0.0 + ) + > 0 + ): + # Here, we still have room for other candidates ; so we add the + # current one to the list we keep. Then, we continue iterating, even + # if there is no room anymore, just in case one of the following + # candidates is an exact match, which would then be preferred on the + # current candidates. + kepts_amls_values_list.append(aml_values) + sum_amount_residual_currency += aml_values[ + "amount_residual_currency" + ] + + if st_line_currency.is_zero( + sign * (st_line_amount + sum_amount_residual_currency) + ): + return "perfect", kepts_amls_values_list + elif kepts_amls_values_list: + return "partial", kepts_amls_values_list + else: + return None, [] + + # Try to match a batch with the early payment feature. Only a perfect match is + # allowed. + match_type, kepts_amls_values_list = match_batch_amls(amls_with_epd_values_list) + if match_type != "perfect": + kepts_amls_values_list = [] + + # Try to match the amls having the same currency as the statement line. + if not kepts_amls_values_list: + _match_type, kepts_amls_values_list = match_batch_amls(amls_values_list) + + # Try to match the whole candidates. + if not kepts_amls_values_list: + kepts_amls_values_list = amls_values_list + + # Try to match the amls having the same currency as the statement line. + if kepts_amls_values_list: + status = self._check_rule_propositions(st_line, kepts_amls_values_list) + result = _create_result_dict(kepts_amls_values_list, status) + if result: + return result + + def _check_rule_propositions(self, st_line, amls_values_list): + """Check restrictions that can't be handled for each move.line separately. + Note: Only used by models having a type equals to 'invoice_matching'. + :param st_line: The statement line. + :param amls_values_list: The candidates account.move.line as a list of dict: + * aml: The record. + * amount_residual: The amount residual to consider. + * amount_residual_currency: The amount residual in foreign currency to + consider. + :return: A string representing what to do with the candidates: + * rejected: Reject candidates. + * allow_write_off: Allow to generate the write-off from the reconcile model + lines if specified. + * allow_auto_reconcile: Allow to automatically reconcile entries if + 'auto_validate' is enabled. + """ + self.ensure_one() + + if not self.allow_payment_tolerance: + return {"allow_write_off", "allow_auto_reconcile"} + + st_line_currency = st_line.foreign_currency_id or st_line.currency_id + st_line_amount_curr = st_line._prepare_move_line_default_vals()[1][ + "amount_currency" + ] + amls_amount_curr = sum( + st_line._prepare_counterpart_amounts_using_st_line_rate( + aml_values["aml"].currency_id, + aml_values["amount_residual"], + aml_values["amount_residual_currency"], + )["amount_currency"] + for aml_values in amls_values_list + ) + sign = 1 if st_line_amount_curr > 0.0 else -1 + amount_curr_after_rec = sign * (amls_amount_curr + st_line_amount_curr) + + # The statement line will be fully reconciled. + if st_line_currency.is_zero(amount_curr_after_rec): + return {"allow_auto_reconcile"} + + # The payment amount is higher than the sum of invoices. In that case, don't + # check the tolerance and don't try to generate any write-off. + if amount_curr_after_rec > 0.0: + return {"allow_auto_reconcile"} + + # No tolerance, reject the candidates. + if self.payment_tolerance_param == 0: + return {"rejected"} + + # If the tolerance is expressed as a fixed amount, check the residual payment + # amount doesn't exceed the tolerance. + if ( + self.payment_tolerance_type == "fixed_amount" + and -amount_curr_after_rec <= self.payment_tolerance_param + ): + return {"allow_write_off", "allow_auto_reconcile"} + + # The tolerance is expressed as a percentage between 0 and 100.0. + reconciled_percentage_left = ( + abs(amount_curr_after_rec / amls_amount_curr) + ) * 100.0 + if ( + self.payment_tolerance_type == "percentage" + and reconciled_percentage_left <= self.payment_tolerance_param + ): + return {"allow_write_off", "allow_auto_reconcile"} + + return {"rejected"} + + +class AccountReconcileModelLine(models.Model): + _inherit = "account.reconcile.model.line" + + def _get_write_off_move_line_dict(self, balance, currency): + self.ensure_one() + return { + "name": self.label, + "balance": balance, + "debit": balance > 0 and balance or 0, + "credit": balance < 0 and -balance or 0, + "account_id": self.account_id.id, + "currency_id": currency.id, + "analytic_distribution": self.analytic_distribution, + "reconcile_model_id": self.model_id.id, + "journal_id": self.journal_id.id, + "tax_ids": [], + } diff --git a/account_reconcile_model_oca/pyproject.toml b/account_reconcile_model_oca/pyproject.toml new file mode 100644 index 0000000000..4231d0cccb --- /dev/null +++ b/account_reconcile_model_oca/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/account_reconcile_model_oca/readme/CONTRIBUTORS.md b/account_reconcile_model_oca/readme/CONTRIBUTORS.md new file mode 100644 index 0000000000..8baa4dbef5 --- /dev/null +++ b/account_reconcile_model_oca/readme/CONTRIBUTORS.md @@ -0,0 +1,6 @@ +- Dixmit + + - Enric Tobella + +- Trobz \<\> + - Do Anh Duy \<\> diff --git a/account_reconcile_model_oca/readme/CREDITS.md b/account_reconcile_model_oca/readme/CREDITS.md new file mode 100644 index 0000000000..83b3ec91f7 --- /dev/null +++ b/account_reconcile_model_oca/readme/CREDITS.md @@ -0,0 +1 @@ +The migration of this module from 17.0 to 18.0 was financially supported by Camptocamp. diff --git a/account_reconcile_model_oca/readme/DESCRIPTION.md b/account_reconcile_model_oca/readme/DESCRIPTION.md new file mode 100644 index 0000000000..f296e00083 --- /dev/null +++ b/account_reconcile_model_oca/readme/DESCRIPTION.md @@ -0,0 +1 @@ +This module restores account reconciliation models functions moved from Odoo community to enterpise in V. 17.0 diff --git a/account_reconcile_model_oca/static/description/icon.png b/account_reconcile_model_oca/static/description/icon.png new file mode 100644 index 0000000000..3a0328b516 Binary files /dev/null and b/account_reconcile_model_oca/static/description/icon.png differ diff --git a/account_reconcile_model_oca/static/description/index.html b/account_reconcile_model_oca/static/description/index.html new file mode 100644 index 0000000000..6c40c3029b --- /dev/null +++ b/account_reconcile_model_oca/static/description/index.html @@ -0,0 +1,438 @@ + + + + + +Account Reconcile Model Oca + + + +
+

Account Reconcile Model Oca

+ + +

Beta License: LGPL-3 OCA/account-reconcile Translate me on Weblate Try me on Runboat

+

This module restores account reconciliation models functions moved from +Odoo community to enterpise in V. 17.0

+

Table of contents

+ +
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Dixmit
  • +
  • Odoo
  • +
+
+
+

Contributors

+ +
+
+

Other credits

+

The migration of this module from 17.0 to 18.0 was financially supported +by Camptocamp.

+
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/account-reconcile project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/account_reconcile_model_oca/tests/__init__.py b/account_reconcile_model_oca/tests/__init__.py new file mode 100644 index 0000000000..4198497cdc --- /dev/null +++ b/account_reconcile_model_oca/tests/__init__.py @@ -0,0 +1 @@ +from . import test_reconciliation_match diff --git a/account_reconcile_model_oca/tests/common.py b/account_reconcile_model_oca/tests/common.py new file mode 100644 index 0000000000..58d8c2ae71 --- /dev/null +++ b/account_reconcile_model_oca/tests/common.py @@ -0,0 +1,242 @@ +import time + +from odoo import Command + +from odoo.addons.account.tests.common import AccountTestInvoicingCommon + + +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): + super().setUpClass() + + 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.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", + "account_type": "liability_current", + "reconcile": True, + "company_ids": cls.company.ids, + } + ) + # cash basis final account + cls.tax_final_account = cls.env["account.account"].create( + { + "name": "TAX_TO_DEDUCT", + "code": "TDEDUCT", + "account_type": "asset_current", + "company_ids": cls.company.ids, + } + ) + cls.tax_base_amount_account = cls.env["account.account"].create( + { + "name": "TAX_BASE", + "code": "TBASE", + "account_type": "asset_current", + "company_ids": cls.company.ids, + } + ) + 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": "Test CABA", + "code": "tCABA", + "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, + "country_id": cls.company.account_fiscal_country_id.id, + "amount": 20, + "tax_exigibility": "on_payment", + "cash_basis_transition_account_id": cls.tax_waiting_account.id, + "invoice_repartition_line_ids": [ + ( + 0, + 0, + { + "repartition_type": "base", + }, + ), + ( + 0, + 0, + { + "repartition_type": "tax", + "account_id": cls.tax_final_account.id, + }, + ), + ], + "refund_repartition_line_ids": [ + ( + 0, + 0, + { + "repartition_type": "base", + }, + ), + ( + 0, + 0, + { + "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": f"product that cost {invoice_amount}", + "quantity": 1, + "price_unit": invoice_amount, + "tax_ids": [Command.set([])], + }, + ) + ], + } + + 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=move_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, + ) diff --git a/account_reconcile_model_oca/tests/test_reconciliation_match.py b/account_reconcile_model_oca/tests/test_reconciliation_match.py new file mode 100644 index 0000000000..595175c7b6 --- /dev/null +++ b/account_reconcile_model_oca/tests/test_reconciliation_match.py @@ -0,0 +1,1486 @@ +from freezegun import freeze_time + +from odoo import Command +from odoo.tests import Form, tagged + +from odoo.addons.account.tests.common import AccountTestInvoicingCommon + + +@tagged("post_install", "-at_install") +class TestReconciliationMatchingRules(AccountTestInvoicingCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + + ################# + # Company setup # + ################# + cls.other_currency = cls.setup_other_currency( + "EUR", rates=[("2016-01-01", 10.0), ("2017-01-01", 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( + [ + ("account_type", "=", "asset_current"), + ("company_ids", "in", 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, + "allow_payment_tolerance": True, + "payment_tolerance_type": "percentage", + "payment_tolerance_param": 0.0, + "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_line_1, + cls.bank_line_2, + cls.bank_line_3, + cls.bank_line_4, + cls.bank_line_5, + cls.cash_line_1, + ) = cls.env["account.bank.statement.line"].create( + [ + { + "journal_id": cls.bank_journal.id, + "date": "2020-01-01", + "payment_ref": "invoice {}-{}".format( + *invoice_number.split("/")[1:] + ), + "partner_id": cls.partner_1.id, + "amount": 100, + "sequence": 1, + }, + { + "journal_id": cls.bank_journal.id, + "date": "2020-01-01", + "payment_ref": "xxxxx", + "partner_id": cls.partner_1.id, + "amount": 600, + "sequence": 2, + }, + { + "journal_id": cls.bank_journal.id, + "date": "2020-01-01", + "payment_ref": "nawak", + "narration": "Communication: RF12 3456", + "partner_id": cls.partner_3.id, + "amount": 600, + "sequence": 1, + }, + { + "journal_id": cls.bank_journal.id, + "date": "2020-01-01", + "payment_ref": "RF12 3456", + "partner_id": cls.partner_3.id, + "amount": 600, + "sequence": 2, + }, + { + "journal_id": cls.bank_journal.id, + "date": "2020-01-01", + "payment_ref": "baaaaah", + "ref": "RF12 3456", + "partner_id": cls.partner_3.id, + "amount": 600, + "sequence": 2, + }, + { + "journal_id": cls.cash_journal.id, + "date": "2020-01-01", + "payment_ref": "yyyyy", + "partner_id": cls.partner_2.id, + "amount": -1000, + "sequence": 1, + }, + ] + ) + cls.payment_credit_account_id = ( + cls.outbound_payment_method_line.payment_account_id + ) + + @classmethod + def _create_invoice_line( + cls, + amount, + partner, + move_type, + currency=None, + pay_reference=None, + ref=None, + name=None, + inv_date="2019-09-01", + ): + """Create an invoice on the fly.""" + invoice_form = Form( + cls.env["account.move"].with_context( + default_move_type=move_type, + default_invoice_date=inv_date, + default_date=inv_date, + ) + ) + invoice_form.partner_id = partner + if currency: + invoice_form.currency_id = currency + 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() + if pay_reference: + invoice.payment_reference = pay_reference + invoice.action_post() + lines = invoice.line_ids + return lines.filtered( + lambda line: line.account_id.account_type + in ("asset_receivable", "liability_payable") + ) + + @classmethod + def _create_st_line( + cls, amount=1000.0, date="2019-01-01", payment_ref="turlututu", **kwargs + ): + st_line = cls.env["account.bank.statement.line"].create( + { + "journal_id": kwargs.get("journal_id", cls.bank_journal.id), + "amount": amount, + "date": date, + "payment_ref": payment_ref, + "partner_id": cls.partner_a.id, + **kwargs, + } + ) + return st_line + + @classmethod + def _create_reconcile_model(cls, **kwargs): + return cls.env["account.reconcile.model"].create( + { + "name": "test", + "rule_type": "invoice_matching", + "allow_payment_tolerance": True, + "payment_tolerance_type": "percentage", + "payment_tolerance_param": 0.0, + **kwargs, + "line_ids": [ + Command.create( + { + "account_id": cls.company_data[ + "default_account_revenue" + ].id, + "amount_type": "percentage", + "label": f"test {i}", + **line_vals, + } + ) + for i, line_vals in enumerate(kwargs.get("line_ids", [])) + ], + "partner_mapping_line_ids": [ + Command.create(line_vals) + for i, line_vals in enumerate( + kwargs.get("partner_mapping_line_ids", []) + ) + ], + } + ) + + @freeze_time("2020-01-01") + def _check_statement_matching(self, rules, expected_values_list): + for statement_line, expected_values in expected_values_list.items(): + res = rules._apply_rules(statement_line, statement_line._retrieve_partner()) + self.assertDictEqual(res, expected_values) + + def test_matching_fields(self): + # Check without restriction. + self._check_statement_matching( + self.rule_1, + { + self.bank_line_1: {"amls": self.invoice_line_1, "model": self.rule_1}, + self.bank_line_2: { + "amls": self.invoice_line_1 + + self.invoice_line_2 + + self.invoice_line_3, + "model": self.rule_1, + }, + self.cash_line_1: {"amls": self.invoice_line_4, "model": self.rule_1}, + }, + ) + + @freeze_time("2020-01-01") + def test_matching_fields_match_text_location(self): + st_line = self._create_st_line( + payment_ref="1111", ref="2222 3333", narration="4444 5555 6666" + ) + + inv1 = self._create_invoice_line( + 1000, self.partner_a, "out_invoice", pay_reference="bernard 1111 gagnant" + ) + inv2 = self._create_invoice_line( + 1000, self.partner_a, "out_invoice", pay_reference="2222 turlututu 3333" + ) + inv3 = self._create_invoice_line( + 1000, + self.partner_a, + "out_invoice", + pay_reference="4444 tsoin 5555 tsoin 6666", + ) + + rule = self._create_reconcile_model( + allow_payment_tolerance=False, + match_text_location_label=True, + match_text_location_reference=False, + match_text_location_note=False, + ) + self.assertDictEqual( + rule._apply_rules(st_line, st_line._retrieve_partner()), + {"amls": inv1, "model": rule}, + ) + + rule.match_text_location_reference = True + self.assertDictEqual( + rule._apply_rules(st_line, st_line._retrieve_partner()), + {"amls": inv2, "model": rule}, + ) + + rule.match_text_location_note = True + self.assertDictEqual( + rule._apply_rules(st_line, st_line._retrieve_partner()), + {"amls": inv3, "model": rule}, + ) + + def test_matching_fields_match_text_location_no_partner(self): + self.bank_line_2.unlink() # One line is enough for this test + self.bank_line_1.partner_id = None + + self.partner_1.name = "Bernard Gagnant" + + self.rule_1.write( + { + "match_partner": False, + "match_partner_ids": [(5, 0, 0)], + "line_ids": [(5, 0, 0)], + } + ) + + st_line_initial_vals = { + "ref": None, + "payment_ref": "nothing", + "narration": None, + } + recmod_initial_vals = { + "match_text_location_label": False, + "match_text_location_note": False, + "match_text_location_reference": False, + } + + rec_mod_options_to_fields = { + "match_text_location_label": "payment_ref", + "match_text_location_note": "narration", + "match_text_location_reference": "ref", + } + + for rec_mod_field, st_line_field in rec_mod_options_to_fields.items(): + self.rule_1.write({**recmod_initial_vals, rec_mod_field: True}) + # Fully reinitialize the statement line + self.bank_line_1.write(st_line_initial_vals) + + # Nothing should match + self._check_statement_matching( + self.rule_1, + { + self.bank_line_1: {}, + }, + ) + + # Test matching with the invoice ref + self.bank_line_1.write( + {st_line_field: self.invoice_line_1.move_id.payment_reference} + ) + + self._check_statement_matching( + self.rule_1, + { + self.bank_line_1: { + "amls": self.invoice_line_1, + "model": self.rule_1, + }, + }, + ) + + # Test matching with the partner name (resetting the statement line first) + self.bank_line_1.write( + {**st_line_initial_vals, st_line_field: self.partner_1.name} + ) + + self._check_statement_matching( + self.rule_1, + { + self.bank_line_1: { + "amls": self.invoice_line_1, + "model": self.rule_1, + }, + }, + ) + + def test_matching_fields_match_journal_ids(self): + self.rule_1.match_journal_ids |= self.cash_line_1.journal_id + self._check_statement_matching( + self.rule_1, + { + self.bank_line_1: {}, + self.bank_line_2: {}, + self.cash_line_1: {"amls": self.invoice_line_4, "model": self.rule_1}, + }, + ) + + 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: {"amls": self.invoice_line_1, "model": self.rule_1}, + self.bank_line_2: { + "amls": self.invoice_line_2 + + self.invoice_line_3 + + self.invoice_line_1, + "model": self.rule_1, + }, + self.cash_line_1: {}, + }, + ) + self.rule_1.match_nature = "amount_paid" + self._check_statement_matching( + self.rule_1, + { + self.bank_line_1: {}, + self.bank_line_2: {}, + self.cash_line_1: {"amls": self.invoice_line_4, "model": self.rule_1}, + }, + ) + + 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: {"amls": self.invoice_line_1, "model": self.rule_1}, + self.bank_line_2: {}, + self.cash_line_1: {}, + }, + ) + self.rule_1.match_amount = "greater" + self.rule_1.match_amount_min = 200 + self._check_statement_matching( + self.rule_1, + { + self.bank_line_1: {}, + self.bank_line_2: { + "amls": self.invoice_line_1 + + self.invoice_line_2 + + self.invoice_line_3, + "model": self.rule_1, + }, + self.cash_line_1: {"amls": self.invoice_line_4, "model": self.rule_1}, + }, + ) + 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: {}, + self.bank_line_2: { + "amls": self.invoice_line_1 + + self.invoice_line_2 + + self.invoice_line_3, + "model": self.rule_1, + }, + self.cash_line_1: {}, + }, + ) + + 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: {}, + self.bank_line_2: {}, + self.cash_line_1: {"amls": self.invoice_line_4, "model": self.rule_1}, + }, + ) + 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: {"amls": self.invoice_line_1, "model": self.rule_1}, + self.bank_line_2: {}, + self.cash_line_1: {"amls": self.invoice_line_4, "model": self.rule_1}, + }, + ) + 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: {}, + self.bank_line_2: { + "amls": self.invoice_line_1 + + self.invoice_line_2 + + self.invoice_line_3, + "model": self.rule_1, + }, + self.cash_line_1: {"amls": self.invoice_line_4, "model": self.rule_1}, + }, + ) + + @freeze_time("2019-01-01") + def test_zero_payment_tolerance(self): + rule = self._create_reconcile_model(line_ids=[{}]) + + for inv_type, bsl_sign in (("out_invoice", 1), ("in_invoice", -1)): + invl = self._create_invoice_line( + 1000.0, self.partner_a, inv_type, inv_date="2019-01-01" + ) + + # Exact matching. + st_line = self._create_st_line(amount=bsl_sign * 1000.0) + self._check_statement_matching( + rule, + {st_line: {"amls": invl, "model": rule}}, + ) + + # No matching because there is no tolerance. + st_line = self._create_st_line(amount=bsl_sign * 990.0) + self._check_statement_matching( + rule, + {st_line: {}}, + ) + + # The payment amount is higher than the invoice one. + st_line = self._create_st_line(amount=bsl_sign * 1010.0) + self._check_statement_matching( + rule, + {st_line: {"amls": invl, "model": rule}}, + ) + + @freeze_time("2019-01-01") + def test_zero_payment_tolerance_auto_reconcile(self): + rule = self._create_reconcile_model( + auto_reconcile=True, + line_ids=[{}], + ) + + for inv_type, bsl_sign in (("out_invoice", 1), ("in_invoice", -1)): + invl = self._create_invoice_line( + 1000.0, + self.partner_a, + inv_type, + pay_reference="123456", + inv_date="2019-01-01", + ) + + # No matching because there is no tolerance. + st_line = self._create_st_line(amount=bsl_sign * 990.0) + self._check_statement_matching( + rule, + {st_line: {}}, + ) + + # The payment amount is higher than the invoice one. + st_line = self._create_st_line( + amount=bsl_sign * 1010.0, payment_ref="123456" + ) + self._check_statement_matching( + rule, + {st_line: {"amls": invl, "model": rule, "auto_reconcile": True}}, + ) + + @freeze_time("2019-01-01") + def test_not_enough_payment_tolerance(self): + rule = self._create_reconcile_model( + payment_tolerance_param=0.5, + line_ids=[{}], + ) + + for inv_type, bsl_sign in (("out_invoice", 1), ("in_invoice", -1)): + with self.subTest(inv_type=inv_type, bsl_sign=bsl_sign): + invl = self._create_invoice_line( + 1000.0, self.partner_a, inv_type, inv_date="2019-01-01" + ) + + # No matching because there is no enough tolerance. + st_line = self._create_st_line(amount=bsl_sign * 990.0) + self._check_statement_matching( + rule, + {st_line: {}}, + ) + + # The payment amount is higher than the invoice one. + # However, since the invoice amount is lower than the payment amount, + # the tolerance is not checked and the invoice line is matched. + st_line = self._create_st_line(amount=bsl_sign * 1010.0) + self._check_statement_matching( + rule, + {st_line: {"amls": invl, "model": rule}}, + ) + + @freeze_time("2019-01-01") + def test_enough_payment_tolerance(self): + rule = self._create_reconcile_model( + payment_tolerance_param=1.0, + line_ids=[{}], + ) + + for inv_type, bsl_sign in (("out_invoice", 1), ("in_invoice", -1)): + invl = self._create_invoice_line( + 1000.0, self.partner_a, inv_type, inv_date="2019-01-01" + ) + + # Enough tolerance to match the invoice line. + st_line = self._create_st_line(amount=bsl_sign * 990.0) + self._check_statement_matching( + rule, + {st_line: {"amls": invl, "model": rule, "status": "write_off"}}, + ) + + # The payment amount is higher than the invoice one. + # However, since the invoice amount is lower than the payment amount, + # the tolerance is not checked and the invoice line is matched. + st_line = self._create_st_line(amount=bsl_sign * 1010.0) + self._check_statement_matching( + rule, + {st_line: {"amls": invl, "model": rule}}, + ) + + @freeze_time("2019-01-01") + def test_enough_payment_tolerance_auto_reconcile_not_full(self): + rule = self._create_reconcile_model( + payment_tolerance_param=1.0, + auto_reconcile=True, + line_ids=[{"amount_type": "percentage_st_line", "amount_string": "200.0"}], + ) + + for inv_type, bsl_sign in (("out_invoice", 1), ("in_invoice", -1)): + invl = self._create_invoice_line( + 1000.0, + self.partner_a, + inv_type, + pay_reference="123456", + inv_date="2019-01-01", + ) + + # Enough tolerance to match the invoice line. + st_line = self._create_st_line( + amount=bsl_sign * 990.0, payment_ref="123456" + ) + self._check_statement_matching( + rule, + { + st_line: { + "amls": invl, + "model": rule, + "status": "write_off", + "auto_reconcile": True, + } + }, + ) + + @freeze_time("2019-01-01") + def test_allow_payment_tolerance_lower_amount(self): + rule = self._create_reconcile_model( + line_ids=[{"amount_type": "percentage_st_line"}] + ) + + for inv_type, bsl_sign in (("out_invoice", 1), ("in_invoice", -1)): + invl = self._create_invoice_line( + 990.0, self.partner_a, inv_type, inv_date="2019-01-01" + ) + st_line = self._create_st_line(amount=bsl_sign * 1000) + + # Partial reconciliation. + self._check_statement_matching( + rule, + {st_line: {"amls": invl, "model": rule}}, + ) + + @freeze_time("2019-01-01") + def test_enough_payment_tolerance_auto_reconcile(self): + rule = self._create_reconcile_model( + payment_tolerance_param=1.0, + auto_reconcile=True, + line_ids=[{}], + ) + + for inv_type, bsl_sign in (("out_invoice", 1), ("in_invoice", -1)): + invl = self._create_invoice_line( + 1000.0, + self.partner_a, + inv_type, + pay_reference="123456", + inv_date="2019-01-01", + ) + + # Enough tolerance to match the invoice line. + st_line = self._create_st_line( + amount=bsl_sign * 990.0, payment_ref="123456" + ) + self._check_statement_matching( + rule, + { + st_line: { + "amls": invl, + "model": rule, + "status": "write_off", + "auto_reconcile": True, + } + }, + ) + + @freeze_time("2019-01-01") + def test_percentage_st_line_auto_reconcile(self): + rule = self._create_reconcile_model( + payment_tolerance_param=1.0, + rule_type="writeoff_suggestion", + auto_reconcile=True, + line_ids=[ + { + "amount_type": "percentage_st_line", + "amount_string": "100.0", + "label": "A", + }, + { + "amount_type": "percentage_st_line", + "amount_string": "-100.0", + "label": "B", + }, + { + "amount_type": "percentage_st_line", + "amount_string": "100.0", + "label": "C", + }, + ], + ) + + for bsl_sign in (1, -1): + st_line = self._create_st_line(amount=bsl_sign * 1000.0) + self._check_statement_matching( + rule, + { + st_line: { + "model": rule, + "status": "write_off", + "auto_reconcile": True, + } + }, + ) + + 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: {}, + self.bank_line_2: {}, + self.cash_line_1: {"amls": self.invoice_line_4, "model": self.rule_1}, + }, + ) + 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: { + "amls": self.invoice_line_1, + "model": self.rule_1, + }, + self.bank_line_2: { + "amls": self.invoice_line_2 + + self.invoice_line_3 + + self.invoice_line_1, + "model": self.rule_1, + }, + self.cash_line_1: {"amls": self.invoice_line_4, "model": self.rule_1}, + }, + ) + + # 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: { + "model": self.rule_2, + "auto_reconcile": False, + "status": "write_off", + }, + self.bank_line_2: { + "model": self.rule_2, + "auto_reconcile": False, + "status": "write_off", + }, + self.cash_line_1: { + "model": self.rule_2, + "auto_reconcile": False, + "status": "write_off", + }, + }, + ) + + # 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: { + "model": self.rule_2, + "auto_reconcile": False, + "status": "write_off", + }, + self.bank_line_2: { + "model": self.rule_2, + "auto_reconcile": False, + "status": "write_off", + }, + self.cash_line_1: {"amls": self.invoice_line_4, "model": self.rule_1}, + }, + ) + + 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.payment_tolerance_param = 10.0 + 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: { + "amls": self.invoice_line_1, + "model": self.rule_1, + "auto_reconcile": True, + }, + self.bank_line_2: { + "amls": self.invoice_line_1 + + self.invoice_line_2 + + self.invoice_line_3, + "model": self.rule_1, + }, + self.cash_line_1: { + "model": self.rule_2, + "status": "write_off", + "auto_reconcile": True, + }, + }, + ) + + 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.allow_payment_tolerance = False + 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: { + "amls": self.invoice_line_1, + "model": self.rule_1, + "auto_reconcile": True, + }, + self.bank_line_2: { + "amls": self.invoice_line_1 + + self.invoice_line_2 + + self.invoice_line_3, + "model": self.rule_1, + }, + }, + ) + + def test_auto_reconcile_with_tax(self): + """Test auto reconciliation with a tax amount included in the bank stat. 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: { + "model": self.rule_1, + "status": "write_off", + "auto_reconcile": True, + }, + self.bank_line_2: { + "model": self.rule_1, + "status": "write_off", + "auto_reconcile": True, + }, + }, + ) + + def test_auto_reconcile_with_tax_fpos(self): + """Test the fiscal positions are applied by reconcile models when using taxes""" + self.rule_1.write( + { + "auto_reconcile": True, + "rule_type": "writeoff_suggestion", + "line_ids": [ + ( + 1, + self.rule_1.line_ids.id, + { + "amount": 100, + "force_tax_included": True, + "tax_ids": [(6, 0, self.tax21.ids)], + }, + ) + ], + } + ) + + self.partner_1.country_id = self.env.ref("base.lu") + belgium = self.env.ref("base.be") + self.partner_2.country_id = belgium + + self.bank_line_2.partner_id = self.partner_2 + + self.bank_line_1.amount = -121 + self.bank_line_2.amount = -112 + + self.env["account.fiscal.position"].create( + { + "name": "Test", + "country_id": belgium.id, + "auto_apply": True, + "tax_ids": [ + Command.create( + { + "tax_src_id": self.tax21.id, + "tax_dest_id": self.tax12.id, + } + ), + ], + } + ) + + self._check_statement_matching( + self.rule_1, + { + self.bank_line_1: { + "model": self.rule_1, + "status": "write_off", + "auto_reconcile": True, + }, + self.bank_line_2: { + "model": self.rule_1, + "status": "write_off", + "auto_reconcile": True, + }, + }, + ) + + def test_reverted_move_matching(self): + partner = self.partner_1 + AccountMove = self.env["account.move"] + account = self.payment_credit_account_id + 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": account.id, + "partner_id": partner.id, + "name": "I'm gonna cut you into little pieces", + "credit": 10, + }, + ), + ], + } + ) + + payment_bnk_line = move.line_ids.filtered( + lambda line: line.account_id == self.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: {"amls": payment_bnk_line, "model": self.rule_1}, + self.bank_line_2: { + "amls": self.invoice_line_1 + + self.invoice_line_2 + + self.invoice_line_3, + "model": self.rule_1, + }, + }, + ) + + 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_inv.active = True + 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: {"amls": invoice_line, "model": self.rule_1}, + self.bank_line_2: {}, + }, + ) + + 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.rule_1.allow_payment_tolerance = False + + 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", # match what we want to test + } + ) + + # TODO: 'invoice_line_1' has no reason to match 'bank_line_1' here... to check + # self._check_statement_matching(self.rule_1, { + # self.bank_line_1: {'amls': self.invoice_line_1, 'model': self.rule_1}, + # self.bank_line_2: {'amls': []}, + # }, self.bank_st) + + def test_inv_matching_rule_auto_rec_no_partner_with_writeoff(self): + self.invoice_line_1.move_id.ref = "doudlidou3555" + + self.bank_line_1.write( + { + "payment_ref": "doudlidou3555", + "partner_id": None, + "amount": 95, + } + ) + + self.rule_1.write( + { + "match_partner": False, + "match_label": "contains", + "match_label_param": "doudlidou", # match what we want to test + "payment_tolerance_param": 10.0, + "auto_reconcile": True, + } + ) + + # Check bank reconciliation + + self._check_statement_matching( + self.rule_1, + { + self.bank_line_1: { + "amls": self.invoice_line_1, + "model": self.rule_1, + "status": "write_off", + "auto_reconcile": True, + }, + self.bank_line_2: {}, + }, + ) + + def test_partner_mapping_rule(self): + st_line = self._create_st_line(partner_id=None, payment_ref="toto42") + + rule = self._create_reconcile_model( + partner_mapping_line_ids=[ + { + "partner_id": self.partner_1.id, + "payment_ref_regex": "toto.*", + } + ], + ) + + # Matching using the regex on payment_ref. + self.assertEqual(st_line._retrieve_partner(), self.partner_1) + + rule.partner_mapping_line_ids.narration_regex = ".*coincoin" + + # No match because the narration is not matching the regex. + self.assertEqual(st_line._retrieve_partner(), self.env["res.partner"]) + + st_line.narration = "42coincoin" + + # Matching is back thanks to "coincoin". + self.assertEqual(st_line._retrieve_partner(), self.partner_1) + + 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 communic. contains the invoice's partner name + self._check_statement_matching( + self.rule_1, + { + self.bank_line_1: {"amls": self.invoice_line_1, "model": self.rule_1}, + self.bank_line_2: {}, + }, + ) + + 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: {"amls": self.invoice_line_1, "model": self.rule_1}, + self.bank_line_2: {}, + }, + ) + + 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.company_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)], + "allow_payment_tolerance": True, + "payment_tolerance_type": "percentage", + "payment_tolerance_param": 5.0, + "match_same_currency": False, + "company_id": self.company_data["company"].id, + "past_months_limit": False, + } + ) + + statement_line = self.env["account.bank.statement.line"].create( + { + "journal_id": journal.id, + "date": "2016-01-01", + "payment_ref": "line", + "partner_id": partner.id, + "foreign_currency_id": self.other_currency.id, + "amount": 300.0, # Rate is 3 GOL = 1 USD in 2016. + # Rate is 10 DAR = 1 USD in 2016 but the rate used by the bank is 9:1. + "amount_currency": 900.0, + } + ) + + 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.other_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.other_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: { + "amls": move_line_1 + move_line_2, + "model": matching_rule, + } + }, + ) + + @freeze_time("2020-01-01") + def test_matching_with_write_off_foreign_currency(self): + journal_foreign_curr = self.company_data["default_journal_bank"].copy() + journal_foreign_curr.currency_id = self.company_data["currency"] + + reco_model = self._create_reconcile_model( + auto_reconcile=True, + rule_type="writeoff_suggestion", + line_ids=[ + { + "amount_type": "percentage", + "amount": 100.0, + "account_id": self.company_data["default_account_revenue"].id, + } + ], + ) + + st_line = self._create_st_line( + amount=100.0, payment_ref="123456", journal_id=journal_foreign_curr.id + ) + self._check_statement_matching( + reco_model, + { + st_line: { + "model": reco_model, + "status": "write_off", + "auto_reconcile": True, + }, + }, + ) + + 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, + "memo": memo, + "destination_account_id": self.company_data[ + "default_account_receivable" + ].id, + } + ) + payment.action_post() + + return payment.move_id.line_ids.filtered( + lambda x: x.account_id.account_type + not in {"asset_receivable", "liability_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) + 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.allow_payment_tolerance = False + + self._check_statement_matching( + self.rule_1, + { + self.bank_line_1: { + "amls": pmt_line_1 + pmt_line_2, + "model": self.rule_1, + "status": "write_off", + }, + }, + ) + + def test_no_amount_check_keep_first(self): + """In case the reconciliation model doesn't check the total amount of the + candidates, we still don't want to suggest more than are necessary to match the + statement. For example, if a statement line amounts to 250 and is to be matched + with three invoices of 100, 200 and 300 (retrieved in this order), only 100 and + 200 should be proposed. + """ + self.rule_1.allow_payment_tolerance = False + self.bank_line_2.amount = 250 + self.bank_line_1.partner_id = None + + self._check_statement_matching( + self.rule_1, + { + self.bank_line_1: {}, + self.bank_line_2: { + "amls": self.invoice_line_1 + self.invoice_line_2, + "model": self.rule_1, + "status": "write_off", + }, + }, + ) + + def test_no_amount_check_exact_match(self): + """If a reconciliation model finds enough candidates for a full reconciliation, + it should still check the following candidates, in case one of them exactly + matches the amount of the statement line. If such a candidate exist, all the + other ones are disregarded. + """ + self.rule_1.allow_payment_tolerance = False + self.bank_line_2.amount = 300 + self.bank_line_1.partner_id = None + + self._check_statement_matching( + self.rule_1, + { + self.bank_line_1: {}, + self.bank_line_2: { + "amls": self.invoice_line_3, + "model": self.rule_1, + "status": "write_off", + }, + }, + )